From 51669e1879b0ec23cdd4d5dd2b29c3614ce41290 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 16:14:35 +0100 Subject: [PATCH 01/14] cross-platform build: macOS/Linux support + installer workflow --- .github/workflows/build-release.yml | 317 +++++++++++++++++++--------- plugins/ModularRandomizer | 1 + 2 files changed, 221 insertions(+), 97 deletions(-) create mode 160000 plugins/ModularRandomizer diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e9ddb9f..3be159a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -9,9 +9,10 @@ on: plugin_name: description: 'Plugin name to build' required: true + default: 'ModularRandomizer' type: string platforms: - description: 'Platforms to build (windows,macos,linux,all)' + description: 'Platforms to build' default: 'all' type: choice options: @@ -22,16 +23,24 @@ on: - windows,macos - windows,linux - macos,linux + version: + description: 'Version string (e.g. 1.0.0)' + required: true + default: '1.0.0' + type: string env: BUILD_TYPE: Release jobs: # ============================================ - # WINDOWS BUILD + # WINDOWS BUILD + INSTALLER # ============================================ build-windows: - if: github.event.inputs.platforms == 'all' || contains(github.event.inputs.platforms, 'windows') || github.event_name == 'push' + if: >- + github.event.inputs.platforms == 'all' || + contains(github.event.inputs.platforms, 'windows') || + github.event_name == 'push' runs-on: windows-latest steps: - name: Checkout repository @@ -44,31 +53,86 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 - name: Configure CMake - run: | - cmake -S . -B build -G "Visual Studio 17 2022" -A x64 + run: cmake -S . -B build -G "Visual Studio 17 2022" -A x64 - name: Build VST3 - run: | - cmake --build build --config Release --target "${{ inputs.plugin_name }}_VST3" + run: cmake --build build --config Release --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_VST3" - name: Build Standalone + run: cmake --build build --config Release --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_Standalone" + + - name: Verify build artifacts + shell: pwsh + run: | + $plugin = "${{ inputs.plugin_name || 'ModularRandomizer' }}" + $vst3 = Get-ChildItem -Path build -Recurse -Filter "*.vst3" -Directory | Select-Object -First 1 + $exe = Get-ChildItem -Path build -Recurse -Filter "${plugin}.exe" | Select-Object -First 1 + + Write-Host "VST3 bundle: $($vst3.FullName)" -ForegroundColor Green + Write-Host "Standalone: $($exe.FullName)" -ForegroundColor Green + + if (-not $vst3) { throw "VST3 build not found!" } + if (-not $exe) { throw "Standalone build not found!" } + + - name: Install Inno Setup + shell: pwsh + run: | + choco install innosetup -y --no-progress + Write-Host "Inno Setup installed" -ForegroundColor Green + + - name: Build Windows Installer + shell: pwsh run: | - cmake --build build --config Release --target "${{ inputs.plugin_name }}_Standalone" + $plugin = "${{ inputs.plugin_name || 'ModularRandomizer' }}" + $version = "${{ inputs.version || '1.0.0' }}" + $issPath = "plugins/$plugin/installer/windows.iss" + $distDir = "plugins/$plugin/dist" + + if (Test-Path $issPath) { + # Create dist directory + New-Item -ItemType Directory -Path $distDir -Force | Out-Null + + # Build installer with version override + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + /DPluginVersion="$version" ` + /DBuildDir="$((Resolve-Path build).Path)" ` + $issPath + + Write-Host "Installer created!" -ForegroundColor Green + Get-ChildItem "$distDir" -Filter "*.exe" + } else { + Write-Host "No Inno Setup script found at $issPath — packaging as ZIP" -ForegroundColor Yellow + # Fallback: create ZIP with raw binaries + New-Item -ItemType Directory -Path $distDir -Force | Out-Null + $vst3Dir = (Get-ChildItem -Path build -Recurse -Filter "*.vst3" -Directory | Select-Object -First 1).FullName + $exePath = (Get-ChildItem -Path build -Recurse -Filter "${plugin}.exe" | Select-Object -First 1).FullName + + $stagingDir = "$distDir\${plugin}-${version}-Windows" + New-Item -ItemType Directory -Path "$stagingDir\VST3" -Force | Out-Null + Copy-Item -Path $vst3Dir -Destination "$stagingDir\VST3\" -Recurse + if ($exePath) { Copy-Item $exePath "$stagingDir\" } + if (Test-Path "plugins/$plugin/installer/LICENSE.txt") { + Copy-Item "plugins/$plugin/installer/LICENSE.txt" "$stagingDir\" + } + Compress-Archive -Path "$stagingDir\*" -DestinationPath "$distDir\${plugin}-${version}-Windows.zip" + } - name: Upload Windows Artifacts uses: actions/upload-artifact@v4 with: - name: windows-${{ inputs.plugin_name }} + name: windows-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | - build/*_artefacts/Release/*.vst3 - build/*_artefacts/Release/*.exe + plugins/${{ inputs.plugin_name || 'ModularRandomizer' }}/dist/* retention-days: 30 # ============================================ - # MACOS BUILD + # MACOS BUILD + INSTALLER # ============================================ build-macos: - if: github.event.inputs.platforms == 'all' || contains(github.event.inputs.platforms, 'macos') || github.event_name == 'push' + if: >- + github.event.inputs.platforms == 'all' || + contains(github.event.inputs.platforms, 'macos') || + github.event_name == 'push' runs-on: macos-latest steps: - name: Checkout repository @@ -77,10 +141,6 @@ jobs: submodules: recursive fetch-depth: 0 - - name: Install Dependencies - run: | - brew install cmake || true - - name: Configure CMake run: | cmake -S . -B build -G Xcode \ @@ -88,32 +148,111 @@ jobs: -DCMAKE_OSX_DEPLOYMENT_TARGET=10.13 - name: Build VST3 - run: | - cmake --build build --config Release --target "${{ inputs.plugin_name }}_VST3" + run: cmake --build build --config Release --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_VST3" - name: Build AU - run: | - cmake --build build --config Release --target "${{ inputs.plugin_name }}_AU" + run: cmake --build build --config Release --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_AU" - name: Build Standalone + run: cmake --build build --config Release --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_Standalone" + + - name: Create macOS Installer Package run: | - cmake --build build --config Release --target "${{ inputs.plugin_name }}_Standalone" + PLUGIN="${{ inputs.plugin_name || 'ModularRandomizer' }}" + VERSION="${{ inputs.version || '1.0.0' }}" + DIST_DIR="plugins/$PLUGIN/dist" + STAGING="$DIST_DIR/staging" + + mkdir -p "$STAGING/vst3" "$STAGING/au" "$STAGING/app" "$DIST_DIR" + + # Find built artifacts + VST3_PATH=$(find build -name "*.vst3" -type d | head -1) + AU_PATH=$(find build -name "*.component" -type d | head -1) + APP_PATH=$(find build -name "*.app" -type d | head -1) + + echo "VST3: $VST3_PATH" + echo "AU: $AU_PATH" + echo "App: $APP_PATH" + + # Create component packages + if [ -d "$VST3_PATH" ]; then + pkgbuild \ + --component "$VST3_PATH" \ + --install-location "/Library/Audio/Plug-Ins/VST3" \ + "$STAGING/${PLUGIN}-VST3.pkg" + fi + + if [ -d "$AU_PATH" ]; then + pkgbuild \ + --component "$AU_PATH" \ + --install-location "/Library/Audio/Plug-Ins/Components" \ + "$STAGING/${PLUGIN}-AU.pkg" + fi + + if [ -d "$APP_PATH" ]; then + pkgbuild \ + --component "$APP_PATH" \ + --install-location "/Applications" \ + "$STAGING/${PLUGIN}-App.pkg" + fi + + # Create combined installer package + # Build a distribution XML for the combined installer + cat > "$STAGING/distribution.xml" << 'DISTXML' + + + PLUGIN_NAME + + + + + + + + + + + + + + + + + PLUGIN_NAME-VST3.pkg + PLUGIN_NAME-AU.pkg + PLUGIN_NAME-App.pkg + + DISTXML + + # Replace placeholders + sed -i '' "s/PLUGIN_NAME/$PLUGIN/g" "$STAGING/distribution.xml" + sed -i '' "s/VERSION/$VERSION/g" "$STAGING/distribution.xml" + + # Build the product archive (combined installer) + productbuild \ + --distribution "$STAGING/distribution.xml" \ + --package-path "$STAGING" \ + "$DIST_DIR/${PLUGIN}-${VERSION}-macOS.pkg" + + echo "macOS installer created: $DIST_DIR/${PLUGIN}-${VERSION}-macOS.pkg" + ls -la "$DIST_DIR/" - name: Upload macOS Artifacts uses: actions/upload-artifact@v4 with: - name: macos-${{ inputs.plugin_name }} + name: macos-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | - build/*_artefacts/Release/*.vst3 - build/*_artefacts/Release/*.component - build/*_artefacts/Release/*.app + plugins/${{ inputs.plugin_name || 'ModularRandomizer' }}/dist/*.pkg retention-days: 30 # ============================================ # LINUX BUILD # ============================================ build-linux: - if: github.event.inputs.platforms == 'all' || contains(github.event.inputs.platforms, 'linux') || github.event_name == 'push' + if: >- + github.event.inputs.platforms == 'all' || + contains(github.event.inputs.platforms, 'linux') || + github.event_name == 'push' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -141,33 +280,58 @@ jobs: xvfb - name: Configure CMake - run: | - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release - name: Build VST3 - run: | - cmake --build build --target "${{ inputs.plugin_name }}_VST3" - - - name: Build LV2 - run: | - xvfb-run cmake --build build --target "${{ inputs.plugin_name }}_LV2" + run: cmake --build build --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_VST3" - name: Build Standalone + run: xvfb-run cmake --build build --target "${{ inputs.plugin_name || 'ModularRandomizer' }}_Standalone" + + - name: Package Linux binaries run: | - xvfb-run cmake --build build --target "${{ inputs.plugin_name }}_Standalone" + PLUGIN="${{ inputs.plugin_name || 'ModularRandomizer' }}" + VERSION="${{ inputs.version || '1.0.0' }}" + DIST_DIR="plugins/$PLUGIN/dist" + STAGING="$DIST_DIR/${PLUGIN}-${VERSION}-Linux" + + mkdir -p "$STAGING/VST3" + + # Find and copy artifacts + VST3_PATH=$(find build -name "*.vst3" -type d | head -1) + STANDALONE=$(find build -name "$PLUGIN" -type f -executable | head -1) + + if [ -d "$VST3_PATH" ]; then + cp -r "$VST3_PATH" "$STAGING/VST3/" + fi + + if [ -f "$STANDALONE" ]; then + cp "$STANDALONE" "$STAGING/" + chmod +x "$STAGING/$PLUGIN" + fi + + # Copy license + if [ -f "plugins/$PLUGIN/installer/LICENSE.txt" ]; then + cp "plugins/$PLUGIN/installer/LICENSE.txt" "$STAGING/" + fi + + # Create tar.gz + cd "$DIST_DIR" + tar -czf "${PLUGIN}-${VERSION}-Linux.tar.gz" "${PLUGIN}-${VERSION}-Linux/" + + echo "Linux package created" + ls -la - name: Upload Linux Artifacts uses: actions/upload-artifact@v4 with: - name: linux-${{ inputs.plugin_name }} + name: linux-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | - build/**/Release/VST3/ - build/**/Release/LV2/ - build/**/Release/Standalone/${{ inputs.plugin_name }} + plugins/${{ inputs.plugin_name || 'ModularRandomizer' }}/dist/*.tar.gz retention-days: 30 # ============================================ - # CREATE RELEASE + # CREATE GITHUB RELEASE # ============================================ release: needs: [build-windows, build-macos, build-linux] @@ -176,80 +340,39 @@ jobs: permissions: contents: write steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Download All Artifacts uses: actions/download-artifact@v4 with: - path: artifacts - pattern: "*-${{ inputs.plugin_name }}" + path: release-artifacts - - name: List Downloaded Artifacts + - name: List artifacts run: | echo "Downloaded artifacts:" - find artifacts -type f - - - name: Create Release Archives - run: | - PLUGIN_NAME="${{ inputs.plugin_name }}" - VERSION="${{ github.ref_name }}" - - mkdir -p release - - # Package Windows artifacts - if [ -d "artifacts/windows-${PLUGIN_NAME}" ]; then - cd "artifacts/windows-${PLUGIN_NAME}" - zip -r "../../release/${PLUGIN_NAME}-${VERSION}-Windows.zip" . - cd ../.. - fi - - # Package macOS artifacts - if [ -d "artifacts/macos-${PLUGIN_NAME}" ]; then - cd "artifacts/macos-${PLUGIN_NAME}" - zip -r "../../release/${PLUGIN_NAME}-${VERSION}-macOS.zip" . - cd ../.. - fi - - # Package Linux artifacts - if [ -d "artifacts/linux-${PLUGIN_NAME}" ]; then - cd "artifacts/linux-${PLUGIN_NAME}" - zip -r "../../release/${PLUGIN_NAME}-${VERSION}-Linux.zip" . - cd ../.. - fi - - echo "Release archives created:" - ls -la release/ + find release-artifacts -type f - name: Create GitHub Release if: github.event_name == 'push' uses: softprops/action-gh-release@v1 with: - files: release/* + files: release-artifacts/**/* generate_release_notes: true body: | - ## ${{ inputs.plugin_name }} Release - - ### Installation - - **Windows**: Extract the ZIP and run the installer or copy VST3 to your plugins folder - - **macOS**: Extract the ZIP and install VST3/AU components to the appropriate folders - - **Linux**: Extract the ZIP and copy VST3/LV2 to your plugins folder + ## ${{ inputs.plugin_name || 'ModularRandomizer' }} ${{ github.ref_name }} - ### Supported Formats - - VST3 (All platforms) - - AU (macOS only) - - LV2 (Linux only) - - Standalone (All platforms) + ### Downloads + - **Windows**: Run the Setup installer (VST3 + Standalone) + - **macOS**: Open the .pkg installer (VST3 + AU + Standalone) + - **Linux**: Extract tar.gz, copy VST3 to ~/.vst3/ ### System Requirements - - **Windows**: Windows 10/11, 64-bit, WebView2 Runtime - - **macOS**: macOS 10.13+, Intel or Apple Silicon - - **Linux**: Ubuntu 20.04+ or equivalent, WebKitGTK + - **Windows**: Windows 10/11 64-bit, WebView2 Runtime + - **macOS**: macOS 10.13+, Intel or Apple Silicon (Universal Binary) + - **Linux**: Ubuntu 20.04+, WebKitGTK 4.1 - - name: Upload Release Artifacts (Manual Trigger) + - name: Upload combined release (manual trigger) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: - name: release-${{ inputs.plugin_name }} - path: release/* + name: release-all-platforms + path: release-artifacts/**/* retention-days: 30 diff --git a/plugins/ModularRandomizer b/plugins/ModularRandomizer new file mode 160000 index 0000000..2273f04 --- /dev/null +++ b/plugins/ModularRandomizer @@ -0,0 +1 @@ +Subproject commit 2273f0499840cddf57fdb3f34c296ae4a54ec94f From b1304803601baa9dbe5753f3e837cc86e7065c4c Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 16:22:46 +0100 Subject: [PATCH 02/14] fix: release job handles missing artifacts gracefully --- .github/workflows/build-release.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 3be159a..891ee0e 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -335,7 +335,12 @@ jobs: # ============================================ release: needs: [build-windows, build-macos, build-linux] - if: always() && (needs.build-windows.result == 'success' || needs.build-macos.result == 'success' || needs.build-linux.result == 'success') + # Only run if at least one build actually succeeded + if: >- + always() && + (needs.build-windows.result == 'success' || + needs.build-macos.result == 'success' || + needs.build-linux.result == 'success') runs-on: ubuntu-latest permissions: contents: write @@ -344,14 +349,19 @@ jobs: uses: actions/download-artifact@v4 with: path: release-artifacts + merge-multiple: false - name: List artifacts run: | echo "Downloaded artifacts:" - find release-artifacts -type f + if [ -d "release-artifacts" ]; then + find release-artifacts -type f + else + echo "No artifacts directory found" + fi - name: Create GitHub Release - if: github.event_name == 'push' + if: github.event_name == 'push' && hashFiles('release-artifacts/**/*') != '' uses: softprops/action-gh-release@v1 with: files: release-artifacts/**/* @@ -374,5 +384,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: release-all-platforms - path: release-artifacts/**/* + path: release-artifacts/ retention-days: 30 + From 261cef31e0aaf19b286419b908f1139202f7a0de Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 16:25:53 +0100 Subject: [PATCH 03/14] fix: register ModularRandomizer submodule in .gitmodules --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitmodules b/.gitmodules index e069789..3ccf538 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "_tools/JUCE"] path = _tools/JUCE url = https://github.com/juce-framework/JUCE +[submodule "plugins/ModularRandomizer"] + path = plugins/ModularRandomizer + url = https://github.com/DimitarPetrov77/ModularRandomizer.git From 035455477b4191b868213d4265d5a3ff988e61f5 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 19:13:59 +0100 Subject: [PATCH 04/14] convert ModularRandomizer from submodule to tracked files for CI --- .gitmodules | 3 - plugins/ModularRandomizer | 1 - plugins/ModularRandomizer/.gitignore | 32 + .../ModularRandomizer/.ideas/architecture.md | 155 + .../.ideas/creative-brief.md | 40 + .../.ideas/parameter-spec.md | 114 + plugins/ModularRandomizer/.ideas/plan.md | 169 + .../.ideas/refactoring-plan.md | 202 + plugins/ModularRandomizer/CMakeLists.txt | 153 + .../Design/04 Female Vocal A.vstpreset | Bin 0 -> 164 bytes .../Design/ModularRandomizer_Audit_v2.md | 334 ++ .../ModularRandomizer/Design/lane-block.html | 806 +++ .../Design/morph-pad-plan.md | 787 +++ .../Design/v1-style-guide.md | 145 + plugins/ModularRandomizer/Design/v1-test.html | 1366 +++++ .../ModularRandomizer/Design/v1-ui-spec.md | 75 + .../Design/v2-style-guide.md | 158 + plugins/ModularRandomizer/Design/v2-test.html | 1448 +++++ .../ModularRandomizer/Design/v2-ui-spec.md | 113 + .../Design/v3-style-guide.md | 172 + plugins/ModularRandomizer/Design/v3-test.html | 1598 ++++++ .../ModularRandomizer/Design/v3-ui-spec.md | 194 + plugins/ModularRandomizer/Design/v4-test.html | 1530 +++++ .../ModularRandomizer/Design/v4-ui-spec.md | 82 + .../Design/v5-style-guide.md | 82 + plugins/ModularRandomizer/Design/v5-test.html | 1829 ++++++ .../ModularRandomizer/Design/v5-ui-spec.md | 587 ++ plugins/ModularRandomizer/README.md | 138 + plugins/ModularRandomizer/Source.zip | Bin 0 -> 209696 bytes .../ModularRandomizer/Source/ParameterIDs.hpp | 7 + .../ModularRandomizer/Source/PluginEditor.cpp | 1787 ++++++ .../ModularRandomizer/Source/PluginEditor.h | 184 + .../Source/PluginHosting.cpp | 1629 ++++++ .../Source/PluginProcessor.cpp | 2282 ++++++++ .../Source/PluginProcessor.h | 1725 ++++++ .../ModularRandomizer/Source/ProcessBlock.cpp | 2996 ++++++++++ .../Source/ui/public/css/base.css | 22 + .../Source/ui/public/css/dialogs.css | 926 +++ .../Source/ui/public/css/header.css | 763 +++ .../Source/ui/public/css/logic_blocks.css | 2746 +++++++++ .../Source/ui/public/css/overrides.css | 32 + .../Source/ui/public/css/plugin_rack.css | 667 +++ .../Source/ui/public/css/themes.css | 150 + .../Source/ui/public/css/variables.css | 127 + .../Source/ui/public/css/wrongeq.css | 2024 +++++++ .../Source/ui/public/fonts/OFL.txt | 93 + .../ui/public/fonts/ShareTechMono-Regular.ttf | Bin 0 -> 42756 bytes .../ui/public/fonts/Share_Tech_Mono,VT323.zip | Bin 0 -> 201979 bytes .../ui/public/fonts/Share_Tech_Mono.zip | Bin 0 -> 47541 bytes .../Source/ui/public/fonts/VT323-Regular.ttf | Bin 0 -> 149688 bytes .../fonts/extracted/Share_Tech_Mono/OFL.txt | 93 + .../Share_Tech_Mono/ShareTechMono-Regular.ttf | Bin 0 -> 42756 bytes .../ui/public/fonts/extracted/VT323/OFL.txt | 93 + .../fonts/extracted/VT323/VT323-Regular.ttf | Bin 0 -> 149688 bytes .../Source/ui/public/index.html | 333 ++ .../Source/ui/public/js/context_menus.js | 310 + .../Source/ui/public/js/controls.js | 556 ++ .../Source/ui/public/js/expose_system.js | 648 +++ .../Source/ui/public/js/help_panel.js | 534 ++ .../Source/ui/public/js/juce_bridge.js | 52 + .../Source/ui/public/js/juce_integration.js | 59 + .../Source/ui/public/js/lane_module.js | 3802 ++++++++++++ .../Source/ui/public/js/logic_blocks.js | 2790 +++++++++ .../Source/ui/public/js/persistence.js | 555 ++ .../Source/ui/public/js/plugin_rack.js | 1896 ++++++ .../Source/ui/public/js/preset_system.js | 1739 ++++++ .../Source/ui/public/js/realtime.js | 782 +++ .../Source/ui/public/js/state.js | 71 + .../Source/ui/public/js/theme_system.js | 2090 +++++++ .../Source/ui/public/js/undo_system.js | 187 + .../Source/ui/public/js/wrongeq_canvas.js | 5093 +++++++++++++++++ .../Source/ui/public/style.css | 1482 +++++ .../ModularRandomizer/installer/LICENSE.txt | 50 + .../ModularRandomizer/installer/windows.iss | 99 + plugins/ModularRandomizer/status.json | 553 ++ 75 files changed, 54336 insertions(+), 4 deletions(-) delete mode 160000 plugins/ModularRandomizer create mode 100644 plugins/ModularRandomizer/.gitignore create mode 100644 plugins/ModularRandomizer/.ideas/architecture.md create mode 100644 plugins/ModularRandomizer/.ideas/creative-brief.md create mode 100644 plugins/ModularRandomizer/.ideas/parameter-spec.md create mode 100644 plugins/ModularRandomizer/.ideas/plan.md create mode 100644 plugins/ModularRandomizer/.ideas/refactoring-plan.md create mode 100644 plugins/ModularRandomizer/CMakeLists.txt create mode 100644 plugins/ModularRandomizer/Design/04 Female Vocal A.vstpreset create mode 100644 plugins/ModularRandomizer/Design/ModularRandomizer_Audit_v2.md create mode 100644 plugins/ModularRandomizer/Design/lane-block.html create mode 100644 plugins/ModularRandomizer/Design/morph-pad-plan.md create mode 100644 plugins/ModularRandomizer/Design/v1-style-guide.md create mode 100644 plugins/ModularRandomizer/Design/v1-test.html create mode 100644 plugins/ModularRandomizer/Design/v1-ui-spec.md create mode 100644 plugins/ModularRandomizer/Design/v2-style-guide.md create mode 100644 plugins/ModularRandomizer/Design/v2-test.html create mode 100644 plugins/ModularRandomizer/Design/v2-ui-spec.md create mode 100644 plugins/ModularRandomizer/Design/v3-style-guide.md create mode 100644 plugins/ModularRandomizer/Design/v3-test.html create mode 100644 plugins/ModularRandomizer/Design/v3-ui-spec.md create mode 100644 plugins/ModularRandomizer/Design/v4-test.html create mode 100644 plugins/ModularRandomizer/Design/v4-ui-spec.md create mode 100644 plugins/ModularRandomizer/Design/v5-style-guide.md create mode 100644 plugins/ModularRandomizer/Design/v5-test.html create mode 100644 plugins/ModularRandomizer/Design/v5-ui-spec.md create mode 100644 plugins/ModularRandomizer/README.md create mode 100644 plugins/ModularRandomizer/Source.zip create mode 100644 plugins/ModularRandomizer/Source/ParameterIDs.hpp create mode 100644 plugins/ModularRandomizer/Source/PluginEditor.cpp create mode 100644 plugins/ModularRandomizer/Source/PluginEditor.h create mode 100644 plugins/ModularRandomizer/Source/PluginHosting.cpp create mode 100644 plugins/ModularRandomizer/Source/PluginProcessor.cpp create mode 100644 plugins/ModularRandomizer/Source/PluginProcessor.h create mode 100644 plugins/ModularRandomizer/Source/ProcessBlock.cpp create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/base.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/dialogs.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/header.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/overrides.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/plugin_rack.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/themes.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/variables.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/OFL.txt create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/ShareTechMono-Regular.ttf create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/Share_Tech_Mono,VT323.zip create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/Share_Tech_Mono.zip create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/VT323-Regular.ttf create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/OFL.txt create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/ShareTechMono-Regular.ttf create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/OFL.txt create mode 100644 plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/VT323-Regular.ttf create mode 100644 plugins/ModularRandomizer/Source/ui/public/index.html create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/context_menus.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/controls.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/expose_system.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/help_panel.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/juce_bridge.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/juce_integration.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/lane_module.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/persistence.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/plugin_rack.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/preset_system.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/realtime.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/state.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/theme_system.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/undo_system.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js create mode 100644 plugins/ModularRandomizer/Source/ui/public/style.css create mode 100644 plugins/ModularRandomizer/installer/LICENSE.txt create mode 100644 plugins/ModularRandomizer/installer/windows.iss create mode 100644 plugins/ModularRandomizer/status.json diff --git a/.gitmodules b/.gitmodules index 3ccf538..e069789 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,3 @@ [submodule "_tools/JUCE"] path = _tools/JUCE url = https://github.com/juce-framework/JUCE -[submodule "plugins/ModularRandomizer"] - path = plugins/ModularRandomizer - url = https://github.com/DimitarPetrov77/ModularRandomizer.git diff --git a/plugins/ModularRandomizer b/plugins/ModularRandomizer deleted file mode 160000 index 2273f04..0000000 --- a/plugins/ModularRandomizer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2273f0499840cddf57fdb3f34c296ae4a54ec94f diff --git a/plugins/ModularRandomizer/.gitignore b/plugins/ModularRandomizer/.gitignore new file mode 100644 index 0000000..c3d5068 --- /dev/null +++ b/plugins/ModularRandomizer/.gitignore @@ -0,0 +1,32 @@ +# Build artifacts +build/ +builds/ +cmake-build-*/ +*.vst3 +*.dll +*.exe +*.lib +*.exp +*.obj +*.o +*.pdb +*.ilk + +# IDE files +.vs/ +.vscode/ +*.vcxproj.user +*.suo +*.sln.docstates +.idea/ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Binary data (generated at build time) +JuceLibraryCode/ + +# Private repo git data +.git_backup/ diff --git a/plugins/ModularRandomizer/.ideas/architecture.md b/plugins/ModularRandomizer/.ideas/architecture.md new file mode 100644 index 0000000..aca80d1 --- /dev/null +++ b/plugins/ModularRandomizer/.ideas/architecture.md @@ -0,0 +1,155 @@ +# DSP Architecture Specification — Modular Randomizer + +## Architecture Type + +This is **not a traditional audio DSP plugin**. It is a **plugin host container** with a **control-rate engine**. The audio signal passes through untouched (or through the loaded sub-plugin). The core processing is all about **parameter manipulation at control rate**, not sample-level DSP. + +--- + +## Core Components + +### 1. Plugin Host Engine +Loads and manages an external VST3/AU plugin instance inside the Modular Randomizer. + +- **Plugin Scanner** — Discovers installed VST3/AU plugins on the system +- **Plugin Loader** — Instantiates the selected plugin, creates its audio processing graph +- **Parameter Discovery** — Reads the loaded plugin's parameter tree, extracts names, ranges, types +- **Audio Passthrough** — Routes audio I/O through the loaded plugin's `processBlock()` +- **State Serialization** — Saves/restores the loaded plugin's state alongside Modular Randomizer's own state + +**JUCE API surface:** +- `juce::AudioPluginFormatManager` — VST3/AU format registration +- `juce::KnownPluginList` — Plugin scanning and caching +- `juce::AudioPluginInstance` — Loaded plugin lifecycle +- `juce::AudioProcessorGraph` — Internal audio routing + +### 2. Parameter Registry +Central store for all discovered parameters from the loaded plugin. + +- **Normalized Parameter Map** — All external parameters mapped to normalized 0.0–1.0 +- **Lock State Tracker** — Per-parameter lock flag (manual or auto-detected) +- **Safety Scanner** — On load, scans parameter names against volume-critical keywords and auto-locks matches +- **Group Manager** — Allows user-defined parameter groups for bulk randomization + +**Keywords for auto-lock:** `master`, `output`, `main vol`, `main volume`, `master vol`, `master volume`, `out level`, `output level`, `volume` + +### 3. Logic Block Engine +The randomization generators. Each Logic Block is an independent instance with its own trigger, constraint, and movement configuration. + +- **Random Number Generator** — Per-block PRNG (e.g. `std::mt19937`) generating values in [0.0, 1.0] +- **Trigger System** — Evaluates firing conditions each control-rate tick: + - *Manual*: Edge-detected button press + - *Tempo Sync*: Beat-position comparator using DAW transport (`juce::AudioPlayHead`) + - *Audio Threshold*: Envelope follower on sidechain/input with level comparator and retrigger holdoff +- **Constraint Processor** — Applies range clamping and step quantization to raw random output +- **Glide Interpolator** — Smoothly transitions current value → target value over configurable time with selectable curve shape + +### 4. Connection Router +Manages the graph topology — which Logic Block outputs connect to which Parameter Nodes. + +- **Connection Map** — Sparse mapping from Logic Block IDs to Parameter Node IDs / Group IDs / "all" +- **Fan-Out Support** — One Logic Block can drive multiple parameters +- **Fan-In Prevention** — Each parameter can only receive from one Logic Block (last-write-wins or first-connected-wins) +- **Enable/Disable** — Connections can be toggled without deletion + +### 5. Value Applicator +The final stage that writes randomized values to the loaded plugin's parameters. + +- **Bypass Check** — Respects global bypass and per-parameter lock states +- **Mix Blend** — Interpolates between original and randomized values based on Global Mix +- **Rate Limiter** — Enforces minimum interval between value changes (global setting) +- **Thread Safety** — Parameter writes happen on the audio thread via `juce::AudioProcessorParameter::setValue()` + +### 6. UI Bridge (WebView2) +Bridges the visual node graph (HTML/JS/Canvas) to the C++ engine. + +- **State Sync** — Pushes parameter lists, connection topology, and Logic Block states to the WebView +- **Event Handling** — Receives user interactions (create block, draw cable, lock param, fire) from JS +- **Real-Time Display** — Streams current parameter values and trigger activity to the UI for visual feedback + +--- + +## Processing Chain + +This plugin has **two parallel processing paths**: + +``` +AUDIO PATH (sample-rate): + Audio In → [Loaded Plugin processBlock()] → Audio Out + ↑ + | parameter writes + | +CONTROL PATH (control-rate, per-block): + Trigger System → fires? → RNG → Constraint Processor → Glide Interpolator + ↓ + Connection Router + ↓ + Value Applicator → Loaded Plugin Parameters + ↑ ↑ + Lock Check Mix Blend +``` + +### Timing Model + +- **Audio path**: Runs at sample rate inside `processBlock()`. The loaded plugin processes audio normally. +- **Control path**: Runs at **block rate** (once per `processBlock()` call, typically every 64–512 samples). Logic Blocks evaluate triggers and update parameter targets at this rate. +- **Glide interpolation**: Can run at block rate with linear interpolation, or at sample rate if click-free smoothing requires it. Block rate is sufficient for most cases given typical buffer sizes (1–10ms). +- **UI updates**: Decoupled from audio thread. UI polls or receives pushed state at ~30–60 fps via WebView message bridge. + +--- + +## Parameter Mapping + +### Host-Level Parameters → Components + +| Parameter | Component | Function | +|:---|:---|:---| +| `host_bypass` | Value Applicator | Gates all parameter writes | +| `host_mix` | Value Applicator | Blends original vs. randomized values | +| `host_rate_limit` | Value Applicator | Enforces minimum event interval | + +### Logic Block Parameters → Components + +| Parameter | Component | Function | +|:---|:---|:---| +| `lb_trigger_mode` | Trigger System | Selects trigger evaluation mode | +| `lb_manual_fire` | Trigger System | Edge-detected manual fire | +| `lb_tempo_division` | Trigger System | Beat division for tempo sync | +| `lb_threshold_level` | Trigger System | Audio threshold comparator level | +| `lb_threshold_release` | Trigger System | Retrigger holdoff timer | +| `lb_range_min` | Constraint Processor | Lower bound clamping | +| `lb_range_max` | Constraint Processor | Upper bound clamping | +| `lb_quantize_enable` | Constraint Processor | Enables step snapping | +| `lb_quantize_steps` | Constraint Processor | Number of discrete steps | +| `lb_movement_mode` | Glide Interpolator | Instant vs. glide selection | +| `lb_glide_time` | Glide Interpolator | Interpolation duration | +| `lb_glide_curve` | Glide Interpolator | Curve shape selection | + +### Per-Parameter Node → Components + +| Parameter | Component | Function | +|:---|:---|:---| +| `pn_locked` | Value Applicator | Blocks randomization for this param | +| `pn_auto_detected` | Safety Scanner | Read-only detection flag | + +--- + +## Complexity Assessment + +**Score: 4/5 (Expert)** + +### Rationale + +| Factor | Complexity | Why | +|:---|:---|:---| +| Plugin Hosting | High | Loading external VST3/AU plugins, managing their lifecycle, audio routing through `AudioProcessorGraph`, and state serialization is one of the most complex JUCE tasks | +| Dynamic Parameter Discovery | Medium-High | Reading parameter trees at runtime, normalizing heterogeneous types, tracking changes | +| Multi-Instance Logic Blocks | Medium | Each block is independent with its own state, but the pattern is repetitive once one works | +| Tempo Sync Triggers | Medium | Requires correct parsing of `AudioPlayHead::PositionInfo` and beat-position math | +| Audio Threshold Triggers | Medium | Envelope follower + level comparator + retrigger holdoff — standard DSP but needs tuning | +| Glide Interpolation | Low-Medium | Straightforward exponential/linear ramp, runs at control rate | +| Node Graph UI | High | Interactive canvas with draggable nodes, cable drawing, right-click menus — rich WebView UI | +| Thread Safety | High | Parameter writes from control engine to loaded plugin must be lock-free and audio-thread safe | +| State Serialization | High | Must serialize both Modular Randomizer state AND the loaded plugin's opaque state blob | + +**Not Level 5** because: No ML, no physical modeling, no novel DSP algorithms. The complexity comes from **systems integration** (hosting, threading, state management) rather than signal processing research. diff --git a/plugins/ModularRandomizer/.ideas/creative-brief.md b/plugins/ModularRandomizer/.ideas/creative-brief.md new file mode 100644 index 0000000..a59f6ac --- /dev/null +++ b/plugins/ModularRandomizer/.ideas/creative-brief.md @@ -0,0 +1,40 @@ +# Creative Brief — Modular Randomizer + +## Hook + +**Stop tweaking. Start discovering.** +A modular, node-based randomization engine that lives inside your DAW. Load any VST3/AU plugin, wire up logic blocks, and let controlled chaos sculpt your sound — on beat, on threshold, or on demand. + +## Description + +### What It Is + +Modular Randomizer is a **plugin host container**. You load an external VST3 or AU instrument/effect inside it. Its job is not to process audio itself — it's to **control the loaded plugin's parameters** through a visual, node-based randomization environment. + +### How It Works + +The interface is a **modular graph workspace**, inspired by modular synth patching: + +1. **Parameter Nodes** — When an external plugin loads, its exposed parameters appear as target nodes on the canvas. Each parameter is a destination that can receive randomized values. + +2. **Logic Blocks** — These are the source nodes. Each block is a self-contained randomization generator with three control areas: + - **Triggers** — *When* to fire: manual button, tempo-synced beat divisions (1/4, 1/8, 1/16 notes via DAW BPM), or audio threshold (fires when incoming signal exceeds a set level, e.g. kick drum detection). + - **Constraints** — *What* to pick: min/max range sliders (e.g. keep a filter between 20%–80%), and step quantization (snap to grid, essential for pitch). + - **Movement** — *How* to apply: instant jump or glide (smooth interpolation over configurable milliseconds, preventing clicks/pops). + +3. **Connections** — Cables drawn from a Logic Block output to a single parameter, a custom group, or the entire plugin at once. + +### Safety System + +Total randomness can ruin sounds or spike volume. Built-in protection: + +- **Auto-Detect Master Volume** — On plugin load, the engine scans parameter names for keywords ("master", "output", "main vol", etc.) and auto-locks matches to prevent volume spikes. +- **Right-Click Locking** — Any parameter or group can be locked via right-click. Locked parameters ignore all incoming randomization signals completely. + +### Sonic Goal + +This is not a sound generator. It's a **creative control surface** — a tool for producers and sound designers who want to break out of manual knob-tweaking and discover unexpected parameter combinations through structured randomness. + +### Visual Aesthetic + +Modular synth workspace. Dark background, cable patching, glowing nodes. Think VCV Rack meets Bitwig's modulator system — functional, clean, and alive with signal flow. diff --git a/plugins/ModularRandomizer/.ideas/parameter-spec.md b/plugins/ModularRandomizer/.ideas/parameter-spec.md new file mode 100644 index 0000000..ff03505 --- /dev/null +++ b/plugins/ModularRandomizer/.ideas/parameter-spec.md @@ -0,0 +1,114 @@ +# Parameter Specification — Modular Randomizer + +## Architecture Note + +This plugin is a **host container**. It does not have traditional audio DSP parameters. Instead, its parameters describe the behavior of the randomization engine and the logic blocks within the node graph. + +The loaded external plugin's parameters are **dynamic** — they are discovered at runtime when a VST3/AU is loaded and exposed as target nodes. + +--- + +## Host-Level Parameters + +These are global controls for the Modular Randomizer itself. + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `host_bypass` | Bypass | Bool | off / on | off | — | Disables all randomization signals | +| `host_mix` | Global Mix | Float | 0.0 – 1.0 | 1.0 | % | Wet/dry blend of randomized vs. original parameter values | +| `host_rate_limit` | Rate Limit | Float | 1 – 1000 | 100 | ms | Minimum interval between randomization events (global) | + +--- + +## Logic Block Parameters + +Each Logic Block instance exposes these parameters. Multiple Logic Blocks can coexist on the graph. + +### Trigger Section + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `lb_trigger_mode` | Trigger Mode | Enum | manual / tempo_sync / audio_threshold / midi | manual | — | Selects when the block fires | +| `lb_manual_fire` | Fire | Trigger | — | — | — | Momentary button, fires one randomization event | +| `lb_tempo_division` | Beat Division | Enum | 1/1, 1/2, 1/4, 1/8, 1/16, 1/32 | 1/4 | note | Active when trigger_mode = tempo_sync | +| `lb_threshold_level` | Threshold | Float | -60.0 – 0.0 | -12.0 | dB | Audio level that triggers firing when trigger_mode = audio_threshold | +| `lb_threshold_release` | Retrigger Time | Float | 10 – 2000 | 100 | ms | Minimum time between audio-triggered fires (prevents retriggering) | +| `lb_midi_mode` | MIDI Mode | Enum | any_note / specific_note / cc | any_note | — | Active when trigger_mode = midi. Determines what MIDI event fires the block | +| `lb_midi_note` | MIDI Note | Int | 0 – 127 | 60 | note | Active when midi_mode = specific_note. Only this note triggers the block | +| `lb_midi_cc` | MIDI CC | Int | 0 – 127 | 1 | cc# | Active when midi_mode = cc. CC value > 64 fires, < 64 does not | +| `lb_velocity_scale` | Velocity Scale | Bool | off / on | off | — | When on, incoming velocity (0–127) scales the randomization range proportionally | + +### Constraint Section + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `lb_range_min` | Range Min | Float | 0.0 – 1.0 | 0.0 | norm | Lower bound of random output (normalized) | +| `lb_range_max` | Range Max | Float | 0.0 – 1.0 | 1.0 | norm | Upper bound of random output (normalized) | +| `lb_quantize_enable` | Quantize | Bool | off / on | off | — | Enables step quantization | +| `lb_quantize_steps` | Steps | Int | 2 – 128 | 12 | — | Number of discrete steps within the range (12 = semitones for pitch) | + +### Movement Section + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `lb_movement_mode` | Movement | Enum | instant / glide | instant | — | How the parameter transitions to the new value | +| `lb_glide_time` | Glide Time | Float | 1 – 5000 | 50 | ms | Duration of smooth interpolation (active when movement = glide) | +| `lb_glide_curve` | Glide Curve | Enum | linear / ease_in / ease_out / ease_in_out | linear | — | Shape of the glide interpolation curve | + +--- + +## Envelope Follower Block Parameters + +An Envelope Follower block is a **continuous modulation source** — unlike Randomize blocks which fire discrete events, this block continuously maps the incoming audio amplitude to target parameter values. Multiple Envelope blocks can coexist alongside Randomize blocks. + +### Response + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `ef_attack` | Attack | Float | 1 – 500 | 10 | ms | How fast the envelope rises when audio gets louder | +| `ef_release` | Release | Float | 1 – 2000 | 100 | ms | How fast the envelope falls when audio gets quieter | + +### Mapping + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `ef_gain` | Gain | Float | 0.0 – 1.0 | 0.5 | norm | Amplifies the envelope signal before mapping. Higher = more sensitive to quiet audio | +| `ef_range_min` | Range Min | Float | 0.0 – 1.0 | 0.0 | norm | Lower bound of the output mapping range | +| `ef_range_max` | Range Max | Float | 0.0 – 1.0 | 1.0 | norm | Upper bound of the output mapping range | +| `ef_invert` | Invert | Bool | off / on | off | — | When on, loud audio drives parameters DOWN instead of up | + +--- + +## Safety & Locking Parameters + +These are per-parameter-node controls, applied to each discovered parameter of the loaded plugin. + +| ID | Name | Type | Range | Default | Unit | Notes | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| `pn_locked` | Locked | Bool | off / on | off* | — | When on, parameter ignores all randomization. *Auto-set to `on` for detected master volume params | +| `pn_auto_detected` | Auto-Detected | Bool | off / on | — | — | Read-only flag indicating the safety system detected this as a volume-critical parameter | + +--- + +## Connection Routing (Graph State — Not Audio Parameters) + +These are part of the graph topology, not traditional plugin parameters. They are serialized as part of the preset/state but don't appear as automatable knobs. + +| Property | Type | Notes | +| :--- | :--- | :--- | +| `connection_source` | Logic Block ID | Which logic block is the source | +| `connection_target` | Parameter Node ID / Group ID / "all" | Single param, named group, or entire plugin | +| `connection_enabled` | Bool | Cable can be toggled on/off without deleting | + +--- + +## Dynamic Runtime Parameters + +These are **not predefined** — they are discovered when the user loads an external VST3/AU plugin: + +- **Parameter count**: Unknown until load time +- **Parameter names**: Read from the loaded plugin's parameter tree +- **Parameter ranges**: Read from the loaded plugin metadata +- **Parameter types**: Float, Int, Bool, Enum — mapped to normalized 0.0–1.0 for randomization + +The safety system scans parameter names against keywords: `master`, `output`, `main vol`, `main volume`, `master vol`, `master volume`, `out level`, `output level`, `volume`. diff --git a/plugins/ModularRandomizer/.ideas/plan.md b/plugins/ModularRandomizer/.ideas/plan.md new file mode 100644 index 0000000..e8fcb3a --- /dev/null +++ b/plugins/ModularRandomizer/.ideas/plan.md @@ -0,0 +1,169 @@ +# Implementation Plan — Modular Randomizer + +## Complexity Score: 4/5 + +## Implementation Strategy: Phased + +Given the complexity score of 4, this plugin requires a multi-phase implementation. The core challenge is **not DSP** — it's **plugin hosting, dynamic parameter management, and a rich interactive UI**. The phases are ordered by dependency: you can't randomize parameters until you can load a plugin, and you can't draw cables until the graph UI exists. + +--- + +## Phase 1: Plugin Host Foundation + +The minimum viable core — load a plugin and pass audio through it. + +- [ ] **Audio Processor Graph Setup** — Create `juce::AudioProcessorGraph` with input/output nodes +- [ ] **Plugin Format Registration** — Register VST3 (and optionally AU on macOS) via `AudioPluginFormatManager` +- [ ] **Plugin Scanner** — Scan system directories, build `KnownPluginList`, cache results +- [ ] **Plugin Loader** — Instantiate a selected plugin, insert into the audio graph +- [ ] **Audio Passthrough** — Verify clean audio routing: DAW → Modular Randomizer → Loaded Plugin → DAW output +- [ ] **Parameter Discovery** — On load, enumerate all parameters from the loaded plugin's tree +- [ ] **Parameter Normalization** — Map all discovered parameter types (float, int, bool, enum) to normalized 0.0–1.0 + +### Validation Gate +✅ Can load a VST3 plugin, pass audio through it, and print its parameter list to the debug console. + +--- + +## Phase 2: Randomization Engine + +The control-rate engine that generates and applies random values. + +### Phase 2.1: Single Logic Block +- [ ] **Random Number Generator** — `std::mt19937` seeded per block, generating [0.0, 1.0] +- [ ] **Manual Trigger** — Edge-detected fire button +- [ ] **Constraint Processor** — Min/max clamping, step quantization +- [ ] **Instant Apply** — Direct parameter write via `setValue()` +- [ ] **Value Applicator** — Bypass check, lock check, rate limiting +- [ ] **Safety Scanner** — Keyword matching on parameter names, auto-lock volume params + +### Phase 2.2: Advanced Triggers +- [ ] **Tempo Sync Trigger** — Read `AudioPlayHead::PositionInfo`, compute beat positions, fire on divisions +- [ ] **Audio Threshold Trigger** — Envelope follower (peak or RMS), level comparator, retrigger holdoff +- [ ] **Trigger Mode Switching** — Clean state transitions between manual/tempo/threshold + +### Phase 2.3: Glide System +- [ ] **Linear Interpolation** — Ramp from current to target over configurable time +- [ ] **Curve Shapes** — Implement ease-in, ease-out, ease-in-out using power functions +- [ ] **Block-Rate Stepping** — Advance interpolation each processBlock call +- [ ] **Instant Fallback** — Zero-time glide = instant jump (no special case needed) + +### Phase 2.4: Multi-Block & Routing +- [ ] **Logic Block Manager** — Create, delete, serialize multiple Logic Block instances +- [ ] **Connection Router** — Map blocks to parameters, groups, or "all" +- [ ] **Fan-Out** — One block drives multiple targets +- [ ] **Fan-In Policy** — Decide and implement conflict resolution (last-write-wins recommended) +- [ ] **Global Mix** — Blend original vs. randomized parameter values + +### Validation Gate +✅ Can load a plugin, create one logic block, randomize a parameter with constraints, glide to the new value, and auto-lock the master volume. + +--- + +## Phase 3: WebView UI — Node Graph + +The interactive visual environment. + +### Phase 3.1: WebView Shell +- [ ] **WebView2 Integration** — Set up `juce::WebBrowserComponent` with WebView2 backend +- [ ] **Message Bridge** — Bidirectional JSON messaging between C++ and JS +- [ ] **State Push** — C++ sends parameter list, block states, and connections to JS on load +- [ ] **Event Receive** — JS sends user actions (create block, draw cable, fire, lock) to C++ + +### Phase 3.2: Node Canvas +- [ ] **Canvas Renderer** — HTML5 Canvas or SVG for the node graph workspace +- [ ] **Parameter Nodes** — Render discovered parameters as target nodes on the canvas +- [ ] **Logic Block Nodes** — Render as source nodes with trigger/constraint/movement controls +- [ ] **Cable Drawing** — Click-drag from output port to input port, Bézier curve rendering +- [ ] **Node Positioning** — Drag to reposition, auto-layout on initial load +- [ ] **Zoom & Pan** — Canvas navigation for large parameter sets + +### Phase 3.3: Interaction & Feedback +- [ ] **Right-Click Context Menu** — Lock/unlock parameters, delete connections +- [ ] **Real-Time Value Display** — Show current parameter values on nodes (polled from C++) +- [ ] **Trigger Activity** — Flash/pulse animation when a Logic Block fires +- [ ] **Cable Activity** — Visual signal flow along cables when randomization occurs +- [ ] **Plugin Selector UI** — Dropdown or browser to select which plugin to load + +### Phase 3.4: Loaded Plugin Editor +- [ ] **Plugin Window** — Option to open the loaded plugin's native editor in a floating window +- [ ] **Parameter Sync** — If user manually tweaks the loaded plugin's editor, reflect changes on the graph + +### Validation Gate +✅ Full visual workflow: select a plugin → plugin loads → parameters appear as nodes → create logic block → draw cable → hit fire → see parameter change on loaded plugin. + +--- + +## Phase 4: Polish & State Management + +- [ ] **Full State Serialization** — Save/restore Modular Randomizer graph + loaded plugin state as a single preset +- [ ] **Undo/Redo** — Track graph topology changes (create block, draw cable, delete, lock) +- [ ] **Parameter Grouping** — UI for creating named groups of parameters +- [ ] **Edge Cases** — Handle plugin unload/reload, parameter count changes, missing plugins +- [ ] **Performance** — Profile control-rate overhead, optimize for large parameter counts (100+ params) +- [ ] **Accessibility** — Keyboard navigation for node graph + +--- + +## Dependencies + +### Required JUCE Modules +- `juce_audio_basics` — Audio buffer management +- `juce_audio_processors` — Plugin hosting, AudioProcessorGraph, parameter management +- `juce_audio_plugin_client` — Plugin format wrapper (VST3/AU export) +- `juce_audio_formats` — Audio format support for plugin hosting +- `juce_audio_utils` — Plugin list/scanner utilities +- `juce_gui_basics` — Window management +- `juce_gui_extra` — WebBrowserComponent (WebView2) + +### External Dependencies +- **Microsoft WebView2 SDK** — Required for WebView2 on Windows (NuGet package, version 1.0.1901.177 or later based on existing Kari project patterns) +- **No additional DSP libraries** — All randomization/interpolation is basic math, no STK or FFTW needed + +### Build System +- **CMake** via JUCE's CMake API +- **FetchContent** for WebView2 SDK (following existing Kari project pattern) + +--- + +## Risk Assessment + +### High Risk +| Risk | Impact | Mitigation | +|:---|:---|:---| +| **Plugin hosting stability** | Loaded plugins can crash, leak memory, or behave unexpectedly | Wrap plugin loading in try/catch, validate plugin before inserting into graph, provide "unload" escape hatch | +| **Thread safety** | Parameter writes from control engine must not block or race with audio thread | Use `juce::AudioProcessorParameter::setValue()` which is designed for this; avoid custom locks in audio path | +| **State serialization with loaded plugin** | Loading a preset with a missing plugin = broken state | Store plugin ID + fallback behavior; warn user if plugin not found | + +### Medium Risk +| Risk | Impact | Mitigation | +|:---|:---|:---| +| **Tempo sync accuracy** | Missed beats or double-fires if beat position math is wrong | Use `ppqPosition` from `AudioPlayHead`, test with various DAWs and tempos | +| **WebView2 availability** | Windows-only, requires Edge/WebView2 runtime | Document requirement; WebView2 Evergreen runtime is widely pre-installed on Windows 10/11 | +| **Large parameter counts** | Plugins with 200+ parameters may overwhelm the UI | Implement search/filter, collapsible groups, pagination in the node graph | + +### Low Risk +| Risk | Impact | Mitigation | +|:---|:---|:---| +| **Random number generation** | Poor distribution or predictable sequences | `std::mt19937` is well-tested and sufficient for this use case | +| **Glide interpolation** | Clicks if glide time is too short | Minimum glide time of 1ms, and instant mode already handles 0ms case | +| **Min/max constraint clamping** | User sets min > max | Enforce min ≤ max in UI and clamp in engine | + +--- + +## Framework Decision: WebView + +**Decision: `webview`** + +**Rationale:** +This plugin's core value proposition is a **node-based visual workspace** — an interactive canvas with draggable nodes, Bézier cable drawing, right-click context menus, zoom/pan, real-time value displays, and trigger animations. This is fundamentally a **rich graphical application** rather than a traditional knob-and-slider plugin UI. + +A native C++ framework like Visage would require building a custom canvas renderer, hit-testing, cable physics, and layout engine from scratch. WebView2 provides: + +1. **HTML5 Canvas / SVG** — Battle-tested 2D graphics with hardware acceleration +2. **DOM event handling** — Click, drag, right-click, wheel events are trivial +3. **CSS animations** — Trigger pulses, cable glow, node transitions with zero DSP overhead +4. **Rapid iteration** — Hot-reload HTML/JS/CSS without rebuilding the plugin +5. **Proven pattern** — The Kari drum synth in this same workspace already uses WebView2 successfully + +The overhead of WebView2 is negligible for a UI that updates at 30–60fps, and the loaded plugin's native editor can still open in its own window. diff --git a/plugins/ModularRandomizer/.ideas/refactoring-plan.md b/plugins/ModularRandomizer/.ideas/refactoring-plan.md new file mode 100644 index 0000000..f096c86 --- /dev/null +++ b/plugins/ModularRandomizer/.ideas/refactoring-plan.md @@ -0,0 +1,202 @@ +# ModularRandomizer UI Refactoring Plan +## From 4262-line monolith → Feature-level modules + +--- + +## Current State + +**The problem:** Everything is in a single `index.html` (4262 lines, 191KB): +- ~2170 lines of CSS (inline `` with `` tags +3. Update CMakeLists.txt + getResource() +4. **Build + test** + +### Phase 2: Infrastructure JS (MEDIUM RISK) +1. Extract `juce-bridge.js` (self-contained IIFE) +2. Extract `state.js` (global declarations) +3. Update CMakeLists.txt + getResource() +4. **Build + test** — native bridge must survive + +### Phase 3: Feature Systems (HIGH RISK — sequential) +Extract one at a time, build+test after each: +1. `theme-system.js` — standalone +2. `undo-system.js` — simple, few deps +3. `plugin-rack.js` — large, the plugin card rendering system +4. `preset-system.js` — preset save/load/delete for both plugin and global +5. `logic-blocks.js` — block cards, assignment, rendering +6. `randomize-engine.js` — core modulation +7. `host-sync.js` — state persistence and sync +8. `realtime-data.js` — live data processing +9. `plugin-browser.js` — scan modal +10. `init.js` — bootstrap wiring + +### Phase 4: Cleanup +1. Delete dead: `app.js`, `style.css` +2. Full build + DAW test +3. Git commit + +--- + +## C++ Changes Required + +### 1. CMakeLists.txt — Register all files +```cmake +juce_add_binary_data(ModularRandomizerWebUI + HEADER_NAME "BinaryData.h" + NAMESPACE BinaryData + SOURCES + Source/ui/public/index.html + Source/ui/public/css/variables.css + Source/ui/public/css/base.css + Source/ui/public/css/header.css + Source/ui/public/css/plugin-rack.css + Source/ui/public/css/logic-blocks.css + Source/ui/public/css/dialogs.css + Source/ui/public/css/themes.css + Source/ui/public/js/juce-bridge.js + Source/ui/public/js/state.js + Source/ui/public/js/theme-system.js + Source/ui/public/js/undo-system.js + Source/ui/public/js/plugin-rack.js + Source/ui/public/js/preset-system.js + Source/ui/public/js/logic-blocks.js + Source/ui/public/js/randomize-engine.js + Source/ui/public/js/host-sync.js + Source/ui/public/js/realtime-data.js + Source/ui/public/js/plugin-browser.js + Source/ui/public/js/init.js +) +``` + +### 2. getResource() — URL routing (CloudWash pattern) +Rewrite from "always return index.html" to proper path routing with MIME types and 404 fallback. + +### 3. PluginEditor.h — Add helper methods +`getMimeForExtension()`, `getExtension()` — copy from CloudWash. + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|-----------| +| Cross-file function calls break | Strict load order; grep all function names after each extraction | +| BinaryData name mangling (hyphens) | JUCE replaces `-` with `_`: `plugin-rack.css` → `pluginrack_css`. Verify in BinaryData.h | +| Font loading from external URL | Keep `@import` in variables.css — works fine in WebView2 | +| getResource routing bugs | Add CloudWash-style 404 fallback page listing all available resources | + +--- + +## Estimated Effort +- Phase 1 (CSS): ~30 min +- Phase 2 (Infrastructure): ~20 min +- Phase 3 (Feature systems, 10 modules): ~2-3 hours +- Phase 4 (Cleanup): ~10 min +- **Total: ~3-4 hours** diff --git a/plugins/ModularRandomizer/CMakeLists.txt b/plugins/ModularRandomizer/CMakeLists.txt new file mode 100644 index 0000000..0c20ca0 --- /dev/null +++ b/plugins/ModularRandomizer/CMakeLists.txt @@ -0,0 +1,153 @@ +# ModularRandomizer - Multi-Plugin Parameter Randomizer +# WebView-based UI with plugin hosting architecture +# Windows: VST3/Standalone (WebView2) + +# ============================================ +# PLATFORM-SPECIFIC CONFIGURATION +# ============================================ +if(WIN32) + set(PLUGIN_FORMATS VST3 Standalone) + set(NEEDS_WEBVIEW2 TRUE) + set(WEBVIEW_BACKEND "WebView2") +elseif(APPLE) + set(PLUGIN_FORMATS VST3 AU Standalone) + set(NEEDS_WEBVIEW2 FALSE) + set(WEBVIEW_BACKEND "WKWebView") + set(AU_MAIN_TYPE kAudioUnitType_MusicEffect) +elseif(UNIX) + set(PLUGIN_FORMATS VST3 LV2 Standalone) + set(NEEDS_WEBVIEW2 FALSE) + set(NEEDS_WEB_BROWSER TRUE) + set(WEBVIEW_BACKEND "WebKitGTK") + set(LV2_URI "https://github.com/noizefield/audio-plugin-coder/ModularRandomizer") +else() + message(FATAL_ERROR "Unsupported platform") +endif() + +message(STATUS "ModularRandomizer: Building for ${PLUGIN_FORMATS}") +message(STATUS "ModularRandomizer: WebView backend: ${WEBVIEW_BACKEND}") + +# ============================================ +# BINARY DATA (Web UI Resources) +# ============================================ +juce_add_binary_data(ModularRandomizerWebUI + HEADER_NAME "BinaryData.h" + NAMESPACE BinaryData + SOURCES + Source/ui/public/index.html + Source/ui/public/css/variables.css + Source/ui/public/css/base.css + Source/ui/public/css/header.css + Source/ui/public/css/plugin_rack.css + Source/ui/public/css/logic_blocks.css + Source/ui/public/css/dialogs.css + Source/ui/public/css/themes.css + Source/ui/public/css/wrongeq.css + Source/ui/public/css/overrides.css + Source/ui/public/js/juce_bridge.js + Source/ui/public/js/state.js + Source/ui/public/js/theme_system.js + Source/ui/public/js/help_panel.js + Source/ui/public/js/undo_system.js + Source/ui/public/js/plugin_rack.js + Source/ui/public/js/context_menus.js + Source/ui/public/js/preset_system.js + Source/ui/public/js/logic_blocks.js + Source/ui/public/js/lane_module.js + Source/ui/public/js/wrongeq_canvas.js + Source/ui/public/js/realtime.js + Source/ui/public/js/expose_system.js + Source/ui/public/js/controls.js + Source/ui/public/js/persistence.js + Source/ui/public/js/juce_integration.js + Source/ui/public/fonts/ShareTechMono-Regular.ttf + Source/ui/public/fonts/VT323-Regular.ttf +) + +# Set include directories for binary data +set_target_properties(ModularRandomizerWebUI PROPERTIES + POSITION_INDEPENDENT_CODE TRUE +) + +# ============================================ +# PLUGIN TARGET +# ============================================ +juce_add_plugin(ModularRandomizer + COMPANY_NAME "Noizefield" + IS_SYNTH FALSE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE + COPY_PLUGIN_AFTER_BUILD FALSE + PLUGIN_MANUFACTURER_CODE Nzfd + PLUGIN_CODE MdRn + FORMATS ${PLUGIN_FORMATS} + PRODUCT_NAME "ModularRandomizer" + BUNDLE_ID "com.noizefield.modularrandomizer" + PLUGIN_NAME "ModularRandomizer" + DESCRIPTION "Multi-Plugin Parameter Randomizer" + + # WebView2 requirement for Windows + NEEDS_WEBVIEW2 ${NEEDS_WEBVIEW2} +) + +# ============================================ +# SOURCE FILES +# ============================================ +target_sources(ModularRandomizer + PRIVATE + Source/PluginProcessor.cpp + Source/PluginProcessor.h + Source/ProcessBlock.cpp + Source/PluginHosting.cpp + Source/PluginEditor.cpp + Source/PluginEditor.h + Source/ParameterIDs.hpp +) + +# ============================================ +# INCLUDE DIRECTORIES +# ============================================ +target_include_directories(ModularRandomizer + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/Source +) + +# ============================================ +# COMPILE DEFINITIONS +# ============================================ +target_compile_definitions(ModularRandomizer + PUBLIC + JUCE_WEB_BROWSER=1 + JUCE_VST3_CAN_REPLACE_VST2=0 + JUCE_DISPLAY_SPLASH_SCREEN=0 + JUCE_WEB_BROWSER_RESOURCE_PROVIDER_AVAILABLE=1 + JUCE_PLUGINHOST_VST3=1 +) + +# WebView2 static linking is Windows-only +if(WIN32) + target_compile_definitions(ModularRandomizer PUBLIC + JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1 + ) +endif() + +# ============================================ +# LINK LIBRARIES +# ============================================ +target_link_libraries(ModularRandomizer + PRIVATE + ModularRandomizerWebUI + juce::juce_audio_basics + juce::juce_audio_processors + juce::juce_audio_plugin_client + juce::juce_audio_formats + juce::juce_audio_utils + juce::juce_gui_basics + juce::juce_gui_extra + juce::juce_dsp + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags +) diff --git a/plugins/ModularRandomizer/Design/04 Female Vocal A.vstpreset b/plugins/ModularRandomizer/Design/04 Female Vocal A.vstpreset new file mode 100644 index 0000000000000000000000000000000000000000..301aee49e19aadf0dbe59dafa678285863539ff3 GIT binary patch literal 164 zcmWFw4l!nAU|=vcGc`6fF>x_5H8nIgGIKFA1VVEo12a=|Am6~C1E>ZB0&+`=Kok&1 z05Jy;gS0a^W)`{G` *"No pluginMutex lock on the audio thread! — a single block of unprocessed audio is inaudible, whereas blocking would cause priority inversion and clicks."* + +And it's true — your audio thread does **not** acquire `pluginMutex`. That was clearly a deliberate decision, and it's the right one. You also use `std::try_to_lock` on `blockMutex` so even that can't block the audio thread. The audio thread is clean. + +Your two-tier polling system, lock-free FIFOs for MIDI/triggers/selfWrite/glide, batched single `__rt_data__` event per tick, and direct DOM manipulation in `realtime.js` are all solid. + +--- + +## The Actual Source of Your Randomize Lag + +The problem is **not** `pluginMutex` on the audio thread. The audio thread never takes it. The problem is what happens on the **message thread** when the user hits FIRE. + +### What happens when you press FIRE + +The JS `randomize()` function in `logic_blocks.js` loops over every target parameter and calls `setParamFn(p.hostId, p.realIndex, newVal)` **individually per parameter** inside the `forEach`. That calls the `setParam` native function, which calls `setHostedParam`, which: + +1. Acquires `pluginMutex` +2. Calls `params[paramIndex]->setValue(normValue)` +3. Calls `recordSelfWrite()` +4. Releases `pluginMutex` + +With 900 targets, this is **900 separate IPC bridge crossings**, each acquiring and releasing the mutex. The JUCE WebView native function bridge is not free — each crossing involves serialization, a context switch through the webview layer, and C++ execution. Doing this 900 times synchronously in a `forEach` loop is the bottleneck. + +After the loop, `refreshParamDisplay()` is called synchronously, which iterates `_dirtyParams` and rebuilds the SVG knob innerHTML for every changed parameter. With 900 dirty params that's 900 SVG string builds and 900 DOM mutations in one synchronous call. + +### The fix + +**Part 1 — Replace 900 individual `setParam` calls with one `fireRandomize` call.** + +The `fireRandomize` native function already exists in `PluginEditor.cpp` at line 317 and `randomizeParams` in `PluginProcessor.cpp` handles the whole batch in one mutex acquisition. You're just not using it from JS — the JS `randomize()` function ignores it entirely and calls `setParam` one at a time instead. + +Rewrite the instant-mode path in `randomize()` in `logic_blocks.js`: + +```javascript +function randomize(bId) { + var b = findBlock(bId); if (!b) return; + var mn = b.rMin / 100, mx = b.rMax / 100; + if (mn > mx) { var t = mn; mn = mx; mx = t; } + var isRelative = b.rangeMode === 'relative'; + var startGlideFn = (window.__JUCE__ && window.__JUCE__.backend) + ? window.__juceGetNativeFunction('startGlide') : null; + + // Collect all instant-mode targets into one batch + var instantIds = [], instantVals = []; + + b.targets.forEach(function (id) { + var p = PMap[id]; if (!p || p.lk) return; + var newVal; + if (isRelative) { + var offset = mn + Math.random() * (mx - mn); + var sign = Math.random() < 0.5 ? -1 : 1; + newVal = p.v + sign * offset; + } else { + newVal = mn + Math.random() * (mx - mn); + } + if (b.quantize && b.qSteps > 1) { + newVal = Math.round(newVal * (b.qSteps - 1)) / (b.qSteps - 1); + } + newVal = Math.max(0, Math.min(1, newVal)); + + if (b.movement === 'glide' && b.glideMs > 0) { + if (startGlideFn && p.hostId !== undefined) { + startGlideFn(p.hostId, p.realIndex, newVal, b.glideMs); + } + p.v = newVal; + _dirtyParams.add(id); + } else { + // Collect for batch — don't call setParam here + p.v = newVal; // update JS state immediately + _dirtyParams.add(id); // mark dirty for display + instantIds.push(p.realIndex); + instantVals.push(newVal); + } + }); + + // One single IPC call for all instant params + if (instantIds.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var fireRandomizeFn = window.__juceGetNativeFunction('fireRandomize'); + // Pass pre-computed values instead of letting C++ re-randomize + // OR use the existing fireRandomize with a values array (see Part 2) + } + + // Defer display refresh — don't block the call stack + requestAnimationFrame(refreshParamDisplay); +} +``` + +**Part 2 — Modify `fireRandomize` to accept pre-computed values from JS.** + +Currently `fireRandomize` re-randomizes on the C++ side, which means JS and C++ would pick different random values. You want JS to own the randomization (it already does, since it handles relative mode, quantization, etc.) and just send the results to C++ for application. + +Change the native function signature to accept `[pluginId, [[paramIndex, value], ...]]`: + +```cpp +// In PluginEditor.cpp, replace fireRandomize handler: +.withNativeFunction( + "applyParamBatch", + [this](const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, [[index, value], [index, value], ...]] + if (args.size() >= 2) + { + int pluginId = (int)args[0]; + auto& pairs = *args[1].getArray(); + + // One mutex acquisition for the entire batch + std::lock_guard lock(audioProcessor.pluginMutex); + for (auto& hp : audioProcessor.hostedPlugins) + { + if (hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + for (auto& pair : pairs) + { + int idx = (int)(*pair.getArray())[0]; + float val = (float)(double)(*pair.getArray())[1]; + if (idx >= 0 && idx < params.size()) + { + params[idx]->setValue(juce::jlimit(0.0f, 1.0f, val)); + audioProcessor.recordSelfWrite(pluginId, idx); + } + } + break; + } + } + } + completion(juce::var("ok")); + } +) +``` + +Then in JS: +```javascript +// One call, one mutex lock, one IPC crossing +var batchFn = window.__juceGetNativeFunction('applyParamBatch'); +if (batchFn && instantIds.length > 0) { + var pairs = instantIds.map(function(idx, i) { return [idx, instantVals[i]]; }); + batchFn(hostPluginId, pairs); +} +``` + +**Part 3 — Replace `refreshParamDisplay()` with `requestAnimationFrame(refreshParamDisplay)`.** + +Line 2629 in `logic_blocks.js`: +```javascript +// Before: +refreshParamDisplay(); + +// After: +requestAnimationFrame(refreshParamDisplay); +``` + +This defers the 900 SVG rebuilds to the browser's next paint cycle instead of blocking the JS call stack. The user won't notice a one-frame delay and the FIRE button will feel instant. + +--- + +## Issue 2 — CONFIRMED CRITICAL: `getHostedParams` in Tier 2 is the cause of UI lag during automation + +**File:** `PluginEditor.cpp` line 1030 + +**This is the issue that makes your UI lag when automating many parameters on plugins like FabFilter Saturn.** + +Every 10th timer tick (~6Hz), Tier 2 calls `audioProcessor.getHostedParams(plugInfo.id)` for every loaded plugin. That function acquires `pluginMutex` and then calls **both** `p->getValue()` and `p->getText(p->getValue(), 32)` on every single parameter. + +`getText` is the killer. It asks the plugin to format a human-readable display string for every parameter every 160ms. Complex plugins like Saturn do real internal work inside `getText` — unit conversion, lookup tables, formatted strings. With Saturn's parameter count this scan can easily take 5–10ms per tick, which blows your 16ms frame budget and causes dropped frames. The plugin's own UI stays smooth because FabFilter renders it on their own schedule. Your UI stutters because your timer is spending most of its budget inside Saturn's `getText` calls. + +**Verify this first** by adding timing in the Tier 2 block: + +```cpp +if (timerTickCount % 10 == 0) +{ + auto t0 = juce::Time::getMillisecondCounterHiRes(); + auto params = audioProcessor.getHostedParams(plugInfo.id); + DBG("Tier2 getHostedParams took: " + juce::String(juce::Time::getMillisecondCounterHiRes() - t0) + "ms"); + // ... +} +``` + +If that number is above 3ms you have confirmed the issue. + +**The fix — strip `getText` out of Tier 2 entirely:** + +Tier 2's only job is to detect idle parameter changes and promote them to Tier 1. It does not need display text — when a param gets promoted to Tier 1, Tier 1 already calls `getParamDisplayTextFast` on the next tick. So Tier 2 only needs the float value, and `getParamValueFast` is lock-free and cheap. + +After the first full `getHostedParams` scan per plugin (which populates `paramIdentCache`), all subsequent Tier 2 ticks should use the fast path: + +```cpp +// In timerCallback, replace the entire Tier 2 section with: +if (timerTickCount % 10 == 0) +{ + for (auto& [key, ident] : paramIdentCache) + { + // Skip params already handled by Tier 1 + if (modulatedParamKeys.count(key) > 0) continue; + if (recentlyChangedKeys.count(key) > 0) continue; + + // Lock-free float read — no getText, no mutex, no plugin callbacks + float val = audioProcessor.getParamValueFast(ident.pluginId, ident.paramIndex); + if (val < 0.0f) continue; + + auto lastIt = lastParamValues.find(key); + bool changed = (lastIt == lastParamValues.end()) + || (std::abs(val - lastIt->second) > 0.0005f); + + if (changed) + { + // Promote to Tier 1 — it will fetch display text on next tick + recentlyChangedKeys[key] = 120; + + // Auto-locate (keep existing logic) + if (lastIt != lastParamValues.end() && selfWritten.count(key) == 0) + { + float delta = std::abs(val - lastIt->second); + if (delta > 0.0005f && delta > biggestDelta) + { + biggestDelta = delta; + touchedParamId = juce::String(key); + } + } + } + lastParamValues[key] = val; + } +} +``` + +You still need one `getHostedParams` call per plugin when it first loads to populate `paramIdentCache`. After that, the fast path handles everything and Tier 2 costs near zero regardless of how many parameters the plugin has. + +--- + +## Issue 3 — CONFIRMED: `loadPlugin` blocks the message thread + +**File:** `PluginEditor.cpp` line 176, `PluginHosting.cpp` line 70 + +Your own comment says `// Load synchronously (VST3 COM requires message thread)`. Instance creation on the message thread is correct for VST3 on Windows. But the `PluginDirectoryScanner` scan loop with `scanNextFile` is pure disk I/O and does not need to be on the message thread. On a slow drive or a large plugin bundle this scan alone can freeze the UI for a second or more before instantiation even begins. + +Split it: scan on a background thread, instantiate on the message thread via `callAsync`. + +```cpp +void loadPluginAsync(const juce::String& pluginPath, + std::function onComplete) +{ + juce::Thread::launch([this, pluginPath, onComplete]() + { + // Phase 1: disk scan on background thread + juce::PluginDescription foundDesc; + bool found = false; + + // ... your existing scan logic here (knownPlugins check + scanner) ... + + if (!found) + { + juce::MessageManager::callAsync([onComplete]() { onComplete(-1); }); + return; + } + + // Phase 2: instantiation must happen on message thread + juce::MessageManager::callAsync([this, foundDesc, onComplete]() + { + juce::String err; + auto instance = formatManager.createPluginInstance( + foundDesc, currentSampleRate, currentBlockSize, err); + + if (!instance) { onComplete(-1); return; } + + // ... your existing bus layout, prepareToPlay, hostedPlugins push ... + + onComplete(newId); + }); + }); +} +``` + +The JS side should show a loading spinner immediately and hide it in the `onComplete` callback. + +--- + +## Issue 4 — MINOR: `reorderPlugins` modifies `hostedPlugins` vector while audio thread may be iterating it + +**File:** `PluginHosting.cpp` line 320 + +`reorderPlugins` acquires `pluginMutex` and does `hostedPlugins = std::move(reordered)` — this replaces the vector's internal buffer. Your audio thread in `ProcessBlock.cpp` explicitly does **not** acquire `pluginMutex`, meaning it can be in the middle of iterating `hostedPlugins` when the vector's internal pointer is replaced. + +The iterator invalidation from moving a vector while iterating it is undefined behavior. In practice it likely works because the audio thread holds a pointer to the old buffer until iteration finishes — but it is technically a data race and sanitizers will catch it. + +The safe fix is to have `reorderPlugins` post a message to the audio thread via a lock-free FIFO (similar to your `glideFifo`) rather than mutating the vector directly. In practice this is low priority since reordering only happens on user drag, not during active audio stress, but it's worth knowing. + +--- + +## Issue 5 — MINOR: `juce::Random rng` created fresh per `randomizeParams` call + +**File:** `PluginProcessor.cpp` line 649 + +`juce::Random` with no argument seeds from the system time. Two calls within the same millisecond get the same seed and produce identical sequences. Make it a member: + +```cpp +// In PluginProcessor.h: +juce::Random rng; // persistent, seeded once + +// In randomizeParams — remove: juce::Random rng; +// Just use the member rng directly +``` + +--- + +## Summary Table + +| Priority | Location | Problem | Fix | +|---|---|---|---| +| **Critical** | `PluginEditor.cpp` Tier 2 | `getText` called on every param every 160ms — causes UI lag during automation of complex plugins like Saturn | Strip `getText` from Tier 2, use `getParamValueFast` only | +| **High** | `logic_blocks.js` randomize() | 900 individual `setParam` IPC calls per FIRE | Batch into one `applyParamBatch` call | +| **High** | `logic_blocks.js` line 2629 | `refreshParamDisplay()` synchronous on 900 params | `requestAnimationFrame(refreshParamDisplay)` | +| **Medium** | `PluginEditor.cpp` loadPlugin | Disk scan blocks message thread on plugin load | Move `PluginDirectoryScanner` to background thread | +| **Low** | `PluginHosting.cpp` reorderPlugins | Vector mutation without audio thread coordination | Lock-free reorder command via FIFO | +| **Low** | `PluginProcessor.cpp` randomizeParams | `juce::Random` reseeded each call | Make `rng` a persistent member | + +--- + +## What Is Not a Problem + +- **Audio thread mutex usage** — there is none. `ProcessBlock.cpp` correctly avoids `pluginMutex` entirely. +- **`blockMutex` in processBlock** — `try_to_lock` means the audio thread never blocks. If it can't get the lock, it skips logic block processing for that buffer. One skipped buffer is inaudible. +- **The two-tier polling design** — this is correct and well-implemented. +- **The selfWriteFifo mechanism** — correct and clean. +- **Glide via lock-free FIFO** — correct. +- **Batched `__rt_data__` event** — correct. diff --git a/plugins/ModularRandomizer/Design/lane-block.html b/plugins/ModularRandomizer/Design/lane-block.html new file mode 100644 index 0000000..eaa8e05 --- /dev/null +++ b/plugins/ModularRandomizer/Design/lane-block.html @@ -0,0 +1,806 @@ + + + + + +Lane Block + + + +
+ + +
+
+
+
Block 1
+
Lane / 120 bpm / 3 params
+
+
+
+ + + +
+
+ + +
+ + + + + +
+ + + Grid +
+ + + + + +
+ +
+ + + +
+ BPM + +
+ + +
+
+
+ + +
+ + +
+ Add parameter lane
+ +
+ + + + diff --git a/plugins/ModularRandomizer/Design/morph-pad-plan.md b/plugins/ModularRandomizer/Design/morph-pad-plan.md new file mode 100644 index 0000000..897717b --- /dev/null +++ b/plugins/ModularRandomizer/Design/morph-pad-plan.md @@ -0,0 +1,787 @@ +# Morph Pad — Implementation Plan + +> **For Agent:** This document is self-contained. Follow it step-by-step. All design decisions are final. + +--- + +## ⚠️ BEFORE YOU START — MANDATORY ANALYSIS + +You MUST read and understand the following files before writing any code. This plugin is a complex, existing WebView-based JUCE 8 audio plugin with modular architecture. You are **adding a new feature** to a working codebase — do not break anything. + +### Skills to Load First (read SKILL.md in each) +1. `.agent/skills/skill_design_webview/SKILL.md` — JUCE 8 WebView2 critical patterns (member order, resource provider, CMake) +2. `.agent/skills/skill_implementation/SKILL.md` — DSP implementation rules (real-time safety, parameter handling) + +### Workflows to Reference +- `.agent/workflows/impl.md` — Implementation phase workflow +- `.agent/workflows/debug.md` — If build fails, follow this +- `.agent/workflows/test.md` — After implementation, run tests + +### Files to Analyze (read in this order) + +**1. Understand the existing architecture:** +- `plugins/ModularRandomizer/Source/PluginProcessor.h` — The `LogicBlock` struct (around line 240-310) is what you're extending. Study `ParamTarget`, `SampleData`, `GlideCommand`, `ActiveGlide`, and the readback structs (`EnvReadback`, `SampleReadback`). Your new `MorphSnapshot` and `MorphReadback` structs follow the same patterns. +- `plugins/ModularRandomizer/Source/PluginProcessor.cpp` — Study these sections: + - `processBlock()` (starts ~line 380) — the Logic Block Engine section processes existing modes. Your morph_pad branch goes as a new `else if` alongside `envelope` and `sample` modes. + - `updateLogicBlocks()` (~line 1200) — parses JSON from the UI. You need to add morph field parsing here. + - Understand how `setParamDirect()`, `getParamValue()`, `recordSelfWrite()` work — you'll call these. + - Understand the trigger detection pattern (MIDI, tempo, audio) used by randomize and sample modes — morph_pad reuses it. + +**2. Understand the existing UI:** +- `plugins/ModularRandomizer/Source/ui/public/js/state.js` — Global state declarations. No changes needed. +- `plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js` — **CRITICAL FILE**. Study: + - `addBlock(mode)` — how blocks are created with default properties + - `buildBlockCard(b, bi)` — how block cards render (mode class, header, body) + - `renderRndBody(b)` / `renderEnvBody(b)` / `renderSampleBody(b)` — how each mode renders its controls. Your `renderMorphBody(b)` follows the same pattern. + - `wireBlocks()` — how events are wired (segmented controls, sliders, toggles, selects). You add morph-specific wiring here (pad drag, snapshot management). + - `syncBlocksToHost()` — how block data is serialised to JSON and sent to C++. You add morph fields here. +- `plugins/ModularRandomizer/Source/ui/public/js/persistence.js` — How UI state is saved/restored. Add morph fields. +- `plugins/ModularRandomizer/Source/ui/public/js/realtime.js` — How real-time readback data from C++ updates the UI. Add morph playhead readback. +- `plugins/ModularRandomizer/Source/ui/public/js/controls.js` — Button handlers. Add morph button handler. +- `plugins/ModularRandomizer/Source/ui/public/index.html` — Main HTML shell. Add the morph button. + +**3. Understand the Editor (C++ → JS bridge):** +- `plugins/ModularRandomizer/Source/PluginEditor.h` — Editor class structure +- `plugins/ModularRandomizer/Source/PluginEditor.cpp` — The `timerCallback()` sends `__rt_data__` JSON to the WebView every tick. You add morph readback data here. + +**4. Understand the styling:** +- `plugins/ModularRandomizer/Source/ui/public/css/variables.css` — CSS custom properties (theme colors) +- `plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css` — Existing block card styles +- `plugins/ModularRandomizer/Source/ui/public/js/theme_system.js` — Theme definitions with CSS variable overrides + +**5. Understand the build system:** +- `plugins/ModularRandomizer/CMakeLists.txt` — If you add new CSS or JS files, they must be added to `juce_add_binary_data()`. + +--- + +## Overview + +A new logic block mode `morph_pad` — the **4th mode** alongside `randomize`, `envelope`, and `sample`. Users save parameter snapshots as dots on a 2D XY pad. A playhead dot travels through the space, interpolating parameter values using Inverse Distance Weighting (IDW) based on proximity to snapshots. + +--- + +## Design Decisions (All Final) + +| Question | Decision | +|----------|----------| +| Step trigger | **Both** ordered cycle AND random — as a segmented option | +| LFO mode | **Multiple shapes** — Circle, Figure-8, Sweep X, Sweep Y | +| Max snapshots | **12** | +| Empty state | **Allowed** — 0 snaps = passthrough (no processing), pad shows placeholder | +| Snapshot values | **Keyed by param ID** — survives target add/remove | +| Button layout | **4 buttons** in same row, shrink the first 3 to fit Morph Pad | + +--- + +## Implementation Order + +**Do these in order. Build and test between phases.** + +1. Phase 1: CSS + Theme (safe, no logic changes) +2. Phase 2: Frontend JS (UI only, no C++ changes) +3. Phase 3: Backend C++ (PluginProcessor.h/cpp) +4. Phase 4: Editor bridge (PluginEditor.cpp rt_data) +5. Phase 5: Persistence (save/restore) +6. Phase 6: Build + Test + +--- + +## Phase 1: CSS + Theme + +### 1.1 `css/variables.css` + +Add morph color: +```css +--morph-color: #5C6BC0; +``` + +### 1.2 `css/logic_blocks.css` + +Add these styles: +```css +/* Morph pad mode class */ +.lcard.mode-morph { } +.lcard.mode-morph.active { border-color: var(--morph-color); } + +/* Shrink add buttons to fit 4 in row */ +.add-blk { font-size: 10px; padding: 4px 6px; } + +/* XY Pad container */ +.morph-pad { + width: 100%; + height: 160px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + position: relative; + overflow: hidden; + cursor: crosshair; + background-image: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px); + background-size: 25% 25%; + background-position: center center; +} +.morph-pad .empty-label { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 10px; + pointer-events: none; +} + +/* Snapshot dots */ +.snap-dot { + width: 10px; height: 10px; + background: var(--text-muted); + border: 1px solid var(--border-strong); + border-radius: 50%; + position: absolute; + cursor: grab; + transform: translate(-50%, -50%); + transition: background 0.15s; + z-index: 2; +} +.snap-dot:hover { background: var(--text-primary); } +.snap-dot.active { background: var(--morph-color); border-color: var(--morph-color); } +.snap-dot .snap-label { + position: absolute; + bottom: 12px; left: 50%; + transform: translateX(-50%); + font-size: 8px; + color: var(--text-muted); + white-space: nowrap; + pointer-events: none; +} + +/* Playhead dot */ +.playhead-dot { + width: 14px; height: 14px; + background: var(--morph-color); + border: 2px solid var(--text-primary); + border-radius: 50%; + position: absolute; + transform: translate(-50%, -50%); + z-index: 5; + box-shadow: 0 0 8px var(--morph-color); + pointer-events: none; +} +.playhead-dot.manual { cursor: grab; pointer-events: auto; } + +/* Snapshot chips */ +.snap-chips { + display: flex; flex-wrap: wrap; + gap: 3px; margin-top: 4px; + align-items: center; +} +.snap-chip { + font-size: 9px; padding: 2px 6px; + border-radius: 3px; + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + display: flex; align-items: center; gap: 3px; +} +.snap-chip:hover { background: var(--bg-cell-hover); } +.snap-chip .snap-del { cursor: pointer; opacity: 0.5; } +.snap-chip .snap-del:hover { opacity: 1; color: var(--locked-icon); } +.snap-add-btn { + font-size: 9px; padding: 2px 8px; + border-radius: 3px; + background: var(--morph-color); + border: 1px solid var(--morph-color); + color: white; + cursor: pointer; +} +``` + +### 1.3 `js/theme_system.js` + +Add `--morph-color` to every theme definition: +``` +earthy: #5C6BC0 +midnight: #7C4DFF +clinical: #42A5F5 +forest: #66BB6A +volcanic: #FF7043 +arctic: #26C6DA +``` + +--- + +## Phase 2: Frontend JS + +### 2.1 `index.html` + +Add button in the `.add-wrap` div (alongside existing 3 buttons): +```html + +``` + +### 2.2 `js/controls.js` + +Add handler: +```js +document.getElementById('addMorph').onclick = function () { addBlock('morph_pad'); }; +``` + +### 2.3 `js/logic_blocks.js` — Main Changes + +#### `addBlock(mode)` — Add morph_pad defaults + +When `mode === 'morph_pad'`, the block object gets these additional properties: +```js +snapshots: [], // [{x, y, values: {paramId: value}, name: 'Snap 1'}] +playheadX: 0.5, +playheadY: 0.5, +morphMode: 'manual', // 'manual', 'auto', 'trigger' +exploreMode: 'wander', // 'wander', 'bounce', 'lfo' +lfoShape: 'circle', // 'circle', 'figure8', 'sweepX', 'sweepY' +morphSpeed: 50, // 0..100 +morphAction: 'jump', // 'jump', 'step' +stepOrder: 'cycle', // 'cycle', 'random' +morphSource: 'midi', // 'midi', 'tempo', 'audio' +// Trigger sub-options reuse existing: beatDiv, midiMode, midiNote, midiCC, midiCh, threshold, audioSrc +jitter: 0, // 0..100 +morphGlide: 200 // 1..2000ms +``` + +#### `buildBlockCard(b, bi)` — Add morph mode + +- Add `'morph_pad'` to the mode segmented control +- Add mode class: `b.mode === 'morph_pad' ? ' mode-morph' : ...` +- In summary: `'Morph / ' + morphModeLabel + ' / ' + b.snapshots.length + ' snaps'` +- Call `renderMorphBody(b)` when `b.mode === 'morph_pad'` + +#### `renderMorphBody(b)` — New function + +Renders the morph pad UI within the block card body. Structure: + +``` +1. XY Pad (.morph-pad) + - If no snapshots: show ".empty-label" with "Add a snapshot to begin" + - For each snapshot: .snap-dot positioned at {x%, (1-y)*100%} + - If snapshots exist: .playhead-dot at {playheadX%, (1-playheadY)*100%} + +2. Snapshot chips (.snap-chips) + - For each snapshot: .snap-chip with name + delete (×) button + - "+ Snap" button (.snap-add-btn) — disabled if snapshots.length >= 12 + +3. Play Mode: segmented [Manual] [Auto] [Trigger] + +4. Auto sub-panel (visible when morphMode === 'auto'): + - Explore: segmented [Wander] [Bounce] [LFO] + - LFO Shape sub (visible when exploreMode === 'lfo'): + segmented [Circle] [Figure-8] [Sweep X] [Sweep Y] + - Speed: slider 0..100 + +5. Trigger sub-panel (visible when morphMode === 'trigger'): + - Action: segmented [Jump] [Step] + - Step Order sub (visible when morphAction === 'step'): + segmented [Cycle] [Random] + - Source: segmented [MIDI] [Tempo] [Audio] + - Source sub-options (reuse existing pattern from renderRndBody): + - MIDI: mode select, note/CC input, channel select + - Tempo: division select + - Audio: source select, threshold input + +6. Modifiers: + - Jitter: slider 0..100% + - Glide: slider 1..2000ms +``` + +#### `wireBlocks()` — Add morph event wiring + +**Playhead drag** (manual mode only): +- `mousedown` on `.playhead-dot.manual` → start drag +- `mousemove` on `.morph-pad` → update `b.playheadX`, `b.playheadY` based on mouse position relative to pad bounds +- `mouseup` → end drag, call `syncBlocksToHost()` + +**Snapshot dot drag:** +- `mousedown` on `.snap-dot` → start drag +- `mousemove` → update snapshot x,y +- `mouseup` → end drag, call `syncBlocksToHost()` + +**"+ Snap" button:** +- Read current values from `PMap` for all targets in the block +- Create snapshot object: `{ x: b.playheadX, y: b.playheadY, values: capturedValues, name: 'Snap N' }` +- Push to `b.snapshots`, cap at 12 +- Re-render block, sync to host + +**Snapshot chip click:** +- Jump playhead to that snapshot's x,y position +- Update playhead dot display + +**Snapshot delete (×):** +- Remove from array +- Re-render, sync to host + +#### `syncBlocksToHost()` — Add morph fields + +For morph_pad blocks, add to the serialised object: +```js +snapshots: b.snapshots.map(function(s) { + // Convert values object to array aligned with current targets + var vals = []; + b.targets.forEach(function(pid) { + vals.push(s.values[pid] !== undefined ? s.values[pid] : 0.5); + }); + return { x: s.x, y: s.y, targetValues: vals }; +}), +playheadX: b.playheadX, +playheadY: b.playheadY, +morphMode: b.morphMode || 'manual', +exploreMode: b.exploreMode || 'wander', +lfoShape: b.lfoShape || 'circle', +morphSpeed: (b.morphSpeed || 50) / 100, // normalise 0..1 +morphAction: b.morphAction || 'jump', +stepOrder: b.stepOrder || 'cycle', +morphSource: b.morphSource || 'midi', +jitter: (b.jitter || 0) / 100, // normalise 0..1 +morphGlide: b.morphGlide || 200 +``` + +### 2.4 `js/realtime.js` — Morph playhead readback + +In the `__rt_data__` handler, add: +```js +if (data.morphHeads) { + for (var mi = 0; mi < data.morphHeads.length; mi++) { + var mh = data.morphHeads[mi]; + var mb = findBlock(mh.id); + if (mb && mb.mode === 'morph_pad' && mb.morphMode !== 'manual') { + mb.playheadX = mh.x; + mb.playheadY = mh.y; + // Direct DOM update (no full re-render) + var dot = document.getElementById('morphHead-' + mh.id); + if (dot) { + dot.style.left = (mh.x * 100) + '%'; + dot.style.top = ((1 - mh.y) * 100) + '%'; + } + } + } +} +``` + +--- + +## Phase 3: Backend C++ + +### 3.1 `PluginProcessor.h` — Extend LogicBlock + +Add inside the `LogicBlock` struct: +```cpp +// ── Morph Pad ── +struct MorphSnapshot { + float x = 0.5f, y = 0.5f; + std::vector targetValues; // aligned with targets array +}; +std::vector snapshots; +float playheadX = 0.5f, playheadY = 0.5f; +juce::String morphMode; // "manual", "auto", "trigger" +juce::String exploreMode; // "wander", "bounce", "lfo" +juce::String lfoShape; // "circle", "figure8", "sweepX", "sweepY" +float morphSpeed = 0.5f; // 0..1 +juce::String morphAction; // "jump", "step" +juce::String stepOrder; // "cycle", "random" +juce::String morphSource; // "midi", "tempo", "audio" +float jitter = 0.0f; // 0..1 +float morphGlide = 200.0f; // ms + +// Morph runtime state (audio thread only, preserved across updateLogicBlocks) +float morphVelX = 0.0f, morphVelY = 0.0f; // wander/bounce velocity +float morphAngle = 0.0f; // bounce angle (radians) +float morphLfoPhase = 0.0f; // LFO phase +int morphStepIndex = 0; // step trigger index +float morphSmoothX = 0.5f, morphSmoothY = 0.5f; // smoothed playhead +``` + +Add readback struct (follow the same pattern as `EnvReadback` and `SampleReadback`): +```cpp +static constexpr int maxMorphReadback = 8; +struct MorphReadback { + std::atomic blockId { -1 }; + std::atomic headX { 0.5f }; + std::atomic headY { 0.5f }; +}; +MorphReadback morphReadback[maxMorphReadback]; +std::atomic numActiveMorphBlocks { 0 }; +``` + +### 3.2 `PluginProcessor.cpp` — updateLogicBlocks() + +Add morph field parsing after the existing sample modulator parsing: +```cpp +// Morph Pad settings +lb.morphMode = obj->getProperty("morphMode").toString(); +lb.exploreMode = obj->getProperty("exploreMode").toString(); +lb.lfoShape = obj->getProperty("lfoShape").toString(); +lb.morphSpeed = (float)(double) obj->getProperty("morphSpeed"); +lb.morphAction = obj->getProperty("morphAction").toString(); +lb.stepOrder = obj->getProperty("stepOrder").toString(); +lb.morphSource = obj->getProperty("morphSource").toString(); +lb.playheadX = (float)(double) obj->getProperty("playheadX"); +lb.playheadY = (float)(double) obj->getProperty("playheadY"); +lb.jitter = (float)(double) obj->getProperty("jitter"); +lb.morphGlide = (float)(double) obj->getProperty("morphGlide"); + +// Defaults +if (lb.morphMode.isEmpty()) lb.morphMode = "manual"; +if (lb.exploreMode.isEmpty()) lb.exploreMode = "wander"; +if (lb.lfoShape.isEmpty()) lb.lfoShape = "circle"; +if (lb.morphAction.isEmpty()) lb.morphAction = "jump"; +if (lb.stepOrder.isEmpty()) lb.stepOrder = "cycle"; +if (lb.morphSource.isEmpty()) lb.morphSource = "midi"; +if (lb.morphGlide <= 0.0f) lb.morphGlide = 200.0f; + +// Parse snapshots array +auto snapsVar = obj->getProperty("snapshots"); +if (snapsVar.isArray()) { + for (int si = 0; si < snapsVar.size() && si < 12; ++si) { + if (auto* sObj = snapsVar[si].getDynamicObject()) { + LogicBlock::MorphSnapshot snap; + snap.x = (float)(double) sObj->getProperty("x"); + snap.y = (float)(double) sObj->getProperty("y"); + auto valsVar = sObj->getProperty("targetValues"); + if (valsVar.isArray()) { + for (int vi = 0; vi < valsVar.size(); ++vi) + snap.targetValues.push_back((float)(double) valsVar[vi]); + } + lb.snapshots.push_back(snap); + } + } +} +``` + +In the "preserve runtime state" section, add: +```cpp +lb.morphVelX = existing.morphVelX; +lb.morphVelY = existing.morphVelY; +lb.morphAngle = existing.morphAngle; +lb.morphLfoPhase = existing.morphLfoPhase; +lb.morphStepIndex = existing.morphStepIndex; +lb.morphSmoothX = existing.morphSmoothX; +lb.morphSmoothY = existing.morphSmoothY; +``` + +### 3.3 `PluginProcessor.cpp` — processBlock() morph_pad branch + +Add a new `else if` block after the sample mode processing, inside the Logic Block Engine section. The morph_pad counter variable `morphIdx` should be `int morphIdx = 0;` declared alongside `envIdx` and `smpIdx`. + +```cpp +else if (lb.mode == "morph_pad" && !lb.snapshots.empty() && lb.enabled) +{ + float targetX = lb.playheadX; + float targetY = lb.playheadY; + + // ── Trigger detection (for trigger mode) ── + bool shouldTrigger = false; + if (lb.morphMode == "trigger") + { + juce::String src = lb.morphSource; + + if (src == "midi") { + // Reuse existing MIDI trigger pattern (same as randomize/sample) + for (int ri = 0; ri < midiCount; ++ri) { + auto& ev = midiRing[((readPos + ri) % midiRingSize)]; + if (lb.midiCh > 0 && ev.channel != lb.midiCh) continue; + if (lb.midiMode == "any_note" && !ev.isCC) { shouldTrigger = true; break; } + if (lb.midiMode == "specific_note" && !ev.isCC && ev.note == lb.midiNote) { shouldTrigger = true; break; } + if (lb.midiMode == "cc" && ev.isCC && ev.note == lb.midiCC) { shouldTrigger = true; break; } + } + } + if (src == "tempo" && playing) { + float bpt = beatsPerTrig(lb.beatDiv); + int currentBeat = (int) std::floor(ppq / bpt); + if (lb.lastBeat < 0) lb.lastBeat = currentBeat; + if (currentBeat != lb.lastBeat) { lb.lastBeat = currentBeat; shouldTrigger = true; } + } + if (src == "audio") { + float audioLvl = (lb.audioSrc == "sidechain") ? scRms : mainRms; + float threshLin = std::pow(10.0f, lb.threshold / 20.0f); + double cooldownSamples = currentSampleRate * 0.1; + if (audioLvl > threshLin && (sampleCounter - lb.lastAudioTrigSample) > cooldownSamples) { + lb.lastAudioTrigSample = sampleCounter; + shouldTrigger = true; + } + } + } + + // ── Auto-Explore mode ── + if (lb.morphMode == "auto") + { + float speed = lb.morphSpeed * 0.02f; + + if (lb.exploreMode == "wander") { + lb.morphVelX += (audioRandom.nextFloat() - 0.5f) * speed * 0.5f; + lb.morphVelY += (audioRandom.nextFloat() - 0.5f) * speed * 0.5f; + lb.morphVelX *= 0.95f; + lb.morphVelY *= 0.95f; + targetX = juce::jlimit(0.0f, 1.0f, lb.playheadX + lb.morphVelX); + targetY = juce::jlimit(0.0f, 1.0f, lb.playheadY + lb.morphVelY); + if (targetX < 0.05f) lb.morphVelX += 0.01f; + if (targetX > 0.95f) lb.morphVelX -= 0.01f; + if (targetY < 0.05f) lb.morphVelY += 0.01f; + if (targetY > 0.95f) lb.morphVelY -= 0.01f; + } + else if (lb.exploreMode == "bounce") { + float dx = std::cos(lb.morphAngle) * speed; + float dy = std::sin(lb.morphAngle) * speed; + targetX = lb.playheadX + dx; + targetY = lb.playheadY + dy; + if (targetX < 0.0f || targetX > 1.0f) { + lb.morphAngle = juce::MathConstants::pi - lb.morphAngle; + targetX = juce::jlimit(0.0f, 1.0f, targetX); + } + if (targetY < 0.0f || targetY > 1.0f) { + lb.morphAngle = -lb.morphAngle; + targetY = juce::jlimit(0.0f, 1.0f, targetY); + } + } + else if (lb.exploreMode == "lfo") { + float rate = speed * 0.1f; + lb.morphLfoPhase += rate; + if (lb.morphLfoPhase > juce::MathConstants::twoPi) + lb.morphLfoPhase -= juce::MathConstants::twoPi; + + if (lb.lfoShape == "circle") { + targetX = 0.5f + 0.4f * std::cos(lb.morphLfoPhase); + targetY = 0.5f + 0.4f * std::sin(lb.morphLfoPhase); + } else if (lb.lfoShape == "figure8") { + targetX = 0.5f + 0.4f * std::sin(lb.morphLfoPhase); + targetY = 0.5f + 0.4f * std::sin(lb.morphLfoPhase * 2.0f); + } else if (lb.lfoShape == "sweepX") { + targetX = 0.5f + 0.4f * std::sin(lb.morphLfoPhase); + targetY = lb.playheadY; + } else if (lb.lfoShape == "sweepY") { + targetX = lb.playheadX; + targetY = 0.5f + 0.4f * std::sin(lb.morphLfoPhase); + } + } + + lb.playheadX = targetX; + lb.playheadY = targetY; + } + + // ── Trigger mode — apply jump/step on trigger ── + if (lb.morphMode == "trigger" && shouldTrigger) + { + int numSnaps = (int) lb.snapshots.size(); + if (lb.morphAction == "jump") { + int ri = audioRandom.nextInt(numSnaps); + targetX = lb.snapshots[ri].x; + targetY = lb.snapshots[ri].y; + } else if (lb.morphAction == "step") { + if (lb.stepOrder == "cycle") + lb.morphStepIndex = (lb.morphStepIndex + 1) % numSnaps; + else + lb.morphStepIndex = audioRandom.nextInt(numSnaps); + targetX = lb.snapshots[lb.morphStepIndex].x; + targetY = lb.snapshots[lb.morphStepIndex].y; + } + lb.playheadX = targetX; + lb.playheadY = targetY; + + // Fire trigger notification to UI + const auto tScope = triggerFifo.write(1); + if (tScope.blockSize1 > 0) triggerRing[tScope.startIndex1] = lb.id; + else if (tScope.blockSize2 > 0) triggerRing[tScope.startIndex2] = lb.id; + } + + // ── Apply jitter ── + float finalX = lb.playheadX; + float finalY = lb.playheadY; + if (lb.jitter > 0.001f) { + finalX += (audioRandom.nextFloat() - 0.5f) * lb.jitter * 0.2f; + finalY += (audioRandom.nextFloat() - 0.5f) * lb.jitter * 0.2f; + finalX = juce::jlimit(0.0f, 1.0f, finalX); + finalY = juce::jlimit(0.0f, 1.0f, finalY); + } + + // ── Smooth playhead (glide) ── + float glideCoeff = std::exp(-1.0f / std::max(1.0f, lb.morphGlide * 0.001f * bufferRate)); + lb.morphSmoothX = glideCoeff * lb.morphSmoothX + (1.0f - glideCoeff) * finalX; + lb.morphSmoothY = glideCoeff * lb.morphSmoothY + (1.0f - glideCoeff) * finalY; + + // ── IDW Interpolation ── + // Compute weights based on playhead distance to each snapshot + float totalWeight = 0.0f; + std::vector weights(lb.snapshots.size(), 0.0f); + for (size_t si = 0; si < lb.snapshots.size(); ++si) { + float dx = lb.morphSmoothX - lb.snapshots[si].x; + float dy = lb.morphSmoothY - lb.snapshots[si].y; + float dist = std::sqrt(dx * dx + dy * dy); + float w = 1.0f / (dist + 0.001f); + weights[si] = w; + totalWeight += w; + } + if (totalWeight > 0.0f) + for (auto& w : weights) w /= totalWeight; + + // ── Mix target values and apply ── + for (size_t ti = 0; ti < lb.targets.size(); ++ti) { + float mixed = 0.0f; + for (size_t si = 0; si < lb.snapshots.size(); ++si) { + if (ti < lb.snapshots[si].targetValues.size()) + mixed += weights[si] * lb.snapshots[si].targetValues[ti]; + } + mixed = juce::jlimit(0.0f, 1.0f, mixed); + setParamDirect(lb.targets[ti].pluginId, lb.targets[ti].paramIndex, mixed); + } + + // ── Write playhead readback for UI ── + if (morphIdx < maxMorphReadback) { + morphReadback[morphIdx].blockId.store(lb.id); + morphReadback[morphIdx].headX.store(lb.morphSmoothX); + morphReadback[morphIdx].headY.store(lb.morphSmoothY); + morphIdx++; + } +} +``` + +After the loop, store the count: +```cpp +numActiveMorphBlocks.store(morphIdx); +``` + +### ⚠️ REAL-TIME SAFETY NOTE + +The `std::vector weights(...)` inside processBlock is a **heap allocation**. For production safety, consider using a fixed-size array: +```cpp +float weights[12] = {}; // max 12 snapshots +int numSnaps = juce::jmin((int) lb.snapshots.size(), 12); +``` +This avoids audio-thread allocation entirely. + +--- + +## Phase 4: Editor Bridge + +### 4.1 `PluginEditor.cpp` — timerCallback() + +In the section that builds `__rt_data__` JSON, add morph readback (follow the same pattern as envelope and sample readback): + +```cpp +int numMorph = processor.numActiveMorphBlocks.load(); +if (numMorph > 0) { + auto morphArr = juce::Array(); + for (int i = 0; i < numMorph; ++i) { + auto* obj = new juce::DynamicObject(); + obj->setProperty("id", processor.morphReadback[i].blockId.load()); + obj->setProperty("x", (double) processor.morphReadback[i].headX.load()); + obj->setProperty("y", (double) processor.morphReadback[i].headY.load()); + morphArr.add(juce::var(obj)); + } + rtObj->setProperty("morphHeads", morphArr); +} +``` + +--- + +## Phase 5: Persistence + +### 5.1 `js/persistence.js` + +In `saveUiStateToHost()`, add morph fields to block serialisation: +```js +snapshots: b.snapshots, // full objects with {x, y, values, name} +playheadX: b.playheadX, +playheadY: b.playheadY, +morphMode: b.morphMode, +exploreMode: b.exploreMode, +lfoShape: b.lfoShape, +morphSpeed: b.morphSpeed, +morphAction: b.morphAction, +stepOrder: b.stepOrder, +morphSource: b.morphSource, +jitter: b.jitter, +morphGlide: b.morphGlide +``` + +In `restoreFromHost()`, restore morph fields with defaults: +```js +snapshots: bd.snapshots || [], +playheadX: bd.playheadX !== undefined ? bd.playheadX : 0.5, +playheadY: bd.playheadY !== undefined ? bd.playheadY : 0.5, +morphMode: bd.morphMode || 'manual', +exploreMode: bd.exploreMode || 'wander', +lfoShape: bd.lfoShape || 'circle', +morphSpeed: bd.morphSpeed !== undefined ? bd.morphSpeed : 50, +morphAction: bd.morphAction || 'jump', +stepOrder: bd.stepOrder || 'cycle', +morphSource: bd.morphSource || 'midi', +jitter: bd.jitter || 0, +morphGlide: bd.morphGlide || 200 +``` + +--- + +## Phase 6: Build + Test + +### 6.1 Check CMakeLists.txt + +Verify all new/modified CSS and JS files are listed in `juce_add_binary_data()`. If you added any new files (unlikely since you're modifying existing ones), add them. + +### 6.2 Build + +```powershell +.\scripts\build-and-install.ps1 -PluginName ModularRandomizer +``` + +### 6.3 Test Checklist + +- [ ] Plugin loads without crash +- [ ] Morph Pad button appears in add-wrap bar alongside other 3 buttons +- [ ] All 4 buttons fit without overflow +- [ ] Clicking "+ Morph Pad" creates a new block with morph_pad mode +- [ ] Mode segmented control shows all 4 modes (Randomize, Envelope, Sample, Morph Pad) +- [ ] XY pad renders with grid lines +- [ ] "Add a snapshot to begin" shows when no snapshots +- [ ] "+ Snap" captures current target values and places dot +- [ ] Snapshot dots are draggable within the pad +- [ ] Playhead dot is draggable in Manual mode +- [ ] Playhead dot is NOT draggable in Auto/Trigger modes +- [ ] Auto mode moves playhead (wander/bounce/lfo) +- [ ] Trigger mode responds to MIDI/tempo/audio +- [ ] Jitter slider randomises playhead position slightly +- [ ] Glide slider smooths parameter transitions +- [ ] Snapshots can be deleted (including all of them) +- [ ] 0 snapshots = passthrough (no parameter changes) +- [ ] Plugin state saves and restores correctly (close/reopen DAW project) +- [ ] Existing block modes (randomize, envelope, sample) still work correctly +- [ ] Plugin unloads without crash + +--- + +## Snapshot Value Storage Format + +Each snapshot stores values as an **object keyed by param ID** (in JS): +```js +snapshot.values = { + "1:3": 0.75, // pluginId:paramIndex = value + "1:7": 0.20, + "2:0": 0.50 +} +``` + +When syncing to C++, this is flattened to an array aligned with the block's current targets order. If a snapshot doesn't have a value for a target (param added after snapshot was created), it falls back to `0.5` (centre). When a param is removed from targets, the snapshot's extra keys are simply ignored — no data loss. + +--- + +## File Change Summary + +| File | Scope | What Changes | +|------|-------|--------------| +| `index.html` | Minor | Add `+ Morph Pad` button | +| `css/variables.css` | Minor | Add `--morph-color` | +| `css/logic_blocks.css` | **Major** | Morph pad, dot, chip, button styles | +| `js/theme_system.js` | Medium | `--morph-color` per theme | +| `js/logic_blocks.js` | **Major** | `renderMorphBody()`, `addBlock()`, `buildBlockCard()`, `wireBlocks()`, `syncBlocksToHost()` | +| `js/controls.js` | Minor | Button click handler | +| `js/persistence.js` | Medium | Save/restore morph fields | +| `js/realtime.js` | Medium | Morph playhead readback | +| `PluginProcessor.h` | Medium | Extend LogicBlock struct + MorphReadback | +| `PluginProcessor.cpp` | **Major** | JSON parse + processBlock morph branch | +| `PluginEditor.cpp` | Medium | Morph readback in `__rt_data__` | diff --git a/plugins/ModularRandomizer/Design/v1-style-guide.md b/plugins/ModularRandomizer/Design/v1-style-guide.md new file mode 100644 index 0000000..f930c53 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v1-style-guide.md @@ -0,0 +1,145 @@ +# Style Guide v1 — Modular Randomizer + +## Design Language +**Ableton Live.** Flat surfaces, muted colors, functional typography. Every pixel earns its place. + +--- + +## Color Palette + +### Core +| Token | Hex | Usage | +|:---|:---|:---| +| `--bg-deep` | `#111111` | App background | +| `--bg-surface` | `#1A1A1A` | Panel backgrounds | +| `--bg-elevated` | `#242424` | Cards, cells, inputs | +| `--bg-hover` | `#2A2A2A` | Hover states | +| `--bg-active` | `#333333` | Active/pressed states | + +### Text +| Token | Hex | Usage | +|:---|:---|:---| +| `--text-primary` | `#CCCCCC` | Primary labels | +| `--text-secondary` | `#888888` | Secondary info, values | +| `--text-muted` | `#555555` | Disabled, placeholder | + +### Accent +| Token | Hex | Usage | +|:---|:---|:---| +| `--accent` | `#FF5500` | Primary accent (Ableton orange) | +| `--accent-hover` | `#FF6E2B` | Accent hover | +| `--accent-muted` | `#FF550033` | Accent at 20% opacity — subtle highlights | + +### Semantic +| Token | Hex | Usage | +|:---|:---|:---| +| `--locked` | `#FF3B3B` | Locked parameter indicator | +| `--auto-locked` | `#FF3B3B44` | Auto-detected lock (striped bg) | +| `--connected` | `#FF5500` | Assigned parameter border | +| `--border` | `#2A2A2A` | Default borders | +| `--border-focus` | `#444444` | Focused element borders | + +--- + +## Typography + +| Element | Font | Size | Weight | Color | +|:---|:---|:---|:---|:---| +| Plugin title | Inter | 13px | 600 | `--text-primary` | +| Section header | Inter | 11px | 600 | `--text-secondary` | +| Parameter name | Inter | 10px | 500 | `--text-primary` | +| Parameter value | JetBrains Mono | 10px | 400 | `--text-secondary` | +| Button label | Inter | 10px | 600 | `--text-primary` | +| Status bar | JetBrains Mono | 10px | 400 | `--text-muted` | + +**Font stack:** `'Inter', system-ui, -apple-system, sans-serif` +**Mono stack:** `'JetBrains Mono', 'SF Mono', 'Consolas', monospace` + +--- + +## Spacing System + +Base unit: **4px** + +| Token | Value | Usage | +|:---|:---|:---| +| `--space-xs` | 4px | Tight gaps between inline elements | +| `--space-sm` | 8px | Padding inside small components | +| `--space-md` | 12px | Standard padding | +| `--space-lg` | 16px | Panel padding | +| `--space-xl` | 24px | Section gaps | + +--- + +## Component Styles + +### Parameter Cell +- Size: 100px × 52px +- Background: `--bg-elevated` +- Border: 1px solid `--border` +- Border-radius: 3px +- Hover: background → `--bg-hover` +- Selected: border → `--accent`, background → `--accent-muted` +- Locked: overlay with `🔒`, background → `--bg-elevated` with diagonal stripe pattern +- Value display: monospace, centered below name + +### Logic Block Panel +- Background: `--bg-surface` +- Border: 1px solid `--border` +- Border-radius: 4px +- Padding: `--space-md` +- Header: section title + close button, bottom border + +### Toggle Switch (3-way: Manual | Tempo | Audio) +- Background: `--bg-deep` +- Active segment: `--accent` background +- Inactive: `--bg-elevated` +- Border-radius: 2px +- Height: 24px + +### Range Slider (dual handle) +- Track: `--bg-deep`, height 4px, rounded +- Active range: `--accent` +- Handle: 12px circle, `--text-primary` fill +- Labels: monospace values at min/max ends + +### Fire Button +- Background: `--accent` +- Text: `#FFFFFF` +- Width: 100% +- Height: 32px +- Border-radius: 3px +- Hover: `--accent-hover` +- Active: scale(0.98) + +### Target Tags +- Background: `--accent-muted` +- Border: 1px solid `--accent` +- Text: `--accent` +- Padding: 2px 8px +- Border-radius: 2px +- Font-size: 9px +- Includes × remove button + +--- + +## Animation + +| Property | Duration | Easing | +|:---|:---|:---| +| Hover transitions | 100ms | ease-out | +| Selection highlight | 150ms | ease-out | +| Fire button pulse | 300ms | ease-out (opacity flash) | +| Panel expand | 200ms | ease-out | + +**Rule:** No animation longer than 300ms. No bounce. No spring physics. Instant feedback. + +--- + +## Iconography + +- **Lock:** Unicode 🔒 or simple SVG padlock (10px) +- **Close:** × character, `--text-muted`, hover → `--text-primary` +- **Bypass:** ⏻ power symbol +- **Add:** + character in circle +- No icon library dependency. All inline SVG or Unicode. diff --git a/plugins/ModularRandomizer/Design/v1-test.html b/plugins/ModularRandomizer/Design/v1-test.html new file mode 100644 index 0000000..92a3959 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v1-test.html @@ -0,0 +1,1366 @@ + + + + + + Modular Randomizer + + + +
+ + +
+
+ + MODULAR RANDOMIZER +
+ +
+ +
+ + + +
+ +
+ +
+ + + 100% +
+
+
+ + +
+ + +
+
+ Parameters +
+ + +
+
+
+
+ +
+
+
+ + +
+
+ Logic Blocks +
+
+ +
+
+
+ + +
+ Serum + 32 params + 3 locked + 1 block + 120 BPM +
+
+ + +
+
Lock Parameter
+
Unlock Parameter
+
+
Select
+
Deselect
+
+ + + + diff --git a/plugins/ModularRandomizer/Design/v1-ui-spec.md b/plugins/ModularRandomizer/Design/v1-ui-spec.md new file mode 100644 index 0000000..ad4b4cf --- /dev/null +++ b/plugins/ModularRandomizer/Design/v1-ui-spec.md @@ -0,0 +1,75 @@ +# UI Specification v1 — Modular Randomizer + +## Design Direction +**Ableton Live inspired.** Flat, functional, muted. The UI works, it doesn't decorate. +No node graph. No cables. No skeuomorphism. Parameters in a grid, logic blocks as assignable panels. + +## Window +- **Size:** 900 × 600px +- **Resizable:** No (fixed) +- **Framework:** WebView2 + +## Layout Structure + +``` +┌────────────────────────────────────────────────────────┐ +│ HEADER BAR (40px) │ +│ [Logo] MODULAR RANDOMIZER [Plugin: Serum ▾] [⏻] [Mix]│ +├──────────────────────────┬─────────────────────────────┤ +│ PARAMETER GRID │ LOGIC BLOCKS │ +│ (scrollable, 520px wide) │ (scrollable, 380px wide) │ +│ │ │ +│ ┌──────┐ ┌──────┐ ┌────┐│ ┌─────────────────────────┐ │ +│ │Cutoff│ │Reso │ │Vol ▒││ │ BLOCK 1 [×]│ │ +│ │ 0.45 │ │ 0.30 │ │🔒 ▒││ │ Trigger: [Man|Tmp|Aud] │ │ +│ └──────┘ └──────┘ └────┘││ │ Range: ====●==== │ │ +│ ┌──────┐ ┌──────┐ ┌────┐││ │ Quantize: [off] │ │ +│ │Drive │ │WavTbl│ │Mix ││ │ Move: [Instant|Glide] │ │ +│ │ 0.70 │ │ 0.00 │ │0.50 ││ │ Glide: ===●===== │ │ +│ └──────┘ └──────┘ └────┘││ │ Targets: Cutoff, Reso │ │ +│ ││ │ [FIRE] │ │ +│ ... more params ... ││ └─────────────────────────┘ │ +│ ││ │ +│ ││ [+ Add Logic Block] │ +├──────────────────────────┴─────────────────────────────┤ +│ STATUS BAR (24px) │ +│ 32 params · 3 locked · 1 block · 120 BPM │ +└────────────────────────────────────────────────────────┘ +``` + +## Interaction Model (Simplified) + +### Loading a Plugin +1. Click plugin selector dropdown in header +2. Browse/search installed VST3 plugins +3. Select → plugin loads → parameters populate the grid + +### Assigning Parameters to a Logic Block +1. Click a parameter cell in the grid → it highlights +2. Click another → multi-select (shift-click for range) +3. Selected params automatically appear as "Targets" on the active Logic Block +- **Or:** Click "Assign" button on a Logic Block, then click params + +### Locking Parameters +- Right-click any parameter cell → toggle lock +- Locked cells show 🔒 icon and muted styling +- Auto-locked params (detected volume) show with striped background + +### Firing Randomization +- Click the **FIRE** button on any Logic Block +- Or: Tempo sync fires automatically on beat +- Or: Audio threshold fires when signal exceeds level + +### No Cables. No Nodes. Just Click and Go. + +## Controls Summary + +| Area | Controls | +|:---|:---| +| Header | Plugin selector, bypass toggle, global mix knob | +| Parameter Grid | Clickable cells, multi-select, right-click lock | +| Logic Block — Trigger | 3-way mode switch, fire button, beat division selector, threshold slider | +| Logic Block — Range | Min/max dual slider, quantize toggle + step count | +| Logic Block — Movement | 2-way mode switch, glide time slider, curve selector | +| Logic Block — Targets | Tag list of assigned parameters, clear button | +| Status Bar | Param count, lock count, block count, DAW BPM | diff --git a/plugins/ModularRandomizer/Design/v2-style-guide.md b/plugins/ModularRandomizer/Design/v2-style-guide.md new file mode 100644 index 0000000..3fed915 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v2-style-guide.md @@ -0,0 +1,158 @@ +# Style Guide v2 — Modular Randomizer + +## Design Language +**Light greyscale.** Paper-like, clean, professional. The only color is orange — used sparingly for active states, selections, and the fire button. Everything else is grey. + +Inspiration: Ableton Live light theme, macOS system preferences, Figma's UI. + +--- + +## Color Palette + +### Surfaces +| Token | Hex | Usage | +|:---|:---|:---| +| `--bg-app` | `#F0F0F0` | App background | +| `--bg-panel` | `#FAFAFA` | Panel/card backgrounds | +| `--bg-cell` | `#FFFFFF` | Parameter cells, inputs | +| `--bg-cell-hover` | `#F5F5F5` | Cell hover state | +| `--bg-cell-active` | `#EDEDED` | Cell pressed state | +| `--bg-inset` | `#E8E8E8` | Slider tracks, inset areas | + +### Borders +| Token | Hex | Usage | +|:---|:---|:---| +| `--border` | `#DEDEDE` | Default borders | +| `--border-strong` | `#C0C0C0` | Panel dividers, headers | +| `--border-focus` | `#999999` | Focused elements | + +### Text +| Token | Hex | Usage | +|:---|:---|:---| +| `--text-primary` | `#333333` | Labels, names | +| `--text-secondary` | `#777777` | Values, secondary info | +| `--text-muted` | `#AAAAAA` | Disabled, placeholders | +| `--text-inverse` | `#FFFFFF` | Text on accent backgrounds | + +### Accent (the only color) +| Token | Hex | Usage | +|:---|:---|:---| +| `--accent` | `#FF5500` | Primary accent | +| `--accent-hover` | `#E64D00` | Accent hover (darker) | +| `--accent-light` | `#FFF0E6` | Selected cell background | +| `--accent-border` | `#FFB380` | Selected cell border | + +### Semantic +| Token | Hex | Usage | +|:---|:---|:---| +| `--locked-bg` | `#FFF0F0` | Locked cell background | +| `--locked-border` | `#FFCCCC` | Locked cell border | +| `--locked-icon` | `#CC3333` | Lock icon color | +| `--auto-lock-bg` | `#FFF5E6` | Auto-detected lock cell | +| `--auto-lock-border` | `#FFD699` | Auto-detected lock border | +| `--midi-dot` | `#33CC33` | MIDI activity indicator | + +--- + +## Typography + +| Element | Font | Size | Weight | Color | +|:---|:---|:---|:---|:---| +| Plugin title | Inter | 13px | 600 | `--text-primary` | +| Section header | Inter | 10px | 600 | `--text-secondary` | +| Parameter name | Inter | 10px | 500 | `--text-primary` | +| Parameter value | JetBrains Mono | 10px | 400 | `--text-secondary` | +| Button label | Inter | 10px | 600 | `--text-inverse` (on accent) | +| Block title | Inter | 11px | 600 | `--text-primary` | +| Block summary | Inter | 10px | 400 | `--text-secondary` | +| Status bar | JetBrains Mono | 10px | 400 | `--text-muted` | +| MIDI note label | JetBrains Mono | 10px | 500 | `--text-primary` | + +**Font stack:** `'Inter', system-ui, -apple-system, sans-serif` +**Mono stack:** `'JetBrains Mono', 'SF Mono', 'Consolas', monospace` + +--- + +## Spacing + +Base unit: **4px** + +| Token | Value | +|:---|:---| +| `--space-xs` | 4px | +| `--space-sm` | 8px | +| `--space-md` | 12px | +| `--space-lg` | 16px | +| `--space-xl` | 24px | + +--- + +## Component Styles + +### Parameter Cell +- Size: flexible, min 96px wide × 52px tall +- Background: `--bg-cell` (white) +- Border: 1px solid `--border` +- Border-radius: 3px +- **Hover**: bg → `--bg-cell-hover` +- **Selected**: bg → `--accent-light`, border → `--accent-border` +- **Locked**: bg → `--locked-bg`, border → `--locked-border`, lock icon visible +- **Auto-locked**: bg → `--auto-lock-bg`, border → `--auto-lock-border`, ⚠ icon +- Shadow: none + +### Logic Block Card +- Background: `--bg-panel` +- Border: 1px solid `--border` +- Border-radius: 4px +- **Active block**: left border 3px solid `--accent` +- **Collapsed**: single row showing summary +- **Expanded**: all controls visible + +### Segmented Control +- Background: `--bg-inset` +- Active segment: `--accent` bg, white text +- Inactive: transparent bg, `--text-secondary` +- Border-radius: 3px +- Height: 26px + +### Slider +- Track: `--bg-inset`, height 4px, rounded +- Filled portion: `--accent` +- Handle: 12px circle, white fill, 1px `--border-strong` stroke +- Labels: mono, `--text-secondary` + +### Fire Button +- Background: `--accent` +- Text: white, 11px, 600 weight +- Full width of block +- Height: 30px +- Border-radius: 3px +- Hover: `--accent-hover` +- Active: slight scale (0.98) +- No shadow + +### Target Tags +- Background: `--accent-light` +- Border: 1px solid `--accent-border` +- Text: `--accent`, 9px, 500 weight +- Padding: 2px 8px +- × button to remove + +### Toggle Switch +- Track: `--bg-inset`, 28×14px, 1px `--border` +- Thumb: white, 10px circle, subtle shadow +- On: track → `--accent`, thumb slides right + +--- + +## Animation + +| Property | Duration | Easing | +|:---|:---|:---| +| Hover | 80ms | ease-out | +| Selection | 120ms | ease-out | +| Fire flash | 250ms | ease-out | +| Block expand/collapse | 200ms | ease-out | +| Slider thumb | 0ms | immediate (no animation on drag) | + +**Rule:** Minimal animation. Functional only. No decorative motion. diff --git a/plugins/ModularRandomizer/Design/v2-test.html b/plugins/ModularRandomizer/Design/v2-test.html new file mode 100644 index 0000000..8de8858 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v2-test.html @@ -0,0 +1,1448 @@ + + + + + + + Modular Randomizer + + + + +
+ + +
+
+
+ MODULAR RANDOMIZER +
+
+
+ + + +
+
+ +
+ + + 100% +
+
+
+ + +
+
+ Parameters +
+ + +
+
+
+
+
+
+ + +
+
+ Logic Blocks +
+
+
+ + +
+ Serum + 32 params + 1 locked + 1 blocks + 120 BPM + MIDI +
+
+ + +
+
🔒 Lock
+
🔓 Unlock
+
+
Select
+
Deselect
+
+ + + + + \ No newline at end of file diff --git a/plugins/ModularRandomizer/Design/v2-ui-spec.md b/plugins/ModularRandomizer/Design/v2-ui-spec.md new file mode 100644 index 0000000..bed888a --- /dev/null +++ b/plugins/ModularRandomizer/Design/v2-ui-spec.md @@ -0,0 +1,113 @@ +# UI Specification v2 — Modular Randomizer + +## Design Direction Change (v1 → v2) +- **Dark → Light greyscale.** Clean, professional, paper-like. Only accent color is orange. +- **Added MIDI trigger mode.** Any note / specific note / CC, with velocity scaling. +- **Deeper UX thinking.** Layout reorganized around actual producer workflow. + +## Window +- **Size:** 900 × 600px +- **Resizable:** No (fixed) +- **Framework:** WebView2 + +--- + +## Core UX Insight + +The plugin serves **four distinct use cases** that can coexist simultaneously: + +| Use Case | Trigger | Movement | Example | +|:---|:---|:---|:---| +| Sound exploration | Manual (click) | Instant | Hit FIRE, discover new filter combos | +| Evolving performance | MIDI note-on | Smooth glide | Every note subtly morphs the sound | +| Rhythmic texture | Tempo sync | Instant | Waveshape snaps to new value every bar | +| Reactive sound design | Audio threshold | Smooth glide | Kick drum morphs a pad synth | + +Multiple blocks allow different params to randomize with different triggers and timing. That's the power. + +--- + +## Layout Structure + +``` +┌────────────────────────────────────────────────────────┐ +│ HEADER (40px) │ +│ ■ MODULAR RANDOMIZER [Plugin: ▾ Serum] [⏻] [Mix ═] │ +├────────────────────────────────────────────────────────┤ +│ │ +│ PARAMETER GRID (full width, scrollable) │ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Cut │ │Reso │ │Drive│ │Wave │ │Semi │ │Fine │ │ +│ │65% │ │30% │ │10% │ │25% │ │50% │ │50% │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Atk │ │Dec │ │Sus │ │Rel │ │LFO │ │■Vol │ │ +│ │ 5% │ │30% │ │70% │ │40% │ │50% │ │🔒80%│ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ │ +├────────────────────────────────────────────────────────┤ +│ BLOCK STRIP (collapsible, horizontal) │ +│ │ +│ ┌─ Block 1 ──────────┐ ┌─ Block 2 ──────────┐ [+] │ +│ │ MIDI · Any Note │ │ Tempo · 1/4 │ │ +│ │ Smooth · 200ms │ │ Instant │ │ +│ │ Range: 20–80% │ │ Range: 0–100% │ │ +│ │ ● Filter Cut, Reso │ │ ● Osc1 Wave │ │ +│ │ [FIRE] │ │ [FIRE] │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +│ │ +├────────────────────────────────────────────────────────┤ +│ 32 params · 1 locked · 2 blocks · 120 BPM │ +└────────────────────────────────────────────────────────┘ +``` + +### Key Layout Change from v1 +Parameter grid is now **full width** (the main focus). Logic blocks are in a **horizontal strip below**, more compact. This puts the parameters front and center — that's what you're looking at 90% of the time. + +--- + +## Interaction Model + +### Assigning Params to Blocks +1. **Select a block** by clicking its header (active block has orange left border) +2. **Click params** in the grid — they toggle selection (orange highlight) +3. Selected params automatically appear as targets on the active block +4. **Shift+click** for range selection +5. **Select All / Clear** buttons in the param panel header + +### Locking Parameters +- **Right-click** any param cell → context menu with Lock/Unlock +- **Auto-lock**: Master Vol detected on load → striped background + lock icon +- Locked params cannot be selected or randomized + +### MIDI Trigger (new in v2) +When a block's trigger is set to MIDI: +- **Any Note**: every incoming MIDI note fires the block +- **Specific Note**: only the selected note fires (shows note name, e.g. "C3") +- **CC**: a CC message above value 64 fires +- **Velocity Scale**: when on, velocity proportionally scales the random range + - vel 127 = full range, vel 64 = half range, vel 1 = barely moves + +### Expanding a Block +- Click a block to expand it (shows all controls) +- When collapsed, shows: trigger type · movement type · target count +- Only one block expanded at a time (accordion) + +--- + +## Controls Summary + +| Area | Controls | +|:---|:---| +| Header | Plugin selector, bypass toggle, global mix slider | +| Param Grid | Clickable cells, multi-select, right-click lock, Select All / Clear | +| Block — Trigger | 4-way mode: Manual / Tempo / MIDI / Audio | +| Block — Trigger (Tempo) | Beat division selector | +| Block — Trigger (MIDI) | MIDI mode: Any Note / Note / CC, note selector, velocity scale toggle | +| Block — Trigger (Audio) | Threshold slider, retrigger time | +| Block — Range | Min/max dual slider, quantize toggle + step count | +| Block — Movement | 2-way: Instant / Smooth, glide time slider | +| Block — Targets | Tag list of assigned params, clear all button | +| Block — Action | FIRE button | +| Status Bar | Param count, lock count, block count, DAW BPM, MIDI activity indicator | diff --git a/plugins/ModularRandomizer/Design/v3-style-guide.md b/plugins/ModularRandomizer/Design/v3-style-guide.md new file mode 100644 index 0000000..b69f05a --- /dev/null +++ b/plugins/ModularRandomizer/Design/v3-style-guide.md @@ -0,0 +1,172 @@ +# Style Guide v3 — Modular Randomizer + +## Design Language +**Light greyscale.** Paper-like, clean, professional. Two accent colors only: +- **Orange** (`#FF5500`) — selections, active states, fire button, randomize controls +- **Blue** (`#22AAFF`) — envelope follower mode, ENV indicator, level meter + +Inspiration: Ableton Live's Session View. Functional over decorative. Every pixel earns its space. + +--- + +## Color Palette + +### Greyscale +| Token | Hex | Usage | +|:---|:---|:---| +| `--bg-app` | `#F0F0F0` | Application background | +| `--bg-panel` | `#FAFAFA` | Header, section bars, status bar | +| `--bg-cell` | `#FFFFFF` | Parameter cells, block cards, inputs | +| `--bg-cell-hover` | `#F5F5F5` | Hover state | +| `--bg-inset` | `#E8E8E8` | Slider tracks, segmented control bg, target boxes | +| `--border` | `#DEDEDE` | Default borders | +| `--border-strong` | `#C0C0C0` | Separator lines, slider thumbs | +| `--border-focus` | `#999999` | Hover/focus borders | + +### Text +| Token | Hex | Usage | +|:---|:---|:---| +| `--text-primary` | `#333333` | Main text | +| `--text-secondary` | `#777777` | Values, descriptions | +| `--text-muted` | `#AAAAAA` | Labels, placeholders, section titles | +| `--text-inverse` | `#FFFFFF` | Text on accent backgrounds | + +### Accent — Orange (Randomize) +| Token | Hex | Usage | +|:---|:---|:---| +| `--accent` | `#FF5500` | Active buttons, selected states, fire button | +| `--accent-hover` | `#E64D00` | Hover on accent elements | +| `--accent-light` | `#FFF0E6` | Selected cell background | +| `--accent-border` | `#FFB380` | Selected cell border, target tags | + +### Accent — Blue (Envelope) +| Token | Hex | Usage | +|:---|:---|:---| +| `--env-color` | `#22AAFF` | Envelope meter fill, active dot, mode button | +| `--env-light` | `#E6F5FF` | Envelope-related hover states | + +### Semantic Colors +| Token | Hex | Usage | +|:---|:---|:---| +| `--locked-bg` | `#FFF0F0` | Locked parameter cell | +| `--locked-border` | `#FFCCCC` | Locked parameter border | +| `--locked-icon` | `#CC3333` | Lock icon color | +| `--auto-lock-bg` | `#FFF5E6` | Auto-detected lock (Master Vol) | +| `--auto-lock-border` | `#FFD699` | Auto-lock border | +| `--midi-dot` | `#33CC33` | MIDI activity indicator | + +--- + +## Typography + +### Font Stack +- **UI**: `Inter`, `-apple-system`, `system-ui`, `sans-serif` +- **Values/Mono**: `JetBrains Mono`, `SF Mono`, `Consolas`, `monospace` + +### Sizes +| Usage | Size | Weight | +|:---|:---|:---| +| Brand name | 12px | 600 | +| Parameter name | 10px | 500 | +| Parameter value | 10px (mono) | 400 | +| Block title | 11px | 600 | +| Block summary | 10px | 400 | +| Section title | 10px | 600 | +| Block label | 9px | 600 | +| Sub-label | 9px | 400 | +| Status bar | 10px (mono) | 400 | +| Button text | 10px | 600 | + +### Text Transform +- Section titles: `UPPERCASE`, `letter-spacing: 1px` +- Block labels: `UPPERCASE`, `letter-spacing: 0.6px` +- Brand name: `UPPERCASE`, `letter-spacing: 0.8px` +- Fire button: `UPPERCASE`, `letter-spacing: 1px` + +--- + +## Spacing + +- Grid gap: 4px +- Cell padding: 7px 8px +- Block body padding: 10px +- Block row gap: 10px +- Section bar height: 28px +- Header height: 40px +- Status bar height: 22px + +--- + +## Component Styles + +### Segmented Control +- Background: `--bg-inset` with 1px `--border` +- Buttons: equal width, 5px 2px padding +- Active (Randomize): `--accent` bg, white text +- Active (Envelope): `--env-color` bg, white text +- Border-right between buttons: 1px `--border` + +### Toggle Switch +- Track: 28 × 14px, `--bg-inset`, 1px `--border` +- Thumb: 10 × 10px circle, white, 1px `--border-strong` +- Active: track → `--accent`, thumb slides right +- Transition: 80ms ease + +### Slider Row +- Label: 9px, `--text-secondary`, min-width 32px +- Track: height 4px, `--bg-inset`, 2px radius +- Thumb: 14px circle, white fill, 2px `--border-strong` border, 50% radius +- Thumb hover: border → `--accent` +- Thumb active: border → `--accent`, `0 0 0 3px var(--accent-light)` shadow +- Value: 10px mono, `--text-secondary`, min-width 32px, right-aligned + +### Target Tag +- Background: `--accent-light` +- Border: 1px `--accent-border` +- Text: `--accent`, 9px, weight 500 +- × button: 10px, 0.6 opacity → 1.0 on hover +- Border radius: 2px + +### FIRE Button +- Full width of block +- Height: 28px +- Background: `--accent` → `--accent-hover` on hover +- Text: white, 10px, weight 600, uppercase +- Active: `scale(0.98)` +- Flash animation: 250ms `box-shadow: 0 0 12px var(--accent)` fade + +### Envelope Level Meter +- Height: 40px +- Background: `--bg-inset`, 1px `--border` +- Fill: linear-gradient from `--env-color` (bottom) to translucent blue (top) +- Transition: 30ms linear (real-time response) +- Label: 9px mono, top-right corner + +### Activity Dots (Status Bar) +- Size: 5px circle +- MIDI: `--midi-dot` (green) when active, `--border-strong` when idle +- ENV: `--env-color` (blue) when active, `--border-strong` when idle +- Envelope header dot: 6px, pulsing animation (1s ease-in-out) + +--- + +## Animations + +| Element | Trigger | Duration | Easing | +|:---|:---|:---|:---| +| Cell hover | mouseenter | 80ms | ease-out | +| Cell selection | click | 80ms | ease-out | +| Slider thumb hover | mouseenter | 80ms | ease | +| Fire flash | click | 250ms | ease-out | +| Env meter fill | audio input | 30ms | linear | +| Env header dot | continuous | 1000ms | ease-in-out | +| Toggle switch | click | 80ms | ease | +| Value bar | randomize | 180ms | ease-out | + +--- + +## Responsive Behavior +- Fixed 920 × 620px (plugin window) +- Grid columns adapt via `auto-fill` to available width +- Block strip scrolls horizontally +- Parameter grid scrolls vertically diff --git a/plugins/ModularRandomizer/Design/v3-test.html b/plugins/ModularRandomizer/Design/v3-test.html new file mode 100644 index 0000000..423f4b3 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v3-test.html @@ -0,0 +1,1598 @@ + + + + + + + Modular Randomizer v3 + + + + +
+ +
+
+
MODULAR RANDOMIZER +
+
+
+ + + +
+
+ +
+ + + 100% +
+
+
+ + +
+
+ Parameters +
+ + +
+
+
+
+
+
+ + +
+
Logic Blocks
+
+
+ + +
+ Serum + 32 params + 1 locked + 1 blocks + 120 BPM + MIDI + ENV +
+
+ + +
+
🔒 Lock
+
🔓 Unlock
+
+
Select
+
Deselect
+
+ + + + + \ No newline at end of file diff --git a/plugins/ModularRandomizer/Design/v3-ui-spec.md b/plugins/ModularRandomizer/Design/v3-ui-spec.md new file mode 100644 index 0000000..4dff9a9 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v3-ui-spec.md @@ -0,0 +1,194 @@ +# UI Specification v3 — Modular Randomizer + +## Design Direction (v2 → v3) +- **Fixed slider styling** — labeled rows (Label + slider + value), no more broken dual overlaps +- **Added Envelope Follower** — continuous audio-reactive modulation as a separate block mode +- **Two block types** — Randomize blocks (discrete triggers) and Envelope blocks (continuous following) +- Maintained light greyscale palette with orange (`#FF5500`) as only accent + +--- + +## Architecture Summary + +The plugin is a **host container** that loads an external VST3/AU and provides two types of modulation: + +1. **Randomize blocks** — fire discrete random values triggered by manual, tempo, MIDI, or audio threshold +2. **Envelope follower blocks** — continuously map incoming audio amplitude to parameter values + +Both block types target the same parameter grid. Multiple blocks of either type can coexist. + +--- + +## Layout + +### Global Structure (920 × 620px) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ HEADER [Plugin Select] [Open Editor] [⏻] Mix │ 40px +├──────────────────────────────────────────────────────────────┤ +│ PARAMETERS [Select All] [Clear] │ 28px bar +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │Osc1│ │Osc1│ │Osc1│ │Osc1│ │Osc2│ │Osc2│ │Osc2│ │Osc2│ │ +│ │Wave│ │Semi│ │Fine│ │Lvl │ │Wave│ │Semi│ │Fine│ │Lvl │ │ Scrollable +│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │ Grid +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │Flt │ │Flt │ │Flt │ │Flt │ │Env1│ │Env1│ │Env1│ │Env1│ │ +│ │Cut │ │Res │ │Drv │ │Type│ │Atk │ │Dec │ │Sus │ │Rel │ │ +│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │ +│ ... more rows ... │ +├──────────────────────────────────────────────────────────────┤ +│ LOGIC BLOCKS │ 28px bar +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────┐ ┌─────┐ │ +│ │ Block 1 │ │ Block 2 │ │+ Rnd│ │+ Env│ │ Scrollable +│ │ [Randomize mode]│ │ [Envelope mode] │ └─────┘ └─────┘ │ horizontal +│ │ Trigger / Range │ │ Attack/Release │ │ +│ │ Quantize / Move │ │ Gain / Range │ │ +│ │ Targets / FIRE │ │ Invert / Tgts │ │ +│ └─────────────────┘ └─────────────────┘ │ +├──────────────────────────────────────────────────────────────┤ +│ STATUS: Serum · 32 params · 1 locked · 2 blocks · MIDI ENV │ 22px +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Parameter Grid + +- **Layout**: CSS Grid, `auto-fill`, `minmax(94px, 1fr)` +- **Cell contents**: Parameter name, value (mono font), value bar, lock icon +- **States**: + - Default: white bg, grey border + - Hover: light grey bg + - Selected (orange): `#FFF0E6` bg, `#FFB380` border, orange value bar + - Locked (red): `#FFF0F0` bg, `#FFCCCC` border, 🔒 icon + - Auto-locked (amber): `#FFF5E6` bg, `#FFD699` border, ⚠ icon +- **Interactions**: + - Left-click: toggle selection (assigns/removes from active block) + - Right-click: context menu (Lock / Unlock / Select / Deselect) + +--- + +## Logic Blocks + +### Block Card Layout +- Min-width: 270px, max-width: 290px +- Header: title + summary text, chevron expand/collapse +- Active block: 3px orange left border +- Click header: expand block + set as active +- Close button: × on header + +### Mode Selector (top of every block) +- Segmented control: **Randomize** | **Envelope** +- Randomize: orange active state +- Envelope: blue (`#22AAFF`) active state + +### Randomize Block Body + +#### Trigger Section +Segmented control: **Manual** | **Tempo** | **MIDI** | **Audio** + +Sub-controls appear based on selection: +- **Manual**: No sub-controls (uses FIRE button) +- **Tempo**: Division dropdown (1/1 through 1/32) +- **MIDI**: Mode dropdown (Any Note / Specific Note / CC) + - Specific Note: note display + slider (0–127) + - CC: CC# number input + - Toggle: "Velocity scales range" +- **Audio**: Threshold slider (-60 to 0 dB) + +#### Range Section +- Two labeled slider rows: + ``` + Min ═══════●════ 25% + Max ══════════●═ 80% + ``` + +#### Quantize Section +- Toggle + step count input (2–128) +- When off, input is disabled/dimmed + +#### Movement Section +Segmented control: **Instant** | **Smooth** +- Smooth: reveals glide time slider (1–2000ms) + +#### Targets +- Tag box showing selected parameter names as orange tags +- Each tag has × to remove individually +- Empty state: "Click parameters to assign" + +#### FIRE Button +- Full-width orange button, uppercase +- Flash animation on click + +### Envelope Follower Block Body + +#### Level Meter +- 40px tall, blue fill from bottom +- Real-time percentage label +- Pulsing blue dot in header indicates active + +#### Response Section +- Attack slider (1–500ms) +- Release slider (1–2000ms) + +#### Mapping Section +- Gain slider (0–100%) +- Min/Max range sliders (same as Randomize) +- Invert toggle + +#### Targets +- Same tag box as Randomize blocks + +#### No FIRE button (always active when audio present) + +--- + +## Slider Design (v3 Fix) + +All sliders use a consistent row layout: + +``` +[Label 32px] [═══════●═════ slider] [Value 32px] +``` + +- **Track**: 4px height, `#E8E8E8` background, 2px radius +- **Thumb**: 14px circle, white fill, 2px `#C0C0C0` border +- **Thumb hover**: border → `#FF5500` +- **Thumb active**: border → `#FF5500`, 3px `#FFF0E6` box-shadow ring +- **Value display**: mono font, right-aligned + +--- + +## Interactions + +### Block Management +- **Add Randomizer**: dashed border button `+ Randomizer` +- **Add Envelope**: dashed border button `+ Envelope` (blue tint) +- **Remove**: × button on block header +- **Activate**: click block header +- **Expand/Collapse**: click header toggles body visibility + +### Parameter Selection +- Click grid cell → toggles selection +- Selected params auto-appear in ALL active block target boxes +- Remove from target box → deselects globally + +### Envelope Follower Real-time +- Audio amplitude drives the level meter continuously +- Selected parameter values update in real-time on the grid +- Status bar ENV dot pulses blue when envelope is active + +--- + +## Status Bar +- Height: 22px +- Shows: Plugin name, param count, locked count, block count, BPM, MIDI dot, ENV dot +- MIDI dot: green flash on MIDI activity +- ENV dot: blue when any envelope block is processing + +--- + +## Window Size +- **Fixed**: 920 × 620px (VST3 plugin window) +- **Not resizable** in v1 implementation diff --git a/plugins/ModularRandomizer/Design/v4-test.html b/plugins/ModularRandomizer/Design/v4-test.html new file mode 100644 index 0000000..5715f07 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v4-test.html @@ -0,0 +1,1530 @@ + + + + + + + Modular Randomizer v4 + + + + +
+
+
+
MODULAR RANDOMIZER +
+
+
+ +
+ + + 100% +
+
+
+
+
+
Loaded Plugin
+
+ + +
+
+ + + 32 params +
+
Click params to assign to +
+
+
+
+
+ Logic Blocks + 0 blocks +
+
+
+ + +
+
+
+
+ Serum + 32 params + 1 locked + 0 blocks + 120 BPM + MIDI + ENV +
+
+
+
Lock
+
Unlock
+
+ + + + \ No newline at end of file diff --git a/plugins/ModularRandomizer/Design/v4-ui-spec.md b/plugins/ModularRandomizer/Design/v4-ui-spec.md new file mode 100644 index 0000000..d425b56 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v4-ui-spec.md @@ -0,0 +1,82 @@ +# UI Specification v4 — Modular Randomizer + +## Design Changes (v3 → v4) + +### Problems Solved +1. **No per-block parameter assignment** — v3 used a single global selection. Now each logic block has its own `targets` Set +2. **Adding all params broke the UI** — Target box now has max-height with scroll, shows abbreviated tags, overflow counter +3. **Plugin was just a dropdown** — Now rendered as a full **Plugin Block** panel (left side) consistent with the rack metaphor +4. **No connection model** — Colored dots on parameters show which blocks target them (visual "cables") +5. **Parameter sharing** — A single parameter can belong to multiple blocks. Color dots make this visible + +--- + +## Architecture: Two-Panel Rack + +``` +┌─────────────────────┬──────────────────────────────────────┐ +│ PLUGIN BLOCK │ LOGIC BLOCKS │ +│ (Left Panel) │ (Right Panel) │ +│ │ │ +│ ┌ Oscillator 1 ─┐│ ┌─ Block 1 (Randomize) ───────────┐│ +│ │ Wave 65% ●○ ││ │ ● Assign Mode Trigger Range ││ +│ │ Semi 50% ○ ││ │ Quantize Movement Targets ││ +│ │ Fine 50% ││ │ [FIRE] ││ +│ │ Level 80% ││ └──────────────────────────────────┘│ +│ └────────────────┘│ ┌─ Block 2 (Envelope) ────────────┐│ +│ ┌ Filter ────────┐│ │ ○ Assign Meter Response ││ +│ │ Cutoff 65% ● ││ │ Mapping Targets ││ +│ │ Res 30% ││ └──────────────────────────────────┘│ +│ └────────────────┘│ │ +│ ... more groups │ [+ Randomizer] [+ Envelope] │ +└─────────────────────┴──────────────────────────────────────┘ +``` + +### Plugin Block (Left Panel, 240px) +- **Header**: "LOADED PLUGIN" label +- **Plugin selector**: Dropdown + "Editor" button +- **Toolbar**: All / None buttons + param count +- **Assign banner**: Shows when assign mode is active for a block (colored) +- **Parameter list**: Grouped by category, collapsible groups + - Each parameter row shows: name, value, bar, colored dots for connected blocks + - Clicking a param while in assign mode toggles it for that block + - Right-click: Lock / Unlock context menu + +### Logic Blocks (Right Panel) +- **Card layout**: 2-column grid, scrollable +- **Each card has**: + - Colored block indicator (unique per block) + - Assign button: enters assign mode for that block + - Mode selector: Randomize / Envelope + - All controls from v3 + - Targets box: shows parameter tags with block color, max-height scroll, overflow counter +- **Per-block targeting**: Each block maintains its own `targets` Set + +--- + +## Parameter Sharing Model + +A parameter CAN be targeted by multiple blocks simultaneously: + +- **Visual indicator**: Colored dots on the parameter row, one per connected block +- **Last-write wins**: If a Randomize block fires while an Envelope block is also modulating, the most recent write takes effect +- **Independent ranges**: Each block applies its own min/max range to the shared parameter +- **Locking**: Locking a parameter removes it from ALL blocks' targets + +--- + +## Assign Mode Workflow + +1. User clicks **Assign** button on a logic block +2. Plugin block shows colored banner: "Click params to assign → Block N" +3. Parameter rows highlight on hover with block color +4. Clicking a param toggles it in that block's target set +5. Colored dots appear/disappear in real-time +6. Click **Assign** again (now shows "✓ Assigning") to exit assign mode +7. "All" / "None" buttons in toolbar work on the active assign block + +--- + +## Window Size +- **960 × 640px** (slightly wider than v3 for two-panel layout) +- Not resizable in v1 implementation diff --git a/plugins/ModularRandomizer/Design/v5-style-guide.md b/plugins/ModularRandomizer/Design/v5-style-guide.md new file mode 100644 index 0000000..2c2f4c3 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v5-style-guide.md @@ -0,0 +1,82 @@ +# Style Guide v5 — Theme Color Rework + +## Summary +Rebuilt three themes (Grey, Earthy, Terminal) from the ground up using color accessibility science. +The Light theme remains unchanged as the reference implementation. + +--- + +## Design Principles Applied + +1. **WCAG AA Compliance** — All text colors achieve ≥4.5:1 contrast on their typical background +2. **Coherent Background Ramp** — bg-app → bg-panel → bg-cell → bg-cell-hover forms a monotonic progression; bg-inset is always darker than bg-app +3. **Visible Borders** — All structural borders achieve ≥2:1 contrast against adjacent surfaces +4. **Distinct Accents** — Accent colors achieve ≥3:1 on cell backgrounds for UI component visibility +5. **Theme Personality** — Each theme maintains a clear visual identity and mood + +--- + +## Grey Theme — "Industrial Dark Mid-Tone" + +**Concept**: Shifted from a broken light-grey (where inset was lighter than app) to a proper dark mid-tone industrial palette. All surfaces are dark enough that light text reads clearly. + +| Token | Old | New | Rationale | +|:---|:---|:---|:---| +| `--bg-app` | `#B0B0B0` | `#3A3A3A` | Mid-tone grey; provides dark base for light text | +| `--bg-panel` | `#C4C4C4` | `#484848` | Slightly lighter than app for panel distinction | +| `--bg-cell` | `#F0F0F0` | `#585858` | Was too far from panel; now coherent step | +| `--bg-inset` | `#DCDCDC` | `#2E2E2E` | Was lighter than app (wrong); now properly recessed | +| `--text-primary` | `#000000` | `#F2F2F2` | Inverted for dark background readability | +| `--text-muted` | `#555555` | `#A0A0A0` | ~4.7:1 on `#585858` cell (was ~3.2:1 on `#B0B0B0`) | +| `--accent` | `#E87430` | `#F08040` | Warmer, brighter orange; ~4.0:1 on cells | +| `--border` | `#909090` | `#6E6E6E` | ~1.3:1 on cells (structural); clearly visible against `#3A3A3A` | + +--- + +## Earthy Theme — "Warm Studio" + +**Concept**: Kept the dark warm palette but replaced the invisible dark-green accent with warm amber. All warm-toned colors now have sufficient luminance contrast. + +| Token | Old | New | Rationale | +|:---|:---|:---|:---| +| `--bg-app` | `#252018` | `#1C1814` | Slightly darker for deeper base | +| `--bg-cell` | `#443E38` | `#342C24` | Closer to panel for tighter ramp | +| `--accent` | `#2D6B3F` | `#D4943C` | **Critical fix**: old green was 1.8:1 on cells; new amber is ~5.2:1 | +| `--text-muted` | `#B0A898` | `#A89880` | Maintained warm tone; 4.6:1 on `#342C24` | +| `--env-color` | `#A08420` | `#C8A838` | Brightened golden; ~5.8:1 on cells | +| `--sample-color` | `#8B3030` | `#C05A3A` | Brightened terracotta; ~3.5:1 on cells | +| `--border` | `#685E50` | `#504436` | Better separation from cell background | +| `--midi-dot` | `#50A858` | `#6EBB56` | Brighter green dot for activity visibility | + +--- + +## Terminal Theme — "CRT Phosphor" + +**Concept**: Authentic CRT terminal with green-tinted surfaces. Backgrounds have a subtle green hue instead of neutral black. Accent is vivid phosphor green (#33DD44) evoking real terminal displays. + +| Token | Old | New | Rationale | +|:---|:---|:---|:---| +| `--bg-app` | `#000000` | `#0A0E0A` | Green-tinted near-black instead of pure black | +| `--bg-cell` | `#161616` | `#141C14` | Green-tinted cells for cohesive CRT feel | +| `--text-primary` | `#E8E8E8` | `#D8F0D8` | Green-tinted white; authentic terminal readout | +| `--text-muted` | `#707070` | `#6E946E` | Green-tinted muted; 4.5:1 on `#141C14` | +| `--accent` | `#40A840` | `#33DD44` | Vivid phosphor green; ~9.5:1 on cells | +| `--border` | `#333333` | `#2A382A` | Green-tinted borders; visible against green-tinted bg | +| `--border-strong` | `#555555` | `#3E5040` | Stronger green borders for panels | +| `--env-color` | `#C0A030` | `#E0C040` | Brighter amber for CRT contrast | +| `--sample-color` | `#C03030` | `#EE4444` | Vivid red for CRT-grade visibility | +| `--thumb-color` | `#E0E0E0` | `#C8E8C8` | Green-tinted thumb for visual cohesion | + +--- + +## Block Colors (bcolors) — Redesigned Per Theme + +Each theme's bcolors array now uses colors that are: +- **Distinguishable** from each other with sufficient hue separation +- **Visible** against that theme's cell/panel backgrounds +- **Thematically coherent** with the palette mood + +--- + +## Preserved: Light Theme (Reference) +No changes. The Light theme already had proper contrast ratios and serves as the default Ableton-inspired design. diff --git a/plugins/ModularRandomizer/Design/v5-test.html b/plugins/ModularRandomizer/Design/v5-test.html new file mode 100644 index 0000000..1ff99c3 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v5-test.html @@ -0,0 +1,1829 @@ + + + + + + Modular Randomizer v5 + + + + +
+
+
+
MODULAR RANDOMIZER +
+
+
+ +
+ + + 100% +
+
+
+
+
+
+ Plugin Blocks + +
+
Assigning to
+
+
+
+
+ Logic Blocks + 0 blocks +
+
+
+ + +
+
+
+
+ Serum + 32 params + 1 locked + 0 blocks + 120 BPM + MIDI + ENV +
+
+
+
Lock
+
Unlock
+
+ + + + + + \ No newline at end of file diff --git a/plugins/ModularRandomizer/Design/v5-ui-spec.md b/plugins/ModularRandomizer/Design/v5-ui-spec.md new file mode 100644 index 0000000..fafd685 --- /dev/null +++ b/plugins/ModularRandomizer/Design/v5-ui-spec.md @@ -0,0 +1,587 @@ +# UI Specification v5 — Modular Randomizer + +## Design Evolution (v4 → v5) + +### Problems Solved +1. **Single plugin only** — v4 supported one loaded plugin. v5 supports **multiple plugin instances** as collapsible, reorderable cards +2. **No plugin discovery** — v4 used a dropdown. v5 has a **Plugin Browser modal** with search, category filtering, and scan path config +3. **No drag-and-drop** — Plugin cards can now be **dragged to reorder** within the left panel +4. **Range mode inflexible** — Added **Relative mode** (±% from current value) alongside Absolute mode +5. **Color consistency** — Block colors are now **persistent** via stored `colorIdx`, not positional + +--- + +## Architecture: Multi-Plugin Two-Panel Rack + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HEADER [⏻] [Mix ═●═] │ 38px +├────────────────────┬────────────────────────────────────────┤ +│ PLUGIN BLOCKS │ LOGIC BLOCKS │ +│ [+ Plugin] │ (scrollable) │ +│ │ │ +│ ┌─ Serum ───────┐ │ ┌─ Block 1 (Randomize) ────────────┐ │ +│ │ ▶ 26 params x│ │ │ ● Assign Mode Trigger Range │ │ +│ │ [Filter...] │ │ │ Abs/Rel Quantize Movement │ │ +│ │ Osc A Wave 25%│ │ │ Targets (3) [FIRE] │ │ +│ │ Osc A Semi 50%│ │ └──────────────────────────────────┘ │ +│ │ Filter Cut 65%│ │ ┌─ Block 2 (Envelope) ─────────────┐ │ +│ │ ... │ │ │ ○ Assign Meter Response │ │ +│ └────────────────┘ │ │ Mapping Targets │ │ +│ ┌─ Vital ───────┐ │ └──────────────────────────────────┘ │ +│ │ ▶ 25 params x│ │ │ +│ │ [Filter...] │ │ [+ Randomizer] [+ Envelope] │ +│ │ ... │ │ │ +│ └────────────────┘ │ │ +│ (drag to reorder) │ │ +├────────────────────┴────────────────────────────────────────┤ +│ STATUS: • Serum · 51 params · 2 locked · 2 blocks · MIDI ENV│ 22px +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Window + +- **Size**: 960 × 640px (fixed, not resizable) +- **Framework**: WebView2 +- **Fonts**: Inter (UI), JetBrains Mono (values) + +--- + +## Color System + +### CSS Variables +| Variable | Value | Usage | +|:---|:---|:---| +| `--bg-app` | `#F0F0F0` | Background | +| `--bg-panel` | `#FAFAFA` | Panel backgrounds | +| `--bg-cell` | `#FFF` | Cards, rows | +| `--bg-cell-hover` | `#F5F5F5` | Hover states | +| `--bg-inset` | `#E8E8E8` | Recessed areas | +| `--border` | `#DEDEDE` | Default borders | +| `--border-strong` | `#C0C0C0` | Prominent borders | +| `--accent` | `#FF5500` | Primary accent (orange) | +| `--accent-light` | `#FFF0E6` | Hover highlight | +| `--env-color` | `#22AAFF` | Envelope accent (blue) | +| `--locked-bg` | `#FFF0F0` | Locked parameter bg | +| `--midi-dot` | `#33CC33` | MIDI activity | + +### Block Colors (persistent per block) +``` +#FF5500, #22AAFF, #33CC33, #CC33CC, #CCAA00, #CC5533, #3388CC, #88AA33 +``` +Stored on each block as `colorIdx`, not derived from array position. + +--- + +## Header (38px) + +``` +[■ brand-mark] MODULAR RANDOMIZER | [⏻ bypass] [Mix ═●═ 100%] +``` + +- **Brand mark**: 10×10px orange square +- **Brand name**: 11px, 700 weight, 1px letter-spacing +- **Bypass button**: Toggle, `.on` class adds orange border +- **Mix slider**: Range 0–100%, labeled + +--- + +## Left Panel — Plugin Blocks (240px wide) + +### Header Bar +- Title: "Plugin Blocks" +- Button: `+ Plugin` → opens Plugin Browser Modal + +### Assign Banner +- Appears when assign mode is active +- Shows: "Assigning to Block N (mode)" +- Background/border/text in block's color + +### Plugin Cards (`.pcard`) +Each loaded plugin renders as a collapsible card: + +``` +┌───────────────────────────┐ +│ ▶ Serum 26 params [Ed][x]│ ← header (grab to drag) +├───────────────────────────┤ +│ [Filter... ]│ ← per-card search +├───────────────────────────┤ +│ Osc A Wave ●○ 25% ═│ ← parameter rows +│ Osc A Semi ○ 50% ═│ +│ Filter Cutoff ● 65% ═│ +│ Master Volume 🔒 80% ═│ ← auto-locked +│ ... │ +└───────────────────────────┘ +``` + +#### Card Header +- **Chevron**: `▶` collapsed, `▼` expanded (90° rotation) +- **Name**: Plugin name (e.g. "Serum"), 10px/600 +- **Info**: Param count, 9px muted +- **Ed button**: Opens plugin editor (future) +- **Close (×)**: Removes plugin, deletes its params from all block targets +- **Drag handle**: Header is `cursor: grab`, card is `draggable="true"` + +#### Card Body (collapsible) +- **Search input**: Filters parameter list by name +- **Parameter area**: `max-height: 180px`, scrollable + - Custom 3px scrollbar + +#### Parameter Rows (`.pr`) +| Element | Details | +|:---|:---| +| Name | 10px, flex: 1 | +| Color dots | One per connected block, uses block's persistent color | +| Value | Mono font, right-aligned, e.g. "65%" | +| Value bar | 3px height, orange fill | +| Lock icon | 🔒 for manual lock, ⚠ for auto-lock | + +#### Parameter States +- **Default**: White bg, grey border +- **Hover (assign mode)**: `assign-highlight` class +- **Assigned (to active block)**: Block color bg at 10% opacity, border at 40% +- **Locked**: `.locked` class, muted text, no interaction + +#### Parameter Interactions +- **Left-click** (during assign mode): Toggle param in active block's target set +- **Right-click**: Context menu → Lock / Unlock +- **Click during no assign mode**: No action (prevents accidental changes) + +### Drag-and-Drop Reordering +- Cards are `draggable="true"` with `data-plugidx` for position +- **Drag feedback**: Source card at 40% opacity, 97% scale +- **Drop indicator**: 2px accent line at top or bottom of target card +- **Drop logic**: Uses mouse Y vs card midpoint to determine insertion direction +- Reorders the `pluginBlocks` array and re-renders + +### Parameter ID Format +Parameters are scoped to their plugin: `"{pluginId}:{paramIndex}"` (e.g. `"1:5"`) + +--- + +## Right Panel — Logic Blocks (flex: 1) + +### Header Bar +- Title: "Logic Blocks" +- Info: "N blocks" count + +### Block Cards (`.lcard`) +Each logic block renders as an expandable card: + +``` +┌─────────────────────────────────────────┐ +│ ▶ ● Block 1 Manual / Instant / 3 [Assign] [×] │ ← header +├─────────────────────────────────────────┤ +│ Mode: [Randomize] [Envelope] │ +│ Trigger: [Manual] [Tempo] [MIDI] [Audio]│ +│ Range: [Absolute] [Relative] │ +│ Min ═══●══ 25% │ +│ Max ══════● 80% │ +│ Quantize: ○ off steps: 12 │ +│ Movement: [Instant] [Smooth] │ +│ Targets (3): │ +│ [Serum: Osc A Wave ×] [Serum: Cutoff ×]│ +│ [FIRE] │ +└─────────────────────────────────────────┘ +``` + +#### Card Header +- Chevron (expand/collapse) +- Color indicator dot (using persistent `colorIdx`) +- Title: "Block N" (sequential, 1-indexed by position) +- Summary: `{trigger} / {movement} / {targetCount} params` +- **Assign button**: Enters/exits assign mode for this block + - Active state: filled with block color +- **Close (×)**: Removes block + +#### Mode Selector +- Segmented: **Randomize** | **Envelope** +- Randomize → orange active +- Envelope → blue active + +### Randomize Block Body + +#### Trigger Section +Segmented: **Manual** | **Tempo** | **MIDI** | **Audio** + +Sub-controls per selection: +- **Manual**: No sub-controls +- **Tempo**: Division dropdown (1/1, 1/2, 1/4, 1/8, 1/16, 1/32) +- **MIDI**: Mode dropdown (Any Note, Specific Note, CC) + - Specific Note → note display + slider (0–127) + - CC → CC# input + - Velocity scales toggle +- **Audio**: Threshold slider (-60 to 0 dB) + +#### Range Section +Segmented mode toggle: **Absolute** | **Relative** + +- **Absolute mode**: + - Min slider: 0–100% + - Max slider: 0–100% +- **Relative mode**: + - ± slider: 1–100% (offset from current value) + +#### Quantize +- Toggle + step count input (2–128) +- Input disabled when toggle is off + +#### Movement +Segmented: **Instant** | **Smooth** +- Smooth → Glide time slider (1–2000ms) + +#### Targets +- Tag box showing assigned parameters +- Each tag: `"{pluginName}: {paramName}" [×]` in block color +- Shows first 6, overflow counter for remainder +- Empty state: "No params assigned" + +#### FIRE Button +- Full-width orange, uppercase +- Flash animation on click +- Flashes MIDI status dot + +### Envelope Follower Block Body + +#### Level Meter +- Vertical bar, blue fill from bottom +- Real-time percentage label +- Pulsing dot in header when active + +#### Response +- Attack slider: 1–500ms +- Release slider: 1–2000ms + +#### Mapping +- Gain slider: 0–100% +- Min/Max range sliders: 0–100% +- Invert toggle + +#### Targets +- Same tag box as Randomize blocks + +#### No FIRE button (always active) + +--- + +## Plugin Browser Modal + +Opens when "+ Plugin" is clicked. Centered overlay with dimmed background. + +``` +┌──────────────────────────────────────────────┐ +│ Plugin Browser [×] │ +├──────────────────────────────────────────────┤ +│ [Search plugins... ] [All][Syn][FX] │ +│ [Smp][Util] │ +├──────────────────────────────────────────────┤ +│ ⚙ VST3 Scan Paths (collapsible) │ +│ [C:\Program Files\Common Files\VST3] [×] │ +│ [C:\Program Files\VSTPlugins ] [×] │ +│ [+ Add Path] │ +├──────────────────────────────────────────────┤ +│ [Se] Serum [SYNTH] │ +│ Xfer Records · 26 params │ +│ [Vi] Vital [SYNTH] │ +│ Matt Tytel · 25 params │ +│ [VR] Valhalla Room [FX] │ +│ Valhalla DSP · 10 params │ +│ [Ko] Kontakt 7 [SAMPLER]│ +│ Native Instruments · 12 params │ +│ ... │ +├──────────────────────────────────────────────┤ +│ 21 plugins found [⚙ Scan Paths]│ +└──────────────────────────────────────────────┘ +``` + +### Modal Components +- **Search**: Filters by plugin name or vendor +- **Category tabs**: All / Synths / FX / Samplers / Utility + - Color-coded badges: blue (synth), purple (fx), green (sampler), orange (utility) +- **Plugin rows**: Icon (2-letter initials), name, vendor + param count, category badge + - Click → loads plugin, closes modal +- **Scan Paths panel**: Toggled via ⚙ button in footer + - Editable path list with add/remove + - In production: triggers `juce::PluginDirectoryScanner` +- **Footer**: Result count + scan paths toggle +- **Close**: × button or click overlay backdrop + +### Plugin Library Data Structure +```javascript +{ + name: 'Serum', + vendor: 'Xfer Records', + cat: 'synth', // synth | fx | sampler | utility + params: ['Osc A Wave', 'Osc A Semi', ...] +} +``` + +In production, this array is populated by scanning VST3 folders using `juce::KnownPluginList`. + +--- + +## Context Menu + +Right-click on any parameter → shows at cursor position: +- **Lock**: Locks param, removes from all block targets +- **Unlock**: Unlocks param (hidden if auto-locked) + +--- + +## Status Bar (22px) + +``` +● Serum · 51 params · 2 locked · 2 blocks · 120 BPM · ● MIDI · ● ENV +``` + +| Element | Behavior | +|:---|:---| +| Plugin dot | Always green | +| Param count | Total across all loaded plugins | +| Lock count | Params with `lk` or `alk` true | +| Block count | Total logic blocks | +| BPM | From DAW host (static in mockup) | +| MIDI dot | Green flash on MIDI trigger | +| ENV dot | Blue when any envelope block is processing | + +--- + +## Data Model + +### Plugin Blocks Array +```javascript +pluginBlocks = [ + { + id: 1, // Unique, incrementing + name: 'Serum', // Plugin name + params: [ // Parameter objects + { id: '1:0', name: 'Osc A Wave', v: 0.25, lk: false, alk: false }, + { id: '1:1', name: 'Osc A Semi', v: 0.50, lk: false, alk: false }, + // ... + { id: '1:24', name: 'Master Volume', v: 0.80, lk: true, alk: true } + ], + expanded: true, // Collapse state + searchFilter: '' // Per-card filter string + }, + // ... more plugin blocks +] +``` + +### Logic Blocks Array +```javascript +blocks = [ + { + id: 1, // Unique, incrementing + mode: 'randomize', // 'randomize' | 'envelope' + targets: new Set(), // Set of param IDs (e.g. '1:5', '2:3') + colorIdx: 0, // Persistent color index into BCOLORS + trigger: 'manual', // 'manual' | 'tempo' | 'midi' | 'audio' + beatDiv: '1/4', + midiMode: 'any_note', + midiNote: 60, + midiCC: 1, + velScale: false, + threshold: -12, + rMin: 0, rMax: 100, + rangeMode: 'absolute', // 'absolute' | 'relative' + quantize: false, + qSteps: 12, + movement: 'instant', // 'instant' | 'glide' + glideMs: 200, + envAtk: 10, envRel: 100, + envSens: 50, envInvert: false, + expanded: true + } +] +``` + +### Parameter Map +```javascript +PMap = { + '1:0': { id: '1:0', name: 'Osc A Wave', v: 0.25, lk: false, alk: false }, + '2:3': { id: '2:3', name: 'Osc 2 Position', v: 0.60, lk: false, alk: false }, + // flat lookup by scoped ID +} +``` + +--- + +## Randomization Logic + +### Absolute Mode +``` +value = rMin/100 + random() * (rMax/100 - rMin/100) +``` + +### Relative Mode +``` +value = currentValue + (random() * 2 - 1) * rMax/100 +value = clamp(0, 1, value) +``` + +### Quantize +``` +step = 1 / qSteps +value = round(value / step) * step +``` + +--- + +## Envelope Follower Logic + +- Simulates audio envelope following using attack/release smoothing +- Attack coefficient: `exp(-1 / (atk * 0.03))` +- Release coefficient: `exp(-1 / (rel * 0.03))` +- Maps envelope value to parameter range: `v = min + mapped * (max - min)` +- Invert: `mapped = 1 - envValue` +- **In-place DOM updates** for performance (no full re-render at 30fps) + +--- + +## JUCE Implementation Map + +### File Structure +``` +plugins/ModularRandomizer/ +├── Source/ +│ ├── PluginProcessor.h/cpp → AudioProcessor, plugin hosting, param relay +│ ├── PluginEditor.h/cpp → WebBrowserComponent setup +│ └── PluginHost.h/cpp → VST3 hosting via juce::AudioPluginFormatManager +├── ui/ +│ ├── index.html → Main UI (from v5-test.html) +│ ├── style.css → Extracted styles +│ └── app.js → Extracted logic +├── Design/ +│ ├── v5-ui-spec.md → This document +│ └── v5-test.html → Working preview +└── status.json +``` + +### Critical JUCE Components + +| Component | JUCE Class | Purpose | +|:---|:---|:---| +| Plugin hosting | `AudioPluginFormatManager` | Scan and load VST3 plugins | +| Plugin scanning | `PluginDirectoryScanner` | Populate plugin browser list | +| Plugin list | `KnownPluginList` | Store scanned plugin info | +| Plugin instances | `AudioPluginInstance` | Loaded plugin instances | +| Parameter relay | `WebSliderRelay` | Expose hosted plugin params to WebView | +| WebView UI | `WebBrowserComponent` | Render HTML/CSS/JS interface | +| Message bridge | `WebBrowserComponent::Options::addNativeFunction()` | JS ↔ C++ communication | + +### WebView ↔ C++ Message Protocol + +#### JS → C++ (native functions) +```javascript +// Scan for plugins +window.__JUCE__.scanPlugins({ paths: ['C:\\...\\VST3'] }) + +// Load a plugin +window.__JUCE__.loadPlugin({ pluginId: 'com.xferrecords.serum', + instanceId: 1 }) + +// Remove a plugin +window.__JUCE__.removePlugin({ instanceId: 1 }) + +// Fire randomization (all param updates sent in batch) +window.__JUCE__.setParams({ params: [ + { instanceId: 1, paramIdx: 5, value: 0.73 }, + { instanceId: 2, paramIdx: 2, value: 0.41 } +]}) + +// Open plugin editor window +window.__JUCE__.openEditor({ instanceId: 1 }) +``` + +#### C++ → JS (eval) +```javascript +// Plugin scan results +onPluginsScanned([ + { id: 'com.xferrecords.serum', name: 'Serum', vendor: 'Xfer Records', + category: 'synth', paramCount: 26 } +]) + +// Plugin loaded — send full param list +onPluginLoaded({ + instanceId: 1, + name: 'Serum', + params: [ + { index: 0, name: 'Osc A Wave', value: 0.25, automatable: true }, + // ... + ] +}) + +// Real-time param update from DAW automation +onParamChanged({ instanceId: 1, paramIdx: 5, value: 0.68 }) + +// Audio envelope level (sent at ~30fps) +onEnvelopeLevel({ rms: 0.45 }) + +// MIDI event +onMidiEvent({ type: 'noteOn', note: 60, velocity: 100 }) +``` + +### Member Declaration Order (Critical) +In `PluginEditor.h`, members MUST be declared in this order: +1. **Relays** (WebSliderRelay instances) +2. **WebView** (WebBrowserComponent) +3. **Attachments** (WebSliderParameterAttachment) + +Violating this order causes crashes on plugin unload. + +### Implementation Phases + +#### Phase 1: Static UI +- Extract v5-test.html into `ui/index.html` + `style.css` + `app.js` +- Verify it loads in WebView with simulated data +- All interactions work with mock data + +#### Phase 2: Plugin Hosting +- Implement `PluginHost` class using `AudioPluginFormatManager` +- Scan VST3 directories (user-configurable paths) +- Load/unload plugin instances +- Wire plugin browser modal to real scan results + +#### Phase 3: Parameter Bridge +- Enumerate hosted plugin parameters +- Create `WebSliderRelay` for each hosted param +- Bridge parameter changes between WebView JS and hosted plugin +- Handle multi-instance parameter namespacing + +#### Phase 4: Trigger Integration +- **Tempo sync**: Use `AudioPlayHead` for host BPM and beat position +- **MIDI trigger**: Process MIDI buffer in `processBlock` +- **Audio trigger**: RMS envelope detection in `processBlock` +- **Manual trigger**: Already works via FIRE button + +#### Phase 5: Envelope Follower +- Real-time RMS computation in `processBlock` +- Send envelope level to WebView at ~30fps via timer +- Apply attack/release smoothing in C++ +- Map envelope to parameter values + +--- + +## Interactions Summary + +| Action | Result | +|:---|:---| +| Click `+ Plugin` | Opens Plugin Browser modal | +| Click plugin in browser | Loads plugin as new card, closes modal | +| Drag plugin card header | Reorders within left panel | +| Click plugin card header | Expand/collapse parameter list | +| Click `×` on plugin card | Remove plugin, clean up all block targets | +| Type in card search | Filters parameter rows by name | +| Right-click parameter | Context menu: Lock/Unlock | +| Click `Assign` on block | Enters assign mode, banner appears | +| Click parameter (assign mode) | Toggles param in block's target set | +| Click `Assign` again / `Done` | Exits assign mode | +| Click `FIRE` | Randomizes all targeted params | +| Change Mode segmented | Switches block between Randomize/Envelope | +| Change Range mode | Toggles Absolute ↔ Relative | +| Drag slider | Updates block setting in real-time | +| Click `×` on target tag | Removes param from block | +| Click `⏻` bypass | Toggles plugin bypass | +| Click `⚙ Scan Paths` | Shows/hides scan path config | diff --git a/plugins/ModularRandomizer/README.md b/plugins/ModularRandomizer/README.md new file mode 100644 index 0000000..9976345 --- /dev/null +++ b/plugins/ModularRandomizer/README.md @@ -0,0 +1,138 @@ +# Modular Randomizer + +A modular parameter modulation engine and plugin host — built as a VST3 plugin with JUCE 8 and a WebView2 interface. + +Load any VST3 instruments or effects, assign their parameters to **Logic Blocks**, and modulate everything in real time through randomization, envelope following, automation lanes, morph pads, and geometric LFO shapes. + +--- + +## Core Features + +### Plugin Hosting +- Load and chain multiple VST3 plugins in series or **parallel routing** with per-bus volume, mute, and solo +- Full parameter discovery: every hosted plugin's parameters appear in a searchable, scrollable rack +- Plugin state is saved and restored with the DAW project automatically +- Crash-isolated hosting — a misbehaving plugin is disabled without taking down the session + +### Logic Blocks +Six modulation modes, each assignable to any combination of hosted plugin parameters: + +| Mode | Description | +|------|-------------| +| **Randomize** | Random values on trigger (tempo, MIDI, audio). Absolute or relative range, optional glide and quantize | +| **Envelope** | Audio envelope follower with attack/release/sensitivity, optional bandpass filter, sidechain input | +| **Sample** | Load an audio file and use its waveform as a modulation source. Loop, one-shot, or ping-pong | +| **Morph Pad** | XY pad with up to 8 snapshots. IDW blending, auto-explore (wander/shapes), triggered sequencing | +| **Shapes** | Geometric LFO paths (circle, figure-8, triangle, star, spiral, butterfly, etc.) with tempo sync, spin, and phase control | +| **Lane** | Drawable automation curves and morph lanes — the most powerful mode (see below) | + +### Automation Lanes +Each lane block can contain multiple sub-lanes running independently: + +- **Curve Lanes** — Draw breakpoint automation with smooth, linear, or step interpolation +- **Morph Lanes** — Capture parameter snapshots and morph between them on beat +- **Per-lane timing** — Loop lengths from 1/16 note to 32 bars, or free-running in seconds +- **Play modes** — Forward, reverse, ping-pong, random +- **One-shot triggering** — Manual, MIDI (note + channel), or audio threshold, with hold and retrigger +- **Overlay system** — Layer lanes of different lengths for polyrhythmic modulation + +#### Lane Effects (Footer) +| Control | Function | +|---------|----------| +| Depth | Output scaling 0–200% | +| Warp | Transfer curve — compress or expand dynamics | +| Steps | Quantize output to N levels | +| Drift | Organic variation — slow wandering or fast micro-jitter | +| DftRng | Drift amplitude as % of parameter range | +| DriftScale | Musical period for drift cycles (1/16 to 32 bars), independent of loop length | + +### Expose to DAW +- **2048 unified proxy parameter slots** (AP_0001 to AP_2048) +- Expose any hosted plugin parameter or logic block control to the DAW's automation system +- Bidirectional: DAW automation ↔ plugin parameter changes +- Discrete parameters appear as stepped values with labels + +### Preset System +- Global presets (full session: all plugins + blocks + lanes + routing) +- Per-plugin presets +- Factory programs with categorized browsing +- Morph snapshot library (save/load snapshot sets across projects) + +### UI +- 13 themes with customizable color palettes +- Scalable interface with local font bundling (Share Tech Mono) +- Real-time modulation arcs, playhead visualization, and value tooltips +- Context menus on nearly everything +- Keyboard shortcuts for fast workflow (S for select, arrows for nudge, Ctrl+C/V for copy/paste) + +--- + +## Architecture + +``` +Source/ +├── PluginProcessor.cpp/h — Plugin hosting, state serialization, lane clip parsing +├── ProcessBlock.cpp — Audio-rate modulation engine (zero-allocation) +├── PluginHosting.cpp — VST3 scan, load, crash isolation +├── PluginEditor.cpp/h — WebView2 bridge with native function API +├── ParameterIDs.hpp — Parameter ID constants +└── ui/public/ + ├── index.html — Entry point + ├── style.css — Base styles + ├── css/ + │ ├── variables.css — Design tokens + │ ├── themes.css — Theme definitions + │ ├── logic_blocks.css — Block and lane styles + │ ├── plugin_rack.css — Plugin rack styles + │ ├── dialogs.css — Modals and panels + │ ├── header.css — Top bar + │ └── overrides.css — Edge case fixes + ├── fonts/ — Share Tech Mono (local TTF) + └── js/ + ├── logic_blocks.js — Block creation, rendering, event handling + ├── lane_module.js — Lane drawing, mouse/keyboard, drift, overlays + ├── plugin_rack.js — Plugin cards, virtual scroll, drag & drop + ├── theme_system.js — 13 themes + CSS variable system + ├── preset_system.js — Global + per-plugin + factory presets + ├── persistence.js — State save/restore across editor lifecycle + ├── expose_system.js — Proxy parameter mapping UI + ├── realtime.js — 30/60Hz readback polling + modulation arcs + ├── controls.js — Sliders, knobs, routing mixer + ├── context_menus.js — Right-click menus + ├── help_panel.js — Tabbed help/reference modal + ├── undo_system.js — Undo/redo stack + ├── state.js — Global state variables + ├── juce_bridge.js — JUCE ↔ WebView message layer + └── juce_integration.js — Native function wrappers +``` + +### Key Design Decisions +- **Zero-allocation audio thread** — All DSP runs without heap allocation or mutex contention +- **Additive modulation bus** — Multiple blocks can modulate the same parameter simultaneously +- **Two-layer meta-modulation** — Blocks can modulate other blocks' parameters +- **O(visible) UI scaling** — Virtual scroll, dirty marking, and batched IPC handle plugins with 2000+ parameters +- **SEH crash isolation** — Plugin hosting and scanning wrapped in structured exception handlers + +--- + +## Build + +**Requirements:** +- JUCE 8 (placed in `_tools/JUCE/` relative to the parent build system) +- CMake 3.22+ +- Visual Studio 2022 (Windows) +- Microsoft WebView2 SDK (1.0.1901.177) + +```powershell +# From the repository root +cmake -B build -G "Visual Studio 17 2022" +cmake --build build --config Release --target ModularRandomizer_VST3 +``` + +**Output:** `build/plugins/ModularRandomizer/ModularRandomizer_artefacts/Release/VST3/ModularRandomizer.vst3` + +--- + +## License + +Copyright © Noizefield diff --git a/plugins/ModularRandomizer/Source.zip b/plugins/ModularRandomizer/Source.zip new file mode 100644 index 0000000000000000000000000000000000000000..cdda07e074be5e4403d4d105f8372f1500269c1c GIT binary patch literal 209696 zcmZ^}W2`7al&-yPn`hg$ZQHhO+qP}nwr$%s&))aU%$Ll}mwPK&E1mlBRZ|4$JJ00@9CZ}tBrg8ttk@&=9uHYUy{juOI7wC48q|2NV95E~HwFHxH*2IxV0 z1kin+_}}~TDA~V&hw>ES;05ynA~>}S<^>ZW+QE_UdoqY@cAnjDJA}fW96(-1EsK*$ zO-SSF%_vT?tgJ2jC8&=nB(6kn%!J>n1Bq(KM_+SPR$B+7&8DmSnowKT{9zOQI<4$aR$V7aRiPz{HXe&!Y z3E`rJb2hC0rLn$WTq8Q$myf@92&6{fy-Dq=k0s>8UirsK83+y=R*QBt#_5oTk9Z|ff9*$ip*cG!Dka)`Hh!Dd|%LmFaY zjeHm8d^~d8e9rFUIZfeXS*LoXs`VNGW+AJ|W865s(Vt29eXeb? zVPSO}2M<#_MyolR4Lk>kTMmDRJZZ(_2HEhRe z%to!>!Rx`vy*=R?3AquWr%->JKLhFX}lOuba5JPc6x@6c9hc_)B0w0 zL-ksPekl(RV)psRZAza2=Z%T<-o|-!yF9->7k35vnbv5isUhHp4fWMNBVZcF zXJ^cr1)z%1FmS(&8Kv2gIb3_W1)sg{#kR`I0;oi&LvDGKT5-rVWE=A5JUJq6Et2|W z3Nvt>WfYT}hLaH*2GDi4(_oX=PLTdc`q~nkdq99ft@g5D;DC|$E{w^2Y`s4x0BxoE z4DcX)eB1l@z6|v428r8gwu=*UmN~Izwd`#?zK<-`vQfQt8K=@wvh4<>cbw z-on1U3#0`A9%^1t@s`?h450E1=5|g4;H)0XF1jTTdb@ z1hfA{xb><3LXt86*7ZIg>&3|{4yxEG<9yurCy__Ikqv71wJy2k;tKkA_Ca8b-6sF7 z=J(50KY~Ik?c?d?_~}ySYZxgFkVlh#4~YeUa_s9eB^q%SYk*Y{wrub6`+i|On!?{QB=|jN^?Vc?o*t*nw3cei zZo0yDKs811PL@a@h*1<;Sfq7&777tOrr&Qd{4#LfB;l)*>88>_UhZGSYNxh{;CKG3 z6`An7xbdebGkz)aS+0J50lZ&LpnehzOTJ|M<2@+=K)ay0fsT0nML8(|LD@w9`#KVU z{%Zkd3W4#yGr)-q!OFR4J$3Z`wM!dsOBXg?8uvdpWRXRsSQ7(ew`=!{p#1)9=q!`8 zZ%3MXmE@mx9KgOR0 z$G&t|yBcZVG3*fm6?olVX7+_x%$gckPwEQjAKm6c`c>LPM*8BcF;B!|(aU_na{coaj)zSmc31H%z4IHfHXlXgb zFbuE^4@A<$AQiyzr-#~mj{9B*29AP_GO&Iu>gMl_p=sk(6D~3<3;`Cux$<0-x|J-I zs9bd23Gk`9OD51?`iY|h2aBqIvQlL-?fbI{9DNR`H&r{pmpyZX^eyYI8Cv9M9E(mo zMh0Tkv=hx>>1fyv7I55>blKdqK8h#NJ88@n#&jVHa{KNp>OnpIkXo`h{Xk4ScKgXd z6%G*R4%X-u1oD0~(V`90TxX4O5i+g)7hS%Ow@;`5VtX~wa)}E4;|KY)tz0MT&bVAG z3#n*zHYd3IYSHQy#N)XDMjZM4`HlEU_0J@jr$KUgnFZrN8#$*-307g`GD9c#7pKvv zKD=atn1X=h;EU#f_XJ+cB|jW~yp(n!nx`S(jhl}#CESj;Uv8koi^^0H06^ZOddm^j z7g;CGu~uRnoh(ZbcgR0~f@iJt2$9FPJDyaGP|T0hJlSyZGJG_cw8$2f6;QZ;g|4o31 z$72N@XdBfY$!9?hfi#R5ny;XQjY;U_D76`@X_;lzA4&@ekYbvTr;t_f(#2k!dC~H~ zpuNcCpK7!Wsw1yi-e>{|eds4a`N=L4etAfnbjxnjc50j}57cZsDB&Q=<61{g48)_sF>*S|4C#{)CSIB0}(+U{$ z+An5wW+;iSGG7dduL@|qtjQYlUaZOU*P;`krKKgeM-J!5NRPMfmjVYjwWL(@UYtaw zV}NE&Kw7Q+;5hGz8u@#mNnjb-i`94+6{g$gNxsWFZ5cj#7N%A@Iu)!!%3kX-t1}q* zquKmXxR4GBHaoK%+2HEWZ;^*BQ1b1Rb|9-mokr4jm*(}yQ9JTm8t0iko2DdctE~B~ z{4g;oOa+jtf@>@NV-WnQs20y*)Fh&$I?}*v=qO1^zc5};ktNm0!$|X`V3A5U4POQ? ze}cREfdH9eJ{?>W@s>-F;4*H0f7XQqDjA>=sJ(9bzWt0@wCmHz8c&m->|Fm3Wgb3U zltWMV-d$aNTnx1B`~D{SytZV>Gi{@^Gc;$K5429F^C#wgCGLQrWCiT9yAgfj8sA+Jula6)k?y@yV zgLJfpyR{IWsv}=urX4d&9W97ugETFA4@}FS*Oq*qADeZ~S7fip-++cKC#Md6RX@q> zc%?WOXzFdM#?!x=II`VzO}ETkZJ9RZ3+^M!gE;C^l|2a%9<4cNOv*kTJ;?a)!R)rcPyG>Ns0UW>Z9SOZ zr)o|oC33V6+Osrj2~wDxYP@y6DX0w^o}y>mOR0dv{cA~&y5 zFCwja2!=>6zVX6IOxu<6_Oh!ELZgc--6zBRlyW9Zi&72bP5axE6-FIf6p%QZ#bVq( zQz1$=Nc>Wo{NA_K&X=UC2zl6=!<-0FDpEV9CCfId5;V4l_ZH*sG$xl=`i^XeZAZqngw8`HAY>h`TgZOVWs>O zit^eqYT}Yxt=vU(y$`(7T&otDWKG|Z99``vuQX5GHpADK@6x)`b>P0y_YNr&_lAq! zZHEIS0tJs%CG?z@I2Qd5I$wh}S#P{TvOY+G)w+EATm^V=#%R{yr zkuT8&a84_SszsvfMDBq+jaGK&d;XT+&{N;z{@0$qU+|2kI>BV;jiS0cw$7F86Q>f% z#K4pBx;OBi#$g9PSMAbukGhA0gkXh_$KmRSnt)t5Rstku~bZtn{4~kje4%47A>2(pC7kvd59{C z#!j-B6S>N|Dfxp)%0k_ZGa-`5MDZWeUqnX0|Pu3T7b9s@QtV`V80w ztocXqbu|`$RU4l<-h9$N&`)zn7TK>q2uLWJ$7yc5I69)?XG8oPoDP@b1@_th)EY>0 zNEz>y)D@4nW~5--O3mku7Je>r10tT_(7>zTB(0tqaqwI4FBD6^h&?GanwPP6BU||8 zHQM!R=xJTm4UjK*!2UJnHsFpNLBUz^xK46Y(ZSqb6q&#^X-EQtuH<`)6}BkT=Hz|pxO@IUMKM)!x!OU?PE6{PA$#WW zUV9g1Q6sTbo~oVq-(KpZLI^?m%9)QOh4-bjXmL%M8ls~yJ<4y)2}~{&#Y@$A4LQ^6 z*v5PccSQ*$>@=Ujxw^?HlHNyh+orSae@Z5<6$^d~It$&>Fq&^wC}L`SA4Fp%3%s0J zL$`>P(sL)KF|v4yQG8PUD~8Yp=r1DFTZ3;O@{O1<9@OUwC-{@U)Wn2X8&n^VqxVHm zM>L>wW4yGe8CUCqdAe@=lVUUrlyUj9qSlf-gZJ17gjcO6sH=&GvK&Z` z#AWzHX_yP2{9G?fhnMu9PsL`0CxFN2!o~o=9HZyIwZ$a zz~GdMg$wZ_-BO@}4!WvM(ah*h5H@v+SkKPx^nwlO!6 z(TqRsGnu|kl?OB~pB)xPyU>Wo@cJF!_kh%mosP$)?}C^S*0(`Ux!w3E zVj@9~Pp7oqvf%rs*TShpM0G{j95x7FSzakjf}tlC)%$=AI1tF3h^mEYVm z7NaJx!!dEShlLse2;CiSXkuFZ{ z@Pi}uf&DoP6`@-gt$gkk`Nkl--}rJq#Rm(#gwt~kYZs7lgGOjWPFLEqp<-OlVg`L^ zhA;B0t}Fo>rIx^81P@iBe1Mzjdf% z(R(iXfIiuCA_Txi-KYud=(XWy)3Vy#S*HEI>;?>OJK-dV_A&gc$z5BabVU3)Ott6o zbAEU1QMAJSKzk`~JL06JQX1CpvrvWKa1U7Y#aYO56=h8MeaZ`e`XtE;Z&E|Fn3!9@ zS8Hh;^=nuId86~f942Ti*lSuZUI){-M~yfDxXa_66C1NO70T9W!4)FNw1cNx2t7!7 zbC}Q;)r|8k{H}ybpcg|_xzoQzWiDTuOJbngZ%duB5Z!F)b%o0N(7Ia! z5AL~8H}+2L#Gm{Y647pK@;)cW*TK14c0J9Czdvj`I=HYmv-0bw9@}`#2EGbR(g2h* zvAT19OV@CKS>i6CnxFR_*W?uwjDJ&8MZ*TOo^qIX_Q&+5;?KPYC|ZUyrAl1+(-l`| z90U|wS0lwR$|QV}!0L;%$=xH(VO($*G)W-#2bbpF|1-(`vQf%lVvp26HA`nrHHtx( zBbpo-tX&x8DxI=gm2K>&;`h{@kECy5aw^CbVu~o_m|?$Q&pn#0;EBD*j&?G&s!;^G z1M6ihro|AOdpmx_5Nb%&2Kiq8J+|I~MmiS-3UOe~stBK_HA_~Ad*rR}m+Z>7EdMVc z$Y&TESC<)`Tiagu;)J7h@hyK>zv~0yFW_?!MoCdQNuPE?Vw97C;Z$G5PKVGFzvGIf zh86vL$BI|G;0psuG9E%sr^rRV72tw!xs-JZ52ZE=#(({)(>b;0-^n9DJ}3Z;eEY&= zwpdua0!a$5(=194t161GqPnBjlx(U{i$?80=(<@Ir>n!EFJy9lb9 zPdpC@sSQbtRx3dDf5YT4Jj$Lls^_ik0zJMijY^}nhrO!Rxx2CP?rLcLDNC`Yk8+09 z1oU-oh7`#vDPHXkOpjnLO|$<%6$XFDm=f0h=;*?sCC=rte6*zZOfX7DXPsEf1GHR=V;9weZZ5jyuTFV8GRn94fEk#pMV^v+u!)NWszTVa(9WRNxM+aHl&+#4Wxa z=Tv7@&|OPB(k(Z( zHk=|Q+}x<>rqd{JOdD?YP8%)@Iki8k{TEX>yY6s&D_5i-D%`p0=WU0D$6jBv1!tPk}jtQUo_ZCCihj{y8;(NHaO<8Dqn_| zjl(7^mvLTc9j{h~mMh?)ZRxa>4dpJde1(xhMs|~bFo9erD!d1rkUUQQ;FMVkxW`U= zJrvZ*+EZ^7iIy{m!Me6D2U*eIfxZi$La9AYPL;^745tA^QTtsax^0NS##z+{V8k(4 zBc%hSh&JOES-#TqGGGs@1J$)fd_uRFB64WH=5Gi{`u#j3Q~lOApsW%7xmoWG6xXp- z4oFtA{=-V)09tPyG37lEmKJHy!9a^Gsx2)YuK(&EmQ9FUnS16?z-@?XwThqE55d?) zLLwilS5wHH0R|hX2$Wkyg*eQpjVu$i+KKd4zLJ78RkCc-oZlI}Sw2DoKC&f_RcR%9Rdr->-yd>+Ay!lWK_~-&Wr|Q{n@q1->Zx%K9yJdJU z#HU*f?(PGvwuj9r@li_X+It|{gBB=u5965j$|U#rN)tzi@BH3*Ksh0Oe*UKEkW`a~ z$cC=jn##H%Q`3Af_a?Qj4O>)VGK2aAcyw0a)Pu3FzlaZf@<($WR|q{#Rf$>tHVDlC zn^S(;QHwBi9|oz^GlNXO@B7Y=Z~OPL|8(cz^1}Wt9oGie4)^*na%bV-=jZ-~Y9=(P zD`QtCD$C~_jr#r2TNT1r70f!AL!|nxh$RN? z3lfbHXJW-g-VBeAXDKbQL72EXc#QBiR<%wLu}Zs+S5ApKv3;LgU!uRBRZ_CLb4OCC zEc6rSld|x!M&|Mu_ZiX2XU@e2td3SCJMci$+FJ1tHpcSE#5+}j1RIPnAz#>d2xF5h zIbx6lw26~|(BV2yg0hKr&XQQwe&`lpO#}AMxW(rw`Iu2-V~Zq=f$d8x{zyr}wN;Vv0K%K{Orr zZ#G8(%c?DKU3RVGfSGmvQaQ5#L7RY8XH!nZNleERI&ajjP!@cNLUy5@75H`SAj=46iX;O3=Ak2uSKxpiT6;_T?*V%E!PRSo*z*~H67IgD#=;;X~bdLcFD?I%g<(cwW#06Ti-sPsH&4s ze#RIXW{#ST#CbA@{bU4y`05lBQ4_r%#E)qf$DK!-D8@^iEVitSd8+?GmIdY z$Z+tI{4^N8%xo#O){rX;ixrjf8BupIG9(Y5DH8&iio8275%orBMJ}|HhdQAaMHq?f zgSR|hGp+&!;(<;j6xoEFsWJ8?1+0oovw!Iuc!e{1@tPMb`lE z4++uQyydJWcU%tyk$i+7r7dYD?Gy}laWzY;icj?CD=N}q#{tvl(ODkA81m|nr#H4>Q49QxPorA4wDCv z>8Rr9Xjy7u<1wIi=X$lYY(0uK^mG(>$I&Y)m9TOyTTV=5%Y_PBOJktFk5g z#D|5HwgsNMK`of6^e%19zCoVgSU&Ny-$2j*m78oDg44VEjS(j2A%IYBzB!V&!imEV zGiBsBp^b5EKYSycFJOCoz?iANFoYAB{DKv)_}1Oj&|AJGDJKk1SNJlSv!7yA6X6pQ zBZa<_s(Thm>X_;5-zRW7wP-3>It*`5e9EJIh?5?w9z%h1|=`Nn?%|zxSc;(sn6yh}cSl5hz6QNydY!IPV1`@ta&#$^G^#uC_&7+GMAX`Ldq_u~f{!(PaMt-ku8BLt3ZBg1zja%++H3 z`9eCu%mx2*g6nx3-JQA|k&ulYVp2fO50(RP z=s9Z7x64oZw`Lu@r23GwLrYFe^QNMS58pd$l8JEzKIFZrI~i=g0`GB0*2no!3LD`< zRewI+usD`Hw$g{~=7#~Daf>Bn+WB7-2h-{2Nzlvd+M{I{7F+0=liK2CffXGUyDfA* zDnnySA3?DR$lKV;Dy2s2a9|o0I7gsxD}56nsAC`~2iE_X4i$exSBw|+fp%37M$8iZ zj(+l0*KKx|n9%G}_qwU6FnbgF7MybKix4{yE&DB$MnLcMZ|y11QfAE;I!U^1c zmv4LbLb3HtvkuwIvjCFW*ZXNB0O_0@DH0 zCq+&f?(5m1v$t{1H$tH7(g$LSnZN#5`R<$j!OMK=m!gd?gepSkWUW$xpXS{O{0CqE zyihdl+XK<1+G%OG7=HG)M8X&`W+j}Cld?1x2>QWMHMUCxKhgLbs7`^< zgAib%eusN{k6H+GYT1=pKipKEn+tPguQaH4FB>k;>>x~j8O}itn}vJ=lsD$M{Q|nbkAo0(AJWMSogIkN3M$WK}%Qe!haTI`jZRAN9&)}*@M@%}8if#tN8JXF z3fyYGGgHeh+Dg)Z?W#S|mBRaT7tbii^WRtF0NHxbbkOvI=HzNwl5*9~6mRL?P|Y2b z6_iXEnldOQJzBN1dUa$~DMu&OjPp{|Wfke|X%1X+X&lrc-&1!LPV_M?@|gJ8j%cY5 zU;i-_@b=TGQ38a)-2g>pa5abySm&j|^tf6?5sI%x6}nQn^rq%TNuozGM^&XF=YxM0 z@}PKSq{m*0O(>L^^`E?P;+pPGX~E%^&}V|C`jg>{7r=;76FhXmJ~U#d>%&XeW))Li zg3s2SH5Clt%Y=!5Impf{58NKJiO{0fRh%iFsr`6I^a2ENBicCUE1sl{!cEXssiO1? zO$k)?&_qDWT6ib{rZYH_9u@1AF+f>F2$Q8JIqttjo_Z5;Iq_p4)Y!=+d-e6#-zXgM z8j%mGp?y=`fpFf=R9M%Sp|hErSzuEeGhb10Z8xVN6x#DjH0kUEH5x(CYuPdMre>^u#&$~-p%?p}OBxqH z1?GcnLdFqfyfKlyfkvqC=9IX4BBgyMaq5E*V9I!IW6}S!zZ_!U$3C4i&#X` zF&ki_fW(`=gQ@B1X(sOvNuo3n5aH{^>FDVw5!x3wEV*^ggY)De6NPtWa(vf;>c#W^ z*K*mB#pLL-rc|Sv&kpRC$!0il${RBpV}2so(U%;`icQhn?(P%aXa4Zb%?#{%s1zEw zAsi<$mS90M5D+@i9VYSWy&dYXa1$md7CjAdH}~D0=BR+65i#p?B&jfxjKLr%(wI^@ zR|O#$4H5!!vK#>+_3W@Ii0b0*Y3N*p;=cAy)0?p+3xPx(`_q7X>jpQHFV=#3*V0VP z)(ouMuCTy+b%OkpNVW4Q-y%|rS2U?e^MU#*Pi%Vg*n$CsEfIWBWn z67wqy^2301eZHz)kx-{L`6fxW9^$W#&%j=ArGtEvq-m!!D0+qRj4A(dS4A;f49TPE z5?dg=QjLgq-T6tHU2vufH4?_cW+Q6`XB)IFYk=m8@%t&qFvVF!D#?vqRV=+V@rogr z*vu2j*iEyK1R?{ia*Jorq+i#i+H`PjI13hc`WUl+X3{nQjgqFF3g&HPT{6|@KB$)$ zrZ>K@{MaBqF7Njiy}4uCpZNJ1v(PJ(Y=V4>2*`9x!Y}ITbM%K0I5SYtAhRCn zQvD2??O|%EAU@JI@XXotJYbl)AV`qcN)2!ps$BOv0(IN;ha5nlL3DY~-YyroHUQN? zJhbjG=5)@@RFn?p4{PxRzW-HAmJy0+uZ-;0k3?-dH48x`l_*FSHA9l(9q9(0Mx)T+ zUpo!+lM~fqnKEmcjEOL2n)a`Dtf50keU1Kj@n~b?y>yA=8rpq#w&%Qso85JVi}N~~ z>V32H-oEM{FsfU70TU{(Q59akuYGaxnv8%@ls%5$_dyI53smYaT_Wk})OY@IK8wtE zI_S1jr``(V`~5t-)63_&_WEmk)(%O=+$nhKZ|E0~Hmr|LM4UK_000%snYJ%gC|D`o zBw=LDA8;l2j-wC~@G2EOH()O*A|ppRL8A^9S`8^QiRg8IaJJ{@bMuQ^6M>gmSw)#+ zHKM=9^;kgA-#MS28hpS}l%>6Qy}^IZx8GO;u@8(eh|s1gNeFVxX{|!^ldyIGq8w3DT2k zNQRi^0;4~%Wgnx~ z7K!>9lj#w+m**;4G`3~_`hr&b6^`z+M+)PmGia%((H0RJNcO74Gk)|sEIHO8vA2(~ z+2D3iv-fO+)|tC*$;V}p>qXRlyTa38(D)$Ki_X!OLr7gj+2|J4-@$+yftRHGX2uI!INr|R5%JQy)(b9^H?=Bx@R%q1|U~1ZEq-B*$`Od6xGGApQVXK$n z#2Cv2Iz{YIZQ_+YRLl}}B{}<8;RS}YO4H4NP-tzzSoIFw)YZOj{ZNRVCsgdB>EBEv zHK>8=Da2`qIr{JsT#gqZ6g$_rNV;qL=6kP;w?N=8i4Ck>doQ;wsHWXMeD0FUKufjT zrz&a{zH$scbgs;dgGtPd;Bhqbs#pTG*WrCB#b?KA4%-44h92kX39nJRRmxZ*ca4YF zL#V2GY|kQ1RZNKoBopsM`3YfvE*LgOXq&n1lvguo37@K%3-5MU- zH9i{0X<{*3{~Vd6!G_Y5F6XHp)J9E_81M1?ka5>hyPrEOpMSKUOtgcQROW%qFoqCt zA&xw2^fTuGRC zAb1WLA##;pOxhQTU9NUQWr7jtOj= zDIf*=q&{dkj(t(XHtLTCKBP-Q<~1H0%q|i~d|s0x5twMot4$D6edwRDz&PT#VKo;(KI)bZf6_& z2=nSpHx>9HwLa`k={1+)o$a6@+_9|)Y*n0o%)M)vU-Cqau}3*bEvIgu79JlW zf)(Kq@1&8rH5oE$X^{VUxm4DlytA z*_uC|l=l2LV0^t45hl#yfO;>CKOuy^wuMA4 zjn>R!pbi&`!+0RMVGvhFpAk?q?5a?uS^mitAzaFAcjBgL&db6c@J9!4St zQV6c%F-Z8GoCwMvq@G&3ik+kx861hA*TegM+Ep_TRsQLVZo*ei-Y*s={@VdC80;G_ zv1{2#a!yb$KwF2?{m`?p&jkniBOO1M8v1P$(te>vMU#f%dUSK0zxW%x)&y}*7Hz|2 zfvKvsJpF&+#!a|Rx?`=JVOW|7V&m6Tc(PA*>n^#Z~mS-+m?<_bTWJIr@{%_@7V0oWN%{`*ibdKW81Ka1la8wArj z7lB(FjgL(G-gKNUV1ScuhZ2YinrI=Zgm?zq#g$#}ZD|TAk=F=f2-C@qrO{Umpohlb z_=j!X|3uxRY)m_dq@B=}vt?&B!XO^a(bFZS0WbCZ!$7K!Chl9`wTbzVV{M>{n3iD8 z#p}JeXdnWlC!ok>GKDc0UE{i#LKB|3gWK$|*V5=_mFYyP2Y;h;BDohKHSlyx)*`A4 z#feGXiu)v=X&sZk1Ao#TVK!yqL}^`AWL&1xG6$G;EJNJs9tmx5PZN3#pg-AgUMx_2 zlU<-dHi4=Qdz2lr;cC2NnMw=E9>O5)TK0}?kt4568*9oDh+fl(y&uOZ5mt!ak^wP$ zdeL$4=Uoi#(*1z<%_bg91u}!5tvezq`VM??Ts^WCzp6-)!AIp#tU_yAETyj2;La9( zy~ojD7^Ikh^~^)B)MSLq~IQt%skW3yo7)2ucyhb$`MUX)DQnKa{$Y#Bry1OEU zh~Q;07=!p5xxxiJNI=O^ge?;S;cEu1*NzF*I4+RA{QyNEHfn=_CkbO;w=Xw*MB)vV z$)QtJ3Q_QRA>HFmV2*=Sb0Wx^o1}c~S+|H?GmBKQ)mU%ru|~mNwtj`xKo~m7gK}pf zFV?2T3=m#zdE`mEJG=7HqLdY8x9bJt=2JVbN0IV|Yy0KOiJbdcp~xHuY-R;qbD%5b zd7jiC$_-|xwQ%^0!a^g8vH#8JC!T7DDjr=82U$<=QhNn}exg%!H^|t9Sky8q;dPA^ zn<~gr(U=;Z@pqOOV(n*cRi9jJQN) z5rL8iw1PP*a%tPb&lj(()SHJjPVW0kZW)Hkt#VNgAxG(m>$Uasj#*ACj}aC_>n$gtYq&dyydn zK{WBoVoIix(Z-`9*80MdvHB#YwEd_BJeuabc;$P5cRiXhz%j7TfcfWb&K3vsz)k- zf>basKwYL3$m&l>4ejwA$7N7iqacyqnJW4InwL!CPY}|gfM}X#^b_2l43eX5MCI|H zx#T=mWZu|H>UThj+PyMgW?n_`)YRfUG1P%5Q8o@GIYL_vl^X^O{nYYrm9D|&S`KC? z)ftX2_aMCp62uR}+bEc1O@pzV@GUNX@d4q*(LkNZ>*B#v7#Q68%cIGx+kF}7lbvxz zLj?C&7U>TbnhHmBDDQgD=YOj9bD-O!$~OS0Mh} zp(hvxREe5ZnmZOra z{ykm)8k)QuCVLLrL)292N|op0qEoEXwg4rr)^vVRyo2ep&)q1`A;@uj?vF#b+X2S= zSFt5F$ZF`zVN?oPyUyTq=nF9xJ409J4)#{0JM;rq2{+1CmkhJH<_CelcWt_uKq4?f zNq~by-pLB#9)DkaF@!-ZB%?uIh&kDBdm-@UZrKM^6|G{3ro33eFs~0gDQVPB2r%le z^RjS-mdGZ8FE}t@7ph$7r0Mdl&+-8tY@X)DGqfPsQx})MB4NNajZXh-i;hjvhfShVrYdhI5n_{ciIgwewwisk8GOwO7j+nE!qv5CZoY(=gSp22rT$7jR}h`o>R z%v2P){vMA=a>-DE+3SRh&tZy4*ajI)O~4dcmScHoTGtTJ%LAOl!9NSnlk#3LDaKL@ zWvr0;Ze6*P5ql{{X0>-~D^jLN80Tr4f_vqSbDIU&#DOzkpq#~LpE^UjQZ*m9h|TXN zf}Ix*E)Eg8{2f7PmY{A96G~@j$l{|oWWYR!(OUGvP3%Bl2@DSj@olMKWGGN5Lq$m;!QtDzO3Lvl>>Aa5^v?eeZD>qy4Jop-Y zkzJ`pQGJCtf_B9+7Ni#ajX)r>i>>^9<*@SR;n;7IOX4)gUlxO~hTy3dSi;Zvj^SG++?{b`?sB~RX!_o`2&;n`Im2r4cZCtU1Q9|{l?Y1i46U0?u(rkXP zKlTOyHfufy%c-pKw`K3Qnd1o2UoF9{sWR$$0O|n>|0MDYKQi%)u`I>AdK@eQMA9A4 zcx?by51UK@1QS#l!98AW|1UGD&Kj#HaBG~~;kbusCA7IVZR}}~ihz)&e-z&RV`BKV zYOOXeYu5X_oJaf8H3^nviCQv4_rV&@ZfB2giX_gU(4HkN?Uc}jCFP4h-zg(~2a@8& zeOCq_|1%3n4M1%DiCtLWRW{NOx2q%UK1qP8SGIDGt?-? zpYZzI2LFaq#g|906Z&4FnAG8wh0#@z58E>QJS- z@YINF_lrxnjJTWSA_%GB2dEIG#0>q%pn|Cgt5k0xg%w0fy0b!JPc?C}$im>udx=1h z<$?jXglit37AHpfvOyf>dh6w!N&n&+8*-bkO20;lXSsHLl^V2hs%_G-$V-3q0Mbff z1(LU!!&p(pae;4SBs&BoD#2KbYJbq>CPr()@-?)B2JmJ{%8|%9d&HWv9e#VM6dOs@ zjQSI(P{DC-&Z0XmnqEy>{m#pUDuRbJj_|d)JH7GOmvzp~L4$KH3c}9nMg@SmGi-%D zf!A`t5>Hd|v&*YAB-X(Lof0UYeLT&ff|8%THw=6UnppNhGLYF^7s#L5S-FGWzS zGQb;|L{h+m2LMM9{5xFIO)zsG7HvT$A$%n?)v~@Zxldo$=(4MX_;}sjYMW;1xIn8v z85ejDN;97``|{%aP)|-j@mUnl1y?n@W_P=-i}o`eEkLRPqc>28+n{AU@0NYtmExRf zRe0c2#puj`(>K~+R|sxR^JEH%$0a_B;p$3U7kADD?4@m^lHsNlvHwn)Y7!g%DW}5g z1T_6>T_+uS1?k2m#S9iG8z~VxxN*m6fp)0;Bct+Nz}w#R3_9Ch{p||@mLl+Khvk`o zUZtA|tzrL1MeyN~yxORa#6jZBtXT&_cq+$=S-KzRAY3d-4zpjS3(zO5XrwIh^M>Iy zEFc04Udp~;3ghSYA`q66L$0TfM2>ELW8R!{eP7_K_ zBm!hp&XkFobhHT}vX0c`)n}Gk?z&2~smBpYH>nsywcN##9DjSe%4YK(2Sg0EcB-fb zw$;iTRFC-sMlv6oC4O11+gcmQMqg#=5OKL07G$qh`o2a^UZ;-CMDdCz^3%CL77Iz; zQ>4-xqKR0BpxUkAIum$0W|$^y%=zx*j%~Nn)pO1nruS`*>w+BV>tWOe^W{1k@LdcG zAzj3M=-1EJyfYJ=hEvW0ykD-ewBo)>2SgFI$qU68-rwDyt(5dx2og0Dg`th_p|X3x zCh%s}EqDof)9s{k3B}HCt?=nEJE|%L1?DKcZCU6oeu)$@r=kb%Z#rS~G`WhXTo_FUKn!sLbqSLJsL0AoXqX!a(s(6sUUIk~tB zS}Uv}Sx{QtTAhP5i9*^%uV81{TWIZMf!kx4g?zp}t*(t$HiH$e*3FO&Qs_b9CNSne zFI5V8yk#wTYHh)OGW8;gJ|;VGKd!J>%HTFsX~Lvryigt9EFgCjgTVRBW7zB1bAb3^ zUJ4b#rJCtUn)z3(jCS$iVudd2?vK$5iRCp?JG;DhxkkwgQn%j>XCNYJOK?*o!kwcw&r^6rKgvGUjUT<9eP-X?7siwb< zG>RugTP-!TA%z0B7-E~H9gQP85D|42ZqAD2;+ks*eu zgIX4T_c3abC~&rW-4^qOeVLyL32?R`7tar^ zZ<_a-0lQ)F=sPUR7(PDME8KLTjJ>rhqn9EyQUNY22_+qP}n&W>%{w)w{+rKLo~@{0e<0&FanurHJL~V`F0o3z+n;e#uayZvWk2O&`qA)|Il4xIc?b z8rp2Kr5^g?TImQ%)n@h}|Cw2rBNN~;F<*TO^hg#b^4S4h93kCt2>q6l@acoe=EnPl z=SFB25}6zD)&LnTe<9FO@_Q|GiB`=*RFm+H|3|?wx!?B-`acmM94fL)E-DaEv(*0{ z0j@aw-y=Yr|7QfK;AC%X>g@dg?)n-T)b-TX}C zsH+*YMTC8&e~G0^BWl2*U70cD=To|9BQ!u6@Lu^PX}e4iZL(tmn#og`0q>K-oN z+%*ej_4gOM2=0(@+q})3*`#g9j`l$!r=h#~i~?4wtTA>5nB|=Ke7!lL&6A;HkuRMf zeu);@^uv^~jBH~sjMZY->y28;S=ehV60F!B2cTg8>M=~kSPCXbjvCF`PB!D_orc<6 zSb7oDv}ma<6iKS`=ubIU&0DpPPZ{}XiaJ7VoM{};Ms4xU76+;8@+pN1c1ag3vK``D zy~0ds$1YJ?LtI=vW2+?8Zieoe+Bbi(2S2*A?qHfa!CSu>sj{Nl`Cajyu<4_DvS5Ki zxCEKnc2YP7fj+c)Aeh!dAk(i4Vzy-5C~g52+hCV^twRw`)rQ*nUh~~o?Jt?6$uKl` zvzsk}K>(SQVk(rg)m6QyZ83V`yCXTIX9m?b|^FZz?tJ#Rnxg5?*^ejc6!*?TSVbckzuo~W@ zS2v>rvDa(bD*b4&LO}(jVQLm)RstBNt`FEx!aFtKUZLKTn*Yw&_dv!bbxaFZS(U&s zN}4I!9GR+e#o4iG%{_KEs8#&T;})W6t(&Mag1upA!UCxtZ@97ql?`OlHZRyu>YH_E zoy|~xxczH*SR-Ud+lOa{2};^#rxvFQ2iIL4odsF2WYPRCkLeRxs6*si7VjzN3+=7f}QqW`$|;zNJg0p{}%#j9ZLmkmBGgrq*F{!kF52N zS}i+6V?z(1$V=||g^ep%I9o&Q0kHwkqq#VL#%Zv6gZom%UU(+y%56rnF$g)|pUx)9a@xPKF2&E3FlG_QTXY(+t4a zi&zBs1ldH^4U96U2`yd9q<(lO&w%v2XQg_M2&fB}uUw<2wWY3u#nj|KnVyI}WK!EI zH%k6Bdh(-IsvIJBy--w=Q?_d^3#QtfVs}p3`>2*=W581*5j0@Y^Wco77VoWm80ch> zUYEhiKAq}$Z-Y26-xhyZm?T9=ye^6p)&6NYhM!rmBatn& z=rDh>Tsm2|0fHaWia*tx?x>v0gwIpVY%Z1wh~}kupNBi3ja6#{nD}iAj)a>kJ0=TY zIVda@Jn~1StjsEw@oG~n7n=XBv*n$h*s6>M;WV?x;{+cjNVvL=P8}VO$_H`&Ym5N; zC9mnl>Hn{?&Ajlx<7Jh6+$esywW;5^bS_^xQ>zZw|Ip@h(&B5<@;$MCp09fLtXL!| zRsy;R=gH5O45RinRoTS%NAACvGW&5Kiw9}t_cmGe!m0wCoSC)mFo?Vm#LiSgBRJW_ zG6P*zH?mFtqT!vb58pH^^hjXNk~Zcdz%k@ZTvmOpgtA!brV{#j8Ww$qDsSX{xc9|t zd&Cl=p$E^x3?Y9snHKQTr71JJ`Dd!}U$-;feM#spf!$!iw z&!xirF)2YjL;ExDAo-f&r+?&V(C8_evGZ30*?Yy$s%!GXFPoY2sB_Fln#^oC3j`pv zWtymkb>nVx){>zmvy#q+7BH%($Pf)@e*rS2-A6^ z)!v&3;tIT}sNCGvY5a1DybfzD($Zm(c?M;7?0YMprGVwaTOuHZ!1x0oAn3T0pj(S>Lpk#3EHqlV=Zhxg3vZQS1C>B{ zVyaB2qYP*=c5=<6az&|-!e)T-INyWVh+G#cXp7Mk(9hGFx#D3kN-g=(v*QEV85&Z| zxw9Z_GECS&-{;(3_XXlNw$2uEv0Pfm_`h42&9u}RL|=qeC1fhx43~da^oT=l5lxlW zc)gXspUd8$18h)>iw{%{QUa?|xW$?K0 zHIjyztqScr+|O)~NjAyn&P`b8^qU<%u}c?d*d(=wbWjwH6OXNw^uwGncsGSsIVK=NX2A!7ni z!}2%gb*{X7M-bROR8Y=7b6W^S`Y$TK`2VIGBs=-(4Vj6h=aV_C=Hkhd19O#PLLmz? zq6wv#rhPRXrxU#sz=oW=kYhNYXHdw4ja5I_VgBNAz*$mC;`N-QgB%+>`@qaAvyK?B zutf_lGan+Y*H6d%1tPnD9-{*!^lVw-z~@o82h(AW!=|V;Y!+9me%%J4UU_g@p|7*9jmwd*bvPtf{us6RJS{0?6moD5X=612iI*ug-^7ws zUmvF7d_5T;s`C#rmi+Kd_-CA5ge?pB__{gx?PdiGsUoYUazT>5X+@wf}wz_ke1|TMwa-`oq@tD+%X&`MrJ;Wz#%Rv(hOH~gWZNR|G*&hYQET+7lZrKJ z076Rwf8tK(VJW3BJ@HE!(qIim|ANY4p%mW0@+Cmq-D-aU3x|Uc&M%D$A{A{VaBn$P zQp^Z{6Ee#N2ggmN;?WqoOkLrBwMgCy?hT14V`IRH#F>cZOQ9783f>JB0luE!QYMy^ zAtw3f{~AzNp?*)?1c-%M#ktKFQp0W73Nw*%4V58dF>PK3a_#-3`@dtl5ZoLsQqP+} zCC+x)eHw!MhP8!EEZe(s0?TXN}u_XMXE7`FdC>5D?rDd264#PrOaR%^gVrnbZ5 z!Sbm2r%^ACt1C7<6`7qh(oDydGgzrVJ zY#*FOmi5slC&kC?kUr;&drEkm3G)8QL1$5Z1ghTF??D%#U_RpYezRi<`>9b!78&#% zETGC0Zj2Xj!>96ngT{X`%wJF~4YcD)uy{p~AUTIeo8Hl7+}zZju|3r1Z;DQIA90t7 zOk=>kj#BC%7j!N5Xq#l~{q_68ro8UKQH3oe~o)RK{s$4hHL;tn(wTJ#`&27(5y zX4cVvZ*J)7$3p<^l(&MpyfJgtyJ#sdx}qL#&I)}u0?0+Zkdridc3|L_-UT^NHNOca zzK9u1aK&ak6sl`DF|=}Vb$?uKp)`FX>2+L;kMGD84u5#=767B>yf7y0Zncg5Xl%2r z5!|A%0*6hZbxw6v$(8*?HJ=^^1wGSJ?v0Y)z(PIOq%*(Pf zYjwlXl{7F{yw)d#_0^q{9y}0KKVLkJfmPNc5)VaStn8FV8x&JL4|9ZD-P#T@q)Z13 zriMCEsyW(cZmW_vnC;DK?etDvJ4~Tn&aIm7lPDeux!I%6<`-&PEdtUfKP_4H&Le01=0&Q^}X|Ork|n5b`eW=$m7D30lHh3aYaX| zwINn-ns{QQY;fO#WqWXmsUR5!1po&AP1)byquJsJm#K`H2>y7-Y*q|L#)mlKXalVU z*c=1I1mDVK017u7SU4v%nm=-$@5douF+$0uU+($P$nf3Kd2;m}8v^>#xcdYODL+8p z-G++fhUZy&oEn1U=3|$bs-0~{80hsyoSJGqmCLM_)>(hq+eDMnV9R8jL}7OGimuuS z94#WiB&sqO!HBjO;g{{84)c~`?2z04j9a&Pa}vOX3`p}5S4!`s-6;*P7dMrlnzsMRxACc|;dn|6)98&JMQe|?7*V)-l z9rSVRP*Ck+D|m%s3W=p@iccB!bKaM(nw&N(3obUr)P$k70Eit2DGaayn=*$ph^%aW z0V7{wMr4d)xyl<-CuWo9IGN+FF@Zj_i7l;J6Cp%4Tzf|CQ5MZmU9DHRAiU8k%#~AW zHDAed&?xIE?aU}^#mDP4yd&=(u4DKNBU;#Qdv9-KorYrpCNlTi3+th`fec5C3CC_b zWuAtQmAV&JaE{PK|2wPQwujBoJ|c$W#5NhtBkn}~h_P{o*mU`-c%h4BU_96t8KXK? zT$qhBMn7<5;47&#shoroFNK3NBPEsTF?+snw7oAp_9R|&HE&9cbDPclKH+S#SG114 z3zjfIFhrR>`!K1hN9N+ednrF560j7Wr7|b42!6;{Sv6oIs%f(l-wD$jK8O+P|JmL* z_`9@Y)ew%-fHRC4?v(*aC&@`|91E5DSGZJ?627od@pAEejeVlQ+0i4tw`7S2 z{7m+eum4G52Nd0ZrZKm}*B=bb_`+pQA6K0bKg#G=W}3K+7Yv?1@d{@N#mI6$u9*Bg z)Pj~ANa94d6M)x2+IZk$UJ40)zc#H#XI`DwY}(IpT7#f}aD6HMhyJX|j=cHeR95RX zXAz!zB;USjM8C*cO@9s8C|jU{EsZI3Q9u+@fHfnnPgn(3&Ip#CzEHVg?6&jZ(yE83 zTzSGl1_N}b*7}oP2agiQhn@bJyD!c%xW|n$cH*yAGHO`eQA#z@vPXHLDAv69thYt) z9oJ5wW$!DkJ$F|i7YO6IhX3e9z(W(MCc&aQzQqV0887O*PT(mn{VHM{8qnxw*aak` z9X2CEKlMqw4QPOdKcf-Pb+oSUgU9EA$JfP>0cL|&v}|YphZ-i|hoopw_I^v`V_tLE z!9eT=6)hDZRfQI&Ba<+*nKiItD?MlEqz@`qF7+K3|MJn?`!cuDZ5EPhpEkl(k8vi; zOk9$uUZ+rmXA?X!U)Q-=v5t^+0HqUWgavgG70Kdt4R8tcN=_*@;j3y{76M)Q`Mg~e zTbAs@0c)1^Xe44dF-FBq-)x8L%@P=l$Xz$ z=4+_L*m=1YP`|2tKy`2DUqh*;L;Lcw0#q%fm0>|JpEC};wvqAh7|_|ba_cPUBVga! z42Kq!c3}Ljn7<-!nRc5^Gb$`B#4ILBs!q}>9b_&djZ~Dg=zRh{qS(Ai7+5RC8Xu_| z?=X|ulYM~Cb$yo@xWZyrhSj^x2>nd=F_@Ce(9dWub;_kc%9TN;;`t( zW0x6c3kB(#9=a$5hse@uzUV5gGW|`oF#8MrjXI?it~brBLHg~yPhl{r`QaAN&P!ys z2y-D1NpN&vIQFY5`+2hU0&CAo8pIRk;u9r92yDltq&>330UXCW4PK4rh|3E+fjk0^ zfQZ4vrtl7@vTQkxO&zF<-i`9jecRA)Uq@R*z8R((Wl{;w3A+e~XHWg&eT>h@Ob~3Q zk@EMAfWoMu*b8sIaQiv-M)|F%4hlo2#RQMl04vFK2XpjQ*Ea}Kc| z;D|>S^pa(}s;JH^LJBhV1w^~KDvO(rmC7Asps{t|!UzmVo!iLXi%{!g-{1nXn38SiLsHG;1m=+K z^KCMf6q+dx;w9BY9poW?^t0kmw7QKE^kz!W_~bk~WymMO17MUHR23!5RKx?*I81vE zR;xJv%58F*jBoC^F^cEv#GM?dp~vevJ6-;jyRW?8iat#9^JG7<^-=nexV^U)9)fu; z`a_^3z6&6Mw_?)5h7B)wy)k;oE(wg&b|FCmW|;6hkPuV-S$1KAC3pv9_t5F}H4qSN19|FO~FrB5#dmM4F#Aj zx9;8Y1m)Y`nSl^gss7r6axX_k3fhts8~IKZx2JSK=nv6i+gMeA!8~~MU~RgC8zDX% zwRzs`xT}*HtXTHrkf0&j%MIJMR;NQZ$K+Y!ahh0`ZBO-suNK%Uzi3H_i*24QwT z6o!;EwKG!{&EOW-q6yzx7Y(F~!c&(WGJi|45PN>_d1NZDI^O9-r#cvMH1E3GSb@`5 z@hW;TBrRtnA!PPQ*fjU;(WML0lYXR;4lR!#lDh4wm4?7=~7RYPol&agF5Qy zvwcp$#~i1LP$tu@Zz$oKAe?YMEH57_yz_Iql1=gEBMY;(@+{yB91c&XQr(AOhxFg1&pNm4KnhQ9 zFdBvbo`iz@neN9^exs)Mgd=+L$;_WncG;wn87jnHauGC(OQ`9rhDb<_iw*Rp_eua> zm?Z*Pmr=5rUKe6W6OAWY2&j}%cUXhHOSI2LyW)$^VzwP-CTWoBm!b%X zEDkPm1?%wNUb;epCiP(i`|KAs3+0yD!Z^1}#ZS5Q!-nEwh)GoMwFRZ7a|}J&@Wqmm zZ!CLk>=%RGmCkZZrhsj$Vj|;Jo?55V>a1e6YQP~szQTUYXH)Rs4>*A$?k)M_ZZE5G zm|4;GaNCz&@-*QOL#vDM$^}H@I`Sif4SFkR1a941N66B{7Y^cRik&AcoSvS_2}BYj z;x>(HQ3-};NuKK=v_g;g1jaPje46$0U*4IiCC(U)bEGIDUSQ@)k_w>PRcBe?xwLN? z)c)@aSuBI65M@Gl(A_@@=4K6~-sJuKSRHy8NLZ!vIN1Sjj_uaTjirG@TYlORG)pJ=aS@!oK2};V>zfc%)T)dCA z$YXxy<2r|vdI7$oiv{hL(&WWC?2-aPCer@ar49G5Tl1r7#{zst>S5U9oI()pBo z8{fJ+9cfFmdZgH1)j2sf@(BYj5y>2ccs2oWm+)T)?W5zkT(YQ+#K@Y+@^FtNW@`X) z#65R!=m3;$@-~UJn@{4+56Z$Pue|gfC%)v;NsAoJIwtXlCLx}7_CY3OPnJo1NH9

S7$;xqT@y&LX;=Llxi+@yHt( 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 oq8 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 Im5rKftdsb1daniof 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 O8NmU8#F&6pAM`(piMzu9 literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/Source/ParameterIDs.hpp b/plugins/ModularRandomizer/Source/ParameterIDs.hpp new file mode 100644 index 0000000..d012f10 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ParameterIDs.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace ParameterIDs { + // Global parameters (exposed to DAW automation) + constexpr char MIX[] = "mix"; + constexpr char BYPASS[] = "bypass"; +} diff --git a/plugins/ModularRandomizer/Source/PluginEditor.cpp b/plugins/ModularRandomizer/Source/PluginEditor.cpp new file mode 100644 index 0000000..4610111 --- /dev/null +++ b/plugins/ModularRandomizer/Source/PluginEditor.cpp @@ -0,0 +1,1787 @@ +/* + ============================================================================== + + Modular Randomizer - PluginEditor + WebView2-based UI with native function bridge + + ============================================================================== +*/ + +#include "PluginEditor.h" +#include +#include +#include "ParameterIDs.hpp" +#include + +//============================================================================== +ModularRandomizerAudioProcessorEditor::ModularRandomizerAudioProcessorEditor ( + ModularRandomizerAudioProcessor& p) + : AudioProcessorEditor (&p), + audioProcessor (p) +{ + DBG ("ModularRandomizer: Editor constructor started"); + + //========================================================================== + // CRITICAL: CREATION ORDER (matches CloudWash webview-004 fix) + // 1. Relays already created (member initialization) + // 2. Create attachments BEFORE WebView + // 3. Create WebBrowserComponent with proper JUCE 8 API + // 4. addAndMakeVisible LAST + //========================================================================== + + // Create parameter attachments BEFORE creating WebView + DBG ("ModularRandomizer: Creating parameter attachments"); + mixAttachment = std::make_unique ( + *audioProcessor.getAPVTS().getParameter (ParameterIDs::MIX), mixRelay); + + bypassAttachment = std::make_unique ( + *audioProcessor.getAPVTS().getParameter (ParameterIDs::BYPASS), bypassRelay); + + // Create WebBrowserComponent with JUCE 8 proper API + // CRITICAL: Attachments must be created BEFORE this point + DBG ("ModularRandomizer: Creating WebView"); + + // Build base options — platform-specific backend selection + auto webViewOptions = juce::WebBrowserComponent::Options{} +#if JUCE_WINDOWS + .withBackend (juce::WebBrowserComponent::Options::Backend::webview2) + .withWinWebView2Options ( + juce::WebBrowserComponent::Options::WinWebView2{} + .withUserDataFolder (juce::File::getSpecialLocation ( + juce::File::SpecialLocationType::tempDirectory)) + ) +#endif + .withNativeIntegrationEnabled() // CRITICAL: Enable window.__JUCE__ backend + .withResourceProvider ([this] (const auto& url) { return getResource (url); }) + .withOptionsFrom (mixRelay) + .withOptionsFrom (bypassRelay) + .withNativeFunction ( + "scanPlugins", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + LOG_TO_FILE ("=== scanPlugins native function called, args count: " << args.size()); + + juce::StringArray paths; + if (args.size() > 0 && args[0].isArray()) + { + for (int i = 0; i < args[0].size(); ++i) + paths.add (args[0][i].toString()); + } + + if (paths.isEmpty()) + paths = ModularRandomizerAudioProcessor::getDefaultScanPaths(); + + // Optional: force rescan (deletes cache) if second arg is true + bool forceRescan = args.size() > 1 && (bool) args[1]; + if (forceRescan) + audioProcessor.clearPluginCache(); + + LOG_TO_FILE (" Launching background scan thread..." + << (forceRescan ? " (FORCED, cache cleared)" : "")); + + // Move completion into a shared_ptr so it can be safely captured + auto sharedCompletion = std::make_shared ( + std::move (completion)); + + juce::Thread::launch ([this, paths, sharedCompletion]() + { + LOG_TO_FILE (" Background thread: starting scan"); + auto results = audioProcessor.scanForPlugins (paths); + LOG_TO_FILE (" Background thread: scan returned " << results.size() << " plugins"); + + juce::Array pluginList; + for (const auto& sp : results) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty ("name", sp.name); + obj->setProperty ("vendor", sp.vendor); + obj->setProperty ("category", sp.category); + obj->setProperty ("path", sp.path); + obj->setProperty ("format", sp.format); + pluginList.add (juce::var (obj)); + } + + LOG_TO_FILE (" Background thread: calling completion with " << pluginList.size() << " items"); + // NativeFunctionCompletion can be called from any thread + (*sharedCompletion) (juce::var (pluginList)); + LOG_TO_FILE (" Background thread: completion called successfully"); + + // Kick off preset indexing in the background + if (! audioProcessor.isPresetIndexReady()) + { + juce::Thread::launch ([this]() { + audioProcessor.buildPresetIndex(); + }); + } + }); + } + ) + .withNativeFunction ( + "getScanProgress", + [this] (const juce::Array&, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + auto prog = audioProcessor.getScanProgress(); + auto* obj = new juce::DynamicObject(); + obj->setProperty ("name", prog.currentPlugin); + obj->setProperty ("progress", prog.progress); + obj->setProperty ("scanning", prog.scanning); + completion (juce::var (obj)); + } + ) + .withNativeFunction ( + "saveUiState", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() > 0) + audioProcessor.setUiState (args[0].toString()); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "getDefaultScanPaths", + [this] (const juce::Array&, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + auto paths = ModularRandomizerAudioProcessor::getDefaultScanPaths(); + juce::var result; + for (const auto& p : paths) + result.append (juce::var (p)); + completion (result); + } + ) + .withNativeFunction ( + "getFullState", + [this] (const juce::Array&, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Build response: { plugins: [...], uiState: "..." } + auto* result = new juce::DynamicObject(); + + // Current hosted plugins (already loaded in processor) + juce::Array plugArr; + auto pluginList = audioProcessor.getHostedPluginList(); + for (const auto& info : pluginList) + { + auto* pObj = new juce::DynamicObject(); + pObj->setProperty ("id", info.id); + pObj->setProperty ("name", info.name); + pObj->setProperty ("path", info.path); + pObj->setProperty ("manufacturer", info.manufacturer); + + // Get current parameter values + auto params = audioProcessor.getHostedParams (info.id); + juce::Array paramArr; + for (const auto& p : params) + { + auto* paramObj = new juce::DynamicObject(); + paramObj->setProperty ("index", p.index); + paramObj->setProperty ("name", p.name); + paramObj->setProperty ("value", (double) p.value); + paramObj->setProperty ("disp", p.displayText); + paramArr.add (juce::var (paramObj)); + } + pObj->setProperty ("params", juce::var (paramArr)); + pObj->setProperty ("busId", info.busId); + pObj->setProperty ("isInstrument", info.isInstrument); + plugArr.add (juce::var (pObj)); + } + result->setProperty ("plugins", juce::var (plugArr)); + result->setProperty ("routingMode", audioProcessor.getRoutingMode()); + + // Saved UI state (blocks, mappings, locks) + auto uiState = audioProcessor.getUiState(); + if (uiState.isNotEmpty()) + result->setProperty ("uiState", uiState); + + completion (juce::var (result)); + } + ) + .withNativeFunction ( + "loadPlugin", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.isEmpty()) + { + completion (juce::var()); + return; + } + + auto pluginPath = args[0].toString(); + LOG_TO_FILE ("=== loadPlugin async: " << pluginPath.toStdString()); + + // Phase 1: scan on background thread — no COM, pure disk I/O + juce::Thread::launch ([this, pluginPath, completion = std::move(completion)]() mutable + { + juce::PluginDescription desc; + bool found = audioProcessor.findPluginDescription (pluginPath, desc); + + if (! found) + { + juce::MessageManager::callAsync ([completion = std::move(completion), pluginPath]() mutable + { + auto* err = new juce::DynamicObject(); + err->setProperty ("error", "Plugin not found: " + pluginPath); + completion (juce::var (err)); + }); + return; + } + + // Phase 2: instantiate on message thread — COM-safe + juce::MessageManager::callAsync ([this, desc, completion = std::move(completion)]() mutable + { + int pluginId = audioProcessor.instantiatePlugin (desc); + + if (pluginId < 0) + { + auto* err = new juce::DynamicObject(); + err->setProperty ("error", "Failed to instantiate plugin"); + completion (juce::var (err)); + return; + } + + auto params = audioProcessor.getHostedParams (pluginId); + auto* result = new juce::DynamicObject(); + result->setProperty ("id", pluginId); + + auto hosted = audioProcessor.getHostedPluginList(); + for (auto& h : hosted) + { + if (h.id == pluginId) + { + result->setProperty ("name", h.name); + result->setProperty ("manufacturer", h.manufacturer); + result->setProperty ("isInstrument", h.isInstrument); + break; + } + } + + juce::Array paramList; + for (const auto& p : params) + { + auto* pObj = new juce::DynamicObject(); + pObj->setProperty ("index", p.index); + pObj->setProperty ("name", p.name); + pObj->setProperty ("value", (double) p.value); + pObj->setProperty ("label", p.label); + pObj->setProperty ("disp", p.displayText); + pObj->setProperty ("automatable", p.automatable); + paramList.add (juce::var (pObj)); + } + result->setProperty ("params", juce::var (paramList)); + + completion (juce::var (result)); + }); + }); + } + ) + .withNativeFunction ( + "removePlugin", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + try + { + if (args.size() > 0) + { + int pluginId = (int) args[0]; + + // Close editor window FIRST (prevents dangling pointer) + try { + auto it = pluginEditorWindows.find (pluginId); + if (it != pluginEditorWindows.end()) + pluginEditorWindows.erase (it); + } catch (...) {} + + // Now remove from processor + try { audioProcessor.removePlugin (pluginId); } catch (...) {} + } + } + catch (...) {} + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setParam", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, paramIndex, normValue] + if (args.size() >= 3) + { + int pluginId = (int) args[0]; + int paramIdx = (int) args[1]; + float val = (float) (double) args[2]; + audioProcessor.setHostedParam (pluginId, paramIdx, val); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "touchParam", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, paramIndex] + if (args.size() >= 2) + audioProcessor.touchParam ((int) args[0], (int) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "untouchParam", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, paramIndex] + if (args.size() >= 2) + audioProcessor.untouchParam ((int) args[0], (int) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "getParams", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId] + if (args.isEmpty()) + { + completion (juce::var()); + return; + } + + int pluginId = (int) args[0]; + auto params = audioProcessor.getHostedParams (pluginId); + + juce::Array paramList; + for (const auto& p : params) + { + auto pObj = new juce::DynamicObject(); + pObj->setProperty ("index", p.index); + pObj->setProperty ("name", p.name); + pObj->setProperty ("value", (double) p.value); + pObj->setProperty ("label", p.label); + pObj->setProperty ("disp", p.displayText); + paramList.add (juce::var (pObj)); + } + completion (juce::var (paramList)); + } + ) + .withNativeFunction ( + "fireRandomize", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, paramIndicesArray, minVal, maxVal] + if (args.size() >= 4) + { + int pluginId = (int) args[0]; + float minVal = (float) (double) args[2]; + float maxVal = (float) (double) args[3]; + + std::vector indices; + if (args[1].isArray()) + { + for (int i = 0; i < args[1].size(); ++i) + indices.push_back ((int) args[1][i]); + } + + audioProcessor.randomizeParams (pluginId, indices, minVal, maxVal); + + // Return updated param values + auto params = audioProcessor.getHostedParams (pluginId); + juce::Array paramList; + for (const auto& p : params) + { + auto pObj = new juce::DynamicObject(); + pObj->setProperty ("index", p.index); + pObj->setProperty ("name", p.name); + pObj->setProperty ("value", (double) p.value); + pObj->setProperty ("disp", p.displayText); + paramList.add (juce::var (pObj)); + } + completion (juce::var (paramList)); + } + else + { + completion (juce::var ("error: missing args")); + } + } + ) + .withNativeFunction ( + "updateBlocks", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [jsonString] + if (args.size() > 0) + { + auto jsonStr = args[0].toString(); + audioProcessor.updateLogicBlocks (jsonStr); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "updateMorphPlayhead", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Lightweight playhead update — args: [blockId, x, y] + if (args.size() >= 3) + { + int blockId = (int) args[0]; + float px = (float) (double) args[1]; + float py = (float) (double) args[2]; + audioProcessor.updateMorphPlayhead (blockId, px, py); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "startGlide", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [pluginId, paramIndex, targetValue, durationMs] + if (args.size() >= 4) + { + int pluginId = (int) args[0]; + int paramIdx = (int) args[1]; + float target = (float) (double) args[2]; + float duration = (float) (double) args[3]; + audioProcessor.startGlide (pluginId, paramIdx, target, duration); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "openPluginEditor", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() > 0) + { + int pluginId = (int) args[0]; + // Must run on message thread (creates GUI) + juce::Component::SafePointer safeThis (this); + juce::MessageManager::callAsync ([safeThis, pluginId]() + { + if (safeThis != nullptr) + safeThis->openPluginEditorWindow (pluginId); + }); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "browseSample", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + int blockId = args.size() > 0 ? (int) args[0] : -1; + if (blockId < 0) + { + completion (juce::var()); + return; + } + + auto sharedCompletion = std::make_shared ( + std::move (completion)); + + auto chooser = std::make_shared ( + "Select Audio File", + juce::File::getSpecialLocation (juce::File::userMusicDirectory), + "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); + + chooser->launchAsync ( + juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this, blockId, chooser, sharedCompletion] (const juce::FileChooser& fc) + { + auto results = fc.getResults(); + if (results.isEmpty()) + { + (*sharedCompletion) (juce::var()); + return; + } + + auto filePath = results[0].getFullPathName(); + bool ok = audioProcessor.loadSampleForBlock (blockId, filePath); + + if (ok) + { + auto waveform = audioProcessor.getSampleWaveform (blockId); + auto* result = new juce::DynamicObject(); + result->setProperty ("name", results[0].getFileName()); + result->setProperty ("path", filePath); + result->setProperty ("duration", + juce::var ((double) results[0].getSize() / 44100.0)); // rough estimate + + juce::Array peaks; + for (float p : waveform) + peaks.add ((double) p); + result->setProperty ("waveform", juce::var (peaks)); + + (*sharedCompletion) (juce::var (result)); + } + else + { + (*sharedCompletion) (juce::var()); + } + }); + } + ) + .withNativeFunction ( + "setPluginBypass", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + audioProcessor.setPluginBypass ((int) args[0], (bool) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "resetPluginCrash", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Re-enable a crashed plugin — user acknowledges the risk + if (args.size() >= 1) + audioProcessor.resetPluginCrash ((int) args[0]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "savePluginPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 4) + { + auto manufacturer = ModularRandomizerAudioProcessor::sanitizeForFilename (args[0].toString()); + auto pluginName = ModularRandomizerAudioProcessor::sanitizeForFilename (args[1].toString()); + auto presetName = args[2].toString(); + auto jsonData = args[3].toString(); + + if (manufacturer.isEmpty()) manufacturer = "Unknown"; + + auto presetsDir = ModularRandomizerAudioProcessor::getSnapshotsDir() + .getChildFile (manufacturer) + .getChildFile (pluginName); + presetsDir.createDirectory(); + + auto presetFile = presetsDir.getChildFile (presetName + ".json"); + presetFile.replaceWithText (jsonData); + completion (juce::var ("ok")); + } + else + completion (juce::var()); + } + ) + .withNativeFunction ( + "getPluginPresets", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + juce::Array presetNames; + if (args.size() >= 2) + { + auto manufacturer = ModularRandomizerAudioProcessor::sanitizeForFilename (args[0].toString()); + auto pluginName = ModularRandomizerAudioProcessor::sanitizeForFilename (args[1].toString()); + if (manufacturer.isEmpty()) manufacturer = "Unknown"; + + auto presetsDir = ModularRandomizerAudioProcessor::getSnapshotsDir() + .getChildFile (manufacturer) + .getChildFile (pluginName); + + if (presetsDir.isDirectory()) + { + for (const auto& f : presetsDir.findChildFiles (juce::File::findFiles, false, "*.json")) + presetNames.add (f.getFileNameWithoutExtension()); + } + } + completion (juce::var (presetNames)); + } + ) + .withNativeFunction ( + "deletePluginPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 3) + { + auto manufacturer = ModularRandomizerAudioProcessor::sanitizeForFilename (args[0].toString()); + auto pluginName = ModularRandomizerAudioProcessor::sanitizeForFilename (args[1].toString()); + auto presetName = args[2].toString(); + if (manufacturer.isEmpty()) manufacturer = "Unknown"; + + auto presetsDir = ModularRandomizerAudioProcessor::getSnapshotsDir() + .getChildFile (manufacturer) + .getChildFile (pluginName); + auto presetFile = presetsDir.getChildFile (presetName + ".json"); + if (presetFile.existsAsFile()) + presetFile.deleteFile(); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "loadPluginPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 3) + { + auto manufacturer = ModularRandomizerAudioProcessor::sanitizeForFilename (args[0].toString()); + auto pluginName = ModularRandomizerAudioProcessor::sanitizeForFilename (args[1].toString()); + auto presetName = args[2].toString(); + if (manufacturer.isEmpty()) manufacturer = "Unknown"; + + auto presetsDir = ModularRandomizerAudioProcessor::getSnapshotsDir() + .getChildFile (manufacturer) + .getChildFile (pluginName); + auto presetFile = presetsDir.getChildFile (presetName + ".json"); + if (presetFile.existsAsFile()) + { + completion (juce::var (presetFile.loadFileAsString())); + return; + } + } + completion (juce::var()); + } + ) + .withNativeFunction ( + "getFactoryPresets", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + juce::Array result; + if (args.size() >= 1) + { + int pluginId = (int) args[0]; + auto presets = audioProcessor.getFactoryPresets (pluginId); + for (const auto& fp : presets) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty ("index", fp.index); + obj->setProperty ("name", fp.name); + if (fp.filePath.isNotEmpty()) + obj->setProperty ("filePath", fp.filePath); + result.add (juce::var (obj)); + } + } + completion (juce::var (result)); + } + ) + .withNativeFunction ( + "loadFactoryPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + { + int pluginId = (int) args[0]; + int progIdx = (int) args[1]; + juce::String filePath = args.size() >= 3 ? args[2].toString() : ""; + + std::vector params; + if (filePath.isNotEmpty()) + params = audioProcessor.loadFactoryPresetFromFile (pluginId, filePath); + else + params = audioProcessor.loadFactoryPreset (pluginId, progIdx); + + juce::Array paramArr; + for (const auto& p : params) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty ("index", p.index); + obj->setProperty ("name", p.name); + obj->setProperty ("value", (double) p.value); + paramArr.add (juce::var (obj)); + } + completion (juce::var (paramArr)); + return; + } + completion (juce::var()); + } + ) + .withNativeFunction ( + "saveGlobalPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + { + auto presetName = args[0].toString(); + auto jsonData = args[1].toString(); + auto chainsDir = ModularRandomizerAudioProcessor::getChainsDir(); + chainsDir.createDirectory(); + auto presetFile = chainsDir.getChildFile (presetName + ".mrchain"); + presetFile.replaceWithText (jsonData); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "getGlobalPresets", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + juce::ignoreUnused (args); + + // Helper: extract plugin names from a preset file (lightweight — reads file once, scans for plugin names) + auto extractPluginNames = [] (const juce::File& f) -> juce::Array + { + juce::Array pluginNames; + auto content = f.loadFileAsString(); + if (content.isEmpty()) return pluginNames; + auto parsed = juce::JSON::parse (content); + if (parsed.isVoid()) return pluginNames; + auto* obj = parsed.getDynamicObject(); + if (obj == nullptr) return pluginNames; + auto pluginsVar = obj->getProperty ("plugins"); + if (pluginsVar.isArray()) + { + for (int i = 0; i < pluginsVar.size(); ++i) + { + if (auto* pObj = pluginsVar[i].getDynamicObject()) + { + auto name = pObj->getProperty ("name").toString(); + auto path = pObj->getProperty ("path").toString(); + if (name.isNotEmpty() && name != "__virtual__" && path != "__virtual__") + pluginNames.add (name); + } + } + } + return pluginNames; + }; + + juce::Array presetEntries; + auto chainsDir = ModularRandomizerAudioProcessor::getChainsDir(); + if (chainsDir.isDirectory()) + { + for (const auto& f : chainsDir.findChildFiles (juce::File::findFiles, false, "*.mrchain")) + { + auto* entry = new juce::DynamicObject(); + entry->setProperty ("name", f.getFileNameWithoutExtension()); + entry->setProperty ("plugins", juce::var (extractPluginNames (f))); + presetEntries.add (juce::var (entry)); + } + } + // Also scan _Import/ folder for new chain files + auto importDir = ModularRandomizerAudioProcessor::getImportDir(); + if (importDir.isDirectory()) + { + for (const auto& f : importDir.findChildFiles (juce::File::findFiles, false, "*.mrchain")) + { + auto baseName = f.getFileNameWithoutExtension(); + auto dest = chainsDir.getChildFile (f.getFileName()); + + // Handle name collision: append _2, _3, etc. + if (dest.existsAsFile()) + { + int suffix = 2; + while (chainsDir.getChildFile (baseName + "_" + juce::String (suffix) + ".mrchain").existsAsFile()) + ++suffix; + baseName = baseName + "_" + juce::String (suffix); + dest = chainsDir.getChildFile (baseName + ".mrchain"); + } + + if (f.copyFileTo (dest)) + { + auto* entry = new juce::DynamicObject(); + entry->setProperty ("name", baseName); + entry->setProperty ("plugins", juce::var (extractPluginNames (dest))); + presetEntries.add (juce::var (entry)); + f.deleteFile(); // remove from _Import/ after successful copy + } + } + } + completion (juce::var (presetEntries)); + } + ) + .withNativeFunction ( + "loadGlobalPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + auto presetName = args[0].toString(); + auto chainsDir = ModularRandomizerAudioProcessor::getChainsDir(); + auto presetFile = chainsDir.getChildFile (presetName + ".mrchain"); + if (presetFile.existsAsFile()) + { + completion (juce::var (presetFile.loadFileAsString())); + return; + } + } + completion (juce::var()); + } + ) + .withNativeFunction ( + "deleteGlobalPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + auto presetName = args[0].toString(); + auto chainsDir = ModularRandomizerAudioProcessor::getChainsDir(); + auto presetFile = chainsDir.getChildFile (presetName + ".mrchain"); + if (presetFile.existsAsFile()) + presetFile.deleteFile(); + } + completion (juce::var ("ok")); + } + ) + // ── EQ Presets ── + .withNativeFunction ( + "saveEqPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + { + auto presetName = args[0].toString(); + auto jsonData = args[1].toString(); + auto eqDir = ModularRandomizerAudioProcessor::getEqPresetsDir(); + eqDir.createDirectory(); + auto presetFile = eqDir.getChildFile (presetName + ".mreq"); + presetFile.replaceWithText (jsonData); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "getEqPresets", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + juce::ignoreUnused (args); + auto eqDir = ModularRandomizerAudioProcessor::getEqPresetsDir(); + juce::Array presetList; + if (eqDir.isDirectory()) + { + for (auto& f : eqDir.findChildFiles (juce::File::findFiles, false, "*.mreq")) + presetList.add (f.getFileNameWithoutExtension()); + } + presetList.sort(); + completion (juce::var (presetList)); + } + ) + .withNativeFunction ( + "loadEqPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + auto presetName = args[0].toString(); + auto eqDir = ModularRandomizerAudioProcessor::getEqPresetsDir(); + auto presetFile = eqDir.getChildFile (presetName + ".mreq"); + if (presetFile.existsAsFile()) + { + completion (juce::var (presetFile.loadFileAsString())); + return; + } + } + completion (juce::var()); + } + ) + .withNativeFunction ( + "deleteEqPreset", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + auto presetName = args[0].toString(); + auto eqDir = ModularRandomizerAudioProcessor::getEqPresetsDir(); + auto presetFile = eqDir.getChildFile (presetName + ".mreq"); + if (presetFile.existsAsFile()) + presetFile.deleteFile(); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "revealPresetFile", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args[0] = type ("chain" or "snapshot") + // args[1] = presetName + // args[2] = manufacturer (snapshot only) + // args[3] = pluginName (snapshot only) + if (args.size() >= 2) + { + auto type = args[0].toString(); + auto presetName = args[1].toString(); + + juce::File target; + if (type == "chain") + { + target = ModularRandomizerAudioProcessor::getChainsDir() + .getChildFile (presetName + ".mrchain"); + } + else if (type == "snapshot" && args.size() >= 4) + { + auto manufacturer = ModularRandomizerAudioProcessor::sanitizeForFilename (args[2].toString()); + auto pluginName = ModularRandomizerAudioProcessor::sanitizeForFilename (args[3].toString()); + if (manufacturer.isEmpty()) manufacturer = "Unknown"; + target = ModularRandomizerAudioProcessor::getSnapshotsDir() + .getChildFile (manufacturer) + .getChildFile (pluginName) + .getChildFile (presetName + ".json"); + } + else if (type == "root") + { + target = ModularRandomizerAudioProcessor::getChainsDir(); + } + else if (type == "eq") + { + target = ModularRandomizerAudioProcessor::getEqPresetsDir() + .getChildFile (presetName + ".mreq"); + if (! target.existsAsFile()) + target = ModularRandomizerAudioProcessor::getEqPresetsDir(); + } + + if (target.exists()) + target.revealToUser(); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setEditorScale", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + double scale = (double) args[0]; + if (scale < 0.5) scale = 0.5; + if (scale > 2.0) scale = 2.0; + // Base UI size matches initial setSize + constexpr int baseW = 1060, baseH = 720; + int w = juce::roundToInt (baseW * scale); + int h = juce::roundToInt (baseH * scale); + juce::Component::SafePointer safeThis (this); + juce::MessageManager::callAsync ([safeThis, w, h]() + { + if (safeThis != nullptr) + safeThis->setSize (w, h); + }); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setRoutingMode", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1) + { + int mode = (int) args[0]; + audioProcessor.setRoutingMode (mode); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setEqCurve", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 1 && args[0].isString()) + { + audioProcessor.setEqCurve (args[0].toString()); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setPluginBus", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + { + int pluginId = (int) args[0]; + int busId = (int) args[1]; + audioProcessor.setPluginBusId (pluginId, busId); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setBusVolume", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + audioProcessor.setBusVolume ((int) args[0], (float) (double) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setBusMute", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + audioProcessor.setBusMute ((int) args[0], (bool) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setBusSolo", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() >= 2) + audioProcessor.setBusSolo ((int) args[0], (bool) args[1]); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "reorderPlugins", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() > 0 && args[0].isArray()) + { + auto* arr = args[0].getArray(); + std::vector orderedIds; + orderedIds.reserve ((size_t) arr->size()); + for (auto& v : *arr) + orderedIds.push_back ((int) v); + audioProcessor.reorderPlugins (orderedIds); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "applyParamBatch", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Batch param apply — sets N params in a single IPC call. + // args: [jsonString] where json is [{"p":pluginId,"i":paramIndex,"v":value}, ...] + if (args.size() > 0) + audioProcessor.applyParamBatch (args[0].toString()); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "setExpandedPlugins", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Visibility culling: JS tells us which plugin IDs are expanded. + // We skip polling params from collapsed plugins. + std::unordered_set newExpanded; + if (args.size() > 0 && args[0].isArray()) + { + for (int i = 0; i < args[0].size(); ++i) + newExpanded.insert ((int) args[0][i]); + } + + // Detect newly expanded plugins — clear their lastParamValues for full resync + for (int id : newExpanded) + { + if (expandedPluginIds.count (id) == 0) + { + // This plugin was just expanded — clear cached values so + // Tier 2 treats all its params as new and sends a full batch + for (auto it = lastParamValues.begin(); it != lastParamValues.end(); ) + { + auto identIt = paramIdentCache.find (it->first); + if (identIt != paramIdentCache.end() && identIt->second.pluginId == id) + it = lastParamValues.erase (it); + else + ++it; + } + } + } + + expandedPluginIds = std::move (newExpanded); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "fireLaneTrigger", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // args: [blockId, laneIdx] — fire manual oneshot trigger + if (args.size() >= 2) + { + int blockId = (int) args[0]; + int laneIdx = (int) args[1]; + audioProcessor.fireLaneTrigger (blockId, laneIdx); + } + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "getParamTextForValue", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Convert an arbitrary normalized value (0..1) to the plugin's display text. + // args: [pluginId, paramIndex, normalizedValue] + // Returns the display string (e.g. "600 Hz", "-12.5 dB") + if (args.size() >= 3) + { + int pluginId = (int) args[0]; + int paramIndex = (int) args[1]; + float normVal = (float) (double) args[2]; + auto text = audioProcessor.getParamTextForValue (pluginId, paramIndex, normVal); + completion (juce::var (text)); + return; + } + completion (juce::var ("")); + } + ) + .withNativeFunction ( + "setVisibleParams", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + // Fine-grained visibility: JS tells us which PIDs are on screen (~8). + // Tier 1 only polls modulated params that are also visible. + std::unordered_set newVisible; + if (args.size() > 0 && args[0].isArray()) + { + for (int i = 0; i < args[0].size(); ++i) + newVisible.insert (args[0][i].toString().toStdString()); + } + visibleParamKeys = std::move (newVisible); + completion (juce::var ("ok")); + } + ) + .withNativeFunction ( + "updateExposeState", + [this] (const juce::Array& args, + juce::WebBrowserComponent::NativeFunctionCompletion completion) + { + if (args.size() > 0) + audioProcessor.updateExposeState (args[0].toString()); + completion (juce::var ("ok")); + } + ); + + webView = std::make_unique (webViewOptions); + + // CRITICAL: addAndMakeVisible AFTER attachments are created + DBG ("ModularRandomizer: Adding WebView to component"); + addAndMakeVisible (*webView); + + // Navigate to resource provider root (serves index.html via root handler) + DBG ("ModularRandomizer: Loading web content"); + webView->goToURL (juce::WebBrowserComponent::getResourceProviderRoot()); + + // Set editor size + setSize (1060, 720); + + // Start timer for periodic UI updates (30fps) + startTimerHz (60); + +#if JUCE_DEBUG + DBG ("Resource provider root: " + juce::WebBrowserComponent::getResourceProviderRoot()); +#endif + + DBG ("ModularRandomizer: Editor constructor completed"); +} + +ModularRandomizerAudioProcessorEditor::~ModularRandomizerAudioProcessorEditor() +{ + stopTimer(); + + // Close all hosted plugin editor windows BEFORE destroying the WebView. + // This prevents use-after-free from async close callbacks. + // Wrapped in try/catch because some plugins crash during editor teardown. + try { pluginEditorWindows.clear(); } catch (...) {} +} + +//============================================================================== +void ModularRandomizerAudioProcessorEditor::paint (juce::Graphics& g) +{ + g.fillAll (juce::Colour (0xFF252018)); +} + +void ModularRandomizerAudioProcessorEditor::resized() +{ + if (webView != nullptr) + webView->setBounds (getLocalBounds()); +} + +void ModularRandomizerAudioProcessorEditor::timerCallback() +{ + if (webView == nullptr) return; + + ++timerTickCount; + + // Build real-time data object for JS + auto* data = new juce::DynamicObject(); + + // Audio levels (0..1 RMS) + data->setProperty ("rms", (double) audioProcessor.currentRmsLevel.load()); + data->setProperty ("scRms", (double) audioProcessor.sidechainRmsLevel.load()); + + // Transport + data->setProperty ("bpm", audioProcessor.currentBpm.load()); + data->setProperty ("playing", audioProcessor.isPlaying.load()); + data->setProperty ("ppq", audioProcessor.ppqPosition.load()); + + // Sample rate: sent once per tick so JS EQ visualization uses actual rate (not hardcoded 48k) + data->setProperty ("sr", audioProcessor.getSampleRate()); + + // MIDI events since last tick (lock-free FIFO read) + juce::Array midiArr; + { + const auto scope = audioProcessor.midiFifo.read (audioProcessor.midiFifo.getNumReady()); + for (int i = 0; i < scope.blockSize1; ++i) + { + const auto& ev = audioProcessor.midiRing[scope.startIndex1 + i]; + auto* mObj = new juce::DynamicObject(); + mObj->setProperty ("note", ev.note); + mObj->setProperty ("vel", ev.velocity); + mObj->setProperty ("ch", ev.channel); + mObj->setProperty ("cc", ev.isCC); + midiArr.add (juce::var (mObj)); + } + for (int i = 0; i < scope.blockSize2; ++i) + { + const auto& ev = audioProcessor.midiRing[scope.startIndex2 + i]; + auto* mObj = new juce::DynamicObject(); + mObj->setProperty ("note", ev.note); + mObj->setProperty ("vel", ev.velocity); + mObj->setProperty ("ch", ev.channel); + mObj->setProperty ("cc", ev.isCC); + midiArr.add (juce::var (mObj)); + } + } + data->setProperty ("midi", juce::var (midiArr)); + + // Envelope follower levels from C++ logic blocks + { + int numEnv = audioProcessor.numActiveEnvBlocks.load(); + if (numEnv > 0) + { + juce::Array envArr; + for (int i = 0; i < numEnv && i < audioProcessor.maxEnvReadback; ++i) + { + auto* e = new juce::DynamicObject(); + e->setProperty ("id", audioProcessor.envReadback[i].blockId.load()); + e->setProperty ("level", (double) audioProcessor.envReadback[i].level.load()); + envArr.add (juce::var (e)); + } + data->setProperty ("envLevels", juce::var (envArr)); + } + } + + // Trigger fire events from C++ logic blocks + { + const auto tScope = audioProcessor.triggerFifo.read (audioProcessor.triggerFifo.getNumReady()); + if (tScope.blockSize1 > 0 || tScope.blockSize2 > 0) + { + juce::Array trigArr; + for (int i = 0; i < tScope.blockSize1; ++i) + trigArr.add (audioProcessor.triggerRing[tScope.startIndex1 + i]); + for (int i = 0; i < tScope.blockSize2; ++i) + trigArr.add (audioProcessor.triggerRing[tScope.startIndex2 + i]); + data->setProperty ("trigFired", juce::var (trigArr)); + } + } + + // Sample modulator playhead positions + { + int numSmp = audioProcessor.numActiveSampleBlocks.load(); + if (numSmp > 0) + { + juce::Array smpArr; + for (int i = 0; i < numSmp && i < audioProcessor.maxSampleReadback; ++i) + { + auto* s = new juce::DynamicObject(); + s->setProperty ("id", audioProcessor.sampleReadback[i].blockId.load()); + s->setProperty ("pos", (double) audioProcessor.sampleReadback[i].playhead.load()); + smpArr.add (juce::var (s)); + } + data->setProperty ("sampleHeads", juce::var (smpArr)); + } + } + + // Morph pad playhead positions + { + int numMorph = audioProcessor.numActiveMorphBlocks.load(); + if (numMorph > 0) + { + juce::Array morphArr; + for (int i = 0; i < numMorph && i < audioProcessor.maxMorphReadback; ++i) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty ("id", audioProcessor.morphReadback[i].blockId.load()); + obj->setProperty ("x", (double) audioProcessor.morphReadback[i].headX.load()); + obj->setProperty ("y", (double) audioProcessor.morphReadback[i].headY.load()); + obj->setProperty ("rot", (double) audioProcessor.morphReadback[i].rotAngle.load()); + obj->setProperty ("out", (double) audioProcessor.morphReadback[i].modOutput.load()); + morphArr.add (juce::var (obj)); + } + data->setProperty ("morphHeads", juce::var (morphArr)); + } + } + + // Lane playhead positions + { + int numLanes = audioProcessor.numActiveLanes.load(); + if (numLanes > 0) + { + juce::Array laneArr; + for (int i = 0; i < numLanes && i < audioProcessor.maxLaneReadback; ++i) + { + auto* obj = new juce::DynamicObject(); + obj->setProperty ("id", audioProcessor.laneReadback[i].blockId.load()); + obj->setProperty ("li", audioProcessor.laneReadback[i].laneIdx.load()); + obj->setProperty ("ph", (double) audioProcessor.laneReadback[i].playhead.load()); + obj->setProperty ("val", (double) audioProcessor.laneReadback[i].value.load()); + obj->setProperty ("act", audioProcessor.laneReadback[i].active.load()); + laneArr.add (juce::var (obj)); + } + data->setProperty ("laneHeads", juce::var (laneArr)); + } + } + + // ═══════════════════════════════════════════════════════════════ + // TWO-TIER PARAMETER POLLING + // Modulated params (targeted by logic blocks): fast poll every tick (60Hz) + // Idle params: slow poll every 15th tick (~4Hz) + // This prevents 200+ param plugins like FabFilter Saturn from killing perf + // ═══════════════════════════════════════════════════════════════ + { + touchedParamId = {}; + + // Drain self-write FIFO every tick (must keep up with audio thread) + // Purpose: auto-locate exclusion ONLY — prevents params we wrote from + // triggering the "touched by plugin UI" detection. + // Does NOT promote to Tier 1 — that would add ALL modulated params back to fast polling. + std::unordered_set selfWritten; + { + const auto scope = audioProcessor.selfWriteFifo.read ( + audioProcessor.selfWriteFifo.getNumReady()); + for (int i = 0; i < scope.blockSize1; ++i) + { + auto& e = audioProcessor.selfWriteRing[scope.startIndex1 + i]; + selfWritten.insert (std::to_string (e.pluginId) + ":" + std::to_string (e.paramIndex)); + } + for (int i = 0; i < scope.blockSize2; ++i) + { + auto& e = audioProcessor.selfWriteRing[scope.startIndex2 + i]; + selfWritten.insert (std::to_string (e.pluginId) + ":" + std::to_string (e.paramIndex)); + } + } + + // Refresh modulated param set periodically (every ~0.5s) + if (timerTickCount % 30 == 0) + modulatedParamKeys = audioProcessor.getModulatedParamKeys(); + + juce::Array paramUpdates; + float biggestDelta = 0.0f; + + // ── TIER 1: Visible modulated + recently-changed params — fast read every tick (60Hz) ── + // KEY OPTIMIZATION: Only poll params that are BOTH modulated AND visible on screen. + // With 2000 modulated params but only ~8 visible, this reduces work by 99.6%. + // Non-visible modulated params are still set by C++ audio thread — just no UI readback. + // + // recently-changed keys are always included (user is actively interacting with them, + // and they expire after 2s). + + // Build tier1Keys: (modulated ∩ visible) ∪ recentlyChanged + // JS includes both rack-visible AND lane-visible PIDs in visibleParamKeys, + // so lane badges still update even when the plugin is collapsed in the rack. + std::unordered_set tier1Keys; + if (!visibleParamKeys.empty()) + { + // Iterate the smaller set (visible) and check membership in the larger (modulated) + for (const auto& vk : visibleParamKeys) + { + if (modulatedParamKeys.count (vk) > 0) + tier1Keys.insert (vk); + } + } + else + { + // No visible set from JS yet — fall back to old behavior (all modulated) + tier1Keys = modulatedParamKeys; + } + // Always add recently-changed keys (user interaction, expires via TTL) + for (auto& rc : recentlyChangedKeys) + tier1Keys.insert (rc.first); + + if (!tier1Keys.empty()) + { + for (const auto& key : tier1Keys) + { + auto idIt = paramIdentCache.find (key); + if (idIt == paramIdentCache.end()) continue; // not yet cached by tier 2 + + // Skip collapsed plugins — no point polling invisible params + if (expandedPluginIds.count (idIt->second.pluginId) == 0) continue; + + float val = audioProcessor.getParamValueFast (idIt->second.pluginId, idIt->second.paramIndex); + if (val < 0.0f) continue; // lock unavailable, skip + + auto lastIt = lastParamValues.find (key); + bool changed = (lastIt == lastParamValues.end()) + || (std::abs (val - lastIt->second) > 0.0005f); + + if (changed) + { + auto* pObj = new juce::DynamicObject(); + pObj->setProperty ("id", juce::String (key)); + pObj->setProperty ("v", (double) val); + // Always send display text — plugin is source of truth + auto disp = audioProcessor.getParamDisplayTextFast ( + idIt->second.pluginId, idIt->second.paramIndex); + pObj->setProperty ("disp", disp); + paramUpdates.add (juce::var (pObj)); + + // Keep recently-changed alive while still changing + if (modulatedParamKeys.count (key) == 0) + recentlyChangedKeys[key] = 120; // refresh TTL + } + + // Modulated params NEVER trigger auto-locate + // — they change constantly from logic blocks, not user interaction + lastParamValues[key] = val; + } + } + + // Decrement TTLs on recently-changed keys, expire old ones + for (auto it = recentlyChangedKeys.begin(); it != recentlyChangedKeys.end(); ) + { + if (--(it->second) <= 0) + it = recentlyChangedKeys.erase (it); + else + ++it; + } + + // ── TIER 2: IDLE params — sliding-window scan every 10th tick (~6Hz) ── + // Scans at most BATCH_SIZE params per tick to spread CPU cost. + // Uses getParamValueFast() + getParamDisplayTextFast() instead of getHostedParams() + // to avoid pluginMutex contention and expensive getText() calls on unchanged params. + if (timerTickCount % 10 == 0) + { + // Rebuild identity cache periodically (every 60 ticks = ~1s) to pick up new plugins + bool rebuildCache = (timerTickCount % 60 == 0); + if (rebuildCache) + { + // Use getHostedPluginList + getHostedParams ONLY for initial discovery + // Skip collapsed plugins — no visible rows to update, saves getText() calls + auto pluginList = audioProcessor.getHostedPluginList(); + for (const auto& plugInfo : pluginList) + { + if (expandedPluginIds.count (plugInfo.id) == 0) + continue; + + auto params = audioProcessor.getHostedParams (plugInfo.id); + for (const auto& p : params) + { + auto paramId = juce::String (plugInfo.id) + ":" + juce::String (p.index); + auto key = paramId.toStdString(); + paramIdentCache[key] = { plugInfo.id, p.index }; + } + } + } + // Rebuild flat vector for O(1) sliding window access + if (rebuildCache) + { + paramIdentVec.clear(); + paramIdentVec.reserve (paramIdentCache.size()); + for (const auto& kv : paramIdentCache) + paramIdentVec.push_back (kv); + } + + // Sliding window: scan at most BATCH_SIZE idle params per tick + constexpr int BATCH_SIZE = 200; + int totalParams = (int) paramIdentVec.size(); + if (totalParams > 0) + { + // Advance window position, wrap around + if (tier2ScanOffset >= totalParams) + tier2ScanOffset = 0; + + int endIdx = std::min (tier2ScanOffset + BATCH_SIZE, totalParams); + for (int vi = tier2ScanOffset; vi < endIdx; ++vi) + { + const auto& [key, ident] = paramIdentVec[vi]; + + // Skip modulated params — Tier 1 handles them at 60Hz + if (modulatedParamKeys.count (key) > 0) + continue; + + // Skip recently-changed keys — tier 1 handles them at 60Hz + if (recentlyChangedKeys.count (key) > 0) + continue; + + // Skip collapsed plugins — no visible rows to update + if (expandedPluginIds.count (ident.pluginId) == 0) + continue; + + float val = audioProcessor.getParamValueFast (ident.pluginId, ident.paramIndex); + if (val < 0.0f) continue; + + auto lastIt = lastParamValues.find (key); + bool changed = (lastIt == lastParamValues.end()) + || (std::abs (val - lastIt->second) > 0.0005f); + + if (changed) + { + auto* pObj = new juce::DynamicObject(); + pObj->setProperty ("id", juce::String (key)); + pObj->setProperty ("v", (double) val); + // Only call getText for params that actually changed — not all idle params + auto disp = audioProcessor.getParamDisplayTextFast (ident.pluginId, ident.paramIndex); + pObj->setProperty ("disp", disp); + paramUpdates.add (juce::var (pObj)); + + // Promote to fast poll — user is actively interacting + recentlyChangedKeys[key] = 120; // 2 seconds at 60Hz + } + + // Auto-locate for idle params + if (lastIt != lastParamValues.end() && selfWritten.count (key) == 0) + { + float delta = std::abs (val - lastIt->second); + if (delta > 0.0005f && delta > biggestDelta) + { + biggestDelta = delta; + touchedParamId = juce::String (key); + } + } + lastParamValues[key] = val; + } + + tier2ScanOffset += BATCH_SIZE; + } + } + + if (paramUpdates.size() > 0) + data->setProperty ("params", juce::var (paramUpdates)); + + if (touchedParamId.isNotEmpty()) + data->setProperty ("touchedParam", touchedParamId); + } + + // ── Proxy sync: read atomic cache from audio thread, apply on message thread ── + audioProcessor.syncProxyCacheToHost(); + + // ── Block proxy sync: forward DAW automation to JS block params ── + { + auto blockUpdates = audioProcessor.drainBlockProxyCache(); + for (auto& upd : blockUpdates) + { + auto js = juce::String ("if(typeof setBlockParamFromDAW==='function')setBlockParamFromDAW(") + + juce::String (upd.blockId) + ",'" + + upd.paramKey.replace ("'", "\\'") + "'," + + juce::String (upd.value, 6) + ");"; + webView->evaluateJavascript (js, nullptr); + } + } + + // Crash notifications from audio thread (lock-free FIFO read) + { + const auto cScope = audioProcessor.crashFifo.read (audioProcessor.crashFifo.getNumReady()); + for (int i = 0; i < cScope.blockSize1; ++i) + { + const auto& ce = audioProcessor.crashRing[cScope.startIndex1 + i]; + auto* cObj = new juce::DynamicObject(); + cObj->setProperty ("pluginId", ce.pluginId); + cObj->setProperty ("pluginName", juce::String (ce.pluginName)); + cObj->setProperty ("reason", juce::String (ce.reason)); + webView->emitEventIfBrowserIsVisible ("__plugin_crashed__", juce::var (cObj)); + } + for (int i = 0; i < cScope.blockSize2; ++i) + { + const auto& ce = audioProcessor.crashRing[cScope.startIndex2 + i]; + auto* cObj = new juce::DynamicObject(); + cObj->setProperty ("pluginId", ce.pluginId); + cObj->setProperty ("pluginName", juce::String (ce.pluginName)); + cObj->setProperty ("reason", juce::String (ce.reason)); + webView->emitEventIfBrowserIsVisible ("__plugin_crashed__", juce::var (cObj)); + } + } + + // ── Spectrum analyzer data for WrongEQ (~20Hz update) ── + if (timerTickCount % 3 == 0) + { + float specBins[ModularRandomizerAudioProcessor::spectrumBinCount]; + int n = audioProcessor.getSpectrumBins (specBins, ModularRandomizerAudioProcessor::spectrumBinCount); + if (n > 0) + { + juce::Array specArr; + specArr.ensureStorageAllocated (n); + for (int i = 0; i < n; ++i) + specArr.add ((double) specBins[i]); + data->setProperty ("spectrum", juce::var (specArr)); + } + } + // ── WrongEQ readback: push C++ eqPoints atomics to JS (~10Hz) ── + // When C++ modulation writes to eqPoints (via setParamDirect), JS needs to know + // so it can update canvas + virtual param displays. Only send if there are EQ points. + if (timerTickCount % 6 == 0) + { + ModularRandomizerAudioProcessor::WeqReadbackPoint weqPts[8]; + int nPts = audioProcessor.getWeqReadback (weqPts, 8); + if (nPts > 0) + { + juce::Array weqArr; + weqArr.ensureStorageAllocated (nPts); + for (int i = 0; i < nPts; ++i) + { + auto* pt = new juce::DynamicObject(); + pt->setProperty ("freq", (double) weqPts[i].freqHz); + pt->setProperty ("gain", (double) weqPts[i].gainDB); + pt->setProperty ("q", (double) weqPts[i].q); + pt->setProperty ("drift",(double) weqPts[i].driftPct); + weqArr.add (juce::var (pt)); + } + data->setProperty ("weqReadback", juce::var (weqArr)); + } + + // Send global EQ params too (modulation can change them) + { + auto wg = audioProcessor.getWeqGlobals(); + auto* gObj = new juce::DynamicObject(); + gObj->setProperty ("depth", (double) wg.depth); + gObj->setProperty ("warp", (double) wg.warp); + gObj->setProperty ("steps", (int) wg.steps); + gObj->setProperty ("tilt", (double) wg.tilt); + data->setProperty ("weqGlobals", juce::var (gObj)); + } + } + + webView->emitEventIfBrowserIsVisible ("__rt_data__", juce::var (data)); +} + +std::optional +ModularRandomizerAudioProcessorEditor::getResource (const juce::String& url) +{ + const auto urlToRetrieve = url == "/" ? juce::String { "index.html" } + : url.fromFirstOccurrenceOf ("/", false, false); + +#if JUCE_DEBUG + DBG ("Resource requested: " + url + " → resolved: " + urlToRetrieve); +#endif + + // Generic BinaryData lookup — iterate all registered resources + for (int i = 0; i < BinaryData::namedResourceListSize; ++i) + { + const char* resourceName = BinaryData::namedResourceList[i]; + const char* originalFilename = BinaryData::getNamedResourceOriginalFilename (resourceName); + + if (originalFilename != nullptr && urlToRetrieve.endsWith (juce::String (originalFilename))) + { + int dataSize = 0; + const char* data = BinaryData::getNamedResource (resourceName, dataSize); + + if (data != nullptr && dataSize > 0) + { + std::vector byteData (static_cast (dataSize)); + std::memcpy (byteData.data(), data, static_cast (dataSize)); + + auto ext = urlToRetrieve.fromLastOccurrenceOf (".", false, false).toLowerCase(); + auto mime = getMimeForExtension (ext); + +#if JUCE_DEBUG + DBG ("Resource FOUND: " + urlToRetrieve + " (" + juce::String (dataSize) + " bytes, " + mime + ")"); +#endif + + return juce::WebBrowserComponent::Resource { std::move (byteData), juce::String { mime } }; + } + } + } + +#if JUCE_DEBUG + DBG ("Resource NOT FOUND: " + urlToRetrieve); +#endif + + return std::nullopt; +} + +const char* ModularRandomizerAudioProcessorEditor::getMimeForExtension (const juce::String& extension) +{ + static const std::unordered_map mimeMap = + { + { { "html" }, "text/html" }, + { { "css" }, "text/css" }, + { { "js" }, "text/javascript" }, + { { "json" }, "application/json" }, + { { "png" }, "image/png" }, + { { "jpg" }, "image/jpeg" }, + { { "svg" }, "image/svg+xml" }, + { { "ttf" }, "font/ttf" }, + { { "woff" }, "font/woff" }, + { { "woff2"}, "font/woff2" } + }; + + if (const auto it = mimeMap.find (extension.toLowerCase()); it != mimeMap.end()) + return it->second; + + return "text/plain"; +} + +void ModularRandomizerAudioProcessorEditor::openPluginEditorWindow (int pluginId) +{ + // Toggle: if already open, close it + auto it = pluginEditorWindows.find (pluginId); + if (it != pluginEditorWindows.end()) + { + pluginEditorWindows.erase (it); + return; + } + + // Get the plugin instance + auto* instance = audioProcessor.getHostedPluginInstance (pluginId); + if (instance == nullptr) + { + DBG ("openPluginEditorWindow: plugin ID " + juce::String (pluginId) + " not found"); + return; + } + + // Find the plugin name for the window title + juce::String windowTitle = "Plugin Editor"; + auto pluginList = audioProcessor.getHostedPluginList(); + for (const auto& info : pluginList) + { + if (info.id == pluginId) + { + windowTitle = info.name; + break; + } + } + + // Create the editor window + pluginEditorWindows[pluginId] = std::make_unique ( + windowTitle, + instance, + [this, pluginId]() + { + // Schedule removal to avoid deleting during callback + juce::Component::SafePointer safeThis (this); + juce::MessageManager::callAsync ([safeThis, pluginId]() + { + if (safeThis != nullptr) + safeThis->pluginEditorWindows.erase (pluginId); + }); + } + ); +} diff --git a/plugins/ModularRandomizer/Source/PluginEditor.h b/plugins/ModularRandomizer/Source/PluginEditor.h new file mode 100644 index 0000000..3bf56e4 --- /dev/null +++ b/plugins/ModularRandomizer/Source/PluginEditor.h @@ -0,0 +1,184 @@ +#pragma once + +#include "PluginProcessor.h" +#include +#include +#include +#include + +//============================================================================== +// SEH helper for plugin editor teardown -- must be a free function because +// MSVC forbids __try in functions whose enclosing class has members that +// require unwinding (e.g. std::function). Also cannot use DBG or any +// construct that creates C++ objects with destructors. +#if JUCE_WINDOWS +static void safeClearEditorContent (juce::DocumentWindow* w) +{ + __try + { + w->clearContentComponent(); + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + OutputDebugStringA ("PluginEditorWindow: SEH caught during editor teardown\n"); + } +} +#else +static inline void safeClearEditorContent (juce::DocumentWindow* w) +{ + try { w->clearContentComponent(); } catch (...) {} +} +#endif + +/** + * Window that hosts a VST3 plugin's native editor GUI. + * Opens as a separate floating window on top of the main plugin window. + */ +class PluginEditorWindow : public juce::DocumentWindow +{ +public: + PluginEditorWindow (const juce::String& name, + juce::AudioPluginInstance* pluginInstance, + std::function onCloseCallback) + : DocumentWindow (name, juce::Colours::darkgrey, DocumentWindow::closeButton), + closeCallback (std::move (onCloseCallback)) + { + if (pluginInstance != nullptr) + { + if (auto* editor = pluginInstance->createEditor()) + { + setContentOwned (editor, true); + } + else + { + // Plugin has no GUI — show placeholder + auto* label = new juce::Label ({}, "No GUI available"); + label->setSize (300, 100); + label->setJustificationType (juce::Justification::centred); + setContentOwned (label, true); + } + } + + setUsingNativeTitleBar (true); + setResizable (true, false); + setAlwaysOnTop (true); + centreWithSize (getWidth(), getHeight()); + setVisible (true); + } + + ~PluginEditorWindow() override + { + // Must clear content component BEFORE DocumentWindow destructor. + // Some plugins crash during editor teardown (especially while modulated), + // so we wrap in SEH to prevent killing the host process. + safeClearEditorContent (this); + } + + void closeButtonPressed() override + { + // SAFETY: Do NOT call clearContentComponent() synchronously here. + // Some plugins' editor destructors trigger re-entrant message dispatch + // (modal dialogs, parameter notifications, COM calls), which could process + // our queued async erase while we're still inside this callback — destroying + // 'this' mid-function and crashing the host. + // + // Instead: hide the window immediately (visual feedback) and defer ALL + // destruction to an async block so the call stack fully unwinds first. + setVisible (false); + + // prevent user from interacting while destruction is pending + if (closeCallback) + { + auto cb = std::move (closeCallback); // move out to prevent double-fire + try { cb(); } + catch (...) { DBG ("PluginEditorWindow: close callback threw"); } + } + } + +private: + std::function closeCallback; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginEditorWindow) +}; + +//============================================================================== +/** + * ModularRandomizer Editor — WebView2-based UI + * + * ⚠️ CRITICAL: Member declaration order prevents DAW crashes on unload. + * Destruction order = reverse of declaration. + * Order: Relays → WebView → Attachments + */ +class ModularRandomizerAudioProcessorEditor : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + ModularRandomizerAudioProcessorEditor (ModularRandomizerAudioProcessor&); + ~ModularRandomizerAudioProcessorEditor() override; + + //============================================================================== + void paint (juce::Graphics&) override; + void resized() override; + +private: + void timerCallback() override; + + // Resource provider for WebView + std::optional getResource (const juce::String& url); + static const char* getMimeForExtension (const juce::String& extension); + + // Open/close a hosted plugin's editor window + void openPluginEditorWindow (int pluginId); + + ModularRandomizerAudioProcessor& audioProcessor; + + // ═══════════════════════════════════════════════════════════════ + // CRITICAL: Destruction Order = Reverse of Declaration + // Order: Relays → WebView → Attachments + // ═══════════════════════════════════════════════════════════════ + + // 1. RELAYS FIRST (destroyed last) + juce::WebSliderRelay mixRelay { "MIX" }; + juce::WebToggleButtonRelay bypassRelay { "BYPASS" }; + + // 2. WEBVIEW SECOND (destroyed middle) + std::unique_ptr webView; + + // 3. ATTACHMENTS LAST (destroyed first) + std::unique_ptr mixAttachment; + std::unique_ptr bypassAttachment; + + // Hosted plugin editor windows (keyed by plugin ID) + std::map> pluginEditorWindows; + + // Timer tick counter for throttled polling + int timerTickCount = 0; + int tier2ScanOffset = 0; + + // Auto-locate: track which param was last touched in hosted plugin UI + std::unordered_map lastParamValues; + juce::String touchedParamId; + + // Two-tier polling: modulated params polled frequently, idle params polled lazily + std::unordered_set modulatedParamKeys; // refreshed periodically + + // Params that recently changed (from hosted plugin UI) — promoted to tier 1 temporarily + // Value = TTL in timer ticks (decremented each tick, removed when 0) + std::unordered_map recentlyChangedKeys; + + // Per-param identity cache: key → {pluginId, paramIndex} for fast value reads + struct ParamIdent { int pluginId; int paramIndex; }; + std::unordered_map paramIdentCache; + std::vector> paramIdentVec; // O(1) indexed access for sliding window + + // Visibility culling: only poll params from expanded (visible) plugins + // Updated by JS via setExpandedPlugins native function + std::unordered_set expandedPluginIds; + + // Fine-grained visibility: only Tier 1 poll params actually visible on screen + // Updated by JS via setVisibleParams native function (~8 PIDs, debounced 100ms) + std::unordered_set visibleParamKeys; + + //============================================================================== + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ModularRandomizerAudioProcessorEditor) +}; diff --git a/plugins/ModularRandomizer/Source/PluginHosting.cpp b/plugins/ModularRandomizer/Source/PluginHosting.cpp new file mode 100644 index 0000000..a597f92 --- /dev/null +++ b/plugins/ModularRandomizer/Source/PluginHosting.cpp @@ -0,0 +1,1629 @@ +/* + ============================================================================== + + PluginHosting.cpp + Plugin hosting: scan, load, remove, param access, proxy pool, glide API + + ============================================================================== +*/ + +#include "PluginProcessor.h" +#include +//============================================================================== +// PLUGIN HOSTING API +//============================================================================== +// ── SEH wrappers: must be free functions with NO C++ objects that need unwinding ── +// MSVC C2712: __try is illegal in functions requiring object unwinding. +// These helpers take only raw pointers, so no destructors are in scope. +#ifdef _WIN32 +// Returns: 1 = more files to scan, 0 = done, -1 = SEH crash +static int sehScanOneFile (juce::PluginDirectoryScanner* scanner, juce::String* name) +{ + __try { + bool more = scanner->scanNextFile (true, *name); + return more ? 1 : 0; + } + __except (EXCEPTION_EXECUTE_HANDLER) { + return -1; + } +} + +// Inner helper: does the actual C++ work (may throw C++ exceptions, has objects with dtors) +static juce::AudioPluginInstance* createInstanceInner ( + juce::AudioPluginFormatManager* mgr, + const juce::PluginDescription* desc, + double sampleRate, int blockSize, + juce::String* errorMsg) +{ + auto result = mgr->createPluginInstance (*desc, sampleRate, blockSize, *errorMsg); + return result.release(); +} + +// Outer SEH wrapper: catches hardware faults. No C++ objects here — only a function call. +#pragma warning(push) +#pragma warning(disable: 4611) // interaction between setjmp and C++ object destruction +static bool sehCreateInstance ( + juce::AudioPluginFormatManager* mgr, + const juce::PluginDescription* desc, + double sampleRate, int blockSize, + juce::String* errorMsg, + juce::AudioPluginInstance** outRaw) +{ + __try { + *outRaw = createInstanceInner (mgr, desc, sampleRate, blockSize, errorMsg); + return true; + } + __except (EXCEPTION_EXECUTE_HANDLER) { + *outRaw = nullptr; + return false; + } +} +#pragma warning(pop) + +// SEH wrapper for setStateInformation — some plugins crash on malformed state data +static bool sehSetState (juce::AudioPluginInstance* instance, const void* data, int size) +{ + __try { + instance->setStateInformation (data, size); + return true; + } + __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} + +// SEH wrapper for releaseResources — some plugins crash during cleanup +bool sehReleaseResources (juce::AudioPluginInstance* instance) +{ + __try { + instance->releaseResources(); + return true; + } + __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} + +// SEH wrapper for plugin instance destruction — some plugins crash in their +// destructor (e.g. GPU surface cleanup, COM shutdown). C++ try-catch cannot +// catch access violations — only __try/__except can. +// Takes a raw pointer and deletes it inside the SEH guard. +bool sehDestroyInstance (juce::AudioPluginInstance* rawInstance) +{ + __try { + delete rawInstance; + return true; + } + __except (EXCEPTION_EXECUTE_HANDLER) { + // The instance leaked, but the host process survives. + return false; + } +} +#endif + +std::vector ModularRandomizerAudioProcessor::scanForPlugins ( + const juce::StringArray& paths) +{ + std::vector results; + juce::StringArray seen; + + LOG_TO_FILE ("scanForPlugins: filesystem scan + knownPlugins enrichment"); + + // Phase 1: Fast filesystem scan for immediate results + for (const auto& scanDir : paths) + { + juce::File dir (scanDir); + if (! dir.isDirectory()) + { + LOG_TO_FILE (" Skipping (not a directory): " << scanDir.toStdString()); + continue; + } + + LOG_TO_FILE (" Scanning directory: " << scanDir.toStdString()); + + auto vst3Items = dir.findChildFiles ( + juce::File::findFilesAndDirectories, true, "*.vst3"); + + for (const auto& item : vst3Items) + { + auto fullPath = item.getFullPathName(); + if (fullPath.contains ("Contents")) continue; + if (seen.contains (fullPath)) continue; + seen.add (fullPath); + + ScannedPlugin sp; + sp.name = item.getFileNameWithoutExtension(); + sp.path = fullPath; + sp.format = "VST3"; + sp.category = "fx"; + sp.vendor = item.getParentDirectory().getFileName(); + if (sp.vendor == dir.getFileName()) sp.vendor = ""; + + results.push_back (sp); + } + } + + // Phase 1.5: Deep scan via PluginDirectoryScanner to populate knownPlugins + // with real metadata (isInstrument, category, manufacturerName). + // Cache results to disk to avoid rescanning on every rebuild/restart. + auto cacheFile = juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile ("Noizefield/ModularRandomizer/known_plugins.xml"); + + bool cacheLoaded = false; + if (cacheFile.existsAsFile()) + { + auto xml = juce::parseXML (cacheFile); + if (xml != nullptr) + { + knownPlugins.recreateFromXml (*xml); + cacheLoaded = knownPlugins.getNumTypes() > 0; + if (cacheLoaded) + { + LOG_TO_FILE (" Loaded " << knownPlugins.getNumTypes() + << " plugins from cache (skipping deep scan)"); + // Brief progress flash so UI sees "Loaded from cache" + { std::lock_guard lk (scanProgressMutex); + scanProgressName = "Loaded from cache"; } + scanProgressFraction.store (1.0f); + } + } + } + + if (!cacheLoaded) + { + // No valid cache — do the full deep scan with per-file progress + scanActive.store (true); +#ifdef _WIN32 + CoInitializeEx (nullptr, COINIT_APARTMENTTHREADED); +#endif + for (int fi = 0; fi < formatManager.getNumFormats(); ++fi) + { + auto* format = formatManager.getFormat (fi); + juce::FileSearchPath searchPath; + for (const auto& dir : paths) + searchPath.add (juce::File (dir)); + + juce::PluginDirectoryScanner scanner ( + knownPlugins, *format, searchPath, + true, // recursive + juce::File() + ); + + juce::String name; + int scannedCount = 0; + bool scanning = true; + while (scanning) + { + try + { +#ifdef _WIN32 + int r = sehScanOneFile (&scanner, &name); + if (r <= 0) + { + if (r < 0) + LOG_TO_FILE (" SEH FAULT during deep scan of: " + << name.toStdString()); + scanning = false; + } +#else + scanning = scanner.scanNextFile (true, name); +#endif + } + catch (...) + { + LOG_TO_FILE (" EXCEPTION during deep scan of: " + << name.toStdString()); + scanning = false; + } + + ++scannedCount; + // Update progress for UI polling + float prog = scanner.getProgress(); + { std::lock_guard lk (scanProgressMutex); + scanProgressName = name; } + scanProgressFraction.store (prog); + + if (scannedCount % 20 == 0) + LOG_TO_FILE (" Deep scan progress: " << scannedCount + << " files (" << (int)(prog * 100) << "%)"); + } + LOG_TO_FILE (" Format '" << format->getName().toStdString() + << "': scanned " << scannedCount << " files"); + } +#ifdef _WIN32 + CoUninitialize(); +#endif + + scanActive.store (false); + scanProgressFraction.store (1.0f); + { std::lock_guard lk (scanProgressMutex); + scanProgressName = "Done"; } + + // Save cache to disk for next time + if (knownPlugins.getNumTypes() > 0) + { + auto xml = knownPlugins.createXml(); + if (xml != nullptr) + { + cacheFile.getParentDirectory().createDirectory(); + xml->writeTo (cacheFile); + LOG_TO_FILE (" Saved plugin cache: " << knownPlugins.getNumTypes() << " types"); + } + } + } + LOG_TO_FILE (" Known plugins: " << knownPlugins.getNumTypes() << " types available"); + + // Phase 2: Enrich filesystem results with real metadata from knownPlugins + // (populated by Phase 1.5 deep scan above) + for (auto& sp : results) + { + auto pluginFileName = juce::File (sp.path).getFileNameWithoutExtension(); + + for (const auto& desc : knownPlugins.getTypes()) + { + if (desc.fileOrIdentifier == sp.path + || desc.fileOrIdentifier.containsIgnoreCase (pluginFileName)) + { + // Overwrite with real metadata + sp.name = desc.name; + sp.vendor = desc.manufacturerName; + sp.format = desc.pluginFormatName; + + // Classify using PluginDescription metadata + if (desc.isInstrument) + sp.category = "synth"; + else if (desc.category.containsIgnoreCase ("Instrument") + || desc.category.containsIgnoreCase ("Synth") + || desc.category.containsIgnoreCase ("Generator")) + sp.category = "synth"; + else if (desc.category.containsIgnoreCase ("Sampler")) + sp.category = "sampler"; + else if (desc.category.containsIgnoreCase ("Analyzer") + || desc.category.containsIgnoreCase ("Tools") + || desc.category.containsIgnoreCase ("Mastering") + || desc.category.containsIgnoreCase ("Restoration") + || desc.category.containsIgnoreCase ("Network")) + sp.category = "utility"; + else + sp.category = "fx"; + + break; + } + } + } + + LOG_TO_FILE ("scanForPlugins complete: " << results.size() << " plugins found (" + << knownPlugins.getNumTypes() << " with metadata)"); + return results; +} + +void ModularRandomizerAudioProcessor::clearPluginCache() +{ + auto cacheFile = juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile ("Noizefield/ModularRandomizer/known_plugins.xml"); + cacheFile.deleteFile(); + knownPlugins.clear(); + LOG_TO_FILE ("clearPluginCache: cache deleted, next scan will be full"); +} + +ModularRandomizerAudioProcessor::ScanProgress +ModularRandomizerAudioProcessor::getScanProgress() +{ + ScanProgress sp; + { std::lock_guard lk (scanProgressMutex); + sp.currentPlugin = scanProgressName; } + sp.progress = scanProgressFraction.load(); + sp.scanning = scanActive.load(); + return sp; +} + +// ── Phase 1: Find plugin description (thread-safe, disk I/O only) ── +bool ModularRandomizerAudioProcessor::findPluginDescription ( + const juce::String& pluginPath, juce::PluginDescription& descOut) +{ + auto pluginFileName = juce::File (pluginPath).getFileNameWithoutExtension(); + + // Check knownPlugins cache first (fast, no disk I/O) + for (const auto& d : knownPlugins.getTypes()) + { + if (d.fileOrIdentifier == pluginPath || d.name == pluginPath + || d.fileOrIdentifier.containsIgnoreCase (pluginFileName)) + { + descOut = d; + return true; + } + } + + // Not cached — scan single file (disk I/O) + LOG_TO_FILE (" Plugin not in known list, scanning single file..."); + + for (int fi = 0; fi < formatManager.getNumFormats(); ++fi) + { + auto* format = formatManager.getFormat (fi); + juce::File pluginFile (pluginPath); + juce::FileSearchPath singlePath (pluginFile.getParentDirectory().getFullPathName()); + + juce::PluginDirectoryScanner scanner ( + knownPlugins, *format, singlePath, + false, // not recursive + juce::File() + ); + + juce::String name; + // SEH + C++ guard: some VST3 factories crash during enumeration + try + { +#ifdef _WIN32 + while (true) + { + int r = sehScanOneFile (&scanner, &name); + if (r < 0) + LOG_TO_FILE (" SEH FAULT during plugin scan: " << pluginPath.toStdString()); + if (r <= 0) break; + } +#else + while (scanner.scanNextFile (true, name)) + { + LOG_TO_FILE (" Single-file scan: " << name.toStdString()); + } +#endif + } + catch (...) + { + LOG_TO_FILE (" EXCEPTION during plugin scan: " << pluginPath.toStdString()); + } + + for (const auto& d : knownPlugins.getTypes()) + { + if (d.fileOrIdentifier == pluginPath + || d.fileOrIdentifier.containsIgnoreCase (pluginPath) + || pluginPath.containsIgnoreCase (d.fileOrIdentifier) + || d.fileOrIdentifier.containsIgnoreCase (pluginFileName)) + { + descOut = d; + LOG_TO_FILE (" Matched: " << d.name.toStdString() << " via " << d.fileOrIdentifier.toStdString()); + return true; + } + } + } + + return false; +} + +// ── Phase 2: Instantiate plugin (message thread only — COM requirement) ── +int ModularRandomizerAudioProcessor::instantiatePlugin (const juce::PluginDescription& desc) +{ + juce::String errorMessage; + std::unique_ptr instance; + +#ifdef _WIN32 + // SEH guard: some VST3 plugins trigger access violations during + // factory creation / COM initialization. C++ try-catch doesn't catch + // hardware faults on Windows — only __try/__except does. + juce::AudioPluginInstance* rawInstance = nullptr; + bool sehOk = sehCreateInstance (&formatManager, &desc, + currentSampleRate, currentBlockSize, + &errorMessage, &rawInstance); + instance.reset (rawInstance); // Adopt raw pointer into unique_ptr + if (!sehOk) + { + LOG_TO_FILE (" SEH FAULT during createPluginInstance for: " << desc.name.toStdString()); + return -1; + } +#else + try + { + instance = formatManager.createPluginInstance ( + desc, currentSampleRate, currentBlockSize, errorMessage); + } + catch (...) + { + LOG_TO_FILE (" CRASH during createPluginInstance for: " << desc.name.toStdString()); + return -1; + } +#endif + + if (instance == nullptr) + { + LOG_TO_FILE (" FAILED to create instance: " << errorMessage.toStdString()); + return -1; + } + + // Configure bus layout + bool pluginIsInstrument = desc.isInstrument + || desc.category.containsIgnoreCase ("Instrument") + || desc.category.containsIgnoreCase ("Synth") + || desc.category.containsIgnoreCase ("Generator"); + + try + { + bool layoutOk = false; + + if (pluginIsInstrument) + { + // ── Synth/instrument: try output-only first (no audio input needed) ── + juce::AudioProcessor::BusesLayout synthLayout; + synthLayout.outputBuses.add (juce::AudioChannelSet::stereo()); + + if (instance->setBusesLayout (synthLayout)) + { + layoutOk = true; + LOG_TO_FILE (" Instrument layout: 0 in, stereo out"); + } + else + { + // Some synths require an input bus even if they don't use it + juce::AudioProcessor::BusesLayout synthFallback; + synthFallback.inputBuses.add (juce::AudioChannelSet::stereo()); + synthFallback.outputBuses.add (juce::AudioChannelSet::stereo()); + if (instance->setBusesLayout (synthFallback)) + { + layoutOk = true; + LOG_TO_FILE (" Instrument layout: stereo in (unused), stereo out"); + } + } + } + + if (! layoutOk) + { + // ── Effect or synth fallback: standard stereo in/out ── + juce::AudioProcessor::BusesLayout stereoLayout; + stereoLayout.inputBuses.add (juce::AudioChannelSet::stereo()); + stereoLayout.outputBuses.add (juce::AudioChannelSet::stereo()); + + if (! instance->setBusesLayout (stereoLayout)) + { + juce::AudioProcessor::BusesLayout monoLayout; + monoLayout.inputBuses.add (juce::AudioChannelSet::mono()); + monoLayout.outputBuses.add (juce::AudioChannelSet::mono()); + + if (! instance->setBusesLayout (monoLayout)) + { + LOG_TO_FILE (" Plugin rejected stereo and mono layouts, using default"); + } + } + } + + int pluginIns = instance->getTotalNumInputChannels(); + int pluginOuts = instance->getTotalNumOutputChannels(); + instance->setPlayConfigDetails (pluginIns, pluginOuts, + currentSampleRate, currentBlockSize); + + LOG_TO_FILE (" Configured plugin: " << pluginIns << " in, " + << pluginOuts << " out, " + << currentSampleRate << " Hz, " + << currentBlockSize << " samples" + << (pluginIsInstrument ? " [INSTRUMENT]" : "")); + } + catch (...) + { + LOG_TO_FILE (" WARNING: exception during bus layout configuration"); + } + + // Prepare the instance + try + { + instance->prepareToPlay (currentSampleRate, currentBlockSize); + } + catch (...) + { + LOG_TO_FILE (" CRASH during prepareToPlay for: " << desc.name.toStdString()); + return -1; + } + + // Create hosted plugin entry + auto hp = std::make_unique(); + hp->id = ++nextPluginId; + hp->name = desc.name; + hp->path = desc.fileOrIdentifier; + hp->description = desc; + hp->instance = std::move (instance); + hp->prepared = true; + hp->isInstrument = pluginIsInstrument; + + int id = hp->id; + int paramCount = (int) hp->instance->getParameters().size(); + + { + std::lock_guard lock (pluginMutex); + + // Safety: if we'd exceed reserved capacity, the push_back would + // reallocate the vector — which would invalidate any pointers the + // audio thread holds (it iterates hostedPlugins without the mutex). + // Reject the load instead of crashing. + if (hostedPlugins.size() >= hostedPlugins.capacity()) + { + LOG_TO_FILE (" REJECTED: plugin vector at capacity (" << hostedPlugins.capacity() + << "). Cannot load more plugins safely."); + // Release the instance gracefully outside the lock + hp->instance->releaseResources(); + return -1; + } + + hostedPlugins.push_back (std::move (hp)); + rebuildPluginSlots(); + } + + LOG_TO_FILE (" Loaded plugin: " << desc.name.toStdString() + << " (ID: " << id << ", Params: " << paramCount << ")"); + + assignProxySlotsForPlugin (id); + + return id; +} + +// ── Convenience wrapper: scan + instantiate (used by setStateInformation) ── +int ModularRandomizerAudioProcessor::loadPlugin (const juce::String& pluginPath) +{ + LOG_TO_FILE ("loadPlugin: " << pluginPath.toStdString()); + + juce::PluginDescription desc; + if (! findPluginDescription (pluginPath, desc)) + { + LOG_TO_FILE (" FAILED: Plugin description not found after scan"); + return -1; + } + return instantiatePlugin (desc); +} + +void ModularRandomizerAudioProcessor::removePlugin (int pluginId) +{ + // This function must ALWAYS succeed — no exceptions, no crashes. + try + { + // 1. Free proxy slots (safe, no mutex needed for proxy array) + try { freeProxySlotsForPlugin (pluginId); } catch (...) {} + + // 2. Null the instance — audio thread will see nullptr and skip. + // Do NOT erase from the vector (audio thread may be iterating it). + std::unique_ptr instanceToDestroy; + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId) + { + // Take ownership — audio thread sees nullptr → skips + instanceToDestroy = std::move (hp->instance); + hp->prepared = false; + hp->crashed = true; + hp->id = -1; // tombstone marker + break; + } + } + rebuildPluginSlots(); + + // 3. Mark any active glides targeting this plugin as expired. + // The audio thread owns glidePool — we don't erase from here. + // Setting samplesLeft=0 causes the audio thread to remove them. + for (int gi = 0; gi < numActiveGlides; ++gi) + { + if (glidePool[gi].pluginId == pluginId) + glidePool[gi].samplesLeft = 0; + } + } + + // 4. Release resources OUTSIDE the mutex (some plugins do blocking I/O) + // SEH-guarded: some plugins crash during releaseResources or their destructor. + // C++ try-catch cannot catch Access Violations — only SEH can. + if (instanceToDestroy) + { +#ifdef _WIN32 + sehReleaseResources (instanceToDestroy.get()); + // Release ownership and destroy under SEH guard + sehDestroyInstance (instanceToDestroy.release()); +#else + try { instanceToDestroy->releaseResources(); } catch (...) {} + // instanceToDestroy destroyed here by unique_ptr dtor +#endif + } + + DBG ("Removed plugin ID: " + juce::String (pluginId)); + } + catch (...) + { + DBG ("removePlugin: exception caught and swallowed for plugin ID " + juce::String (pluginId)); + } +} + +// Garbage-collect dead plugin entries (id == -1). Call from message thread only. +void ModularRandomizerAudioProcessor::purgeDeadPlugins() +{ + std::lock_guard lock (pluginMutex); + hostedPlugins.erase ( + std::remove_if (hostedPlugins.begin(), hostedPlugins.end(), + [] (const std::unique_ptr& hp) { return hp->id < 0; }), + hostedPlugins.end()); + rebuildPluginSlots(); +} + +void ModularRandomizerAudioProcessor::reorderPlugins (const std::vector& orderedIds) +{ + std::lock_guard lock (pluginMutex); + + // In-place reorder using swaps — the vector's size and allocation never change, + // so the audio thread's iteration remains safe (no iterator invalidation). + for (size_t targetPos = 0; targetPos < orderedIds.size() && targetPos < hostedPlugins.size(); ++targetPos) + { + int wantedId = orderedIds[targetPos]; + + // Find which position currently holds the wanted ID + size_t foundPos = targetPos; + for (size_t j = targetPos; j < hostedPlugins.size(); ++j) + { + if (hostedPlugins[j] && hostedPlugins[j]->id == wantedId) + { + foundPos = j; + break; + } + } + + // Swap into correct position (std::swap on unique_ptr is just pointer swap) + if (foundPos != targetPos) + std::swap (hostedPlugins[targetPos], hostedPlugins[foundPos]); + } + rebuildPluginSlots(); +} + +std::vector +ModularRandomizerAudioProcessor::getHostedParams (int pluginId) +{ + std::vector result; + std::lock_guard lock (pluginMutex); + + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + auto& params = hp->instance->getParameters(); + for (int i = 0; i < params.size(); ++i) + { + auto* p = params[i]; + + // Skip non-automatable parameters + if (! p->isAutomatable()) + continue; + + // Skip VST3 internal MIDI CC / Aftertouch / Pitchbend parameters + auto name = p->getName (64); + if (name.startsWith ("MIDI CC") + || name.startsWith ("Aftertouch") + || name.startsWith ("Pitchbend")) + continue; + + ParamInfo info; + info.index = i; + info.name = name; + info.value = p->getValue(); // normalised 0-1 + info.label = p->getLabel(); + info.displayText = p->getText (p->getValue(), 32); + info.automatable = true; + result.push_back (info); + } + break; + } + } + return result; +} + +std::vector +ModularRandomizerAudioProcessor::getFactoryPresets (int pluginId) +{ + std::vector result; + juce::String pluginName; + juce::String vendorName; + juce::String pluginPath; + + { + std::lock_guard lock (pluginMutex); + + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + pluginName = hp->name; + vendorName = hp->description.manufacturerName; + pluginPath = hp->path; + + // ── Strategy 1: Plugin program list ── + int numPrograms = hp->instance->getNumPrograms(); + LOG_TO_FILE ("getFactoryPresets: plugin '" << hp->name.toStdString() + << "' id=" << pluginId + << " numPrograms=" << numPrograms); + + bool hasRealPrograms = false; + if (numPrograms >= 1) + { + // Sample first few program names to check for generic placeholders + int samplesToCheck = juce::jmin (numPrograms, 5); + int genericCount = 0; + for (int si = 0; si < samplesToCheck; ++si) + { + auto sname = hp->instance->getProgramName (si).trim(); + if (sname.isEmpty() || sname == "Default" || sname == "Init" + || sname == "default" || sname == "init") + { + genericCount++; + continue; + } + // Check generic patterns + if (sname.startsWith ("ProgramChange ") || sname.startsWith ("Program ") + || sname.startsWith ("Preset ") || sname.startsWith ("Prog ") + || sname.startsWith ("Bank ") || sname.startsWith ("Patch ")) + { + auto suffix = sname.fromFirstOccurrenceOf (" ", false, false).trim(); + if (suffix.containsOnly ("0123456789")) + genericCount++; + } + } + // If most samples are NOT generic, treat as real programs + hasRealPrograms = (genericCount < samplesToCheck); + } + + if (hasRealPrograms) + { + juce::StringArray seenNames; + for (int i = 0; i < numPrograms; ++i) + { + auto name = hp->instance->getProgramName (i).trim(); + if (name.isEmpty()) continue; + // Skip generic numbered names + if (name.startsWith ("Program ") || name.startsWith ("Preset ") + || name.startsWith ("ProgramChange ") || name.startsWith ("Prog ") + || name.startsWith ("Bank ") || name.startsWith ("Patch ")) + { + auto suffix = name.fromFirstOccurrenceOf (" ", false, false).trim(); + if (suffix.containsOnly ("0123456789")) + continue; + } + // Skip duplicates (some plugins return same name for all slots) + if (seenNames.contains (name, true)) continue; + seenNames.add (name); + result.push_back ({ i, name }); + } + } + break; + } + } + } + + // ── Strategy 2: Use the pre-built preset index ── + if (result.empty() && pluginName.isNotEmpty() && presetIndexReady.load()) + { + result = getIndexedPresets (pluginName, vendorName); + LOG_TO_FILE ("getFactoryPresets: index lookup for '" << pluginName.toStdString() + << "' returned " << result.size() << " presets"); + } + + return result; +} + +std::vector +ModularRandomizerAudioProcessor::loadFactoryPreset (int pluginId, int programIndex) +{ + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + hp->instance->setCurrentProgram (programIndex); + break; + } + } + } + // Return updated param values (reuses existing getHostedParams logic) + return getHostedParams (pluginId); +} + +std::vector +ModularRandomizerAudioProcessor::loadFactoryPresetFromFile (int pluginId, const juce::String& filePath) +{ + juce::File presetFile (filePath); + if (! presetFile.existsAsFile()) + { + LOG_TO_FILE ("loadFactoryPresetFromFile: file not found: " << filePath.toStdString()); + return getHostedParams (pluginId); + } + + juce::MemoryBlock fileData; + if (! presetFile.loadFileAsData (fileData)) + { + LOG_TO_FILE ("loadFactoryPresetFromFile: failed to read file"); + return getHostedParams (pluginId); + } + + // ── Parse .vstpreset binary format ── + // Header: "VST3"(4) + version(4) + classID(32) + chunkListOffset(8) = 48 bytes + // Chunk list: "List"(4) + count(4) + entries(each: id(4) + offset(8) + size(8) = 20) + juce::MemoryBlock compState, contState; + bool parsed = false; + + auto* raw = static_cast (fileData.getData()); + auto fileSize = (int64_t) fileData.getSize(); + + if (fileSize >= 48 && raw[0] == 'V' && raw[1] == 'S' && raw[2] == 'T' && raw[3] == '3') + { + int64_t chunkListOffset = 0; + std::memcpy (&chunkListOffset, raw + 40, 8); + + if (chunkListOffset > 0 && chunkListOffset + 8 <= fileSize) + { + auto* listPtr = raw + chunkListOffset; + if (listPtr[0] == 'L' && listPtr[1] == 'i' && listPtr[2] == 's' && listPtr[3] == 't') + { + int32_t entryCount = 0; + std::memcpy (&entryCount, listPtr + 4, 4); + + for (int32_t i = 0; i < entryCount && i < 16; ++i) + { + auto* entry = listPtr + 8 + (i * 20); + if (entry + 20 > raw + fileSize) break; + + char chunkId[5] = {}; + std::memcpy (chunkId, entry, 4); + int64_t chunkOffset = 0, chunkSize = 0; + std::memcpy (&chunkOffset, entry + 4, 8); + std::memcpy (&chunkSize, entry + 12, 8); + + if (chunkOffset >= 0 && chunkSize > 0 && chunkOffset + chunkSize <= fileSize) + { + if (std::strcmp (chunkId, "Comp") == 0) + { + compState.setSize ((size_t) chunkSize); + std::memcpy (compState.getData(), raw + chunkOffset, (size_t) chunkSize); + } + else if (std::strcmp (chunkId, "Cont") == 0) + { + contState.setSize ((size_t) chunkSize); + std::memcpy (contState.getData(), raw + chunkOffset, (size_t) chunkSize); + } + } + } + parsed = compState.getSize() > 0; + } + } + } + + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + if (parsed) + { + // JUCE's VST3 setStateInformation expects XML wrapped data: + // + // [base64 comp state] + // [base64 controller state] + // + // Then converted to binary via AudioProcessor::copyXmlToBinary() + juce::XmlElement state ("VST3PluginState"); + state.createNewChildElement ("IComponent")->addTextElement (compState.toBase64Encoding()); + if (contState.getSize() > 0) + state.createNewChildElement ("IEditController")->addTextElement (contState.toBase64Encoding()); + + juce::MemoryBlock juceState; + juce::AudioProcessor::copyXmlToBinary (state, juceState); + + LOG_TO_FILE ("loadFactoryPresetFromFile: applying vstpreset '" + << presetFile.getFileName().toStdString() + << "' comp=" << compState.getSize() + << " cont=" << contState.getSize() + << " juceWrapped=" << juceState.getSize() << " bytes"); + + sehSetState (hp->instance.get(), juceState.getData(), (int) juceState.getSize()); + } + else + { + // Non-VST3 format (.fxp etc.) — try raw data + LOG_TO_FILE ("loadFactoryPresetFromFile: trying raw data for '" + << presetFile.getFileName().toStdString() + << "' (" << fileData.getSize() << " bytes)"); + sehSetState (hp->instance.get(), fileData.getData(), (int) fileData.getSize()); + } + break; + } + } + } + return getHostedParams (pluginId); +} + +// ============================================================ +// PRESET INDEX — Bitwig-style scan-all-directories approach +// ============================================================ + +juce::File ModularRandomizerAudioProcessor::getPresetIndexFile() const +{ + return juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile ("Noizefield/ModularRandomizer/preset_index.json"); +} + +void ModularRandomizerAudioProcessor::buildPresetIndex() +{ + LOG_TO_FILE ("buildPresetIndex: starting..."); + auto startTime = juce::Time::getMillisecondCounterHiRes(); + + // Try loading from disk cache first + if (loadPresetIndexFromFile()) + { + LOG_TO_FILE ("buildPresetIndex: loaded from cache"); + presetIndexReady.store (true); + return; + } + + // Scan all standard VST3 preset directories + juce::Array rootDirs; + + auto appData = juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory); + rootDirs.add (appData.getChildFile ("VST3 Presets")); + +#if JUCE_WINDOWS + auto commonFiles = juce::File ("C:\\Program Files\\Common Files"); + rootDirs.add (commonFiles.getChildFile ("VST3 Presets")); +#elif JUCE_MAC + rootDirs.add (juce::File ("/Library/Audio/Presets")); + rootDirs.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("Library/Audio/Presets")); +#elif JUCE_LINUX + rootDirs.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile (".vst3/presets")); +#endif + + auto docsDir = juce::File::getSpecialLocation (juce::File::userDocumentsDirectory); + rootDirs.add (docsDir.getChildFile ("VST3 Presets")); + + // Also scan vendor-specific directories commonly used + // Walk %APPDATA% top-level for vendored preset folders + auto appDataChildren = appData.findChildFiles (juce::File::findDirectories, false); + for (const auto& vendorDir : appDataChildren) + { + // Check if this vendor dir has Presets subdirectories + auto subDirs = vendorDir.findChildFiles (juce::File::findDirectories, false); + for (const auto& sub : subDirs) + { + if (sub.getFileName().containsIgnoreCase ("Preset")) + { + rootDirs.addIfNotAlreadyThere (sub); + } + // Check inside plugin-named folders for Presets + auto presetsSub = sub.getChildFile ("Presets"); + if (presetsSub.isDirectory()) + rootDirs.addIfNotAlreadyThere (presetsSub); + } + } + + // Scan inside installed VST3 bundles + juce::Array vst3Dirs; + vst3Dirs.add (commonFiles.getChildFile ("VST3")); + for (const auto& vst3Dir : vst3Dirs) + { + if (! vst3Dir.isDirectory()) continue; + auto bundles = vst3Dir.findChildFiles (juce::File::findDirectories, true, "*.vst3"); + for (const auto& bundle : bundles) + { + auto resourcesDir = bundle.getChildFile ("Contents").getChildFile ("Resources"); + if (resourcesDir.isDirectory()) + rootDirs.addIfNotAlreadyThere (resourcesDir); + } + } + + // Build the index + std::map> newIndex; + int totalFiles = 0; + + for (const auto& rootDir : rootDirs) + { + if (! rootDir.isDirectory()) continue; + + // Find all preset files recursively + auto vstPresets = rootDir.findChildFiles (juce::File::findFiles, true, "*.vstpreset"); + auto fxpPresets = rootDir.findChildFiles (juce::File::findFiles, true, "*.fxp"); + + auto processFiles = [&] (const juce::Array& files) + { + for (const auto& f : files) + { + // Determine plugin name from directory structure + // Typical: VST3 Presets///[subfolder/]preset.vstpreset + // Or: //Presets/preset.vstpreset + auto relativePath = f.getRelativePathFrom (rootDir); + auto parts = juce::StringArray::fromTokens (relativePath, "\\/", ""); + + juce::String pluginKey; + if (parts.size() >= 3) + { + // Vendor/Plugin/... → key = "plugin" (lowercase) + pluginKey = parts[1].toLowerCase(); + } + else if (parts.size() >= 2) + { + // Plugin/preset.vstpreset → key = directory name + pluginKey = parts[0].toLowerCase(); + } + else + { + // Just a file at root level — use parent dir name + pluginKey = rootDir.getFileName().toLowerCase(); + } + + if (pluginKey.isEmpty()) continue; + + auto presetName = f.getFileNameWithoutExtension(); + + // Skip duplicates within the same plugin + bool dupe = false; + auto& existing = newIndex[pluginKey]; + for (const auto& e : existing) + { + if (e.name.equalsIgnoreCase (presetName)) + { + dupe = true; + break; + } + } + if (dupe) continue; + + int idx = -((int)existing.size() + 1); + existing.push_back ({ idx, presetName, f.getFullPathName() }); + totalFiles++; + } + }; + + processFiles (vstPresets); + processFiles (fxpPresets); + } + + LOG_TO_FILE ("buildPresetIndex: scanned " << totalFiles << " preset files across " + << newIndex.size() << " plugins in " + << (int)(juce::Time::getMillisecondCounterHiRes() - startTime) << "ms"); + + { + std::lock_guard lock (presetIndexMutex); + presetIndex = std::move (newIndex); + } + + savePresetIndexToFile(); + presetIndexReady.store (true); +} + +std::vector +ModularRandomizerAudioProcessor::getIndexedPresets (const juce::String& pluginName, const juce::String& vendorName) +{ + std::lock_guard lock (presetIndexMutex); + + auto key = pluginName.toLowerCase(); + + // Exact match + auto it = presetIndex.find (key); + if (it != presetIndex.end() && ! it->second.empty()) + return it->second; + + // Fuzzy match: check if any key contains the plugin name or vice versa + for (auto& [k, v] : presetIndex) + { + if (k.contains (key) || key.contains (k)) + { + if (! v.empty()) + return v; + } + } + + // Try vendor + plugin combination + if (vendorName.isNotEmpty()) + { + auto vendorKey = vendorName.toLowerCase(); + for (auto& [k, v] : presetIndex) + { + if (k.contains (key) || (k.contains (vendorKey) && ! v.empty())) + return v; + } + } + + return {}; +} + +void ModularRandomizerAudioProcessor::savePresetIndexToFile() +{ + std::lock_guard lock (presetIndexMutex); + + auto indexFile = getPresetIndexFile(); + indexFile.getParentDirectory().createDirectory(); + + auto* root = new juce::DynamicObject(); + root->setProperty ("version", 1); + root->setProperty ("timestamp", juce::Time::currentTimeMillis()); + + auto* plugins = new juce::DynamicObject(); + for (auto& [pluginKey, presets] : presetIndex) + { + juce::Array presetArr; + for (const auto& p : presets) + { + auto* pObj = new juce::DynamicObject(); + pObj->setProperty ("name", p.name); + pObj->setProperty ("filePath", p.filePath); + presetArr.add (juce::var (pObj)); + } + plugins->setProperty (pluginKey, juce::var (presetArr)); + } + root->setProperty ("plugins", juce::var (plugins)); + + auto json = juce::JSON::toString (juce::var (root)); + indexFile.replaceWithText (json); + + LOG_TO_FILE ("savePresetIndexToFile: saved " << presetIndex.size() << " plugins to " << indexFile.getFullPathName().toStdString()); +} + +bool ModularRandomizerAudioProcessor::loadPresetIndexFromFile() +{ + auto indexFile = getPresetIndexFile(); + if (! indexFile.existsAsFile()) + return false; + + // Check if the file is less than 24 hours old + auto fileAge = juce::Time::getCurrentTime() - indexFile.getLastModificationTime(); + if (fileAge.inHours() > 24) + { + LOG_TO_FILE ("loadPresetIndexFromFile: cache expired (age=" << (int)fileAge.inHours() << "h)"); + return false; + } + + auto json = juce::JSON::parse (indexFile.loadFileAsString()); + if (! json.isObject()) + return false; + + auto* root = json.getDynamicObject(); + if (! root) return false; + + auto pluginsVar = root->getProperty ("plugins"); + if (! pluginsVar.isObject()) return false; + + auto* plugins = pluginsVar.getDynamicObject(); + if (! plugins) return false; + + std::map> newIndex; + + for (auto& prop : plugins->getProperties()) + { + auto pluginKey = prop.name.toString(); + auto presetsArr = prop.value; + if (! presetsArr.isArray()) continue; + + std::vector presets; + for (int i = 0; i < presetsArr.size(); ++i) + { + auto pVar = presetsArr[i]; + if (! pVar.isObject()) continue; + auto* pObj = pVar.getDynamicObject(); + if (! pObj) continue; + + auto name = pObj->getProperty ("name").toString(); + auto filePath = pObj->getProperty ("filePath").toString(); + presets.push_back ({ -(i + 1), name, filePath }); + } + + if (! presets.empty()) + newIndex[pluginKey] = std::move (presets); + } + + { + std::lock_guard lock (presetIndexMutex); + presetIndex = std::move (newIndex); + } + + LOG_TO_FILE ("loadPresetIndexFromFile: loaded " << presetIndex.size() << " plugins from cache"); + return true; +} + +void ModularRandomizerAudioProcessor::setHostedParam (int pluginId, int paramIndex, float normValue) +{ + // O(1) lookup via pluginSlots — no linear scan needed + int slot = slotForId (pluginId); + if (slot >= 0) + { + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance != nullptr) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + { + params[paramIndex]->setValue (normValue); + recordSelfWrite (pluginId, paramIndex); + updateParamBase (pluginId, paramIndex, normValue); + } + } + } +} + +void ModularRandomizerAudioProcessor::startGlide (int pluginId, int paramIndex, + float targetValue, float durationMs) +{ + // Write to lock-free FIFO — safe to call from message thread + GlideCommand cmd { pluginId, paramIndex, targetValue, durationMs }; + const auto scope = glideFifo.write (1); + if (scope.blockSize1 > 0) + glideRing[scope.startIndex1] = cmd; + else if (scope.blockSize2 > 0) + glideRing[scope.startIndex2] = cmd; + // If FIFO is full, command is silently dropped (very unlikely with 512 slots) +} + +//============================================================================== +// Proxy Parameter Pool — DAW automation bridge +//============================================================================== + +void ModularRandomizerAudioProcessor::assignProxySlotsForPlugin (int pluginId) +{ + std::lock_guard lock (pluginMutex); + + // Find the hosted plugin and assign proxy slots for each of its params + HostedPlugin* target = nullptr; + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId) + { + target = hp.get(); + break; + } + } + if (target == nullptr || target->instance == nullptr) return; + + auto& hostedParams = target->instance->getParameters(); + int slot = 0; + + for (int pi = 0; pi < (int) hostedParams.size(); ++pi) + { + // Find next free proxy slot (skips block-occupied slots) + while (slot < proxyParamCount && !proxyMap[slot].isFree()) + ++slot; + if (slot >= proxyParamCount) break; // pool exhausted + + proxyMap[slot].pluginId = pluginId; + proxyMap[slot].paramIndex = pi; + + // Set proxy value to match hosted param (suppress feedback) + if (proxyParams[slot] != nullptr) + { + // Set dynamic name: "PluginName: ParamName" + auto paramName = hostedParams[pi]->getName (128); + proxyParams[slot]->setDynamicName (target->name + ": " + paramName); + + proxySyncActive.store (true); + proxyParams[slot]->setValueNotifyingHost (hostedParams[pi]->getValue()); + proxySyncActive.store (false); + } + ++slot; + } + + // Notify host that parameter info changed (name/value updates) + updateHostDisplay (juce::AudioProcessor::ChangeDetails{}.withParameterInfoChanged (true)); +} + +void ModularRandomizerAudioProcessor::freeProxySlotsForPlugin (int pluginId) +{ + for (int i = 0; i < proxyParamCount; ++i) + { + if (proxyMap[i].pluginId == pluginId) + { + proxyMap[i].clear(); + + // Reset proxy value and name + if (proxyParams[i] != nullptr) + { + proxyParams[i]->setDynamicName (juce::String ("Slot ") + juce::String (i + 1)); + proxyParams[i]->clearDisplayInfo(); + proxySyncActive.store (true); + proxyParams[i]->setValueNotifyingHost (0.0f); + proxySyncActive.store (false); + } + } + } + + updateHostDisplay (juce::AudioProcessor::ChangeDetails{}.withParameterInfoChanged (true)); +} + +void ModularRandomizerAudioProcessor::parameterChanged (const juce::String& parameterID, float newValue) +{ + // Ignore sync-back writes (processBlock → proxy) + if (proxySyncActive.load()) return; + + // Only handle unified proxy params (AP_NNNN) + if (! parameterID.startsWith ("AP_")) return; + + int slot = parameterID.substring (3).getIntValue(); + if (slot < 0 || slot >= proxyParamCount) return; + + auto& m = proxyMap[slot]; + + if (m.isBlock()) + { + // Block param — store value for editor timer to forward to JS + proxyValueCache[slot].store (newValue); + blockProxyDirty.store (true); + return; + } + + if (! m.isPlugin()) return; + + // Forward to hosted plugin + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == m.pluginId && hp->instance != nullptr) + { + auto& params = hp->instance->getParameters(); + if (m.paramIndex >= 0 && m.paramIndex < (int) params.size()) + { + params[m.paramIndex]->setValue (newValue); + recordSelfWrite (m.pluginId, m.paramIndex); + } + break; + } + } +} + +//============================================================================== +// Expose State — Selective proxy slot management +//============================================================================== + +void ModularRandomizerAudioProcessor::updateExposeState (const juce::String& jsonData) +{ + auto parsed = juce::JSON::parse (jsonData); + if (! parsed.isObject()) return; + + auto* root = parsed.getDynamicObject(); + if (! root) return; + + // ── Handle plugin expose state ── + auto pluginsVar = root->getProperty ("plugins"); + if (pluginsVar.isObject()) + { + auto* pluginsObj = pluginsVar.getDynamicObject(); + if (pluginsObj) + { + for (auto& prop : pluginsObj->getProperties()) + { + int pluginId = prop.name.toString().getIntValue(); + auto* pState = prop.value.getDynamicObject(); + if (! pState) continue; + + bool exposed = pState->getProperty ("exposed"); + auto excludedVar = pState->getProperty ("excluded"); + + // Build excluded set + std::unordered_set excludedParams; + if (excludedVar.isArray()) + { + for (int i = 0; i < excludedVar.size(); ++i) + excludedParams.insert ((int) excludedVar[i]); + } + + if (! exposed) + { + // Unexpose entire plugin — free all its proxy slots + freeProxySlotsForPlugin (pluginId); + } + else if (! excludedParams.empty()) + { + // Selectively free excluded params + for (int i = 0; i < proxyParamCount; ++i) + { + if (proxyMap[i].pluginId == pluginId + && excludedParams.count (proxyMap[i].paramIndex) > 0) + { + // Free this specific slot + proxyMap[i].clear(); + if (proxyParams[i] != nullptr) + { + proxyParams[i]->setDynamicName (juce::String ("Slot ") + juce::String (i + 1)); + proxyParams[i]->clearDisplayInfo(); + proxySyncActive.store (true); + proxyParams[i]->setValueNotifyingHost (0.0f); + proxySyncActive.store (false); + } + } + } + + // Check if any params from this plugin need to be re-assigned + // (user may have previously excluded then re-included) + std::lock_guard lock (pluginMutex); + HostedPlugin* target = nullptr; + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + target = hp.get(); + break; + } + } + if (target) + { + auto& hostedParams = target->instance->getParameters(); + for (int pi = 0; pi < (int) hostedParams.size(); ++pi) + { + // Skip excluded params + if (excludedParams.count (pi) > 0) continue; + + // Check if already assigned + bool alreadyAssigned = false; + for (int si = 0; si < proxyParamCount; ++si) + { + if (proxyMap[si].pluginId == pluginId && proxyMap[si].paramIndex == pi) + { + alreadyAssigned = true; + break; + } + } + if (alreadyAssigned) continue; + + // Find free slot and assign + for (int si = 0; si < proxyParamCount; ++si) + { + if (proxyMap[si].isFree()) + { + proxyMap[si].pluginId = pluginId; + proxyMap[si].paramIndex = pi; + if (proxyParams[si] != nullptr) + { + auto paramName = hostedParams[pi]->getName (128); + proxyParams[si]->setDynamicName (target->name + ": " + paramName); + proxySyncActive.store (true); + proxyParams[si]->setValueNotifyingHost (hostedParams[pi]->getValue()); + proxySyncActive.store (false); + } + break; + } + } + } + } + + updateHostDisplay (juce::AudioProcessor::ChangeDetails{}.withParameterInfoChanged (true)); + } + } + } + } + + // ── Handle block expose state (unified pool) ── + auto blocksVar = root->getProperty ("blocks"); + if (blocksVar.isObject()) + { + auto* blocksObj = blocksVar.getDynamicObject(); + if (blocksObj) + { + for (auto& prop : blocksObj->getProperties()) + { + int blockId = prop.name.toString().getIntValue(); + auto* bState = prop.value.getDynamicObject(); + if (! bState) continue; + + bool exposed = bState->getProperty ("exposed"); + auto excludedVar = bState->getProperty ("excluded"); + + std::unordered_set excludedKeys; + if (excludedVar.isArray()) + { + for (int i = 0; i < excludedVar.size(); ++i) + excludedKeys.insert (excludedVar[i].toString().toStdString()); + } + + auto blockNameVar = bState->getProperty ("name"); + juce::String blockName = blockNameVar.isString() ? blockNameVar.toString() + : (juce::String ("Block ") + juce::String (blockId)); + + auto paramsVar = bState->getProperty ("params"); + + if (! exposed) + { + // Free all unified pool slots for this block + for (int i = 0; i < proxyParamCount; ++i) + { + if (proxyMap[i].blockId == blockId) + { + proxyMap[i].clear(); + if (proxyParams[i] != nullptr) + { + proxyParams[i]->setDynamicName (juce::String ("Slot ") + juce::String (i + 1)); + proxyParams[i]->clearDisplayInfo(); + proxySyncActive.store (true); + proxyParams[i]->setValueNotifyingHost (0.0f); + proxySyncActive.store (false); + } + } + } + } + else + { + if (paramsVar.isArray()) + { + for (int pi = 0; pi < paramsVar.size(); ++pi) + { + auto* paramObj = paramsVar[pi].getDynamicObject(); + if (! paramObj) continue; + + auto key = paramObj->getProperty ("key").toString(); + auto label = paramObj->getProperty ("label").toString(); + auto type = paramObj->getProperty ("type").toString(); + + if (excludedKeys.count (key.toStdString()) > 0) continue; + + // Check if already assigned in unified pool + bool alreadyAssigned = false; + for (int si = 0; si < proxyParamCount; ++si) + { + if (proxyMap[si].blockId == blockId && proxyMap[si].blockParamKey == key) + { + alreadyAssigned = true; + break; + } + } + if (alreadyAssigned) continue; + + // Find free slot in unified pool + for (int si = 0; si < proxyParamCount; ++si) + { + if (proxyMap[si].isFree()) + { + proxyMap[si].blockId = blockId; + proxyMap[si].blockParamKey = key; + if (proxyParams[si] != nullptr) + { + proxyParams[si]->setDynamicName (blockName + " - " + label); + + // Configure display info based on param type + if (type == "discrete") + { + auto optsVar = paramObj->getProperty ("options"); + if (optsVar.isArray()) + { + juce::StringArray opts; + for (int oi = 0; oi < optsVar.size(); ++oi) + opts.add (optsVar[oi].toString()); + proxyParams[si]->setDiscreteOptions (opts); + } + } + else if (type == "bool") + { + proxyParams[si]->setDiscreteOptions ({ "Off", "On" }); + } + else // float + { + float dispMin = paramObj->getProperty ("min"); + float dispMax = paramObj->getProperty ("max"); + auto suffix = paramObj->getProperty ("suffix").toString(); + proxyParams[si]->setDisplayInfo (suffix, dispMin, dispMax); + } + } + break; + } + } + } + } + + // Free excluded params + for (int i = 0; i < proxyParamCount; ++i) + { + if (proxyMap[i].blockId == blockId + && excludedKeys.count (proxyMap[i].blockParamKey.toStdString()) > 0) + { + proxyMap[i].clear(); + if (proxyParams[i] != nullptr) + { + proxyParams[i]->setDynamicName (juce::String ("Slot ") + juce::String (i + 1)); + proxyParams[i]->clearDisplayInfo(); + proxySyncActive.store (true); + proxyParams[i]->setValueNotifyingHost (0.0f); + proxySyncActive.store (false); + } + } + } + } + } + } + + updateHostDisplay (juce::AudioProcessor::ChangeDetails{}.withParameterInfoChanged (true)); + } + + LOG_TO_FILE ("updateExposeState: processed expose state update"); +} diff --git a/plugins/ModularRandomizer/Source/PluginProcessor.cpp b/plugins/ModularRandomizer/Source/PluginProcessor.cpp new file mode 100644 index 0000000..c2a13d1 --- /dev/null +++ b/plugins/ModularRandomizer/Source/PluginProcessor.cpp @@ -0,0 +1,2282 @@ +/* + ============================================================================== + + Modular Randomizer - PluginProcessor + VST3 Plugin Hosting Engine with parameter randomization + + ============================================================================== +*/ + +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include "ParameterIDs.hpp" + +//============================================================================== +// Enum parsers — called once per updateLogicBlocks (message thread), so processBlock +// uses integer comparisons instead of ~100 juce::String == "literal" per call (H4 fix). +//============================================================================== +using P = ModularRandomizerAudioProcessor; + +P::BlockMode P::parseBlockMode (const juce::String& s) { + if (s == "randomize") return BlockMode::Randomize; + if (s == "envelope") return BlockMode::Envelope; + if (s == "sample") return BlockMode::Sample; + if (s == "morph_pad") return BlockMode::MorphPad; + if (s == "shapes") return BlockMode::Shapes; + if (s == "shapes_range") return BlockMode::ShapesRange; + if (s == "lane") return BlockMode::Lane; + return BlockMode::Unknown; +} +P::TriggerType P::parseTriggerType (const juce::String& s) { + if (s == "tempo") return TriggerType::Tempo; + if (s == "midi") return TriggerType::Midi; + if (s == "audio") return TriggerType::Audio; + return TriggerType::Manual; +} +P::MidiTrigMode P::parseMidiTrigMode (const juce::String& s) { + if (s == "specific_note") return MidiTrigMode::SpecificNote; + if (s == "cc") return MidiTrigMode::CC; + return MidiTrigMode::AnyNote; +} +P::AudioSource P::parseAudioSource (const juce::String& s) { + if (s == "sidechain") return AudioSource::Sidechain; + return AudioSource::Main; +} +P::RangeMode P::parseRangeMode (const juce::String& s) { + if (s == "relative") return RangeMode::Relative; + return RangeMode::Absolute; +} +P::Movement P::parseMovement (const juce::String& s) { + if (s == "glide") return Movement::Glide; + return Movement::Instant; +} +P::Polarity P::parsePolarity (const juce::String& s) { + if (s == "up") return Polarity::Up; + if (s == "down") return Polarity::Down; + if (s == "unipolar") return Polarity::Unipolar; + return Polarity::Bipolar; +} +P::ClockSource P::parseClockSource (const juce::String& s) { + if (s == "internal") return ClockSource::Internal; + return ClockSource::Daw; +} +P::LoopMode P::parseLoopMode (const juce::String& s) { + if (s == "loop") return LoopMode::Loop; + if (s == "pingpong") return LoopMode::Pingpong; + return LoopMode::Oneshot; +} +P::JumpMode P::parseJumpMode (const juce::String& s) { + if (s == "random") return JumpMode::Random; + return JumpMode::Restart; +} +P::MorphMode P::parseMorphMode (const juce::String& s) { + if (s == "auto") return MorphMode::Auto; + if (s == "trigger") return MorphMode::Trigger; + return MorphMode::Manual; +} +P::ExploreMode P::parseExploreMode (const juce::String& s) { + if (s == "bounce") return ExploreMode::Bounce; + if (s == "shapes") return ExploreMode::Shapes; + if (s == "orbit") return ExploreMode::Orbit; + if (s == "path") return ExploreMode::Path; + return ExploreMode::Wander; +} +P::LfoShape P::parseLfoShape (const juce::String& s) { + if (s == "figure8") return LfoShape::Figure8; + if (s == "sweepX") return LfoShape::SweepX; + if (s == "sweepY") return LfoShape::SweepY; + if (s == "triangle") return LfoShape::Triangle; + if (s == "square") return LfoShape::Square; + if (s == "hexagon") return LfoShape::Hexagon; + if (s == "pentagram") return LfoShape::Pentagram; + if (s == "hexagram") return LfoShape::Hexagram; + if (s == "rose4") return LfoShape::Rose4; + if (s == "lissajous") return LfoShape::Lissajous; + if (s == "spiral") return LfoShape::Spiral; + if (s == "cat") return LfoShape::Cat; + if (s == "butterfly") return LfoShape::Butterfly; + if (s == "infinityKnot") return LfoShape::InfinityKnot; + return LfoShape::Circle; +} +P::MorphAction P::parseMorphAction (const juce::String& s) { + if (s == "step") return MorphAction::Step; + return MorphAction::Jump; +} +P::StepOrder P::parseStepOrder (const juce::String& s) { + if (s == "random") return StepOrder::Random; + return StepOrder::Cycle; +} +P::ShapeTracking P::parseShapeTracking (const juce::String& s) { + if (s == "vertical") return ShapeTracking::Vertical; + if (s == "distance") return ShapeTracking::Distance; + return ShapeTracking::Horizontal; +} +P::ShapeTrigger P::parseShapeTrigger (const juce::String& s) { + if (s == "midi") return ShapeTrigger::Midi; + return ShapeTrigger::Free; +} +P::LaneInterp P::parseLaneInterp (const juce::String& s) { + if (s == "step") return LaneInterp::Step; + if (s == "linear") return LaneInterp::Linear; + return LaneInterp::Smooth; +} +P::LanePlayMode P::parseLanePlayMode (const juce::String& s) { + if (s == "reverse") return LanePlayMode::Reverse; + if (s == "pingpong") return LanePlayMode::Pingpong; + if (s == "random") return LanePlayMode::Random; + return LanePlayMode::Forward; +} +float P::parseBeatsPerDiv (const juce::String& div) { + if (div == "32" || div == "32/1") return 128.0f; + if (div == "16" || div == "16/1") return 64.0f; + if (div == "8" || div == "8/1") return 32.0f; + if (div == "4" || div == "4/1") return 16.0f; + if (div == "2" || div == "2/1") return 8.0f; + if (div == "1/1") return 4.0f; + if (div == "1/2") return 2.0f; + if (div == "1/4") return 1.0f; + if (div == "1/8") return 0.5f; + if (div == "1/16") return 0.25f; + if (div == "1/32") return 0.125f; + if (div == "1/64") return 0.0625f; + if (div == "1/2.") return 3.0f; + if (div == "1/4.") return 1.5f; + if (div == "1/8.") return 0.75f; + if (div == "1/16.") return 0.375f; + if (div == "1/2T") return 4.0f / 3.0f; + if (div == "1/4T") return 2.0f / 3.0f; + if (div == "1/8T") return 1.0f / 3.0f; + if (div == "1/16T") return 0.5f / 3.0f; + return 1.0f; +} + +//============================================================================== +ModularRandomizerAudioProcessor::ModularRandomizerAudioProcessor() +#ifndef JucePlugin_PreferredChannelConfigurations + : AudioProcessor (BusesProperties() + #if ! JucePlugin_IsMidiEffect + #if ! JucePlugin_IsSynth + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + #endif + .withOutput ("Output", juce::AudioChannelSet::stereo(), true) + #endif + .withInput ("Sidechain", juce::AudioChannelSet::stereo(), false) + ), +#else + : +#endif + apvts (*this, nullptr, "ModularRandomizer", createParameterLayout()) +{ + // Register VST3 format for plugin scanning/hosting + formatManager.addFormat (new juce::VST3PluginFormat()); + + // Register audio file formats for sample modulator + audioFileFormatManager.registerBasicFormats(); + + // Initialize flat param tracking arrays (zero heap allocations on audio thread) + for (int s = 0; s < kMaxPlugins; ++s) + for (int p = 0; p < kMaxParams; ++p) + { + paramWritten[s][p] = -1.0f; // sentinel: never written + paramTouched[s][p].store (false, std::memory_order_relaxed); + } + initParamBase(); + + // Pre-allocate hosted plugin vector so push_back never reallocates + // while the audio thread iterates (audio thread doesn't hold pluginMutex) + hostedPlugins.reserve (32); + + // Look up proxy raw pointers from APVTS (unified pool: AP_0000–AP_2047) + for (int i = 0; i < proxyParamCount; ++i) + { + auto id = juce::String ("AP_") + juce::String (i).paddedLeft ('0', 4); + proxyParams[i] = dynamic_cast (apvts.getParameter (id)); + apvts.addParameterListener (id, this); + proxyValueCache[i].store (-999.0f); + } + + // ── Create organized preset directory structure ── + getDataRoot().createDirectory(); + getChainsDir().createDirectory(); + getSnapshotsDir().createDirectory(); + getImportDir().createDirectory(); + + // One-time migration from old flat → new organized structure + migrateOldPresets(); +} + + +ModularRandomizerAudioProcessor::~ModularRandomizerAudioProcessor() +{ + // Unregister proxy listeners (unified pool) + for (int i = 0; i < proxyParamCount; ++i) + { + auto id = juce::String ("AP_") + juce::String (i).paddedLeft ('0', 4); + apvts.removeParameterListener (id, this); + } + + // Release all hosted plugins — SEH-guarded per-plugin so one crashing + // plugin doesn't prevent cleanup of the rest. + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->instance) + { +#ifdef _WIN32 + sehReleaseResources (hp->instance.get()); + sehDestroyInstance (hp->instance.release()); +#else + try { hp->instance->releaseResources(); } catch (...) {} + hp->instance.reset(); +#endif + } + } + hostedPlugins.clear(); + } +} + +//============================================================================== +juce::AudioProcessorValueTreeState::ParameterLayout ModularRandomizerAudioProcessor::createParameterLayout() +{ + std::vector> params; + + params.push_back (std::make_unique ( + juce::ParameterID { ParameterIDs::MIX, 1 }, + "Mix", + juce::NormalisableRange (0.0f, 100.0f, 0.1f), + 100.0f, + juce::String(), + juce::AudioProcessorParameter::genericParameter, + [](float value, int) { return juce::String (value, 0) + "%"; } + )); + + params.push_back (std::make_unique ( + juce::ParameterID { ParameterIDs::BYPASS, 1 }, + "Bypass", + false + )); + + // Unified proxy parameter pool — AP_0000 to AP_2047 + // Block params assigned first (top of DAW list), plugin params fill after + for (int i = 0; i < proxyParamCount; ++i) + { + auto id = juce::String ("AP_") + juce::String (i).paddedLeft ('0', 4); + auto name = juce::String ("Slot ") + juce::String (i + 1); + params.push_back (std::make_unique ( + juce::ParameterID { id, 1 }, + name, + juce::NormalisableRange (0.0f, 1.0f, 0.0f), + 0.0f + )); + } + + return { params.begin(), params.end() }; +} + +//============================================================================== +void ModularRandomizerAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + currentSampleRate = sampleRate; + currentBlockSize = samplesPerBlock; + + // Reset glide pool (fixed-size array, no allocation needed) + numActiveGlides = 0; + + // Initialize modbus base tracking + initParamBase(); + + // Pre-allocate parallel bus buffers (sized for max channels + block size) + int numCh = getTotalNumOutputChannels(); + for (int i = 0; i < maxBuses; ++i) + busBuffers[i].setSize (numCh, samplesPerBlock, false, false, true); + + // Pre-allocate dry buffer for wet/dry mix (avoids heap alloc in processBlock) + dryBuffer.setSize (numCh, samplesPerBlock, false, false, true); + + // Pre-allocate synth accumulation buffer for layering multiple synths (sequential mode) + synthAccum.setSize (numCh, samplesPerBlock, false, false, true); + + // Pre-allocate MIDI trigger event buffer + blockMidiEvents.reserve (kMaxBlockMidi); + + // Pre-allocate WrongEQ band-split buffers (2N+1 bands for N points) + for (int i = 0; i < maxXoverBands; ++i) + { + eqBandBuffers[i].setSize (numCh, samplesPerBlock, false, false, true); + eqBandGain[i] = 1.0f; // start at full volume + } + + // Prepare crossover filters, allpass compensation (2N crossovers for N points max) + for (int i = 0; i < maxCrossovers; ++i) + { + crossovers[i].reset(); + + // Reset per-(crossover, lower-band) allpass filters for phase compensation. + for (int lb = 0; lb < maxCrossovers; ++lb) + for (int ch = 0; ch < 2; ++ch) + allpassComp[i][lb][ch].reset(); + } + + // Reset per-point parametric EQ biquad filters (all cascaded stages) + for (int i = 0; i < maxEqBands; ++i) + { + for (int st = 0; st < maxBiquadStages; ++st) + for (int ch = 0; ch < maxEqChannels; ++ch) + eqBiquads[i][st][ch].reset(); + eqBiquadActive[i] = false; + eqPrevValid[i] = false; + } + + // Initialize EQ oversampler + { + int factor = eqOversampleFactor.load(); + int order = (factor >= 4) ? 2 : (factor >= 2) ? 1 : 0; + eqOversampleOrder = order; + if (order > 0) + { + eqOversampler = std::make_unique>( + (juce::uint32) numCh, (juce::uint32) order, + juce::dsp::Oversampling::filterHalfBandPolyphaseIIR, false); + eqOversampler->initProcessing ((size_t) samplesPerBlock); + eqOversamplerReady = true; + } + else + { + eqOversampler.reset(); + eqOversamplerReady = false; + } + } + + + // Initialize proxy value cache sentinels (-999.0f = no pending update) + for (int i = 0; i < proxyParamCount; ++i) + proxyValueCache[i].store (-999.0f, std::memory_order_relaxed); + proxyDirty.store (false); + + // Purge dead plugin entries (safe: processBlock is not running during prepareToPlay) + purgeDeadPlugins(); + + // Prepare all hosted plugins (reconfigure on rate/blocksize change) + // Per-plugin try-catch: one crashing plugin doesn't prevent the rest + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->instance != nullptr) + { + try + { + // Always reconfigure — sample rate or block size may have changed + int pluginIns = hp->instance->getTotalNumInputChannels(); + int pluginOuts = hp->instance->getTotalNumOutputChannels(); + hp->instance->setPlayConfigDetails (pluginIns, pluginOuts, + sampleRate, samplesPerBlock); + hp->instance->prepareToPlay (sampleRate, samplesPerBlock); + hp->prepared = true; + } + catch (...) + { + LOG_TO_FILE ("prepareToPlay: EXCEPTION for plugin '" << hp->name.toStdString() + << "' (ID: " << hp->id << "). Marking crashed."); + hp->crashed = true; + hp->prepared = false; + } + } + } +} + +void ModularRandomizerAudioProcessor::syncProxyCacheToHost() +{ + // Drain proxy value cache from audio thread → call setValueNotifyingHost on message thread. + // Audio thread writes float values into proxyValueCache atomics; this method reads them. + if (! proxyDirty.exchange (false, std::memory_order_acquire)) + return; + + proxySyncActive.store (true); + for (int i = 0; i < proxyParamCount; ++i) + { + float cached = proxyValueCache[i].exchange (-999.0f, std::memory_order_relaxed); + if (cached > -998.0f && proxyParams[i] != nullptr) + { + if (std::abs (cached - proxyParams[i]->get()) > 0.0001f) + proxyParams[i]->setValueNotifyingHost (cached); + } + } + proxySyncActive.store (false); +} + +std::vector +ModularRandomizerAudioProcessor::drainBlockProxyCache() +{ + std::vector updates; + if (! blockProxyDirty.exchange (false)) + return updates; + + for (int i = 0; i < proxyParamCount; ++i) + { + auto& m = proxyMap[i]; + if (! m.isBlock()) continue; + + float cached = proxyValueCache[i].exchange (-999.0f); + if (cached < -900.0f) continue; + + updates.push_back ({ m.blockId, m.blockParamKey, cached }); + } + return updates; +} + +void ModularRandomizerAudioProcessor::releaseResources() +{ + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->instance != nullptr && hp->prepared) + { +#ifdef _WIN32 + sehReleaseResources (hp->instance.get()); +#else + try { hp->instance->releaseResources(); } catch (...) {} +#endif + hp->prepared = false; + } + } +} + +#ifndef JucePlugin_PreferredChannelConfigurations +bool ModularRandomizerAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + #if JucePlugin_IsMidiEffect + juce::ignoreUnused (layouts); + return true; + #else + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::mono() + && layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + + #if ! JucePlugin_IsSynth + if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) + return false; + #endif + + // Sidechain: accept disabled, mono, or stereo + auto scLayout = layouts.getChannelSet (true, 1); + if (! scLayout.isDisabled() + && scLayout != juce::AudioChannelSet::mono() + && scLayout != juce::AudioChannelSet::stereo()) + return false; + + return true; + #endif +} +#endif + + + + +void ModularRandomizerAudioProcessor::updateLogicBlocks (const juce::String& jsonData) +{ + auto parsed = juce::JSON::parse (jsonData); + if (! parsed.isArray()) return; + + std::vector newBlocks; + newBlocks.reserve ((size_t) parsed.size()); + + for (int i = 0; i < parsed.size(); ++i) + { + auto* obj = parsed[i].getDynamicObject(); + if (obj == nullptr) continue; + + LogicBlock lb; + lb.id = (int) obj->getProperty ("id"); + lb.mode = obj->getProperty ("mode").toString(); + lb.enabled = obj->hasProperty ("enabled") ? (bool) obj->getProperty ("enabled") : true; + lb.trigger = obj->getProperty ("trigger").toString(); + lb.beatDiv = obj->getProperty ("beatDiv").toString(); + lb.midiMode = obj->getProperty ("midiMode").toString(); + lb.midiNote = (int) obj->getProperty ("midiNote"); + lb.midiCC = (int) obj->getProperty ("midiCC"); + lb.midiCh = (int) obj->getProperty ("midiCh"); + lb.threshold = (float) (double) obj->getProperty ("threshold"); + lb.audioSrc = obj->getProperty ("audioSrc").toString(); + lb.rMin = (float) (double) obj->getProperty ("rMin"); + lb.rMax = (float) (double) obj->getProperty ("rMax"); + lb.rangeMode = obj->getProperty ("rangeMode").toString(); + lb.quantize = (bool) obj->getProperty ("quantize"); + lb.qSteps = (int) obj->getProperty ("qSteps"); + lb.movement = obj->getProperty ("movement").toString(); + lb.glideMs = (float) (double) obj->getProperty ("glideMs"); + lb.envAtk = (float) (double) obj->getProperty ("envAtk"); + lb.envRel = (float) (double) obj->getProperty ("envRel"); + lb.envSens = (float) (double) obj->getProperty ("envSens"); + lb.envInvert = (bool) obj->getProperty ("envInvert"); + lb.envFilterMode = obj->hasProperty("envFilterMode") ? obj->getProperty("envFilterMode").toString() : "flat"; + lb.envFilterFreq = obj->hasProperty("envFilterFreq") ? (float)(double) obj->getProperty("envFilterFreq") : 1000.0f; + lb.envFilterBW = obj->hasProperty("envFilterBW") ? (float)(double) obj->getProperty("envFilterBW") : 2.0f; + // Convert mode+freq+bw to HPF/LPF cutoffs + if (lb.envFilterMode == "lp") { lb.envBandLo = 20.0f; lb.envBandHi = lb.envFilterFreq; } + else if (lb.envFilterMode == "hp") { lb.envBandLo = lb.envFilterFreq; lb.envBandHi = 20000.0f; } + else if (lb.envFilterMode == "bp") { lb.envBandLo = lb.envFilterFreq / std::pow(2.0f, lb.envFilterBW * 0.5f); + lb.envBandHi = lb.envFilterFreq * std::pow(2.0f, lb.envFilterBW * 0.5f); } + else { lb.envBandLo = 20.0f; lb.envBandHi = 20000.0f; } // flat + + // Polarity control (relative mode: bipolar, up, down) + lb.polarity = obj->getProperty ("polarity").toString(); + if (lb.polarity.isEmpty()) lb.polarity = "bipolar"; + + // Clock source: "daw" or "internal" + lb.clockSource = obj->getProperty ("clockSource").toString(); + if (lb.clockSource.isEmpty()) lb.clockSource = "daw"; + lb.internalBpm = (float) (double) obj->getProperty ("internalBpm"); + if (lb.internalBpm <= 0.0f) lb.internalBpm = 120.0f; + + // Sample modulator settings + lb.loopMode = obj->getProperty ("loopMode").toString(); + lb.sampleSpeed = (float) (double) obj->getProperty ("sampleSpeed"); + lb.sampleReverse = (bool) obj->getProperty ("sampleReverse"); + lb.jumpMode = obj->getProperty ("jumpMode").toString(); + + // Defaults for missing values + if (lb.loopMode.isEmpty()) lb.loopMode = "loop"; + if (lb.sampleSpeed <= 0.0f) lb.sampleSpeed = 1.0f; + if (lb.jumpMode.isEmpty()) lb.jumpMode = "restart"; + + // Morph Pad settings + lb.morphMode = obj->getProperty("morphMode").toString(); + lb.exploreMode = obj->getProperty("exploreMode").toString(); + lb.lfoShape = obj->getProperty("lfoShape").toString(); + lb.lfoDepth = obj->hasProperty("lfoDepth") ? (float)(double) obj->getProperty("lfoDepth") : 0.8f; + lb.lfoRotation = obj->hasProperty("lfoRotation") ? (float)(double) obj->getProperty("lfoRotation") : 0.0f; + lb.morphSpeed = (float)(double) obj->getProperty("morphSpeed"); + lb.morphAction = obj->getProperty("morphAction").toString(); + lb.stepOrder = obj->getProperty("stepOrder").toString(); + lb.morphSource = obj->getProperty("morphSource").toString(); + lb.playheadX = (float)(double) obj->getProperty("playheadX"); + lb.playheadY = (float)(double) obj->getProperty("playheadY"); + // Circular clamp — ensure playhead is inside the pad (r=0.45) + { float pdx = lb.playheadX - 0.5f, pdy = lb.playheadY - 0.5f; + float pd = std::sqrt (pdx * pdx + pdy * pdy); + if (pd > 0.45f) { float ps = 0.45f / pd; lb.playheadX = 0.5f + pdx * ps; lb.playheadY = 0.5f + pdy * ps; } } + lb.jitter = (float)(double) obj->getProperty("jitter"); + lb.morphGlide = (float)(double) obj->getProperty("morphGlide"); + lb.morphTempoSync = (bool) obj->getProperty("morphTempoSync"); + lb.morphSyncDiv = obj->getProperty("morphSyncDiv").toString(); + lb.snapRadius = (float)(double) obj->getProperty("snapRadius"); + + // Defaults + if (lb.morphMode.isEmpty()) lb.morphMode = "manual"; + if (lb.exploreMode.isEmpty()) lb.exploreMode = "wander"; + if (lb.exploreMode == "lfo") lb.exploreMode = "shapes"; // backward compat + if (lb.lfoShape.isEmpty()) lb.lfoShape = "circle"; + if (lb.morphAction.isEmpty()) lb.morphAction = "jump"; + if (lb.stepOrder.isEmpty()) lb.stepOrder = "cycle"; + if (lb.morphSource.isEmpty()) lb.morphSource = "midi"; + if (lb.morphGlide <= 0.0f) lb.morphGlide = 200.0f; + if (lb.morphSyncDiv.isEmpty()) lb.morphSyncDiv = "1/4"; + if (lb.snapRadius <= 0.0f) lb.snapRadius = 1.0f; + + // ── Shapes Block fields ── + lb.shapeType = obj->getProperty("shapeType").toString(); + lb.shapeTracking = obj->getProperty("shapeTracking").toString(); + lb.shapeSize = (float)(double) obj->getProperty("shapeSize"); + lb.shapeSpin = (float)(double) obj->getProperty("shapeSpin"); + lb.shapeSpeed = (float)(double) obj->getProperty("shapeSpeed"); + lb.shapePhaseOffset = (float)(double) obj->getProperty("shapePhaseOffset"); + lb.shapeDepth = (float)(double) obj->getProperty("shapeDepth"); + lb.shapeRange = obj->getProperty("shapeRange").toString(); + lb.shapePolarity = obj->getProperty("shapePolarity").toString(); + lb.shapeTempoSync = (bool) obj->getProperty("shapeTempoSync"); + lb.shapeSyncDiv = obj->getProperty("shapeSyncDiv").toString(); + lb.shapeTrigger = obj->getProperty("shapeTrigger").toString(); + if (lb.shapeType.isEmpty()) lb.shapeType = "circle"; + if (lb.shapeTracking.isEmpty()) lb.shapeTracking = "horizontal"; + if (lb.shapeRange.isEmpty()) lb.shapeRange = "relative"; + if (lb.shapePolarity.isEmpty()) lb.shapePolarity = "bipolar"; + if (lb.shapeSyncDiv.isEmpty()) lb.shapeSyncDiv = "1/4"; + if (lb.shapeTrigger.isEmpty()) lb.shapeTrigger = "free"; + + // Per-param ranges for shapes_range mode + auto rangesVar = obj->getProperty("targetRanges"); + if (rangesVar.isArray()) + { + for (int ri = 0; ri < rangesVar.size(); ++ri) + lb.targetRangeValues.push_back((float)(double) rangesVar[ri]); + } + // Per-param base values (anchor positions) for shapes_range mode + auto rangeBasesVar = obj->getProperty("targetRangeBases"); + if (rangeBasesVar.isArray()) + { + for (int ri = 0; ri < rangeBasesVar.size(); ++ri) + lb.targetRangeBaseValues.push_back((float)(double) rangeBasesVar[ri]); + } + + // ── Lane clips ── + auto lanesVar = obj->getProperty("lanes"); + if (lanesVar.isArray()) + { + for (int li = 0; li < lanesVar.size(); ++li) + { + if (auto* lObj = lanesVar[li].getDynamicObject()) + { + LogicBlock::LaneClip lc; + // Parse lane targets (multi-param per lane) + auto laneTargetsVar = lObj->getProperty("targets"); + if (laneTargetsVar.isArray()) + { + for (int lt = 0; lt < laneTargetsVar.size(); ++lt) + { + if (auto* ltObj = laneTargetsVar[lt].getDynamicObject()) + { + LogicBlock::LaneClip::LaneTarget tgt; + tgt.pluginId = (int) ltObj->getProperty("pluginId"); + tgt.paramIndex = (int) ltObj->getProperty("paramIndex"); + lc.targets.push_back(tgt); + } + } + } + else + { + // Backwards compat: single pluginId/paramIndex + LogicBlock::LaneClip::LaneTarget tgt; + tgt.pluginId = (int) lObj->getProperty("pluginId"); + tgt.paramIndex = (int) lObj->getProperty("paramIndex"); + lc.targets.push_back(tgt); + } + lc.loopLen = lObj->getProperty("loopLen").toString(); + lc.steps = (float)(double) lObj->getProperty("steps"); // 0=off, 2-32 + lc.depth = (float)(double) lObj->getProperty("depth"); + lc.drift = (lObj->hasProperty("drift") ? (float)(double) lObj->getProperty("drift") : (float)(double) lObj->getProperty("slew")) / 50.0f; // -1..+1 (fallback: legacy slew) + lc.driftRange = lObj->hasProperty("driftRange") ? (float)(double) lObj->getProperty("driftRange") : 5.0f; + lc.driftScale = lObj->hasProperty("driftScale") ? lObj->getProperty("driftScale").toString() : "1/1"; + lc.driftScaleBeats = parseBeatsPerDiv(lc.driftScale); + lc.warp = (float)(double) lObj->getProperty("warp") / 50.0f; // -1..+1 + + lc.interp = lObj->getProperty("interp").toString(); + lc.synced = (bool) lObj->getProperty("synced"); + lc.muted = (bool) lObj->getProperty("muted"); + lc.playMode = lObj->getProperty("playMode").toString(); + lc.freeSecs = (float)(double) lObj->getProperty("freeSecs"); + if (lc.loopLen.isEmpty()) lc.loopLen = "1/1"; + if (lc.interp.isEmpty()) lc.interp = "smooth"; + if (lc.playMode.isEmpty()) lc.playMode = "forward"; + if (lc.freeSecs <= 0.0f) lc.freeSecs = 4.0f; + + // Oneshot / trigger config + lc.oneshotMode = (lObj->hasProperty("trigMode") ? lObj->getProperty("trigMode").toString() : "loop") == "oneshot"; + juce::String trigSrc = lObj->hasProperty("trigSource") ? lObj->getProperty("trigSource").toString() : "manual"; + lc.trigSourceE = (trigSrc == "midi") ? 1 : (trigSrc == "audio") ? 2 : 0; + lc.trigMidiNote = lObj->hasProperty("trigMidiNote") ? (int) lObj->getProperty("trigMidiNote") : -1; + lc.trigMidiCh = lObj->hasProperty("trigMidiCh") ? (int) lObj->getProperty("trigMidiCh") : 0; + float threshDb = lObj->hasProperty("trigThreshold") ? (float)(double) lObj->getProperty("trigThreshold") : -12.0f; + lc.trigThresholdLin = std::pow(10.0f, threshDb / 20.0f); + lc.trigRetrigger = lObj->hasProperty("trigRetrigger") ? (bool) lObj->getProperty("trigRetrigger") : true; + lc.trigHold = lObj->hasProperty("trigHold") ? (bool) lObj->getProperty("trigHold") : false; + lc.trigAudioSrc = (lObj->hasProperty("trigAudioSrc") ? lObj->getProperty("trigAudioSrc").toString() : "main") == "sidechain"; + + auto ptsVar = lObj->getProperty("pts"); + if (ptsVar.isArray()) + { + for (int pi = 0; pi < ptsVar.size(); ++pi) + { + if (auto* ptObj = ptsVar[pi].getDynamicObject()) + { + LogicBlock::LaneClip::Point pt; + pt.x = (float)(double) ptObj->getProperty("x"); + pt.y = (float)(double) ptObj->getProperty("y"); + lc.pts.push_back(pt); + } + } + } + + // Morph lane mode + lc.morphMode = lObj->hasProperty("morphMode") ? (bool) lObj->getProperty("morphMode") : false; + auto morphSnapsVar = lObj->getProperty("morphSnapshots"); + if (morphSnapsVar.isArray()) + { + for (int ms = 0; ms < morphSnapsVar.size(); ++ms) + { + if (auto* msObj = morphSnapsVar[ms].getDynamicObject()) + { + LogicBlock::LaneClip::MorphSnapshot snap; + snap.position = (float)(double) msObj->getProperty("position"); + snap.hold = msObj->hasProperty("hold") ? (float)(double) msObj->getProperty("hold") : 0.5f; + snap.curve = msObj->hasProperty("curve") ? (int) msObj->getProperty("curve") : 0; + snap.depth = msObj->hasProperty("depth") ? (float)(double) msObj->getProperty("depth") : 1.0f; + snap.drift = msObj->hasProperty("drift") ? (float)(double) msObj->getProperty("drift") : (msObj->hasProperty("slew") ? (float)(double) msObj->getProperty("slew") : 0.0f); + snap.driftRange = msObj->hasProperty("driftRange") ? (float)(double) msObj->getProperty("driftRange") : 5.0f; + { + juce::String dsStr = msObj->hasProperty("driftScale") ? msObj->getProperty("driftScale").toString() + : (lObj->hasProperty("driftScale") ? lObj->getProperty("driftScale").toString() : "1/1"); + snap.driftScaleBeats = parseBeatsPerDiv(dsStr); + } + snap.warp = msObj->hasProperty("warp") ? (float)(double) msObj->getProperty("warp") : 0.0f; + snap.steps = msObj->hasProperty("steps") ? (int) msObj->getProperty("steps") : 0; + snap.label = msObj->getProperty("name").toString(); + snap.source = msObj->getProperty("source").toString(); + auto valsVar = msObj->getProperty("values"); + if (auto* valsObj = valsVar.getDynamicObject()) + { + for (auto& prop : valsObj->getProperties()) + { + snap.values[prop.name.toString().toStdString()] = (float)(double) prop.value; + } + } + lc.morphSnapshots.push_back(std::move(snap)); + } + } + // Ensure sorted by position + std::sort(lc.morphSnapshots.begin(), lc.morphSnapshots.end(), + [](const auto& a, const auto& b) { return a.position < b.position; }); + + // ── Pre-parse snapshot values for audio thread (ZERO allocations at RT) ── + // Convert string keys "pluginId:paramIndex" → integer pairs. + // Sort by (pluginId, paramIndex) so all snapshots share the same key order, + // enabling index-matched iteration on the audio thread. + for (auto& snap : lc.morphSnapshots) + { + snap.parsedValues.clear(); + snap.parsedValues.reserve(snap.values.size()); + for (const auto& [key, val] : snap.values) + { + auto colon = key.find(':'); + if (colon != std::string::npos) + { + LogicBlock::LaneClip::MorphSnapshot::ParsedValue pv; + pv.pluginId = std::atoi(key.substr(0, colon).c_str()); + pv.paramIndex = std::atoi(key.substr(colon + 1).c_str()); + pv.value = val; + snap.parsedValues.push_back(pv); + } + } + // Sort so index i in snapA == index i in snapB for same param + std::sort(snap.parsedValues.begin(), snap.parsedValues.end(), + [](const auto& a, const auto& b) { + return a.pluginId < b.pluginId || + (a.pluginId == b.pluginId && a.paramIndex < b.paramIndex); + }); + } + } + + // ── Pre-build sorted target key set for audio thread ── + lc.targetKeySorted.clear(); + lc.targetKeySorted.reserve(lc.targets.size()); + for (const auto& tgt : lc.targets) + lc.targetKeySorted.push_back({ tgt.pluginId, tgt.paramIndex }); + std::sort(lc.targetKeySorted.begin(), lc.targetKeySorted.end()); + + lb.laneClips.push_back(std::move(lc)); + } + } + } + + // Parse snapshots array + auto snapsVar = obj->getProperty("snapshots"); + if (snapsVar.isArray()) { + for (int si = 0; si < snapsVar.size() && si < 12; ++si) { + if (auto* sObj = snapsVar[si].getDynamicObject()) { + LogicBlock::MorphSnapshot snap; + snap.x = (float)(double) sObj->getProperty("x"); + snap.y = (float)(double) sObj->getProperty("y"); + auto valsVar = sObj->getProperty("targetValues"); + if (valsVar.isArray()) { + for (int vi = 0; vi < valsVar.size(); ++vi) + snap.targetValues.push_back((float)(double) valsVar[vi]); + } + lb.snapshots.push_back(snap); + } + } + } + + // Parse targets array: [{hostId, paramIndex}, ...] + auto targetsVar = obj->getProperty ("targets"); + if (targetsVar.isArray()) + { + for (int t = 0; t < targetsVar.size(); ++t) + { + if (auto* tObj = targetsVar[t].getDynamicObject()) + { + ParamTarget pt; + pt.pluginId = (int) tObj->getProperty ("hostId"); + pt.paramIndex = (int) tObj->getProperty ("paramIndex"); + lb.targets.push_back (pt); + } + } + } + + // Parse targetBases array (base values captured at assignment time in JS) + auto basesVar = obj->getProperty ("targetBases"); + if (basesVar.isArray()) + { + lb.targetBaseValues.resize (lb.targets.size(), 0.5f); + lb.targetLastWritten.resize (lb.targets.size(), 0.5f); + for (int t = 0; t < basesVar.size() && t < (int) lb.targets.size(); ++t) + { + float base = (float)(double) basesVar[t]; + lb.targetBaseValues[t] = base; + lb.targetLastWritten[t] = base; + } + } + + // Preserve runtime state from existing blocks with matching ID + for (const auto& existing : logicBlocks) + { + if (existing.id == lb.id) + { + lb.currentEnvValue = existing.currentEnvValue; + lb.lastBeat = existing.lastBeat; + lb.lastAudioTrigSample = existing.lastAudioTrigSample; + lb.internalPpq = existing.internalPpq; + + // Preserve sample data and playback state + lb.sampleData = existing.sampleData; + lb.samplePlayhead = existing.samplePlayhead; + lb.sampleDirection = existing.sampleDirection; + + // Preserve relative-mode base values if mode and targets unchanged + // Covers both randomize/sample (rangeMode) and shapes (shapeRange) + bool newRelative = (lb.rangeMode == "relative" || lb.shapeRange == "relative"); + bool oldRelative = (existing.rangeMode == "relative" || existing.shapeRange == "relative"); + if (newRelative && oldRelative + && lb.targets.size() == existing.targets.size()) + { + // Carry over base values and last-written state smoothly + // Polarity changes only affect the formula, no need to reset params + lb.targetBaseValues = existing.targetBaseValues; + lb.targetLastWritten = existing.targetLastWritten; + } + + // Preserve morph runtime state + lb.morphVelX = existing.morphVelX; + lb.morphVelY = existing.morphVelY; + lb.morphAngle = existing.morphAngle; + lb.morphLfoPhase = existing.morphLfoPhase; + lb.morphStepIndex = existing.morphStepIndex; + lb.morphSmoothX = existing.morphSmoothX; + lb.morphSmoothY = existing.morphSmoothY; + lb.prevAppliedX = existing.prevAppliedX; + lb.prevAppliedY = existing.prevAppliedY; + lb.morphNoisePhaseX = existing.morphNoisePhaseX; + lb.morphNoisePhaseY = existing.morphNoisePhaseY; + lb.morphOrbitPhase = existing.morphOrbitPhase; + lb.morphOrbitTarget = existing.morphOrbitTarget; + lb.morphPathProgress = existing.morphPathProgress; + lb.morphPathIndex = existing.morphPathIndex; + lb.lfoRotAngle = existing.lfoRotAngle; + + // Preserve shapes runtime state + lb.shapePhase = existing.shapePhase; + lb.shapeRotAngle = existing.shapeRotAngle; + lb.smoothedRangeValues = existing.smoothedRangeValues; + lb.smoothedShapeDepth = existing.smoothedShapeDepth; + lb.shapeWasPlaying = existing.shapeWasPlaying; + lb.shapeWasEnabled = existing.shapeWasEnabled; + + // Preserve envelope follower runtime state + lb.currentEnvValue = existing.currentEnvValue; + lb.envHpf = existing.envHpf; + lb.envLpf = existing.envLpf; + + // Preserve lane playhead positions + if (lb.laneClips.size() == existing.laneClips.size()) + { + for (size_t li = 0; li < lb.laneClips.size(); ++li) + { + lb.laneClips[li].playhead = existing.laneClips[li].playhead; + lb.laneClips[li].direction = existing.laneClips[li].direction; + lb.laneClips[li].driftPhase = existing.laneClips[li].driftPhase; + lb.laneClips[li].wasPlaying = existing.laneClips[li].wasPlaying; + lb.laneClips[li].oneshotActive = existing.laneClips[li].oneshotActive; + lb.laneClips[li].oneshotDone = existing.laneClips[li].oneshotDone; + lb.laneClips[li].midiNoteHeld = existing.laneClips[li].midiNoteHeld; + } + } + + // Force prevApplied to match morphSmooth so the block rebuild + // doesn't trigger a spurious IDW re-application (which would + // overwrite any manual parameter tweaks the user has made). + lb.prevAppliedX = lb.morphSmoothX; + lb.prevAppliedY = lb.morphSmoothY; + + break; + } + } + // H4 fix: parse string fields → enum mirrors + pre-compute beat divisions + // Called once on message thread so processBlock uses integer comparisons. + lb.modeE = parseBlockMode (lb.mode); + lb.triggerE = parseTriggerType (lb.trigger); + lb.midiModeE = parseMidiTrigMode (lb.midiMode); + lb.audioSrcE = parseAudioSource (lb.audioSrc); + lb.rangeModeE = parseRangeMode (lb.rangeMode); + lb.movementE = parseMovement (lb.movement); + lb.polarityE = parsePolarity (lb.polarity); + lb.clockSourceE = parseClockSource (lb.clockSource); + lb.loopModeE = parseLoopMode (lb.loopMode); + lb.jumpModeE = parseJumpMode (lb.jumpMode); + lb.morphModeE = parseMorphMode (lb.morphMode); + lb.exploreModeE = parseExploreMode (lb.exploreMode); + lb.lfoShapeE = parseLfoShape (lb.lfoShape); + lb.morphActionE = parseMorphAction (lb.morphAction); + lb.stepOrderE = parseStepOrder (lb.stepOrder); + lb.shapeTypeE = parseLfoShape (lb.shapeType); + lb.shapeTrackingE = parseShapeTracking (lb.shapeTracking); + lb.shapeRangeE = parseRangeMode (lb.shapeRange); + lb.shapePolarityE = parsePolarity (lb.shapePolarity); + lb.shapeTriggerE = parseShapeTrigger (lb.shapeTrigger); + lb.beatDivBeats = parseBeatsPerDiv (lb.beatDiv); + lb.morphSyncDivBeats = parseBeatsPerDiv (lb.morphSyncDiv); + lb.shapeSyncDivBeats = parseBeatsPerDiv (lb.shapeSyncDiv); + + // Force relative for continuous blocks — absolute only valid for Randomize + if (lb.modeE == BlockMode::Envelope || lb.modeE == BlockMode::Sample) + lb.rangeModeE = RangeMode::Relative; + if (lb.modeE == BlockMode::Shapes || lb.modeE == BlockMode::ShapesRange) + lb.shapeRangeE = RangeMode::Relative; + + // Pre-compute lane clip enums + beat division floats + for (auto& lc : lb.laneClips) + { + lc.interpE = parseLaneInterp (lc.interp); + lc.playModeE = parseLanePlayMode (lc.playMode); + lc.loopLenFree = (lc.loopLen == "free"); + lc.loopLenBeats = lc.loopLenFree ? 0.0f : parseBeatsPerDiv (lc.loopLen); + } + + // H3 fix: Pre-allocate vectors that processBlock uses, so the audio thread + // never needs to call resize() (which can heap-allocate). + auto n = lb.targets.size(); + if (lb.targetBaseValues.size() != n) + { + lb.targetBaseValues.resize (n, 0.5f); + lb.targetLastWritten.resize (n, -1.0f); + } + // smoothedRangeValues for shapes_range mode + if (lb.modeE == BlockMode::ShapesRange && lb.smoothedRangeValues.size() < n) + lb.smoothedRangeValues.resize (n, 0.0f); + + newBlocks.push_back (std::move (lb)); + } + + std::lock_guard lock (blockMutex); + + // Restore base values for blocks that transitioned enabled→disabled + // or lost targets (proper modulation recall behavior) + for (const auto& old : logicBlocks) + { + if (!old.enabled || old.targetBaseValues.empty()) continue; + + // Find matching new block + const LogicBlock* newLb = nullptr; + for (const auto& nb : newBlocks) + { + if (nb.id == old.id) { newLb = &nb; break; } + } + + bool wasModulating = old.enabled && !old.targets.empty() + && (old.modeE == BlockMode::Envelope || old.modeE == BlockMode::Shapes || old.modeE == BlockMode::ShapesRange + || old.modeE == BlockMode::MorphPad || old.modeE == BlockMode::Sample || old.modeE == BlockMode::Lane); + + if (!wasModulating) continue; + + // Block removed, disabled, or targets cleared → restore bases + bool shouldRestore = (newLb == nullptr) + || (!newLb->enabled) + || (newLb->targets.empty()); + + if (shouldRestore) + { + for (size_t ti = 0; ti < old.targets.size() && ti < old.targetBaseValues.size(); ++ti) + { + setHostedParam (old.targets[ti].pluginId, old.targets[ti].paramIndex, + old.targetBaseValues[ti]); + int slot = slotForId (old.targets[ti].pluginId); + if (slot >= 0 && old.targets[ti].paramIndex < kMaxParams) + { + paramWritten[slot][old.targets[ti].paramIndex] = -1.0f; + paramTouched[slot][old.targets[ti].paramIndex].store (false, std::memory_order_release); + } + } + } + else if (newLb != nullptr) + { + // Check for specific targets that were removed + for (size_t ti = 0; ti < old.targets.size() && ti < old.targetBaseValues.size(); ++ti) + { + bool stillPresent = false; + for (const auto& nt : newLb->targets) + { + if (nt.pluginId == old.targets[ti].pluginId && nt.paramIndex == old.targets[ti].paramIndex) + { stillPresent = true; break; } + } + if (!stillPresent) + { + setHostedParam (old.targets[ti].pluginId, old.targets[ti].paramIndex, + old.targetBaseValues[ti]); + int slot = slotForId (old.targets[ti].pluginId); + if (slot >= 0 && old.targets[ti].paramIndex < kMaxParams) + { + paramWritten[slot][old.targets[ti].paramIndex] = -1.0f; + paramTouched[slot][old.targets[ti].paramIndex].store (false, std::memory_order_release); + } + } + } + } + } + + // Clear paramTouched only for params belonging to blocks whose config changed + // (prevents stale touched state while not disrupting unrelated knob drags) + for (auto& nb : newBlocks) + { + // Find matching old block + const LogicBlock* oldMatch = nullptr; + for (auto& old : logicBlocks) + if (old.id == nb.id) { oldMatch = &old; break; } + + // If block is new, changed mode, or changed targets → clear touched for its targets + bool changed = (oldMatch == nullptr) + || (oldMatch->mode != nb.mode) + || (oldMatch->targets.size() != nb.targets.size()); + + if (!changed && oldMatch) + { + for (size_t ti = 0; ti < nb.targets.size(); ++ti) + { + if (nb.targets[ti].pluginId != oldMatch->targets[ti].pluginId + || nb.targets[ti].paramIndex != oldMatch->targets[ti].paramIndex) + { changed = true; break; } + } + } + + if (changed) + { + for (auto& tgt : nb.targets) + { + int slot = slotForId (tgt.pluginId); + if (slot >= 0 && tgt.paramIndex < kMaxParams) + paramTouched[slot][tgt.paramIndex].store (false, std::memory_order_release); + } + } + } + + logicBlocks = std::move (newBlocks); +} + +void ModularRandomizerAudioProcessor::updateMorphPlayhead (int blockId, float x, float y) +{ + std::lock_guard lock (blockMutex); + for (auto& lb : logicBlocks) + { + if (lb.id == blockId && lb.mode == "morph_pad") + { + // Circular clamp (r=0.45) before storing + float dx = x - 0.5f, dy = y - 0.5f; + float d = std::sqrt (dx * dx + dy * dy); + if (d > 0.45f) { float s = 0.45f / d; x = 0.5f + dx * s; y = 0.5f + dy * s; } + lb.playheadX = x; + lb.playheadY = y; + break; + } + } +} + +void ModularRandomizerAudioProcessor::fireLaneTrigger (int blockId, int laneIdx) +{ + std::lock_guard lock (blockMutex); + for (auto& lb : logicBlocks) + { + if (lb.id == blockId && (int) lb.laneClips.size() > laneIdx) + { + lb.laneClips[laneIdx].manualTrigger = true; + break; + } + } +} + +int ModularRandomizerAudioProcessor::slotForId (int pluginId) const +{ + // Use pluginId directly as the array slot (modulo kMaxPlugins). + // pluginIds are unique and monotonically increasing, so no collisions + // occur as long as we don't exceed kMaxPlugins simultaneous plugins. + if (pluginId < 0) return -1; + return pluginId % kMaxPlugins; +} + +int ModularRandomizerAudioProcessor::getSpectrumBins (float* outBins, int maxBins) +{ + if (!fftReady.load()) return 0; + fftReady.store (false); + + // Perform FFT (on message thread — safe, no audio thread overhead) + juce::dsp::FFT fft (fftOrder); + fft.performRealOnlyForwardTransform (fftWorkBuffer, true); + + int halfSize = fftSize / 2; + int numBins = juce::jmin (spectrumBinCount, maxBins); + + // Map to log-spaced frequency bins (20Hz - 20kHz) + float minFreq = 20.0f; + float maxFreq = 20000.0f; + float logMin = std::log10 (minFreq); + float logMax = std::log10 (maxFreq); + float sr = (float) currentSampleRate; + if (sr < 1.0f) sr = 44100.0f; + + for (int b = 0; b < numBins; ++b) + { + // Frequency at this bin position + float t = (float) b / (float) (numBins - 1); + float freq = std::pow (10.0f, logMin + t * (logMax - logMin)); + + // Map frequency to FFT bin index + int fftBin = juce::jlimit (0, halfSize - 1, (int) (freq * (float) fftSize / sr)); + + // Average a small range of bins for smoother display + int lo = juce::jmax (0, fftBin - 1); + int hi = juce::jmin (halfSize - 1, fftBin + 1); + float mag = 0.0f; + for (int i = lo; i <= hi; ++i) + { + float re = fftWorkBuffer[i * 2]; + float im = fftWorkBuffer[i * 2 + 1]; + mag += std::sqrt (re * re + im * im); + } + mag /= (float) (hi - lo + 1); + + // Convert to dB + float db = mag > 0.0f ? 20.0f * std::log10 (mag / (float) fftSize) : -100.0f; + outBins[b] = juce::jlimit (-100.0f, 20.0f, db); + } + + return numBins; +} + +void ModularRandomizerAudioProcessor::rebuildPluginSlots() +{ + // Called from message thread whenever plugins are added/removed/reordered. + // Populates O(1) lookup table used by audio thread hot path. + std::memset (pluginSlots, 0, sizeof (pluginSlots)); + + // Remove old gesture listeners BEFORE destroying them (prevents dangling pointers) + for (auto& hp : hostedPlugins) + { + if (hp && hp->instance) + { + auto& params = hp->instance->getParameters(); + for (auto& gl : gestureListeners) + for (auto* p : params) + p->removeListener (gl.get()); + } + } + gestureListeners.clear(); + + for (auto& hp : hostedPlugins) + { + if (hp) + { + int slot = slotForId (hp->id); + if (slot >= 0) + pluginSlots[slot] = hp.get(); + + // Register gesture listeners on all params for hosted-UI touch detection. + if (hp->instance && slot >= 0) + { + auto listener = std::make_unique (slot, paramTouched); + auto& params = hp->instance->getParameters(); + for (auto* p : params) + p->addListener (listener.get()); + gestureListeners.push_back (std::move (listener)); + } + } + } +} + +// The actual gesture callback is handled by GestureListener (see header) +void ModularRandomizerAudioProcessor::parameterGestureChanged (int, bool) {} + + +void ModularRandomizerAudioProcessor::setParamDirect (int pluginId, int paramIndex, float value) +{ + // ── WrongEQ virtual params: write modulation OFFSETS to eqPoints ── + // The base values (freqHz, gainDB, q) are set by JS via setEqCurve(). + // Modulation writes to separate modFreqHz/modGainDB/modQ offsets so + // JS drift animation doesn't fight with C++ modulation sources. + if (pluginId == kWeqPluginId) + { + // ── Per-band params (0..31): band*4+field ── + if (paramIndex >= 0 && paramIndex < maxEqBands * 4) + { + int band = paramIndex / 4; + int field = paramIndex % 4; + if (band >= 0 && band < numEqPoints.load (std::memory_order_relaxed)) + { + switch (field) + { + case 0: // freqHz (log: norm 0..1 → 20..20000 Hz) + { + float targetHz = 20.0f * std::pow (1000.0f, juce::jlimit (0.0f, 1.0f, value)); + float baseHz = eqPoints[band].freqHz.load (std::memory_order_relaxed); + eqPoints[band].modFreqHz.store (targetHz - baseHz, std::memory_order_relaxed); + eqPoints[band].modActive.store (true, std::memory_order_relaxed); + break; + } + case 1: // gainDB (norm 0..1 → -maxDB..+maxDB dB) + { + float maxDB = eqDbRange.load (std::memory_order_relaxed); + float targetDB = juce::jlimit (0.0f, 1.0f, value) * maxDB * 2.0f - maxDB; + float baseDB = eqPoints[band].gainDB.load (std::memory_order_relaxed); + eqPoints[band].modGainDB.store (targetDB - baseDB, std::memory_order_relaxed); + eqPoints[band].modActive.store (true, std::memory_order_relaxed); + break; + } + case 2: // Q (norm 0..1 → 0.025..40.0) + { + float targetQ = 0.025f + juce::jlimit (0.0f, 1.0f, value) * 39.975f; + float baseQ = eqPoints[band].q.load (std::memory_order_relaxed); + eqPoints[band].modQ.store (targetQ - baseQ, std::memory_order_relaxed); + eqPoints[band].modActive.store (true, std::memory_order_relaxed); + break; + } + case 3: // driftPct (norm 0..1 → 0..100%) + eqPoints[band].driftPct.store (juce::jlimit (0.0f, 1.0f, value) * 100.0f, + std::memory_order_relaxed); + eqDirty.store (true, std::memory_order_release); + break; + } + } + } + // ── Global params (100..110): EQ globals ── + else if (paramIndex >= kWeqGlobalBase && paramIndex < kWeqGlobalBase + kWeqGlobalCount) + { + int g = paramIndex - kWeqGlobalBase; + float v = juce::jlimit (0.0f, 1.0f, value); + switch (g) + { + case 0: // depth (norm 0..1 → 0..200%) + eqGlobalDepth.store (v * 200.0f, std::memory_order_relaxed); + eqDirty.store (true, std::memory_order_release); + break; + case 1: // warp (norm 0..1 → -100..+100) + eqGlobalWarp.store (v * 200.0f - 100.0f, std::memory_order_relaxed); + eqDirty.store (true, std::memory_order_release); + break; + case 2: // steps (norm 0..1 → 0..32) + eqGlobalSteps.store ((int) (v * 32.0f), std::memory_order_relaxed); + eqDirty.store (true, std::memory_order_release); + break; + case 3: // tilt (norm 0..1 → -100..+100) + eqGlobalTilt.store (v * 200.0f - 100.0f, std::memory_order_relaxed); + eqDirty.store (true, std::memory_order_release); + break; + // Cases 4-10 are JS-side meta params (drift, lfo) — they don't + // have C++ atomics. Modulation for these is applied via JS weqApplyVirtualParam. + // Setting eqDirty is enough to trigger a re-sync from JS. + default: + eqDirty.store (true, std::memory_order_release); + break; + } + } + return; + } + + // Skip if user is currently grabbing this param (lock-free atomic read) + int slot = slotForId (pluginId); + if (slot < 0) return; + + if (paramIndex >= 0 && paramIndex < kMaxParams + && paramTouched[slot][paramIndex].load (std::memory_order_acquire)) + return; + + // O(1) lookup via pluginSlots — no linear scan + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + { + params[paramIndex]->setValue (value); + } + } +} + +float ModularRandomizerAudioProcessor::getParamValue (int pluginId, int paramIndex) const +{ + // ── WrongEQ virtual params: read from eqPoints/globals atomics ── + if (pluginId == kWeqPluginId) + { + // Per-band params (0..31) + if (paramIndex >= 0 && paramIndex < maxEqBands * 4) + { + int band = paramIndex / 4; + int field = paramIndex % 4; + if (band >= 0 && band < numEqPoints.load (std::memory_order_relaxed)) + { + switch (field) + { + case 0: return std::log (eqPoints[band].freqHz.load (std::memory_order_relaxed) / 20.0f) + / std::log (1000.0f); + case 1: + { + float maxDB = eqDbRange.load (std::memory_order_relaxed); + return (eqPoints[band].gainDB.load (std::memory_order_relaxed) + maxDB) / (maxDB * 2.0f); + } + case 2: return (eqPoints[band].q.load (std::memory_order_relaxed) - 0.025f) / 39.975f; + case 3: return eqPoints[band].driftPct.load (std::memory_order_relaxed) / 100.0f; + } + } + } + // Global params (100..113) + else if (paramIndex >= kWeqGlobalBase && paramIndex < kWeqGlobalBase + kWeqGlobalCount) + { + int g = paramIndex - kWeqGlobalBase; + switch (g) + { + case 0: return eqGlobalDepth.load (std::memory_order_relaxed) / 200.0f; + case 1: return (eqGlobalWarp.load (std::memory_order_relaxed) + 100.0f) / 200.0f; + case 2: return (float) eqGlobalSteps.load (std::memory_order_relaxed) / 32.0f; + case 3: return (eqGlobalTilt.load (std::memory_order_relaxed) + 100.0f) / 200.0f; + // Cases 4-13: JS-side meta params — no C++ atomics. Return 0.5 as safe default. + default: return 0.5f; + } + } + return 0.5f; + } + + int slot = slotForId (pluginId); + if (slot >= 0) + { + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + return params[paramIndex]->getValue(); + } + } + return 0.5f; // safe default center +} + +void ModularRandomizerAudioProcessor::randomizeParams (int pluginId, + const std::vector& paramIndices, + float minVal, float maxVal) +{ + // Persistent RNG — avoids identical sequences when called rapidly (M2 fix) + static juce::Random messageThreadRng; + + // No mutex needed — hostedPlugins is structurally stable, setValue() is atomic + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + auto& params = hp->instance->getParameters(); + + for (int idx : paramIndices) + { + if (idx >= 0 && idx < params.size()) + { + float val = minVal + messageThreadRng.nextFloat() * (maxVal - minVal); + params[idx]->setValue (juce::jlimit (0.0f, 1.0f, val)); + recordSelfWrite (pluginId, idx); + } + } + break; + } + } +} + +void ModularRandomizerAudioProcessor::applyParamBatch (const juce::String& jsonBatch) +{ + // Batch param apply — sets N params in a single call. + // JSON format: [{"p":pluginId,"i":paramIndex,"v":value}, ...] + // No lock needed: hostedPlugins is structurally stable, setValue() is atomic. + auto parsed = juce::JSON::parse (jsonBatch); + if (! parsed.isArray()) return; + + for (int k = 0; k < parsed.size(); ++k) + { + auto* obj = parsed[k].getDynamicObject(); + if (obj == nullptr) continue; + + int pluginId = (int) obj->getProperty ("p"); + int paramIndex = (int) obj->getProperty ("i"); + float value = (float) (double) obj->getProperty ("v"); + + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + { + params[paramIndex]->setValue (juce::jlimit (0.0f, 1.0f, value)); + recordSelfWrite (pluginId, paramIndex); + updateParamBase (pluginId, paramIndex, juce::jlimit (0.0f, 1.0f, value)); + } + break; + } + } + } +} + +std::vector +ModularRandomizerAudioProcessor::getHostedPluginList() +{ + std::vector result; + std::lock_guard lock (pluginMutex); + + for (auto& hp : hostedPlugins) + { + if (hp->id < 0) continue; // skip tombstoned entries + HostedPluginInfo info; + info.id = hp->id; + info.name = hp->name; + info.path = hp->path; + info.manufacturer = hp->description.manufacturerName; + info.numParams = hp->instance ? (int) hp->instance->getParameters().size() : 0; + info.busId = hp->busId; + info.isInstrument = hp->isInstrument; + result.push_back (info); + } + return result; +} + +juce::AudioPluginInstance* ModularRandomizerAudioProcessor::getHostedPluginInstance (int pluginId) +{ + std::lock_guard lock (pluginMutex); + + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->instance != nullptr) + return hp->instance.get(); + } + return nullptr; +} + +void ModularRandomizerAudioProcessor::setPluginBusId (int pluginId, int busId) +{ + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId) + { + // busId is now a stable UID (not a band index) — just store it. + // 0 = unassigned, positive = matches eqPoints[p].busId for routing. + hp->busId = std::max (0, busId); + break; + } + } +} + +void ModularRandomizerAudioProcessor::setBusVolume (int bus, float vol) +{ + if (bus >= 0 && bus < maxBuses) + busVolume[bus].store (juce::jlimit (0.0f, 2.0f, vol)); +} + +void ModularRandomizerAudioProcessor::setBusMute (int bus, bool m) +{ + if (bus >= 0 && bus < maxBuses) + busMute[bus].store (m); +} + +void ModularRandomizerAudioProcessor::setBusSolo (int bus, bool s) +{ + if (bus >= 0 && bus < maxBuses) + busSolo[bus].store (s); +} + +// ── WrongEQ: receive curve data from JS ── +void ModularRandomizerAudioProcessor::setEqCurve (const juce::String& jsonData) +{ + auto parsed = juce::JSON::parse (jsonData); + if (parsed.isVoid()) return; + + auto* obj = parsed.getDynamicObject(); + if (! obj) return; + + // Global flags + if (obj->hasProperty ("globalBypass")) + eqGlobalBypass.store ((bool) obj->getProperty ("globalBypass")); + if (obj->hasProperty ("preEq")) + eqPreEq.store ((bool) obj->getProperty ("preEq")); + if (obj->hasProperty ("unassignedMode")) + eqUnassignedMode.store ((int) obj->getProperty ("unassignedMode")); + if (obj->hasProperty ("splitMode")) + { + bool newSplit = (bool) obj->getProperty ("splitMode"); + if (newSplit != eqSplitMode.load()) + { + eqSplitMode.store (newSplit); + eqDirty.store (true, std::memory_order_release); // reconfigure crossovers + } + } + // Oversampling factor (1=off, 2=2×, 4=4×) + if (obj->hasProperty ("oversample")) + { + int newOS = juce::jlimit (1, 4, (int) obj->getProperty ("oversample")); + // Normalize to valid values: 1, 2, or 4 + if (newOS == 3) newOS = 2; + int oldOS = eqOversampleFactor.load(); + if (newOS != oldOS) + { + eqOversampleFactor.store (newOS); + // Reconfigure oversampler (safe: setEqCurve is called from message thread) + int numCh = getTotalNumOutputChannels(); + int order = (newOS >= 4) ? 2 : (newOS >= 2) ? 1 : 0; + eqOversampleOrder = order; + if (order > 0) + { + eqOversampler = std::make_unique>( + (juce::uint32) numCh, (juce::uint32) order, + juce::dsp::Oversampling::filterHalfBandPolyphaseIIR, false); + eqOversampler->initProcessing ((size_t) currentBlockSize); + eqOversamplerReady = true; + } + else + { + eqOversampler.reset(); + eqOversamplerReady = false; + } + } + } + + // Dynamic dB range: parse from JS so gain clamping uses the user-selected range + if (obj->hasProperty ("dbRange")) + { + float dbr = (float)(double) obj->getProperty ("dbRange"); + eqDbRange.store (juce::jlimit (6.0f, 48.0f, dbr)); + } + // Snapshot old global values to detect changes + float oldDepth = eqGlobalDepth.load(); + float oldWarp = eqGlobalWarp.load(); + int oldSteps = eqGlobalSteps.load(); + float oldTilt = eqGlobalTilt.load(); + + // Global depth: scales all EQ gains (0-200%). + // Skip if actively modulated by logic blocks (modbus would fight setEqCurve). + // weqParamBase[slot] >= 0 when modulation was recently active (persists across buffers). + if (obj->hasProperty ("globalDepth")) + { + int slot = weqSlot (kWeqGlobalBase + 0); + if (slot < 0 || weqParamBase[slot] < -0.5f) + eqGlobalDepth.store (juce::jlimit (0.0f, 200.0f, (float)(double) obj->getProperty ("globalDepth"))); + } + // Global warp: S-curve contrast (-100 to +100) + if (obj->hasProperty ("globalWarp")) + { + int slot = weqSlot (kWeqGlobalBase + 1); + if (slot < 0 || weqParamBase[slot] < -0.5f) + eqGlobalWarp.store (juce::jlimit (-100.0f, 100.0f, (float)(double) obj->getProperty ("globalWarp"))); + } + // Global steps: quantize gain to N discrete levels (0 = continuous, ≥2 = stepped) + if (obj->hasProperty ("globalSteps")) + { + int slot = weqSlot (kWeqGlobalBase + 2); + if (slot < 0 || weqParamBase[slot] < -0.5f) + eqGlobalSteps.store (juce::jlimit (0, 64, (int) obj->getProperty ("globalSteps"))); + } + // Global tilt: frequency-dependent gain offset (-100 to +100) + if (obj->hasProperty ("globalTilt")) + { + int slot = weqSlot (kWeqGlobalBase + 3); + if (slot < 0 || weqParamBase[slot] < -0.5f) + eqGlobalTilt.store (juce::jlimit (-100.0f, 100.0f, (float)(double) obj->getProperty ("globalTilt"))); + } + + // If any global EQ parameter changed, set eqDirty so biquad coefficients are recalculated. + // Note: this triggers BIQUAD recalc but NOT crossover reconfig (gated separately by curveChanged). + bool globalsChanged = (std::abs (eqGlobalDepth.load() - oldDepth) > 0.1f || + std::abs (eqGlobalWarp.load() - oldWarp) > 0.1f || + eqGlobalSteps.load() != oldSteps || + std::abs (eqGlobalTilt.load() - oldTilt) > 0.1f); + if (globalsChanged) + eqDirty.store (true, std::memory_order_release); + + float maxDB = eqDbRange.load(); + + auto pointsVar = obj->getProperty ("points"); + if (auto* pointsArr = pointsVar.getArray()) + { + int n = juce::jmin ((int) pointsArr->size(), (int) maxEqBands); + + // Sort by frequency for crossover filter configuration + struct PtSort { float hz; float db; int busId; bool solo; bool mute; float q; int filterType; float drift; bool preEq; int stereoMode; int slope; int origIdx; }; + std::vector sorted; + sorted.reserve (n); + for (int i = 0; i < n; ++i) + { + if (auto* pt = (*pointsArr)[i].getDynamicObject()) + { + // Parse filter type string to int + int ft = 0; + auto typeStr = pt->getProperty ("type").toString().toLowerCase(); + if (typeStr == "lp") ft = 1; + else if (typeStr == "hp") ft = 2; + else if (typeStr == "notch") ft = 3; + else if (typeStr == "lshf") ft = 4; + else if (typeStr == "hshf") ft = 5; + + int sl = pt->hasProperty ("slope") ? (int) pt->getProperty ("slope") : 1; + if (sl != 1 && sl != 2 && sl != 4) sl = 1; + + sorted.push_back ({ + (float)(double) pt->getProperty ("freqHz"), + (float)(double) pt->getProperty ("gainDB"), + (int) pt->getProperty ("busId"), + (bool) pt->getProperty ("solo"), + (bool) pt->getProperty ("mute"), + pt->hasProperty ("q") ? (float)(double) pt->getProperty ("q") : 0.707f, + ft, + pt->hasProperty ("drift") ? (float)(double) pt->getProperty ("drift") : 0.0f, + pt->hasProperty ("preEq") ? (bool) pt->getProperty ("preEq") : true, + pt->hasProperty ("stereoMode") ? (int) pt->getProperty ("stereoMode") : 0, + sl, + i + }); + } + } + std::sort (sorted.begin(), sorted.end(), + [] (const PtSort& a, const PtSort& b) { return a.hz < b.hz; }); + + // Only set eqDirty if the point data actually changed. + // The animation calls setEqCurve periodically, but usually only + // drift offsets change — user EQ points stay the same. Setting + // eqDirty unconditionally caused crossover reconfig too often. + bool pointDataChanged = ((int) sorted.size() != numEqPoints.load()); + if (!pointDataChanged) + { + for (int i = 0; i < (int) sorted.size() && !pointDataChanged; ++i) + { + int idx = sorted[i].origIdx; + float curFreq = eqPoints[idx].freqHz.load(); + float curGain = eqPoints[idx].gainDB.load(); + float curQ = eqPoints[idx].q.load(); + int curFt = eqPoints[idx].filterType.load(); + if (std::abs (curFreq - juce::jlimit (20.0f, 20000.0f, sorted[i].hz)) > 0.5f || + std::abs (curGain - juce::jlimit (-maxDB, maxDB, sorted[i].db)) > 0.05f || + std::abs (curQ - juce::jlimit (0.25f, 18.0f, sorted[i].q)) > 0.01f || + curFt != sorted[i].filterType) + pointDataChanged = true; + } + } + + // Store sorted points into atomic arrays + // KEY: Write to eqPoints[origIdx] (original JS order), NOT sorted position. + // This keeps each biquad slot associated with the same logical point even + // when points swap frequency order during drag — preventing massive + // coefficient discontinuities (FabFilter Pro-Q 3 behavior). + for (int i = 0; i < (int) sorted.size(); ++i) + { + int idx = sorted[i].origIdx; + eqPoints[idx].freqHz.store (juce::jlimit (20.0f, 20000.0f, sorted[i].hz)); + eqPoints[idx].gainDB.store (juce::jlimit (-maxDB, maxDB, sorted[i].db)); + eqPoints[idx].busId.store (sorted[i].busId); + eqPoints[idx].solo.store (sorted[i].solo); + eqPoints[idx].mute.store (sorted[i].mute); + eqPoints[idx].q.store (juce::jlimit (0.025f, 40.0f, sorted[i].q)); + eqPoints[idx].filterType.store (sorted[i].filterType); + eqPoints[idx].driftPct.store (juce::jlimit (0.0f, 100.0f, sorted[i].drift)); + eqPoints[idx].preEq.store (sorted[i].preEq); + eqPoints[idx].stereoMode.store (juce::jlimit (0, 2, sorted[i].stereoMode)); + eqPoints[idx].slope.store (sorted[i].slope); + } + + // Store sorted order for crossover pass (sorted position → original index) + for (int i = 0; i < (int) sorted.size(); ++i) + eqSortOrder[i] = sorted[i].origIdx; + for (int i = (int) sorted.size(); i < maxEqBands; ++i) + eqSortOrder[i] = -1; + + // Clear unused slots + for (int i = (int) sorted.size(); i < maxEqBands; ++i) + { + eqPoints[i].busId.store (-1); + eqPoints[i].solo.store (false); + eqPoints[i].mute.store (false); + eqPoints[i].q.store (0.707f); + eqPoints[i].filterType.store (0); + eqPoints[i].driftPct.store (0.0f); + eqPoints[i].stereoMode.store (0); + eqPoints[i].slope.store (1); + } + + numEqPoints.store ((int) sorted.size()); + if (pointDataChanged) + eqDirty.store (true, std::memory_order_release); + } + +} + +//============================================================================== +//============================================================================== +void ModularRandomizerAudioProcessor::setUiState (const juce::String& json) +{ + std::lock_guard lock (uiStateMutex); + uiStateJson = json; +} + +juce::String ModularRandomizerAudioProcessor::getUiState() const +{ + std::lock_guard lock (uiStateMutex); + return uiStateJson; +} + +//============================================================================== +// Sample Modulator API +//============================================================================== + +bool ModularRandomizerAudioProcessor::loadSampleForBlock (int blockId, const juce::String& filePath) +{ + juce::File file (filePath); + if (! file.existsAsFile()) + { + LOG_TO_FILE ("loadSampleForBlock: file not found: " << filePath.toStdString()); + return false; + } + + std::unique_ptr reader (audioFileFormatManager.createReaderFor (file)); + if (reader == nullptr) + { + LOG_TO_FILE ("loadSampleForBlock: unsupported format: " << filePath.toStdString()); + return false; + } + + // Read audio, convert to mono + juce::AudioBuffer rawBuffer ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&rawBuffer, 0, (int) reader->lengthInSamples, 0, true, true); + + // Mix to mono + juce::AudioBuffer monoBuffer (1, (int) reader->lengthInSamples); + monoBuffer.clear(); + for (int ch = 0; ch < rawBuffer.getNumChannels(); ++ch) + monoBuffer.addFrom (0, 0, rawBuffer, ch, 0, rawBuffer.getNumSamples(), + 1.0f / (float) rawBuffer.getNumChannels()); + + // Generate waveform peaks for UI (~200 points) + int numPeaks = 200; + int samplesPerPeak = juce::jmax (1, (int) reader->lengthInSamples / numPeaks); + std::vector peaks; + peaks.reserve ((size_t) numPeaks); + const float* mono = monoBuffer.getReadPointer (0); + for (int p = 0; p < numPeaks && p * samplesPerPeak < monoBuffer.getNumSamples(); ++p) + { + float peak = 0.0f; + int start = p * samplesPerPeak; + int end = juce::jmin (start + samplesPerPeak, monoBuffer.getNumSamples()); + for (int s = start; s < end; ++s) + peak = juce::jmax (peak, std::abs (mono[s])); + peaks.push_back (peak); + } + + // Build SampleData + auto sd = std::make_shared(); + sd->buffer = std::move (monoBuffer); + sd->sampleRate = reader->sampleRate; + sd->filePath = filePath; + sd->fileName = file.getFileName(); + sd->waveformPeaks = std::move (peaks); + sd->durationSeconds = (float) reader->lengthInSamples / (float) reader->sampleRate; + + // Assign to the matching logic block + std::lock_guard lock (blockMutex); + for (auto& lb : logicBlocks) + { + if (lb.id == blockId) + { + lb.sampleData = sd; + lb.samplePlayhead = 0.0; + lb.sampleDirection = lb.sampleReverse ? -1 : 1; + LOG_TO_FILE ("loadSampleForBlock: loaded " << sd->fileName.toStdString() + << " (" << sd->durationSeconds << "s, " << sd->buffer.getNumSamples() << " samples)"); + return true; + } + } + + LOG_TO_FILE ("loadSampleForBlock: block ID " << blockId << " not found"); + return false; +} + +std::vector ModularRandomizerAudioProcessor::getSampleWaveform (int blockId) +{ + std::lock_guard lock (blockMutex); + for (const auto& lb : logicBlocks) + { + if (lb.id == blockId && lb.sampleData != nullptr) + return lb.sampleData->waveformPeaks; + } + return {}; +} + +juce::String ModularRandomizerAudioProcessor::getSampleFileName (int blockId) +{ + std::lock_guard lock (blockMutex); + for (const auto& lb : logicBlocks) + { + if (lb.id == blockId && lb.sampleData != nullptr) + return lb.sampleData->fileName; + } + return {}; +} + +//============================================================================== +juce::AudioProcessorEditor* ModularRandomizerAudioProcessor::createEditor() +{ + return new ModularRandomizerAudioProcessorEditor (*this); +} + +bool ModularRandomizerAudioProcessor::hasEditor() const +{ + return true; +} + +//============================================================================== +const juce::String ModularRandomizerAudioProcessor::getName() const { return JucePlugin_Name; } +bool ModularRandomizerAudioProcessor::acceptsMidi() const { return true; } +bool ModularRandomizerAudioProcessor::producesMidi() const { return false; } +bool ModularRandomizerAudioProcessor::isMidiEffect() const { return false; } +double ModularRandomizerAudioProcessor::getTailLengthSeconds() const { return 0.0; } +int ModularRandomizerAudioProcessor::getNumPrograms() { return 1; } +int ModularRandomizerAudioProcessor::getCurrentProgram() { return 0; } +void ModularRandomizerAudioProcessor::setCurrentProgram (int) {} +const juce::String ModularRandomizerAudioProcessor::getProgramName (int) { return {}; } +void ModularRandomizerAudioProcessor::changeProgramName (int, const juce::String&) {} + +//============================================================================== +void ModularRandomizerAudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + std::unique_ptr xml (state.createXml()); + + // Save hosted plugins (defensive: if a plugin crashes during param read, + // we still save the rest of the state) + try + { + std::lock_guard lock (pluginMutex); + auto* pluginsXml = xml->createNewChildElement ("HOSTED_PLUGINS"); + for (auto& hp : hostedPlugins) + { + if (hp->id < 0) continue; // skip dead entries + auto* plugEl = pluginsXml->createNewChildElement ("PLUGIN"); + plugEl->setAttribute ("id", hp->id); + plugEl->setAttribute ("name", hp->name); + plugEl->setAttribute ("path", hp->path); + plugEl->setAttribute ("busId", hp->busId); + + // Save all parameter values — wrapped per-plugin so one + // misbehaving VST3 doesn't prevent saving the others. + if (hp->instance) + { + try + { + auto& params = hp->instance->getParameters(); + auto* paramsEl = plugEl->createNewChildElement ("PARAMS"); + for (int i = 0; i < (int) params.size(); ++i) + { + auto* pEl = paramsEl->createNewChildElement ("P"); + pEl->setAttribute ("i", i); + pEl->setAttribute ("v", (double) params[i]->getValue()); + } + } + catch (...) + { + LOG_TO_FILE ("getState: exception reading params for plugin '" << hp->name.toStdString() << "'"); + } + } + } + pluginsXml->setAttribute ("nextId", nextPluginId); + pluginsXml->setAttribute ("routingMode", routingMode.load()); + } + catch (...) + { + LOG_TO_FILE ("getState: exception during plugin serialization"); + } + + // Save UI state (blocks, mappings, locks) + { + std::lock_guard lock (uiStateMutex); + if (uiStateJson.isNotEmpty()) + xml->createNewChildElement ("UI_STATE")->addTextElement (uiStateJson); + } + + copyXmlToBinary (*xml, destData); +} + +void ModularRandomizerAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xmlState (getXmlFromBinary (data, sizeInBytes)); + if (xmlState == nullptr) return; + + // Restore APVTS + if (xmlState->hasTagName (apvts.state.getType())) + apvts.replaceState (juce::ValueTree::fromXml (*xmlState)); + + // Restore hosted plugins + auto* pluginsXml = xmlState->getChildByName ("HOSTED_PLUGINS"); + if (pluginsXml != nullptr) + { + int restoredNextId = pluginsXml->getIntAttribute ("nextId", 0); + routingMode.store (pluginsXml->getIntAttribute ("routingMode", 0)); + + // Clear existing plugins safely — audio thread doesn't hold pluginMutex. + // Single lock scope: null instances + clear vector atomically so the audio + // thread never sees a partially-cleared vector. + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->instance && hp->prepared) + { +#ifdef _WIN32 + sehReleaseResources (hp->instance.get()); +#else + try { hp->instance->releaseResources(); } catch (...) {} +#endif + } +#ifdef _WIN32 + // SEH-guard the destructor: some plugins crash during teardown + if (hp->instance) + sehDestroyInstance (hp->instance.release()); +#else + hp->instance.reset(); // audio thread sees nullptr → skips +#endif + hp->prepared = false; + } + hostedPlugins.clear(); + hostedPlugins.reserve (32); // maintain pre-reserved capacity + } + + // Reset flat param tracking arrays + for (int s = 0; s < kMaxPlugins; ++s) + for (int p = 0; p < kMaxParams; ++p) + { + paramWritten[s][p] = -1.0f; + paramTouched[s][p].store (false, std::memory_order_relaxed); + } + initParamBase(); + numActiveGlides = 0; + + // Clear proxy mappings (unified pool: reset all fields) + for (int i = 0; i < proxyParamCount; ++i) + { + proxyMap[i].clear(); + if (proxyParams[i] != nullptr) + { + proxyParams[i]->setDynamicName (juce::String ("Slot ") + juce::String (i + 1)); + proxyParams[i]->clearDisplayInfo(); + } + proxyValueCache[i].store (-999.0f); + } + + // Reload each plugin from its saved path — wrapped per-plugin so + // one crashing VST3 doesn't prevent restoring the others. + for (auto* plugEl : pluginsXml->getChildIterator()) + { + if (plugEl->getTagName() != "PLUGIN") continue; + + int savedId = plugEl->getIntAttribute ("id", 0); + auto savedPath = plugEl->getStringAttribute ("path"); + + if (savedPath.isEmpty()) continue; + + try + { + // Load the plugin (this handles scanning + instantiation) + int newId = loadPlugin (savedPath); + if (newId < 0) + { + LOG_TO_FILE ("State restore: failed to reload plugin from " << savedPath.toStdString()); + continue; + } + + // Patch the plugin ID to match saved ID (preserves block target refs) + { + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == newId) + { + hp->id = savedId; + + // Restore parameter values + auto* paramsEl = plugEl->getChildByName ("PARAMS"); + if (paramsEl != nullptr && hp->instance) + { + auto& params = hp->instance->getParameters(); + for (auto* pEl : paramsEl->getChildIterator()) + { + int idx = pEl->getIntAttribute ("i", -1); + float val = (float) pEl->getDoubleAttribute ("v", 0.0); + if (idx >= 0 && idx < (int) params.size()) + params[idx]->setValue (val); + } + } + + // Patch proxy map entries to match new ID + for (int pi = 0; pi < proxyParamCount; ++pi) + { + if (proxyMap[pi].pluginId == newId) + proxyMap[pi].pluginId = savedId; + } + + // Restore bus ID for parallel routing + hp->busId = plugEl->getIntAttribute ("busId", 0); + + break; + } + } + } + } + catch (...) + { + LOG_TO_FILE ("State restore: EXCEPTION loading plugin '" << savedPath.toStdString() + << "' (id " << savedId << "). Skipping."); + } + } + + // Restore nextPluginId to avoid ID collisions + nextPluginId = juce::jmax (nextPluginId, restoredNextId); + } + + // Restore UI state + auto* uiStateXml = xmlState->getChildByName ("UI_STATE"); + if (uiStateXml != nullptr) + { + std::lock_guard lock (uiStateMutex); + uiStateJson = uiStateXml->getAllSubText().trim(); + + // ── Pre-populate EQ points from saved UI state so audio processing starts + // immediately without waiting for the WebView to load and call setEqCurve. ── + if (routingMode.load() == 2 && uiStateJson.isNotEmpty()) + { + auto parsed = juce::JSON::parse (uiStateJson); + if (auto* root = parsed.getDynamicObject()) + { + auto weqVar = root->getProperty ("wrongEq"); + if (auto* weq = weqVar.getDynamicObject()) + { + // Restore global params + float dbRange = weq->hasProperty ("dbRange") ? (float)(double) weq->getProperty ("dbRange") : 24.0f; + eqDbRange.store (juce::jlimit (6.0f, 48.0f, dbRange)); + if (weq->hasProperty ("depth")) + eqGlobalDepth.store (juce::jlimit (0.0f, 200.0f, (float)(double) weq->getProperty ("depth"))); + if (weq->hasProperty ("warp")) + eqGlobalWarp.store (juce::jlimit (-100.0f, 100.0f, (float)(double) weq->getProperty ("warp"))); + if (weq->hasProperty ("steps")) + eqGlobalSteps.store (juce::jlimit (0, 64, (int) weq->getProperty ("steps"))); + if (weq->hasProperty ("tilt")) + eqGlobalTilt.store (juce::jlimit (-100.0f, 100.0f, (float)(double) weq->getProperty ("tilt"))); + if (weq->hasProperty ("reso")) + eqGlobalReso.store (juce::jlimit (0.0f, 100.0f, (float)(double) weq->getProperty ("reso"))); + if (weq->hasProperty ("bypass")) + eqGlobalBypass.store ((bool) weq->getProperty ("bypass")); + + // Restore oversampling factor + if (weq->hasProperty ("oversample")) + { + int osf = juce::jlimit (1, 4, (int) weq->getProperty ("oversample")); + if (osf == 3) osf = 2; + eqOversampleFactor.store (osf); + // Oversampler will be configured in prepareToPlay + } + + auto ptsVar = weq->getProperty ("points"); + if (auto* ptsArr = ptsVar.getArray()) + { + float maxDB = eqDbRange.load(); + int n = juce::jmin ((int) ptsArr->size(), (int) maxEqBands); + + struct PtRestore { float hz; float db; int busId; bool solo; bool mute; float q; int ft; float drift; bool preEq; int stereoMode; int slope; }; + std::vector pts; + pts.reserve (n); + + for (int i = 0; i < n; ++i) + { + if (auto* pt = (*ptsArr)[i].getDynamicObject()) + { + // Convert normalized x (0-1 log scale) → freq Hz + float xNorm = (float)(double) pt->getProperty ("x"); + float hz = 20.0f * std::pow (1000.0f, juce::jlimit (0.0f, 1.0f, xNorm)); + + // Convert normalized y (0=top=+maxDB, 1=bottom=-maxDB) → dB + float yNorm = (float)(double) pt->getProperty ("y"); + float db = (-maxDB) + (1.0f - juce::jlimit (0.0f, 1.0f, yNorm)) * (maxDB * 2.0f); + + int ft = 0; // Bell default + auto typeStr = pt->getProperty ("type").toString().toLowerCase(); + if (typeStr == "lp") ft = 1; + else if (typeStr == "hp") ft = 2; + else if (typeStr == "notch") ft = 3; + else if (typeStr == "lshf") ft = 4; + else if (typeStr == "hshf") ft = 5; + + pts.push_back ({ + hz, db, + pt->hasProperty ("uid") ? (int) pt->getProperty ("uid") : (i + 1), + (bool) pt->getProperty ("solo"), + (bool) pt->getProperty ("mute"), + pt->hasProperty ("q") ? (float)(double) pt->getProperty ("q") : 0.707f, + ft, + pt->hasProperty ("drift") ? (float)(double) pt->getProperty ("drift") : 0.0f, + pt->hasProperty ("preEq") ? (bool) pt->getProperty ("preEq") : true, + pt->hasProperty ("stereoMode") ? (int) pt->getProperty ("stereoMode") : 0, + pt->hasProperty ("slope") ? (int) pt->getProperty ("slope") : 1 + }); + } + } + + // Sort by frequency (same as setEqCurve) + std::sort (pts.begin(), pts.end(), + [] (const PtRestore& a, const PtRestore& b) { return a.hz < b.hz; }); + + numEqPoints.store ((int) pts.size()); + for (int i = 0; i < (int) pts.size(); ++i) + { + eqPoints[i].freqHz.store (juce::jlimit (20.0f, 20000.0f, pts[i].hz)); + eqPoints[i].gainDB.store (juce::jlimit (-maxDB, maxDB, pts[i].db)); + eqPoints[i].busId.store (pts[i].busId); + eqPoints[i].solo.store (pts[i].solo); + eqPoints[i].mute.store (pts[i].mute); + eqPoints[i].q.store (juce::jlimit (0.025f, 40.0f, pts[i].q)); + eqPoints[i].filterType.store (pts[i].ft); + eqPoints[i].driftPct.store (juce::jlimit (0.0f, 100.0f, pts[i].drift)); + eqPoints[i].preEq.store (pts[i].preEq); + eqPoints[i].stereoMode.store (juce::jlimit (0, 2, pts[i].stereoMode)); + eqPoints[i].slope.store (juce::jlimit (1, 4, pts[i].slope)); + // Sorted order = identity (pts are already sorted by frequency) + eqSortOrder[i] = i; + } + for (int i = (int) pts.size(); i < maxEqBands; ++i) + { + eqPoints[i].busId.store (-1); + eqPoints[i].solo.store (false); + eqPoints[i].mute.store (false); + eqSortOrder[i] = -1; // clear unused sort order slots + } + eqDirty.store (true); + } + } + } + } + } +} + +//============================================================================== +void ModularRandomizerAudioProcessor::setPluginBypass (int pluginId, bool bypass) +{ + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId) + { + hp->bypassed = bypass; + break; + } + } +} + +void ModularRandomizerAudioProcessor::resetPluginCrash (int pluginId) +{ + std::lock_guard lock (pluginMutex); + for (auto& hp : hostedPlugins) + { + if (hp->id == pluginId && hp->crashed) + { + // Re-prepare the plugin before allowing it to process again + if (hp->instance != nullptr) + { + try + { + hp->instance->prepareToPlay (currentSampleRate, currentBlockSize); + hp->crashed = false; + hp->prepared = true; + LOG_TO_FILE ("resetPluginCrash: Re-enabled plugin '" + << hp->name.toStdString() << "' (ID: " << pluginId << ")"); + } + catch (...) + { + LOG_TO_FILE ("resetPluginCrash: Plugin '" + << hp->name.toStdString() << "' crashed again during prepareToPlay"); + // Leave crashed = true + } + } + break; + } + } +} + +//============================================================================== +// One-time migration: old flat structure → new organized structure +//============================================================================== +void ModularRandomizerAudioProcessor::migrateOldPresets() +{ + auto root = getDataRoot(); + auto marker = root.getChildFile (".migrated_v2"); + + if (marker.existsAsFile()) + return; // already migrated + + LOG_TO_FILE ("migrateOldPresets: starting one-time preset migration..."); + + // ── Migrate GlobalPresets/*.json → Chains/*.mrchain ── + auto oldGlobal = root.getChildFile ("GlobalPresets"); + if (oldGlobal.isDirectory()) + { + for (const auto& f : oldGlobal.findChildFiles (juce::File::findFiles, false, "*.json")) + { + auto dest = getChainsDir().getChildFile (f.getFileNameWithoutExtension() + ".mrchain"); + if (! dest.existsAsFile()) + { + f.copyFileTo (dest); + LOG_TO_FILE (" Migrated chain: " << f.getFileName().toStdString() + << " -> " << dest.getFileName().toStdString()); + } + } + } + + // ── Migrate Presets/{pluginName}/ → Snapshots/Unknown/{pluginName}/ ── + auto oldPresets = root.getChildFile ("Presets"); + if (oldPresets.isDirectory()) + { + for (const auto& dir : oldPresets.findChildFiles (juce::File::findDirectories, false)) + { + auto destDir = getSnapshotsDir() + .getChildFile ("Unknown") + .getChildFile (sanitizeForFilename (dir.getFileName())); + destDir.createDirectory(); + + for (const auto& f : dir.findChildFiles (juce::File::findFiles, false, "*.json")) + { + auto dest = destDir.getChildFile (f.getFileName()); + if (! dest.existsAsFile()) + { + f.copyFileTo (dest); + LOG_TO_FILE (" Migrated snapshot: " << dir.getFileName().toStdString() + << "/" << f.getFileName().toStdString()); + } + } + } + } + + // ── Generate README.txt ── + auto readme = root.getChildFile ("README.txt"); + if (! readme.existsAsFile()) + { + readme.replaceWithText ( + "ModularRandomizer Preset Library\n" + "================================\n\n" + "Chains/ - Complete plugin chains (.mrchain files)\n" + " Share these with other ModularRandomizer users!\n" + " Drop .mrchain files into Chains/_Import/ to import them.\n\n" + "Snapshots/ - Individual plugin presets, organized by manufacturer\n" + " e.g. Snapshots/Xfer Records/OTT/My Preset.json\n\n" + "PluginCache/ - VST3 scan cache (auto-generated, safe to delete)\n\n" + "This folder is shared across all plugin formats (VST3, AU, Standalone).\n" + ); + } + + marker.replaceWithText ("migrated"); + LOG_TO_FILE ("migrateOldPresets: migration complete."); +} + +//============================================================================== +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new ModularRandomizerAudioProcessor(); +} diff --git a/plugins/ModularRandomizer/Source/PluginProcessor.h b/plugins/ModularRandomizer/Source/PluginProcessor.h new file mode 100644 index 0000000..75afad2 --- /dev/null +++ b/plugins/ModularRandomizer/Source/PluginProcessor.h @@ -0,0 +1,1725 @@ +#pragma once + +#ifdef _WIN32 +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +// Debug logger — outputs to debugger console only, no disk I/O. +// Uses DBG in Debug builds; compiles to nothing in Release. +#ifdef JUCE_DEBUG + #define LOG_TO_FILE(msg) do { \ + std::ostringstream _oss; _oss << msg; \ + DBG (_oss.str()); \ + } while(0) +#else + #define LOG_TO_FILE(msg) do {} while(0) +#endif + +//============================================================================== +/** + * Modular Randomizer - Multi-Plugin Parameter Randomizer + * + * Hosts multiple VST3 plugins, exposes their parameters, + * and allows randomization via the WebView UI. + */ + +struct HostedPlugin +{ + int id = 0; + juce::String name; + juce::String path; + std::unique_ptr instance; + juce::PluginDescription description; + bool prepared = false; + bool bypassed = false; + bool crashed = false; // true if plugin threw/faulted during processBlock + bool isInstrument = false; // true if synth/sampler — buffer zeroed before processBlock + int crashCount = 0; // lifetime crash counter + int busId = 0; // parallel bus ID (0 = default/main) +}; + +/** SEH-guarded processBlock wrapper. + Isolated as a free function because __try/__except cannot coexist + with C++ objects that have destructors in the same function scope. */ +bool sehGuardedProcessBlock (juce::AudioPluginInstance* instance, + juce::AudioBuffer& buffer, + juce::MidiBuffer& midi); + +/** SEH-guarded releaseResources — catches hardware faults during plugin cleanup */ +bool sehReleaseResources (juce::AudioPluginInstance* instance); + +/** SEH-guarded instance destruction — catches hardware faults in plugin destructors. + Takes ownership of the raw pointer and deletes it. */ +bool sehDestroyInstance (juce::AudioPluginInstance* rawInstance); + +/** A parameter with a dynamically changeable display name and range. + Used for the proxy parameter pool so the DAW shows + meaningful names like "Vital: Cutoff" instead of "Slot 1". */ +class ProxyParameter : public juce::AudioParameterFloat +{ +public: + ProxyParameter (const juce::ParameterID& paramID, const juce::String& defaultName, + const juce::NormalisableRange& range, float defaultVal) + : juce::AudioParameterFloat (paramID, defaultName, range, defaultVal), + dynamicName (defaultName) {} + + juce::String getName (int maxLen) const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + return dynamicName.substring (0, maxLen); + } + + void setDynamicName (const juce::String& newName) + { + const juce::SpinLock::ScopedLockType sl (nameLock); + dynamicName = newName; + } + + /** Set discrete option labels — DAW shows these in automation dropdown */ + void setDiscreteOptions (const juce::StringArray& opts) + { + const juce::SpinLock::ScopedLockType sl (nameLock); + discreteOptions = opts; + numDiscreteSteps = opts.size(); + } + + /** Set float display suffix and range for text display */ + void setDisplayInfo (const juce::String& suffix, float dispMin, float dispMax) + { + const juce::SpinLock::ScopedLockType sl (nameLock); + displaySuffix = suffix; + displayMin = dispMin; + displayMax = dispMax; + numDiscreteSteps = 0; + } + + void clearDisplayInfo() + { + const juce::SpinLock::ScopedLockType sl (nameLock); + discreteOptions.clear(); + displaySuffix = ""; + numDiscreteSteps = 0; + } + + juce::String getText (float normValue, int /*maxLen*/) const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + if (numDiscreteSteps > 0 && discreteOptions.size() > 0) + { + int idx = juce::jlimit (0, discreteOptions.size() - 1, + (int) std::round (normValue * (discreteOptions.size() - 1))); + return discreteOptions[idx]; + } + if (displaySuffix.isNotEmpty()) + { + float val = displayMin + normValue * (displayMax - displayMin); + return juce::String (val, 1) + displaySuffix; + } + return juce::String (normValue, 3); + } + + float getValueForText (const juce::String& text) const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + if (numDiscreteSteps > 0 && discreteOptions.size() > 0) + { + int idx = discreteOptions.indexOf (text); + if (idx >= 0) return (float) idx / (float) (discreteOptions.size() - 1); + } + return text.getFloatValue(); + } + + int getNumSteps() const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + return numDiscreteSteps > 0 ? numDiscreteSteps : 0x7fffffff; + } + + bool isDiscrete() const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + return numDiscreteSteps > 0; + } + + bool isBoolean() const override + { + const juce::SpinLock::ScopedLockType sl (nameLock); + return numDiscreteSteps == 2; + } + +private: + mutable juce::SpinLock nameLock; + juce::String dynamicName; + juce::StringArray discreteOptions; + juce::String displaySuffix; + float displayMin = 0.0f; + float displayMax = 1.0f; + int numDiscreteSteps = 0; +}; + +struct ScannedPlugin +{ + juce::String name; + juce::String vendor; + juce::String category; + juce::String path; + juce::String format; + int numParams = 0; +}; + +/** Audio sample data loaded for Sample Modulator blocks. + Shared via shared_ptr so audio thread can hold a reference + while message thread replaces it. */ +struct SampleData +{ + juce::AudioBuffer buffer; // mono, at original sample rate + double sampleRate = 44100.0; + juce::String filePath; + juce::String fileName; + std::vector waveformPeaks; // downsampled peak values for UI (~200 points) + float durationSeconds = 0.0f; +}; + +class ModularRandomizerAudioProcessor : public juce::AudioProcessor, + public juce::AudioProcessorValueTreeState::Listener, + public juce::AudioProcessorParameter::Listener +{ +public: + //============================================================================== + ModularRandomizerAudioProcessor(); + ~ModularRandomizerAudioProcessor() override; + + //============================================================================== + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + #ifndef JucePlugin_PreferredChannelConfigurations + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + #endif + + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + //============================================================================== + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + //============================================================================== + const juce::String getName() const override; + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + //============================================================================== + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int index) override; + const juce::String getProgramName (int index) override; + void changeProgramName (int index, const juce::String& newName) override; + + //============================================================================== + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + //============================================================================== + juce::AudioProcessorValueTreeState& getAPVTS() { return apvts; } + + //============================================================================== + // Plugin Hosting API (called from Editor via native functions) + //============================================================================== + + /** Scan VST3 directories and return found plugins */ + std::vector scanForPlugins (const juce::StringArray& paths); + + /** Delete the plugin scan cache — forces a full rescan on next call */ + void clearPluginCache(); + + /** Get current scan progress for UI feedback */ + struct ScanProgress { juce::String currentPlugin; float progress; bool scanning; }; + ScanProgress getScanProgress(); + + // Scan progress state (written by scan thread, read by UI via getScanProgress) + std::mutex scanProgressMutex; + juce::String scanProgressName; + std::atomic scanProgressFraction { 0.0f }; + std::atomic scanActive { false }; + + /** Load a VST3 plugin by file path, returns the hosted plugin ID or -1 */ + int loadPlugin (const juce::String& pluginPath); + + /** Phase 1: Find plugin description from cache or disk scan (thread-safe) */ + bool findPluginDescription (const juce::String& pluginPath, + juce::PluginDescription& descOut); + + /** Phase 2: Instantiate from description (message thread only — COM) */ + int instantiatePlugin (const juce::PluginDescription& desc); + + /** Remove a hosted plugin by ID */ + void removePlugin (int pluginId); + + /** Garbage-collect dead plugin entries (call from message thread only) */ + void purgeDeadPlugins(); + + /** Reorder hosted plugins to match the given ID order from the UI */ + void reorderPlugins (const std::vector& orderedIds); + + /** Get parameter info for a hosted plugin */ + struct ParamInfo { + int index; + juce::String name; + float value; // normalised 0-1 + juce::String label; + juce::String displayText; // formatted value from plugin (e.g. "440 Hz", "50%") + bool automatable; + }; + std::vector getHostedParams (int pluginId); + + /** Set a parameter on a hosted plugin (instant, for UI knob turns) */ + void setHostedParam (int pluginId, int paramIndex, float normValue); + + /** Start a smooth parameter glide (called from JS, processed per-buffer in processBlock) */ + void startGlide (int pluginId, int paramIndex, float targetValue, float durationMs); + + /** Update logic blocks from UI JSON (called from Editor native function) */ + void updateLogicBlocks (const juce::String& jsonData); + + /** Lightweight morph playhead update (called from drag — avoids full JSON reparse) */ + void updateMorphPlayhead (int blockId, float x, float y); + + /** Fire a manual oneshot trigger on a specific lane */ + void fireLaneTrigger (int blockId, int laneIdx); + + /** Randomize specific parameters on a hosted plugin */ + void randomizeParams (int pluginId, const std::vector& paramIndices, + float minVal, float maxVal); + + /** Bypass or unbypass a hosted plugin (audio thread safe) */ + void setPluginBypass (int pluginId, bool bypass); + + /** Reset a crashed plugin so it can process again */ + void resetPluginCrash (int pluginId); + + /** Get the list of currently hosted plugins (for UI) */ + struct HostedPluginInfo { + int id; + juce::String name; + juce::String path; + juce::String manufacturer; + int numParams; + int busId; + bool isInstrument; + }; + std::vector getHostedPluginList(); + + /** Get a raw pointer to a hosted plugin instance (for opening its editor) */ + juce::AudioPluginInstance* getHostedPluginInstance (int pluginId); + + /** Get factory preset (program) names from a hosted plugin */ + struct FactoryPresetInfo { int index; juce::String name; juce::String filePath; }; + std::vector getFactoryPresets (int pluginId); + + /** Load a factory preset by program index, returns all param values after switch */ + std::vector loadFactoryPreset (int pluginId, int programIndex); + + /** Load a factory preset from a .vstpreset file, returns all param values after load */ + std::vector loadFactoryPresetFromFile (int pluginId, const juce::String& filePath); + + /** Preset indexing — scan all VST3 preset directories once, cache to disk */ + void buildPresetIndex(); + std::vector getIndexedPresets (const juce::String& pluginName, const juce::String& vendorName); + bool isPresetIndexReady() const { return presetIndexReady.load(); } + + // Preset index internals + std::map> presetIndex; + std::mutex presetIndexMutex; + std::atomic presetIndexReady { false }; + juce::File getPresetIndexFile() const; + void savePresetIndexToFile(); + bool loadPresetIndexFromFile(); + + /** Set the parallel bus ID for a hosted plugin */ + void setPluginBusId (int pluginId, int busId); + + /** Routing mode: 0=sequential, 1=parallel, 2=wrongeq */ + int getRoutingMode() const { return routingMode.load(); } + void setRoutingMode (int mode) { routingMode.store (juce::jlimit (0, 2, mode)); } + + /** WrongEQ: receive EQ curve data from JS */ + void setEqCurve (const juce::String& jsonData); + + void setBusVolume (int bus, float vol); + void setBusMute (int bus, bool m); + void setBusSolo (int bus, bool s); + + //============================================================================== + // File System Helpers — organized preset directory structure + //============================================================================== + + /** Root data directory: %APPDATA%/Noizefield/ModularRandomizer (Win) + ~/Library/Noizefield/ModularRandomizer (Mac) + ~/.local/share/Noizefield/ModularRandomizer (Linux, future) */ + static juce::File getDataRoot() + { + return juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) + .getChildFile ("Noizefield/ModularRandomizer"); + } + + static juce::File getChainsDir() { return getDataRoot().getChildFile ("Chains"); } + static juce::File getSnapshotsDir() { return getDataRoot().getChildFile ("Snapshots"); } + static juce::File getEqPresetsDir() { return getDataRoot().getChildFile ("EqPresets"); } + static juce::File getImportDir() { return getChainsDir().getChildFile ("_Import"); } + + /** Strip characters that are invalid in file/folder names on any OS */ + static juce::String sanitizeForFilename (const juce::String& name) + { + return name.removeCharacters ("\\/:*?\"<>|") + .trimStart().trimEnd() + .substring (0, 100); + } + + /** One-time migration from old flat structure to new organized structure */ + void migrateOldPresets(); + + /** Platform-appropriate default VST3 scan directories */ + static juce::StringArray getDefaultScanPaths() + { + juce::StringArray paths; +#if JUCE_MAC + paths.add ("/Library/Audio/Plug-Ins/VST3"); + paths.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("Library/Audio/Plug-Ins/VST3").getFullPathName()); +#elif JUCE_WINDOWS + paths.add ("C:\\Program Files\\Common Files\\VST3"); + paths.add ("C:\\Program Files\\VSTPlugins"); +#elif JUCE_LINUX + paths.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile (".vst3").getFullPathName()); + paths.add ("/usr/lib/vst3"); + paths.add ("/usr/local/lib/vst3"); +#endif + return paths; + } + + /** UI state persistence — blocks, mappings, locks, order stored as JSON */ + void setUiState (const juce::String& json); + juce::String getUiState() const; + + //============================================================================== + // Sample Modulator API + //============================================================================== + + /** Load an audio file for a specific logic block. Returns true on success. */ + bool loadSampleForBlock (int blockId, const juce::String& filePath); + + /** Get downsampled waveform peaks for UI display. */ + std::vector getSampleWaveform (int blockId); + + /** Get the sample file name for a block (empty if none loaded). */ + juce::String getSampleFileName (int blockId); + +private: + //============================================================================== + juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + juce::AudioProcessorValueTreeState apvts; + + //============================================================================== + // Plugin Hosting Engine + //============================================================================== + juce::AudioPluginFormatManager formatManager; + juce::KnownPluginList knownPlugins; + juce::AudioFormatManager audioFileFormatManager; // WAV, AIFF, FLAC, etc. + + std::vector> hostedPlugins; + std::mutex pluginMutex; + int nextPluginId = 0; + + // Routing mode: 0 = sequential (chain), 1 = parallel (split/sum), 2 = wrongeq (band-split) + std::atomic routingMode { 0 }; + static constexpr int maxBuses = 8; + juce::AudioBuffer busBuffers[maxBuses]; // pre-allocated in prepareToPlay + + // Per-bus mixer state (parallel mode) + std::atomic busVolume[maxBuses] { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f }; + std::atomic busMute[maxBuses] { false, false, false, false, false, false, false, false }; + std::atomic busSolo[maxBuses] { false, false, false, false, false, false, false, false }; + + double currentSampleRate = 44100.0; + int currentBlockSize = 512; + + // Pre-allocated scratch buffers (sized in prepareToPlay, never allocate on audio thread) + juce::AudioBuffer dryBuffer; // dry signal copy for wet/dry mix + juce::AudioBuffer synthAccum; // accumulates layered synth outputs (sequential mode) + struct MidiTrigEvent { int note; int vel; int ch; bool isCC; }; + std::vector blockMidiEvents; // MIDI events for trigger matching + static constexpr int kMaxBlockMidi = 128; + + // ── WrongEQ: band-split DSP ── + // Crossover filter bank: 2 crossovers per EQ point (lo/hi Q edges). + // Each bell creates an exact Q-width band. Gap bands between bells are passthrough. + // N points → 2N crossovers → 2N+1 bands. + // Allpass compensation: each (crossover, lower-band) pair has its OWN allpass filter + // to maintain phase coherence across all bands (mastering-grade transparency). + static constexpr int maxEqBands = 8; // max user EQ points + static constexpr int maxCrossovers = maxEqBands * 2; // 2 crossovers per point (lo + hi Q edge) + static constexpr int maxXoverBands = maxCrossovers + 1; // 2N+1 bands + static constexpr int kWeqPluginId = -100; // matches JS WEQ_VIRTUAL_ID — used as pluginId for WrongEQ params + static constexpr int kWeqGlobalBase = 100; // Global params start at index 100 (matches JS cppIndex offsets) + static constexpr int kWeqGlobalCount = 11; // depth(100)..lfoDep(110) + + // ── SVF-based LR4 crossover filter ── + // Linkwitz-Riley 4th order = two cascaded Butterworth 2nd-order SVFs (Q = 1/√2). + // Per-sample coefficient interpolation eliminates thumps on frequency changes. + struct SVFLR4 { + // Single SVF stage (2nd-order Butterworth, Q = 1/√2 → k = √2) + struct SVFStage { + float ic1eq = 0.0f, ic2eq = 0.0f; + float g = 0.0f, a1c = 1.0f, a2c = 0.0f, a3c = 0.0f; + float dg = 0.0f, da1c = 0.0f, da2c = 0.0f, da3c = 0.0f; + float tgt_g = 0.0f, tgt_a1c = 1.0f, tgt_a2c = 0.0f, tgt_a3c = 0.0f; + bool paramsSet = false; + + inline void setTarget (float freqHz, float sampleRate, int nSamples) + { + static constexpr float kBW = 1.41421356237f; + tgt_g = std::tan (juce::MathConstants::pi * freqHz / sampleRate); + tgt_a1c = 1.0f / (1.0f + tgt_g * (tgt_g + kBW)); + tgt_a2c = tgt_g * tgt_a1c; + tgt_a3c = tgt_g * tgt_a2c; + if (! paramsSet) { + g = tgt_g; a1c = tgt_a1c; a2c = tgt_a2c; a3c = tgt_a3c; + dg = da1c = da2c = da3c = 0.0f; + paramsSet = true; + } else { + float inv = 1.0f / (float) juce::jmax (1, nSamples); + dg = (tgt_g - g) * inv; + da1c = (tgt_a1c - a1c) * inv; + da2c = (tgt_a2c - a2c) * inv; + da3c = (tgt_a3c - a3c) * inv; + } + } + inline void step() { g += dg; a1c += da1c; a2c += da2c; a3c += da3c; } + inline void snapToTarget() { g = tgt_g; a1c = tgt_a1c; a2c = tgt_a2c; a3c = tgt_a3c; } + + // Process one sample, returns LP output. HP = in - k*bp - lp + inline float tickLP (float in) + { + float v3 = in - ic2eq; + float v1 = a1c * ic1eq + a2c * v3; + float v2 = ic2eq + a2c * ic1eq + a3c * v3; + ic1eq = 2.0f * v1 - ic1eq; + ic2eq = 2.0f * v2 - ic2eq; + if (std::abs(ic1eq) < 1e-20f) ic1eq = 0.0f; + if (std::abs(ic2eq) < 1e-20f) ic2eq = 0.0f; + return v2; // lowpass + } + inline float tickHP (float in) + { + static constexpr float kBW = 1.41421356237f; + float v3 = in - ic2eq; + float v1 = a1c * ic1eq + a2c * v3; + float v2 = ic2eq + a2c * ic1eq + a3c * v3; + ic1eq = 2.0f * v1 - ic1eq; + ic2eq = 2.0f * v2 - ic2eq; + if (std::abs(ic1eq) < 1e-20f) ic1eq = 0.0f; + if (std::abs(ic2eq) < 1e-20f) ic2eq = 0.0f; + return in - kBW * v1 - v2; // highpass + } + void reset() { ic1eq = ic2eq = 0.0f; paramsSet = false; } + }; + + // 4 stages: 2 for LP cascade, 2 for HP cascade + SVFStage lp1, lp2, hp1, hp2; + + inline void setTarget (float freqHz, float sampleRate, int nSamples) { + lp1.setTarget(freqHz, sampleRate, nSamples); + lp2.setTarget(freqHz, sampleRate, nSamples); + hp1.setTarget(freqHz, sampleRate, nSamples); + hp2.setTarget(freqHz, sampleRate, nSamples); + } + inline void step() { lp1.step(); lp2.step(); hp1.step(); hp2.step(); } + inline void snapToTarget() { lp1.snapToTarget(); lp2.snapToTarget(); hp1.snapToTarget(); hp2.snapToTarget(); } + + // Process one sample: LR4 LP = LP2(LP1(x)), LR4 HP = HP2(HP1(x)) + inline void tick (float in, float& lp_out, float& hp_out) + { + lp_out = lp2.tickLP(lp1.tickLP(in)); + hp_out = hp2.tickHP(hp1.tickHP(in)); + } + + // Allpass output = LP + HP (LR4 property) + inline float tickAllpass (float in) + { + float lp, hp; + tick(in, lp, hp); + return lp + hp; + } + + void reset() { lp1.reset(); lp2.reset(); hp1.reset(); hp2.reset(); } + }; + + // Crossover band: one LR4 pair per crossover point, per channel + struct CrossoverBand { + SVFLR4 filters[2]; // per-channel (stereo max) + float cutoffHz = 1000.0f; + float targetCutoffHz = 1000.0f; + bool active = false; + + void prepare (float sampleRate, int nSamples) + { + cutoffHz = targetCutoffHz; + for (int ch = 0; ch < 2; ++ch) + filters[ch].setTarget(cutoffHz, sampleRate, nSamples); + } + void reset() { for (int ch = 0; ch < 2; ++ch) filters[ch].reset(); } + }; + + CrossoverBand crossovers[maxCrossovers]; + // Per-(crossover, lower-band) allpass filters for phase compensation — per channel + SVFLR4 allpassComp[maxCrossovers][maxCrossovers][2]; // [xover][lower_band][channel] + juce::AudioBuffer eqBandBuffers[maxXoverBands]; + int numEqBands = 0; + float eqBandGain[maxXoverBands]; + // TPT State Variable Filter (Cytomic / Zavalishin topology). + // Immune to zipper noise: integrator state remains valid across parameter changes. + // Parameters (freq, gain, Q) can change every sample with zero artifacts. + static constexpr int maxEqChannels = 2; + + struct SVFEqFilter { + float ic1eq = 0.0f, ic2eq = 0.0f; // integrator states + + // Current interpolated coefficients (advanced per sample via deltas) + float g = 0.0f, k = 1.0f, a1c = 1.0f, a2c = 0.0f, a3c = 0.0f; + float A = 1.0f; + int cachedType = 0; + + // Target coefficients (computed once per buffer from target params) + float tgt_g = 0.0f, tgt_k = 1.0f, tgt_a1c = 1.0f, tgt_a2c = 0.0f, tgt_a3c = 0.0f; + float tgt_A = 1.0f; + + // Per-sample deltas for linear interpolation + float dg = 0.0f, dk = 0.0f, da1c = 0.0f, da2c = 0.0f, da3c = 0.0f, dA = 0.0f; + + bool paramsSet = false; // false until first setTarget call + + // Compute TARGET coefficients from parameters. Call once per buffer. + // Does NOT update current coefficients — those advance per-sample via step(). + inline void setTarget (float freqHz, float gainDB, float Q, int filterType, float sampleRate, int nSamples) + { + cachedType = filterType; + tgt_g = std::tan (juce::MathConstants::pi * freqHz / sampleRate); + tgt_A = std::pow (10.0f, gainDB / 40.0f); + tgt_k = 1.0f / Q; // constant-Q for all types + tgt_a1c = 1.0f / (1.0f + tgt_g * (tgt_g + tgt_k)); + tgt_a2c = tgt_g * tgt_a1c; + tgt_a3c = tgt_g * tgt_a2c; + + if (! paramsSet) + { + // First call: snap current to target (no interpolation) + g = tgt_g; k = tgt_k; a1c = tgt_a1c; a2c = tgt_a2c; a3c = tgt_a3c; A = tgt_A; + dg = dk = da1c = da2c = da3c = dA = 0.0f; + paramsSet = true; + } + else + { + // Compute per-sample deltas for linear interpolation + float inv = 1.0f / (float) juce::jmax (1, nSamples); + dg = (tgt_g - g) * inv; + dk = (tgt_k - k) * inv; + da1c = (tgt_a1c - a1c) * inv; + da2c = (tgt_a2c - a2c) * inv; + da3c = (tgt_a3c - a3c) * inv; + dA = (tgt_A - A) * inv; + } + } + + // Advance coefficients by one sample (linear interpolation step) + inline void step() + { + g += dg; + k += dk; + a1c += da1c; + a2c += da2c; + a3c += da3c; + A += dA; + } + + // Process one sample using current (interpolated) coefficients. No transcendentals. + inline float tick (float in) + { + float v3 = in - ic2eq; + float v1 = a1c * ic1eq + a2c * v3; + float v2 = ic2eq + a2c * ic1eq + a3c * v3; + ic1eq = 2.0f * v1 - ic1eq; + ic2eq = 2.0f * v2 - ic2eq; + + // Flush denormals + if (std::abs (ic1eq) < 1.0e-20f) ic1eq = 0.0f; + if (std::abs (ic2eq) < 1.0e-20f) ic2eq = 0.0f; + + if (std::isnan (ic1eq) || std::isnan (ic2eq) || + std::isinf (ic1eq) || std::isinf (ic2eq)) + { + ic1eq = ic2eq = 0.0f; + return in; + } + + float lp = v2; + float bp = v1; + float hp = in - k * bp - lp; + float invA = (A > 0.001f) ? (1.0f / A) : 0.0f; + float gainMix = A - invA; // constant-Q bell: (A - 1/A) + + switch (cachedType) + { + case 0: return in + gainMix * k * bp; // Bell (constant-Q) + case 1: return lp; // Low-pass + case 2: return hp; // High-pass + case 3: return lp + hp; // Notch + case 4: return in + gainMix * lp; // Low shelf + case 5: return in + gainMix * hp; // High shelf + default: return in + gainMix * k * bp; + } + } + + // Snap current coefficients to target (call at end of buffer to prevent drift) + inline void snapToTarget() + { + g = tgt_g; k = tgt_k; a1c = tgt_a1c; a2c = tgt_a2c; a3c = tgt_a3c; A = tgt_A; + } + + void reset() { ic1eq = ic2eq = 0.0f; paramsSet = false; } + }; + + // Up to 4 cascaded SVF stages per band per channel (for 12/24/48 dB/oct slopes). + static constexpr int maxBiquadStages = 4; + SVFEqFilter eqBiquads[maxEqBands][maxBiquadStages][maxEqChannels]; + bool eqBiquadActive[maxEqBands] {}; + + // Per-point previous parameter values for linear interpolation across each buffer. + // The SVF is updated per-sample with interpolated params → zero staircase even at + // low buffer rates. On first use or after reset, these snap to the target. + float eqPrevFreq[maxEqBands]; + float eqPrevGain[maxEqBands]; + float eqPrevQ[maxEqBands]; + bool eqPrevValid[maxEqBands]; // false until first processBlock initialises + + // EQ point data: frequency + busId for routing + struct EqPointData { + std::atomic freqHz { 1000.0f }; + std::atomic busId { -1 }; // which bus processes this band (-1 = passthrough) + std::atomic gainDB { 0.0f }; + std::atomic solo { false }; // audition this band only + std::atomic mute { false }; // silence this band + std::atomic q { 0.707f }; // Q factor (resonance) + std::atomic filterType { 0 }; // 0=Bell, 1=LP, 2=HP, 3=Notch, 4=LShelf, 5=HShelf + std::atomic driftPct { 0.0f }; // frequency drift amount 0-100% + std::atomic preEq { true }; // true = apply EQ before plugins, false = split-only + std::atomic stereoMode { 0 }; // 0=Stereo(LR), 1=Mid, 2=Side + std::atomic slope { 1 }; // biquad stages: 1=12dB/oct, 2=24dB/oct, 4=48dB/oct + + // Modulation offsets (additive, from setEqParam / proxy params). + // Applied ON TOP of base values during audio processing. + // This separates JS drift (which writes base values) from C++ modulation. + std::atomic modFreqHz { 0.0f }; // offset in Hz (added to freqHz) + std::atomic modGainDB { 0.0f }; // offset in dB (added to gainDB) + std::atomic modQ { 0.0f }; // offset (added to q) + std::atomic modActive { false }; // true = modulation is applied + }; + EqPointData eqPoints[maxEqBands]; + std::atomic numEqPoints { 0 }; + std::atomic eqDirty { false }; // set by setEqCurve, consumed by processBlock to reconfigure filters + std::atomic eqSortOrder[maxEqBands]; // maps sorted position → original JS index for crossover + + + // Dynamic dB range: parsed from JS (6, 12, 18, 24, 36, 48). Used for gain clamping. + std::atomic eqDbRange { 24.0f }; + // Global depth: 0-200%, scales all EQ gains. 100% = full effect, 0% = no EQ. + std::atomic eqGlobalDepth { 100.0f }; + // Global warp: -100 to +100. +warp = S-curve contrast (tanh), -warp = expand (power curve) + std::atomic eqGlobalWarp { 0.0f }; + // Global steps: 0 = continuous, ≥2 = quantize gain to N steps across ±dBrange + std::atomic eqGlobalSteps { 0 }; + // Global tilt: -100 to +100. Applies a frequency-dependent gain tilt. + // +tilt boosts highs / cuts lows, -tilt boosts lows / cuts highs. Pivot at geometric center. + std::atomic eqGlobalTilt { 0.0f }; + // Post-EQ tilt filter state: 1st-order LP/HP split at 632Hz + // Applied AFTER all EQ biquads — tilts the whole combined curve uniformly (matching JS visual). + float tiltLpState[2] = { 0.0f, 0.0f }; // per-channel 1st-order LP state + float tiltGainLowCur[2] = { 1.0f, 1.0f }; // smoothed low-band gain (per channel) + float tiltGainHighCur[2] = { 1.0f, 1.0f }; // smoothed high-band gain (per channel) + + // Global reso: 0 to 100. Multiplies all band Q values for resonant character. + // 0 = no boost (Q stays as set), 100 = Q boosted by up to 8x. + std::atomic eqGlobalReso { 0.0f }; + + // Global WrongEQ flags + std::atomic eqGlobalBypass { false }; // true = bypass all EQ processing (dry signal) + // Note: eqPreEq is DEPRECATED — now per-point (eqPoints[i].preEq). Kept for backward compat. + std::atomic eqPreEq { true }; + // Unassigned plugin mode: 0 = bypassed (skip), 1 = global (post-EQ insert on summed signal) + std::atomic eqUnassignedMode { 0 }; + // Split mode: when true, crossovers use point frequency directly (no Q-based bandwidth). + // Each point = 1 crossover, giving N+1 clean frequency bands at the visible divider positions. + std::atomic eqSplitMode { false }; + + // Oversampling for EQ biquad processing (reduces frequency cramping near Nyquist). + // Factor: 1 = off, 2 = 2× oversampling, 4 = 4× oversampling. + std::atomic eqOversampleFactor { 1 }; + std::unique_ptr> eqOversampler; + int eqOversampleOrder = 0; // current oversampler order (0=1x, 1=2x, 2=4x) + bool eqOversamplerReady = false; + +public: + /** Batch param apply — sets multiple params in a single call (avoids N IPC round-trips). + Called from message thread. */ + void applyParamBatch (const juce::String& jsonBatch); + +private: + +public: + // ── WrongEQ readback for editor timer ── + struct WeqReadbackPoint { + float freqHz, gainDB, q, driftPct; + }; + int getWeqReadback (WeqReadbackPoint* out, int maxPts) const + { + int n = numEqPoints.load (std::memory_order_relaxed); + if (n > maxPts) n = maxPts; + if (n > maxEqBands) n = maxEqBands; + for (int i = 0; i < n; ++i) + { + out[i].freqHz = eqPoints[i].freqHz.load (std::memory_order_relaxed); + out[i].gainDB = eqPoints[i].gainDB.load (std::memory_order_relaxed); + out[i].q = eqPoints[i].q.load (std::memory_order_relaxed); + out[i].driftPct = eqPoints[i].driftPct.load (std::memory_order_relaxed); + } + return n; + } + + struct WeqGlobalReadback { + float depth, warp, tilt; + int steps; + }; + WeqGlobalReadback getWeqGlobals() const + { + return { + eqGlobalDepth.load (std::memory_order_relaxed), + eqGlobalWarp.load (std::memory_order_relaxed), + eqGlobalTilt.load (std::memory_order_relaxed), + eqGlobalSteps.load (std::memory_order_relaxed) + }; + } + + //============================================================================== + // Unified Proxy Parameter Pool — single pool for both block + plugin params + // Block params assigned first → top of DAW list, plugin params fill after + //============================================================================== + static constexpr int proxyParamCount = 2048; + + /** Drain proxy value cache from audio thread and call setValueNotifyingHost. + Must be called from the message thread (editor timer). */ + void syncProxyCacheToHost(); + + /** Drain block proxy cache — returns pending DAW-driven block param updates */ + struct BlockParamUpdate { int blockId; juce::String paramKey; float value; }; + std::vector drainBlockProxyCache(); + + /** Update expose state from JS: selectively assign/free proxy slots based on user preferences. + JSON format: { plugins: { id: { exposed, excluded: [...] } }, blocks: { ... } } */ + void updateExposeState (const juce::String& jsonData); + +private: + + struct ProxyMapping { + int pluginId = -1; // >= 0 = hosted plugin param + int paramIndex = -1; + int blockId = -1; // >= 0 = logic block param + juce::String blockParamKey; // e.g. "shapeDepth", "lane.0.depth" + + bool isFree() const { return pluginId < 0 && blockId < 0; } + bool isBlock() const { return blockId >= 0; } + bool isPlugin() const { return pluginId >= 0; } + void clear() { pluginId = -1; paramIndex = -1; blockId = -1; blockParamKey = ""; } + }; + + ProxyMapping proxyMap[proxyParamCount]; + ProxyParameter* proxyParams[proxyParamCount] {}; // raw ptrs (owned by APVTS) + std::atomic proxySyncActive { false }; // prevents feedback loops + int proxySyncCounter = 0; // per-instance throttle counter + + /** Proxy value cache: audio thread writes, message thread reads + calls setValueNotifyingHost. + Sentinel -999.0f = not yet written / no update pending. */ + std::atomic proxyValueCache[proxyParamCount]; + std::atomic proxyDirty { false }; // true when cache has pending updates + + /** Block proxy dirty flag — set when DAW automates a block param slot */ + std::atomic blockProxyDirty { false }; + + /** Assign proxy slots when a plugin is loaded, free them on remove */ + void assignProxySlotsForPlugin (int pluginId); + void freeProxySlotsForPlugin (int pluginId); + + /** Called by APVTS listener when a proxy param is automated from the DAW */ + void parameterChanged (const juce::String& parameterID, float newValue) override; + + //============================================================================== + // UI State Persistence + //============================================================================== + mutable std::mutex uiStateMutex; + juce::String uiStateJson; // Full UI state as JSON (blocks, mappings, locks) + + //============================================================================== + // Glide Engine — per-buffer parameter interpolation + //============================================================================== + + /** Lock-free command FIFO: message thread writes, audio thread reads */ + struct GlideCommand { + int pluginId = 0; + int paramIndex = 0; + float targetVal = 0.0f; + float durationMs = 0.0f; + }; + static constexpr int glideRingSize = 512; + GlideCommand glideRing[glideRingSize]; + juce::AbstractFifo glideFifo { glideRingSize }; + + /** Active glides being interpolated — only accessed on audio thread. + Fixed-size pool: swap-to-end removal, no heap allocations. */ + struct ActiveGlide { + int pluginId = 0; + int paramIndex = 0; + float currentVal = 0.0f; + float targetVal = 0.0f; + float increment = 0.0f; // per-sample linear increment + int samplesLeft = 0; + }; + static constexpr int kMaxGlides = 256; + std::array glidePool; + int numActiveGlides = 0; // only modified on audio thread + + //============================================================================== + // Logic Block Engine — triggers, randomization, envelope followers + // Runs in processBlock so it works even when the editor window is closed. + //============================================================================== + + // ── Enum types for zero-alloc comparison in processBlock (H4 fix) ── + enum class BlockMode : uint8_t { Randomize, Envelope, Sample, MorphPad, Shapes, ShapesRange, Lane, Unknown }; + enum class TriggerType : uint8_t { Manual, Tempo, Midi, Audio }; + enum class MidiTrigMode : uint8_t { AnyNote, SpecificNote, CC }; + enum class AudioSource : uint8_t { Main, Sidechain }; + enum class RangeMode : uint8_t { Absolute, Relative }; + enum class Movement : uint8_t { Instant, Glide }; + enum class Polarity : uint8_t { Bipolar, Up, Down, Unipolar }; + enum class ClockSource : uint8_t { Daw, Internal }; + enum class LoopMode : uint8_t { Oneshot, Loop, Pingpong }; + enum class JumpMode : uint8_t { Restart, Random }; + enum class MorphMode : uint8_t { Manual, Auto, Trigger }; + enum class ExploreMode : uint8_t { Wander, Bounce, Shapes, Orbit, Path }; + enum class LfoShape : uint8_t { Circle, Figure8, SweepX, SweepY, Triangle, Square, Hexagon, Pentagram, Hexagram, Rose4, Lissajous, Spiral, Cat, Butterfly, InfinityKnot }; + enum class MorphAction : uint8_t { Jump, Step }; + enum class StepOrder : uint8_t { Cycle, Random }; + enum class ShapeTracking : uint8_t { Horizontal, Vertical, Distance }; + enum class ShapeTrigger : uint8_t { Free, Midi }; + enum class LaneInterp : uint8_t { Smooth, Step, Linear }; + enum class LanePlayMode : uint8_t { Forward, Reverse, Pingpong, Random }; + + // ── Enum parsers (called once in updateLogicBlocks, message thread) ── + static BlockMode parseBlockMode (const juce::String& s); + static TriggerType parseTriggerType (const juce::String& s); + static MidiTrigMode parseMidiTrigMode (const juce::String& s); + static AudioSource parseAudioSource (const juce::String& s); + static RangeMode parseRangeMode (const juce::String& s); + static Movement parseMovement (const juce::String& s); + static Polarity parsePolarity (const juce::String& s); + static ClockSource parseClockSource (const juce::String& s); + static LoopMode parseLoopMode (const juce::String& s); + static JumpMode parseJumpMode (const juce::String& s); + static MorphMode parseMorphMode (const juce::String& s); + static ExploreMode parseExploreMode (const juce::String& s); + static LfoShape parseLfoShape (const juce::String& s); + static MorphAction parseMorphAction (const juce::String& s); + static StepOrder parseStepOrder (const juce::String& s); + static ShapeTracking parseShapeTracking(const juce::String& s); + static ShapeTrigger parseShapeTrigger (const juce::String& s); + static LaneInterp parseLaneInterp (const juce::String& s); + static LanePlayMode parseLanePlayMode (const juce::String& s); + static float parseBeatsPerDiv (const juce::String& s); + + struct ParamTarget { + int pluginId = 0; + int paramIndex = 0; + }; + + struct LogicBlock { + int id = 0; + juce::String mode; // kept for serialization + BlockMode modeE = BlockMode::Unknown; // enum mirror for processBlock + bool enabled = true; // false = bypassed, skip processing + std::vector targets; + + // Trigger + juce::String trigger; + TriggerType triggerE = TriggerType::Manual; + juce::String beatDiv; + float beatDivBeats = 1.0f; // pre-computed beats-per-trigger + juce::String midiMode; + MidiTrigMode midiModeE = MidiTrigMode::AnyNote; + int midiNote = 60; + int midiCC = 1; + int midiCh = 0; // 0 = any channel + float threshold = -12.0f; + juce::String audioSrc; + AudioSource audioSrcE = AudioSource::Main; + + // Range + float rMin = 0.0f; + float rMax = 1.0f; + juce::String rangeMode; + RangeMode rangeModeE = RangeMode::Absolute; + bool quantize = false; + int qSteps = 12; + + // Movement + juce::String movement; + Movement movementE = Movement::Instant; + float glideMs = 200.0f; + + float envAtk = 10.0f; + float envRel = 100.0f; + float envSens = 50.0f; + bool envInvert = false; + float envBandLo = 20.0f; // HPF cutoff Hz (computed from mode+freq+bw) + float envBandHi = 20000.0f; // LPF cutoff Hz (computed from mode+freq+bw) + juce::String envFilterMode; // flat, lp, hp, bp + float envFilterFreq = 1000.0f;// Center/cutoff frequency in Hz + float envFilterBW = 2.0f; // Bandwidth in octaves (for bp mode) + + // Per-block biquad state for envelope band filter + struct BiquadState { + float s1 = 0, s2 = 0; // Transposed Direct Form II state + float a0 = 1, a1 = 0, a2 = 0, b1 = 0, b2 = 0; + float lastFreq = -1; + void setHighpass(float freq, float sr) { + if (std::abs(freq - lastFreq) < 0.5f) return; // skip if unchanged + lastFreq = freq; + float w0 = juce::MathConstants::twoPi * freq / sr; + float cosw = std::cos(w0), sinw = std::sin(w0); + float alpha = sinw / (2.0f * 0.707f); // Q = 0.707 (Butterworth) + float norm = 1.0f / (1.0f + alpha); + a0 = ((1.0f + cosw) * 0.5f) * norm; + a1 = -(1.0f + cosw) * norm; + a2 = a0; + b1 = -2.0f * cosw * norm; + b2 = (1.0f - alpha) * norm; + } + void setLowpass(float freq, float sr) { + if (std::abs(freq - lastFreq) < 0.5f) return; + lastFreq = freq; + float w0 = juce::MathConstants::twoPi * freq / sr; + float cosw = std::cos(w0), sinw = std::sin(w0); + float alpha = sinw / (2.0f * 0.707f); + float norm = 1.0f / (1.0f + alpha); + a0 = ((1.0f - cosw) * 0.5f) * norm; + a1 = (1.0f - cosw) * norm; + a2 = a0; + b1 = -2.0f * cosw * norm; + b2 = (1.0f - alpha) * norm; + } + // Transposed Direct Form II — numerically stable, correct IIR + float process(float in) { + float out = a0 * in + s1; + s1 = a1 * in - b1 * out + s2; + s2 = a2 * in - b2 * out; + return out; + } + void reset() { s1 = s2 = 0; lastFreq = -1; } + }; + BiquadState envHpf, envLpf; + + // Polarity control + juce::String polarity = "bipolar"; + Polarity polarityE = Polarity::Bipolar; + + // Clock source + juce::String clockSource = "daw"; + ClockSource clockSourceE = ClockSource::Daw; + float internalBpm = 120.0f; + double internalPpq = 0.0; // internal clock beat accumulator (for tempo triggers) + + // Sample modulator settings + juce::String loopMode; + LoopMode loopModeE = LoopMode::Oneshot; + float sampleSpeed = 1.0f; + bool sampleReverse = false; + juce::String jumpMode; + JumpMode jumpModeE = JumpMode::Restart; + + // Runtime state (audio thread only, preserved across updateLogicBlocks) + float currentEnvValue = 0.0f; + int lastBeat = -1; + double lastAudioTrigSample = 0.0; + + // Sample runtime state + std::shared_ptr sampleData; + double samplePlayhead = 0.0; + int sampleDirection = 1; + std::vector targetBaseValues; + std::vector targetLastWritten; + std::vector targetExtPause; // per-target cooldown frames: pause mod after hosted-UI change + + // ── Morph Pad ── + struct MorphSnapshot { + float x = 0.5f, y = 0.5f; + std::vector targetValues; + }; + std::vector snapshots; + float playheadX = 0.5f, playheadY = 0.5f; + juce::String morphMode; + MorphMode morphModeE = MorphMode::Manual; + juce::String exploreMode; + ExploreMode exploreModeE = ExploreMode::Wander; + juce::String lfoShape; + LfoShape lfoShapeE = LfoShape::Circle; + float lfoDepth = 0.8f; + float lfoRotation = 0.0f; + float morphSpeed = 0.5f; + juce::String morphAction; + MorphAction morphActionE = MorphAction::Jump; + juce::String stepOrder; + StepOrder stepOrderE = StepOrder::Cycle; + juce::String morphSource; + float jitter = 0.0f; + float morphGlide = 200.0f; + bool morphTempoSync = false; + juce::String morphSyncDiv; + float morphSyncDivBeats = 1.0f; // pre-computed + float snapRadius = 1.0f; + + // Morph runtime state (audio thread only, preserved across updateLogicBlocks) + float morphVelX = 0.0f, morphVelY = 0.0f; + float morphAngle = 0.0f; + float morphLfoPhase = 0.0f; + float lfoRotAngle = 0.0f; + int morphStepIndex = 0; + float morphSmoothX = 0.5f, morphSmoothY = 0.5f; + float prevAppliedX = 0.5f, prevAppliedY = 0.5f; + float morphNoisePhaseX = 0.0f, morphNoisePhaseY = 0.0f; + float morphOrbitPhase = 0.0f; + int morphOrbitTarget = 0; + float morphPathProgress = 0.0f; + int morphPathIndex = 0; + + // ── Shapes Block ── + juce::String shapeType; + LfoShape shapeTypeE = LfoShape::Circle; // reuses LfoShape enum + juce::String shapeTracking; + ShapeTracking shapeTrackingE = ShapeTracking::Horizontal; + float shapeSize = 0.8f; + float shapeSpin = 0.0f; + float shapeSpeed = 0.5f; + float shapePhaseOffset = 0.0f; // User phase offset (0..1 = 0..360°) + float shapeDepth = 0.5f; + juce::String shapeRange; + RangeMode shapeRangeE = RangeMode::Absolute; + juce::String shapePolarity; + Polarity shapePolarityE = Polarity::Bipolar; + bool shapeTempoSync = false; + juce::String shapeSyncDiv; + float shapeSyncDivBeats = 1.0f; // pre-computed + juce::String shapeTrigger; + ShapeTrigger shapeTriggerE = ShapeTrigger::Free; + // Per-param range values for shapes_range mode (aligned with targets) + std::vector targetRangeValues; + std::vector targetRangeBaseValues; + std::vector smoothedRangeValues; + // Shapes runtime state + float shapePhase = 0.0f; + float shapeRotAngle = 0.0f; + float smoothedShapeDepth = 0.0f; + bool shapeWasPlaying = false; // for transport-start PPQ snap + bool shapeWasEnabled = false; // for restore-on-disable + + // ── Lane Clips ── + struct LaneClip { + struct LaneTarget { int pluginId = 0; int paramIndex = 0; }; + std::vector targets; + struct Point { float x = 0.0f, y = 0.0f; }; + std::vector pts; + juce::String loopLen; + float loopLenBeats = 1.0f; // pre-computed (0 = free mode) + bool loopLenFree = false; // true when loopLen == "free" + float freeSecs = 4.0f; + float steps = 0.0f; // output quantization: 0=off, 2-32=discrete levels + float depth = 1.0f; + float drift = 0.0f; + float driftRange = 5.0f; // 0-100: amplitude as % of full parameter range + juce::String driftScale; // musical period for drift noise: "1/4", "1/1", "4/1", etc. + float driftScaleBeats = 4.0f; // parsed beats for driftScale + float warp = 0.0f; + + juce::String interp; + LaneInterp interpE = LaneInterp::Smooth; + juce::String playMode; + LanePlayMode playModeE = LanePlayMode::Forward; + bool synced = true; + bool muted = false; + // Oneshot / trigger config + bool oneshotMode = false; // true = oneshot, false = loop + bool oneshotActive = false; // currently playing (runtime) + bool oneshotDone = false; // completed, waiting for retrigger + bool manualTrigger = false; // set from JS fire button + int trigSourceE = 0; // 0=manual, 1=midi, 2=audio + int trigMidiNote = -1; // -1=any, 0-127=specific + int trigMidiCh = 0; // 0=any + float trigThresholdLin = 0.25f; // linear threshold (from dB) + bool trigRetrigger = true; + bool trigHold = false; // MIDI: false=trigger once, true=gate/sustain + bool trigAudioSrc = false; // false=main, true=sidechain + // Morph lane mode + bool morphMode = false; // false = curve lane, true = morph lane + struct MorphSnapshot { + float position = 0.0f; // 0-1 on timeline + float hold = 0.5f; // 0-1: fraction of zone as plateau + int curve = 0; // 0=smooth,1=linear,2=sharp,3=late + float depth = 1.0f; // 0-1: per-snapshot depth (default 100%) + float drift = 0.0f; // per-snapshot drift variation + float driftRange = 5.0f; // per-snapshot drift range (0-100%) + float driftScaleBeats = 4.0f; // per-snapshot drift timing (beats) + float warp = 0.0f; // per-snapshot warp (S-curve contrast) + int steps = 0; // per-snapshot output quantization (0=off) + juce::String label; + juce::String source; // plugin name it came from + // paramId ("pluginId:paramIndex") → normalised value + std::unordered_map values; + + // ── Pre-parsed for audio thread (ZERO allocations) ── + // Built by updateLogicBlocks on message thread. + // Mirrors 'values' but with integer keys for RT-safe access. + struct ParsedValue { + int pluginId; + int paramIndex; + float value; + }; + std::vector parsedValues; // pre-parsed from 'values' + }; + std::vector morphSnapshots; // sorted by position + + // ── Pre-built target lookup for audio thread (ZERO allocations) ── + // Built by updateLogicBlocks. Uses flat sorted vector for O(log n) lookup + // without any string operations on the audio thread. + struct IntKey { + int pluginId; + int paramIndex; + bool operator<(const IntKey& o) const { + return pluginId < o.pluginId || (pluginId == o.pluginId && paramIndex < o.paramIndex); + } + bool operator==(const IntKey& o) const { + return pluginId == o.pluginId && paramIndex == o.paramIndex; + } + }; + std::vector targetKeySorted; // sorted for binary search + + // Runtime + double playhead = 0.0; + int direction = 1; + float driftPhase = 0.0f; // running phase for deterministic noise + bool wasPlaying = false; // for transport-start PPQ snap + bool midiNoteHeld = false; // MIDI sustain tracking + }; + std::vector laneClips; + }; + + std::mutex blockMutex; // Protects logicBlocks; updateLogicBlocks holds it, + // processBlock uses try_lock + std::vector logicBlocks; + juce::Random audioRandom; // RNG for audio-thread randomization + double sampleCounter = 0.0; // Monotonic sample position for trigger cooldowns + + // ══════════════════════════════════════════════════════════════ + // Pre-allocated flat arrays — ZERO heap allocations on audio thread + // ══════════════════════════════════════════════════════════════ + static constexpr int kMaxPlugins = 32; + static constexpr int kMaxParams = 1024; + + // O(1) plugin lookup by slot — avoids linear scan of hostedPlugins + // Updated by rebuildPluginSlots() whenever plugins are added/removed + HostedPlugin* pluginSlots[kMaxPlugins] = {}; + void rebuildPluginSlots(); + + // Last value written to each param by ANY logic block. + // Used by relative mode to distinguish "another block wrote this" from "user moved it". + // Sentinel: -1.0f = never written. + float paramWritten[kMaxPlugins][kMaxParams]; + + // Params currently being touched (grabbed) by the user — skip modulation. + // Atomic: written by UI thread, read by audio thread. + std::atomic paramTouched[kMaxPlugins][kMaxParams]; + + // ── Modulation Bus ── + // Accumulates offsets from all continuous blocks per buffer, resolves once. + // Prevents "last-writer-wins" when multiple blocks target the same param. + struct ModAccum { + float base = 0.0f; // Base value (from Randomize/Morph) + float offset = 0.0f; // Sum of all continuous block offsets + bool hasBase = false; + bool hasOffset = false; + }; + ModAccum modBus[kMaxPlugins][kMaxParams]; + // WrongEQ dedicated modbus: per-band (8 bands × 4 fields = 32) + global (14) + static constexpr int kWeqModSlots_perBand = maxEqBands * 4; + static constexpr int kWeqModSlots = kWeqModSlots_perBand + kWeqGlobalCount; + ModAccum weqModBus[kWeqModSlots]; + + // Stable base value for each param — the "user knob position" that + // modulation offsets are applied relative to. -1 = not yet captured. + float paramBase[kMaxPlugins][kMaxParams]; + // What the modbus wrote last buffer — for detecting external changes. + float paramModWritten[kMaxPlugins][kMaxParams]; + // WrongEQ dedicated base/written arrays + float weqParamBase[kWeqModSlots]; + float weqParamModWritten[kWeqModSlots]; + + void initParamBase() + { + for (int s = 0; s < kMaxPlugins; ++s) + for (int p = 0; p < kMaxParams; ++p) + { + paramBase[s][p] = -1.0f; + paramModWritten[s][p] = -1.0f; + } + for (int w = 0; w < kWeqModSlots; ++w) + { + weqParamBase[w] = -1.0f; + weqParamModWritten[w] = -1.0f; + } + } + + void clearModBus() + { + for (int s = 0; s < kMaxPlugins; ++s) + for (int p = 0; p < kMaxParams; ++p) + modBus[s][p] = {}; + for (int w = 0; w < kWeqModSlots; ++w) + weqModBus[w] = {}; + } + + // Map WrongEQ paramIndex to flat modbus slot: + // per-band (0..31) → slot 0..31 (direct) + // global (100..110) → slot 32..42 (kWeqModSlots_perBand + offset) + // Returns -1 if out of range. + static int weqSlot (int paramIndex) + { + if (paramIndex >= 0 && paramIndex < kWeqModSlots_perBand) + return paramIndex; // per-band + if (paramIndex >= kWeqGlobalBase && paramIndex < kWeqGlobalBase + kWeqGlobalCount) + return kWeqModSlots_perBand + (paramIndex - kWeqGlobalBase); // global + return -1; + } + + void writeModBase (int pluginId, int paramIndex, float value) + { + if (pluginId == kWeqPluginId) + { + int s = weqSlot (paramIndex); + if (s >= 0) + { + weqModBus[s].base = value; + weqModBus[s].hasBase = true; + weqParamBase[s] = value; + } + return; + } + int s = slotForId (pluginId); + if (s >= 0 && paramIndex >= 0 && paramIndex < kMaxParams) + { + modBus[s][paramIndex].base = value; + modBus[s][paramIndex].hasBase = true; + paramBase[s][paramIndex] = value; + } + } + + void addModOffset (int pluginId, int paramIndex, float off) + { + if (pluginId == kWeqPluginId) + { + int s = weqSlot (paramIndex); + if (s >= 0) + { + weqModBus[s].offset += off; + weqModBus[s].hasOffset = true; + } + return; + } + int s = slotForId (pluginId); + if (s >= 0 && paramIndex >= 0 && paramIndex < kMaxParams) + { + modBus[s][paramIndex].offset += off; + modBus[s][paramIndex].hasOffset = true; + } + } + + // Called by Randomize when it fires — sets the new resting position + void updateParamBase (int pluginId, int paramIndex, float value) + { + if (pluginId == kWeqPluginId) return; // WrongEQ: base IS the atomic — no separate storage + int s = slotForId (pluginId); + if (s >= 0 && paramIndex >= 0 && paramIndex < kMaxParams) + paramBase[s][paramIndex] = value; + } + + void resolveModBus() + { + for (int s = 0; s < kMaxPlugins; ++s) + { + auto* hp = pluginSlots[s]; + if (hp == nullptr) continue; + int pid = hp->id; + + for (int p = 0; p < kMaxParams; ++p) + { + auto& acc = modBus[s][p]; + if (!acc.hasBase && !acc.hasOffset) + { + // No modulation this buffer — release the base so next + // modulation start re-captures the current knob value. + if (paramBase[s][p] > -0.5f) + { + // Snap param back to base on last active buffer + // (so the param isn't left stranded at a modulated position) + paramBase[s][p] = -1.0f; + paramModWritten[s][p] = -1.0f; + } + continue; + } + + float base; + if (acc.hasBase) + { + // Explicit base setter (Morph Pad) — use directly + base = acc.base; + } + else + { + // Offset-only — need a stable base + if (paramBase[s][p] < -0.5f) + { + // First buffer of modulation — capture current param value + paramBase[s][p] = getParamValue (pid, p); + } + else if (paramModWritten[s][p] > -0.5f) + { + // Detect external change: if the current param value differs + // from what the modbus wrote last buffer, someone else moved it + // (user knob drag, Randomize, glide, etc.) — adopt as new base + float cur = getParamValue (pid, p); + if (std::abs (cur - paramModWritten[s][p]) > 0.005f) + paramBase[s][p] = cur; + } + base = paramBase[s][p]; + } + + float final_ = juce::jlimit (0.0f, 1.0f, base + acc.offset); + setParamDirect (pid, p, final_); + paramWritten[s][p] = final_; + paramModWritten[s][p] = final_; + } + } + + // ── WrongEQ modbus resolution ── + // weqSlot maps paramIndex → flat slot; here we need the reverse. + auto weqParamIndex = [] (int slot) -> int { + if (slot < kWeqModSlots_perBand) return slot; // per-band: slot IS paramIndex + return kWeqGlobalBase + (slot - kWeqModSlots_perBand); // global: 100 + offset + }; + + for (int w = 0; w < kWeqModSlots; ++w) + { + auto& acc = weqModBus[w]; + if (!acc.hasBase && !acc.hasOffset) + { + if (weqParamBase[w] > -0.5f) + { + weqParamBase[w] = -1.0f; + weqParamModWritten[w] = -1.0f; + } + continue; + } + + int pi = weqParamIndex (w); // actual paramIndex for C++ get/set calls + + float base; + if (acc.hasBase) + { + base = acc.base; + } + else + { + if (weqParamBase[w] < -0.5f) + weqParamBase[w] = getParamValue (kWeqPluginId, pi); + else if (weqParamModWritten[w] > -0.5f) + { + float cur = getParamValue (kWeqPluginId, pi); + if (std::abs (cur - weqParamModWritten[w]) > 0.005f) + weqParamBase[w] = cur; + } + base = weqParamBase[w]; + } + + float final_ = juce::jlimit (0.0f, 1.0f, base + acc.offset); + setParamDirect (kWeqPluginId, pi, final_); + weqParamModWritten[w] = final_; + } + } + + // Per-plugin gesture listener — detects hosted plugin UI knob drags. + // Sets paramTouched[slot][paramIndex] when a gesture begins, clears on end. + struct GestureListener : public juce::AudioProcessorParameter::Listener + { + int slot; + std::atomic (&touched)[kMaxPlugins][kMaxParams]; + + GestureListener (int s, std::atomic (&t)[kMaxPlugins][kMaxParams]) + : slot (s), touched (t) {} + + void parameterValueChanged (int, float) override {} + void parameterGestureChanged (int paramIndex, bool starting) override + { + if (paramIndex >= 0 && paramIndex < kMaxParams) + touched[slot][paramIndex].store (starting, std::memory_order_release); + } + }; + std::vector> gestureListeners; + + // Map pluginId → array slot index (pluginId % kMaxPlugins) + int slotForId (int pluginId) const; + + + /** Set a param directly on a hosted plugin (audio thread) */ + void setParamDirect (int pluginId, int paramIndex, float value); + + // AudioProcessorParameter::Listener — detects hosted plugin UI gestures + void parameterValueChanged (int, float) override {} // unused, we poll instead + void parameterGestureChanged (int parameterIndex, bool gestureIsStarting) override; + +public: + /** Mark a param as touched (user grabbing it) — modulation suspends */ + void touchParam (int pluginId, int paramIndex) + { + int slot = slotForId (pluginId); + if (slot >= 0 && paramIndex >= 0 && paramIndex < kMaxParams) + paramTouched[slot][paramIndex].store (true, std::memory_order_release); + } + + /** Release a touched param — modulation continues from the already-adopted base */ + void untouchParam (int pluginId, int paramIndex) + { + int slot = slotForId (pluginId); + if (slot >= 0 && paramIndex >= 0 && paramIndex < kMaxParams) + { + paramTouched[slot][paramIndex].store (false, std::memory_order_release); + // Note: base value was already adopted continuously during processBlock + // (Bitwig-style). No recapture needed here — just clear the flag. + } + } + + /** Read a param value from a hosted plugin (audio thread, pluginMutex already held) */ + float getParamValue (int pluginId, int paramIndex) const; + + /** Get the set of param keys (pluginId:paramIndex strings) currently targeted by any logic block. + Used by the editor to prioritize polling of modulated params. + Uses blockMutex (not pluginMutex), so safe to call from message thread. */ + std::unordered_set getModulatedParamKeys() + { + std::unordered_set result; + std::lock_guard lock (blockMutex); + for (const auto& lb : logicBlocks) + { + if (!lb.enabled) continue; + for (const auto& t : lb.targets) + result.insert (std::to_string (t.pluginId) + ":" + std::to_string (t.paramIndex)); + for (const auto& lc : lb.laneClips) + for (const auto& lt : lc.targets) + result.insert (std::to_string (lt.pluginId) + ":" + std::to_string (lt.paramIndex)); + } + return result; + } + + /** Fast single-param value read — lock-free, O(1) lookup! + JUCE's AudioProcessorParameter::getValue() is already atomic internally. + hostedPlugins vector is stable (pre-reserved, never erased during processing). + This is how DAWs read params without blocking the audio thread. */ + float getParamValueFast (int pluginId, int paramIndex) + { + int slot = slotForId (pluginId); + if (slot >= 0) + { + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + return params[paramIndex]->getValue(); + } + } + return -1.0f; + } + + /** Fast single-param display text — lock-free, O(1) lookup! */ + juce::String getParamDisplayTextFast (int pluginId, int paramIndex) + { + int slot = slotForId (pluginId); + if (slot >= 0) + { + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + return params[paramIndex]->getText (params[paramIndex]->getValue(), 32); + } + } + return {}; + } + + /** Convert an arbitrary normalized value (0..1) to the plugin's display text. + Like getParamDisplayTextFast but for a hypothetical value, not the current one. */ + juce::String getParamTextForValue (int pluginId, int paramIndex, float normalizedValue) + { + int slot = slotForId (pluginId); + if (slot >= 0) + { + auto* hp = pluginSlots[slot]; + if (hp && hp->id == pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (paramIndex >= 0 && paramIndex < params.size()) + return params[paramIndex]->getText (juce::jlimit (0.0f, 1.0f, normalizedValue), 32); + } + } + return {}; + } + +public: + //============================================================================== + // Real-time data for UI (written in processBlock, read from editor timer) + //============================================================================== + std::atomic currentRmsLevel { 0.0f }; // Main input audio RMS (0..1) + std::atomic sidechainRmsLevel { 0.0f }; // Sidechain input RMS (0..1) + std::atomic currentBpm { 120.0 }; // DAW BPM + std::atomic isPlaying { false }; // DAW transport playing + std::atomic ppqPosition { 0.0 }; // PPQ position for tempo sync + + // ── Spectrum Analyzer (FFT) ── + static constexpr int fftOrder = 11; // 2^11 = 2048 samples + static constexpr int fftSize = 1 << fftOrder; + static constexpr int spectrumBinCount = 128; // log-spaced output bins + float fftInputBuffer[fftSize] = {}; + int fftInputPos = 0; + std::atomic fftReady { false }; + float fftWorkBuffer[fftSize * 2] = {}; + float spectrumBinsOut[spectrumBinCount] = {}; + + /** Get log-spaced spectrum bins (dB) for UI. Returns bin count (128) or 0 if not ready. */ + int getSpectrumBins (float* outBins, int maxBins); + + // MIDI event buffer for UI (recent note-on/CC events) + // Lock-free SPSC FIFO: audio thread writes, UI timer reads + struct MidiEvent { + int note = 0; // MIDI note number (0-127) or CC number + int velocity = 0; // velocity or CC value + int channel = 0; // MIDI channel (1-16) + bool isCC = false; // true if CC, false if note + }; + static constexpr int midiRingSize = 256; + MidiEvent midiRing[midiRingSize]; + juce::AbstractFifo midiFifo { midiRingSize }; + + // Envelope follower levels for UI display (written by processBlock) + static constexpr int maxEnvReadback = 16; + struct EnvReadback { + std::atomic blockId { -1 }; + std::atomic level { 0.0f }; + }; + EnvReadback envReadback[maxEnvReadback]; + std::atomic numActiveEnvBlocks { 0 }; + + // Sample modulator playhead positions for UI display + static constexpr int maxSampleReadback = 16; + struct SampleReadback { + std::atomic blockId { -1 }; + std::atomic playhead { 0.0f }; // 0..1 normalised position + }; + SampleReadback sampleReadback[maxSampleReadback]; + std::atomic numActiveSampleBlocks { 0 }; + + // Morph pad playhead positions for UI display + static constexpr int maxMorphReadback = 8; + struct MorphReadback { + std::atomic blockId { -1 }; + std::atomic headX { 0.5f }; + std::atomic headY { 0.5f }; + std::atomic rotAngle { 0.0f }; + std::atomic modOutput { 0.0f }; // raw shapes output -1..+1 for fill arc + }; + MorphReadback morphReadback[maxMorphReadback]; + std::atomic numActiveMorphBlocks { 0 }; + + // Lane playhead positions for UI display + static constexpr int maxLaneReadback = 32; // max lanes across all blocks + struct LaneReadback { + std::atomic blockId { -1 }; + std::atomic laneIdx { -1 }; + std::atomic playhead { 0.0f }; + std::atomic value { 0.5f }; // current evaluated value (0..1) + std::atomic active { true }; // false when oneshot is done/idle + }; + LaneReadback laneReadback[maxLaneReadback]; + std::atomic numActiveLanes { 0 }; + + // Trigger fire events for UI flash (lock-free FIFO) + static constexpr int triggerRingSize = 64; + int triggerRing[triggerRingSize] = {}; + juce::AbstractFifo triggerFifo { triggerRingSize }; + + // Crash notification FIFO — audio thread writes, UI timer reads + struct CrashEvent { + int pluginId = 0; + char pluginName[64] = {}; + char reason[128] = {}; + }; + static constexpr int crashRingSize = 16; + CrashEvent crashRing[crashRingSize] = {}; + juce::AbstractFifo crashFifo { crashRingSize }; + + // Self-write tracking for auto-locate filtering + // Records pluginId:paramIndex pairs that OUR code wrote, + // so the editor can exclude them from "touched by plugin UI" detection. + struct SelfWriteEvent { + int pluginId; + int paramIndex; + }; + static constexpr int selfWriteRingSize = 2048; + SelfWriteEvent selfWriteRing[selfWriteRingSize] = {}; + juce::AbstractFifo selfWriteFifo { selfWriteRingSize }; + + /** Record that we wrote a param (call from any thread, lock-free) */ + void recordSelfWrite (int pluginId, int paramIndex) + { + const auto scope = selfWriteFifo.write (1); + if (scope.blockSize1 > 0) + selfWriteRing[scope.startIndex1] = { pluginId, paramIndex }; + } + +private: + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ModularRandomizerAudioProcessor) +}; diff --git a/plugins/ModularRandomizer/Source/ProcessBlock.cpp b/plugins/ModularRandomizer/Source/ProcessBlock.cpp new file mode 100644 index 0000000..ffa0a8d --- /dev/null +++ b/plugins/ModularRandomizer/Source/ProcessBlock.cpp @@ -0,0 +1,2996 @@ +/* + ============================================================================== + + ProcessBlock.cpp + Audio processing pipeline: processBlock, SEH crash guard, glide engine + + ============================================================================== +*/ + +#include "PluginProcessor.h" +#include "ParameterIDs.hpp" +#include +//============================================================================== +// SEH-guarded processBlock wrapper (Windows only) +// Isolated as a free function because __try/__except cannot coexist +// with C++ objects that have destructors in the same function scope. +//============================================================================== +#ifdef _WIN32 +bool sehGuardedProcessBlock (juce::AudioPluginInstance* instance, + juce::AudioBuffer& buffer, + juce::MidiBuffer& midi) +{ + __try + { + instance->processBlock (buffer, midi); + return true; // success + } + __except (EXCEPTION_EXECUTE_HANDLER) + { + return false; // hardware fault caught + } +} +#else +// Non-Windows: no SEH available, just call directly (C++ try/catch wraps this) +bool sehGuardedProcessBlock (juce::AudioPluginInstance* instance, + juce::AudioBuffer& buffer, + juce::MidiBuffer& midi) +{ + instance->processBlock (buffer, midi); + return true; +} +#endif + +void ModularRandomizerAudioProcessor::processBlock (juce::AudioBuffer& buffer, + juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + + // Clear any output channels that don't have corresponding inputs + // (standard JUCE boilerplate — prevents garbage in unused channels) + auto totalNumInputChannels = getTotalNumInputChannels(); + auto totalNumOutputChannels = getTotalNumOutputChannels(); + for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i) + buffer.clear (i, 0, buffer.getNumSamples()); + + // Determine main bus channel count (exclude sidechain channels) + int mainBusChannels = (getBus (true, 0) != nullptr) + ? getBus (true, 0)->getNumberOfChannels() : buffer.getNumChannels(); + mainBusChannels = juce::jmin (mainBusChannels, buffer.getNumChannels()); + + // ── Reset EQ modulation offsets each block ── + // The meta-modulation pass (below) will re-apply them via setParamDirect + // if still active. This ensures offsets decay to zero when modulation stops. + if (routingMode.load() == 2) + { + int nPts = numEqPoints.load (std::memory_order_relaxed); + for (int i = 0; i < nPts && i < maxEqBands; ++i) + { + if (eqPoints[i].modActive.load (std::memory_order_relaxed)) + { + eqPoints[i].modFreqHz.store (0.0f, std::memory_order_relaxed); + eqPoints[i].modGainDB.store (0.0f, std::memory_order_relaxed); + eqPoints[i].modQ.store (0.0f, std::memory_order_relaxed); + eqPoints[i].modActive.store (false, std::memory_order_relaxed); + } + } + } + + // ── Capture real-time data for UI ── + + // Audio RMS level (from main input, before processing) + { + int scStart = getBus (true, 1) != nullptr && getBus (true, 1)->isEnabled() + ? getBus (true, 0)->getNumberOfChannels() : -1; + + float rms = 0.0f; + float scRms = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + float chRms = buffer.getRMSLevel (ch, 0, buffer.getNumSamples()); + if (scStart >= 0 && ch >= scStart) + scRms = juce::jmax (scRms, chRms); + else + rms = juce::jmax (rms, chRms); + } + currentRmsLevel.store (rms); + sidechainRmsLevel.store (scRms); + } + + // ── FFT spectrum accumulation (mono sum into ring buffer) ── + { + int numSamp = buffer.getNumSamples(); + int chCount = juce::jmin(mainBusChannels, buffer.getNumChannels()); + for (int s = 0; s < numSamp; ++s) + { + float mono = 0.0f; + for (int ch = 0; ch < chCount; ++ch) + mono += buffer.getReadPointer(ch)[s]; + if (chCount > 1) mono /= (float) chCount; + fftInputBuffer[fftInputPos] = mono; + if (++fftInputPos >= fftSize) + { + fftInputPos = 0; + // Copy + Hann window into work buffer + for (int i = 0; i < fftSize; ++i) + { + float w = 0.5f * (1.0f - std::cos(juce::MathConstants::twoPi * (float)i / (float)(fftSize - 1))); + fftWorkBuffer[i] = fftInputBuffer[i] * w; + } + std::memset(fftWorkBuffer + fftSize, 0, sizeof(float) * fftSize); + fftReady.store(true); + } + } + } + + // MIDI events for UI triggers (lock-free FIFO write) + for (const auto metadata : midiMessages) + { + auto msg = metadata.getMessage(); + MidiEvent ev; + if (msg.isNoteOn()) + { + ev = { msg.getNoteNumber(), msg.getVelocity(), msg.getChannel(), false }; + } + else if (msg.isController()) + { + ev = { msg.getControllerNumber(), msg.getControllerValue(), msg.getChannel(), true }; + } + else + { + continue; + } + + const auto scope = midiFifo.write (1); + if (scope.blockSize1 > 0) + midiRing[scope.startIndex1] = ev; + else if (scope.blockSize2 > 0) + midiRing[scope.startIndex2] = ev; + // If FIFO is full, event is silently dropped (acceptable for UI triggers) + } + + // DAW transport info + if (auto* playHead = getPlayHead()) + { + if (auto pos = playHead->getPosition()) + { + if (auto bpm = pos->getBpm()) + currentBpm.store (*bpm); + isPlaying.store (pos->getIsPlaying()); + if (auto ppq = pos->getPpqPosition()) + ppqPosition.store (*ppq); + } + } + + // ── Audio processing ── + + auto bypass = apvts.getRawParameterValue (ParameterIDs::BYPASS)->load(); + if (bypass > 0.5f) + return; // Bypass: audio passes through unmodified + + auto mixPercent = apvts.getRawParameterValue (ParameterIDs::MIX)->load(); + float wet = mixPercent / 100.0f; + + // No pluginMutex lock on the audio thread! — a single block of unprocessed audio is + // inaudible, whereas blocking would cause priority inversion and clicks. + // NOTE: No pluginMutex lock on the audio thread! + // hostedPlugins is structurally stable: removePlugin() only nulls instances + // (never erases), purgeDeadPlugins() only runs from prepareToPlay(). + // processOnePlugin() checks for null instance. The old try_lock caused + // crackles because UI parameter polling blocked the mutex, making the + // audio thread skip processing for entire buffers. + if (wet < 0.001f) + return; + + // In WrongEQ mode, the EQ biquads and crossover processing must run even + // without any hosted plugins — the EQ IS the effect. Only skip when + // there are genuinely no plugins AND we're not in WrongEQ mode with points. + bool hasEqWork = (routingMode.load() == 2 && numEqPoints.load() > 0); + if (hostedPlugins.empty() && !hasEqWork) + return; + + // Save dry signal (use pre-allocated member buffer — no heap alloc) + bool needsDryMix = wet < 0.999f; + if (needsDryMix) + { + int dryChannels = juce::jmin (mainBusChannels, dryBuffer.getNumChannels()); + int drySamples = juce::jmin (buffer.getNumSamples(), dryBuffer.getNumSamples()); + for (int ch = 0; ch < dryChannels; ++ch) + dryBuffer.copyFrom (ch, 0, buffer, ch, 0, drySamples); + } + + // Advance monotonic sample counter (for trigger cooldowns) + sampleCounter += buffer.getNumSamples(); + + // ── Logic Block Engine: triggers + envelope followers ── + { + std::unique_lock blockLock (blockMutex, std::try_to_lock); + if (blockLock.owns_lock() && ! logicBlocks.empty()) + { + clearModBus(); + + float mainRms = currentRmsLevel.load(); + float scRms = sidechainRmsLevel.load(); + double ppq = ppqPosition.load(); + bool playing = isPlaying.load(); + int numSamples = buffer.getNumSamples(); + float bufferRate = (float) (currentSampleRate / numSamples); // Hz + + // Collect MIDI events from this buffer for trigger matching + // Uses pre-allocated member vector — no per-buffer allocation + blockMidiEvents.clear(); + for (const auto metadata : midiMessages) + { + auto msg = metadata.getMessage(); + if (msg.isNoteOn()) + blockMidiEvents.push_back ({ msg.getNoteNumber(), msg.getVelocity(), msg.getChannel(), false }); + else if (msg.isNoteOff()) + blockMidiEvents.push_back ({ msg.getNoteNumber(), 0, msg.getChannel(), false }); // vel=0 for note-off + else if (msg.isController()) + blockMidiEvents.push_back ({ msg.getControllerNumber(), msg.getControllerValue(), msg.getChannel(), true }); + } + + // Beat divisions are now pre-computed as floats in updateLogicBlocks (H4 fix) + + // ── Reusable filtered audio level helper ── + // Returns RMS level for a block, optionally band-filtered via its biquad state. + // Blocks with default band (20/20k) skip filtering for zero overhead. + auto getFilteredAudioLevel = [&](LogicBlock& lb) -> float { + bool useBand = (lb.envBandLo > 25.0f || lb.envBandHi < 19000.0f); + if (!useBand) + return (lb.audioSrcE == AudioSource::Sidechain) ? scRms : mainRms; + + float sr = (float) currentSampleRate; + lb.envHpf.setHighpass(juce::jlimit(20.0f, 20000.0f, lb.envBandLo), sr); + lb.envLpf.setLowpass(juce::jlimit(20.0f, 20000.0f, lb.envBandHi), sr); + + float sumSq = 0.0f; + bool useSc = (lb.audioSrcE == AudioSource::Sidechain); + int ch0 = useSc ? mainBusChannels : 0; + int chN = useSc ? buffer.getNumChannels() : mainBusChannels; + if (ch0 >= chN) { ch0 = 0; chN = juce::jmin(1, buffer.getNumChannels()); } + for (int ch = ch0; ch < chN; ch++) + { + const float* data = buffer.getReadPointer(ch); + for (int s = 0; s < numSamples; s++) + { + float sig = lb.envHpf.process(data[s]); + sig = lb.envLpf.process(sig); + sumSq += sig * sig; + } + } + return std::sqrt(sumSq / std::max(1, numSamples * (chN - ch0))); + }; + + int envIdx = 0; + int smpIdx = 0; + int morphIdx = 0; + numActiveLanes.store(0); + + // ── Reusable trigger detection helper ── + // Checks MIDI, Tempo, and Audio triggers for a logic block. + // Advances internal clock and updates lastBeat/lastAudioTrigSample as needed. + // Used by Randomize and Sample blocks (which share identical trigger logic). + auto checkTrigger = [&](LogicBlock& lb) -> bool { + bool fired = false; + + // MIDI trigger + if (lb.triggerE == TriggerType::Midi && ! blockMidiEvents.empty()) + { + for (const auto& ev : blockMidiEvents) + { + if (lb.midiCh > 0 && ev.ch != lb.midiCh) continue; + if (ev.isCC && lb.midiModeE == MidiTrigMode::CC && ev.note == lb.midiCC) { fired = true; break; } + if (ev.isCC || ev.vel == 0) continue; // skip CCs and note-offs + if (lb.midiModeE == MidiTrigMode::AnyNote) { fired = true; break; } + if (lb.midiModeE == MidiTrigMode::SpecificNote && ev.note == lb.midiNote) { fired = true; break; } + } + } + + // Tempo trigger + if (lb.triggerE == TriggerType::Tempo) + { + bool useInternal = (lb.clockSourceE == ClockSource::Internal); + if (useInternal && lb.internalBpm > 0.0f) + { + double beatsPerSec = (double) lb.internalBpm / 60.0; + double secsThisBuffer = (double) numSamples / currentSampleRate; + lb.internalPpq += beatsPerSec * secsThisBuffer; + } + double effectivePpq = useInternal ? lb.internalPpq : ppq; + bool canFire = useInternal ? true : playing; + if (canFire) + { + float bpt = lb.beatDivBeats; + int currentBeat = (int) std::floor (effectivePpq / bpt); + if (lb.lastBeat < 0) lb.lastBeat = currentBeat; + if (currentBeat != lb.lastBeat) + { + lb.lastBeat = currentBeat; + fired = true; + } + } + } + + // Audio trigger + if (lb.triggerE == TriggerType::Audio) + { + float audioLvl = getFilteredAudioLevel(lb); + float threshLin = std::pow (10.0f, lb.threshold / 20.0f); + double cooldownSamples = currentSampleRate * 0.1; + if (audioLvl > threshLin && (sampleCounter - lb.lastAudioTrigSample) > cooldownSamples) + { + lb.lastAudioTrigSample = sampleCounter; + fired = true; + } + } + + return fired; + }; + + // ── Reusable 2D shape position computer ── + // Given a shape enum, phase angle t, and radius R, returns (dx, dy). + // Used by Morph Pad LFO shapes and Shapes block — identical geometry. + auto computeShapeXY = [](LfoShape shape, float t, float R) -> std::pair { + float twoPi = juce::MathConstants::twoPi; + float halfPi = juce::MathConstants::halfPi; + float dx = 0.0f, dy = 0.0f; + + if (shape == LfoShape::Circle) { + dx = R * std::cos(t); dy = R * std::sin(t); + } else if (shape == LfoShape::Figure8) { + dx = R * std::sin(t); dy = R * std::sin(t * 2.0f); + } else if (shape == LfoShape::SweepX) { + dx = R * std::sin(t); dy = 0.0f; + } else if (shape == LfoShape::SweepY) { + dx = 0.0f; dy = R * std::sin(t); + } else if (shape == LfoShape::Triangle || shape == LfoShape::Square || shape == LfoShape::Hexagon) { + int n = (shape == LfoShape::Triangle) ? 3 : (shape == LfoShape::Square) ? 4 : 6; + float segF = t * (float)n / twoPi; + int seg = ((int)segF) % n; + float segT = segF - std::floor(segF); + float a0 = twoPi * seg / (float)n - halfPi; + float a1 = twoPi * ((seg + 1) % n) / (float)n - halfPi; + dx = R * (std::cos(a0) + segT * (std::cos(a1) - std::cos(a0))); + dy = R * (std::sin(a0) + segT * (std::sin(a1) - std::sin(a0))); + } else if (shape == LfoShape::Pentagram) { + constexpr int order[5] = {0,2,4,1,3}; + float segF = t * 5.0f / twoPi; + int seg = ((int)segF) % 5; + float segT = segF - std::floor(segF); + int from = order[seg], to = order[(seg+1)%5]; + float a0 = twoPi * from / 5.0f - halfPi; + float a1 = twoPi * to / 5.0f - halfPi; + dx = R * (std::cos(a0) + segT * (std::cos(a1) - std::cos(a0))); + dy = R * (std::sin(a0) + segT * (std::sin(a1) - std::sin(a0))); + } else if (shape == LfoShape::Hexagram) { + // Star of David: trace two interlocked triangles + // First triangle (0,2,4), then second triangle (1,3,5) + // Full path: 0→2→4→0→1→3→5→1 (normalized to 6 segments) + constexpr int starOrder[6] = {0, 2, 4, 1, 3, 5}; + float segF = t * 6.0f / twoPi; + int seg = ((int)segF) % 6; + float segT = segF - std::floor(segF); + int fromIdx = starOrder[seg], toIdx = starOrder[(seg+1)%6]; + float aFrom = twoPi * fromIdx / 6.0f - halfPi; + float aTo = twoPi * toIdx / 6.0f - halfPi; + dx = R * (std::cos(aFrom) + segT * (std::cos(aTo) - std::cos(aFrom))); + dy = R * (std::sin(aFrom) + segT * (std::sin(aTo) - std::sin(aFrom))); + } else if (shape == LfoShape::Rose4) { + float r = R * std::cos(2.0f * t); + dx = r * std::cos(t); dy = r * std::sin(t); + } else if (shape == LfoShape::Lissajous) { + dx = R * 0.7f * std::sin(3.0f * t); dy = R * 0.7f * std::sin(2.0f * t); + } else if (shape == LfoShape::Spiral) { + float progress = t / twoPi; + float rNorm = progress < 0.5f ? progress * 2.0f : (1.0f - progress) * 2.0f; + float sR = R * (0.05f + 0.95f * rNorm); + float sA = t * 3.0f; + dx = sR * std::cos(sA); dy = sR * std::sin(sA); + } else if (shape == LfoShape::Cat) { + // Cat face: polar contour with ears, eyes, nose, mouth + float bodyR = R * 0.52f; + float pi = juce::MathConstants::pi; + + // Angular distance helper (wraps around) + auto angDist = [twoPi](float a, float b) { + float d = std::abs(a - b); + return d > juce::MathConstants::pi ? twoPi - d : d; + }; + + float bump = 0.0f; + + // -- Ears: sharp triangular bumps at ~55deg and ~125deg -- + float earR = R * 0.42f, earW = 0.32f, earTipW = 0.09f; + float dE; + dE = angDist(t, pi * 0.31f); // right ear ~56deg + if (dE < earW) { + float x = 1.0f - dE / earW; + bump += earR * x * x; + if (dE < earTipW) bump += R * 0.18f * (1.0f - dE / earTipW); + } + dE = angDist(t, pi * 0.69f); // left ear ~124deg + if (dE < earW) { + float x = 1.0f - dE / earW; + bump += earR * x * x; + if (dE < earTipW) bump += R * 0.18f * (1.0f - dE / earTipW); + } + + // -- Eyes: small outward bumps at ~320deg and ~220deg -- + float eyeR = R * 0.08f, eyeW = 0.18f; + dE = angDist(t, pi * 1.78f); // right eye ~320deg + if (dE < eyeW) bump += eyeR * (1.0f - dE / eyeW) * (1.0f - dE / eyeW); + dE = angDist(t, pi * 1.22f); // left eye ~220deg + if (dE < eyeW) bump += eyeR * (1.0f - dE / eyeW) * (1.0f - dE / eyeW); + + // -- Nose: small inward dip at ~270deg -- + dE = angDist(t, pi * 1.5f); + if (dE < 0.12f) bump -= R * 0.06f * (1.0f - dE / 0.12f); + + // -- Mouth: W-shape at bottom (~255deg and ~285deg bumps, ~270deg dip) -- + dE = angDist(t, pi * 1.42f); // left mouth corner ~255deg + if (dE < 0.1f) bump += R * 0.04f * (1.0f - dE / 0.1f); + dE = angDist(t, pi * 1.58f); // right mouth corner ~285deg + if (dE < 0.1f) bump += R * 0.04f * (1.0f - dE / 0.1f); + + // -- Chin: slight flat tuck -- + dE = angDist(t, pi * 1.5f); + if (dE < 0.35f) bump -= R * 0.03f * (1.0f - dE / 0.35f) * (1.0f - dE / 0.35f); + + float totalR = bodyR + bump; + dx = totalR * std::cos(t); dy = totalR * std::sin(t); + } else if (shape == LfoShape::Butterfly) { + // Butterfly curve: r = e^cos(t) - 2*cos(4t), closes in one 2pi cycle + float r = std::exp(std::cos(t)) - 2.0f * std::cos(4.0f * t); + float scale = R * 0.21f; + dx = scale * r * std::sin(t); dy = -scale * r * std::cos(t); + } else if (shape == LfoShape::InfinityKnot) { + // Trefoil knot 2D projection: three-lobed continuous path + dx = R * 0.7f * (std::sin(t) + 2.0f * std::sin(2.0f * t)) / 3.0f; + dy = R * 0.7f * (std::cos(t) - 2.0f * std::cos(2.0f * t)) / 3.0f; + } else { + dx = R * std::cos(t); dy = R * std::sin(t); + } + + return { dx, dy }; + }; + + for (auto& lb : logicBlocks) + { + if (lb.targets.empty() || ! lb.enabled) continue; + + // ===== RANDOMIZE MODE ===== + if (lb.modeE == BlockMode::Randomize) + { + bool shouldFire = checkTrigger(lb); + + // --- FIRE: generate random values and apply --- + if (shouldFire) + { + for (const auto& tgt : lb.targets) + { + float newVal; + if (lb.rangeModeE == RangeMode::Relative) + { + // Get current value — O(1) lookup + float cur = getParamValue (tgt.pluginId, tgt.paramIndex); + newVal = cur + (audioRandom.nextFloat() * 2.0f - 1.0f) * lb.rMax; + } + else + { + newVal = lb.rMin + audioRandom.nextFloat() * (lb.rMax - lb.rMin); + } + + if (lb.quantize && lb.qSteps > 1) + newVal = std::round (newVal * (lb.qSteps - 1)) / (float) (lb.qSteps - 1); + + newVal = juce::jlimit (0.0f, 1.0f, newVal); + + if (lb.movementE == Movement::Glide && lb.glideMs > 0.0f) + { + // Push directly to glidePool (already on audio thread) + float cur = getParamValue (tgt.pluginId, tgt.paramIndex); + int total = juce::jmax (1, (int) (lb.glideMs * 0.001 * currentSampleRate)); + // Update existing glide or create new + bool found = false; + for (int gi = 0; gi < numActiveGlides; ++gi) + { + auto& g = glidePool[gi]; + if (g.pluginId == tgt.pluginId && g.paramIndex == tgt.paramIndex) + { + g.targetVal = newVal; + g.increment = (newVal - g.currentVal) / (float) total; + g.samplesLeft = total; + found = true; + break; + } + } + if (! found && numActiveGlides < kMaxGlides) + { + glidePool[numActiveGlides++] = { tgt.pluginId, tgt.paramIndex, cur, newVal, + (newVal - cur) / (float) total, total }; + } + // Glide target is the new user base + updateParamBase (tgt.pluginId, tgt.paramIndex, newVal); + } + else + { + setParamDirect (tgt.pluginId, tgt.paramIndex, newVal); + updateParamBase (tgt.pluginId, tgt.paramIndex, newVal); + } + } + + // Notify UI of trigger fire (lock-free FIFO) + const auto tScope = triggerFifo.write (1); + if (tScope.blockSize1 > 0) triggerRing[tScope.startIndex1] = lb.id; + else if (tScope.blockSize2 > 0) triggerRing[tScope.startIndex2] = lb.id; + } + } + + // ===== ENVELOPE MODE ===== + else if (lb.modeE == BlockMode::Envelope) + { + float audioLvl = getFilteredAudioLevel(lb); + float raw = audioLvl * (lb.envSens / 50.0f); + + // Per-buffer attack/release smoothing + float ac = std::exp (-1.0f / std::max (1.0f, lb.envAtk * 0.001f * bufferRate)); + float rc = std::exp (-1.0f / std::max (1.0f, lb.envRel * 0.001f * bufferRate)); + + if (raw > lb.currentEnvValue) + lb.currentEnvValue = ac * lb.currentEnvValue + (1.0f - ac) * raw; + else + lb.currentEnvValue = rc * lb.currentEnvValue + (1.0f - rc) * raw; + + float cl = juce::jlimit (0.0f, 1.0f, lb.currentEnvValue); + float mp = lb.envInvert ? (1.0f - cl) : cl; + + // Always relative: offset ±depth from resting value (modbus) + { + float depth = lb.rMax; + + // Compute offset based on polarity setting + float offset; + if (lb.polarityE == Polarity::Up) + offset = mp * depth; + else if (lb.polarityE == Polarity::Down) + offset = -mp * depth; + else + offset = (mp * 2.0f - 1.0f) * depth; + + for (size_t ti = 0; ti < lb.targets.size(); ++ti) + addModOffset (lb.targets[ti].pluginId, lb.targets[ti].paramIndex, offset); + } + + // Write envelope level for UI readback + if (envIdx < maxEnvReadback) + { + envReadback[envIdx].blockId.store (lb.id); + envReadback[envIdx].level.store (cl); + envIdx++; + } + } + + // ===== SAMPLE MODULATOR MODE ===== + else if (lb.modeE == BlockMode::Sample && lb.sampleData != nullptr) + { + auto sd = lb.sampleData; // shared_ptr copy (safe) + int totalSamp = sd->buffer.getNumSamples(); + if (totalSamp < 2) continue; + + // --- Check for jump triggers (same system as randomize) --- + bool shouldJump = checkTrigger(lb); + + // Apply jump + if (shouldJump) + { + if (lb.jumpModeE == JumpMode::Random) + lb.samplePlayhead = audioRandom.nextDouble() * (double) (totalSamp - 1); + else // "restart" + lb.samplePlayhead = lb.sampleReverse ? (double) (totalSamp - 1) : 0.0; + lb.sampleDirection = lb.sampleReverse ? -1 : 1; + + // Notify UI of trigger fire + const auto tScope = triggerFifo.write (1); + if (tScope.blockSize1 > 0) triggerRing[tScope.startIndex1] = lb.id; + else if (tScope.blockSize2 > 0) triggerRing[tScope.startIndex2] = lb.id; + } + + // --- Advance playhead --- + double playbackRate = (sd->sampleRate / currentSampleRate) * (double) lb.sampleSpeed; + double advance = playbackRate * (double) numSamples; + if (lb.sampleReverse) advance = -advance; + if (lb.sampleDirection < 0) advance = -advance; + + lb.samplePlayhead += advance; + + // Handle loop modes + if (lb.loopModeE == LoopMode::Loop) + { + lb.samplePlayhead = std::fmod (lb.samplePlayhead, (double) totalSamp); + if (lb.samplePlayhead < 0.0) lb.samplePlayhead += (double) totalSamp; + } + else if (lb.loopModeE == LoopMode::Pingpong) + { + // Reflect playhead as many times as needed (handles high speed) + double len = (double) totalSamp; + int safety = 100; + while (safety-- > 0 && (lb.samplePlayhead >= len || lb.samplePlayhead < 0.0)) + { + if (lb.samplePlayhead >= len) + { + lb.samplePlayhead = 2.0 * len - lb.samplePlayhead; + lb.sampleDirection *= -1; + } + if (lb.samplePlayhead < 0.0) + { + lb.samplePlayhead = -lb.samplePlayhead; + lb.sampleDirection *= -1; + } + } + lb.samplePlayhead = juce::jlimit (0.0, len - 1.0, lb.samplePlayhead); + } + else // "oneshot" + { + lb.samplePlayhead = juce::jlimit (0.0, (double) (totalSamp - 1), lb.samplePlayhead); + } + + // --- Compute amplitude over traversed samples (with optional band filtering) --- + float raw; + { + bool useBand = (lb.envBandLo > 25.0f || lb.envBandHi < 19000.0f); + if (useBand) + { + float sr = (float) sd->sampleRate; + lb.envHpf.setHighpass(juce::jlimit(20.0f, 20000.0f, lb.envBandLo), sr); + lb.envLpf.setLowpass(juce::jlimit(20.0f, 20000.0f, lb.envBandHi), sr); + } + + // Iterate through all sample positions the playhead covered this buffer + int steps = juce::jmax(1, (int) std::abs(advance)); + double step = (steps > 1) ? advance / (double) steps : 0.0; + double readHead = lb.samplePlayhead - advance; // start of this buffer's traversal + float sumSq = 0.0f; + + for (int si = 0; si < steps; si++) + { + double rh = readHead + step * (double) si; + // Wrap for loop mode + if (lb.loopModeE == LoopMode::Loop) + { + rh = std::fmod(rh, (double) totalSamp); + if (rh < 0.0) rh += (double) totalSamp; + } + int p = juce::jlimit(0, totalSamp - 1, (int) rh); + float s = sd->buffer.getSample(0, p); + + if (useBand) + { + s = lb.envHpf.process(s); + s = lb.envLpf.process(s); + } + sumSq += s * s; + } + raw = std::sqrt(sumSq / (float) steps) * (lb.envSens / 50.0f); + } + + // --- Attack/release envelope smoothing --- + float ac = std::exp (-1.0f / std::max (1.0f, lb.envAtk * 0.001f * bufferRate)); + float rc = std::exp (-1.0f / std::max (1.0f, lb.envRel * 0.001f * bufferRate)); + if (raw > lb.currentEnvValue) + lb.currentEnvValue = ac * lb.currentEnvValue + (1.0f - ac) * raw; + else + lb.currentEnvValue = rc * lb.currentEnvValue + (1.0f - rc) * raw; + + float cl = juce::jlimit (0.0f, 1.0f, lb.currentEnvValue); + float mp = lb.envInvert ? (1.0f - cl) : cl; + + // Always relative: offset ±depth from resting value (modbus) + { + float depth = lb.rMax; + + float offset; + if (lb.polarityE == Polarity::Up) + offset = mp * depth; + else if (lb.polarityE == Polarity::Down) + offset = -mp * depth; + else + offset = (mp * 2.0f - 1.0f) * depth; + + for (size_t ti = 0; ti < lb.targets.size(); ++ti) + addModOffset (lb.targets[ti].pluginId, lb.targets[ti].paramIndex, offset); + } + + // Write envelope level for UI readback (shares env readback) + if (envIdx < maxEnvReadback) + { + envReadback[envIdx].blockId.store (lb.id); + envReadback[envIdx].level.store (cl); + envIdx++; + } + + // Write playhead position for UI + if (smpIdx < maxSampleReadback) + { + sampleReadback[smpIdx].blockId.store (lb.id); + sampleReadback[smpIdx].playhead.store ((float) (lb.samplePlayhead / (double) totalSamp)); + smpIdx++; + } + } + + // ===== MORPH PAD MODE ===== + else if (lb.modeE == BlockMode::MorphPad && !lb.snapshots.empty() && lb.enabled) + { + float targetX = lb.playheadX; + float targetY = lb.playheadY; + + // -- Trigger detection (for trigger mode) -- + bool shouldTrigger = false; + if (lb.morphModeE == MorphMode::Trigger) + { + juce::String src = lb.morphSource; + + if (src == "midi" && ! blockMidiEvents.empty()) { + for (const auto& ev : blockMidiEvents) { + if (lb.midiCh > 0 && ev.ch != lb.midiCh) continue; + if (lb.midiModeE == MidiTrigMode::CC && ev.isCC && ev.note == lb.midiCC) { shouldTrigger = true; break; } + if (ev.isCC || ev.vel == 0) continue; // skip CCs and note-offs + if (lb.midiModeE == MidiTrigMode::AnyNote) { shouldTrigger = true; break; } + if (lb.midiModeE == MidiTrigMode::SpecificNote && ev.note == lb.midiNote) { shouldTrigger = true; break; } + } + } + if (src == "tempo") { + bool useInternal = (lb.clockSourceE == ClockSource::Internal); + if (useInternal && lb.internalBpm > 0.0f) { + double beatsPerSec = (double) lb.internalBpm / 60.0; + double secsThisBuffer = (double) numSamples / currentSampleRate; + lb.internalPpq += beatsPerSec * secsThisBuffer; + } + double effectivePpq = useInternal ? lb.internalPpq : ppq; + bool canFire = useInternal ? true : playing; + if (canFire) { + float bpt = lb.beatDivBeats; + int currentBeat = (int) std::floor (effectivePpq / bpt); + if (lb.lastBeat < 0) lb.lastBeat = currentBeat; + if (currentBeat != lb.lastBeat) { lb.lastBeat = currentBeat; shouldTrigger = true; } + } + } + if (src == "audio") { + float audioLvl = (lb.audioSrcE == AudioSource::Sidechain) ? scRms : mainRms; + float threshLin = std::pow (10.0f, lb.threshold / 20.0f); + double cooldownSamples = currentSampleRate * 0.1; + if (audioLvl > threshLin && (sampleCounter - lb.lastAudioTrigSample) > cooldownSamples) { + lb.lastAudioTrigSample = sampleCounter; + shouldTrigger = true; + } + } + } + + // -- Auto-Explore mode -- + if (lb.morphModeE == MorphMode::Auto) + { + int numSnaps = (int) lb.snapshots.size(); + + // -- CORRECT time computation -- + // secsPerBuffer = actual wall-clock seconds this buffer covers + float secsPerBuffer = (float) numSamples / (float) currentSampleRate; + float cyclesPerSec; + + if (lb.morphTempoSync) + { + double bpm = (lb.clockSourceE == ClockSource::Internal) ? (double) lb.internalBpm : currentBpm.load(); + if (bpm > 0.0) + { + float divBeats = lb.morphSyncDivBeats; + double beatsPerSec = bpm / 60.0; + cyclesPerSec = (float) (beatsPerSec / divBeats); + } + else + { + cyclesPerSec = 0.5f; // fallback if no BPM + } + } + else + { + // morphSpeed 0..1 → 0.02 Hz .. 4 Hz + float sp = lb.morphSpeed; + cyclesPerSec = 0.02f + sp * sp * 4.0f; + } + + // Phase delta per buffer (radians) — cap at Ï€ to avoid aliasing + float rawPhaseDelta = juce::MathConstants::twoPi * cyclesPerSec * secsPerBuffer; + float phaseDelta = std::min (rawPhaseDelta, juce::MathConstants::pi); + // Linear speed: pad-diameters per second → distance this buffer + float linearDelta = cyclesPerSec * secsPerBuffer; + + // Unified linear speed matching LFO circle tangential velocity: + // LFO circle radius = 0.4, tangential speed = 2Ï€ * r * cps + float padSpeed = juce::MathConstants::twoPi * 0.4f * cyclesPerSec; // pad-units/sec + float distThisBuffer = padSpeed * secsPerBuffer; // distance to travel this buffer + + if (lb.exploreModeE == ExploreMode::Wander) { + // ── Brownian random walk ── + // Acceleration drives direction changes; scaled to padSpeed + float accelMag = padSpeed * 6.0f * secsPerBuffer; + lb.morphVelX += (audioRandom.nextFloat() - 0.5f) * accelMag; + lb.morphVelY += (audioRandom.nextFloat() - 0.5f) * accelMag; + + // Drag — smooth curves with gradual direction changes + float drag = std::exp (-3.0f * secsPerBuffer); + lb.morphVelX *= drag; + lb.morphVelY *= drag; + + // Clamp velocity to padSpeed (same linear speed as LFO) + float velMag = std::sqrt (lb.morphVelX * lb.morphVelX + lb.morphVelY * lb.morphVelY); + if (velMag > padSpeed && velMag > 0.0f) { + float sc = padSpeed / velMag; + lb.morphVelX *= sc; + lb.morphVelY *= sc; + } + + // Integrate position + targetX = lb.playheadX + lb.morphVelX * secsPerBuffer; + targetY = lb.playheadY + lb.morphVelY * secsPerBuffer; + + // Bounce off circle boundary (radius 0.44) + float cdx = targetX - 0.5f, cdy = targetY - 0.5f; + float cdist = std::sqrt (cdx * cdx + cdy * cdy); + if (cdist > 0.44f && cdist > 0.0f) { + float nx = cdx / cdist, ny = cdy / cdist; + float dot = lb.morphVelX * nx + lb.morphVelY * ny; + if (dot > 0.0f) { + lb.morphVelX -= 2.0f * dot * nx; + lb.morphVelY -= 2.0f * dot * ny; + } + targetX = 0.5f + nx * 0.43f; + targetY = 0.5f + ny * 0.43f; + } + } + else if (lb.exploreModeE == ExploreMode::Bounce) { + // ── Billiard ball — same speed as LFO circle ── + float bdx = std::cos (lb.morphAngle) * distThisBuffer; + float bdy = std::sin (lb.morphAngle) * distThisBuffer; + targetX = lb.playheadX + bdx; + targetY = lb.playheadY + bdy; + + // Circular boundary reflection + float cdx = targetX - 0.5f, cdy = targetY - 0.5f; + float cdist = std::sqrt (cdx * cdx + cdy * cdy); + if (cdist > 0.44f && cdist > 0.0f) { + float bnx = cdx / cdist, bny = cdy / cdist; + float dot = std::cos (lb.morphAngle) * bnx + std::sin (lb.morphAngle) * bny; + lb.morphAngle = std::atan2 (std::sin (lb.morphAngle) - 2.0f * dot * bny, + std::cos (lb.morphAngle) - 2.0f * dot * bnx); + lb.morphAngle += (audioRandom.nextFloat() - 0.5f) * 0.25f; + targetX = 0.5f + bnx * 0.43f; + targetY = 0.5f + bny * 0.43f; + } + } + else if (lb.exploreModeE == ExploreMode::Shapes) { + lb.morphLfoPhase += phaseDelta; + while (lb.morphLfoPhase > juce::MathConstants::twoPi) + lb.morphLfoPhase -= juce::MathConstants::twoPi; + + // Accumulate shape rotation (lfoRotation: -1..+1 → ±2 rev/sec) + float rotSpeed = lb.lfoRotation * 2.0f * juce::MathConstants::twoPi; + lb.lfoRotAngle += rotSpeed * secsPerBuffer; + while (lb.lfoRotAngle > juce::MathConstants::twoPi) + lb.lfoRotAngle -= juce::MathConstants::twoPi; + while (lb.lfoRotAngle < -juce::MathConstants::twoPi) + lb.lfoRotAngle += juce::MathConstants::twoPi; + + float t = lb.morphLfoPhase; + // Depth controls shape radius: 0..1 → 0..0.48 + float R = lb.lfoDepth * 0.48f; + + // Compute shape position relative to center + auto [dx, dy] = computeShapeXY (lb.lfoShapeE, t, R); + + // SweepX/SweepY: preserve current position on the non-sweeping axis + if (lb.lfoShapeE == LfoShape::SweepX) dy = lb.playheadY - 0.5f; + if (lb.lfoShapeE == LfoShape::SweepY) dx = lb.playheadX - 0.5f; + + // Apply shape rotation (rotate dx,dy around center) + if (std::abs (lb.lfoRotAngle) > 0.0001f) { + float cosR = std::cos (lb.lfoRotAngle); + float sinR = std::sin (lb.lfoRotAngle); + float rx = dx * cosR - dy * sinR; + float ry = dx * sinR + dy * cosR; + dx = rx; + dy = ry; + } + + targetX = 0.5f + dx; + targetY = 0.5f + dy; + } + else if (lb.exploreModeE == ExploreMode::Orbit && numSnaps > 0) { + lb.morphOrbitPhase += phaseDelta; + while (lb.morphOrbitPhase > juce::MathConstants::twoPi) { + lb.morphOrbitPhase -= juce::MathConstants::twoPi; + lb.morphOrbitTarget = (lb.morphOrbitTarget + 1) % numSnaps; + } + int ot = lb.morphOrbitTarget % numSnaps; + float orbitR = 0.12f; + targetX = lb.snapshots[ot].x + orbitR * std::cos (lb.morphOrbitPhase); + targetY = lb.snapshots[ot].y + orbitR * std::sin (lb.morphOrbitPhase); + } + else if (lb.exploreModeE == ExploreMode::Path && numSnaps > 1) { + // Path speed: traverse one segment in the same time + // as one LFO revolution + lb.morphPathProgress += cyclesPerSec * secsPerBuffer; + if (lb.morphPathProgress >= 1.0f) { + lb.morphPathProgress -= 1.0f; + lb.morphPathIndex = (lb.morphPathIndex + 1) % numSnaps; + } + int curr = lb.morphPathIndex % numSnaps; + int next = (lb.morphPathIndex + 1) % numSnaps; + float t = lb.morphPathProgress; + t = t * t * (3.0f - 2.0f * t); // smoothstep + targetX = lb.snapshots[curr].x + t * (lb.snapshots[next].x - lb.snapshots[curr].x); + targetY = lb.snapshots[curr].y + t * (lb.snapshots[next].y - lb.snapshots[curr].y); + } + + // Final circular clamp (all explore modes) + float fcx = targetX - 0.5f, fcy = targetY - 0.5f; + float fcd = std::sqrt (fcx * fcx + fcy * fcy); + if (fcd > 0.48f) { float s = 0.48f / fcd; targetX = 0.5f + fcx * s; targetY = 0.5f + fcy * s; } + + lb.playheadX = targetX; + lb.playheadY = targetY; + } + + // -- Trigger mode: apply jump/step on trigger -- + if (lb.morphModeE == MorphMode::Trigger && shouldTrigger) + { + int numSnaps = (int) lb.snapshots.size(); + if (lb.morphActionE == MorphAction::Jump) { + int ri = audioRandom.nextInt (numSnaps); + targetX = lb.snapshots[ri].x; + targetY = lb.snapshots[ri].y; + } else if (lb.morphActionE == MorphAction::Step) { + if (lb.stepOrderE == StepOrder::Cycle) + lb.morphStepIndex = (lb.morphStepIndex + 1) % numSnaps; + else + lb.morphStepIndex = audioRandom.nextInt (numSnaps); + targetX = lb.snapshots[lb.morphStepIndex].x; + targetY = lb.snapshots[lb.morphStepIndex].y; + } + lb.playheadX = targetX; + lb.playheadY = targetY; + + // Fire trigger notification to UI + const auto tScope = triggerFifo.write (1); + if (tScope.blockSize1 > 0) triggerRing[tScope.startIndex1] = lb.id; + else if (tScope.blockSize2 > 0) triggerRing[tScope.startIndex2] = lb.id; + } + + // -- Apply jitter -- + float finalX = lb.playheadX; + float finalY = lb.playheadY; + if (lb.jitter > 0.001f) { + finalX += (audioRandom.nextFloat() - 0.5f) * lb.jitter * 0.2f; + finalY += (audioRandom.nextFloat() - 0.5f) * lb.jitter * 0.2f; + // Circular clamp + float jdx = finalX - 0.5f, jdy = finalY - 0.5f; + float jd = std::sqrt (jdx * jdx + jdy * jdy); + if (jd > 0.48f) { float js = 0.48f / jd; finalX = 0.5f + jdx * js; finalY = 0.5f + jdy * js; } + } + + // -- Smooth playhead (glide) -- + // Auto-explore modes produce continuous motion — pass through directly. + // Only trigger/manual modes need glide smoothing (discrete jumps). + if (lb.morphModeE == MorphMode::Auto) { + lb.morphSmoothX = finalX; + lb.morphSmoothY = finalY; + } else { + float glideTimeSec = std::max (0.001f, lb.morphGlide * 0.001f); + float secsThisBuffer = (float) numSamples / (float) currentSampleRate; + float glideCoeff = std::exp (-secsThisBuffer / glideTimeSec); + lb.morphSmoothX = glideCoeff * lb.morphSmoothX + (1.0f - glideCoeff) * finalX; + lb.morphSmoothY = glideCoeff * lb.morphSmoothY + (1.0f - glideCoeff) * finalY; + } + + // Snap to target when close enough — prevents asymptotic residual + // from keeping IDW firing indefinitely + if (std::abs (lb.morphSmoothX - finalX) < 1e-5f) lb.morphSmoothX = finalX; + if (std::abs (lb.morphSmoothY - finalY) < 1e-5f) lb.morphSmoothY = finalY; + + // -- Only apply IDW when the smoothed playhead has actually moved -- + // This prevents constant parameter overwrites when the dot is stationary, + // allowing the user to manually tweak hosted plugin parameters. + float dsx = lb.morphSmoothX - lb.prevAppliedX; + float dsy = lb.morphSmoothY - lb.prevAppliedY; + if (dsx * dsx + dsy * dsy > 1e-6f) + { + lb.prevAppliedX = lb.morphSmoothX; + lb.prevAppliedY = lb.morphSmoothY; + + // -- IDW Interpolation with exact radius boundary -- + // snapRadius is the actual distance (0.05..1.0) in pad coordinates. + // Weight = (1 - dist/radius)^2 — drops to exactly zero at boundary. + float radius = lb.snapRadius; + float weights[12] = {}; + int numSnaps = juce::jmin ((int) lb.snapshots.size(), 12); + float totalWeight = 0.0f; + for (int si = 0; si < numSnaps; ++si) { + float dx = lb.morphSmoothX - lb.snapshots[si].x; + float dy = lb.morphSmoothY - lb.snapshots[si].y; + float dist = std::sqrt (dx * dx + dy * dy); + float w = 0.0f; + if (dist < radius) { + float t = 1.0f - dist / radius; + w = t * t; // quadratic: smooth at center, zero at boundary + } + weights[si] = w; + totalWeight += w; + } + if (totalWeight > 0.0f) + { + for (int si = 0; si < numSnaps; ++si) weights[si] /= totalWeight; + + // -- Mix target values and apply -- + for (size_t ti = 0; ti < lb.targets.size(); ++ti) { + float mixed = 0.0f; + for (int si = 0; si < numSnaps; ++si) { + if (ti < lb.snapshots[si].targetValues.size()) + mixed += weights[si] * lb.snapshots[si].targetValues[ti]; + } + mixed = juce::jlimit (0.0f, 1.0f, mixed); + writeModBase (lb.targets[ti].pluginId, lb.targets[ti].paramIndex, mixed); + } + } + } + + // -- Write playhead readback for UI -- + if (morphIdx < maxMorphReadback) { + // Circular clamp before sending to UI (r=0.45) + float rbX = lb.morphSmoothX, rbY = lb.morphSmoothY; + float rbdx = rbX - 0.5f, rbdy = rbY - 0.5f; + float rbd = std::sqrt (rbdx * rbdx + rbdy * rbdy); + if (rbd > 0.48f) { float rbs = 0.48f / rbd; rbX = 0.5f + rbdx * rbs; rbY = 0.5f + rbdy * rbs; } + morphReadback[morphIdx].blockId.store (lb.id); + morphReadback[morphIdx].headX.store (rbX); + morphReadback[morphIdx].headY.store (rbY); + morphReadback[morphIdx].rotAngle.store (lb.lfoRotAngle); + morphIdx++; + } + } + + // ===== SHAPES BLOCK: restore base values on disable or mode change ===== + else if (lb.shapeWasEnabled && !lb.targets.empty() + && (!lb.enabled || (lb.modeE != BlockMode::Shapes && lb.modeE != BlockMode::ShapesRange))) + { + lb.shapeWasEnabled = false; + // Snap all params back to their user-defined base values + for (size_t ti = 0; ti < lb.targets.size() && ti < lb.targetBaseValues.size(); ++ti) + { + float base = lb.targetBaseValues[ti]; + setParamDirect (lb.targets[ti].pluginId, lb.targets[ti].paramIndex, base); + lb.targetLastWritten[ti] = base; + int _s = slotForId (lb.targets[ti].pluginId); + if (_s >= 0 && lb.targets[ti].paramIndex < kMaxParams) + paramWritten[_s][lb.targets[ti].paramIndex] = base; + } + } + + // ===== SHAPES BLOCK (including shapes_range) ===== + else if ((lb.modeE == BlockMode::Shapes || lb.modeE == BlockMode::ShapesRange) && lb.enabled && !lb.targets.empty()) + { + lb.shapeWasEnabled = true; + float twoPi = juce::MathConstants::twoPi; + float secsPerBuffer = (float) numSamples / (float) currentSampleRate; + + bool dawSynced = (lb.shapeTempoSync && lb.clockSourceE != ClockSource::Internal); + + // Transport gating: DAW-synced shapes pause when transport stops + if (dawSynced && !playing) + { + lb.shapeWasPlaying = false; + // Still write dot readback so UI shows frozen position + if (morphIdx < maxMorphReadback) { + float t = lb.shapePhase + lb.shapePhaseOffset * twoPi; + while (t > twoPi) t -= twoPi; + float effectiveSize = (lb.modeE == BlockMode::ShapesRange) ? 1.0f : lb.shapeSize; + float R = effectiveSize * 0.48f; + float dx = R * std::cos(t), dy = R * std::sin(t); + if (std::abs(lb.shapeRotAngle) > 0.0001f) { + float cosR = std::cos(lb.shapeRotAngle), sinR = std::sin(lb.shapeRotAngle); + float rx = dx * cosR - dy * sinR, ry = dx * sinR + dy * cosR; + dx = rx; dy = ry; + } + morphReadback[morphIdx].blockId.store(lb.id); + morphReadback[morphIdx].headX.store(0.5f + dx); + morphReadback[morphIdx].headY.store(0.5f + dy); + morphReadback[morphIdx].rotAngle.store(lb.shapeRotAngle); + morphIdx++; + } + continue; + } + + // PPQ sync: snap phase to beat position on transport start + if (dawSynced && playing && !lb.shapeWasPlaying) + { + float beatsPerCycle = lb.shapeSyncDivBeats; + if (beatsPerCycle > 0.0f) + { + double beatsIntoLoop = std::fmod(ppq, (double) beatsPerCycle); + if (beatsIntoLoop < 0.0) beatsIntoLoop += (double) beatsPerCycle; + lb.shapePhase = (float)(beatsIntoLoop / beatsPerCycle) * twoPi; + } + } + lb.shapeWasPlaying = playing; + + // MIDI retrigger: reset phase on note-on (with channel filtering + soft-engage reset) + if (lb.shapeTriggerE == ShapeTrigger::Midi && ! blockMidiEvents.empty()) + { + for (const auto& ev : blockMidiEvents) + { + if (ev.isCC || ev.vel == 0) continue; // skip CCs and note-offs + if (lb.midiCh > 0 && ev.ch != lb.midiCh) continue; + lb.shapePhase = 0.0f; + // Reset soft-engage depth so it ramps in smoothly from 0 + lb.smoothedShapeDepth = 0.0f; + for (auto& sv : lb.smoothedRangeValues) sv = 0.0f; + break; + } + } + + // Advance phase based on speed + float speedHz; + // Determine effective BPM from chosen clock source + float effectiveBpm = (lb.clockSourceE == ClockSource::Internal) ? lb.internalBpm : (float) currentBpm.load(); + if (lb.shapeTempoSync && effectiveBpm > 0.0f) + { + float beatsPerCycle = lb.shapeSyncDivBeats; + float secsPerCycle = beatsPerCycle * 60.0f / effectiveBpm; + speedHz = 1.0f / std::max(0.001f, secsPerCycle); + } + else + { + speedHz = 0.02f + lb.shapeSpeed * lb.shapeSpeed * lb.shapeSpeed * 4.98f; // exponential: 0.02–5 Hz, most range in slow end + } + float phaseDelta = speedHz * twoPi * secsPerBuffer; + lb.shapePhase += phaseDelta; + while (lb.shapePhase > twoPi) lb.shapePhase -= twoPi; + + // Accumulate spin (exponential: gentle near center, fast at extremes) + float spinNorm = lb.shapeSpin; // -1..+1 + float spinExp = spinNorm * spinNorm * (spinNorm > 0 ? 1.0f : -1.0f); // preserve sign, square magnitude + float spinSpeed = spinExp * 2.0f * twoPi; + lb.shapeRotAngle += spinSpeed * secsPerBuffer; + while (lb.shapeRotAngle > twoPi) lb.shapeRotAngle -= twoPi; + while (lb.shapeRotAngle < -twoPi) lb.shapeRotAngle += twoPi; + + // Apply user phase offset (0..1 maps to 0..2π) + float t = lb.shapePhase + lb.shapePhaseOffset * twoPi; + while (t > twoPi) t -= twoPi; + // shapes_range always uses max size + float effectiveSize = (lb.modeE == BlockMode::ShapesRange) ? 1.0f : lb.shapeSize; + float R = effectiveSize * 0.48f; + + // ── Shape computation (uses shared helper) ── + auto [dx, dy] = computeShapeXY (lb.shapeTypeE, t, R); + + + // Apply spin rotation + if (std::abs(lb.shapeRotAngle) > 0.0001f) { + float cosR = std::cos(lb.shapeRotAngle); + float sinR = std::sin(lb.shapeRotAngle); + float rx = dx * cosR - dy * sinR; + float ry = dx * sinR + dy * cosR; + dx = rx; dy = ry; + } + + // ── Tracking: reduce 2D → 1D ── + float normR = std::max(R, 0.001f); + float output = 0.0f; + if (lb.shapeTrackingE == ShapeTracking::Horizontal) { + output = dx / normR; // -1..+1 + } else if (lb.shapeTrackingE == ShapeTracking::Vertical) { + output = dy / normR; // -1..+1 + } else { // "distance" + output = std::sqrt(dx*dx + dy*dy) / normR; // 0..+1 + } + + // Apply polarity — compute modVal per-target for shapes_range + bool isShapesRange = (lb.modeE == BlockMode::ShapesRange); + + // ── Apply to targets ── + auto n = lb.targets.size(); + if (lb.targetBaseValues.size() != n) + { + lb.targetBaseValues.resize (n); + lb.targetLastWritten.resize (n, -1.0f); + lb.targetExtPause.resize (n, 0); + for (size_t ti = 0; ti < n; ++ti) + { + // shapes_range: use JS-supplied base values (anchor positions) + if (isShapesRange && ti < lb.targetRangeBaseValues.size()) + lb.targetBaseValues[ti] = lb.targetRangeBaseValues[ti]; + else + lb.targetBaseValues[ti] = getParamValue (lb.targets[ti].pluginId, lb.targets[ti].paramIndex); + } + } + + // shapes_range: always pick up latest JS-supplied bases + // (e.g. after randomize updates targetRangeBases → syncBlocksToHost) + if (isShapesRange) + { + for (size_t ti = 0; ti < n && ti < lb.targetRangeBaseValues.size(); ++ti) + lb.targetBaseValues[ti] = lb.targetRangeBaseValues[ti]; + } + + // Rate-correct smoothing coefficient for ~80ms ramp + float rampCoeff = 1.0f - std::exp (-secsPerBuffer / 0.08f); + + for (size_t ti = 0; ti < n; ++ti) + { + // Per-param depth: use targetRangeValues for shapes_range, else global shapeDepth + float targetDepth = isShapesRange + ? (ti < lb.targetRangeValues.size() ? lb.targetRangeValues[ti] : 0.0f) + : lb.shapeDepth; + + // Soft-engage: smoothly ramp effective depth to prevent jumps + float depth; + if (isShapesRange) + { + if (lb.smoothedRangeValues.size() <= ti) + lb.smoothedRangeValues.resize (ti + 1, 0.0f); + + float prevSmoothed = lb.smoothedRangeValues[ti]; + lb.smoothedRangeValues[ti] += rampCoeff * (targetDepth - lb.smoothedRangeValues[ti]); + depth = lb.smoothedRangeValues[ti]; + + // Recapture base when modulation first engages (0 → non-zero) + if (std::abs (prevSmoothed) < 0.001f && std::abs (depth) >= 0.001f) + lb.targetBaseValues[ti] = getParamValue (lb.targets[ti].pluginId, lb.targets[ti].paramIndex); + } + else + { + // Regular shapes: ramp shapeDepth with soft-engage + float prevSD = lb.smoothedShapeDepth; + lb.smoothedShapeDepth += rampCoeff * (targetDepth - lb.smoothedShapeDepth); + depth = lb.smoothedShapeDepth; + + // Recapture base when modulation first engages + if (ti == 0 && std::abs (prevSD) < 0.001f && std::abs (depth) >= 0.001f) + { + for (size_t ri = 0; ri < n; ++ri) + lb.targetBaseValues[ri] = getParamValue (lb.targets[ri].pluginId, lb.targets[ri].paramIndex); + } + } + + // Check if user is currently dragging this param + int _tSlot = slotForId (lb.targets[ti].pluginId); + bool isTouched = (_tSlot >= 0 && lb.targets[ti].paramIndex < kMaxParams + && paramTouched[_tSlot][lb.targets[ti].paramIndex].load (std::memory_order_acquire)); + + float modVal = 0.0f; + if (lb.shapePolarityE == Polarity::Bipolar) { + modVal = output * std::abs(depth); + } else if (lb.shapePolarityE == Polarity::Unipolar) { + float norm = (lb.shapeTrackingE == ShapeTracking::Distance) ? output : (output + 1.0f) * 0.5f; + modVal = norm * depth; + } else if (lb.shapePolarityE == Polarity::Up) { + float norm = (lb.shapeTrackingE == ShapeTracking::Distance) ? output : std::abs(output); + modVal = norm * std::abs(depth); + } else { // "down" + float norm = (lb.shapeTrackingE == ShapeTracking::Distance) ? output : std::abs(output); + modVal = -norm * std::abs(depth); + } + // Always relative — modbus handles base resolution + float newVal = modVal; // Raw offset for modbus + + // Detect external param changes (base tracking for shapes) + if (!isShapesRange && !isTouched) + { + float cur = getParamValue (lb.targets[ti].pluginId, lb.targets[ti].paramIndex); + int _sl = slotForId (lb.targets[ti].pluginId); + float _pw = (_sl >= 0 && lb.targets[ti].paramIndex < kMaxParams) ? paramWritten[_sl][lb.targets[ti].paramIndex] : -1.0f; + bool extChanged = false; + if (_pw > -0.5f && std::abs (cur - _pw) > 0.02f) + extChanged = true; + else if (_pw < -0.5f && lb.targetLastWritten[ti] >= 0.0f && std::abs (cur - lb.targetLastWritten[ti]) > 0.02f) + extChanged = true; + if (extChanged && ti < lb.targetExtPause.size()) + lb.targetExtPause[ti] = std::max (30, (int)(1.0f / secsPerBuffer)); + } + + // Decrement external pause counter + bool extPaused = false; + if (ti < lb.targetExtPause.size() && lb.targetExtPause[ti] > 0) + { + lb.targetExtPause[ti]--; + extPaused = true; + } + + // When touched or externally paused: skip writing (no fighting), + // but modVal was still computed so LFO cycle continues smoothly. + if (isTouched || extPaused) + { + lb.targetLastWritten[ti] = newVal; + // Sync paramWritten to current param value so external-change + // detection doesn't re-fire every buffer (which would reset + // the cooldown endlessly and cause flicker) + if (extPaused) + { + int _s = slotForId (lb.targets[ti].pluginId); + if (_s >= 0 && lb.targets[ti].paramIndex < kMaxParams) + paramWritten[_s][lb.targets[ti].paramIndex] = getParamValue (lb.targets[ti].pluginId, lb.targets[ti].paramIndex); + } + continue; + } + + // Skip writing when depth is effectively zero — no modulation to apply. + // Without this, shapes_range targets with range=0 would continuously + // write base+0=base, fighting any user knob movements. + if (std::abs (depth) < 0.001f) + continue; + + addModOffset (lb.targets[ti].pluginId, lb.targets[ti].paramIndex, newVal); + lb.targetLastWritten[ti] = newVal; + } + + // -- Write dot readback for UI -- + if (morphIdx < maxMorphReadback) { + float dotX = 0.5f + dx, dotY = 0.5f + dy; + morphReadback[morphIdx].blockId.store(lb.id); + morphReadback[morphIdx].headX.store(dotX); + morphReadback[morphIdx].headY.store(dotY); + morphReadback[morphIdx].rotAngle.store(lb.shapeRotAngle); + morphReadback[morphIdx].modOutput.store(output); + morphIdx++; + } + } + // ===== LANE CLIPS ===== + else if (lb.modeE == BlockMode::Lane && lb.enabled && !lb.laneClips.empty()) + { + float secsPerBuffer = (float) numSamples / (float) currentSampleRate; + int laneIdx = 0; + + // Advance internal beat accumulator for this block + bool useInternal = (lb.clockSourceE == ClockSource::Internal); + if (useInternal && lb.internalBpm > 0.0f) + { + double beatsPerSec = (double) lb.internalBpm / 60.0; + lb.internalPpq += beatsPerSec * (double) secsPerBuffer; + } + + for (auto& lc : lb.laneClips) + { + bool hasDriftData = !lc.morphMode && (std::abs(lc.drift) > 0.001f && lc.driftRange > 0.001f); + bool hasCurveData = !lc.pts.empty() || hasDriftData; + bool hasMorphData = lc.morphMode && lc.morphSnapshots.size() >= 2; + if (lc.muted || (!hasCurveData && !hasMorphData)) { laneIdx++; continue; } + + // Calculate loop duration in seconds + float loopSecs; + bool dawSynced = (lc.synced && lb.clockSourceE != ClockSource::Internal); + + if (lc.loopLenFree) + { + loopSecs = std::max(0.1f, lc.freeSecs); + } + else + { + float loopBeats = lc.loopLenBeats; + + float bpm = dawSynced + ? (float) currentBpm.load() + : lb.internalBpm; + if (bpm <= 0.0f) bpm = 120.0f; + loopSecs = loopBeats * 60.0f / bpm; + } + + // ── ONESHOT TRIGGER DETECTION ── + if (lc.oneshotMode) + { + bool shouldTrigger = false; + + if (lc.trigSourceE == 0) { // Manual + if (lc.manualTrigger) + { + lc.manualTrigger = false; + shouldTrigger = true; + } + } + else if (lc.trigSourceE == 1) { // MIDI + for (const auto& ev : blockMidiEvents) { + if (ev.isCC) continue; + bool noteMatch = (lc.trigMidiNote < 0 || ev.note == lc.trigMidiNote); + bool chMatch = (lc.trigMidiCh == 0 || ev.ch == lc.trigMidiCh); + if (noteMatch && chMatch) { + if (ev.vel > 0) { + if (lc.trigHold) lc.midiNoteHeld = true; + if (!lc.oneshotActive || lc.trigRetrigger) + shouldTrigger = true; + } else if (lc.trigHold) { + lc.midiNoteHeld = false; // note-off (gate mode only) + } + } + } + } + else if (lc.trigSourceE == 2) { // Audio + float rms = lc.trigAudioSrc ? scRms : mainRms; + if (rms > lc.trigThresholdLin && !lc.oneshotActive) + shouldTrigger = true; + } + + if (shouldTrigger && (lc.trigRetrigger || lc.oneshotDone || !lc.oneshotActive)) + { + lc.playhead = 0.0; + lc.oneshotActive = true; + lc.oneshotDone = false; + lc.driftPhase = 0.0f; + } + + // MIDI gate: if note released while active, stop (only in hold mode) + if (lc.trigHold && lc.trigSourceE == 1 && !lc.midiNoteHeld && lc.oneshotActive) + { + lc.oneshotActive = false; + lc.oneshotDone = true; + } + + if (!lc.oneshotActive) + { + // Write idle readback and skip processing + int rbIdx = numActiveLanes.load(); + if (rbIdx < maxLaneReadback) + { + laneReadback[rbIdx].blockId.store(lb.id); + laneReadback[rbIdx].laneIdx.store(laneIdx); + laneReadback[rbIdx].playhead.store((float) lc.playhead); + laneReadback[rbIdx].value.store(0.5f); + laneReadback[rbIdx].active.store(false); + numActiveLanes.store(rbIdx + 1); + } + laneIdx++; + continue; + } + + // Advance playhead: gate mode loops, trigger mode stops at end + float playDelta = secsPerBuffer / std::max(0.001f, loopSecs); + lc.playhead += playDelta; + if (lc.playhead >= 1.0) + { + if (lc.trigHold && lc.trigSourceE == 1 && lc.midiNoteHeld) { + // Loop while note is held (gate mode) + while (lc.playhead >= 1.0) lc.playhead -= 1.0; + } else { + lc.playhead = 1.0; + lc.oneshotActive = false; + lc.oneshotDone = true; + } + } + } + else + { + // ── NORMAL LOOP MODE (existing logic) ── + + // Transport gating: DAW-synced lanes pause when transport stops + if (dawSynced && !playing) + { + lc.wasPlaying = false; + // Still write readback so UI shows frozen playhead + int rbIdx = numActiveLanes.load(); + if (rbIdx < maxLaneReadback) + { + laneReadback[rbIdx].blockId.store(lb.id); + laneReadback[rbIdx].laneIdx.store(laneIdx); + laneReadback[rbIdx].playhead.store((float) lc.playhead); + laneReadback[rbIdx].value.store(0.5f); + laneReadback[rbIdx].active.store(true); + numActiveLanes.store(rbIdx + 1); + } + laneIdx++; + continue; + } + + // PPQ sync: snap playhead to beat position on transport start + if (dawSynced && playing && !lc.wasPlaying && !lc.loopLenFree) + { + double beatsIntoLoop = std::fmod(ppq, (double) lc.loopLenBeats); + if (beatsIntoLoop < 0.0) beatsIntoLoop += (double) lc.loopLenBeats; + lc.playhead = beatsIntoLoop / (double) lc.loopLenBeats; + } + lc.wasPlaying = playing; + + // Beat-synced forward mode: derive playhead directly from PPQ/internalPpq + // so all lanes with the same loopLen show identical positions + bool beatSynced = !lc.loopLenFree && lc.playModeE == LanePlayMode::Forward; + if (beatSynced && (dawSynced || useInternal)) + { + double effectivePpq = dawSynced ? ppq : lb.internalPpq; + double beatsIntoLoop = std::fmod(effectivePpq, (double) lc.loopLenBeats); + if (beatsIntoLoop < 0.0) beatsIntoLoop += (double) lc.loopLenBeats; + lc.playhead = beatsIntoLoop / (double) lc.loopLenBeats; + } + else + { + float playDelta = secsPerBuffer / std::max(0.001f, loopSecs); + + // Playhead modes + if (lc.playModeE == LanePlayMode::Reverse) + { + lc.playhead -= playDelta; + while (lc.playhead < 0.0) lc.playhead += 1.0; + } + else if (lc.playModeE == LanePlayMode::Pingpong) + { + lc.playhead += playDelta * lc.direction; + if (lc.playhead >= 1.0) { lc.playhead = 2.0 - lc.playhead; lc.direction = -1; } + if (lc.playhead <= 0.0) { lc.playhead = -lc.playhead; lc.direction = 1; } + lc.playhead = juce::jlimit(0.0, 1.0, lc.playhead); + } + else if (lc.playModeE == LanePlayMode::Random) + { + lc.playhead += playDelta; + if (lc.playhead >= 1.0) { lc.playhead = audioRandom.nextFloat(); } + } + else // "forward" (free-running for non-synced or free-length) + { + lc.playhead += playDelta; + while (lc.playhead >= 1.0) lc.playhead -= 1.0; + } + } + + } // end else (normal loop mode) + + float pos = (float) lc.playhead; + while (pos >= 1.0f) pos -= 1.0f; + while (pos < 0.0f) pos += 1.0f; + + // ══════════ MORPH LANE OUTPUT ══════════ + if (lc.morphMode && lc.morphSnapshots.size() >= 2) + { + auto& snaps = lc.morphSnapshots; + int numSnaps = (int) snaps.size(); + + // Find bracketing snapshots + int idx = numSnaps - 2; // default to last pair + for (int si = 0; si < numSnaps - 1; ++si) + { + if (pos <= snaps[si + 1].position) { idx = si; break; } + } + + auto& snapA = snaps[idx]; + auto& snapB = snaps[idx + 1]; + float gap = snapB.position - snapA.position; + float blend = 0.0f; + + if (gap > 0.0001f) + { + // Calculate hold zones + float holdA = gap * (snapA.hold * 0.5f); + float holdB = gap * (snapB.hold * 0.5f); + float morphZone = gap - holdA - holdB; + if (morphZone < 0.0f) + { + holdA = gap * 0.5f; + holdB = gap * 0.5f; + morphZone = 0.0f; + } + + float localPh = pos - snapA.position; + + if (localPh <= holdA) + blend = 0.0f; + else if (localPh >= gap - holdB) + blend = 1.0f; + else + { + blend = (localPh - holdA) / std::max(0.0001f, morphZone); + + // Per-snapshot transition curve (applied to destination) + switch (snapB.curve) + { + case 0: // smooth (cosine S-curve) + blend = 0.5f - 0.5f * std::cos(blend * 3.14159265f); + break; + case 1: // linear — no transform + break; + case 2: // sharp (ease-in) + blend = blend * blend; + break; + case 3: // late (ease-out) + blend = 1.0f - (1.0f - blend) * (1.0f - blend); + break; + } + } + + // Global step override + if (lc.interpE == LaneInterp::Step) + blend = 0.0f; + } + + // ── AUDIO-THREAD-SAFE morph interpolation ── + // Uses pre-parsed integer arrays — ZERO heap allocations. + // parsedValues are sorted by (pluginId, paramIndex) so we can + // iterate snapA and snapB by index when sizes match, or fall back + // to scanning snapB for mismatched sizes. + + float snapDepth = snapB.depth; + float snapWarp = snapB.warp; + int snapSteps = snapB.steps; + + auto& pvA = snapA.parsedValues; + auto& pvB = snapB.parsedValues; + + // Fast path: both snapshots have same size and same key order + // (common case — all snapshots capture same params) + bool sameSize = (pvA.size() == pvB.size()); + + for (int pi = 0; pi < (int)pvA.size(); ++pi) + { + auto& pA = pvA[pi]; + + // Check if this param is in this lane's target list + // Binary search on pre-sorted targetKeySorted — O(log n), no strings + if (!lc.targetKeySorted.empty()) + { + LogicBlock::LaneClip::IntKey searchKey { pA.pluginId, pA.paramIndex }; + if (!std::binary_search(lc.targetKeySorted.begin(), + lc.targetKeySorted.end(), searchKey)) + continue; + } + + // Find valB: fast if same index matches, else linear scan + float valB; + bool foundB = false; + if (sameSize && pvB[pi].pluginId == pA.pluginId + && pvB[pi].paramIndex == pA.paramIndex) + { + valB = pvB[pi].value; + foundB = true; + } + else + { + // Fallback: linear scan (rare — only when snapshots differ) + for (int bi = 0; bi < (int)pvB.size(); ++bi) + { + if (pvB[bi].pluginId == pA.pluginId + && pvB[bi].paramIndex == pA.paramIndex) + { + valB = pvB[bi].value; + foundB = true; + break; + } + } + } + if (!foundB) continue; + + float morphed = pA.value + (valB - pA.value) * blend; + + // Per-snapshot depth: scale toward center + morphed = 0.5f + (morphed - 0.5f) * snapDepth; + + // Per-snapshot warp: S-curve contrast (bipolar) + if (std::abs(snapWarp) > 0.01f) + { + float w = snapWarp * 0.01f; + if (w > 0.0f) + { + float t = std::tanh(w * 3.0f * (morphed * 2.0f - 1.0f)); + morphed = 0.5f + 0.5f * t / std::tanh(w * 3.0f); + } + else + { + float aw = -w; + float centered = morphed * 2.0f - 1.0f; + float sign = centered >= 0.0f ? 1.0f : -1.0f; + float ac = std::abs(centered); + morphed = 0.5f + 0.5f * sign * std::pow(ac, 1.0f / (1.0f + aw * 3.0f)); + } + } + + // Per-snapshot steps: quantize output + if (snapSteps >= 2) + { + float s = (float)snapSteps; + morphed = std::round(morphed * (s - 1.0f)) / (s - 1.0f); + } + + morphed = juce::jlimit(0.0f, 1.0f, morphed); + + // Per-param drift: each parameter gets unique organic variation + // Uses per-snapshot drift/driftRange + lane-level driftScale + float snapDriftNorm = snapB.drift / 50.0f; // -50..+50 → -1..+1 + float driftAmt = std::abs(snapDriftNorm); + float driftRangeNorm = snapB.driftRange / 100.0f; + if (driftAmt > 0.001f && driftRangeNorm > 0.001f) + { + auto hashI = [](int32_t n) -> float { + uint32_t h = (uint32_t)n; + h ^= h >> 16; h *= 0x45d9f3bu; h ^= h >> 16; h *= 0x45d9f3bu; h ^= h >> 16; + return ((float)(h & 0xFFFF) / 32768.0f) - 1.0f; + }; + auto smoothNoise = [&hashI](float phase) -> float { + int i0 = (int)std::floor(phase); + float frac = phase - (float)i0; + float v0 = hashI(i0 - 1), v1 = hashI(i0); + float v2 = hashI(i0 + 1), v3 = hashI(i0 + 2); + float a = -0.5f * v0 + 1.5f * v1 - 1.5f * v2 + 0.5f * v3; + float b = v0 - 2.5f * v1 + 2.0f * v2 - 0.5f * v3; + float c = -0.5f * v0 + 0.5f * v2; + return ((a * frac + b) * frac + c) * frac + v1; + }; + + float baseFreq = (snapDriftNorm > 0.0f) + ? (1.0f + driftAmt * 2.0f) + : (4.0f + driftAmt * 10.0f); + float phaseScale = lc.loopLenBeats / std::max(0.25f, snapB.driftScaleBeats); + float sharpness = std::max(0.0f, (driftAmt - 0.7f) / 0.3f); + float freq = baseFreq * (1.0f + sharpness * 2.0f) * phaseScale; + + float paramSeed = hashI(pA.pluginId * 1000 + pA.paramIndex) * 100.0f; + + float p1 = (float)lc.playhead * freq + paramSeed; + float p2 = (float)lc.playhead * freq * 2.37f + 7.13f + paramSeed; + float noise = smoothNoise(p1) * 0.7f + smoothNoise(p2) * 0.3f; + + if (sharpness > 0.01f) + { + float p3 = (float)lc.playhead * freq * 5.19f + 13.7f + paramSeed; + noise = noise * (1.0f - sharpness * 0.3f) + smoothNoise(p3) * sharpness * 0.3f; + } + + morphed = juce::jlimit(0.0f, 1.0f, morphed + noise * driftRangeNorm); + } + + writeModBase(pA.pluginId, pA.paramIndex, morphed); + } + + // Readback + float readbackVal = blend; + int rbIdx = numActiveLanes.load(); + if (rbIdx < maxLaneReadback) + { + laneReadback[rbIdx].blockId.store(lb.id); + laneReadback[rbIdx].laneIdx.store(laneIdx); + laneReadback[rbIdx].playhead.store((float) lc.playhead); + laneReadback[rbIdx].value.store(readbackVal); + laneReadback[rbIdx].active.store(lc.oneshotMode ? lc.oneshotActive : true); + numActiveLanes.store(rbIdx + 1); + } + laneIdx++; + } + // ══════════ CURVE LANE OUTPUT (existing) ══════════ + else + { + // Evaluate curve at position + float value = 0.5f; + auto& pts = lc.pts; + int n = (int) pts.size(); + + if (n == 1) + { + value = pts[0].y; + } + else if (pos <= pts[0].x) + { + value = pts[0].y; + } + else if (pos >= pts[n - 1].x) + { + value = pts[n - 1].y; + } + else + { + int seg = 0; + for (int si = 0; si < n - 1; ++si) + { + if (pos >= pts[si].x && pos < pts[si + 1].x) + { seg = si; break; } + } + float x0 = pts[seg].x, x1 = pts[seg + 1].x; + float y0 = pts[seg].y, y1 = pts[seg + 1].y; + float t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0f; + + if (lc.interpE == LaneInterp::Step) + value = y0; + else if (lc.interpE == LaneInterp::Smooth) + { + float ts = t * t * (3.0f - 2.0f * t); + value = y0 + (y1 - y0) * ts; + } + else + value = y0 + (y1 - y0) * t; + } + + // y=0 top → param=1, y=1 bottom → param=0 + float paramVal = 1.0f - value; + + // Depth: scale toward center (0.5) + paramVal = 0.5f + (paramVal - 0.5f) * lc.depth; + + // Warp: S-curve contrast, bipolar + if (std::abs(lc.warp) > 0.001f) + { + float centered = (paramVal - 0.5f) * 2.0f; // -1..+1 + if (lc.warp > 0.0f) + { + // Positive warp: compress (S-curve via tanh) + float k = 1.0f + lc.warp * 8.0f; + float shaped = std::tanh(centered * k) / std::tanh(k); + paramVal = shaped * 0.5f + 0.5f; + } + else + { + // Negative warp: expand (inverse S-curve — push extremes) + float aw = std::abs(lc.warp); + float sign = centered >= 0.0f ? 1.0f : -1.0f; + float ac = std::abs(centered); + float expanded = std::pow(ac, 1.0f / (1.0f + aw * 3.0f)) * sign; + paramVal = expanded * 0.5f + 0.5f; + } + } + + // Steps: output quantization + int stepsI = (int) lc.steps; + if (stepsI >= 2) + { + paramVal = std::round(paramVal * (float) stepsI) / (float) stepsI; + } + + paramVal = juce::jlimit(0.0f, 1.0f, paramVal); + + // Drift: deterministic organic variation with smooth→sharp character + // Positive (+): slow wandering. Negative (-): fast micro-jitter + // drift is -1..+1 (speed/character), driftRange is 0-100 (amplitude %) + float driftAmt = std::abs(lc.drift); + float driftRangeNorm = lc.driftRange / 100.0f; // 0..1.0 + if (driftAmt > 0.001f && driftRangeNorm > 0.001f) + { + // Hash function: integer → deterministic float -1..+1 + auto hashI = [](int32_t n) -> float { + uint32_t h = (uint32_t)n; + h ^= h >> 16; h *= 0x45d9f3bu; h ^= h >> 16; h *= 0x45d9f3bu; h ^= h >> 16; + return ((float)(h & 0xFFFF) / 32768.0f) - 1.0f; + }; + // Smoothly interpolated value noise (hermite) + auto smoothNoise = [&hashI](float phase) -> float { + int i0 = (int)std::floor(phase); + float frac = phase - (float)i0; + float v0 = hashI(i0 - 1); + float v1 = hashI(i0); + float v2 = hashI(i0 + 1); + float v3 = hashI(i0 + 2); + float a = -0.5f * v0 + 1.5f * v1 - 1.5f * v2 + 0.5f * v3; + float b = v0 - 2.5f * v1 + 2.0f * v2 - 0.5f * v3; + float c = -0.5f * v0 + 0.5f * v2; + return ((a * frac + b) * frac + c) * frac + v1; + }; + // Base frequency: positive=very slow, negative=moderate jitter + float baseFreq = (lc.drift > 0.0f) + ? (1.0f + driftAmt * 2.0f) // slow: 1-3 cycles per scale period + : (4.0f + driftAmt * 10.0f); // jitter: 4-14 cycles per scale period + + // Phase scaling: drift operates on driftScale time, not loop time + float phaseScale = lc.loopLenBeats / std::max(0.25f, lc.driftScaleBeats); + + // Above 70%: boost frequency for sharper character (up to 3x) + float sharpness = std::max(0.0f, (driftAmt - 0.7f) / 0.3f); + float freq = baseFreq * (1.0f + sharpness * 2.0f) * phaseScale; + + float p1 = (float)lc.playhead * freq; + float p2 = (float)lc.playhead * freq * 2.37f + 7.13f; + float noise = smoothNoise(p1) * 0.7f + smoothNoise(p2) * 0.3f; + + // Add 3rd octave at high sharpness for extra texture + if (sharpness > 0.01f) + { + float p3 = (float)lc.playhead * freq * 5.19f + 13.7f; + noise = noise * (1.0f - sharpness * 0.3f) + smoothNoise(p3) * sharpness * 0.3f; + } + + // Amplitude from driftRange (as fraction of full 0..1 range) + paramVal = juce::jlimit(0.0f, 1.0f, paramVal + noise * driftRangeNorm); + } + + // Lane output: absolute parameter positioning (like Morph Pad) + // paramVal is already 0..1, representing the target parameter value + for (const auto& tgt : lc.targets) + writeModBase (tgt.pluginId, tgt.paramIndex, paramVal); + + // Write readback for UI + int rbIdx = numActiveLanes.load(); + if (rbIdx < maxLaneReadback) + { + laneReadback[rbIdx].blockId.store(lb.id); + laneReadback[rbIdx].laneIdx.store(laneIdx); + laneReadback[rbIdx].playhead.store((float) lc.playhead); + laneReadback[rbIdx].value.store(paramVal); + laneReadback[rbIdx].active.store(lc.oneshotMode ? lc.oneshotActive : true); + numActiveLanes.store(rbIdx + 1); + } + laneIdx++; + } // end else (curve mode) + } + } + } + resolveModBus(); + numActiveEnvBlocks.store (envIdx); + numActiveSampleBlocks.store (smpIdx); + numActiveMorphBlocks.store (morphIdx); + } + } + + // ── Drain glide command FIFO (lock-free read) ── + { + const auto scope = glideFifo.read (glideFifo.getNumReady()); + + auto applyCmd = [this] (const GlideCommand& cmd) + { + // O(1) lookup via pluginSlots + float currentVal = getParamValue (cmd.pluginId, cmd.paramIndex); + + int totalSamples = juce::jmax (1, (int) (cmd.durationMs * 0.001 * currentSampleRate)); + + // Check if a glide already exists for this param — update in-place + for (int gi = 0; gi < numActiveGlides; ++gi) + { + auto& g = glidePool[gi]; + if (g.pluginId == cmd.pluginId && g.paramIndex == cmd.paramIndex) + { + g.targetVal = cmd.targetVal; + g.increment = (cmd.targetVal - g.currentVal) / (float) totalSamples; + g.samplesLeft = totalSamples; + return; + } + } + + // New glide (fixed-size pool, no allocation) + if (numActiveGlides < kMaxGlides) + { + glidePool[numActiveGlides++] = { + cmd.pluginId, cmd.paramIndex, + currentVal, cmd.targetVal, + (cmd.targetVal - currentVal) / (float) totalSamples, + totalSamples + }; + } + }; + + for (int i = 0; i < scope.blockSize1; ++i) + applyCmd (glideRing[scope.startIndex1 + i]); + for (int i = 0; i < scope.blockSize2; ++i) + applyCmd (glideRing[scope.startIndex2 + i]); + } + + // ── Advance active glides (per-buffer interpolation) ── + { + int numSamples = buffer.getNumSamples(); + + // Swap-to-end removal: O(1) per removal, no shifting + for (int gi = 0; gi < numActiveGlides; ) + { + auto& g = glidePool[gi]; + int advance = juce::jmin (numSamples, g.samplesLeft); + g.currentVal += g.increment * (float) advance; + g.samplesLeft -= advance; + + // Snap to target when done + if (g.samplesLeft <= 0) + g.currentVal = g.targetVal; + + // Apply to parameter — route through setParamDirect to handle WrongEQ + hosted + float gVal = juce::jlimit (0.0f, 1.0f, g.currentVal); + setParamDirect (g.pluginId, g.paramIndex, gVal); + // Update modbus base so continuous modulators track the glide + updateParamBase (g.pluginId, g.paramIndex, gVal); + + if (g.samplesLeft <= 0) + { + // Swap with last element and decrement count (O(1) removal) + glidePool[gi] = glidePool[--numActiveGlides]; + // Don't increment gi — re-check the swapped-in element + } + else + { + ++gi; + } + } + } + + // ── Crash-protected single-plugin processing (shared by both modes) ── + // Creates a stereo-only alias buffer so hosted plugins never see sidechain channels. + auto processOnePlugin = [this, &midiMessages, mainBusChannels] (HostedPlugin& hp, juce::AudioBuffer& buf) -> bool + { + if (hp.instance == nullptr || ! hp.prepared || hp.bypassed || hp.crashed) + return true; // skip = success + + // Create a channel-limited alias (no allocation — just pointers into the existing buffer) + int pluginChannels = juce::jmin (mainBusChannels, buf.getNumChannels()); + int pluginSamples = buf.getNumSamples(); + + // Build an alias AudioBuffer that references only the main bus channels + float* channelPtrs[8] = {}; + for (int ch = 0; ch < juce::jmin (pluginChannels, 8); ++ch) + channelPtrs[ch] = buf.getWritePointer (ch); + + juce::AudioBuffer pluginBuf (channelPtrs, pluginChannels, pluginSamples); + + // Use last bus buffer as scratch for crash rollback (never allocated here) + auto& rollback = busBuffers[maxBuses - 1]; + int numCh = juce::jmin (pluginChannels, rollback.getNumChannels()); + int numSmp = juce::jmin (pluginSamples, rollback.getNumSamples()); + for (int ch = 0; ch < numCh; ++ch) + rollback.copyFrom (ch, 0, pluginBuf, ch, 0, numSmp); + + bool ok = false; + try { ok = sehGuardedProcessBlock (hp.instance.get(), pluginBuf, midiMessages); } + catch (...) { ok = false; } + + if (! ok) + { + hp.crashed = true; + hp.crashCount++; + for (int ch = 0; ch < numCh; ++ch) + pluginBuf.copyFrom (ch, 0, rollback, ch, 0, numSmp); + + CrashEvent ce; + ce.pluginId = hp.id; + auto nameStd = hp.name.toStdString(); + std::strncpy (ce.pluginName, nameStd.c_str(), sizeof (ce.pluginName) - 1); + std::strncpy (ce.reason, "Plugin crashed during audio processing", + sizeof (ce.reason) - 1); + const auto scope = crashFifo.write (1); + if (scope.blockSize1 > 0) crashRing[scope.startIndex1] = ce; + else if (scope.blockSize2 > 0) crashRing[scope.startIndex2] = ce; + + // NOTE: LOG_TO_FILE removed — it does disk I/O which blocks the audio thread. + // The crash FIFO carries the info; the editor drains it and can log if needed. + return false; + } + + // NaN/Inf sanitization + for (int ch = 0; ch < pluginChannels; ++ch) + { + auto* data = pluginBuf.getWritePointer (ch); + for (int s = 0; s < pluginSamples; ++s) + { + if (std::isnan (data[s]) || std::isinf (data[s])) + data[s] = 0.0f; + } + } + return true; + }; + + // ── Route audio through hosted plugins ── + // NO LOCK here — removePlugin only nulls the instance (never erases), + // so the vector is stable. processOnePlugin checks for null instance. + if (routingMode.load() == 0) + { + // SEQUENTIAL MODE: DAW-correct instrument + effect routing + // + // Strategy: two-pass approach + // Pass 1: Process all instruments (synths) — each gets a zeroed buffer, + // outputs are SUMMED (layered) into synthAccum + // Pass 2: Copy summed synth output into main buffer (replacing DAW input), + // then process all effects sequentially + // + // If there are NO instruments, effects process the DAW input directly (pure FX chain). + + // Count instruments to decide routing path + bool hasInstruments = false; + for (auto& hp : hostedPlugins) + { + if (hp->isInstrument && hp->instance && hp->prepared && !hp->bypassed && !hp->crashed) + { + hasInstruments = true; + break; + } + } + + if (hasInstruments) + { + // Pass 1: Layer all synths into synthAccum + int numChannels = buffer.getNumChannels(); + int numSamples = buffer.getNumSamples(); + int accumCh = juce::jmin (numChannels, synthAccum.getNumChannels()); + int accumSmp = juce::jmin (numSamples, synthAccum.getNumSamples()); + + synthAccum.clear (0, accumSmp); + + for (auto& hp : hostedPlugins) + { + if (! hp->isInstrument) continue; + + // Each synth gets a zeroed buffer → generates from MIDI only + // Use a bus buffer as temporary workspace (never the rollback buffer) + auto& synthBuf = busBuffers[0]; // safe: not in parallel mode + for (int ch = 0; ch < juce::jmin (numChannels, synthBuf.getNumChannels()); ++ch) + synthBuf.clear (ch, 0, juce::jmin (numSamples, synthBuf.getNumSamples())); + + processOnePlugin (*hp, synthBuf); + + // Accumulate (layer) — ADD this synth's output to the accum buffer + for (int ch = 0; ch < accumCh; ++ch) + synthAccum.addFrom (ch, 0, synthBuf, ch, 0, accumSmp); + } + + // Replace main buffer with summed synth output + for (int ch = 0; ch < accumCh; ++ch) + buffer.copyFrom (ch, 0, synthAccum, ch, 0, accumSmp); + + // Pass 2: Process effects sequentially (they see the combined synth output) + for (auto& hp : hostedPlugins) + { + if (hp->isInstrument) continue; // already processed + processOnePlugin (*hp, buffer); + } + } + else + { + // Pure FX chain — no instruments, process DAW input directly + for (auto& hp : hostedPlugins) + processOnePlugin (*hp, buffer); + } + } + else if (routingMode.load() == 2) + { + // ── WRONGEQ: band-split → per-band plugin processing → recombine ── + int numChannels = buffer.getNumChannels(); + int numSamples = buffer.getNumSamples(); + int nPts = numEqPoints.load(); + + // Global bypass: pass audio through unprocessed + if (eqGlobalBypass.load()) + { + eqDirty.exchange (false); // consume pending updates + // Still process serial chain so plugins stay fed + for (auto& hp : hostedPlugins) + processOnePlugin (*hp, buffer); + } + else if (nPts < 1) + { + // No EQ points — serial fallback + eqDirty.exchange (false); + for (auto& hp : hostedPlugins) + processOnePlugin (*hp, buffer); + } + else + { + // ── Crossover reconfiguration: ONLY when curve data changes from JS ── + // Crossovers are structural — reconfigured at ~60Hz JS sync rate. + // Biquad coefficients are recalculated EVERY processBlock from atomics + // to eliminate the JS-sync-rate staircase (smooth per-buffer tracking). + bool curveChanged = eqDirty.exchange (false, std::memory_order_acq_rel); + + // 2 crossovers per point → 2N+1 bands. + // Odd bands (1,3,5...) = point bands (exact Q range). + // Even bands (0,2,4...) = gap bands (passthrough). + int nXovers = nPts * 2; + numEqBands = nXovers + 1; + float sr = (float) currentSampleRate; + + if (curveChanged) + { + // Pre-compute band frequency edges for every sorted point. + // Matches JS weqBandRange() — each filter type defines its band differently: + // Bell/Notch: Q-derived bandwidth edges (Audio EQ Cookbook) + // LP/LowShelf: 20Hz to f0 (affects everything below cutoff) + // HP/HighShelf: f0 to Nyquist (affects everything above cutoff) + float ptLo[maxEqBands], ptHi[maxEqBands]; + for (int i = 0; i < nPts; ++i) + { + int origIdx = eqSortOrder[i].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + float f0 = eqPoints[origIdx].freqHz.load(); + int ft = eqPoints[origIdx].filterType.load(); + float Q = juce::jlimit (0.025f, 40.0f, eqPoints[origIdx].q.load()); + + switch (ft) + { + case 0: // Bell + case 3: // Notch — same Q-derived bandwidth as Bell + { + float bwOct = (2.0f / std::log (2.0f)) * std::asinh (1.0f / (2.0f * Q)); + ptLo[i] = f0 / std::pow (2.0f, bwOct * 0.5f); + ptHi[i] = f0 * std::pow (2.0f, bwOct * 0.5f); + break; + } + case 1: // LP — band covers 20Hz to cutoff + case 4: // Low Shelf — band covers 20Hz to corner freq + ptLo[i] = 20.0f; + ptHi[i] = f0; + break; + case 2: // HP — band covers cutoff to Nyquist + case 5: // High Shelf — band covers corner freq to Nyquist + ptLo[i] = f0; + ptHi[i] = sr * 0.49f; + break; + default: // fallback: Q-derived + { + float bwOct = (2.0f / std::log (2.0f)) * std::asinh (1.0f / (2.0f * Q)); + ptLo[i] = f0 / std::pow (2.0f, bwOct * 0.5f); + ptHi[i] = f0 * std::pow (2.0f, bwOct * 0.5f); + break; + } + } + } + else + { + ptLo[i] = ptHi[i] = 1000.0f; + } + } + + // ── Split mode override: each point's band spans from prev divider to this divider ── + // In normal mode: point band = ptLo..ptHi (Q-derived width) + // In split mode: point band = prev_point_freq..this_point_freq + // Point 0: 20Hz → f[0] (lowest band) + // Point 1: f[0] → f[1] (between point 0 and 1) + // ... + // Gap band above last point: f[N-1] → 20kHz (passthrough) + // The gap bands (even indices) collapse to zero width. + if (eqSplitMode.load (std::memory_order_relaxed)) + { + for (int i = 0; i < nPts; ++i) + { + int origIdx = eqSortOrder[i].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + float f0 = eqPoints[origIdx].freqHz.load(); + // ptLo = previous point's frequency (or 20Hz for first) + if (i == 0) + ptLo[i] = 20.0f; + else + { + int prevIdx = eqSortOrder[i - 1].load(); + ptLo[i] = (prevIdx >= 0 && prevIdx < maxEqBands) + ? eqPoints[prevIdx].freqHz.load() : 20.0f; + } + // ptHi = this point's frequency (crossover position) + ptHi[i] = f0; + } + } + } + + // Build crossover frequency array: 2 per point (lo edge, hi edge). + // xover[2*i] = ptLo[i] (lower Q edge) + // xover[2*i+1] = ptHi[i] (upper Q edge) + float xoverFreqs[maxCrossovers]; + for (int i = 0; i < nPts; ++i) + { + xoverFreqs[i * 2] = juce::jlimit (20.0f, sr * 0.49f, ptLo[i]); + xoverFreqs[i * 2 + 1] = juce::jlimit (20.0f, sr * 0.49f, ptHi[i]); + } + + // Handle overlapping Q ranges between adjacent sorted points. + // When ptHi[i] > ptLo[i+1] (bells overlap), split the contested + // frequency region at the geometric midpoint between the two + // center frequencies. Each band keeps the non-overlapping portion + // plus half the overlap. The gap band between them collapses. + for (int i = 0; i < nPts - 1; ++i) + { + float hiOfCurrent = xoverFreqs[i * 2 + 1]; // current point's upper edge + float loOfNext = xoverFreqs[(i + 1) * 2]; // next point's lower edge + if (hiOfCurrent > loOfNext) + { + // Overlap detected: split at geometric midpoint + float mid = std::sqrt (hiOfCurrent * loOfNext); + mid = juce::jlimit (20.0f, sr * 0.49f, mid); + xoverFreqs[i * 2 + 1] = mid; // pull P_i upper edge down + xoverFreqs[(i + 1) * 2] = mid; // push P_{i+1} lower edge up + } + } + + // Enforce minimum spacing between all consecutive crossovers. + // After overlap resolution, some crossovers may be coincident. + static constexpr float kMinOctaveSep = 1.0f / 12.0f; // ~1 semitone minimum + bool isSplit = eqSplitMode.load (std::memory_order_relaxed); + for (int i = 1; i < nXovers; ++i) + { + // In split mode, skip spacing for gap band crossover pairs: + // xover[2k+1] (ptHi[k]) and xover[2k+2] (ptLo[k+1]) are deliberately + // coincident to collapse the gap band to zero width. + if (isSplit && (i % 2 == 0) && i >= 2) + { + // This is an even index (ptLo of next point) following an odd index + // (ptHi of prev point). They share the same divider frequency. + continue; + } + float minFreq = xoverFreqs[i - 1] * std::pow (2.0f, kMinOctaveSep); + if (xoverFreqs[i] < minFreq) + xoverFreqs[i] = juce::jlimit (20.0f, sr * 0.49f, minFreq); + } + + // Set target crossover frequencies (smooth interpolation happens per-block below) + for (int i = 0; i < nXovers; ++i) + { + float freq = xoverFreqs[i]; + bool wasInactive = ! crossovers[i].active; + + if (std::abs (freq - crossovers[i].targetCutoffHz) > 0.1f || wasInactive) + { + crossovers[i].targetCutoffHz = freq; + + if (wasInactive) + { + // Brand-new crossover: snap immediately (no state to interpolate from) + crossovers[i].cutoffHz = freq; + crossovers[i].reset(); + // Prepare with snap (nSamples=1 → instant) + crossovers[i].prepare (sr, 1); + for (int lb = 0; lb < i; ++lb) + for (int ch2 = 0; ch2 < juce::jmin (numChannels, 2); ++ch2) + { + allpassComp[i][lb][ch2].reset(); + allpassComp[i][lb][ch2].setTarget (freq, sr, 1); + allpassComp[i][lb][ch2].snapToTarget(); + } + // Fade ALL bands from silence when new crossover added. + // The remainder band also changes (now HP-filtered), so it + // needs fading too to prevent a click. + for (int fb = 0; fb < maxXoverBands; ++fb) + eqBandGain[fb] = 0.0f; + } + } + crossovers[i].active = true; + } + for (int i = nXovers; i < maxCrossovers; ++i) + crossovers[i].active = false; + } + + // ── Crossover coefficient interpolation: set targets EVERY processBlock ── + // SVF-based crossovers use per-sample coefficient interpolation. + // Just set the target frequency — interpolation happens in the processing loop. + { + for (int i = 0; i < nXovers; ++i) + { + if (! crossovers[i].active) continue; + float tgt = crossovers[i].targetCutoffHz; + crossovers[i].cutoffHz = tgt; + for (int ch2 = 0; ch2 < juce::jmin (numChannels, 2); ++ch2) + crossovers[i].filters[ch2].setTarget (tgt, sr, numSamples); + for (int lb = 0; lb < i; ++lb) + for (int ch2 = 0; ch2 < juce::jmin (numChannels, 2); ++ch2) + allpassComp[i][lb][ch2].setTarget (tgt, sr, numSamples); + } + } + + // ── Parametric EQ: read parameters for SVF per-sample processing ── + // SVF TPT filters compute coefficients internally per-sample, so we just + // read the parameters from atomics — no coefficient computation needed. + float eqFreqs[maxEqBands], eqGains[maxEqBands], eqQs[maxEqBands]; + int eqTypes[maxEqBands], eqStages[maxEqBands]; + bool eqMuted[maxEqBands]; + { + for (int i = 0; i < nPts; ++i) + { + float freqBase = eqPoints[i].freqHz.load(); + float gainBase = eqPoints[i].gainDB.load(); + float qBase = eqPoints[i].q.load(); + if (eqPoints[i].modActive.load (std::memory_order_relaxed)) + { + freqBase += eqPoints[i].modFreqHz.load (std::memory_order_relaxed); + gainBase += eqPoints[i].modGainDB.load (std::memory_order_relaxed); + qBase += eqPoints[i].modQ.load (std::memory_order_relaxed); + } + float maxDB = eqDbRange.load(); + float gain = juce::jlimit (-maxDB, maxDB, gainBase) * (eqGlobalDepth.load() / 100.0f); + + // Apply global warp to target gain + float warpVal = eqGlobalWarp.load(); + if (std::abs (warpVal) > 0.5f) + { + float norm = (gain + maxDB) / (maxDB * 2.0f); + float w = warpVal / 100.0f; + if (w > 0.0f) + { + float mid = norm * 2.0f - 1.0f; + norm = 0.5f + 0.5f * std::tanh (w * 3.0f * mid) / std::tanh (w * 3.0f); + } + else + { + float aw = -w; + float c = norm * 2.0f - 1.0f; + float sv = c >= 0.0f ? 1.0f : -1.0f; + norm = 0.5f + 0.5f * sv * std::pow (std::abs (c), 1.0f / (1.0f + aw * 3.0f)); + } + gain = -maxDB + norm * (maxDB * 2.0f); + } + + // Apply global steps + int steps = eqGlobalSteps.load(); + if (steps >= 2) + { + float stepSz = (maxDB * 2.0f) / (float)(steps - 1); + gain = std::round (gain / stepSz) * stepSz; + } + + // Muted or preEq-off → passthrough (0 dB gain makes SVF bell/shelf = unity) + bool isMuted = eqPoints[i].mute.load() || !eqPoints[i].preEq.load(); + + eqFreqs[i] = juce::jlimit (20.0f, sr * 0.49f, freqBase); + eqGains[i] = isMuted ? 0.0f : gain; + eqQs[i] = juce::jlimit (0.025f, 40.0f, qBase); + eqTypes[i] = eqPoints[i].filterType.load(); + eqStages[i] = juce::jlimit (1, maxBiquadStages, eqPoints[i].slope.load()); + eqMuted[i] = isMuted; + eqBiquadActive[i] = true; + } + } + + // ── Step 0: Apply parametric EQ SVFs with per-sample coefficient interpolation ── + // Coefficients (g, k, a1c, a2c, a3c, A) are linearly interpolated per-sample. + // Target coefficients computed ONCE per buffer (one tan/pow). Per-sample deltas + // ensure coefficient changes are 1/nSamples of total → zero thumps at any frequency. + // No parameter smoothing needed — the coefficient interpolation IS the smoothing. + { + auto processSVFs = [&](float** channelData, int nSamp, int nCh, float svfSR) + { + for (int i = 0; i < nPts && i < maxEqBands; ++i) + { + if (! eqBiquadActive[i]) continue; + // For muted LP/HP/Notch: still process to keep SVF state warm, + // but output the original input (passthrough). This prevents + // a click when unmuting — the filter state is already primed. + bool muteBypass = eqMuted[i] && (eqTypes[i] == 1 || eqTypes[i] == 2 || eqTypes[i] == 3); + + float freq = juce::jlimit (20.0f, svfSR * 0.49f, eqFreqs[i]); + float gain = eqGains[i]; + float Q = juce::jlimit (0.025f, 40.0f, eqQs[i]); + int ft = eqTypes[i]; + int ns = eqStages[i]; + + // Set target coefficients + compute per-sample deltas (once per buffer) + for (int st = 0; st < ns; ++st) + for (int ch = 0; ch < juce::jmin (nCh, (int) maxEqChannels); ++ch) + eqBiquads[i][st][ch].setTarget (freq, gain, Q, ft, svfSR, nSamp); + + // Per-sample: step coefficients, then tick audio + for (int ch = 0; ch < juce::jmin (nCh, (int) maxEqChannels); ++ch) + { + auto* samples = channelData[ch]; + for (int s = 0; s < nSamp; ++s) + { + for (int st = 0; st < ns; ++st) + { + eqBiquads[i][st][ch].step(); + float filtered = eqBiquads[i][st][ch].tick (samples[s]); + if (! muteBypass) + samples[s] = filtered; + // When muteBypass: filter processes (keeping state warm) + // but output stays as original input + } + } + } + + // Snap to target at end of buffer (prevent floating-point drift) + for (int st = 0; st < ns; ++st) + for (int ch = 0; ch < juce::jmin (nCh, (int) maxEqChannels); ++ch) + eqBiquads[i][st][ch].snapToTarget(); + } + }; + + if (eqOversamplerReady && eqOversampler) + { + int osFactor = 1 << eqOversampleOrder; + float osSR = sr * osFactor; + juce::dsp::AudioBlock inputBlock (buffer); + auto osBlock = eqOversampler->processSamplesUp (inputBlock); + int osNumSamples = (int) osBlock.getNumSamples(); + int osNumChannels = (int) osBlock.getNumChannels(); + float* osChannels[2] = { nullptr, nullptr }; + for (int ch = 0; ch < juce::jmin (osNumChannels, 2); ++ch) + osChannels[ch] = osBlock.getChannelPointer ((size_t) ch); + processSVFs (osChannels, osNumSamples, osNumChannels, osSR); + eqOversampler->processSamplesDown (inputBlock); + } + else + { + float* channels[2] = { nullptr, nullptr }; + for (int ch = 0; ch < juce::jmin (numChannels, 2); ++ch) + channels[ch] = buffer.getWritePointer (ch); + processSVFs (channels, numSamples, numChannels, sr); + } + } + + // ── Step 0.5: Post-EQ tilt filter ── + // 1st-order LP/HP split at 632Hz pivot. Low band gets gainLow, high band gets gainHigh. + // This tilts the entire combined EQ curve uniformly, matching the JS visual. + // +tilt = boost highs / cut lows, -tilt = boost lows / cut highs. + { + float tiltVal = eqGlobalTilt.load(); + // Compute tilt gains: symmetric in dB around pivot + // JS: tiltDB = logPos * (tiltVal/100) * 12, logPos ≈ ±5 at 20Hz/20kHz. + // 1st-order filter asymptotes to gainLow/gainHigh well below/above fc. + float tiltDB = (tiltVal / 100.0f) * 12.0f; // max ±12dB at frequency extremes + float gainLowTarget = std::pow (10.0f, -tiltDB / 20.0f); + float gainHighTarget = std::pow (10.0f, tiltDB / 20.0f); + + // 1st-order LP coefficient: alpha = 1 - exp(-2π * fc / sr) + float tiltSR = (float) currentSampleRate; + float tiltAlpha = 1.0f - std::exp (-2.0f * juce::MathConstants::pi * 632.0f / tiltSR); + + int tiltChans = juce::jmin (numChannels, 2); + for (int ch = 0; ch < tiltChans; ++ch) + { + auto* samples = buffer.getWritePointer (ch); + float lpState = tiltLpState[ch]; + float gLow = tiltGainLowCur[ch]; + float gHigh = tiltGainHighCur[ch]; + + // Per-sample gain smoothing: ~5ms time constant + float smoothCoeff = 1.0f - std::exp (-1.0f / (tiltSR * 0.005f)); + + for (int s = 0; s < numSamples; ++s) + { + gLow += smoothCoeff * (gainLowTarget - gLow); + gHigh += smoothCoeff * (gainHighTarget - gHigh); + + float x = samples[s]; + lpState += tiltAlpha * (x - lpState); + float hp = x - lpState; + samples[s] = lpState * gLow + hp * gHigh; + } + + tiltLpState[ch] = lpState; + tiltGainLowCur[ch] = gLow; + tiltGainHighCur[ch] = gHigh; + } + } + + int nBands = numEqBands; // 2*nPts + 1 + + // Check if any plugins actually need band routing. + // If none → skip the entire crossover/band-split/route/sum section. + // The crossovers use JUCE LinkwitzRiley (DFII biquads) which produce + // low-frequency thumps when cutoff frequencies change. By skipping when + // not needed, the SVF-processed buffer passes straight to output. + bool needsBandSplit = false; + { + for (auto& hp : hostedPlugins) + { + if (! hp->instance || ! hp->prepared || hp->bypassed || hp->crashed) continue; + int plugBusId = hp->busId; + for (int si = 0; si < nPts; ++si) + { + int origIdx = eqSortOrder[si].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + int ptBus = eqPoints[origIdx].busId.load(); + if (ptBus >= 0 && ptBus == plugBusId) + { needsBandSplit = true; break; } + } + } + if (needsBandSplit) break; + } + } + + if (needsBandSplit) + { + + // ── Step 1: Cascaded Linkwitz-Riley band splitting with allpass compensation ── + // remaining = input; for each crossover: band[i] = LP(remaining), remaining = HP(remaining) + // After each split, compensate ALL lower bands with the allpass at this crossover freq. + // This ensures every band has identical group delay → transparent reconstruction. + int remIdx = nBands - 1; + for (int ch = 0; ch < juce::jmin (numChannels, eqBandBuffers[remIdx].getNumChannels()); ++ch) + eqBandBuffers[remIdx].copyFrom (ch, 0, buffer, ch, 0, numSamples); + + for (int i = 0; i < nXovers && i < maxCrossovers - 1; ++i) + { + if (! crossovers[i].active) continue; + + for (int ch = 0; ch < juce::jmin (numChannels, eqBandBuffers[i].getNumChannels()); ++ch) + eqBandBuffers[i].copyFrom (ch, 0, eqBandBuffers[remIdx], ch, 0, numSamples); + + // Per-sample SVF crossover: step coefficients, then tick LP/HP + int nCh = juce::jmin (numChannels, 2); + for (int s = 0; s < numSamples; ++s) + { + for (int ch = 0; ch < nCh; ++ch) + { + crossovers[i].filters[ch].step(); + float in = eqBandBuffers[remIdx].getSample (ch, s); + float lp, hp; + crossovers[i].filters[ch].tick (in, lp, hp); + eqBandBuffers[i].setSample (ch, s, lp); + eqBandBuffers[remIdx].setSample (ch, s, hp); + } + } + // Snap to target at end of buffer + for (int ch = 0; ch < nCh; ++ch) + crossovers[i].filters[ch].snapToTarget(); + + // ── Allpass phase compensation for all lower bands ── + for (int lb = 0; lb < i; ++lb) + { + for (int s = 0; s < numSamples; ++s) + { + for (int ch = 0; ch < nCh; ++ch) + { + allpassComp[i][lb][ch].step(); + float in = eqBandBuffers[lb].getSample (ch, s); + eqBandBuffers[lb].setSample (ch, s, allpassComp[i][lb][ch].tickAllpass (in)); + } + } + for (int ch = 0; ch < nCh; ++ch) + allpassComp[i][lb][ch].snapToTarget(); + } + } + + // ── Step 1.5: M/S encode point bands that need it ── + // Point bands are odd: band = 2*sortedIdx + 1 + // Skip muted bands — avoids M/S encoding without corresponding decode. + // Zero the unneeded channel BEFORE plugins (Mid-only → zero Side, Side-only → zero Mid). + // This ensures plugins only receive the selected component. + if (numChannels >= 2) + { + for (int si = 0; si < nPts; ++si) + { + int b = si * 2 + 1; // point band index + if (b >= nBands) break; + int origIdx = eqSortOrder[si].load(); + if (origIdx < 0 || origIdx >= maxEqBands) continue; + if (eqPoints[origIdx].mute.load()) continue; // muted: skip M/S + int sm = eqPoints[origIdx].stereoMode.load(); + if (sm == 0) continue; // stereo, no encode needed + + auto* L = eqBandBuffers[b].getWritePointer (0); + auto* R = eqBandBuffers[b].getWritePointer (1); + for (int s = 0; s < numSamples; ++s) + { + float mid = (L[s] + R[s]) * 0.5f; + float side = (L[s] - R[s]) * 0.5f; + L[s] = mid; + R[s] = side; + } + + // Zero the unneeded component BEFORE plugin processing + if (sm == 1) // Mid-only: zero Side channel + { + for (int s = 0; s < numSamples; ++s) + R[s] = 0.0f; + } + else // Side-only: zero Mid channel + { + for (int s = 0; s < numSamples; ++s) + L[s] = 0.0f; + } + } + } + + // ── Step 2: Solo/mute and route plugins to assigned bands ── + // Pro-Q 4 semantics: + // Mute = bypass the filter (skip plugin processing, but audio passes through) + // Solo = bandpass isolation (silence all non-soloed bands) + // Separate arrays: bandMuted[] for plugin skip, bandSilenced[] for summation. + bool bandMuted[maxXoverBands] = {}; // muted bands: skip plugins, but audio passes through + bool bandSilenced[maxXoverBands] = {}; // silenced bands: audio removed from output (solo) + bool anySolo = false; + bool bandSoloed[maxXoverBands] = {}; + + for (int si = 0; si < nPts; ++si) + { + int b = si * 2 + 1; // point band index + if (b >= nBands) break; + int origIdx = eqSortOrder[si].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + if (eqPoints[origIdx].solo.load()) { bandSoloed[b] = true; anySolo = true; } + if (eqPoints[origIdx].mute.load()) bandMuted[b] = true; + } + } + // Solo: silence all non-soloed bands (including gaps) + if (anySolo) + { + for (int b = 0; b < nBands; ++b) + if (! bandSoloed[b]) bandSilenced[b] = true; + } + + // Process plugins on their assigned bands + // Skip if band is muted (bypass) or silenced (solo isolation) + for (auto& hp : hostedPlugins) + { + if (! hp->instance || ! hp->prepared || hp->bypassed || hp->crashed) continue; + int plugBusId = hp->busId; + + int targetBand = -1; + for (int si = 0; si < nPts; ++si) + { + int origIdx = eqSortOrder[si].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + int ptBus = eqPoints[origIdx].busId.load(); + if (ptBus >= 0 && ptBus == plugBusId) + { + targetBand = si * 2 + 1; // point band = odd index + break; + } + } + } + // If no matching eqPoint found, plugin has no valid band + if (targetBand < 0 || targetBand >= nBands) continue; + // Skip plugins on muted or silenced bands + if (bandMuted[targetBand] || bandSilenced[targetBand]) continue; + + // Plugin processes its assigned band + processOnePlugin (*hp, eqBandBuffers[targetBand]); + } + + // ── Step 2.5: M/S decode point bands back to L/R ── + // Skip for muted bands (they weren't encoded in Step 1.5). + // Channel zeroing already happened in Step 1.5 (before plugins), + // so we just need to convert M/S → L/R. + if (numChannels >= 2) + { + for (int si = 0; si < nPts; ++si) + { + int b = si * 2 + 1; + if (b >= nBands) break; + if (bandMuted[b]) continue; // muted: wasn't encoded + int origIdx = eqSortOrder[si].load(); + if (origIdx < 0 || origIdx >= maxEqBands) continue; + int sm = eqPoints[origIdx].stereoMode.load(); + if (sm == 0) continue; // stereo: no decode needed + + auto* chM = eqBandBuffers[b].getWritePointer (0); + auto* chS = eqBandBuffers[b].getWritePointer (1); + + // M/S → L/R: Left = Mid + Side, Right = Mid - Side + for (int s = 0; s < numSamples; ++s) + { + float left = chM[s] + chS[s]; + float right = chM[s] - chS[s]; + chM[s] = left; + chS[s] = right; + } + } + } + + // ── Step 3: Sum all bands with per-sample gain smoothing ── + // Smoothed gain ramp prevents clicks on solo/mute transitions. + // eqBandGain[b] approaches target (0.0 or 1.0) at ~3ms ramp. + static constexpr float kBandGainRamp = 1.0f / 512.0f; // 512 samples ≈ 11ms at 44.1kHz + buffer.clear(); + for (int b = 0; b < nBands; ++b) + { + float targetGain = bandSilenced[b] ? 0.0f : 1.0f; + float currentGain = eqBandGain[b]; + + if (std::abs (currentGain - targetGain) < 0.001f) + { + // Already at target — fast path + currentGain = targetGain; + eqBandGain[b] = targetGain; + if (targetGain < 0.001f) continue; // fully silent, skip + + for (int ch = 0; ch < numChannels; ++ch) + buffer.addFrom (ch, 0, eqBandBuffers[b], ch, 0, numSamples); + } + else + { + // Ramping — per-sample gain interpolation + for (int ch = 0; ch < numChannels; ++ch) + { + auto* dst = buffer.getWritePointer (ch); + auto* src = eqBandBuffers[b].getReadPointer (ch); + float g = currentGain; + for (int s = 0; s < numSamples; ++s) + { + // Linear ramp toward target + if (g < targetGain) + g = juce::jmin (g + kBandGainRamp, targetGain); + else if (g > targetGain) + g = juce::jmax (g - kBandGainRamp, targetGain); + dst[s] += src[s] * g; + } + if (ch == 0) eqBandGain[b] = g; // store final gain after first channel + } + } + } + + // ── Step 4: Global post-EQ plugins (unassigned to any band) ── + // When eqUnassignedMode == 1, plugins not matched to any EQ point + // process the full summed signal sequentially as master inserts. + if (eqUnassignedMode.load (std::memory_order_relaxed) == 1) + { + for (auto& hp : hostedPlugins) + { + if (! hp->instance || ! hp->prepared || hp->bypassed || hp->crashed) continue; + int plugBusId = hp->busId; + + // Check if this plugin is assigned to any EQ band + bool assigned = false; + for (int si = 0; si < nPts; ++si) + { + int origIdx = eqSortOrder[si].load(); + if (origIdx >= 0 && origIdx < maxEqBands) + { + int ptBus = eqPoints[origIdx].busId.load(); + if (ptBus >= 0 && ptBus == plugBusId) { assigned = true; break; } + } + } + if (assigned) continue; // already processed in Step 2 + + // Unassigned: process on the full summed buffer (post-EQ global insert) + processOnePlugin (*hp, buffer); + } + } + + } // end needsBandSplit + else + { + // No band routing needed — SVF output goes straight to buffer. + // Process all plugins as serial inserts on the full buffer. + for (auto& hp : hostedPlugins) + { + if (! hp->instance || ! hp->prepared || hp->bypassed || hp->crashed) continue; + processOnePlugin (*hp, buffer); + } + } + + } // end WrongEQ else block + } + else + { + // PARALLEL: group by busId, process each bus independently, sum outputs + int numChannels = buffer.getNumChannels(); + int numSamples = buffer.getNumSamples(); + + // Discover which buses are active (have at least one non-skipped plugin) + bool busActive[maxBuses] = {}; + bool busHasSynth[maxBuses] = {}; // true if first active plugin on bus is an instrument + for (auto& hp : hostedPlugins) + { + int b = juce::jlimit (0, maxBuses - 2, hp->busId); // clamp, reserve last for rollback + if (hp->instance && hp->prepared && !hp->bypassed && !hp->crashed) + { + if (! busActive[b] && hp->isInstrument) + busHasSynth[b] = true; // first active plugin on this bus is a synth + busActive[b] = true; + } + } + + // Check if any bus has solo enabled + bool anySolo = false; + for (int i = 0; i < maxBuses - 1; ++i) + if (busActive[i] && busSolo[i].load()) anySolo = true; + + // Count effective buses (after mute/solo filtering) + int effectiveBusCount = 0; + for (int i = 0; i < maxBuses - 1; ++i) + { + if (! busActive[i]) continue; + if (busMute[i].load()) continue; + if (anySolo && ! busSolo[i].load()) continue; + effectiveBusCount++; + } + + if (effectiveBusCount <= 1) + { + // Only one effective bus (or none) — process sequentially, no split/sum overhead + // If the bus starts with a synth, zero the buffer first + int activeBusIdx = -1; + for (int i = 0; i < maxBuses - 1; ++i) + { + if (busActive[i] && !busMute[i].load() && (!anySolo || busSolo[i].load())) + { activeBusIdx = i; break; } + } + if (activeBusIdx >= 0 && busHasSynth[activeBusIdx]) + buffer.clear(); + + for (auto& hp : hostedPlugins) + { + int b = juce::jlimit (0, maxBuses - 2, hp->busId); + if (busMute[b].load()) continue; + if (anySolo && ! busSolo[b].load()) continue; + processOnePlugin (*hp, buffer); + } + // Apply bus volume for the single active bus + if (effectiveBusCount == 1) + { + for (int i = 0; i < maxBuses - 1; ++i) + { + if (! busActive[i]) continue; + if (busMute[i].load()) continue; + if (anySolo && ! busSolo[i].load()) continue; + float vol = busVolume[i].load(); + if (std::abs (vol - 1.0f) > 0.001f) + buffer.applyGain (vol); + break; + } + } + } + else + { + // Initialize each active bus buffer: + // - Synth buses: zeroed (synths generate from MIDI) + // - FX buses: copy of input audio (effects process it) + for (int b = 0; b < maxBuses - 1; ++b) + { + if (! busActive[b]) continue; + if (busMute[b].load()) continue; + if (anySolo && ! busSolo[b].load()) continue; + + if (busHasSynth[b]) + { + // Synth bus: zero the buffer — synth will generate from MIDI + for (int ch = 0; ch < juce::jmin (numChannels, busBuffers[b].getNumChannels()); ++ch) + busBuffers[b].clear (ch, 0, numSamples); + } + else + { + // FX bus: copy input audio for processing + for (int ch = 0; ch < juce::jmin (numChannels, busBuffers[b].getNumChannels()); ++ch) + busBuffers[b].copyFrom (ch, 0, buffer, ch, 0, numSamples); + } + } + + // Process each bus's plugin chain + for (auto& hp : hostedPlugins) + { + int b = juce::jlimit (0, maxBuses - 2, hp->busId); + if (busMute[b].load()) continue; + if (anySolo && ! busSolo[b].load()) continue; + processOnePlugin (*hp, busBuffers[b]); + } + + // Sum all active bus outputs into main buffer — UNITY GAIN + // Each bus applies its own volume. No automatic gain compensation. + buffer.clear(); + for (int b = 0; b < maxBuses - 1; ++b) + { + if (! busActive[b]) continue; + if (busMute[b].load()) continue; + if (anySolo && ! busSolo[b].load()) continue; + float vol = busVolume[b].load(); + for (int ch = 0; ch < numChannels; ++ch) + buffer.addFrom (ch, 0, busBuffers[b], ch, 0, numSamples, vol); + } + } + } + + // ── Snapshot hosted param values → proxy cache (lock-free atomic writes) ── + // Audio thread writes to atomic cache; message thread timer reads + calls setValueNotifyingHost. + // This avoids calling setValueNotifyingHost on the audio thread (Rule 6). + if (++proxySyncCounter >= 4) + { + proxySyncCounter = 0; + bool anyDirty = false; + for (int i = 0; i < proxyParamCount; ++i) + { + auto& m = proxyMap[i]; + if (! m.isPlugin() || proxyParams[i] == nullptr) continue; + + for (auto& hp : hostedPlugins) + { + if (hp->id == m.pluginId && hp->instance) + { + auto& params = hp->instance->getParameters(); + if (m.paramIndex >= 0 && m.paramIndex < (int) params.size()) + { + float hosted = params[m.paramIndex]->getValue(); + if (std::abs (hosted - proxyParams[i]->get()) > 0.0001f) + { + proxyValueCache[i].store (hosted, std::memory_order_relaxed); + anyDirty = true; + } + } + break; + } + } + } + if (anyDirty) + proxyDirty.store (true, std::memory_order_release); + } + + // Apply dry/wet mix + if (needsDryMix) + { + float dry = 1.0f - wet; + int mixChannels = juce::jmin (mainBusChannels, dryBuffer.getNumChannels()); + int mixSamples = juce::jmin (buffer.getNumSamples(), dryBuffer.getNumSamples()); + for (int ch = 0; ch < mixChannels; ++ch) + { + auto* wetData = buffer.getWritePointer (ch); + auto* dryData = dryBuffer.getReadPointer (ch); + for (int s = 0; s < mixSamples; ++s) + wetData[s] = dryData[s] * dry + wetData[s] * wet; + } + } +} diff --git a/plugins/ModularRandomizer/Source/ui/public/css/base.css b/plugins/ModularRandomizer/Source/ui/public/css/base.css new file mode 100644 index 0000000..25bbb2d --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/base.css @@ -0,0 +1,22 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box +} + +body { + background: var(--bg-app); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + width: 100%; + height: 100vh; + overflow: hidden; + user-select: none +} + +.app { + display: flex; + flex-direction: column; + height: 100% +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/dialogs.css b/plugins/ModularRandomizer/Source/ui/public/css/dialogs.css new file mode 100644 index 0000000..d341e06 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/dialogs.css @@ -0,0 +1,926 @@ +/* Context menu */ +.ctx { + display: none; + position: fixed; + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 4px 0; + min-width: 140px; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, .3) +} + +.ctx.vis { + display: block +} + +.ctx-i { + padding: 5px 12px; + font-size: 11px; + cursor: pointer +} + +.ctx-i:hover { + background: var(--accent-light) +} + +.ctx-i.disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +.ctx-sep { + height: 1px; + background: var(--border); + margin: 3px 0; +} + +.ctx-sub { + position: relative +} + +.ctx-sub::after { + content: '\25B6'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 7px; + color: var(--text-muted) +} + +.ctx-submenu { + display: none; + position: absolute; + left: 100%; + top: 0; + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 4px 0; + min-width: 140px; + z-index: 101; + box-shadow: 0 2px 8px rgba(0, 0, 0, .15) +} + +.ctx-sub:hover .ctx-submenu { + display: block +} + +.ctx-submenu .ctx-i { + display: flex; + align-items: center; + gap: 6px +} + +.ctx-block-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 2px; + flex-shrink: 0 +} + +/* Preset browser modal */ +.preset-save-row { + display: flex; + gap: 6px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); +} + +.preset-save-row input { + flex: 1; + border: 1px solid var(--border); + border-radius: 3px; + padding: 5px 8px; + font-family: var(--font-sans); + font-size: 12px; + background: var(--bg-cell); + color: var(--input-text); + outline: none; +} + +.preset-save-row input:focus { + border-color: var(--accent); +} + +.preset-save-btn { + padding: 5px 14px; + background: var(--accent); + color: var(--fire-text); + border: none; + border-radius: 3px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + cursor: pointer; + letter-spacing: .5px; +} + +.preset-save-btn:hover { + opacity: .85; +} + +.preset-row { + display: flex; + align-items: center; + padding: 7px 14px; + cursor: pointer; + gap: 8px; + border-bottom: 1px solid var(--bg-inset); +} + +.preset-row:hover { + background: var(--accent-light); +} + +.preset-row.active { + background: rgba(var(--accent-rgb, 92, 107, 192), 0.12); + border-left: 2px solid var(--accent); +} + +.preset-name { + flex: 1; + font-size: 12px; + font-weight: 500; +} + +.preset-del { + background: none; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-size: 11px; + padding: 1px 6px; + cursor: pointer; +} + +.preset-del:hover { + border-color: var(--locked-icon); + color: var(--locked-icon); +} + +.preset-reveal { + background: none; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-size: 0; + padding: 3px 5px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity .15s, border-color .15s, color .15s; +} + +.preset-row:hover .preset-reveal { + opacity: 1; +} + +.preset-reveal:hover { + border-color: var(--accent); + color: var(--accent); +} + +.preset-reveal svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.preset-empty { + padding: 24px 14px; + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +/* Preset peek popup — shows plugin chain contents */ +.gp-peek-popup { + position: fixed; + z-index: 700; + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + box-shadow: 0 6px 24px rgba(0, 0, 0, .45); + min-width: 200px; + max-width: 280px; + padding: 0; + animation: peekFadeIn .12s ease-out; + overflow: hidden; +} + +@keyframes peekFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.gp-peek-title { + padding: 8px 12px 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: .3px; + color: var(--text-primary); + border-bottom: 1px solid var(--border); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gp-peek-meta { + padding: 4px 12px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted); +} + +.gp-peek-list { + padding: 4px 0; +} + +.gp-peek-plug { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; +} + +.gp-peek-plug:hover { + background: var(--accent-light); +} + +.gp-peek-idx { + width: 16px; + height: 16px; + border-radius: 3px; + background: var(--bg-inset); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + color: var(--text-muted); + flex-shrink: 0; +} + +.gp-peek-name { + flex: 1; + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gp-peek-badge { + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .4px; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.gp-peek-badge.bypass { + background: rgba(220, 80, 60, 0.15); + color: #D05040; +} + +.gp-peek-badge.locked { + background: rgba(92, 107, 192, 0.15); + color: var(--accent); +} + +.gp-peek-badge.missing { + background: rgba(220, 60, 60, 0.2); + color: #E04040; +} + +.gp-peek-plug.missing .gp-peek-name { + opacity: 0.5; + text-decoration: line-through; +} + +.gp-peek-plug.missing .gp-peek-idx { + border-color: rgba(220, 60, 60, 0.3); + color: #E04040; +} + +.gp-peek-loading, +.gp-peek-empty { + padding: 14px 12px; + font-size: 11px; + color: var(--text-muted); + text-align: center; +} + +/* Inline badges on global preset rows (plugin count + missing warnings) */ +.gp-row-badges { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.gp-row-count { + font-size: 9px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: .3px; +} + +.gp-row-missing { + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .3px; + padding: 1px 4px; + border-radius: 2px; + background: rgba(220, 60, 60, 0.2); + color: #E04040; +} + +/* Preset/Snapshot filter tabs */ +.preset-filter-row { + display: flex; + gap: 0; + padding: 6px 14px; + border-bottom: 1px solid var(--border); +} + +.preset-filter { + flex: 1; + padding: 4px 8px; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: all .15s; +} + +.preset-filter:first-child { + border-radius: 3px 0 0 3px; +} + +.preset-filter:last-child { + border-radius: 0 3px 3px 0; + border-left: none; +} + +.preset-filter:not(:first-child):not(:last-child) { + border-left: none; +} + +.preset-filter:hover { + color: var(--text-secondary); + background: var(--bg-cell-hover); +} + +.preset-filter.on { + background: var(--accent); + color: var(--fire-text); + border-color: var(--accent); +} + +/* Type badge on preset rows */ +.preset-type-badge { + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + padding: 1px 5px; + border-radius: 2px; + flex-shrink: 0; +} + +.preset-type-badge.type-preset { + background: rgba(80, 160, 120, 0.15); + color: var(--lane-active, #70B890); +} + +.preset-type-badge.type-snapshot { + background: rgba(92, 107, 192, 0.15); + color: var(--morph-color); +} + +.preset-type-badge.type-factory { + background: rgba(220, 160, 50, 0.15); + color: #DCA032; +} + +/* Snapshot library sub-info line */ +.preset-sub { + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; +} + +/* Global preset selector in header */ +.gp-area { + display: flex; + align-items: center; + gap: 6px; +} + +.gp-label { + font-size: 10px; + color: var(--text-muted); + letter-spacing: .5px; + text-transform: uppercase; +} + +.gp-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + max-width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gp-btn { + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 10px; + padding: 4px 10px; + cursor: pointer; + letter-spacing: .3px; +} + +.gp-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +/* Preset nav arrows */ +.gp-nav { + background: none; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-size: 8px; + padding: 3px 6px; + cursor: pointer; + line-height: 1; +} + +.gp-nav:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +/* Dirty indicator */ +.gp-dirty { + display: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + margin-left: -2px; + flex-shrink: 0; +} + +.gp-dirty.on { + display: inline-block; +} + +/* Save type toggle in per-plugin preset modal */ +.preset-type-toggle { + display: flex; + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.preset-type-btn { + padding: 4px 8px; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + background: var(--bg-cell); + border: none; + border-right: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + text-transform: uppercase; + letter-spacing: .4px; +} + +.preset-type-btn:last-child { + border-right: none; +} + +.preset-type-btn.on { + background: var(--accent); + color: var(--fire-text); +} + +.preset-type-btn:hover:not(.on) { + background: var(--bg-cell-hover); + color: var(--text-secondary); +} + +/* Search toolbar for preset modals */ +.preset-toolbar { + padding: 6px 14px; + border-bottom: 1px solid var(--border); +} + +/* Plugin Browser Modal */ +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, .55); + z-index: 600; + align-items: center; + justify-content: center +} + +.modal-overlay.vis { + display: flex +} + +.modal { + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + width: 520px; + max-height: 480px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, .5) +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-strong); + background: var(--bg-cell) +} + +.modal-title { + font-size: 13px; + font-weight: 700; + letter-spacing: .5px +} + +.modal-close { + background: none; + border: none; + font-size: 18px; + color: var(--text-muted); + cursor: pointer; + padding: 2px 6px +} + +.modal-close:hover { + color: var(--text-primary) +} + +.modal-toolbar { + display: flex; + gap: 6px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + align-items: center; +} + +#snapLibModal .modal-toolbar { + flex-direction: column; + align-items: stretch; +} + +.modal-search { + flex: 1; + border: 1px solid var(--border); + border-radius: 3px; + padding: 5px 8px; + font-family: var(--font-sans); + font-size: 12px; + background: var(--bg-cell); + color: var(--input-text); + outline: none +} + +.modal-search:focus { + border-color: var(--accent) +} + +.modal-tabs { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden +} + +.modal-tab { + padding: 4px 10px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; + background: var(--bg-cell); + border: none; + border-right: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer +} + +.modal-tab:last-child { + border-right: none +} + +.modal-tab:hover { + color: var(--text-secondary); + background: var(--bg-cell-hover) +} + +.modal-tab.on { + background: var(--accent); + color: var(--fire-text) +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 4px 0; + min-height: 200px +} + +.modal-body::-webkit-scrollbar { + width: 5px +} + +.modal-body::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.plug-row { + display: flex; + align-items: center; + padding: 6px 14px; + cursor: pointer; + gap: 8px; + border-bottom: 1px solid var(--bg-inset) +} + +.plug-row:hover { + background: var(--accent-light) +} + +.plug-icon { + width: 28px; + height: 28px; + border-radius: 4px; + background: var(--bg-inset); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + flex-shrink: 0 +} + +.plug-info { + flex: 1; + min-width: 0 +} + +.plug-name { + font-size: 12px; + font-weight: 600 +} + +.plug-meta { + font-size: 10px; + color: var(--text-muted); + margin-top: 1px +} + +.plug-type { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + padding: 1px 5px; + border-radius: 2px; + background: var(--bg-inset); + color: var(--text-muted) +} + +.plug-type.synth { + background: rgba(80, 120, 160, 0.2); + color: var(--sample-color, #90B0C8) +} + +.plug-type.fx { + background: rgba(139, 64, 96, 0.2); + color: var(--shapes-color, #C08898) +} + +.plug-type.sampler { + background: rgba(45, 107, 63, 0.2); + color: var(--lane-active, #70A880) +} + +.plug-type.utility { + background: rgba(180, 150, 80, 0.18); + color: var(--env-color, #C8B060) +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + border-top: 1px solid var(--border); + background: var(--bg-cell) +} + +.modal-footer-info { + font-size: 10px; + color: var(--text-muted) +} + +.scan-btn { + font-family: var(--font-sans); + font-size: 10px; + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 3px 8px; + border-radius: 3px; + cursor: pointer +} + +.scan-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +.no-results { + text-align: center; + padding: 30px; + color: var(--text-muted); + font-size: 12px +} + +/* Scanning indicator */ +.scan-indicator { + padding: 30px 24px; + text-align: center; +} + +.scan-bar { + width: 100%; + height: 3px; + background: var(--bg-inset); + border-radius: 2px; + overflow: hidden; + margin-bottom: 12px; +} + +.scan-bar-fill { + width: 30%; + height: 100%; + background: var(--accent); + border-radius: 2px; + animation: scanSlide 1.5s ease-in-out infinite; +} + +@keyframes scanSlide { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(400%); + } +} + +.scan-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.scan-indicator .scan-paths { + display: block; + font-size: 10px; + color: var(--text-muted); + opacity: 0.6; + padding: 0; + border: none; + background: none; +} + +.scan-pulse { + display: inline-block; + color: var(--accent); + animation: scanPulse 1s ease-in-out infinite; +} + +@keyframes scanPulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +/* Scan paths modal */ +.scan-paths { + display: none; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-app) +} + +.scan-paths.vis { + display: block +} + +.scan-paths-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted); + margin-bottom: 4px +} + +.scan-path-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 3px +} + +.scan-path-row input { + flex: 1; + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 6px; + font-family: var(--font-mono); + font-size: 10px; + background: var(--bg-cell); + color: var(--input-text); + outline: none +} + +.scan-path-rm { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 12px; + padding: 2px +} + +.scan-path-rm:hover { + color: var(--text-primary) +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/header.css b/plugins/ModularRandomizer/Source/ui/public/css/header.css new file mode 100644 index 0000000..aeb81e7 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/header.css @@ -0,0 +1,763 @@ +/* HEADER */ +.header { + display: flex; + align-items: center; + height: 44px; + padding: 0 14px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-strong); + gap: 10px; + flex-shrink: 0 +} + +.brand { + display: flex; + align-items: center; + gap: 6px +} + +.brand-mark { + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 2px +} + +.brand-name { + font-size: 13px; + font-weight: 700; + letter-spacing: 1px +} + +.h-div { + width: 1px; + height: 18px; + background: var(--border) +} + +.h-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto +} + +.sm-btn { + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 10px; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer +} + +.sm-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +.bypass { + width: 28px; + height: 26px; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center +} + +.bypass.on { + background: var(--accent); + border-color: var(--accent); + color: var(--fire-text) +} + +.mix-area { + display: flex; + align-items: center; + gap: 5px +} + +.mix-area label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .5px +} + +.mix-area .mix-val { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + min-width: 26px +} + +.mix-area input[type="range"] { + width: 70px +} + +/* Status bar */ +.status { + display: flex; + align-items: center; + height: 24px; + padding: 0 12px; + background: var(--bg-panel); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + gap: 14px; + flex-shrink: 0 +} + +.sd { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + margin-right: 2px +} + +.sd.on { + background: var(--midi-dot) +} + +.sd.off { + background: var(--border-strong) +} + +.sd.env { + background: var(--env-color) +} + +/* Undo button */ +.undo-btn { + font-size: 11px; + padding: 3px 10px; + border: 1px solid var(--border-strong); + border-radius: 3px; + background: var(--bg-cell); + cursor: pointer; + font-family: var(--font-sans); + color: var(--text-primary); + display: flex; + align-items: center; + gap: 3px; + transition: all .15s; +} + +.undo-btn:hover { + background: var(--bg-cell-hover); + border-color: var(--border-focus); +} + +.undo-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.undo-badge { + font-size: 9px; + background: var(--accent); + color: var(--fire-text); + border-radius: 6px; + padding: 0 4px; + min-width: 12px; + text-align: center; + line-height: 14px; +} + +/* Routing mode buttons */ +.routing-btn { + font-size: 10px; + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-cell); + color: var(--text-muted); + cursor: pointer; + font-family: var(--font-sans); + transition: all .15s; +} + +.routing-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +.routing-btn.on { + background: var(--accent); + border-color: var(--accent); + color: var(--fire-text); +} + +/* Bus dropdown on plugin cards */ +.pcard-bus-wrap { + display: inline-flex; + align-items: center; + border-radius: 3px; + margin-right: 4px; + vertical-align: middle; + padding: 0 1px; + transition: opacity .15s; +} + +.pcard-bus-wrap:hover { + opacity: 0.85; +} + +.pcard-bus-sel { + background: transparent; + border: none; + color: var(--bus-badge-text, #000); + font-size: 9px; + font-weight: 700; + font-family: var(--font-sans); + letter-spacing: .3px; + cursor: pointer; + padding: 1px 4px 1px 2px; + outline: none; + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; +} + +.pcard-bus-sel option { + background: var(--bg-panel); + color: var(--text-primary); +} + +/* ── Bus group container (parallel mode) ── */ +.bus-group { + border-left: 4px solid var(--bus-tint, #888); + border-radius: 4px; + margin-bottom: 6px; + padding: 0 0 2px 0; + background: color-mix(in srgb, var(--bus-tint, #888) var(--bus-group-tint, 8%), var(--bg-app)); + transition: opacity .15s; +} + +.bus-group.bus-muted { + opacity: 0.45; +} + +.bus-group .pcard { + margin-left: 4px; +} + +/* ── Bus header strip ── */ +.bus-header { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: color-mix(in srgb, var(--bus-tint, #888) var(--bus-header-tint, 16%), var(--bg-panel)); + border-bottom: 1px solid color-mix(in srgb, var(--bus-tint, #888) 25%, var(--border)); + border-radius: 4px 4px 0 0; + width: 100%; + box-sizing: border-box; + cursor: pointer; +} + +.bus-chev { + font-size: 8px; + color: var(--text-muted); + transition: transform .15s; + display: inline-block; + flex-shrink: 0; +} + +.bus-chev.open { + transform: rotate(90deg); +} + +.bus-count { + font-size: 9px; + color: var(--text-muted); + font-weight: 400; +} + +.bus-header.bus-soloed { + box-shadow: inset 0 0 12px color-mix(in srgb, var(--bus-solo-bg, #CCAA33) 30%, transparent); +} + +.bus-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 6px currentColor; +} + +.bus-name { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: .5px; + text-transform: uppercase; + min-width: 38px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.bus-vol-slider { + -webkit-appearance: none; + appearance: none; + width: 70px; + height: 14px; + background: transparent; + cursor: pointer; + outline: none; +} + +.bus-vol-slider::-webkit-slider-runnable-track { + height: 4px; + background: var(--slider-track); + border-radius: 2px; +} + +.bus-vol-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--slider-thumb); + border: 1px solid var(--border); + margin-top: -3px; + cursor: pointer; + transition: background .1s; +} + +.bus-vol-slider::-webkit-slider-thumb:hover { + background: var(--text-primary); +} + +.bus-vol-slider::-moz-range-track { + height: 4px; + background: var(--slider-track); + border-radius: 2px; + border: none; +} + +.bus-vol-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--slider-thumb); + border: 1px solid var(--border); + cursor: pointer; +} + +.bus-vol-label { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + min-width: 36px; + text-align: right; +} + +.bus-mute-btn, +.bus-solo-btn { + font-size: 9px; + font-weight: 700; + font-family: var(--font-sans); + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-cell); + color: var(--text-muted); + cursor: pointer; + transition: all .1s; +} + +.bus-mute-btn:hover, +.bus-solo-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +.bus-mute-btn.on { + background: var(--bus-mute-bg, #CC4444); + border-color: var(--bus-mute-bg, #CC4444); + color: var(--bus-mute-text, white); +} + +.bus-solo-btn.on { + background: var(--bus-solo-bg, #CCAA33); + border-color: var(--bus-solo-bg, #CCAA33); + color: var(--bus-solo-text, #1a1a1a); +} + +/* Per-bus Pre/Post EQ toggle */ +.bus-preq-btn { + font-size: 8px; + font-weight: 700; + font-family: var(--font-sans); + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-cell); + color: var(--text-muted); + cursor: pointer; + transition: all .1s; + letter-spacing: .3px; + text-transform: uppercase; +} + +.bus-preq-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +.bus-preq-btn.on { + background: color-mix(in srgb, var(--accent) 25%, var(--bg-cell)); + border-color: var(--accent); + color: var(--accent); +} + +/* Help button */ +.help-btn { + width: 26px; + height: 26px; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + font-size: 13px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .15s; + flex-shrink: 0; +} + +.help-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); +} + +/* Shortcuts modal */ +.sc-section { + margin-bottom: 14px; +} + +.sc-section:last-child { + margin-bottom: 0; +} + +.sc-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--accent, #4fc3f7); + margin-bottom: 6px; + padding-bottom: 3px; + border-bottom: 1px solid var(--border); +} + +.sc-grid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 3px 12px; + align-items: baseline; +} + +.sc-key { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 6px; + text-align: center; + white-space: nowrap; +} + +.sc-grid>span:nth-child(even) { + color: var(--text-secondary); + font-size: 12px; +} + +/* Help panel (dynamic) */ +.help-modal { + width: 580px; + max-height: 620px; +} + +.help-tabs { + display: flex; + gap: 2px; + padding: 6px 12px 0; + background: var(--bg-cell); + border-bottom: 1px solid var(--border); +} + +.help-tab { + padding: 5px 12px; + border: none; + background: none; + color: var(--text-muted); + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all .15s; +} + +.help-tab:hover { + color: var(--text-secondary); +} + +.help-tab.active { + color: var(--text-primary); + border-bottom-color: var(--accent); +} + +.help-body { + padding: 18px 28px !important; + font-size: 12.5px; + line-height: 1.65; + overflow-y: auto; + max-height: 500px; +} + +.help-body p { + margin: 8px 0; + color: var(--text-secondary); + line-height: 1.55; +} + +.help-body ul { + margin: 6px 0 12px 20px; + padding: 0; + color: var(--text-secondary); + line-height: 1.65; +} + +.help-body li { + margin: 2px 0; +} + +.help-body b { + color: var(--text-primary); +} + +.help-tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + margin-right: 4px; + background: color-mix(in srgb, var(--tag-color, var(--accent)) 15%, transparent); + color: var(--tag-color, var(--accent)); + border: 1px solid color-mix(in srgb, var(--tag-color, var(--accent)) 30%, transparent); +} + +/* ── Expose to DAW Dropdown ── */ +.expose-dropdown { + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + min-width: 220px; + max-height: 420px; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + padding: 6px 0; + font-family: var(--font-sans); +} + +.expose-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--accent); + padding: 4px 12px 6px; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +.expose-section-label { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted); + padding: 6px 12px 3px; +} + +.expose-item { + display: flex; + align-items: center; + padding: 4px 12px; + gap: 6px; + transition: background .1s; +} + +.expose-item:hover { + background: var(--bg-cell-hover); +} + +.expose-check { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + flex: 1; + min-width: 0; +} + +.expose-check input[type="checkbox"] { + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.expose-name { + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.expose-count { + font-size: 9px; + font-family: var(--font-mono); + color: var(--text-muted); + flex-shrink: 0; +} + +.expose-expand-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + padding: 0 2px; + transition: color .1s; + flex-shrink: 0; +} + +.expose-expand-btn:hover { + color: var(--accent); +} + +.expose-empty { + font-size: 11px; + color: var(--text-muted); + padding: 12px; + text-align: center; +} + +/* Expose submenu (param list) */ +.expose-submenu { + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + padding: 4px 0; + font-family: var(--font-sans); +} + +.expose-sub-title { + font-size: 10px; + font-weight: 700; + color: var(--text-primary); + padding: 4px 12px 6px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 6px; +} + +.expose-sub-actions { + margin-left: auto; + display: flex; + gap: 3px; +} + +.expose-sub-btn { + font-size: 9px; + font-family: var(--font-sans); + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-cell); + color: var(--text-muted); + cursor: pointer; + transition: all .1s; +} + +.expose-sub-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); + background: var(--bg-cell-hover); +} + +.expose-sub-search { + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} + +.expose-search-input { + width: 100%; + box-sizing: border-box; + padding: 3px 8px; + font-size: 11px; + font-family: var(--font-sans); + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-primary); + outline: none; +} + +.expose-search-input:focus { + border-color: var(--border-focus); +} + +.expose-search-input::placeholder { + color: var(--text-muted); +} + +.expose-sub-scroll { + max-height: 350px; + overflow-y: auto; + padding: 4px 0; +} + +.expose-sub-item { + padding: 3px 12px; + font-size: 11px; + transition: background .1s; +} + +.expose-sub-item:hover { + background: var(--bg-cell-hover); +} + +.expose-type-tag { + font-size: 8px; + font-family: var(--font-mono); + color: var(--accent); + opacity: 0.7; + margin-left: 4px; +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css b/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css new file mode 100644 index 0000000..fde573c --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css @@ -0,0 +1,2746 @@ +/* LOGIC BLOCKS (right) */ +.logic-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden +} + +.lp-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background: var(--bg-cell); + border-bottom: 1px solid var(--border-strong); + height: 32px +} + +.lp-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted) +} + +.lp-scroll { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-content: flex-start +} + +.lp-scroll::-webkit-scrollbar { + width: 4px +} + +.lp-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* Block card */ +.lcard { + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 4px; + width: calc(50% - 4px); + min-width: 280px; + flex-shrink: 0; + align-self: flex-start +} + + + +.lcard.drag-hover { + outline: 2px dashed var(--drag-color, var(--drag-highlight, var(--accent))); + outline-offset: -2px; + background: color-mix(in srgb, var(--drag-color, var(--drag-highlight, var(--accent))) 6%, var(--bg-cell)) +} + +.lhead { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + cursor: pointer +} + +.lhead:hover { + background: var(--bg-cell-hover) +} + +.ltitle { + font-size: 12px; + font-weight: 600 +} + +.lsum { + font-size: 10px; + color: var(--text-secondary); + margin-left: 6px +} + +.lclose { + background: none; + border: none; + color: var(--text-muted); + font-size: 15px; + cursor: pointer; + padding: 2px 4px +} + +.lclose:hover { + color: var(--text-primary) +} + +.lbody { + padding: 7px 8px; + display: flex; + flex-direction: column; + gap: 5px +} + +.lbody.hide { + display: none +} + +.block-color { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 4px; + flex-shrink: 0 +} + +/* Shared controls */ +.blbl { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted) +} + +.brow { + display: flex; + flex-direction: column; + gap: 3px +} + +.brow-inline { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap +} + +/* Section divider — thin line between logical groups */ +.bdiv { + height: 1px; + background: var(--border); + margin: 5px 0; + opacity: 0.5 +} + +/* Grouped section with subtle background */ +.bsec { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + padding: 5px 6px; + display: flex; + flex-direction: column; + gap: 3px +} + +.seg { + display: flex; + background: var(--bg-cell); + border-radius: 3px; + overflow: hidden; + border: 1px solid var(--border) +} + +/* Compact inline segment for binary/small choices */ +.seg-inline { + display: inline-flex; + background: var(--bg-cell); + border-radius: 3px; + overflow: hidden; + border: 1px solid var(--border); + flex-shrink: 0 +} + +.seg-inline button { + padding: 2px 8px; + background: none; + border: none; + border-right: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 9px; + font-weight: 500; + cursor: pointer; + white-space: nowrap +} + +.seg-inline button:last-child { + border-right: none +} + +.seg-inline button:hover { + color: var(--text-primary); + background: var(--bg-cell-hover) +} + +.seg-inline button.on { + background: var(--accent-light); + color: var(--text-primary); + font-weight: 600 +} + +.seg button { + flex: 1; + padding: 4px 2px 3px; + background: none; + border: none; + border-bottom: 2px solid transparent; + border-right: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + cursor: pointer +} + +.seg button:last-child { + border-right: none +} + +.seg button:hover { + color: var(--text-primary); + background: var(--bg-cell-hover) +} + +.seg button.on { + border-bottom-color: var(--accent); + color: var(--text-primary); + font-weight: 600 +} + +.seg button.env-on { + border-bottom-color: var(--env-color); + color: var(--text-primary); + font-weight: 600 +} + +/* ---- Mode-scoped block colors ---- */ +.mode-rand .seg button.on, +.mode-rand .seg button.env-on, +.mode-rand .seg button.smp-on { + border-bottom-color: var(--rand-color); + color: var(--text-primary); + font-weight: 600 +} + +.mode-env .seg button.on, +.mode-env .seg button.env-on, +.mode-env .seg button.smp-on { + border-bottom-color: var(--env-color); + color: var(--text-primary); + font-weight: 600 +} + +.mode-smp .seg button.on, +.mode-smp .seg button.env-on, +.mode-smp .seg button.smp-on { + border-bottom-color: var(--sample-color); + color: var(--text-primary); + font-weight: 600 +} + +/* Mode-scoped inline segment colors */ +.mode-rand .seg-inline button.on { + background: var(--si-rand-bg, rgba(232, 162, 68, 0.25)) +} + +.mode-env .seg-inline button.on { + background: var(--si-env-bg, rgba(212, 184, 60, 0.25)) +} + +.mode-smp .seg-inline button.on { + background: var(--si-smp-bg, rgba(204, 98, 64, 0.25)) +} + +.mode-morph .seg-inline button.on { + background: var(--si-morph-bg, rgba(92, 107, 192, 0.25)) +} + +.mode-shapes .seg-inline button.on { + background: var(--si-shapes-bg, rgba(160, 112, 208, 0.25)) +} + +.mode-rand .tgl.on { + background: var(--rand-color); + border-color: var(--rand-color) +} + +.mode-rand .tgl.on::after { + border-color: var(--rand-color) +} + +.mode-env .tgl.on { + background: var(--env-color); + border-color: var(--env-color) +} + +.mode-env .tgl.on::after { + border-color: var(--env-color) +} + +.mode-smp .tgl.on { + background: var(--sample-color); + border-color: var(--sample-color) +} + +.mode-smp .tgl.on::after { + border-color: var(--sample-color) +} + +.mode-rand .fire { + background: var(--rand-color) +} + +.mode-rand .fire:hover { + filter: brightness(0.85) +} + + + +.sub { + display: none; + padding-top: 4px +} + +.sub.vis { + display: flex; + flex-direction: column; + gap: 4px +} + +.sub-row { + display: flex; + align-items: center; + gap: 6px +} + +.sub-lbl { + font-size: 10px; + color: var(--text-secondary); + min-width: 40px +} + +.sub-sel { + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + padding: 3px 20px 3px 8px; + border-radius: 2px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23999'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 4px center +} + +.sub-input { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 5px; + width: 40px; + text-align: center +} + +.sub-input:focus { + outline: none; + border-color: var(--accent) +} + +.note-disp { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 8px; + min-width: 34px; + text-align: center +} + +.tgl-row { + display: flex; + align-items: center; + gap: 6px +} + +.tgl { + width: 30px; + height: 15px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; + position: relative; + flex-shrink: 0 +} + +.tgl::after { + content: ''; + position: absolute; + width: 11px; + height: 11px; + background: var(--thumb-color); + border: 1px solid var(--border-strong); + border-radius: 50%; + top: 1px; + left: 1px; + transition: all 80ms +} + +.tgl.on { + background: var(--accent); + border-color: var(--accent) +} + +.tgl.on::after { + left: 16px; + border-color: var(--accent) +} + +.tgl-lbl { + font-size: 10px; + color: var(--text-secondary) +} + +.sl-row { + display: flex; + align-items: center; + gap: 6px; + padding: 1px 0 +} + +.sl-row input[type="range"] { + flex: 1 +} + +.sl-lbl { + font-size: 11px; + color: var(--text-secondary); + min-width: 38px; + white-space: nowrap +} + +.sl-val { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + min-width: 32px; + text-align: right +} + +/* Logic block knob rows */ +.knob-row { + display: flex; + justify-content: flex-start; + gap: 6px; + padding: 3px 0 1px +} + +.bk { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + user-select: none; + min-width: 40px; + flex: 1 +} + +.bk-svg { + line-height: 0 +} + +.bk-val { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-secondary); + margin-top: 1px; + white-space: nowrap +} + +.bk-lbl { + font-size: 8px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 0px +} + +/* Disabled state for controls overridden by tempo sync */ +.sync-disabled { + opacity: 0.35; + filter: grayscale(0.6); + pointer-events: none; + cursor: default +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 14px; + background: transparent; + outline: none; + cursor: pointer +} + +/* Targets */ +.tgt-box { + display: flex; + flex-direction: column; + min-height: 20px; + padding: 0; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + max-height: 120px; + overflow: hidden +} + +.tgt-box::-webkit-scrollbar { + width: 3px +} + +.tgt-box::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.tgt-search { + width: 100%; + border: none; + border-bottom: 1px solid var(--border); + padding: 4px 6px; + font-family: var(--font-sans); + font-size: 10px; + background: var(--bg-panel); + color: var(--input-text); + outline: none; + box-sizing: border-box; + flex-shrink: 0 +} + +.tgt-search:focus { + border-bottom-color: var(--accent) +} + +.tgt-search::placeholder { + color: var(--text-muted) +} + +.tgt-list { + overflow-y: auto; + flex: 1; + min-height: 0 +} + +.tgt-list::-webkit-scrollbar { + width: 3px +} + +.tgt-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.tgt-row { + display: flex; + align-items: center; + padding: 2px 6px; + gap: 4px; + cursor: pointer; + transition: background 60ms +} + +.tgt-row:hover { + background: var(--bg-cell-hover) +} + +.tgt-name { + flex: 1; + font-size: 9px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.tgt-lane-tag { + font-size: 8px; + font-weight: 700; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; + letter-spacing: 0.5px +} + +.tg { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 9px; + font-weight: 500; + padding: 2px 6px; + border-radius: 2px +} + +.tg .tx { + cursor: pointer; + opacity: .5; + font-size: 10px +} + +.tg .tx:hover { + opacity: 1 +} + +.tg-empty { + font-size: 10px; + color: var(--text-muted); + padding: 1px 4px +} + +.fire { + width: 100%; + height: 30px; + background: var(--accent); + border: none; + border-radius: 3px; + color: var(--fire-text, #fff); + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + cursor: pointer +} + +.fire:hover { + background: var(--accent-hover) +} + +.fire:active { + transform: scale(.98); + background: var(--fire-active-bg, var(--accent-hover)) +} + +.fire.flash { + animation: fl 250ms ease-out +} + +@keyframes fl { + 0% { + box-shadow: 0 0 12px var(--accent) + } + + 100% { + box-shadow: none + } +} + +/* ── Redesigned block sections ── */ + +/* Section container with label — used for grouped controls */ +.block-section { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.block-section:last-child { + border-bottom: none; + padding-bottom: 2px; +} + +.block-section-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 2px; +} + +/* Behaviour row — trigger + movement on one line with a divider dot */ +.behaviour-row { + display: flex; + align-items: center; + gap: 6px; +} + +.behaviour-row .seg { + flex: 1; + min-width: 0; +} + +.divider-dot { + width: 3px; + height: 3px; + background: var(--border-strong); + border-radius: 50%; + flex-shrink: 0; +} + +/* Constraints box — sunken container for range/quantize controls */ +.constraints-box { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 8px; + display: flex; + flex-direction: column; + gap: 5px; +} + +/* Two-column modifier layout with vertical divider */ +.mod-columns { + display: flex; + align-items: center; + gap: 0; +} + +.mod-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; +} + +.mod-divider { + width: 1px; + align-self: stretch; + background: var(--border); + margin: 0 6px; + flex-shrink: 0; +} + +.constraints-box .sl-row { + padding: 0; +} + +.constraints-box .tgl-row { + padding-top: 2px; + border-top: 1px solid var(--border); + margin-top: 1px; +} + +/* Constraints header row — mode toggle + value readout */ +.constraints-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.constraints-val { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + letter-spacing: 0.5px; +} + +/* Envelope input section — meter + source combined */ +.env-input-box { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 8px; + display: flex; + flex-direction: column; + gap: 6px; + /* Knob tracks blend with dark inset bg — use border color for contrast */ + --knob-track: var(--border); + --lk-rand-track: var(--border); + --lk-env-track: var(--border); + --lk-smp-track: var(--border); + --lk-morph-track: var(--border); + --lk-shapes-track: var(--border); +} + +.env-input-box .brow-inline { + padding-top: 4px; + border-top: 1px solid var(--border); +} + +/* Polarity row — compact inline */ +.polarity-row { + display: flex; + align-items: center; + gap: 6px; +} + +.constraints-box .polarity-row { + padding-top: 4px; + border-top: 1px solid var(--border); + margin-top: 1px; +} + +/* Envelope */ +.env-meter { + height: 36px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + position: relative; + overflow: hidden +} + +.env-meter-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, var(--env-color), transparent); + will-change: height; +} + +.env-peak-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: var(--env-color); + opacity: 0.8; + will-change: bottom; + transition: bottom 40ms linear, opacity 0.8s ease-out; + pointer-events: none; +} + +.env-label { + position: absolute; + right: 4px; + top: 2px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary) +} + +.env-active-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--env-color); + margin-right: 3px; + opacity: 0.3; + transition: opacity 60ms linear; +} + +/* Envelope filter frequency response visualization */ +.env-filter-viz { + width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; + line-height: 0; +} + +.env-filter-svg { + width: 100%; + height: 42px; + display: block; +} + +/* Sample Modulator styles */ +.seg button.smp-on { + background: var(--sample-color); + color: #F0EAE0; + border-color: var(--sample-color) +} + +.lcard.smp-active { + border-color: var(--sample-color) !important +} + +.sample-zone { + position: relative; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + min-height: 50px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 6px; + flex: 1 +} + +.waveform-cv { + width: 100%; + height: 48px; + border-radius: 2px +} + +.waveform-head { + position: absolute; + top: 6px; + left: 0; + width: 1px; + height: 48px; + background: var(--sample-color); + pointer-events: none; + will-change: transform +} + +.sample-name { + font-size: 10px; + color: var(--text-secondary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px +} + +.drop-label { + font-size: 11px; + color: var(--text-muted); + padding: 8px 0 +} + +.load-smp { + font-size: 10px !important; + padding: 3px 12px !important; + border-color: var(--sample-color) !important; + color: var(--sample-color) !important +} + +.load-smp:hover { + background: var(--sample-color) !important; + color: var(--fire-text) !important +} + +.lcard.disabled { + opacity: 0.45; +} + +.lcard.disabled .lbody { + pointer-events: none; +} + +.pwr-btn { + width: 10px; + height: 10px; + border: 1px solid var(--border-strong); + border-radius: 2px; + background: var(--bg-inset); + cursor: pointer; + transition: all .15s; + flex-shrink: 0 +} + +.pwr-btn.on { + background: #40CC50; + border-color: #30A040; + box-shadow: 0 0 4px #40CC5088, 0 0 8px #40CC5044 +} + +.pwr-btn:hover { + border-color: var(--border-focus) +} + +.smp-active-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--sample-color); + margin-right: 3px; + animation: ep 1s ease-in-out infinite +} + +/* Add buttons */ +.add-wrap { + display: flex; + gap: 6px; + padding: 8px; + flex-shrink: 0 +} + +.add-blk { + flex: 1; + height: 36px; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px 6px +} + +.add-blk:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +/* Morph pad mode class */ + + + +.mode-morph .seg button.on, +.mode-morph .seg button.env-on, +.mode-morph .seg button.smp-on { + border-bottom-color: var(--morph-color); + color: var(--text-primary); + font-weight: 600 +} + +.mode-morph .tgl.on { + background: var(--morph-color); + border-color: var(--morph-color) +} + +.mode-morph .tgl.on::after { + border-color: var(--morph-color) +} + +.mode-morph .fire { + background: var(--morph-color) +} + +.mode-morph .fire:hover { + filter: brightness(0.85) +} + + + +/* Shapes mode */ + + +.mode-shapes .seg button.on { + border-bottom-color: var(--shapes-color); + color: var(--text-primary); + font-weight: 600 +} + +.mode-shapes .tgl.on { + background: var(--shapes-color); + border-color: var(--shapes-color) +} + + + +.shapes-pad .lfo-path-svg polyline { + stroke: var(--shapes-color); +} + +.shapes-pad .playhead-dot::after { + background: var(--shapes-color); +} + +/* Shapes Range — per-param range list */ +.sr-ranges { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 120px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) var(--bg-inset) +} + +.sr-ranges::-webkit-scrollbar { + width: 4px +} + +.sr-ranges::-webkit-scrollbar-track { + background: var(--bg-inset); + border-radius: 2px +} + +.sr-ranges::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.sr-ranges::-webkit-scrollbar-thumb:hover { + background: var(--border-focus) +} + +.sr-range-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 6px; + background: var(--bg-inset); + border-radius: 2px; + font-size: 9px +} + +.sr-range-name { + color: var(--text-secondary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.sr-range-val { + font-family: var(--font-mono); + color: var(--shapes-color); + font-weight: 600; + margin-left: 8px; + flex-shrink: 0 +} + +.sr-range-empty { + font-size: 9px; + color: var(--text-muted); + padding: 6px; + text-align: center; + font-style: italic +} + +.sr-range-val.sr-pos { + color: #66CC88; +} + +.sr-range-val.sr-neg { + color: #CC6666; +} + +/* Shape readout lines — show the 1D output on the pad */ +.shape-readout { + position: absolute; + pointer-events: none; + z-index: 3; + transition: none; +} + +.shape-readout-horizontal { + /* Vertical line showing X position */ + width: 1px; + height: 100%; + top: 0; + left: 50%; + border-left: 1px dashed var(--shapes-color); + opacity: 0.5; +} + +.shape-readout-vertical { + /* Horizontal line showing Y position */ + width: 100%; + height: 1px; + left: 0; + top: 50%; + border-top: 1px dashed var(--shapes-color); + opacity: 0.5; +} + +.shape-readout-distance { + /* Circle showing distance */ + border-radius: 50%; + border: 1px dashed var(--shapes-color); + opacity: 0.35; + top: 50%; + left: 50%; + width: 0; + height: 0; + transform: translate(-50%, -50%); +} + +/* XY Pad container — circular */ +.morph-pad { + width: 200px; + height: 200px; + margin: 0 auto; + background: var(--bg-inset); + border: 2px solid var(--border); + border-radius: 50%; + position: relative; + overflow: visible; + cursor: crosshair; + background-image: + radial-gradient(circle, transparent 68%, var(--border) 69%, var(--border) 70%, transparent 71%), + linear-gradient(0deg, transparent 49.5%, var(--border) 49.5%, var(--border) 50.5%, transparent 50.5%), + linear-gradient(90deg, transparent 49.5%, var(--border) 49.5%, var(--border) 50.5%, transparent 50.5%); + background-size: 100% 100%; + background-position: center center; +} + +.lfo-path-svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} + +.lfo-path-svg polyline { + stroke: var(--morph-color); + opacity: 0.65; +} + +.lfo-path-svg { + transform-origin: 50% 50%; +} + +@keyframes lfo-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.morph-pad .empty-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 10px; + pointer-events: none; +} + +.morph-pad.empty { + opacity: 0.5; + cursor: default; +} + +/* Snapshot dots */ +.snap-dot { + width: 10px; + height: 10px; + background: var(--text-muted); + border: 1px solid var(--border-strong); + border-radius: 50%; + position: absolute; + cursor: grab; + transform: translate(-50%, -50%); + transition: background 0.15s; + z-index: 2; +} + +.snap-dot:hover { + background: var(--text-primary); +} + +.snap-dot.active { + background: var(--morph-color); + border-color: var(--morph-color); +} + +.snap-dot .snap-label { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + font-size: 8px; + color: var(--text-muted); + white-space: nowrap; + pointer-events: none; +} + +/* Playhead dot */ +.playhead-dot { + width: 10px; + height: 10px; + background: transparent; + border: 1.5px solid var(--text-primary); + border-radius: 50%; + position: absolute; + transform: translate(-50%, -50%); + z-index: 5; + pointer-events: none; +} + +.playhead-dot::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + background: var(--morph-color); + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.playhead-dot.manual { + cursor: grab; + pointer-events: auto; +} + +/* Snapshot chips */ +.snap-chips { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 4px; + align-items: center; +} + +.snap-chip { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 3px; +} + +.snap-chip:hover { + background: var(--bg-cell-hover); +} + +.snap-chip .snap-del { + cursor: pointer; + opacity: 0.5; +} + +.snap-chip .snap-del:hover { + opacity: 1; + color: var(--locked-icon); +} + +.snap-add-btn { + font-size: 9px; + padding: 2px 8px; + border-radius: 3px; + background: var(--morph-color); + border: 1px solid var(--morph-color); + color: var(--fire-text); + cursor: pointer; +} + +.snap-add-btn:hover { + filter: brightness(1.2); +} + +.snap-add-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.snap-lib-btn { + font-size: 9px; + padding: 2px 8px; + border-radius: 3px; + background: var(--bg-cell); + border: 1px solid var(--morph-color); + color: var(--morph-color); + cursor: pointer; +} + +.snap-lib-btn:hover { + background: var(--morph-color); + color: var(--fire-text); +} + +.snap-lib-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Snapshot flash — radiates from the pad when a snapshot is captured */ +@keyframes morph-snap-flash { + 0% { + box-shadow: 0 0 0 0 var(--morph-color); + } + + 50% { + box-shadow: 0 0 18px 6px var(--morph-color); + } + + 100% { + box-shadow: none; + } +} + +.morph-pad.snap-flash { + animation: morph-snap-flash 0.4s ease-out; +} + +/* Snapshot chip was just added — glow entrance */ +@keyframes snap-chip-glow { + 0% { + background: var(--morph-color); + color: var(--fire-text); + } + + 100% { + background: var(--bg-cell); + color: var(--text-secondary); + } +} + +.snap-chip.just-added { + animation: snap-chip-glow 0.6s ease-out; +} + +/* Radius ring visualization — shown while adjusting snapRadius slider */ +.radius-ring { + position: absolute; + border-radius: 50%; + border: 1px solid var(--snap-ring-color, var(--morph-color)); + background: radial-gradient(circle, transparent 40%, color-mix(in srgb, var(--snap-ring-color, var(--morph-color)), transparent 85%) 100%); + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 1; + opacity: var(--snap-ring-opacity, 0.6); + transition: opacity 0.3s; +} + +.radius-ring.fading { + opacity: 0; + transition: opacity 0.4s; +} + +/* ══ Lane Mode ══ */ + +/* Lane block takes full width — equivalent to 2 normal blocks side by side */ +.lcard.mode-lane { + width: 100%; + min-width: 0; +} + +.mode-lane .seg button.on { + border-bottom-color: var(--lane-color); + color: var(--text-primary); + font-weight: 600 +} + +.mode-lane .tgl.on { + background: var(--lane-color); + border-color: var(--lane-color) +} + +.mode-lane .seg-inline button.on { + background: color-mix(in srgb, var(--lane-color) 25%, transparent) +} + +/* Lane section — remove inner padding to let toolbar be flush */ +.lane-section { + padding: 4px 0 0 !important; + border-bottom: none !important; +} + +/* Lane toolbar */ +.lane-toolbar { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 3px; + padding: 3px 4px; + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + user-select: none; + position: relative; + flex-shrink: 0; + overflow: hidden; +} + +.lane-tbtn { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + padding: 2px 5px; + cursor: pointer; + transition: all .1s; + white-space: nowrap; + line-height: 1.3; +} + +.lane-tbtn:hover { + color: var(--text-secondary); + border-color: var(--border); +} + +.lane-tbtn.on { + color: var(--text-primary); + background: var(--bg-cell); + border-color: var(--border-strong); +} + +.lane-tsep { + width: 1px; + height: 14px; + background: var(--border); + margin: 0 2px; + flex-shrink: 0; +} + +.lane-tlbl { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +/* Grid tabs (compact inline pill group) */ +.lane-itabs { + display: flex; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px; + gap: 1px; + flex-shrink: 0; +} + +.lane-itab { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + background: none; + border: none; + padding: 2px 5px; + cursor: pointer; + transition: all .1s; + white-space: nowrap; + border-radius: 2px; + letter-spacing: 0.04em; + line-height: 1.3; +} + +.lane-itab.on { + background: var(--bg-cell); + color: var(--text-primary); +} + +.lane-itab:hover { + color: var(--text-secondary); +} + +.lane-toolbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 5px; +} + +/* Lane empty state */ +.lane-empty { + font-size: 10px; + color: var(--text-muted); + text-align: center; + padding: 16px 8px; + font-style: italic; +} + +/* Lane stack */ +.lane-stack { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 4px 0; +} + +.lane-item { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-panel, #1a1a1a); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), + inset 0 0 0 1px rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.lane-item:last-child { + border-bottom: 1px solid var(--border); +} + +/* Lane header */ +.lane-hdr { + display: flex; + align-items: stretch; + height: 28px; + border-bottom: 1px solid var(--border); + user-select: none; + background: color-mix(in srgb, var(--bg-panel) 80%, var(--bg-app)); +} + +.lane-hdr-arrow { + width: 26px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-right: 1px solid var(--border); + cursor: pointer; + color: var(--text-muted); + font-size: 9px; + transition: color .1s; +} + +.lane-hdr-arrow:hover { + color: var(--text-primary); +} + +.lane-hdr-color { + width: 3px; + flex-shrink: 0; +} + +.lane-hdr-name { + flex: 1; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-right: 1px solid var(--border); + min-width: 0; + font-family: var(--font-mono); +} + +.lane-hdr-ctrls { + display: flex; + align-items: stretch; + flex-shrink: 0; +} + +.lane-hdr-sel { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: none; + border: none; + border-left: 1px solid var(--border); + outline: none; + cursor: pointer; + padding: 0 8px; + max-width: 56px; +} + +.lane-hdr-sel option { + background: var(--bg-cell); + color: var(--text-primary); +} + +.lane-hdr-phase, +.lane-hdr-clear { + display: flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border-left: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + cursor: pointer; + transition: color .1s; + white-space: nowrap; +} + +.lane-hdr-phase:hover, +.lane-hdr-clear:hover { + color: var(--text-secondary); +} + +.lane-hdr-mute { + width: 28px; + flex-shrink: 0; + border-left: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; + color: var(--text-muted); + transition: color .1s; +} + +.lane-hdr-mute:hover { + color: var(--text-primary); +} + +.lane-hdr-mute.lit { + color: var(--lane-active); +} + +/* Lane delete button */ +.lane-del-btn { + width: 22px; + flex-shrink: 0; + border-left: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + opacity: 0.4; + transition: color .1s, opacity .1s; +} + +.lane-del-btn:hover { + color: var(--locked-icon, #E05848); + opacity: 1; +} + +/* Overlay picker button */ +.lane-hdr-overlay { + padding: 0 6px; + flex-shrink: 0; + border-left: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-muted); + opacity: 0.5; + transition: opacity .15s, color .15s; + user-select: none; +} + +.lane-hdr-overlay:hover { + opacity: 0.9; + color: var(--text-primary); +} + +.lane-hdr-overlay.active { + opacity: 1; + color: var(--accent-primary, #4fc3f7); + text-shadow: 0 0 4px var(--accent-primary, #4fc3f7); +} + +/* Morph sidebar capture button */ +.lane-sidebar-capture { + margin-top: 2px; +} + +.lane-cap-direct { + border-color: var(--accent, var(--border-strong)) !important; + color: var(--text-primary) !important; +} + +.lane-cap-reset { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 10px; + color: var(--text-muted); + cursor: pointer; + border-radius: 50%; + margin-left: -2px; + margin-top: 2px; + transition: color .1s, background .1s; + flex-shrink: 0; + user-select: none; +} + +.lane-cap-reset:hover { + color: #e57373; + background: rgba(229, 115, 115, 0.15); +} + +.lane-ft-info { + font-size: 9px; + color: var(--text-muted); + padding: 0 6px; + opacity: 0.7; +} + +/* Morph footer controls */ +.lane-ft-sep { + width: 1px; + height: 14px; + background: var(--border); + margin: 0 6px; + flex-shrink: 0; +} + +.lane-ft-sel-label { + font-size: 9px; + font-weight: 600; + color: var(--accent, var(--text-primary)); + padding: 0 4px; + white-space: nowrap; +} + +.lane-morph-curve-sel { + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + border: 1px solid var(--border); + background: var(--surface-secondary); + color: var(--text-primary); + cursor: pointer; +} + +/* Morph context menu */ +.morph-ctx-menu { + background: var(--surface-secondary, #1e1e1e); + border: 1px solid var(--border, #333); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + padding: 4px 0; + overflow: hidden; +} + +/* Morph delete button */ +.lane-ft-del { + color: #e57373 !important; + border-color: #e57373 !important; + padding: 0 4px !important; + min-width: 0 !important; + font-size: 10px !important; +} + +.lane-ft-del:hover { + background: rgba(229, 115, 115, 0.15) !important; +} + +/* Inline snapshot name editor */ +.morph-inline-edit { + position: absolute; + z-index: 100; + text-align: center; + font-size: 9px; + padding: 1px 2px; + background: var(--surface-secondary); + color: var(--text-primary); + border: 1px solid var(--border-strong, var(--border)); + border-radius: 3px; + outline: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +/* Morph sidebar snapshot list */ +.lane-snap-list { + flex: 1; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 1px; + padding: 1px 0; + min-height: 0; +} + +.lane-snap-list::-webkit-scrollbar { + width: 3px; +} + +.lane-snap-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +.lane-snap-item { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 4px; + cursor: pointer; + border-radius: 3px; + transition: background .1s; + font-size: 9px; + position: relative; +} + +.lane-snap-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.lane-snap-item.sel { + background: rgba(255, 255, 255, 0.1); + box-shadow: inset 2px 0 0 var(--accent); +} + +.lane-snap-num { + font-weight: 700; + font-size: 10px; + min-width: 12px; + text-align: center; +} + +.lane-snap-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-size: 10px; +} + +.lane-snap-del { + opacity: 0; + font-size: 10px; + color: var(--text-muted); + cursor: pointer; + padding: 0 2px; + transition: opacity .15s, color .15s; +} + +.lane-snap-item:hover .lane-snap-del { + opacity: 0.6; +} + +.lane-snap-del:hover { + opacity: 1 !important; + color: #e57373; +} + +.lane-snap-hold { + font-size: 9px; + color: var(--text-muted); + opacity: 0.8; + flex-shrink: 0; +} + +/* Library button */ +.lane-morph-lib-btn { + margin-top: 2px; +} + +/* Morph lane bottom row — snapshot list + param list side by side */ +.lane-morph-lists { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-inset, var(--bg-app)); + min-height: 80px; + max-height: 160px; +} + +.lane-morph-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 4px 5px 5px; + background: var(--bg-inset, var(--bg-app)); + border: 1px solid var(--border); + border-radius: 3px; + margin: 3px; + overflow: hidden; +} + +.lane-morph-col+.lane-morph-col { + margin-left: 0; +} + +.lane-morph-col-head { + display: flex; + align-items: center; + gap: 6px; + padding: 0 2px 3px; + flex-shrink: 0; +} + +.lane-morph-col-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + flex-shrink: 0; + white-space: nowrap; +} + +.lane-morph-search { + flex: 1; + min-width: 0; + max-width: 120px; + padding: 2px 5px; + font-family: var(--font-sans); + font-size: 9px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-cell, #1e1e1e); + color: var(--text-primary); + outline: none; +} + +.lane-morph-search::placeholder { + color: var(--text-muted); + opacity: 0.5; +} + +.lane-morph-search:focus { + border-color: var(--accent); +} + +.lane-morph-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 28px; +} + +.lane-morph-scroll::-webkit-scrollbar { + width: 3px; +} + +.lane-morph-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +/* Larger items in the morph bottom row */ +.lane-morph-lists .lane-snap-item { + padding: 4px 6px; + font-size: 11px; + gap: 5px; +} + +.lane-morph-lists .lane-snap-num { + font-size: 11px; + min-width: 16px; +} + +.lane-morph-lists .lane-snap-name { + font-size: 11px; +} + +.lane-morph-lists .lane-snap-hold { + font-size: 9px; + opacity: 0.8; +} + +.lane-morph-lists .lane-param-chip { + font-size: 11px; + padding: 4px 6px; + min-height: 24px; +} + +.lane-morph-lists .lane-param-chip-name { + font-size: 11px; +} + +.lane-morph-empty { + font-size: 9px; + color: var(--text-muted); + font-style: italic; + padding: 6px 4px; + opacity: 0.6; +} + +/* Morph knobs in sidebars (right: effects, left: drift) */ +.lane-morph-knob { + display: block; + width: 100%; + text-align: center; + margin: 1px 0; + padding: 2px 2px !important; + font-size: 9px !important; +} + +select.lane-morph-knob { + padding: 2px 1px !important; + font-size: 9px !important; + box-sizing: border-box; +} + +/* Lane body (canvas row) */ +.lane-body { + display: flex; + align-items: stretch; + overflow: hidden; +} + +/* Left sidebar — param list */ +.lane-lb-left { + width: 96px; + flex-shrink: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 4px; + gap: 3px; + background: var(--bg-panel); + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +/* Scrollable param chip container */ +.lane-param-list { + flex: 1; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.lane-param-list::-webkit-scrollbar { + width: 3px; +} + +.lane-param-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +/* Individual param chip */ +.lane-param-chip { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 4px; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 2px; + font-size: 9px; + font-family: var(--font-mono); + color: var(--text-secondary); + min-height: 20px; + width: 100%; +} + +.lane-param-chip-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.lane-param-chip-x { + cursor: pointer; + opacity: 0.4; + font-size: 11px; + line-height: 1; + flex-shrink: 0; +} + +.lane-param-chip-x:hover { + opacity: 1; + color: var(--locked-icon); +} + +/* Add param button */ +.lane-add-param-btn { + font-size: 9px; + font-family: var(--font-mono); + color: var(--text-muted); + background: none; + border: 1px dashed var(--border); + border-radius: 2px; + padding: 3px 0; + cursor: pointer; + width: 100%; + text-align: center; + transition: all .1s; + flex-shrink: 0; +} + +.lane-add-param-btn:hover { + color: var(--text-primary); + border-color: var(--border-strong); + background: var(--bg-cell); +} + +/* Add Lane buttons — centered pair */ +.lane-add-btns { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 4px 8px; +} + +.lane-add-btn { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px dashed var(--border-strong); + border-radius: 3px; + padding: 5px 14px; + cursor: pointer; + transition: all .15s; + text-align: center; + white-space: nowrap; +} + +.lane-add-btn:hover { + color: var(--text-primary); + border-color: var(--accent, var(--lane-color)); + border-style: solid; + background: color-mix(in srgb, var(--accent, var(--lane-color)) 10%, var(--bg-cell)); +} + +/* Header badge for multi-param count */ +.lane-hdr-badge { + display: inline-block; + font-size: 9px; + font-weight: 600; + background: var(--lane-color); + color: var(--bg-app); + border-radius: 8px; + padding: 0 5px; + margin-left: 3px; + vertical-align: middle; + line-height: 16px; +} + +/* Add-param dropdown menu */ +.lane-add-menu { + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 3px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-height: 200px; + overflow-y: auto; + min-width: 140px; +} + +.lane-add-menu::-webkit-scrollbar { + width: 4px; +} + +.lane-add-menu::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +.lane-add-menu::-webkit-scrollbar-track { + background: transparent; +} + +.lane-add-menu-item { + padding: 5px 10px; + font-size: 10px; + font-family: var(--font-mono); + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + border-bottom: 1px solid var(--border); +} + +.lane-add-menu-item:last-child { + border-bottom: none; +} + +.lane-add-menu-item:hover { + background: var(--bg-cell-hover); + color: var(--text-primary); +} + +/* Section headers in add-param menu */ +.lane-add-menu-hdr { + padding: 5px 10px 3px; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + background: var(--bg-inset); + position: sticky; + top: 0; +} + +/* Search input in add-param menu */ +.lane-add-menu-search { + width: 100%; + padding: 5px 10px; + font-size: 10px; + font-family: var(--font-mono); + color: var(--text-primary); + background: var(--bg-inset); + border: none; + border-bottom: 1px solid var(--border); + outline: none; + box-sizing: border-box; +} + +.lane-add-menu-search::placeholder { + color: var(--text-muted); +} + +/* "Knob" display — emulates the mockup's C-circle readout */ +.lane-lb-knob { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity .15s; + padding: 2px 0; + flex: 1; + min-height: 0; + user-select: none; +} + +.lane-lb-knob:hover { + opacity: 0.8; +} + +.lane-knob-letter { + font-family: var(--font-sans); + font-size: 22px; + font-weight: 300; + color: var(--text-muted); + line-height: 1; +} + +.lane-knob-val { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-secondary); + line-height: 1.2; +} + +.lane-knob-lbl { + font-size: 6px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + line-height: 1.2; +} + +.lane-interp-stack { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; +} + +.lane-ibtn { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.04em; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 0; + cursor: pointer; + text-align: center; + transition: all .1s; + line-height: 1.4; +} + +.lane-ibtn:hover { + color: var(--text-secondary); + border-color: var(--border-strong); +} + +.lane-ibtn.on { + color: var(--text-primary); + background: var(--bg-cell); + border-color: var(--border-strong); +} + +/* Canvas area */ +.lane-canvas-wrap { + flex: 1; + position: relative; + background: var(--bg-inset); + min-width: 0; + overflow: hidden; +} + +.lane-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + cursor: crosshair; +} + +.lane-playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--lane-playhead); + pointer-events: none; + z-index: 3; + box-shadow: 0 0 6px color-mix(in srgb, var(--lane-playhead) 50%, transparent), 0 0 2px var(--lane-playhead); + left: 0; + will-change: transform; +} + +/* Value indicator — vertical bar on right edge showing current param value */ +.lane-val-indicator { + position: absolute; + right: 0; + bottom: 0; + width: 3px; + height: 50%; + background: color-mix(in srgb, var(--lane-active) 50%, transparent); + pointer-events: none; + z-index: 2; + will-change: height; + border-radius: 1px 1px 0 0; +} + +/* Free seconds input */ +.lane-hdr-fsec { + width: 38px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-primary); + font-size: 9px; + font-family: var(--font-mono); + padding: 1px 3px; + text-align: center; + outline: none; +} + +.lane-hdr-fsec:focus { + border-color: var(--lane-color); +} + +.lane-hdr-unit { + font-size: 8px; + color: var(--text-muted); + margin-right: 3px; +} + +/* Play mode selector */ +.lane-hdr-pm { + max-width: 62px; + font-size: 9px; +} + +/* Right sidebar */ +.lane-lb-right { + width: 66px; + flex-shrink: 0; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5px 4px; + gap: 5px; + background: var(--bg-panel); +} + +.lane-rb-label { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + text-align: center; +} + +.lane-sync-pill { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 4px; + cursor: pointer; + width: 100%; + text-align: center; + transition: all .1s; + letter-spacing: 0.03em; + line-height: 1.4; +} + +.lane-sync-pill.on { + color: var(--lane-active); + border-color: color-mix(in srgb, var(--lane-active) 35%, transparent); + background: color-mix(in srgb, var(--lane-active) 6%, transparent); +} + +/* Lane footer — compact strip like a DAW channel bottom bar */ +.lane-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px 6px; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.18); + border-top: 1px solid var(--border); + min-height: 28px; +} + +.lane-ft-knob { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 3px; + padding: 2px 6px; + cursor: ns-resize; + user-select: none; + white-space: nowrap; + transition: color .1s, border-color .1s; +} + +.lane-ft-knob:hover { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-cell-hover); +} + +.lane-ft-sel { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 3px; + padding: 2px 4px; + cursor: pointer; + outline: none; + transition: color .1s, border-color .1s; +} + +.lane-ft-sel:hover, +.lane-ft-sel:focus { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-cell-hover); +} + +.lane-ft-spacer { + flex: 1; + min-width: 4px; +} + +.lane-ft-sep { + color: var(--border-strong, var(--border)); + font-size: 12px; + opacity: 0.4; + user-select: none; + padding: 0 1px; +} + +.lane-ft-btn { + font-size: 10px; + color: var(--text-primary); + background: var(--bg-cell); + border: 1px solid var(--border-strong, var(--border)); + border-radius: 3px; + padding: 3px 8px; + cursor: pointer; + transition: all 0.1s; + white-space: nowrap; + margin-left: 2px; +} + +.lane-ft-btn:hover { + color: var(--text-primary); + background: var(--bg-cell-hover, var(--bg-cell)); + border-color: var(--border-strong); +} + +/* ── Trigger row (oneshot controls) ── */ +.lane-trig-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + background: rgba(0, 0, 0, 0.12); + border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + font-size: 10px; +} + +.lane-trig-label { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.lane-fire-btn { + font-size: 10px; + color: var(--text-primary); + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 3px; + padding: 2px 10px; + cursor: pointer; + font-weight: 600; + transition: all 0.12s; +} + +.lane-fire-btn:hover { + filter: brightness(1.2); + transform: scale(1.04); +} + +.lane-fire-btn.fired { + background: #fff; + color: #000; + transform: scale(1.1); +} + +.lane-trig-slider { + width: 80px; + height: 12px; + accent-color: var(--accent); + cursor: pointer; +} + +.lane-trig-db { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 9px; + min-width: 36px; +} + +.lane-trig-chk { + color: var(--text-secondary); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 3px; + margin-left: auto; +} + +.lane-trig-chk input[type="checkbox"] { + accent-color: var(--accent); + width: 12px; + height: 12px; + cursor: pointer; +} + +/* ── Lane value tooltip (hover/drag on breakpoints) ── */ +.lane-value-tip { + position: absolute; + z-index: 10; + pointer-events: none; + background: rgba(0, 0, 0, 0.82); + border: 1px solid var(--border-strong, #555); + border-radius: 3px; + padding: 2px 5px; + font-family: var(--font-mono); + font-size: 9px; + color: #fff; + white-space: nowrap; + line-height: 1.3; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.lane-value-tip::after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid rgba(0, 0, 0, 0.82); +} + +/* ── Selected param chip in curve lane sidebar ── */ +.lane-param-chip.lane-param-sel { + outline: 1px solid var(--accent, #78b4ff); + background: color-mix(in srgb, var(--accent, #78b4ff) 10%, var(--bg-cell)); + color: var(--text-primary); +} + +.lane-param-chip.lane-param-sel .lane-param-chip-name { + color: var(--accent, #78b4ff); +} + +/* ── Param value badge (curve + morph lanes) ── */ +.lane-param-val-badge { + font-family: var(--font-mono); + font-size: 9px; + color: var(--accent, #78b4ff); + opacity: 1; + flex-shrink: 0; + margin-left: auto; + padding-left: 4px; + white-space: nowrap; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.02em; +} + +/* Morph lane: badge in the middle, × pushed right */ +.lane-morph-lists .lane-param-val-badge { + margin-left: 0; + padding-left: 6px; +} + +.lane-morph-lists .lane-param-chip-x { + margin-left: auto; +} + +/* Dimmer static range badge for non-selected curve params */ +.lane-param-val-badge.lane-param-range { + font-size: 7px; + color: var(--text-muted); + opacity: 0.6; +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/overrides.css b/plugins/ModularRandomizer/Source/ui/public/css/overrides.css new file mode 100644 index 0000000..40bb17d --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/overrides.css @@ -0,0 +1,32 @@ +/* ── OVERRIDES + Behavioral fixes that must load after all component CSS. + Only contains things that can't live in component files. */ + +/* Range input: reset appearance so dynamic JS styles work */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 14px; + background: transparent; + outline: none; + cursor: pointer +} + +/* During knob drag: suppress ALL hover effects */ +body.knob-dragging, +body.knob-dragging * { + cursor: ns-resize !important +} + +body.knob-dragging *:not(.lane-ft-knob) { + pointer-events: none !important +} + +body.knob-dragging .lane-ft-knob:hover, +.lane-ft-knob.dragging, +.lane-ft-knob.dragging:hover { + color: var(--text-primary) !important; + border-color: var(--accent) !important; + background: var(--bg-cell) !important; + transition: none !important +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/plugin_rack.css b/plugins/ModularRandomizer/Source/ui/public/css/plugin_rack.css new file mode 100644 index 0000000..edd1f51 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/plugin_rack.css @@ -0,0 +1,667 @@ +/* RACK */ +.rack { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden +} + +/* PLUGIN BLOCK (left) */ +.plugin-block { + width: 340px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-right: 1px solid var(--border-strong) +} + +.pb-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-cell); + border-bottom: 1px solid var(--border-strong) +} + +.pb-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted) +} + +.pb-load { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-bottom: 1px solid var(--border) +} + +.pb-load select { + flex: 1; + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + padding: 4px 22px 4px 8px; + border-radius: 3px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23999'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 6px center +} + +.pb-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-bottom: 1px solid var(--border); + background: var(--bg-cell) +} + +.pb-toolbar .sm-btn { + font-size: 9px; + padding: 3px 8px +} + +.pb-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: auto +} + +/* Search box */ +.pb-search { + display: flex; + padding: 4px 10px; + border-bottom: 1px solid var(--border); + background: var(--bg-cell) +} + +.pb-search input { + flex: 1; + border: 1px solid var(--border); + border-radius: 2px; + padding: 4px 8px; + font-family: var(--font-sans); + font-size: 11px; + color: var(--input-text); + background: var(--bg-panel); + outline: none +} + +.pb-search input:focus { + border-color: var(--accent) +} + +.pb-search input::placeholder { + color: var(--text-muted) +} + +/* Assign banner */ +.assign-banner { + display: none; + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + text-align: center +} + +.assign-banner.vis { + display: block +} + +/* Param scroll - flat list */ +.param-scroll { + flex: 1; + overflow-y: auto; + padding: 2px 4px +} + +.param-scroll::-webkit-scrollbar { + width: 4px +} + +.param-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* Plugin scroll - contains plugin cards */ +.plugin-scroll { + flex: 1; + overflow-y: auto; + padding: 4px +} + +.plugin-scroll::-webkit-scrollbar { + width: 4px +} + +.plugin-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* Plugin card */ +.pcard { + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + margin-bottom: 4px; + transition: opacity 120ms, transform 80ms +} + +.pcard.dragging { + opacity: 0.4; + transform: scale(0.97) +} + +.pcard.drag-over-top { + border-top: 2px solid var(--accent); + margin-top: -1px +} + +.pcard.drag-over-bottom { + border-bottom: 2px solid var(--accent); + margin-bottom: -1px +} + +.pcard-head { + display: flex; + align-items: center; + padding: 5px 8px; + cursor: grab; + gap: 5px; + background: var(--ph-bg, transparent); + border-bottom: 1px solid var(--ph-border, var(--border)) +} + +.pcard-head:active { + cursor: grabbing +} + +.pcard-head:hover { + filter: brightness(1.06) +} + +.pcard-name { + font-size: 11px; + font-weight: 600; + color: var(--ph-text, var(--text-primary)); + flex: 1 +} + +.pcard-info { + font-size: 10px; + color: var(--ph-text, var(--text-muted)); + margin-left: 4px; + opacity: 0.7 +} + +.pcard-close { + background: none; + border: none; + color: var(--ph-text, var(--text-muted)); + font-size: 13px; + cursor: pointer; + padding: 2px 4px +} + +.pcard-close:hover { + color: var(--text-primary); + background: var(--card-btn-hover, var(--bg-cell-hover)) +} + +/* Plugin card header buttons (All/None, Open) */ +.pcard-head .sm-btn { + background: var(--card-btn-bg, var(--bg-cell)); + border: 1px solid var(--card-btn-border, var(--border)); + color: var(--card-btn-text, var(--text-secondary)); + transition: all 80ms +} + +.pcard-head .sm-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); + background: var(--card-btn-hover, var(--bg-cell-hover)) +} + +.pcard-body { + display: flex; + flex-direction: column +} + +.pcard-body.hide { + display: none +} + +/* Plugin card footer toolbar */ +.pcard-foot { + display: flex; + align-items: center; + gap: 3px; + padding: 4px 6px; + border-top: 1px solid var(--border); + background: var(--pf-bg, var(--bg-cell)) +} + +.pf-btn { + background: none; + border: 1px solid var(--pf-border, var(--border)); + border-radius: 3px; + color: var(--pf-text, var(--text-secondary)); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + padding: 3px 6px; + cursor: pointer; + white-space: nowrap; + transition: all 80ms +} + +.pf-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); + background: var(--bg-cell-hover) +} + +/* Bypass icon button — pushed to far right */ +.pf-bypass { + margin-left: auto; + background: none; + border: 1px solid var(--pf-border, var(--border)); + border-radius: 3px; + color: var(--pf-text, var(--text-muted)); + font-size: 13px; + padding: 2px 5px; + cursor: pointer; + line-height: 1; + transition: all 80ms +} + +.pf-bypass:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +.pf-bypass.pf-active { + background: var(--locked-bg); + border-color: var(--locked-border); + color: var(--locked-icon) +} + +/* Snapshot dropdown */ +.pf-snap-wrap { + position: relative; + display: inline-flex +} + +.pf-snap-menu { + display: none; + position: fixed; + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 4px 0; + min-width: 130px; + z-index: 200; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) +} + +.pf-snap-menu.vis { + display: block +} + +.pf-snap-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap +} + +.pf-snap-item:hover { + background: var(--bg-cell-hover); + color: var(--text-primary) +} + +.pf-snap-item.disabled { + opacity: 0.4; + cursor: default +} + +.pf-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0 +} + +.pcard-search { + padding: 4px 8px; + border-bottom: 1px solid var(--border) +} + +.pcard-search input { + width: 100%; + border: 1px solid var(--border); + border-radius: 2px; + padding: 3px 6px; + font-family: var(--font-sans); + font-size: 10px; + background: var(--bg-panel); + color: var(--input-text); + outline: none +} + +.pcard-search input:focus { + border-color: var(--accent) +} + +.pcard-params { + max-height: 216px; + overflow-y: auto; + padding: 0 3px; + position: relative +} + +.pcard-params::-webkit-scrollbar { + width: 3px +} + +.pcard-params::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* Parameter row */ +.pr { + display: flex; + align-items: center; + padding: 2px 6px; + border-radius: 2px; + cursor: pointer; + gap: 4px; + height: 36px; + border: 1px solid transparent; + border-bottom: 1px solid var(--border); + overflow: hidden; + transition: all 60ms +} + +.pr-grip { + font-size: 10px; + color: var(--text-muted); + opacity: 0.35; + cursor: grab; + user-select: none; + flex-shrink: 0; + line-height: 1; + transition: opacity .12s; +} + +.pr:hover .pr-grip { + opacity: 0.7; +} + +.pr-grip:active { + cursor: grabbing; +} + +.pr:hover { + background: var(--bg-cell-hover) +} + +.pr.locked { + background: var(--locked-bg); + border-color: var(--locked-border); + cursor: default; + opacity: .6 +} + +.pr.locked:hover { + background: var(--locked-bg) +} + +.pr.assign-highlight { + border: 1px dashed var(--border-focus) +} + +.pr.assign-highlight:hover { + background: var(--accent-light); + border-color: var(--accent) +} + +.pr.selected { + background: var(--accent-light); + border-color: var(--accent-border) +} + +.pr.selected:hover { + background: var(--accent-light) +} + +/* Auto-locate flash */ +@keyframes paramTouch { + 0% { + background: var(--accent); + } + + 100% { + background: transparent; + } +} + +.pr.touched { + animation: paramTouch 800ms ease-out; +} + +.pr-name { + font-size: 11px; + font-weight: 500; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.pr-val { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + min-width: 28px; + text-align: right +} + +/* Param knob */ +.pr-knob { + flex-shrink: 0; + width: 30px; + height: 30px; + cursor: ns-resize; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50% +} + +.pr-bar { + width: 36px; + height: 4px; + background: var(--bar-track, var(--bg-inset)); + border-radius: 1px; + overflow: hidden +} + +.pr-bar-f { + height: 100%; + background: var(--bar-fill, var(--knob-value)); + border-radius: 1px; + opacity: 0.7 +} + +.pr-dots { + display: flex; + gap: 2px; + margin-left: 2px +} + +.pr-dot { + width: 5px; + height: 5px; + border-radius: 50% +} + +.pr-lock { + font-size: 8px +} + +.pcard.bypassed { + opacity: 0.4; +} + +.pcard.bypassed .pcard-name { + text-decoration: line-through; +} + +.pcard-preset { + background: var(--card-btn-bg, transparent); + border: 1px solid var(--card-btn-border, var(--border)); + border-radius: 3px; + color: var(--card-btn-text, var(--text-muted)); + font-size: 10px; + padding: 2px 6px; + cursor: pointer; + transition: all 80ms +} + +.pcard-preset:hover { + border-color: var(--border-focus); + color: var(--text-primary); + background: var(--card-btn-hover, var(--bg-cell-hover)) +} + +/* Expand chevron */ +.lchev { + font-size: 9px; + color: var(--text-muted); + margin-left: 4px; + transition: transform 120ms +} + +.lchev.open { + transform: rotate(90deg) +} + +/* ── Placeholder loading card ── */ +.pcard.pcard-loading { + opacity: 0.7; + pointer-events: none; + animation: loadingPulse 1.8s ease-in-out infinite +} + +@keyframes loadingPulse { + + 0%, + 100% { + opacity: 0.5 + } + + 50% { + opacity: 0.8 + } +} + +.pcard-loading-bar { + height: 3px; + background: var(--bg-inset); + border-radius: 2px; + margin: 12px 10px; + overflow: hidden +} + +.pcard-loading-fill { + height: 100%; + width: 40%; + background: var(--accent, #4a8cff); + border-radius: 2px; + animation: loadingSlide 1.4s ease-in-out infinite +} + +@keyframes loadingSlide { + 0% { + transform: translateX(-100%) + } + + 100% { + transform: translateX(350%) + } +} + +/* Loading dots shimmer text */ +.loading-dots::after { + content: ''; + animation: dotAnim 1.2s steps(4, end) infinite +} + +@keyframes dotAnim { + 0% { + content: '' + } + + 25% { + content: '.' + } + + 50% { + content: '..' + } + + 75% { + content: '...' + } +} + +/* ── Preset flash confirmation ── */ +@keyframes presetFlashAnim { + 0% { + box-shadow: 0 0 12px 4px var(--preset-flash-glow, rgba(74, 170, 136, 0.5)); + border-color: var(--preset-flash-color, #4a8) + } + + 100% { + box-shadow: none; + border-color: var(--border) + } +} + +.pcard.preset-flash { + animation: presetFlashAnim 0.8s ease-out +} + +/* ── Virtual WrongEQ plugin card ── */ +.pcard.pcard-virtual { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + border-left: 3px solid var(--accent); +} + +.pcard-head-virtual { + cursor: default !important; + background: color-mix(in srgb, var(--accent) 6%, var(--bg-cell)) !important; +} + +.pcard-head-virtual:active { + cursor: default !important; +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/themes.css b/plugins/ModularRandomizer/Source/ui/public/css/themes.css new file mode 100644 index 0000000..bdc5e52 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/themes.css @@ -0,0 +1,150 @@ +/* Settings panel */ +.settings-wrap { + position: relative +} + +.settings-btn { + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-secondary); + font-size: 15px; + padding: 3px 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px +} + +.settings-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +.settings-btn-label { + font-size: 10px; + font-family: var(--font-sans); + font-weight: 500; + letter-spacing: .3px +} + +.settings-drop { + display: none; + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 10px 12px; + min-width: 220px; + z-index: 150; + box-shadow: 0 6px 24px rgba(0, 0, 0, .5) +} + +.settings-drop.vis { + display: block +} + +.settings-section { + margin-bottom: 10px +} + +.settings-section:last-child { + margin-bottom: 0 +} + +.settings-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--text-muted); + margin-bottom: 6px +} + +.theme-grid { + display: flex; + flex-direction: column; + gap: 4px +} + +.theme-card { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--bg-cell); + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: border-color 80ms; + position: relative +} + +.theme-card:hover { + border-color: var(--border-focus) +} + +.theme-card.active { + border-color: var(--accent) +} + +.theme-default-badge { + position: absolute; + right: 4px; + top: 2px; + font-size: 10px; + color: var(--accent); + opacity: 0.85; + pointer-events: none; + line-height: 1 +} + +.theme-swatch { + display: flex; + gap: 2px; + flex-shrink: 0 +} + +.theme-swatch span { + width: 12px; + height: 12px; + border-radius: 2px; + border: 1px solid rgba(0, 0, 0, .15) +} + +.theme-name { + font-size: 11px; + font-weight: 600; + color: var(--text-primary) +} + +.settings-row { + display: flex; + align-items: center; + gap: 6px +} + +.settings-row label { + font-size: 10px; + color: var(--text-secondary); + min-width: 36px +} + +.settings-row select { + flex: 1; + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 11px; + padding: 4px 8px; + cursor: pointer; + outline: none +} + +.settings-row select:hover { + border-color: var(--border-focus) +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/variables.css b/plugins/ModularRandomizer/Source/ui/public/css/variables.css new file mode 100644 index 0000000..0f065d6 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/variables.css @@ -0,0 +1,127 @@ +@font-face { + font-family: 'Share Tech Mono'; + src: url('fonts/ShareTechMono-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: block; +} + +@font-face { + font-family: 'VT323'; + src: url('fonts/VT323-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: block; +} + +:root { + --bg-app: #252018; + --bg-panel: #302A22; + --bg-cell: #443E38; + --bg-cell-hover: #524C44; + --bg-inset: #1E1A14; + --bg-input: #1E1A14; + --border: #685E50; + --border-strong: #887868; + --border-focus: #A89880; + --text-primary: #FAF6F0; + --text-secondary: #DDD6C8; + --text-muted: #B0A898; + --input-text: #FAF6F0; + --accent: #2D6B3F; + --accent-hover: #245A34; + --accent-light: rgba(45, 107, 63, 0.22); + --accent-border: rgba(45, 107, 63, 0.55); + --accent-rgb: 45, 107, 63; + --locked-bg: rgba(160, 60, 50, 0.18); + --locked-border: rgba(160, 60, 50, 0.4); + --locked-icon: #C04040; + --auto-lock-bg: rgba(180, 150, 60, 0.15); + --auto-lock-border: rgba(180, 150, 60, 0.35); + --midi-dot: #50A858; + --rand-color: #2D6B3F; + --env-color: #A08420; + --sample-color: #8B3030; + --morph-color: #5C6BC0; + --shapes-color: #9C5BBF; + --thumb-color: #F0E8D8; + + /* Knobs */ + --knob-track: #3A3428; + --knob-value: #2D6B3F; + --knob-dot: #70B080; + + /* Preset/Plugin headers */ + --pf-bg: #1E1A14; + --pf-border: #685E50; + --pf-text: #B0A898; + --ph-bg: #1E1A14; + --ph-border: #685E50; + --ph-text: #FAF6F0; + + /* Sliders & bars */ + --slider-track: #685E50; + --slider-thumb: #F0E8D8; + --bar-track: #302A22; + --bar-fill: #2D6B3F; + + /* Card buttons */ + --card-btn-bg: #1E1A14; + --card-btn-border: #685E50; + --card-btn-text: #B0A898; + --card-btn-hover: #302A22; + + /* Fire (active mode button) */ + --fire-text: #FAF6F0; + --fire-active-bg: #2D6B3F; + + /* Snap ring */ + --snap-ring-color: #5C6BC0; + --snap-ring-opacity: 0.55; + + /* Status indicator backgrounds */ + --si-rand-bg: rgba(45, 107, 63, 0.18); + --si-env-bg: rgba(160, 132, 32, 0.18); + --si-smp-bg: rgba(139, 48, 48, 0.18); + --si-morph-bg: rgba(92, 107, 192, 0.18); + --si-shapes-bg: rgba(156, 91, 191, 0.18); + + /* Lane / automation */ + --lane-color: #3A9CA0; + --lane-grid: rgba(255, 255, 255, 0.18); + --lane-grid-label: rgba(255, 255, 255, 0.28); + --lane-playhead: rgba(255, 255, 255, 0.65); + --lane-active: #5ED45E; + + /* Misc */ + --range-arc: #ff8c42; + --scrollbar-thumb: rgba(255, 255, 255, 0.2); + --scrollbar-track: transparent; + + /* Bus */ + --bus-mute-bg: #CC4444; + --bus-mute-text: #FFFFFF; + --bus-solo-bg: #CCAA33; + --bus-solo-text: #1a1a1a; + --bus-group-tint: 8%; + --bus-header-tint: 16%; + --bus-tint: 0%; + --bus-badge-text: #000000; + + /* Toast notifications */ + --toast-success-bg: linear-gradient(135deg, #0a3a1a, #082a10); + --toast-success-border: #4a8; + --toast-error-bg: linear-gradient(135deg, #4a1010, #2a0808); + --toast-error-border: #ff3333; + --toast-info-bg: linear-gradient(135deg, #102040, #081828); + --toast-info-border: #4a8cff; + --toast-text: #ffffff; + + /* Preset flash confirmation */ + --preset-flash-color: #4a8; + --preset-flash-glow: rgba(74, 170, 136, 0.5); + + /* Fonts */ + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Consolas', monospace; +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css b/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css new file mode 100644 index 0000000..f418e90 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css @@ -0,0 +1,2024 @@ +/* ============================================================ + WrongEQ — Drawable frequency-band EQ + per-band plugin routing + Uses the same design language as lane_module / logic_blocks + ============================================================ */ + +/* ── Floating window layer (non-blocking) ── */ +.weq-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 500; + /* No backdrop — main UI stays fully interactive */ + pointer-events: none; +} + +.weq-overlay.visible { + display: block; +} + +/* ── Floating popup container (draggable) ── */ +.weq-popup { + pointer-events: auto; + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 10px; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.03); + width: min(97vw, 1180px); + max-height: 94vh; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + animation: weqSlideIn 0.18s ease-out; + resize: both; +} + +/* When being dragged, remove the centering transform */ +.weq-popup.weq-dragged { + transform: none; +} + +@keyframes weqSlideIn { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 12px)) scale(0.97); + } + + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +/* ── Open button in main UI ── */ +.weq-open-btn { + background: var(--accent) !important; + color: var(--fire-text) !important; + font-weight: 700 !important; + letter-spacing: 0.06em; + border-color: var(--accent) !important; + animation: weqPulse 2s ease-in-out infinite; +} + +@keyframes weqPulse { + + 0%, + 100% { + box-shadow: 0 0 0 0 var(--accent); + } + + 50% { + box-shadow: 0 0 8px 0 var(--accent); + } +} + +/* ── Header ── */ +.weq-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-inset); + border-bottom: 1px solid var(--border); + border-radius: 8px 8px 0 0; + cursor: move; + user-select: none; +} + +.weq-title { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--text-primary); + text-transform: uppercase; +} + +.weq-subtitle { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: 8px; +} + +.weq-header-ctrls { + display: flex; + align-items: center; + gap: 5px; +} + +.weq-hdr-btn { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + padding: 3px 8px; + cursor: pointer; + transition: all .15s; +} + +.weq-hdr-btn:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +.weq-hdr-btn.on { + color: var(--fire-text); + background: var(--accent); + border-color: var(--accent); +} + +/* ── Preset strip ── */ +.weq-preset-strip { + display: flex; + align-items: center; + gap: 2px; + margin: 0 6px; +} + +.weq-preset-nav { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + padding: 3px 5px; + cursor: pointer; + transition: all .12s; + line-height: 1; +} + +.weq-preset-nav:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +.weq-preset-name { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + padding: 3px 10px; + cursor: pointer; + min-width: 80px; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + transition: all .12s; +} + +.weq-preset-name:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +/* ── EQ Preset Dropdown ── */ +.weq-preset-dropdown { + position: fixed; + z-index: 999; + background: color-mix(in srgb, var(--bg-panel) 96%, black); + border: 1px solid var(--border-strong); + border-radius: 5px; + padding: 4px 0; + min-width: 200px; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 4px 16px rgba(0, 0, 0, .5); + backdrop-filter: blur(8px); +} + +.weq-preset-dropdown::-webkit-scrollbar { + width: 4px; +} + +.weq-preset-dropdown::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +.weq-pd-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + transition: background .1s; +} + +.weq-pd-row:hover { + background: var(--accent-light); + color: var(--text-primary); +} + +.weq-pd-row.active { + color: var(--accent); + border-left: 2px solid var(--accent); + padding-left: 10px; +} + +.weq-pd-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weq-pd-del { + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + font-weight: 700; + padding: 0 4px; + border-radius: 3px; + transition: all .1s; + line-height: 1; +} + +.weq-pd-del:hover { + color: var(--locked-icon); + background: rgba(220, 60, 60, 0.15); +} + +.weq-pd-sep { + height: 1px; + background: var(--border); + margin: 4px 8px; +} + +/* ── Toolbar (Tools + Grid) ── */ +.weq-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + background: var(--bg-panel); + flex-wrap: wrap; +} + +.weq-tbtn { + font-family: var(--font-mono); + font-size: 11px; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: 3px; + padding: 3px 8px; + cursor: pointer; + transition: all .12s; + white-space: nowrap; +} + +.weq-tbtn:hover { + color: var(--text-primary); + border-color: var(--border-strong); +} + +.weq-tbtn.on { + color: var(--fire-text); + background: var(--accent); + border-color: var(--accent); +} + +.weq-tsep { + width: 1px; + height: 16px; + background: var(--border); + margin: 0 2px; + flex-shrink: 0; +} + +.weq-tlbl { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + letter-spacing: 0.05em; + white-space: nowrap; +} + +/* ── Canvas area ── */ +.weq-canvas-area { + display: flex; + position: relative; +} + +.weq-db-axis { + display: none; + /* Labels are now drawn on the canvas itself */ +} + +.weq-db-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + line-height: 1; + user-select: none; +} + +.weq-canvas-wrap { + position: relative; + flex: 1; + min-height: 320px; + background: var(--bg-inset); + overflow: hidden; + cursor: crosshair; +} + +.weq-canvas-wrap canvas { + display: block; + width: 100%; + height: 100%; +} + +/* Floating tooltip on hover */ +.weq-tip { + position: absolute; + pointer-events: none; + background: rgba(0, 0, 0, 0.85); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 10px; + padding: 3px 8px; + border-radius: 3px; + white-space: nowrap; + z-index: 10; + transform: translateX(-50%); + transition: opacity 0.08s; +} + +.weq-freq-axis { + display: none; + /* Labels are now drawn on the canvas itself */ +} + +.weq-freq-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + user-select: none; +} + +/* ── Footer controls ── */ +.weq-footer { + display: flex; + align-items: stretch; + gap: 4px; + padding: 5px 10px; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +/* Two-column body: main (canvas+bands) + side panel */ +.weq-body-wrap { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.weq-body-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ── Side panel (modulation controls) ── */ +.weq-side-panel { + width: 210px; + flex-shrink: 0; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-inset) 60%, #000); + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; +} + +.weq-side-panel::-webkit-scrollbar { + width: 4px; +} + +.weq-side-panel::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.weq-sp-section { + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 6px; + padding: 10px 10px 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.weq-sp-title { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 1.5px; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + user-select: none; + margin-bottom: 2px; +} + +.weq-sp-row { + display: flex; + align-items: center; + gap: 4px; +} + +.weq-sp-knob { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-inset); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 4px; + padding: 4px 8px; + cursor: ns-resize; + user-select: none; + white-space: nowrap; + flex: 1; + text-align: center; + transition: color .1s, border-color .1s; +} + +.weq-sp-knob:hover { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-cell-hover); +} + +.weq-sp-knob.weq-anim-on { + color: var(--accent); + border-color: var(--accent); + animation: weqPulse 1.5s ease-in-out infinite; +} + +.weq-sp-label { + font-family: var(--font-mono); + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + min-width: 32px; + user-select: none; +} + +.weq-sp-sel { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-inset); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 4px; + padding: 3px 6px; + cursor: pointer; + outline: none; + flex: 1; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + transition: border-color .12s; +} + +.weq-sp-sel:hover, +.weq-sp-sel:focus { + border-color: var(--accent); +} + +.weq-sp-toggle { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + padding: 4px 8px; + cursor: pointer; + transition: all .12s; + flex: 1; + text-align: center; +} + +.weq-sp-toggle:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +.weq-sp-toggle.on { + color: var(--fire-text); + background: var(--accent); + border-color: var(--accent); +} + +.weq-sp-toggle.on.weq-anim-on { + animation: weqPulse 1.5s ease-in-out infinite; +} + +.weq-ft-group { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px 3px 6px; + border-left: 2px solid rgba(255, 255, 255, 0.06); + position: relative; +} + +.weq-ft-group:first-child { + border-left: none; + padding-left: 0; +} + +.weq-ft-group-sel { + flex: 1; + min-width: 0; +} + +.weq-ft-label { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1px; + color: rgba(255, 255, 255, 0.2); + text-transform: uppercase; + user-select: none; + white-space: nowrap; + margin-right: 2px; +} + +.weq-ft-knob { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 4px; + padding: 3px 7px; + cursor: ns-resize; + user-select: none; + white-space: nowrap; + transition: color .1s, border-color .1s; +} + +.weq-ft-knob:hover { + color: var(--text-primary); + border-color: var(--accent); + background: var(--bg-cell-hover); +} + +/* Animated knob indicator — glowing pulse */ +.weq-ft-knob.weq-anim-on { + color: var(--accent); + border-color: var(--accent); + animation: weqPulse 1.5s ease-in-out infinite; +} + +@keyframes weqPulse { + + 0%, + 100% { + box-shadow: 0 0 2px rgba(45, 107, 63, 0.3); + } + + 50% { + box-shadow: 0 0 8px rgba(45, 107, 63, 0.6); + } +} + +.weq-ft-sep { + color: var(--border); + font-size: 12px; + user-select: none; +} + +.weq-ft-sel { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 4px; + padding: 3px 6px; + cursor: pointer; + outline: none; + transition: border-color .12s; +} + +.weq-ft-sel:hover { + border-color: var(--accent); +} + +.weq-ft-sel:focus { + border-color: var(--accent); +} + +.weq-ft-btn { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + white-space: nowrap; + transition: all .1s; +} + +.weq-ft-btn:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +.weq-ft-btn.on { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +/* Split mode button has a distinctive active state */ +#weqSplitBtn.on { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 25%, transparent); + animation: weqSplitPulse 3s ease-in-out infinite; +} + +@keyframes weqSplitPulse { + + 0%, + 100% { + box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 15%, transparent); + } + + 50% { + box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 30%, transparent); + } +} + +.weq-ft-spacer { + flex: 1; +} + +/* ── Point info (shows frequency + gain of selected point) ── */ +.weq-point-info { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.weq-point-info strong { + color: var(--text-primary); +} + +/* ── Per-segment settings popup ── */ +.weq-seg-popup { + position: absolute; + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 5px; + padding: 8px; + z-index: 100; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + display: none; + min-width: 150px; +} + +.weq-seg-popup.visible { + display: block; +} + +.weq-seg-popup-title { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.weq-seg-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} + +.weq-seg-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + width: 40px; + flex-shrink: 0; +} + +.weq-seg-val { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-cell); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + cursor: ns-resize; + user-select: none; + flex: 1; + text-align: center; +} + +.weq-seg-val:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +/* ── Band colors in canvas ── */ +.weq-band-legend { + display: flex; + gap: 12px; + padding: 5px 12px; + flex-wrap: wrap; + align-items: center; + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.weq-band-chip { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 5px; +} + +.weq-band-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.weq-band-label { + font-size: 9px; + font-weight: 700; + opacity: 0.5; + letter-spacing: 0.5px; +} + +.weq-band-type { + font-size: 8px; + font-weight: 600; + opacity: 0.4; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.weq-band-fx-dot { + font-size: 7px; + color: var(--accent); + opacity: 0.7; + margin-left: -2px; +} + +.weq-band-chip.has-fx { + opacity: 1; +} + +.weq-band-chip.has-fx .weq-band-dot { + box-shadow: 0 0 4px currentColor; +} + +.weq-band-pass { + opacity: 0.4; + font-style: italic; +} + +.weq-band-pass .weq-band-range { + font-weight: 400; + font-size: 10px; +} + +/* ── Tooltip ── */ +.weq-tip { + position: absolute; + background: color-mix(in srgb, var(--bg-panel) 92%, transparent); + border: 1px solid var(--border-strong); + border-radius: 5px; + padding: 5px 10px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + pointer-events: none; + white-space: nowrap; + z-index: 90; + transform: translate(-50%, -100%); + margin-top: -10px; + letter-spacing: 0.3px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +/* ── Grid tabs (reuse lane pattern) ── */ +.weq-grid-tabs { + display: flex; + gap: 1px; + background: var(--border); + border-radius: 3px; + overflow: hidden; +} + +.weq-grid-tab { + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg-cell); + color: var(--text-muted); + border: none; + padding: 4px 10px; + cursor: pointer; + transition: all .1s; +} + +.weq-grid-tab:hover { + color: var(--text-primary); +} + +.weq-grid-tab.on { + background: var(--accent); + color: var(--fire-text); +} + +/* ── Per-band Solo/Mute buttons ── */ +.weq-band-sm { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + width: 16px; + height: 14px; + line-height: 14px; + padding: 0; + border: 1px solid var(--border); + border-radius: 2px; + background: none; + color: var(--text-muted); + cursor: pointer; + transition: all .12s; + flex-shrink: 0; +} + +.weq-band-sm:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +.weq-band-sm.on { + background: var(--accent); + border-color: var(--accent); + color: var(--fire-text); +} + +/* Solo 'S' gets a gold-ish color when active */ +.weq-band-sm[data-weqsolo].on { + background: var(--bus-solo-bg); + border-color: var(--bus-solo-bg); +} + +/* Mute 'M' gets a red-ish color when active */ +.weq-band-sm[data-weqmute].on { + background: var(--locked-icon); + border-color: var(--locked-icon); +} + +/* ── Band chip enhanced ── */ +.weq-band-chip { + display: flex; + align-items: center; + gap: 5px; +} + +.weq-band-plug { + font-family: var(--font-mono); + font-size: 8px; + color: var(--accent); + letter-spacing: 0.02em; + max-width: 55px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Muted band chip */ +.weq-band-chip.weq-muted { + opacity: 0.35; +} + +.weq-band-chip.weq-muted .weq-band-dot { + background: var(--text-muted) !important; +} + +/* Soloed band chip */ +.weq-band-chip.weq-soloed { + outline: 1px solid var(--bus-solo-bg); + border-radius: 3px; + padding: 1px 2px; +} + +/* ── Bypass active indicator ── */ +.weq-bypass-on { + background: var(--locked-icon) !important; + border-color: var(--locked-icon) !important; + color: #fff !important; +} + +/* ── Delta mode active — subtle accent on the header button ── */ +#weqDelta.on { + background: color-mix(in srgb, var(--accent) 70%, var(--text-primary)); + border-color: var(--accent); +} + +/* ── Close button ── */ +.weq-close-btn { + font-size: 18px !important; + width: 28px !important; + height: 28px !important; + display: flex !important; + align-items: center; + justify-content: center; + padding: 0 !important; + border-radius: 4px !important; + background: none !important; + border: 1px solid var(--border) !important; + color: var(--text-muted) !important; + transition: all .12s; +} + +.weq-close-btn:hover { + background: color-mix(in srgb, var(--locked-icon) 15%, transparent) !important; + border-color: color-mix(in srgb, var(--locked-icon) 50%, transparent) !important; + color: var(--locked-icon) !important; +} + +/* ── Band Parameter Rows ── */ +.weq-bands-section { + border-top: 1px solid var(--border); + padding: 4px 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.weq-bands-row { + display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.weq-bands-header { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px 6px; + flex-wrap: wrap; +} + +.weq-bands-section::-webkit-scrollbar { + width: 5px; +} + +.weq-bands-section::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.weq-bands-title { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; +} + +/* ═══════════════════════════════════════════ + Band Cards — vertical box per band, horizontal scroll + ═══════════════════════════════════════════ */ + +.weq-bands-count { + font-size: 10px; + font-weight: 400; + color: var(--text-muted); + margin-left: 2px; +} + +/* Horizontal scroll container */ +.weq-bands-scroll { + display: flex; + gap: 6px; + padding: 0 12px 6px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + flex: 1; + align-items: stretch; +} + +.weq-bands-scroll::-webkit-scrollbar { + height: 5px; +} + +.weq-bands-scroll::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.weq-bands-scroll::-webkit-scrollbar-track { + background: transparent; +} + +/* ── Band Card ── */ +.weq-band-card { + display: flex; + flex-direction: column; + min-width: 160px; + max-width: 160px; + background: var(--bg-cell); + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-right: none; + border-radius: 6px 0 0 6px; + overflow: hidden; + gap: 0; + flex-shrink: 0; + transition: background .1s, opacity .12s, border-color .15s; + cursor: pointer; + position: relative; + user-select: none; +} + +.weq-band-card:hover { + background: color-mix(in srgb, var(--bg-cell) 92%, white); + border-color: color-mix(in srgb, var(--border) 90%, white); +} + +.weq-band-card.muted { + opacity: 0.25; +} + +.weq-band-card.dimmed { + opacity: 0.4; +} + +.weq-band-card.soloed { + background: color-mix(in srgb, var(--bus-solo-bg) 8%, var(--bg-cell)); + border-color: color-mix(in srgb, var(--bus-solo-bg) 30%, var(--border)); + opacity: 1; +} + +.weq-band-card.focused { + background: color-mix(in srgb, var(--accent) 6%, var(--bg-cell)); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); +} + +.weq-band-card.seg-sel { + background: color-mix(in srgb, var(--accent) 5%, var(--bg-cell)); + border-color: var(--accent); +} + +/* ── Top accent bar ── */ +.weq-card-accent { + height: 3px; + width: 100%; + flex-shrink: 0; +} + +/* ── Card header ── */ +.weq-card-head { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 8px 3px 10px; +} + +.weq-card-num { + font-family: var(--font-mono); + font-size: 15px; + font-weight: 800; + color: var(--band-color, var(--text-primary)); + min-width: 16px; +} + +.weq-card-head-spacer { + flex: 1; +} + +/* ── Segment checkbox ── */ +.weq-card-head .weq-seg-chk { + width: 16px; + height: 16px; + font-size: 9px; + border-radius: 2px; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity .1s; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.weq-band-card:hover .weq-seg-chk, +.weq-seg-chk.on { + opacity: 1; +} + +.weq-seg-chk.on { + background: var(--accent); + border-color: var(--accent); + color: var(--bg-panel); +} + +/* ── Type selector (inline radio toggles) ── */ +.weq-card-types { + display: flex; + gap: 1px; + margin: 1px 6px 0; + border-radius: 3px; + overflow: hidden; +} + +.weq-type-btn { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.02em; + flex: 1; + height: 22px; + line-height: 22px; + border: none; + background: color-mix(in srgb, var(--bg-cell) 70%, black); + color: var(--text-muted); + cursor: pointer; + padding: 0; + text-align: center; + transition: all .08s; +} + +.weq-type-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.weq-type-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.weq-type-btn:hover { + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-cell) 50%, white); +} + +.weq-type-btn.active { + background: var(--band-color, var(--accent)); + color: var(--bg-panel); + font-weight: 700; +} + +/* ── Slope selector (12/24/48 dB/oct) — smaller, subtler variant ── */ +.weq-card-slope { + margin-top: 0; +} + +.weq-card-slope .weq-type-btn { + font-size: 8px; + height: 18px; + line-height: 18px; + font-weight: 500; + color: var(--text-muted); + opacity: 0.7; +} + +.weq-card-slope .weq-type-btn.active { + opacity: 1; + background: color-mix(in srgb, var(--band-color, var(--accent)) 60%, var(--bg-cell)); + color: var(--text-primary); + font-weight: 700; +} + +.weq-card-slope .weq-type-btn:hover { + opacity: 1; +} + +/* ── Frequency — hero value ── */ +.weq-card-freq { + font-family: var(--font-mono); + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + text-align: center; + letter-spacing: -0.03em; + line-height: 1; + padding: 6px 8px 4px; +} + +/* ── Gain + Q — labeled param boxes ── */ +.weq-card-params { + display: flex; + align-items: stretch; + gap: 4px; + padding: 2px 8px; +} + +.weq-card-param-box { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + background: color-mix(in srgb, var(--bg-cell) 65%, black); + border-radius: 4px; + padding: 3px 5px; + gap: 1px; +} + +.weq-card-plbl { + font-family: var(--font-mono); + font-size: 7px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--text-muted); + opacity: 0.5; + text-transform: uppercase; +} + +.weq-card-pval { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + color: var(--text-secondary); + cursor: ns-resize; + white-space: nowrap; + transition: color .1s; +} + +.weq-card-pval:hover { + color: var(--text-primary); +} + +.weq-card-pval.boost { + color: #6fd68c; +} + +.weq-card-pval.cut { + color: #e06464; +} + +.weq-card-pval.slope { + color: var(--text-muted); + cursor: default; + font-weight: 400; + font-size: 11px; +} + +/* ── Stereo + Mode row ── */ +.weq-card-mode-row { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 8px; +} + +.weq-card-stereo { + display: flex; + gap: 1px; + flex-shrink: 0; +} + +.weq-ms-btn { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 600; + width: 22px; + height: 20px; + line-height: 20px; + border: none; + background: color-mix(in srgb, var(--bg-cell) 65%, black); + color: var(--text-muted); + cursor: pointer; + padding: 0; + text-align: center; + transition: all .08s; +} + +.weq-ms-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.weq-ms-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.weq-ms-btn:hover { + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-cell) 50%, white); +} + +.weq-ms-btn.active { + background: #3a8a8a; + color: var(--bg-panel); + font-weight: 700; +} + +/* ── Mode pill (Post-EQ / Split) ── */ +.weq-card-mode { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.05em; + color: var(--text-muted); + background: none; + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 3px; + padding: 0 6px; + height: 20px; + line-height: 20px; + cursor: pointer; + transition: all .1s; + flex-shrink: 0; +} + +.weq-card-mode:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +.weq-card-mode.on { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} + +/* ═══════════════════════════════════════════ + Band unit (card + vertical strip) & Routing Panel + ═══════════════════════════════════════════ */ + +/* ── Unit container: card + strip side by side ── */ +.weq-band-unit { + display: flex; + flex-shrink: 0; +} + +/* ── Vertical strip (right of card, Bitwig-style) ── */ +.weq-card-strip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 22px; + background: color-mix(in srgb, var(--bg-cell) 75%, black); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-left: none; + border-radius: 0 6px 6px 0; + cursor: pointer; + transition: all .15s; + position: relative; + gap: 4px; +} + +.weq-card-strip:hover { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-cell)); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); +} + +.weq-card-strip.active { + background: color-mix(in srgb, var(--accent) 15%, var(--bg-cell)); + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); +} + +.weq-strip-plus { + font-size: 14px; + font-weight: 300; + color: color-mix(in srgb, var(--accent) 70%, var(--text-muted)); + line-height: 1; + transition: color .12s; +} + +.weq-card-strip:hover .weq-strip-plus { + color: var(--accent); +} + +.weq-card-strip.active .weq-strip-plus { + color: var(--accent); +} + +.weq-strip-badge { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + min-width: 14px; + height: 14px; + line-height: 14px; + border-radius: 7px; + background: var(--accent); + color: var(--bg-panel); + text-align: center; + padding: 0 2px; +} + +/* ── Routing panel (inline sibling of unit) ── */ +.weq-routing-panel { + display: none; + flex-direction: column; + min-width: 200px; + max-width: 200px; + background: #1a1c22; + border: 1px solid color-mix(in srgb, var(--accent) 30%, #2a2d36); + border-radius: 6px; + flex-shrink: 0; + overflow: hidden; + max-height: 100%; +} + +.weq-routing-panel.open { + display: flex; +} + +/* ── Panel header ── */ +.weq-routing-hdr { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #15171c; + border-bottom: 1px solid #2a2d36; +} + +.weq-routing-title { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + color: #888; + text-transform: uppercase; +} + +.weq-routing-band { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; +} + +/* ── Scrollable plugin list ── */ +.weq-routing-scroll { + flex: 1; + overflow-y: auto; + padding: 6px; + min-height: 30px; +} + +.weq-routing-scroll::-webkit-scrollbar { + width: 4px; +} + +.weq-routing-scroll::-webkit-scrollbar-thumb { + background: #3a3d46; + border-radius: 2px; +} + +.weq-routing-scroll::-webkit-scrollbar-track { + background: transparent; +} + +/* ── Plugin row ── */ +.weq-routing-row { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 6px; + background: #22252e; + border-radius: 4px; + margin-bottom: 3px; +} + +.weq-routing-row:last-child { + margin-bottom: 0; +} + +.weq-routing-idx { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + color: #555; + width: 14px; + text-align: center; + flex-shrink: 0; +} + +/* Reorder arrows */ +.weq-routing-order { + display: flex; + flex-direction: column; + gap: 0; + flex-shrink: 0; + min-width: 10px; +} + +.weq-routing-mv { + font-size: 7px; + line-height: 1; + padding: 1px; + border: none; + background: none; + color: #666; + cursor: pointer; + transition: color .1s; +} + +.weq-routing-mv:hover { + color: #ddd; +} + +/* Plugin name */ +.weq-routing-name { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +/* UI button */ +.weq-routing-ui { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--accent) 50%, #333); + border-radius: 3px; + background: none; + color: var(--accent); + cursor: pointer; + flex-shrink: 0; + transition: all .1s; +} + +.weq-routing-ui:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: #fff; +} + +/* Remove button */ +.weq-routing-rm { + font-size: 13px; + line-height: 1; + padding: 1px 4px; + border: none; + background: none; + color: #555; + cursor: pointer; + transition: all .1s; + flex-shrink: 0; + border-radius: 3px; +} + +.weq-routing-rm:hover { + color: #e06464; + background: rgba(224, 100, 100, 0.15); +} + +/* Empty state */ +.weq-routing-empty { + font-family: var(--font-mono); + font-size: 11px; + color: #555; + text-align: center; + padding: 12px 6px; +} + +/* ── Action buttons at bottom ── */ +.weq-routing-actions { + display: flex; + gap: 4px; + padding: 6px; + border-top: 1px solid #2a2d36; + background: #15171c; +} + +.weq-routing-assign { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: #999; + background: #22252e; + border: 1px solid #333; + border-radius: 4px; + padding: 5px 0; + cursor: pointer; + transition: all .12s; + text-align: center; + flex: 1; +} + +.weq-routing-assign:hover { + border-color: #666; + color: #ddd; + background: #2a2d36; +} + +.weq-routing-load { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, #1a1c22); + border: 1px solid color-mix(in srgb, var(--accent) 35%, #333); + border-radius: 4px; + padding: 5px 0; + cursor: pointer; + transition: all .12s; + text-align: center; + flex: 1; +} + +.weq-routing-load:hover { + border-color: var(--accent); + color: #fff; + background: color-mix(in srgb, var(--accent) 15%, #1a1c22); +} + +/* ── S / M / Del buttons (shared, used in card header) ── */ +.weq-s-btn { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + width: 20px; + height: 20px; + line-height: 20px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + background: color-mix(in srgb, var(--bg-cell) 60%, black); + color: var(--text-muted); + cursor: pointer; + padding: 0; + text-align: center; + transition: all .08s; +} + +.weq-s-btn:hover { + border-color: var(--text-secondary); + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-cell) 40%, black); +} + +.weq-s-btn.on.solo { + background: var(--bus-solo-bg, #a07a2e); + border-color: var(--bus-solo-bg, #a07a2e); + color: var(--bg-panel); +} + +.weq-s-btn.on.mute { + background: #c44; + border-color: #c44; + color: var(--bg-panel); +} + +.weq-s-btn.del { + opacity: 0; + font-size: 14px; + color: var(--locked-icon); + background: none; + border-color: transparent; + transition: opacity .1s; +} + +.weq-band-card:hover .weq-s-btn.del { + opacity: 0.4; +} + +.weq-s-btn.del:hover { + opacity: 1 !important; + background: color-mix(in srgb, var(--locked-icon) 15%, transparent); + border-color: var(--locked-icon); +} + + +/* ── Segment toolbar (inline version) ── */ +.weq-seg-toolbar-inline { + display: flex; + align-items: center; + gap: 3px; + flex-wrap: wrap; +} + +.weq-seg-toolbar { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 10px; + background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel)); + border: 1px solid var(--accent); + border-radius: 6px; + margin-top: 4px; + flex-wrap: wrap; +} + +.weq-seg-info { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent); + font-weight: 600; + margin-right: 4px; + white-space: nowrap; +} + +.weq-seg-btn { + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-cell); + color: var(--text-secondary); + cursor: pointer; + transition: all .1s; + white-space: nowrap; +} + +.weq-seg-btn:hover { + border-color: var(--accent); + color: var(--text-primary); + background: color-mix(in srgb, var(--accent) 12%, var(--bg-cell)); +} + +.weq-seg-btn:active { + transform: scale(0.96); +} + +.weq-seg-clear { + margin-left: auto; + color: var(--text-muted); + font-size: 12px; + padding: 3px 6px; +} + +.weq-seg-clear:hover { + color: var(--locked-icon); + border-color: var(--locked-icon); + background: color-mix(in srgb, var(--locked-icon) 8%, transparent); +} + +/* ═══════════════════════════════════════════ + Routing Sidebar — vertical tab + collapsible panel + ═══════════════════════════════════════════ */ + +.weq-routing-sidebar { + display: flex; + flex-direction: row; + flex-shrink: 0; +} + +.weq-routing-tab { + width: 22px; + min-width: 22px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--bg-inset); + border-right: 1px solid var(--border); + cursor: pointer; + padding: 6px 0; + transition: background 0.15s; +} + +.weq-routing-tab:hover { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-inset)); +} + +.weq-routing-tab-label { + writing-mode: vertical-lr; + text-orientation: mixed; + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--text-muted); + text-transform: uppercase; + white-space: nowrap; + user-select: none; +} + +.weq-routing-sidebar.open .weq-routing-tab-label { + color: var(--accent); +} + +.weq-routing-tab-badge { + font-family: var(--font-mono); + font-size: 8px; + font-weight: 700; + color: var(--bg-main); + background: var(--accent); + border-radius: 6px; + padding: 0 4px; + min-width: 14px; + text-align: center; + line-height: 14px; +} + +.weq-routing-panel-content { + flex: 1; + min-width: 0; + padding: 0; + overflow: hidden; + max-width: 0; + opacity: 0; + transition: max-width 0.2s ease, opacity 0.15s ease, padding 0.2s ease; +} + +.weq-routing-sidebar.open .weq-routing-panel-content { + max-width: 300px; + opacity: 1; + padding: 6px 10px; +} + +.weq-global-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.weq-global-title { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ── Signal Chain ── */ +.weq-chain-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.weq-chain-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 11px; +} + +.weq-chain-eq { + background: color-mix(in srgb, var(--accent) 8%, var(--bg-inset)); + border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--border)); +} + +.weq-chain-eq.bypassed { + opacity: 0.4; +} + +.weq-chain-icon { + font-size: 9px; + color: var(--accent); +} + +.weq-chain-name { + font-weight: 700; + color: var(--text-primary); +} + +.weq-chain-info { + color: var(--text-muted); + font-size: 9px; + margin-left: auto; +} + +.weq-chain-arrow { + text-align: center; + color: var(--text-muted); + font-size: 10px; + line-height: 1; + opacity: 0.5; + padding: 1px 0; +} + +.weq-chain-section-hdr { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} + +.weq-chain-section-label { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.weq-global-list { + display: flex; + flex-direction: column; + gap: 3px; +} + +.weq-global-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11px; +} + +.weq-global-row.dimmed { + opacity: 0.4; +} + +.weq-global-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + color: var(--text-primary); +} + +.weq-routing-toglobal, +.weq-routing-assign-band, +.weq-routing-byp { + font-family: var(--font-mono); + font-size: 9px; + padding: 1px 4px; + border: 1px solid var(--border); + border-radius: 3px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} + +.weq-routing-toglobal:hover, +.weq-routing-assign-band:hover, +.weq-routing-byp:hover { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.weq-routing-byp.on { + color: #e8a030; + border-color: #e8a030; + background: color-mix(in srgb, #e8a030 12%, transparent); +} + +.weq-routing-row.bypassed .weq-routing-name, +.weq-global-row.bypassed .weq-global-name { + opacity: 0.45; + text-decoration: line-through; +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/OFL.txt b/plugins/ModularRandomizer/Source/ui/public/fonts/OFL.txt new file mode 100644 index 0000000..1c3c287 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012, Carrois Type Design, Ralph du Carrois (post@carrois.com www.carrois.com), with Reserved Font Name 'Share' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/ShareTechMono-Regular.ttf b/plugins/ModularRandomizer/Source/ui/public/fonts/ShareTechMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0ae0b19750c51a751bc45f54622443d55d643999 GIT binary patch literal 42756 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 kdt!U;-u0Z_Q9AZ|#Z5eIpF literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/Share_Tech_Mono,VT323.zip b/plugins/ModularRandomizer/Source/ui/public/fonts/Share_Tech_Mono,VT323.zip new file mode 100644 index 0000000000000000000000000000000000000000..6097a6dd798f30fe7e368f868b3a1f82425dbbbd GIT binary patch literal 201979 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 literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/OFL.txt b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/OFL.txt new file mode 100644 index 0000000..1c3c287 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012, Carrois Type Design, Ralph du Carrois (post@carrois.com www.carrois.com), with Reserved Font Name 'Share' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/ShareTechMono-Regular.ttf b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/Share_Tech_Mono/ShareTechMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0ae0b19750c51a751bc45f54622443d55d643999 GIT binary patch literal 42756 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 kdt!U;-u0Z_Q9AZ|#Z5eIpF literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/OFL.txt b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/OFL.txt new file mode 100644 index 0000000..2011c93 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/VT323-Regular.ttf b/plugins/ModularRandomizer/Source/ui/public/fonts/extracted/VT323/VT323-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6aec59930ed26b7802dec57ed6e13970b0088cba GIT binary patch literal 149688 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 literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/Source/ui/public/index.html b/plugins/ModularRandomizer/Source/ui/public/index.html new file mode 100644 index 0000000..c047c75 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/index.html @@ -0,0 +1,333 @@ + + + + + + Modular Randomizer v5 + + + + + + + + + + + + +

+
+
Lock
+
Unlock
+
+
Unassign from Block +
+
+
+
Assign to Block +
+
+
+
Assign to Lane +
+
+
+
+
Lock All
+
Unlock All
+
+
Duplicate Plugin
+
+
Bypass Plugin
+
+
Randomize All
+
+
Add Snapshot to +
+
+
+
Save State +
+
Save as Preset
+
Save as Snapshot
+
+
+
Browse Library...
+
+ + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/ModularRandomizer/Source/ui/public/js/context_menus.js b/plugins/ModularRandomizer/Source/ui/public/js/context_menus.js new file mode 100644 index 0000000..a7e2a56 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/context_menus.js @@ -0,0 +1,310 @@ +// ============================================================ +// CONTEXT MENUS +// Param context menu, plugin context menu, lock/unlock +// ============================================================ +// Context menu +function showCtx(x, y, p) { + var m = document.getElementById('ctx'); + var menuW = 160, menuH = 200; + var vw = window.innerWidth, vh = window.innerHeight; + var posLeft = x, posTop = y; + if (posLeft + menuW > vw - 4) posLeft = vw - menuW - 4; + if (posLeft < 4) posLeft = 4; + if (posTop + menuH > vh - 4) posTop = Math.max(4, y - menuH); + m.style.left = posLeft + 'px'; m.style.top = posTop + 'px'; + m.classList.add('vis'); + var pids = selectedParams.size > 0 ? Array.from(selectedParams) : [p.id]; + var count = pids.length; + var suffix = count > 1 ? ' (' + count + ')' : ''; + // Determine Lock/Unlock visibility based on ALL selected params + var anyLockable = false, anyUnlockable = false, anyLocked = false; + pids.forEach(function (pid) { + var pp = PMap[pid]; if (!pp) return; + if (!pp.lk && !pp.alk) anyLockable = true; + if (pp.lk && !pp.alk) anyUnlockable = true; + if (pp.lk) anyLocked = true; + }); + var cL = document.getElementById('cL'); + var cU = document.getElementById('cU'); + cL.style.display = anyLockable ? '' : 'none'; + cU.style.display = anyUnlockable ? '' : 'none'; + cL.textContent = 'Lock' + suffix; + cU.textContent = 'Unlock' + suffix; + // Build "Unassign from Block" submenu — show blocks that have any selected param + var unSub = document.getElementById('ctxUnassignMenu'); + var unSep = document.getElementById('ctxUnassignSep'); + var unWrap = document.getElementById('ctxUnassignSub'); + var assignedBlocks = []; + for (var bi = 0; bi < blocks.length; bi++) { + var bl = blocks[bi]; + var hasAny = false; + for (var pi = 0; pi < pids.length; pi++) { + if (bl.targets.has(pids[pi])) { hasAny = true; break; } + } + if (hasAny) assignedBlocks.push({ bl: bl, idx: bi }); + } + if (assignedBlocks.length > 0 && !anyLocked) { + unSep.style.display = ''; unWrap.style.display = ''; + var ush = ''; + for (var ai = 0; ai < assignedBlocks.length; ai++) { + var ab = assignedBlocks[ai]; + ush += '
Block ' + (ab.idx + 1) + ' (' + ab.bl.mode + ')
'; + } + unSub.innerHTML = ush; + unSub.querySelectorAll('[data-unassignblock]').forEach(function (item) { + item.onclick = function (e) { + e.stopPropagation(); + var bid = parseInt(item.dataset.unassignblock); + var bl = findBlock(bid); + if (!bl) return; + pids.forEach(function (pid) { bl.targets.delete(pid); cleanBlockAfterUnassign(bl, pid); }); + m.classList.remove('vis'); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + }; + }); + } else { + unSep.style.display = 'none'; unWrap.style.display = 'none'; + } + // Build "Assign to Block" submenu + var sub = document.getElementById('ctxAssignMenu'); + var sep = document.getElementById('ctxAssignSep'); + var subWrap = document.getElementById('ctxAssignSub'); + if (blocks.length > 0 && !anyLocked) { + sep.style.display = ''; subWrap.style.display = ''; + var sh = ''; + for (var bi = 0; bi < blocks.length; bi++) { + var bl = blocks[bi]; + sh += '
Block ' + (bi + 1) + ' (' + bl.mode + ')
'; + } + sub.innerHTML = sh; + sub.querySelectorAll('[data-assignblock]').forEach(function (item) { + item.onclick = function (e) { + e.stopPropagation(); + var bid = parseInt(item.dataset.assignblock); + var bl = findBlock(bid); + if (!bl) return; + pids.forEach(function (pid) { + var pp = PMap[pid]; + if (pp && !pp.lk) assignTarget(bl, pid); + }); + m.classList.remove('vis'); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + }; + }); + } else { + sep.style.display = 'none'; subWrap.style.display = 'none'; + } + // Build "Assign to Lane" submenu — show lane blocks with their lanes + var laneSub = document.getElementById('ctxLaneMenu'); + var laneSep = document.getElementById('ctxLaneSep'); + var laneWrap = document.getElementById('ctxLaneSub'); + var laneBlocks = []; + for (var bi = 0; bi < blocks.length; bi++) { + var bl = blocks[bi]; + if (bl.mode === 'lanes' && bl.lanes && bl.lanes.length > 0) { + laneBlocks.push({ bl: bl, idx: bi }); + } + } + if (laneBlocks.length > 0 && !anyLocked) { + laneSep.style.display = ''; laneWrap.style.display = ''; + var lh = ''; + for (var li = 0; li < laneBlocks.length; li++) { + var lb = laneBlocks[li]; + for (var lj = 0; lj < lb.bl.lanes.length; lj++) { + var lane = lb.bl.lanes[lj]; + var lName = lane.morphMode ? 'Morph' : (lane.pids && lane.pids.length > 0 ? (PMap[lane.pids[0]] ? PMap[lane.pids[0]].name : 'Lane') : 'Lane'); + if (lName.length > 12) lName = lName.substring(0, 11) + '\u2026'; + lh += '
B' + (lb.idx + 1) + ' / ' + lName + (lane.morphMode ? ' \u21CB' : '') + '
'; + } + } + laneSub.innerHTML = lh; + laneSub.querySelectorAll('[data-assignlane-b]').forEach(function (item) { + item.onclick = function (e) { + e.stopPropagation(); + var bid = parseInt(item.dataset.assignlaneB); + var lIdx = parseInt(item.dataset.assignlaneLi); + var bl = findBlock(bid); + if (!bl || !bl.lanes[lIdx]) return; + var lane = bl.lanes[lIdx]; + pids.forEach(function (pid) { + var pp = PMap[pid]; + if (pp && !pp.lk) { + assignTarget(bl, pid); + if (lane.pids.indexOf(pid) < 0) lane.pids.push(pid); + } + }); + m.classList.remove('vis'); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + }; + }); + } else { + laneSep.style.display = 'none'; laneWrap.style.display = 'none'; + } +} +function showPlugCtx(x, y, plugId) { + var m = document.getElementById('plugCtx'); + var menuW = 180, menuH = 220; + var vw = window.innerWidth, vh = window.innerHeight; + var posLeft = x, posTop = y; + if (posLeft + menuW > vw - 4) posLeft = vw - menuW - 4; + if (posLeft < 4) posLeft = 4; + if (posTop + menuH > vh - 4) posTop = Math.max(4, y - menuH); + m.style.left = posLeft + 'px'; + m.style.top = posTop + 'px'; + m.classList.add('vis'); + // Update bypass label + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugId) { pb = pluginBlocks[i]; break; } } + document.getElementById('pcBypass').textContent = (pb && pb.bypassed) ? 'Unbypass Plugin' : 'Bypass Plugin'; + // Build "Add Snapshot to" submenu — show morph_pad blocks + var snapSep = document.getElementById('pcSnapSep'); + var snapWrap = document.getElementById('pcSnapSub'); + var snapMenu = document.getElementById('pcSnapMenu'); + var morphBlocks = getMorphBlocks(); + if (morphBlocks.length > 0) { + snapSep.style.display = ''; snapWrap.style.display = ''; + var sh = ''; + for (var mi = 0; mi < morphBlocks.length; mi++) { + var mb = morphBlocks[mi]; + var full = mb.snapCount >= 12; + sh += '
Block ' + (mb.idx + 1) + ' (' + mb.snapCount + '/12)' + (full ? ' — Full' : '') + '
'; + } + snapMenu.innerHTML = sh; + snapMenu.querySelectorAll('[data-snapblock]').forEach(function (item) { + if (item.classList.contains('disabled')) return; + item.onclick = function (e) { + e.stopPropagation(); + var bid = parseInt(item.dataset.snapblock); + var pid = parseInt(item.dataset.snapplug); + addSnapshotToMorphBlock(bid, pid); + m.classList.remove('vis'); + }; + }); + } else { + snapSep.style.display = 'none'; snapWrap.style.display = 'none'; + } +} +document.addEventListener('click', function () { + document.getElementById('ctx').classList.remove('vis'); + document.getElementById('plugCtx').classList.remove('vis'); +}); +// Lock: operates on all selected params +document.getElementById('cL').onclick = function () { + var pids = selectedParams.size > 0 ? Array.from(selectedParams) : (ctxP ? [ctxP.id] : []); + pushUndoSnapshot(); + pids.forEach(function (pid) { + var pp = PMap[pid]; if (!pp || pp.alk) return; + pp.lk = true; + blocks.forEach(function (b) { b.targets.delete(pid); cleanBlockAfterUnassign(b, pid); }); + }); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); +}; +// Unlock: operates on all selected params +document.getElementById('cU').onclick = function () { + var pids = selectedParams.size > 0 ? Array.from(selectedParams) : (ctxP ? [ctxP.id] : []); + pushUndoSnapshot(); + pids.forEach(function (pid) { + var pp = PMap[pid]; if (!pp || pp.alk) return; + pp.lk = false; + }); + selectedParams.clear(); + renderAllPlugins(); syncBlocksToHost(); +}; + +// Plugin context menu actions +document.getElementById('pcLockAll').onclick = function () { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugCtxPluginId) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + pushUndoSnapshot(); + pb.params.forEach(function (p) { + p.lk = true; + blocks.forEach(function (b) { b.targets.delete(p.id); cleanBlockAfterUnassign(b, p.id); }); + }); + renderAllPlugins(); renderBlocks(); updCounts(); syncBlocksToHost(); saveUiStateToHost(); +}; +document.getElementById('pcUnlockAll').onclick = function () { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugCtxPluginId) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + pushUndoSnapshot(); + pb.params.forEach(function (p) { if (!p.alk) p.lk = false; }); + renderAllPlugins(); updCounts(); syncBlocksToHost(); saveUiStateToHost(); +}; +document.getElementById('pcBypass').onclick = function () { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugCtxPluginId) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + pb.bypassed = !pb.bypassed; + // Sync to C++ audio thread + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setPluginBypass'); + fn(pb.hostId, pb.bypassed); + } + renderAllPlugins(); saveUiStateToHost(); +}; +document.getElementById('pcDuplicate').onclick = function () { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugCtxPluginId) { pb = pluginBlocks[i]; break; } } + if (!pb || !pb.path) return; + document.getElementById('plugCtx').classList.remove('vis'); + // Load same plugin, then copy all param values + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + showToast('Duplicating ' + pb.name + '…', 'info', 2000); + var loadFn = window.__juceGetNativeFunction('loadPlugin'); + var savedParams = []; + pb.params.forEach(function (p) { savedParams.push({ idx: p.realIndex, val: p.v, lk: p.lk, alk: p.alk }); }); + loadFn(pb.path).then(function (result) { + if (!result || result.error) { showToast('Failed to duplicate: ' + (result ? result.error : 'unknown'), 'error', 3000); return; } + var hostedId = result.id; + var params = (result.params || []).map(function (p, i) { + var fid = hostedId + ':' + p.index; + var param = { id: fid, name: p.name, v: p.value, disp: p.disp || '', lk: false, alk: false, realIndex: p.index, hostId: hostedId }; + PMap[fid] = param; + return param; + }); + pluginBlocks.push({ id: hostedId, hostId: hostedId, name: result.name, path: pb.path, manufacturer: result.manufacturer || pb.manufacturer || '', params: params, expanded: true, searchFilter: '', busId: pb.busId }); + // Apply saved param values + var setParamFn = window.__juceGetNativeFunction('setParam'); + savedParams.forEach(function (sp) { + var newParam = null; + for (var pi = 0; pi < params.length; pi++) { if (params[pi].realIndex === sp.idx) { newParam = params[pi]; break; } } + if (newParam) { + newParam.v = sp.val; + newParam.lk = sp.lk; + newParam.alk = sp.alk; + if (setParamFn) setParamFn(hostedId, sp.idx, sp.val); + } + }); + showToast(result.name + ' duplicated', 'success', 2000); + renderAllPlugins(); updCounts(); saveUiStateToHost(); syncExpandedPlugins(); + }).catch(function (err) { showToast('Duplicate failed: ' + err, 'error', 3000); }); +}; +document.getElementById('pcRandomize').onclick = function () { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugCtxPluginId) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + var oldVals = []; + pb.params.forEach(function (p) { if (!p.lk && !p.alk) oldVals.push({ id: p.id, val: p.v }); }); + var setParamFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setParam') : null; + pb.params.forEach(function (p) { + if (p.lk || p.alk) return; + var newVal = Math.random(); + p.v = newVal; + if (setParamFn && p.hostId !== undefined) setParamFn(p.hostId, p.realIndex, newVal); + // Update base anchor in any shapes_range block targeting this param + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + if (b.mode === 'shapes_range' && b.targets.has(p.id) && b.targetRangeBases) { + b.targetRangeBases[p.id] = newVal; + } + } + }); + pushMultiParamUndo(oldVals); + renderAllPlugins(); + syncBlocksToHost(); +}; diff --git a/plugins/ModularRandomizer/Source/ui/public/js/controls.js b/plugins/ModularRandomizer/Source/ui/public/js/controls.js new file mode 100644 index 0000000..6a29ed3 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/controls.js @@ -0,0 +1,556 @@ +// ============================================================ +// UI CONTROLS +// Bypass, mix, scale, auto-locate, add buttons, plugin browser +// ============================================================ +// Bypass button — connected to JUCE BYPASS toggle relay +document.getElementById('bypassBtn').onclick = function () { + this.classList.toggle('on'); + // Send to JUCE backend if available + if (window.__JUCE__ && window.__JUCE__.getToggleState) { + try { + var state = window.__JUCE__.getToggleState('BYPASS'); + if (state) state.setValue(!state.getValue()); + } catch (e) { console.log('Bypass relay not ready'); } + } +}; +// Mix slider — connected to JUCE MIX slider relay +document.getElementById('mixSlider').oninput = function () { + document.getElementById('mixVal').textContent = this.value + '%'; + // Send to JUCE backend if available + if (window.__JUCE__ && window.__JUCE__.getSliderState) { + try { + var state = window.__JUCE__.getSliderState('MIX'); + if (state) state.setNormalisedValue(this.value / 100.0); + } catch (e) { console.log('Mix relay not ready'); } + } +}; +// UI Scale — professional: resize JUCE editor window, CSS fills it +var currentScale = 1; +var autoLocate = true; +document.getElementById('scaleSelect').onchange = function () { + var scale = parseFloat(this.value); + if (isNaN(scale) || scale < 0.25) scale = 1; + currentScale = scale; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setEditorScale'); + fn(scale); + } + saveUiStateToHost(); +}; +function applyScale(scale) { + currentScale = scale; + var sel = document.getElementById('scaleSelect'); + for (var i = 0; i < sel.options.length; i++) { + if (parseFloat(sel.options[i].value) === scale) { sel.selectedIndex = i; break; } + } + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setEditorScale'); + fn(scale); + } +} +// Auto-Locate toggle +document.getElementById('autoLocateChk').onchange = function () { + autoLocate = this.checked; + saveUiStateToHost(); +}; +// Internal Tempo BPM input +document.getElementById('internalBpmInput').onchange = function () { + var v = Math.max(20, Math.min(300, parseInt(this.value) || 120)); + this.value = v; + internalBpm = v; + syncBlocksToHost(); + saveUiStateToHost(); +}; +// Plugin Routing mode toggle +document.querySelectorAll('.routing-btn').forEach(function (btn) { + btn.onclick = function () { + var mode = parseInt(btn.dataset.rmode); + routingMode = mode; + document.querySelectorAll('.routing-btn').forEach(function (b) { b.classList.toggle('on', parseInt(b.dataset.rmode) === mode); }); + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setRoutingMode'); + fn(mode); + } + // Show/hide WrongEQ button + if (typeof weqSetVisible === 'function') weqSetVisible(mode === 2); + // Sync EQ state to C++ when entering WrongEQ mode + if (mode === 2 && typeof weqSyncToHost === 'function') weqSyncToHost(); + renderAllPlugins(); saveUiStateToHost(); + }; +}); +document.getElementById('addRnd').onclick = function () { addBlock('randomize'); }; +document.getElementById('addEnv').onclick = function () { addBlock('envelope'); }; +document.getElementById('addSmp').onclick = function () { addBlock('sample'); }; +document.getElementById('addMorph').onclick = function () { addBlock('morph_pad'); }; +document.getElementById('addShapes').onclick = function () { addBlock('shapes'); }; +document.getElementById('addShapesRange').onclick = function () { addBlock('shapes_range'); }; +document.getElementById('addLane').onclick = function () { addBlock('lane'); }; +document.getElementById('addPluginBtn').onclick = function () { openPluginBrowser(); }; +document.getElementById('undoBtn').onclick = function () { performUndo(); }; +document.getElementById('redoBtn').onclick = function () { performRedo(); }; + +// Collapse / Expand All Plugins +document.getElementById('collapseAllBtn').onclick = function () { + pluginBlocks.forEach(function (pb) { pb.expanded = false; }); + renderAllPlugins(); saveUiStateToHost(); +}; +document.getElementById('expandAllBtn').onclick = function () { + pluginBlocks.forEach(function (pb) { pb.expanded = true; }); + renderAllPlugins(); saveUiStateToHost(); +}; + +// Plugin loading state — prevent double-clicks +var pluginLoading = false; +function setPluginLoading(isLoading, name) { + pluginLoading = isLoading; + var btn = document.getElementById('addPluginBtn'); + if (isLoading) { + btn.disabled = true; + btn.textContent = 'Loading...'; + btn.title = 'Loading ' + (name || 'plugin') + '...'; + } else { + btn.disabled = false; + btn.textContent = '+ Plugin'; + btn.title = ''; + } +} + +// ── Toast notification system ── +// Types: 'success' (green), 'error' (red), 'info' (blue) +// Colors driven by CSS variables for theme support +function showToast(message, type, durationMs) { + type = type || 'info'; + durationMs = durationMs || 3500; + var container = document.getElementById('crash-toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'crash-toast-container'; + container.style.cssText = 'position:fixed;top:12px;right:12px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; + document.body.appendChild(container); + } + var rs = getComputedStyle(document.documentElement); + var colors = { + success: { + bg: rs.getPropertyValue('--toast-success-bg').trim() || 'linear-gradient(135deg,#0a3a1a,#082a10)', + border: rs.getPropertyValue('--toast-success-border').trim() || '#4a8', + icon: '✓' + }, + error: { + bg: rs.getPropertyValue('--toast-error-bg').trim() || 'linear-gradient(135deg,#4a1010,#2a0808)', + border: rs.getPropertyValue('--toast-error-border').trim() || '#ff3333', + icon: '✕' + }, + info: { + bg: rs.getPropertyValue('--toast-info-bg').trim() || 'linear-gradient(135deg,#102040,#081828)', + border: rs.getPropertyValue('--toast-info-border').trim() || '#4a8cff', + icon: 'ℹ' + } + }; + var textColor = rs.getPropertyValue('--toast-text').trim() || '#fff'; + var c = colors[type] || colors.info; + var toast = document.createElement('div'); + toast.style.cssText = 'pointer-events:auto;background:' + c.bg + ';border:1px solid ' + c.border + ';border-radius:8px;padding:10px 14px;color:' + textColor + ';font-size:12px;font-family:inherit;box-shadow:0 4px 24px rgba(0,0,0,0.4);display:flex;align-items:center;gap:8px;animation:crashSlideIn 0.3s ease-out;max-width:380px;cursor:pointer;'; + toast.innerHTML = '' + c.icon + '' + message + ''; + toast.onclick = function () { dismiss(); }; + container.appendChild(toast); + function dismiss() { + toast.style.animation = 'crashSlideOut 0.2s ease-in forwards'; + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); + } + setTimeout(dismiss, durationMs); +} + +// ── Placeholder card for plugin loading ── +function appendPlaceholderCard(placeholderId, plugName) { + var c = document.getElementById('pluginScroll'); + var card = document.createElement('div'); + card.className = 'pcard pcard-loading'; + card.id = placeholderId; + card.innerHTML = '
' + escHtml(plugName) + 'Loading
' + + '
'; + c.appendChild(card); +} +function removePlaceholderCard(placeholderId) { + var el = document.getElementById(placeholderId); + if (el) el.remove(); +} +function showLoadError(plugName, error) { + showToast('Failed to load ' + plugName + ': ' + (error || 'Unknown error'), 'error', 5000); +} + +// ── Keyboard Shortcuts ── +// Use CAPTURE phase so we intercept before WebView2's native handlers +document.addEventListener('keydown', function (e) { + var tag = e.target.tagName; + var inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); + var code = e.code || ''; + var key = (e.key || '').toLowerCase(); + + // Ctrl+Z — Undo (works even in inputs) + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (key === 'z' || code === 'KeyZ')) { + e.preventDefault(); + e.stopPropagation(); + // Route to EQ undo when WrongEQ overlay is visible + var weqOverlay = document.getElementById('weqOverlay'); + if (weqOverlay && weqOverlay.classList.contains('visible') && typeof _weqPerformUndo === 'function') { + _weqPerformUndo(); + } else { + performUndo(); + } + return; + } + // Ctrl+Shift+Z — Redo (works even in inputs) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (key === 'z' || code === 'KeyZ')) { + e.preventDefault(); + e.stopPropagation(); + var weqOverlay2 = document.getElementById('weqOverlay'); + if (weqOverlay2 && weqOverlay2.classList.contains('visible') && typeof _weqPerformRedo === 'function') { + _weqPerformRedo(); + } else { + performRedo(); + } + return; + } + // Ctrl+Y — Redo (alternative, works even in inputs) + if ((e.ctrlKey || e.metaKey) && (key === 'y' || code === 'KeyY')) { + e.preventDefault(); + e.stopPropagation(); + var weqOverlay3 = document.getElementById('weqOverlay'); + if (weqOverlay3 && weqOverlay3.classList.contains('visible') && typeof _weqPerformRedo === 'function') { + _weqPerformRedo(); + } else { + performRedo(); + } + return; + } + // Ctrl+S — Quick-save global preset (works even in inputs) + if ((e.ctrlKey || e.metaKey) && (key === 's' || code === 'KeyS')) { + e.preventDefault(); + e.stopPropagation(); + if (typeof currentGlobalPresetName !== 'undefined' && currentGlobalPresetName) { + document.getElementById('gpSave').click(); + } else { + openGlobalPresetBrowser(); + } + return; + } + + // Skip remaining shortcuts when typing in inputs + if (inInput) { + if (e.key === 'Escape') { e.target.blur(); return; } + return; + } + // Escape — close modals / exit assign mode + if (e.key === 'Escape') { + // Close any open modal + var modals = document.querySelectorAll('.modal-overlay.vis'); + if (modals.length > 0) { + modals.forEach(function (m) { m.classList.remove('vis'); }); + return; + } + // Close context menus + document.getElementById('ctx').classList.remove('vis'); + document.getElementById('plugCtx').classList.remove('vis'); + // Exit assign mode + if (assignMode) { + assignMode = null; + renderBlocks(); renderAllPlugins(); + return; + } + // Clear selection + if (selectedParams.size > 0) { + selectedParams.clear(); + renderAllPlugins(); + } + return; + } + // Space — trigger active randomizer block + if (e.key === ' ' || e.code === 'Space') { + e.preventDefault(); + if (typeof actId !== 'undefined' && actId !== null) { + var b = findBlock(actId); + if (b && b.mode === 'randomize' && b.enabled !== false) { + var ov = []; b.targets.forEach(function (pid) { var p = PMap[pid]; if (p && !p.lk && !p.alk) ov.push({ id: pid, val: p.v }); }); + randomize(actId); + if (ov.length) pushMultiParamUndo(ov); + flashDot('midiD'); + } + } + return; + } + // Delete -- remove selected params from active block (skip lane mode - handled by lane_module.js) + if (e.key === 'Delete' || e.key === 'Backspace') { + if (typeof actId !== 'undefined' && actId !== null) { + var b = findBlock(actId); + if (b && b.mode === 'lane') return; // let lane_module.js handle it + } + if (selectedParams.size > 0 && typeof actId !== 'undefined' && actId !== null) { + var b = findBlock(actId); + if (b) { + selectedParams.forEach(function (pid) { b.targets.delete(pid); cleanBlockAfterUnassign(b, pid); }); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } + } + return; + } + // Ctrl+A — select all params in assign mode + if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { + e.preventDefault(); + if (assignMode) { + // Select all visible (non-locked) params + selectedParams.clear(); + pluginBlocks.forEach(function (pb) { + pb.params.forEach(function (p) { + if (!p.lk && !p.alk) selectedParams.add(p.id); + }); + }); + renderAllPlugins(); + } + return; + } + // R — apply range (existing shortcut, keep it) + if (e.key === 'r' || e.key === 'R') { + if (!assignMode) return; + var b = findBlock(assignMode); + if (!b || b.mode !== 'shapes_range') return; + if (selectedParams.size === 0) return; + e.preventDefault(); + if (!b.targetRanges) b.targetRanges = {}; + if (!b.targetRangeBases) b.targetRangeBases = {}; + selectedParams.forEach(function (pid) { + var p = PMap[pid]; + if (!p || p.lk) return; + assignTarget(b, pid); + b.targetRanges[pid] = 0; + b.targetRangeBases[pid] = p.v; + }); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } +}, true); // CAPTURE phase — intercept before WebView2 native handlers +// Plugin browser modal logic +var modalCat = 'all', modalQuery = ''; +var _scanPollId = 0; +function doScanPlugins(forceRescan) { + if (!window.__JUCE__ || !window.__JUCE__.backend || scanInProgress) return; + scanInProgress = true; + + // Update modal immediately to show scanning state + renderModalScanningState('', 0); + + // Show scanning indicator in plugin scroll area (for startup scan) + var ps = document.getElementById('pluginScroll'); + if (ps && pluginBlocks.length === 0 && !document.getElementById('pluginModal').classList.contains('vis')) { + var scanCard = document.createElement('div'); + scanCard.className = 'pcard pcard-loading'; + scanCard.id = 'startup-scan-indicator'; + scanCard.innerHTML = '
Scanning Plugins' + + 'Locating
' + + '
' + + '
' + + scanPaths.join(', ') + '
'; + ps.appendChild(scanCard); + } + + // Poll for per-plugin progress every 250ms + if (_scanPollId) clearInterval(_scanPollId); + var progressFn = window.__juceGetNativeFunction('getScanProgress'); + _scanPollId = setInterval(function () { + if (!scanInProgress) { clearInterval(_scanPollId); _scanPollId = 0; return; } + progressFn().then(function (p) { + if (!p || !scanInProgress) return; + var name = p.name || ''; + var pct = Math.round((p.progress || 0) * 100); + // Update modal if visible + if (document.getElementById('pluginModal').classList.contains('vis')) { + renderModalScanningState(name, pct); + } + // Update startup card if present + var sn = document.getElementById('startup-scan-name'); + if (sn) sn.textContent = name || 'Scanning...'; + var sf = document.querySelector('#startup-scan-indicator .pcard-loading-fill'); + if (sf) sf.style.width = pct + '%'; + }); + }, 250); + + var scanFn = window.__juceGetNativeFunction('scanPlugins'); + scanFn(scanPaths, !!forceRescan).then(function (result) { + scanInProgress = false; + if (_scanPollId) { clearInterval(_scanPollId); _scanPollId = 0; } + // Remove startup scan indicator + var si = document.getElementById('startup-scan-indicator'); + if (si) si.remove(); + + if (result && result.length) { + scannedPlugins = result.map(function (p) { + return { name: p.name || 'Unknown', vendor: p.vendor || '', cat: p.category || 'fx', path: p.path || '', fmt: p.format || 'VST3' }; + }); + } else { + scannedPlugins = []; + } + // Update modal list if browser is open + if (document.getElementById('pluginModal').classList.contains('vis')) { + renderModalList(); + } + }); +} +function renderModalScanningState(pluginName, pct) { + var mInfo = document.getElementById('modalInfo'); + var mBody = document.getElementById('modalBody'); + pct = pct || 0; + pluginName = pluginName || ''; + // Shorten long paths to just the filename + if (pluginName.indexOf('/') >= 0 || pluginName.indexOf('\\') >= 0) { + var parts = pluginName.replace(/\\/g, '/').split('/'); + pluginName = parts[parts.length - 1] || parts[parts.length - 2] || pluginName; + } + if (mInfo) mInfo.innerHTML = '\u25CF Scanning ' + + (pct > 0 ? '(' + pct + '%) ' : '') + + (pluginName ? '' + pluginName + '' : 'plugin directories\u2026'); + if (mBody) mBody.innerHTML = '
' + + '
' + + '
' + + (pluginName ? pluginName : 'Scanning ' + scanPaths.length + ' director' + (scanPaths.length === 1 ? 'y' : 'ies') + '\u2026') + + '
' + + '
' + scanPaths.map(function (p) { + var parts = p.replace(/\\/g, '/').split('/'); + return parts[parts.length - 1] || parts[parts.length - 2] || p; + }).join(' \u00B7 ') + '
'; +} +function openPluginBrowser() { + modalCat = 'all'; modalQuery = ''; + document.getElementById('modalSearch').value = ''; + document.getElementById('scanPaths').classList.remove('vis'); + renderModalTabs(); + // If scanning, show scanning state; else render list (auto-scan if empty) + if (scanInProgress) { + renderModalScanningState(); + } else if (scannedPlugins.length === 0) { + doScanPlugins(); + } else { + renderModalList(); + } + document.getElementById('pluginModal').classList.add('vis'); + document.getElementById('modalSearch').focus(); +} +function closePluginBrowser() { document.getElementById('pluginModal').classList.remove('vis'); } +document.getElementById('modalClose').onclick = closePluginBrowser; +document.getElementById('pluginModal').onclick = function (e) { if (e.target === this) closePluginBrowser(); }; +document.getElementById('modalSearch').oninput = function () { modalQuery = this.value; if (!scanInProgress) renderModalList(); }; +document.getElementById('modalTabs').onclick = function (e) { + var tab = e.target.closest('.modal-tab'); if (!tab) return; + modalCat = tab.dataset.cat; renderModalTabs(); + if (scanInProgress) { renderModalScanningState(); } else { renderModalList(); } +}; +function renderModalTabs() { + document.querySelectorAll('.modal-tab').forEach(function (t) { + t.className = 'modal-tab' + (t.dataset.cat === modalCat ? ' on' : ''); + }); +} +function renderModalList() { + var body = document.getElementById('modalBody'); + // If still scanning, show scanning state + if (scanInProgress) { renderModalScanningState(); return; } + var q = modalQuery.toLowerCase(); + var filtered = scannedPlugins.filter(function (p) { + if (modalCat !== 'all' && p.cat !== modalCat) return false; + if (q && p.name.toLowerCase().indexOf(q) === -1 && p.vendor.toLowerCase().indexOf(q) === -1) return false; + return true; + }); + document.getElementById('modalInfo').textContent = filtered.length + ' plugin' + (filtered.length !== 1 ? 's' : '') + ' found'; + if (filtered.length === 0) { body.innerHTML = '
No plugins found. Click \u2699 Scan Paths to configure.
'; return; } + var catLabels = { synth: 'Instrument', fx: 'Effect', sampler: 'Sampler', utility: 'Utility' }; + var h = ''; + filtered.forEach(function (p) { + var initials = p.name.split(' ').map(function (w) { return w[0]; }).join('').substring(0, 2); + var vendorLine = (p.vendor || '') + (p.fmt && p.fmt !== 'VST3' ? (p.vendor ? ' \u00B7 ' : '') + p.fmt : ''); + h += '
'; + h += '
' + initials + '
'; + h += '
' + p.name + '
' + vendorLine + '
'; + h += '' + (catLabels[p.cat] || p.cat) + ''; + h += '
'; + }); + body.innerHTML = h; + body.querySelectorAll('.plug-row').forEach(function (row) { + row.onclick = function () { + addPlugin(row.dataset.ppath); + closePluginBrowser(); + }; + }); +} +// Scan paths toggle +document.getElementById('scanToggle').onclick = function () { + var sp = document.getElementById('scanPaths'); + sp.classList.toggle('vis'); renderScanPaths(); + // Also trigger a fresh scan with updated paths + if (!sp.classList.contains('vis')) { + scannedPlugins = []; // Clear so next open triggers re-scan + doScanPlugins(); + saveUiStateToHost(); + } +}; +document.getElementById('addScanPath').onclick = function () { + scanPaths.push(''); renderScanPaths(); +}; +function renderScanPaths() { + var c = document.getElementById('scanPathList'); c.innerHTML = ''; + scanPaths.forEach(function (p, i) { + var row = document.createElement('div'); row.className = 'scan-path-row'; + row.innerHTML = ''; + c.appendChild(row); + }); + c.querySelectorAll('input').forEach(function (inp) { + inp.onchange = function () { + scanPaths[parseInt(inp.dataset.spi)] = inp.value; + saveUiStateToHost(); + }; + }); + c.querySelectorAll('[data-sprm]').forEach(function (btn) { + btn.onclick = function () { + scanPaths.splice(parseInt(btn.dataset.sprm), 1); + renderScanPaths(); + saveUiStateToHost(); + }; + }); + // Also render in settings dropdown + renderSettingsScanPaths(); +} +function renderSettingsScanPaths() { + var c = document.getElementById('settingsScanPathList'); + if (!c) return; + c.innerHTML = ''; + scanPaths.forEach(function (p, i) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:3px;margin-bottom:2px;align-items:center;'; + row.innerHTML = '' + + ''; + c.appendChild(row); + }); + c.querySelectorAll('input[data-sspi]').forEach(function (inp) { + inp.onchange = function () { + scanPaths[parseInt(inp.dataset.sspi)] = inp.value; + renderScanPaths(); + saveUiStateToHost(); + }; + }); + c.querySelectorAll('[data-ssprm]').forEach(function (btn) { + btn.onclick = function () { + scanPaths.splice(parseInt(btn.dataset.ssprm), 1); + renderScanPaths(); + saveUiStateToHost(); + }; + }); +} +document.getElementById('settingsAddPath').onclick = function () { + scanPaths.push(''); + renderScanPaths(); +}; +document.getElementById('settingsRescan').onclick = function () { + scannedPlugins = []; + doScanPlugins(true); // force=true → clears cache, does full deep scan + saveUiStateToHost(); + showToast('Rescanning plugin directories (cache cleared)...', 'info', 2000); +}; +function flashDot(id) { var d = document.getElementById(id); d.classList.add('on'); setTimeout(function () { d.classList.remove('on'); }, 150); } +function updCounts() { var ap = allParams(); document.getElementById('stP').textContent = ap.length; document.getElementById('stL').textContent = ap.filter(function (p) { return p.lk || p.alk; }).length; document.getElementById('stB').textContent = blocks.length; document.getElementById('blockInfo').textContent = blocks.length + ' blocks'; } diff --git a/plugins/ModularRandomizer/Source/ui/public/js/expose_system.js b/plugins/ModularRandomizer/Source/ui/public/js/expose_system.js new file mode 100644 index 0000000..28071e0 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/expose_system.js @@ -0,0 +1,648 @@ +/* + * Expose System — Selective parameter exposure to DAW automation + * Manages which hosted plugin params and logic block params are visible + * to the DAW's automation dropdown via the proxy parameter pool. + * + * State: window._exposeState = { + * plugins: { [pluginId]: { exposed: bool, excludedParams: Set } }, + * blocks: { [blockId]: { exposed: bool, excludedParams: Set } } + * } + */ + +// ── State ── +if (!window._exposeState) { + window._exposeState = { plugins: {}, blocks: {} }; +} + +// ── Block param definitions: what each block type can expose ── +var BLOCK_EXPOSABLE_PARAMS = { + shapes: [ + { key: 'shapeSpeed', label: 'Speed', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapeSize', label: 'Size', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapeSpin', label: 'Spin', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapePhaseOffset', label: 'Phase', type: 'float', min: 0, max: 360, suffix: '\u00b0' }, + { key: 'shapeType', label: 'Shape', type: 'discrete', options: ['circle', 'figure8', 'sweepX', 'sweepY', 'triangle', 'square', 'hexagon', 'pentagram', 'hexagram', 'rose4', 'lissajous', 'spiral', 'cat', 'butterfly', 'infinityKnot'] }, + { key: 'shapeTracking', label: 'Tracking', type: 'discrete', options: ['horizontal', 'vertical', 'distance'] }, + { key: 'shapePolarity', label: 'Polarity', type: 'discrete', options: ['bipolar', 'unipolar', 'up', 'down'] }, + { key: 'shapeTrigger', label: 'Trigger', type: 'discrete', options: ['free', 'midi'] }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + shapes_range: [ + { key: 'shapeSpeed', label: 'Speed', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapeSize', label: 'Size', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapeSpin', label: 'Spin', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'shapePhaseOffset', label: 'Phase', type: 'float', min: 0, max: 360, suffix: '\u00b0' }, + { key: 'shapeType', label: 'Shape', type: 'discrete', options: ['circle', 'figure8', 'sweepX', 'sweepY', 'triangle', 'square', 'hexagon', 'pentagram', 'hexagram', 'rose4', 'lissajous', 'spiral', 'cat', 'butterfly', 'infinityKnot'] }, + { key: 'shapeTracking', label: 'Tracking', type: 'discrete', options: ['horizontal', 'vertical', 'distance'] }, + { key: 'shapePolarity', label: 'Polarity', type: 'discrete', options: ['bipolar', 'unipolar', 'up', 'down'] }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + randomize: [ + { key: 'rMin', label: 'Range Min', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'rMax', label: 'Range Max', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'glideMs', label: 'Glide', type: 'float', min: 0, max: 2000, suffix: 'ms' }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + envelope: [ + { key: 'envAtk', label: 'Attack', type: 'float', min: 0, max: 500, suffix: 'ms' }, + { key: 'envRel', label: 'Release', type: 'float', min: 0, max: 2000, suffix: 'ms' }, + { key: 'envSens', label: 'Sensitivity', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'envInvert', label: 'Invert', type: 'bool' }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + sample: [ + { key: 'sampleSpeed', label: 'Speed', type: 'float', min: 0.1, max: 4, suffix: 'x' }, + { key: 'sampleReverse', label: 'Reverse', type: 'bool' }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + morph_pad: [ + { key: 'playheadX', label: 'Pad X', type: 'float', min: 0, max: 1, suffix: '' }, + { key: 'playheadY', label: 'Pad Y', type: 'float', min: 0, max: 1, suffix: '' }, + { key: 'morphSpeed', label: 'Speed', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'lfoDepth', label: 'LFO Depth', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'jitter', label: 'Jitter', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'enabled', label: 'Enabled', type: 'bool' } + ], + lane: [ + { key: 'enabled', label: 'Enabled', type: 'bool' } + // Per-lane params (depth, speed) are added dynamically based on lane count + ] +}; + +// Get exposable params for a block, including dynamic per-lane params +function getExposableParamsForBlock(b) { + var base = BLOCK_EXPOSABLE_PARAMS[b.mode] || []; + var params = base.slice(); // copy + + // Lane blocks get per-lane depth/speed + if (b.mode === 'lane' && b.lanes) { + for (var li = 0; li < b.lanes.length; li++) { + var laneLabel = b.lanes[li].morphMode ? 'Morph ' + (li + 1) : 'Lane ' + (li + 1); + params.push({ key: 'lane.' + li + '.depth', label: laneLabel + ' Depth', type: 'float', min: 0, max: 100, suffix: '%' }); + if (b.lanes[li].morphMode) { + // Morph lanes don't have speed in the traditional sense + } else { + params.push({ key: 'lane.' + li + '.speed', label: laneLabel + ' Speed', type: 'float', min: 0, max: 100, suffix: '%' }); + } + } + } + return params; +} + +// ── Dropdown Rendering ── + +function openExposeDropdown(anchorEl) { + closeExposeDropdown(); + + var drop = document.createElement('div'); + drop.id = 'exposeDrop'; + drop.className = 'expose-dropdown'; + + var html = ''; + html += '
Expose to DAW
'; + + // Section: Hosted Plugins + var pluginList = typeof pluginBlocks !== 'undefined' ? pluginBlocks : []; + if (pluginList.length > 0) { + html += ''; + for (var pi = 0; pi < pluginList.length; pi++) { + var plug = pluginList[pi]; + var pState = window._exposeState.plugins[plug.id]; + var isExposed = pState ? pState.exposed : true; // default: exposed (backwards compat) + var excludeCount = pState && pState.excludedParams ? pState.excludedParams.size : 0; + var paramCount = plug.params ? plug.params.length : 0; + var activeCount = paramCount - excludeCount; + + html += '
'; + html += ''; + if (isExposed && paramCount > 0) { + html += '' + activeCount + '/' + paramCount + ''; + html += ''; + } + html += '
'; + } + } + + // Section: Logic Blocks + if (typeof blocks !== 'undefined' && blocks.length > 0) { + html += ''; + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + var bState = window._exposeState.blocks[b.id]; + var isExposed = bState ? bState.exposed : false; // default: not exposed (new feature) + var allParams = getExposableParamsForBlock(b); + var excludeCount = bState && bState.excludedParams ? bState.excludedParams.size : 0; + var activeCount = allParams.length - excludeCount; + var blockName = b.name || (b.mode + ' #' + b.id); + + html += '
'; + html += ''; + if (isExposed && allParams.length > 0) { + html += '' + activeCount + '/' + allParams.length + ''; + html += ''; + } + html += '
'; + } + } + + if (pluginList.length === 0 && (typeof blocks === 'undefined' || blocks.length === 0)) { + html += '
No plugins or blocks loaded
'; + } + + drop.innerHTML = html; + + // Position below the anchor button + var rect = anchorEl.getBoundingClientRect(); + drop.style.position = 'fixed'; + drop.style.top = (rect.bottom + 2) + 'px'; + drop.style.right = (window.innerWidth - rect.right) + 'px'; + drop.style.zIndex = '9999'; + + document.body.appendChild(drop); + + // Wire events + drop.addEventListener('change', function (e) { + var inp = e.target; + if (!inp || inp.tagName !== 'INPUT') return; + var action = inp.dataset.action; + if (action === 'toggle-plugin') { + togglePluginExpose(parseInt(inp.dataset.plugid), inp.checked); + } else if (action === 'toggle-block') { + toggleBlockExpose(parseInt(inp.dataset.bid), inp.checked); + } else if (action === 'toggle-plugin-param') { + togglePluginParamExpose(parseInt(inp.dataset.plugid), parseInt(inp.dataset.pidx), inp.checked); + } else if (action === 'toggle-block-param') { + toggleBlockParamExpose(parseInt(inp.dataset.bid), inp.dataset.pkey, inp.checked); + } + // Refresh dropdown to update counts + openExposeDropdown(anchorEl); + }); + + drop.addEventListener('click', function (e) { + var btn = e.target.closest('[data-action]'); + if (!btn) return; + var action = btn.dataset.action; + if (action === 'expand-plugin') { + e.stopPropagation(); + showPluginParamSubmenu(drop, btn, parseInt(btn.dataset.plugid)); + } else if (action === 'expand-block') { + e.stopPropagation(); + showBlockParamSubmenu(drop, btn, parseInt(btn.dataset.bid)); + } + }); + + // Dismiss on outside click + setTimeout(function () { + document.addEventListener('mousedown', _exposeDismiss); + }, 50); +} + +function _exposeDismiss(e) { + var drop = document.getElementById('exposeDrop'); + var sub = document.getElementById('exposeSubmenu'); + if (drop && !drop.contains(e.target) && (!sub || !sub.contains(e.target))) { + closeExposeDropdown(); + } +} + +function closeExposeDropdown() { + var drop = document.getElementById('exposeDrop'); + if (drop) drop.remove(); + var sub = document.getElementById('exposeSubmenu'); + if (sub) sub.remove(); + document.removeEventListener('mousedown', _exposeDismiss); +} + +// ── Submenu shared state for shift-click range selection ── +var _exposeSubLastIdx = -1; + +// ── Submenu: Plugin Params ── +function showPluginParamSubmenu(drop, anchorBtn, pluginId) { + var existing = document.getElementById('exposeSubmenu'); + // Toggle: if submenu is already showing for this plugin, close it + if (existing && existing._exposePluginId === pluginId) { + existing.remove(); + return; + } + if (existing) existing.remove(); + + var plug = null; + if (typeof pluginBlocks !== 'undefined') { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === pluginId) { plug = pluginBlocks[i]; break; } + } + } + if (!plug || !plug.params) return; + + var pState = window._exposeState.plugins[pluginId] || { exposed: true, excludedParams: new Set() }; + var sub = document.createElement('div'); + sub.id = 'exposeSubmenu'; + sub.className = 'expose-submenu'; + sub._exposePluginId = pluginId; + + var html = '
' + (plug.name || 'Plugin') + ' Params'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += ''; + html += '
'; + for (var i = 0; i < plug.params.length; i++) { + var p = plug.params[i]; + var isIncluded = !pState.excludedParams.has(i); + html += ''; + } + html += '
'; + sub.innerHTML = html; + + // Position to the right of the dropdown + var dropRect = drop.getBoundingClientRect(); + sub.style.position = 'fixed'; + sub.style.top = dropRect.top + 'px'; + sub.style.left = (dropRect.right + 2) + 'px'; + sub.style.zIndex = '10000'; + sub.style.maxHeight = '400px'; + + document.body.appendChild(sub); + _exposeSubLastIdx = -1; + + // Wire search filter + var searchInp = sub.querySelector('.expose-search-input'); + if (searchInp) { + searchInp.addEventListener('input', function () { + var q = searchInp.value.toLowerCase(); + sub.querySelectorAll('.expose-sub-item').forEach(function (item) { + var name = item.textContent.toLowerCase(); + item.style.display = name.indexOf(q) >= 0 ? '' : 'none'; + }); + }); + // Prevent dropdown dismiss when clicking in search + searchInp.addEventListener('mousedown', function (e) { e.stopPropagation(); }); + } + + // Wire change events with shift-click range selection + sub.addEventListener('click', function (e) { + var inp = e.target.closest('input[type="checkbox"]'); + if (inp && inp.dataset.action === 'toggle-plugin-param') { + var idx = parseInt(inp.dataset.pidx); + if (e.shiftKey && _exposeSubLastIdx >= 0 && _exposeSubLastIdx !== idx) { + // Range select: toggle all between last and current to same state + var lo = Math.min(_exposeSubLastIdx, idx); + var hi = Math.max(_exposeSubLastIdx, idx); + var checked = inp.checked; + var allBoxes = sub.querySelectorAll('input[data-action="toggle-plugin-param"]'); + allBoxes.forEach(function (cb) { + var ci = parseInt(cb.dataset.pidx); + if (ci >= lo && ci <= hi) { + cb.checked = checked; + togglePluginParamExpose(pluginId, ci, checked); + } + }); + } else { + togglePluginParamExpose(pluginId, idx, inp.checked); + } + _exposeSubLastIdx = idx; + // Update count in main dropdown + _updateExposeCount(drop, pluginId, 'plugin'); + e.stopPropagation(); + return; + } + // Select All / Deselect All buttons + var btn = e.target.closest('[data-action]'); + if (!btn) return; + if (btn.dataset.action === 'select-all-plugin' || btn.dataset.action === 'deselect-all-plugin') { + var setTo = btn.dataset.action === 'select-all-plugin'; + var allBoxes = sub.querySelectorAll('input[data-action="toggle-plugin-param"]'); + allBoxes.forEach(function (cb) { + cb.checked = setTo; + togglePluginParamExpose(pluginId, parseInt(cb.dataset.pidx), setTo); + }); + _updateExposeCount(drop, pluginId, 'plugin'); + } + }); +} + +// ── Submenu: Block Params ── +function showBlockParamSubmenu(drop, anchorBtn, blockId) { + var existing = document.getElementById('exposeSubmenu'); + // Toggle: if submenu is already showing for this block, close it + if (existing && existing._exposeBlockId === blockId) { + existing.remove(); + return; + } + if (existing) existing.remove(); + + var b = findBlock(blockId); + if (!b) return; + + var bState = window._exposeState.blocks[blockId] || { exposed: true, excludedParams: new Set() }; + var allParams = getExposableParamsForBlock(b); + + var sub = document.createElement('div'); + sub.id = 'exposeSubmenu'; + sub.className = 'expose-submenu'; + sub._exposeBlockId = blockId; + + var html = '
' + (b.name || b.mode) + ' Params'; + html += ''; + html += ''; + html += ''; + html += '
'; + html += ''; + html += '
'; + for (var i = 0; i < allParams.length; i++) { + var p = allParams[i]; + var isIncluded = !bState.excludedParams.has(p.key); + var typeTag = p.type === 'discrete' ? ' list' : (p.type === 'bool' ? ' on/off' : ''); + html += ''; + } + html += '
'; + sub.innerHTML = html; + + var dropRect = drop.getBoundingClientRect(); + sub.style.position = 'fixed'; + sub.style.top = dropRect.top + 'px'; + sub.style.left = (dropRect.right + 2) + 'px'; + sub.style.zIndex = '10000'; + sub.style.maxHeight = '400px'; + + document.body.appendChild(sub); + _exposeSubLastIdx = -1; + + // Wire search filter + var searchInp = sub.querySelector('.expose-search-input'); + if (searchInp) { + searchInp.addEventListener('input', function () { + var q = searchInp.value.toLowerCase(); + sub.querySelectorAll('.expose-sub-item').forEach(function (item) { + var name = item.textContent.toLowerCase(); + item.style.display = name.indexOf(q) >= 0 ? '' : 'none'; + }); + }); + searchInp.addEventListener('mousedown', function (e) { e.stopPropagation(); }); + } + + // Wire events with shift-click range selection + sub.addEventListener('click', function (e) { + var inp = e.target.closest('input[type="checkbox"]'); + if (inp && inp.dataset.action === 'toggle-block-param') { + var label = inp.closest('[data-eidx]'); + var idx = label ? parseInt(label.dataset.eidx) : -1; + if (e.shiftKey && _exposeSubLastIdx >= 0 && idx >= 0 && _exposeSubLastIdx !== idx) { + var lo = Math.min(_exposeSubLastIdx, idx); + var hi = Math.max(_exposeSubLastIdx, idx); + var checked = inp.checked; + var allLabels = sub.querySelectorAll('[data-eidx]'); + allLabels.forEach(function (lbl) { + var ci = parseInt(lbl.dataset.eidx); + if (ci >= lo && ci <= hi) { + var cb = lbl.querySelector('input[type="checkbox"]'); + if (cb) { + cb.checked = checked; + toggleBlockParamExpose(blockId, cb.dataset.pkey, checked); + } + } + }); + } else { + toggleBlockParamExpose(blockId, inp.dataset.pkey, inp.checked); + } + _exposeSubLastIdx = idx; + _updateExposeCount(drop, blockId, 'block'); + e.stopPropagation(); + return; + } + // Select All / Deselect All buttons + var btn = e.target.closest('[data-action]'); + if (!btn) return; + if (btn.dataset.action === 'select-all-block' || btn.dataset.action === 'deselect-all-block') { + var setTo = btn.dataset.action === 'select-all-block'; + var allBoxes = sub.querySelectorAll('input[data-action="toggle-block-param"]'); + allBoxes.forEach(function (cb) { + cb.checked = setTo; + toggleBlockParamExpose(blockId, cb.dataset.pkey, setTo); + }); + _updateExposeCount(drop, blockId, 'block'); + } + }); +} + +// ── Helper: update count badge in main dropdown after submenu changes ── +function _updateExposeCount(drop, id, type) { + if (!drop) return; + var selector = type === 'plugin' ? '[data-plugid="' + id + '"]' : '[data-bid="' + id + '"]'; + var item = drop.querySelector('.expose-item' + selector); + if (!item) return; + var countEl = item.querySelector('.expose-count'); + if (!countEl) return; + if (type === 'plugin') { + var plug = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === id) { plug = pluginBlocks[i]; break; } + } + if (!plug) return; + var pState = window._exposeState.plugins[id] || { excludedParams: new Set() }; + var total = plug.params ? plug.params.length : 0; + countEl.textContent = (total - pState.excludedParams.size) + '/' + total; + } else { + var b = findBlock(id); + if (!b) return; + var bState = window._exposeState.blocks[id] || { excludedParams: new Set() }; + var allP = getExposableParamsForBlock(b); + countEl.textContent = (allP.length - bState.excludedParams.size) + '/' + allP.length; + } +} + +// ── Toggle Actions ── + +function togglePluginExpose(pluginId, exposed) { + if (!window._exposeState.plugins[pluginId]) { + window._exposeState.plugins[pluginId] = { exposed: exposed, excludedParams: new Set() }; + } else { + window._exposeState.plugins[pluginId].exposed = exposed; + } + // Notify C++ + _syncExposeStateToHost(); +} + +function togglePluginParamExpose(pluginId, paramIdx, included) { + var pState = window._exposeState.plugins[pluginId]; + if (!pState) { + pState = { exposed: true, excludedParams: new Set() }; + window._exposeState.plugins[pluginId] = pState; + } + if (included) { + pState.excludedParams.delete(paramIdx); + } else { + pState.excludedParams.add(paramIdx); + } + _syncExposeStateToHost(); +} + +function toggleBlockExpose(blockId, exposed) { + if (!window._exposeState.blocks[blockId]) { + window._exposeState.blocks[blockId] = { exposed: exposed, excludedParams: new Set() }; + } else { + window._exposeState.blocks[blockId].exposed = exposed; + } + _syncExposeStateToHost(); +} + +function toggleBlockParamExpose(blockId, paramKey, included) { + var bState = window._exposeState.blocks[blockId]; + if (!bState) { + bState = { exposed: true, excludedParams: new Set() }; + window._exposeState.blocks[blockId] = bState; + } + if (included) { + bState.excludedParams.delete(paramKey); + } else { + bState.excludedParams.add(paramKey); + } + _syncExposeStateToHost(); +} + +// ── Sync to C++ ── +// Sends the full expose state to C++ which manages proxy slot assignment/release + +function _syncExposeStateToHost() { + var fn = window.__juceGetNativeFunction ? window.__juceGetNativeFunction('updateExposeState') : null; + if (!fn) return; + + // Serialize: plugins as { id: { exposed, excluded: [indices] } } + var payload = { plugins: {}, blocks: {} }; + + for (var pid in window._exposeState.plugins) { + var ps = window._exposeState.plugins[pid]; + payload.plugins[pid] = { + exposed: ps.exposed, + excluded: Array.from(ps.excludedParams || []) + }; + } + + for (var bid in window._exposeState.blocks) { + var bs = window._exposeState.blocks[bid]; + // Include block name and param definitions so C++ can name the BX_ slots + var b = typeof findBlock === 'function' ? findBlock(parseInt(bid)) : null; + var blockName = b ? (b.name || (b.mode + ' #' + b.id)) : ('Block ' + bid); + var paramDefs = b ? getExposableParamsForBlock(b).map(function (p) { + var def = { key: p.key, label: p.label, type: p.type || 'float' }; + if (p.options) def.options = p.options; + if (p.min !== undefined) def.min = p.min; + if (p.max !== undefined) def.max = p.max; + if (p.suffix !== undefined) def.suffix = p.suffix; + return def; + }) : []; + + payload.blocks[bid] = { + exposed: bs.exposed, + excluded: Array.from(bs.excludedParams || []), + name: blockName, + params: paramDefs + }; + } + + fn(JSON.stringify(payload)); + // Mark state dirty so auto-save picks up the change + if (typeof markStateDirty === 'function') markStateDirty(); +} + +// ── Persistence helpers (called by persistence.js) ── + +function getExposeStateForSave() { + var out = { plugins: {}, blocks: {} }; + for (var pid in window._exposeState.plugins) { + var ps = window._exposeState.plugins[pid]; + out.plugins[pid] = { exposed: ps.exposed, excluded: Array.from(ps.excludedParams || []) }; + } + for (var bid in window._exposeState.blocks) { + var bs = window._exposeState.blocks[bid]; + out.blocks[bid] = { exposed: bs.exposed, excluded: Array.from(bs.excludedParams || []) }; + } + return out; +} + +function restoreExposeState(data) { + if (!data) return; + window._exposeState = { plugins: {}, blocks: {} }; + if (data.plugins) { + for (var pid in data.plugins) { + var ps = data.plugins[pid]; + window._exposeState.plugins[pid] = { + exposed: ps.exposed !== false, + excludedParams: new Set(ps.excluded || []) + }; + } + } + if (data.blocks) { + for (var bid in data.blocks) { + var bs = data.blocks[bid]; + window._exposeState.blocks[bid] = { + exposed: bs.exposed !== false, + excludedParams: new Set(bs.excluded || []) + }; + } + } + _syncExposeStateToHost(); +} + +// ── DAW → Block param sync ── +// Called from C++ evaluateScript when DAW automates an AP_ slot mapped to a block +function setBlockParamFromDAW(blockId, paramKey, value) { + var b = typeof findBlock === 'function' ? findBlock(blockId) : null; + if (!b) return; + + // Look up param definition — use getExposableParamsForBlock for full list including dynamic lanes + var paramDefs = typeof getExposableParamsForBlock === 'function' ? getExposableParamsForBlock(b) : (BLOCK_EXPOSABLE_PARAMS[b.mode] || []); + var pDef = null; + for (var i = 0; i < paramDefs.length; i++) { + if (paramDefs[i].key === paramKey) { pDef = paramDefs[i]; break; } + } + + // Handle lane dynamic params (e.g. "lane.0.depth") + var laneMatch = paramKey.match(/^lane\.(\d+)\.(\w+)$/); + if (laneMatch) { + var laneIdx = parseInt(laneMatch[1]); + var laneProp = laneMatch[2]; + if (b.lanes && b.lanes[laneIdx]) { + // Use pDef range if available + var lMin = pDef ? (pDef.min || 0) : 0; + var lMax = pDef ? (pDef.max || 100) : 100; + b.lanes[laneIdx][laneProp] = value * (lMax - lMin) + lMin; + } + if (typeof syncBlocksToHost === 'function') syncBlocksToHost(); + if (typeof renderBlocks === 'function') renderBlocks(); + return; + } + + if (!pDef) { + // Unknown param, set raw + b[paramKey] = value; + } else if (pDef.type === 'bool') { + b[paramKey] = value >= 0.5; + } else if (pDef.type === 'discrete' && pDef.options) { + var idx = Math.round(value * (pDef.options.length - 1)); + idx = Math.max(0, Math.min(pDef.options.length - 1, idx)); + b[paramKey] = pDef.options[idx]; + } else { + // Float — use min/max from param definition + var fMin = pDef.min !== undefined ? pDef.min : 0; + var fMax = pDef.max !== undefined ? pDef.max : 100; + b[paramKey] = value * (fMax - fMin) + fMin; + } + + if (typeof syncBlocksToHost === 'function') syncBlocksToHost(); + if (typeof renderBlocks === 'function') renderBlocks(); +} + diff --git a/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js b/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js new file mode 100644 index 0000000..403da84 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js @@ -0,0 +1,534 @@ +/** + * Help & Reference Panel + * Builds the ? modal dynamically with tabbed content: + * - Shortcuts (keyboard & mouse) + * - Logic Blocks reference + * - Lanes reference (incl. Morph Lanes) + * - WrongEQ reference + * - Expose to DAW + * - Tips & workflow + */ +(function () { + var btn = document.getElementById('helpBtn'); + if (!btn) return; + + // ── Tab definitions ── + var tabs = [ + { id: 'shortcuts', label: '\u2328 Shortcuts' }, + { id: 'blocks', label: '\u25A6 Blocks' }, + { id: 'lanes', label: '\u223F Lanes' }, + { id: 'weq', label: '\u2261 WrongEQ' }, + { id: 'expose', label: '\u2197 Expose' }, + { id: 'tips', label: '\u2605 Tips' } + ]; + + // ── Content builders ── + function grid(rows) { + return '
' + rows.map(function (r) { + return '' + r[0] + '' + r[1] + ''; + }).join('') + '
'; + } + function section(title, body) { + return '
' + title + '
' + body + '
'; + } + function para(text) { return '

' + text + '

'; } + function heading(text) { return '
' + text + '
'; } + function bullet(items) { + return '
    ' + + items.map(function (t) { return '
  • ' + t + '
  • '; }).join('') + '
'; + } + function tag(label, color) { + return '' + label + ''; + } + + var content = {}; + + // ── SHORTCUTS TAB ── + content.shortcuts = + section('Global', grid([ + ['Ctrl+Z', 'Undo'], + ['Ctrl+Shift+Z', 'Redo'], + ['Ctrl+S', 'Save preset'], + ['Space', 'Play / Pause'], + ['Delete', 'Delete active block'], + ['R', 'Randomize active block'], + ['Ctrl+A', 'Select all blocks'], + ['Escape', 'Close panel / blur input'], + ['?', 'Toggle this help panel'] + ])) + + section('Plugin Rack', grid([ + ['Click header', 'Expand / collapse plugin'], + ['Right-click header', 'Plugin context menu'], + ['Drag param grip', 'Drag parameter to block'], + ['Click param name', 'Toggle assign to active block'], + ['Ctrl+Click param', 'Multi-select parameters'], + ['Scroll knob', 'Fine-adjust parameter value'], + ['Double-click knob', 'Reset to default'] + ])) + + section('Lane Editor \u2014 Mouse', grid([ + ['Click', 'Place point (Draw) / Select (Select)'], + ['Ctrl+Click', 'Toggle point selection'], + ['Shift+Drag', 'Constrain to H/V axis'], + ['Right-Click', 'Context menu on point'], + ['Double-Click', 'Add breakpoint'], + ['Shift+Scroll', 'Adjust lane depth'] + ])) + + section('Lane Editor \u2014 Keyboard', grid([ + ['S', 'Toggle Select / Draw tool'], + ['Arrows', 'Nudge selected points 5%'], + ['Shift+Arrows', 'Fine nudge 1%'], + ['Ctrl+C', 'Copy shape'], + ['Ctrl+V', 'Paste shape at next grid step'], + ['Ctrl+D', 'Duplicate shape'], + ['Ctrl+A', 'Select all points'], + ['Delete', 'Delete selected points'], + ['Escape', 'Cancel / deselect all'] + ])) + + section('WrongEQ \u2014 Canvas', grid([ + ['Click', 'Add point at 0 dB / Select point'], + ['Drag point', 'Move frequency and gain'], + ['Double-Click point', 'Reset gain to 0 dB'], + ['Double-Click empty', 'Add point at 0 dB'], + ['Shift+Drag', 'Constrain to H or V axis'], + ['Right-Click', 'Context menu (solo, mute, assign, etc.)'], + ['\u2191 / \u2193', 'Nudge gain \u00b11 dB (Shift = \u00b16 dB)'], + ['\u2190 / \u2192', 'Nudge freq \u00b11 semitone (Shift = \u00b1\u2153 oct)'], + ['Ctrl+D', 'Duplicate selected point'], + ['Delete', 'Delete selected point'], + ['Ctrl+Shift+X', 'Clear all points'], + ['Escape', 'Close WrongEQ popup'] + ])) + + section('WrongEQ \u2014 Band Rows', grid([ + ['Drag Gain', 'Adjust band gain (vertical drag)'], + ['Dbl-Click Gain', 'Reset to 0 dB'], + ['Drag Q', 'Adjust Q factor 0.1\u201310 (vertical drag)'], + ['Dbl-Click Q', 'Reset Q to 0.707'], + ['Click Type', 'Cycle: Bell / LP / HP / Notch / LShf / HShf'], + ['Drag Drift', 'Adjust drift 0\u2013100%'], + ['Dbl-Click Drift', 'Reset drift to 0%'], + ['S / M', 'Solo / Mute toggle'] + ])); + + // ── BLOCKS TAB ── + content.blocks = + heading('What are Logic Blocks?') + + para('Logic blocks generate modulation signals that control your hosted plugin parameters in real time. ' + + 'Each block has targets (the parameters it controls) and a mode that determines how values are generated.') + + + section(tag('Randomize', '#ff6b6b') + ' Randomize', + para('Generates random values for all assigned parameters on each trigger.') + + '
' + + 'TriggerTempo beat, MIDI note, audio threshold' + + 'RangeMin/Max % \u2014 limits how far values can move' + + 'Range ModeAbsolute: random within fixed range. Relative: offset from current value' + + 'QuantizeSnap to N equal steps (e.g. 12 = semitones)' + + 'MovementInstant: jump. Glide: smooth transition over time' + + '
' + ) + + + section(tag('Envelope', '#10b981') + ' Envelope Follower', + para('Tracks audio input level (main or sidechain) and converts it to modulation. ' + + 'Louder audio = higher modulation output. Great for ducking, pumping, and reactive effects.') + + '
' + + 'AttackHow fast the follower responds to rising levels (0\u2013500ms)' + + 'ReleaseHow fast it falls when audio drops (0\u20132000ms)' + + 'SensitivityAmplification of the input signal (0\u2013100%)' + + 'InvertFlip the output \u2014 loud audio = low modulation' + + 'Audio SourceMain: track input. Sidechain: external input' + + 'Band FilterOptional bandpass to isolate a frequency range (e.g. kick drum only)' + + '
' + ) + + + section(tag('Sample', '#4ecdc4') + ' Sample Modulator', + para('Loads an audio file and uses its waveform as a modulation source. ' + + 'The waveform amplitude drives parameter values over time.') + + '
' + + 'Loop ModeLoop: continuous. One-shot: fire once. Ping-pong: bounce' + + 'SpeedPlayback rate multiplier (0.1x\u20134x via DAW, up to 32x in UI)' + + 'ReversePlay waveform backwards' + + 'Jump ModeRestart: reset on trigger. Continue: keep position' + + 'TriggerTempo, MIDI, or audio threshold triggers playback' + + '
' + ) + + + section(tag('Morph Pad', '#a78bfa') + ' Morph Pad', + para('XY pad with up to 8 snapshots. Moving the cursor blends between stored parameter states using inverse distance weighting.') + + '
' + + 'SnapshotsRight-click pad to add/capture. Each stores all target values' + + 'ManualDrag the cursor yourself' + + 'AutoAutomated movement: Wander (random drift) or Shapes (geometric LFO path)' + + 'TriggerJump to snapshots in order (cycle/random) on MIDI/tempo/audio' + + 'JitterRandom perturbation added to cursor position' + + 'Snap RadiusHow close cursor must be to lock onto a snapshot' + + 'GlideSmooth transition time between snapshot jumps (ms)' + + '
' + ) + + + section(tag('Shapes', '#f59e0b') + ' Shapes / Shapes Range', + para('Continuous LFO-style modulation using geometric shapes. The cursor traces a shape path, modulating parameters by its position.') + + '
' + + 'ShapeCircle, Figure-8, Triangle, Square, Hexagon, Star, Spiral, Butterfly, Infinity + more' + + 'TrackingHorizontal: X position. Vertical: Y. Distance: from center' + + 'SpeedRotation speed (free or tempo-synced)' + + 'SpinRotates the shape itself over time' + + 'SizeScale of the shape path (depth of modulation)' + + 'PhaseStarting angle offset (0\u00B0\u2013360\u00B0)' + + 'PolarityBipolar: up+down. Unipolar: positive only. Up/Down: one direction' + + 'TriggerFree: always runs. MIDI: reset phase on note-on' + + '
' + + para('Shapes Range: each target gets its own depth slider, allowing fine per-parameter control of modulation amount.') + ) + + + section(tag('Lane', '#38bdf8') + ' Automation Lanes', + para('Draw custom curves that modulate parameters over time. Each lane block can contain multiple sub-lanes with independent timing. See the Lanes tab for details including Morph Lanes.') + ) + + + heading('Shared Controls') + + '
' + + 'Power \u26A1Enable/disable the block without removing it' + + 'Clock SourceDAW tempo or internal BPM (set in Settings)' + + 'PolarityBipolar: modulate up and down. Up: only above base. Down: only below' + + 'StackingMultiple blocks on the same param add together (additive modulation)' + + '
'; + + // ── LANES TAB ── + content.lanes = + heading('Automation Lanes') + + para('Lanes are drawable automation curves. Each lane targets one or more parameters, with its own loop length, ' + + 'interpolation mode, and timing settings. A single Lane block can contain multiple sub-lanes running independently.') + + + section('Drawing & Editing', + '
' + + 'Draw toolClick canvas to place points. They auto-connect with curves' + + 'Select toolClick/drag to select points, then move them' + + 'Steps0 = smooth curve. 4/8/16/32 = quantize to step grid' + + 'DepthOverall modulation amount (Shift+Scroll to adjust)' + + 'InterpSmooth: spline curves. Step: hard jumps. Linear: straight lines' + + '
' + ) + + + section('Timing', + '
' + + 'Loop LengthSynced: 1/16 to 32 bars. Free: seconds (0.1 to 60)' + + 'SyncedLock to DAW/internal tempo. Uncheck for free-running (seconds)' + + 'Play ModeForward: normal loop. Reverse: backwards. Ping-pong: bounce. Random: random jumps' + + '
' + ) + + + section('One-Shot / Trigger', + para('Set a lane to One-Shot mode so it only plays when triggered, instead of looping continuously.') + + '
' + + 'Loop / One-ShotLoop: runs forever. One-Shot: plays once per trigger' + + 'SourceManual: Fire button. MIDI: note-on trigger. Audio: threshold trigger' + + 'MIDI NoteAny Note or a specific note (C-1 to G9)' + + 'MIDI ChannelAny or specific channel (1\u201316)' + + 'HoldMIDI only \u2014 Off: trigger once. On: loop while note is held, stop on release' + + 'Audio ThresholdLevel in dB that triggers the lane (-48 to 0)' + + 'RetriggerAllow restart while already playing' + + '
' + ) + + + section('Lane Header', + '
' + + '\u2298 ClearReset the lane curve back to a flat 50% line' + + 'OVLOverlay another lane\u2019s shape for polyrhythmic modulation' + + '\u25CF / \u25CBMute / unmute the lane' + + '\u00D7Delete the lane' + + '
' + ) + + + section('Lane Footer \u2014 Core Effects', + '
' + + 'DepthOutput modulation depth (0\u2013200%). Scales curve toward center. Drag vertical to adjust' + + 'WarpCurve transfer function \u2014 negative = expand extremes, positive = compress to center' + + 'StepsQuantize output to N equal levels (0 = off, 2\u201332)' + + '
' + ) + + + section('Lane Footer \u2014 Drift', + para('Drift adds deterministic organic variation to the curve. Think of it as "life" \u2014 gentle wandering or sharp jitter.') + + '
' + + 'DriftSpeed \u0026 character: positive = slow wandering, negative = fast micro-jitter. Above \u00B170% = sharper texture' + + 'DftRngDrift amplitude as % of full parameter range (0\u2013100%)' + + 'DriftScaleMusical period for one drift cycle. 1/16 = fast detail, 32 bars = glacial macro shifts. Decoupled from loop length' + + '
' + ) + + + section('Overlays', + para('A lane can overlay another lane of different length, creating polyrhythmic modulation. ' + + 'The overlay runs at its own speed and adds to the base lane\u2019s output.') + ) + + + section('\u21CB Morph Lanes', + para('Any lane can be toggled into Morph Mode by clicking the \u21CB Morph button in the lane header. ' + + 'Instead of drawing freehand curves, morph lanes blend between snapshots of parameter values over time.') + + '
' + + '\u21CB MorphToggle morph mode on/off for any lane' + + 'Add SnapshotCapture current target param values as a new morph point' + + 'Browse LibraryLoad param snapshots from the snapshot library' + + 'PlayheadSweeps through snapshots in sequence \u2014 params morph to each snapshot\'s values' + + 'DepthControls how strongly the morph affects parameters (0\u2013100%)' + + 'TimingUses the same loop length and sync settings as regular lanes' + + '
' + + para('Morph lanes are especially powerful with tempo sync: the playhead sweeps through snapshots on beat, ' + + 'creating rhythmic parameter morphing. Each snapshot shows as a numbered badge in the sidebar and can be reordered or deleted.') + + para('Tip: combine morph lanes with regular drawn lanes in the same block for layered modulation \u2014 ' + + 'morph lanes handle the broad preset sweeps while drawn lanes add fine detail.') + ); + + // ── WRONGEQ TAB ── + content.weq = + heading('WrongEQ \u2014 Mastering-Grade Drawable EQ') + + para('WrongEQ is a fully parametric, drawable EQ with integrated multiband plugin routing, ' + + 'wave ripple modulation, and drift animation. Each EQ point creates a crossover frequency that splits the audio into independent bands.') + + + section('Architecture', + para('Exclusive multiband splitting \u2014 Linkwitz-Riley crossovers divide the spectrum into non-overlapping frequency bands. ' + + 'Each Hz belongs to exactly one band. Bands sum back transparently with allpass phase compensation.') + + bullet([ + 'Signal flow: Input \u2192 Parametric EQ (biquads) \u2192 Crossover split \u2192 Per-band plugins \u2192 Sum \u2192 Output', + 'Band count: N points = N+1 bands (below first, between each pair, above last)', + 'Phase coherent: LR4 crossovers + allpass compensation \u2192 flat magnitude sum', + 'Coefficient smoothing: all biquads use 512-sample linear interpolation \u2192 no clicks on parameter changes' + ]) + ) + + + section(tag('Important', '#ff6b6b') + ' Exclusive Band Splitting', + para('Because bands are exclusive frequency slices, two bands at the same frequency do NOT both receive the same audio. ' + + 'The minimum spacing is 1/6 octave \u2014 if two points drift closer, the system enforces separation.') + + bullet([ + 'If two points overlap due to drift, one band gets almost all the energy and the other gets near-silence', + 'To process the same frequency with multiple effects, place them on the same band (sequential chain)', + 'For true parallel processing of the full signal, use Parallel routing mode instead' + ]) + ) + + + section('Filter Types', + '
' + + 'BellClassic parametric boost/cut. Gain + Q control shape' + + 'LPLow-pass \u2014 unity gain, cuts above the frequency. Q controls steepness' + + 'HPHigh-pass \u2014 unity gain, cuts below the frequency. Q controls steepness' + + 'NotchBand-reject \u2014 unity gain, cuts at the frequency. Q controls width' + + 'LShfLow shelf \u2014 boosts/cuts everything below. Q = slope (S). S=1 steepest monotonic, S>1 adds bump' + + 'HShfHigh shelf \u2014 boosts/cuts everything above. Same slope behavior as LShf' + + '
' + + para('Note: LP, HP, and Notch are unity-gain filters \u2014 the gain knob and depth control have no effect on them. ' + + 'They always cut at the set frequency regardless of depth setting.') + ) + + + section('Per-Band Plugin Routing', + para('Each EQ point acts as a bus for hosting plugins. Plugins assigned to a band process only that band\u2019s audio.') + + '
' + + 'AssignUse the bus dropdown on each plugin card to assign it to a band' + + 'Post-EQEQ biquad applied before splitting \u2192 band receives EQ-shaped audio + plugin processing' + + 'SplitEQ biquad skipped \u2192 pure frequency isolation, plugins process clean band audio' + + 'Solo / MutePer-band S/M buttons on the bus header \u2014 audition individual bands' + + 'M/S ModePer-band stereo mode: Stereo (default), Mid-only, or Side-only' + + '
' + + para('Multiple plugins on the same band are processed sequentially (serial chain). ' + + 'Plugins on different bands process independent buffers (parallel by band).') + ) + + + section('Side Panel \u2014 Curve', + '
' + + 'DepthScales all band gains 0\u2013200%. At 0% all boosts/cuts are flat. LP/HP/Notch are unaffected' + + 'WarpS-curve contrast. Positive = compress toward center, negative = expand extremes' + + 'StepsQuantize gain to N equal levels. 0 = smooth, 12 = semitone-like steps' + + '
' + ) + + + section('Side Panel \u2014 Drift', + para('Drift adds organic frequency and gain animation to EQ points.') + + '
' + + 'SpdDrift speed. Positive = smooth sine sweep, negative = jittery noise' + + 'RngDrift range \u2014 how far points wander (0\u20134 octaves)' + + 'SclMusical period for one drift cycle (1/16 note to 32 bars)' + + '\u223F ContContinuous mode \u2014 also modulates gain with complex noise layering' + + '
' + ) + + + section('Side Panel \u2014 Wave Ripple', + para('Wave Ripple generates animated filter bands between user points. Creates spectral textures that move over time.') + + '
' + + 'ON / OFFEnable/disable the ripple engine' + + 'Lo / HidB floor and ceiling for the ripple wave' + + 'SpdRipple animation rate in Hz' + + 'MulRipple cycles per segment between points' + + 'GravGravity \u2014 tapers ripple amplitude toward spectral edges' + + 'OffsPhase offset \u2014 shifts the ripple starting position' + + 'ShpWaveform: Sine, Pure, Triangle, Saw, Square, Pulse, Comb, Formant, Staircase, Chirp, Fractal, Shark, Spiral, DNA, Chaos, Noise' + + 'SharpFixed resolution \u2014 multiply controls frequency only' + + 'DenseMultiply adds more ripple points for thicker texture' + + '\u27F3 InvInvert \u2014 flip ripple polarity' + + '
' + + para('Ripple points are bell filters with auto-calculated Q and overlap compensation. ' + + 'The summed response stays within the Lo/Hi range. All coefficient changes are smoothed over 512 samples to prevent clicks.') + ) + + + section('Side Panel \u2014 LFO', + '
' + + 'RateGain LFO speed in Hz (0 = off)' + + 'DepLFO depth in dB \u2014 how much gain oscillates' + + '
' + ) + + + section('Side Panel \u2014 Range', + '
' + + 'dBCanvas range: \u00B16, \u00B112, \u00B118, \u00B124, \u00B136, or \u00B148 dB' + + '
' + ) + + + section('Segment Operations', + para('Select multiple points (S tool + click/Ctrl+click), then use toolbar operations:') + + '
' + + 'FillGenerate evenly-spaced points between selection' + + 'MirrorFlip gain values within selection' + + 'RandomizeRandom gain values for selected points' + + 'FadeLinear fade between first and last selected' + + 'NormalizeScale selection to fill the current dB range' + + 'FlattenSet all selected to 0 dB' + + '
' + ); + + // ── EXPOSE TAB ── + content.expose = + heading('Expose to DAW') + + para('The \u2197 Expose button in the header opens the exposure panel. This controls which parameters appear in your DAW\'s automation dropdown. ' + + 'By default, hosted plugin params are exposed when loaded.') + + + section('How It Works', + para('Modular Randomizer has a unified pool of 2048 proxy parameters (AP_0001 to AP_2048). ' + + 'When you expose a plugin or block, its parameters are mapped to proxy slots. The DAW sees these slots as automatable parameters.') + + '
' + + 'Block paramsAssigned first \u2014 always appear at the top of the DAW\'s automation list' + + 'Plugin paramsAssigned after blocks \u2014 appear below in the list' + + 'Slot namingEach slot shows "BlockName - ParamLabel" or "PluginName: ParamName"' + + '
' + ) + + + section('Plugin Exposure', + '
' + + 'Expose toggleShow/hide all params of a plugin in the DAW automation list' + + 'Exclude paramsExpand to hide individual params you don\'t need' + + '
' + + para('Useful for plugins with hundreds of parameters \u2014 expose only what you actually automate.') + ) + + + section('Block Exposure', + '
' + + 'Expose toggleShow/hide a logic block\'s internal params in the DAW' + + 'Exposed paramsVaries by block: Speed, Size, Phase, Shape type, Attack, Release, etc.' + + 'Lane paramsPer-lane Depth, Drift, DftRng, Warp, and Steps are exposed individually' + + 'Discrete paramsShape type, tracking mode, polarity etc. appear as stepped values in the DAW' + + '
' + ) + + + section('DAW Automation', + para('Once exposed, parameters work bidirectionally:') + + bullet([ + 'DAW \u2192 Plugin: automation lanes in DAW directly control the parameter', + 'Plugin \u2192 DAW: when a hosted plugin\'s param changes, the DAW automation lane updates', + 'Float params: show proper ranges and units (e.g. 0\u2013360\u00B0 for Phase)', + 'Discrete params: show as stepped values with labels (e.g. "circle", "figure8")', + 'Bool params: show as Off/On toggle in the DAW' + ]) + ) + + + heading('Tips') + + bullet([ + 'Unexpose plugins you\'re not automating to keep the DAW parameter list clean', + 'Block params always stay at the top of the list for easy access', + 'Expose state is saved with your DAW project and global presets', + 'Adding more blocks pushes plugin params further down \u2014 they stay organized automatically' + ]); + + // ── TIPS TAB ── + content.tips = + heading('Workflow Tips') + + bullet([ + 'Assign params fast: click the \u27A4 assign button on a block, then click params in the plugin rack. Click assign again to finish.', + 'Lock parameters: right-click a param row \u2192 Lock. Locked params are excluded from all randomization and modulation.', + 'Auto-lock: right-click \u2192 Auto-Lock marks a param to be locked when randomizing but still controllable by blocks.', + 'Drag & drop: drag the \u2807 grip on a param row directly onto a block to assign it.', + 'Context menus: right-click almost anything \u2014 blocks, params, lane canvas, morph pad \u2014 for contextual actions.', + 'Multiple blocks: stack multiple blocks on the same parameters. Their modulations add together.', + 'Shapes Range: gives per-parameter depth control. Great for subtle, differentiated modulation across many params.', + 'Copy lanes: Ctrl+C/V in the lane editor copies the shape. Ctrl+D duplicates in place.', + 'Presets per plugin: right-click a plugin header to save/load presets for individual plugins.', + 'Morph lanes: toggle \u21CB Morph on any lane to switch from freehand curves to snapshot-based parameter morphing.', + 'Open plugin UI: click the \u25A3 button on a plugin card to open its native editor window.' + ]) + + + heading('Performance') + + bullet([ + 'All modulation runs per-buffer (not per-sample), so CPU impact is minimal.', + 'Disable unused blocks with the power button \u26A1 to skip processing entirely.', + 'Mute individual lanes that you want to keep but not hear right now.', + 'Virtual scroll handles plugins with hundreds of parameters efficiently.', + 'Collapse plugins in the rack when not editing \u2014 saves polling CPU.' + ]) + + + heading('Parallel Routing') + + bullet([ + 'Switch to Parallel mode in Settings to route plugins into separate buses.', + 'Each bus has independent Volume, Mute, and Solo controls.', + 'Drag plugins between buses, or use the bus selector dropdown on each plugin card.', + 'Buses are mixed together at the output with unity gain.' + ]) + + + heading('DAW Integration') + + bullet([ + 'State saving: your entire setup (plugins, blocks, lanes, expose state, theme, routing) is saved with the DAW project automatically.', + 'Auto-save: UI state is saved every 3 seconds and on editor close, so nothing is lost.', + 'Plugin crash protection: if a hosted plugin crashes during audio processing, it is automatically disabled. Other plugins keep running.', + 'Sidechain: enable the sidechain input in your DAW to feed external audio to envelope followers and audio-triggered blocks.', + 'Internal BPM: set in Settings \u2014 use when your DAW isn\'t playing or for tempo-independent modulation.' + ]); + + // ── Build the modal ── + var modal = document.getElementById('shortcutsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'shortcutsModal'; + document.body.appendChild(modal); + } + + modal.innerHTML = + ''; + + // ── Tab switching ── + modal.querySelectorAll('.help-tab').forEach(function (tab) { + tab.onclick = function () { + modal.querySelectorAll('.help-tab').forEach(function (t) { + t.classList.remove('active'); + }); + tab.classList.add('active'); + var body = document.getElementById('shortcutsBody'); + if (body && content[tab.dataset.tab]) { + body.innerHTML = content[tab.dataset.tab]; + body.scrollTop = 0; + } + }; + }); + + // ── Open / close ── + btn.onclick = function () { modal.classList.toggle('vis'); }; + document.getElementById('shortcutsClose').onclick = function () { modal.classList.remove('vis'); }; + modal.onclick = function (e) { if (e.target === modal) modal.classList.remove('vis'); }; +})(); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/juce_bridge.js b/plugins/ModularRandomizer/Source/ui/public/js/juce_bridge.js new file mode 100644 index 0000000..c5bf9f7 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/juce_bridge.js @@ -0,0 +1,52 @@ +// ============================================================ +// JUCE NATIVE FUNCTION BRIDGE +// Replicates the getNativeFunction from JUCE's ES module +// since we can't import it from a non-module script context. +// ============================================================ +(function () { + var lastPromiseId = 0; + var promises = {}; + + // Wait for __JUCE__ to be available, then set up the completion listener + function setupCompletionListener() { + if (window.__JUCE__ && window.__JUCE__.backend) { + window.__JUCE__.backend.addEventListener('__juce__complete', function (data) { + var pid = data.promiseId; + if (promises[pid]) { + promises[pid].resolve(data.result); + delete promises[pid]; + } + }); + return true; + } + return false; + } + + // Try immediately, then retry + if (!setupCompletionListener()) { + var retryInterval = setInterval(function () { + if (setupCompletionListener()) clearInterval(retryInterval); + }, 100); + } + + // Global getNativeFunction implementation + window.__juceGetNativeFunction = function (name) { + return function () { + var promiseId = lastPromiseId++; + var args = Array.prototype.slice.call(arguments); + var result = new Promise(function (resolve, reject) { + promises[promiseId] = { resolve: resolve, reject: reject }; + }); + + if (window.__JUCE__ && window.__JUCE__.backend) { + window.__JUCE__.backend.emitEvent('__juce__invoke', { + name: name, + params: args, + resultId: promiseId + }); + } + + return result; + }; + }; +})(); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/juce_integration.js b/plugins/ModularRandomizer/Source/ui/public/js/juce_integration.js new file mode 100644 index 0000000..981c68a --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/juce_integration.js @@ -0,0 +1,59 @@ +// ============================================================ +// JUCE BACKEND INTEGRATION +// Sync UI controls with host parameters via relay system +// ============================================================ +// Init: restore from host or start fresh +restoreFromHost(); +// Plugin scan is LAZY — triggered when the browser is opened and +// scannedPlugins is empty. The knownPlugins cache persists in C++. +// The peek popup uses scannedPlugins (if available) for "installed on disk" +// checks, and only shows MISSING if the cache has actually been populated. + +// ============================================================ +// JUCE BACKEND INTEGRATION +// Sync UI controls with host parameters via relay system +// ============================================================ +function initJuceIntegration() { + if (!window.__JUCE__) { + console.log('Waiting for JUCE backend...'); + setTimeout(initJuceIntegration, 100); + return; + } + console.log('JUCE backend detected, syncing parameters'); + + // Sync Mix slider from host + try { + var mixState = window.__JUCE__.getSliderState('MIX'); + if (mixState) { + var slider = document.getElementById('mixSlider'); + var valEl = document.getElementById('mixVal'); + // Set initial value from host + var initVal = Math.round(mixState.getNormalisedValue() * 100); + slider.value = initVal; + valEl.textContent = initVal + '%'; + // Listen for host-side changes + mixState.valueChangedEvent.addListener(function () { + var v = Math.round(mixState.getNormalisedValue() * 100); + slider.value = v; + valEl.textContent = v + '%'; + }); + } + } catch (e) { console.log('Mix relay error:', e); } + + // Sync Bypass toggle from host + try { + var bypState = window.__JUCE__.getToggleState('BYPASS'); + if (bypState) { + var btn = document.getElementById('bypassBtn'); + // Set initial value from host + if (bypState.getValue()) btn.classList.add('on'); + else btn.classList.remove('on'); + // Listen for host-side changes + bypState.valueChangedEvent.addListener(function () { + if (bypState.getValue()) btn.classList.add('on'); + else btn.classList.remove('on'); + }); + } + } catch (e) { console.log('Bypass relay error:', e); } +} +initJuceIntegration(); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/lane_module.js b/plugins/ModularRandomizer/Source/ui/public/js/lane_module.js new file mode 100644 index 0000000..4bdd237 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/lane_module.js @@ -0,0 +1,3802 @@ +/* + * Lane Mode Module - per-param automation lanes + * Extracted from logic_blocks.js for maintainability + * All functions here operate on the shared global state (blocks, PMap, etc.) + * Depends on: state.js, undo_system.js (pushUndoSnapshot, syncBlocksToHost) + */ +// -*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*- +// LANE MODE - per-param automation lanes +// -*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*- +var LANE_CANVAS_H = 130; // default expanded px per lane +var LANE_Y_PAD = 8; // px padding top/bottom so dots at 0%/100% are fully visible +function laneYtoCanvas(y, H) { return LANE_Y_PAD + y * (H - 2 * LANE_Y_PAD); } +function laneCanvasToY(py, H) { return Math.max(0, Math.min(1, (py - LANE_Y_PAD) / (H - 2 * LANE_Y_PAD))); } + +function ensureLanes(b) { + // Lanes support multiple params (pids array). + // Each assigned target can live in its own lane OR share a lane. + if (!b.lanes) b.lanes = []; + var tArr = Array.from(b.targets); + + // Migrate old single-pid lanes to pids array + for (var i = 0; i < b.lanes.length; i++) { + if (b.lanes[i].pid && !b.lanes[i].pids) { + b.lanes[i].pids = [b.lanes[i].pid]; + delete b.lanes[i].pid; + } + if (!b.lanes[i].pids) b.lanes[i].pids = []; + } + + // Build set of all PIDs in targets + var targetSet = {}; + tArr.forEach(function (pid) { targetSet[pid] = true; }); + + // Remove PIDs from lanes that are no longer in targets + for (var i = 0; i < b.lanes.length; i++) { + b.lanes[i].pids = b.lanes[i].pids.filter(function (pid) { return targetSet[pid]; }); + } + // Remove empty lanes that were auto-generated (lost all params) + // Keep: morph lanes, lanes the user explicitly created (they'll add params later) + // An auto-generated lane has default points and no explicit user creation marker + // Simple heuristic: keep lanes that have _userCreated flag or morphMode + b.lanes = b.lanes.filter(function (l) { return l.pids.length > 0 || l.morphMode || l._userCreated; }); + + // Init overlay state for existing lanes (transient, not persisted) + for (var i = 0; i < b.lanes.length; i++) { + if (!b.lanes[i]._overlayLanes) b.lanes[i]._overlayLanes = []; + } + + // Find PIDs not yet in any lane — batch into ONE auto-lane (not one per PID!) + var assignedPids = {}; + b.lanes.forEach(function (l) { + l.pids.forEach(function (pid) { assignedPids[pid] = true; }); + }); + var unassigned = tArr.filter(function (pid) { return !assignedPids[pid]; }); + if (unassigned.length > 0) { + b.lanes.push({ + pids: unassigned, + color: LANE_COLORS[b.lanes.length % LANE_COLORS.length], + pts: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }], + loopLen: '1/1', + freeSecs: 4, + depth: 100, + drift: 0, + driftRange: 5, + driftScale: '1/1', + warp: 0, + steps: 0, + + interp: 'smooth', + playMode: 'forward', + synced: true, + muted: false, + collapsed: false, + trigMode: 'loop', + trigSource: 'manual', + trigMidiNote: -1, + trigMidiCh: 0, + trigThreshold: -12, + trigAudioSrc: 'main', + trigRetrigger: true, + trigHold: false, + _overlayLanes: [] + }); + } +} + +// Overlay helpers: loop length in beats for ratio calculation +function laneLoopBeats(lane) { + if (lane.loopLen === 'free') return lane.freeSecs || 4; + var BEAT_MAP = { + '1/16': 0.25, '1/16T': 0.25 * 2 / 3, + '1/8': 0.5, '1/8.': 0.75, '1/8T': 0.5 * 2 / 3, + '1/4': 1, '1/4.': 1.5, '1/4T': 1 * 2 / 3, + '1/2': 2, '1/2.': 3, '1/2T': 2 * 2 / 3, + '1/1': 4, '2/1': 8, '4/1': 16, '8/1': 32, '16/1': 64, '32/1': 128 + }; + return BEAT_MAP[lane.loopLen] || 4; +} + +// Scale overlay points for different loop lengths (tile or crop) +// ratio = laneLoopBeats(currentLane) / laneLoopBeats(overlayLane) +// ratio > 1 => overlay is shorter, tile it +// ratio < 1 => overlay is longer, show the segment matching the current playback position +function getOverlayPoints(olane, ratio) { + var pts = olane.pts; + if (!pts || pts.length < 2) return []; + var scaled = []; + if (ratio >= 1) { + // Tile: repeat the overlay shape to fill the current lane's duration + var tiles = Math.ceil(ratio); + for (var t = 0; t < tiles; t++) { + for (var i = 0; i < pts.length; i++) { + // Skip first point of subsequent tiles (duplicate of prev tile's last) + if (t > 0 && i === 0) continue; + var nx = (t + pts[i].x) / ratio; + if (nx > 1.001) break; + scaled.push({ x: Math.min(1, nx), y: pts[i].y }); + } + } + } else { + // Dynamic crop: show the segment of the overlay that matches current playback. + // The overlayed lane's playhead tells us which segment is active. + var ph = olane._phPos || 0; + var segCount = Math.round(1 / ratio); + var segIdx = Math.min(Math.floor(ph / ratio), segCount - 1); + var cropStart = segIdx * ratio; + var cropEnd = cropStart + ratio; + + // Interpolate start point + var startY = interpolateAtX(pts, cropStart); + scaled.push({ x: 0, y: startY }); + + // Add points within the window + for (var i = 0; i < pts.length; i++) { + if (pts[i].x <= cropStart) continue; + if (pts[i].x >= cropEnd) break; + scaled.push({ x: (pts[i].x - cropStart) / ratio, y: pts[i].y }); + } + + // Interpolate end point + var endY = interpolateAtX(pts, Math.min(cropEnd, 1)); + scaled.push({ x: 1, y: endY }); + } + return scaled; +} + +// Helper: interpolate y value at a given x position in a point array +function interpolateAtX(pts, x) { + if (!pts || pts.length === 0) return 0.5; + if (x <= pts[0].x) return pts[0].y; + if (x >= pts[pts.length - 1].x) return pts[pts.length - 1].y; + for (var i = 0; i < pts.length - 1; i++) { + if (x >= pts[i].x && x < pts[i + 1].x) { + var t = (x - pts[i].x) / (pts[i + 1].x - pts[i].x); + return pts[i].y + (pts[i + 1].y - pts[i].y) * t; + } + } + return pts[pts.length - 1].y; +} + +function renderLaneBody(b) { + ensureLanes(b); + var h = ''; + + // -"-"- TOOLBAR -"-"- + h += '
'; + h += '
'; + // Tools + h += ''; + h += ''; + h += ''; + h += '
'; + // Grid + h += 'Grid'; + h += '
'; + ['free', '1/16', '1/8', '1/4', '1/2', '1/1', '2/1', '4/1'].forEach(function (g) { + h += ''; + }); + h += '
'; + h += '
'; + // Clear + Random + h += ''; + h += ''; + // Right side: sync + h += '
'; + h += '
'; + h += '
'; + h += '
'; // toolbar end + + // -"-"- LANES -"-"- + if (b.lanes.length === 0) { + h += '
Assign parameters to create lanes
'; + } else { + h += '
'; + for (var li = 0; li < b.lanes.length; li++) { + var lane = b.lanes[li]; + // Build header name from pids array + var firstName = '', extraCount = 0; + if (lane.pids && lane.pids.length > 0) { + var fp = PMap[lane.pids[0]]; + firstName = fp ? (paramPluginName(lane.pids[0]) + ' / ' + fp.name) : lane.pids[0]; + extraCount = lane.pids.length - 1; + } else if (lane.morphMode) { + firstName = 'Morph Lane'; + } + var pNameHtml = firstName + (extraCount > 0 ? ' +' + extraCount + '' : ''); + // Lane header + h += '
'; + h += '
'; + h += '
' + (lane.collapsed ? '\u25B6' : '\u25BC') + '
'; + h += '
'; + h += '
' + pNameHtml + '
'; + // Right controls + h += '
'; + h += ''; + // Free seconds input (only visible when loopLen is "free") + if (lane.loopLen === 'free') { + h += ''; + h += 's'; + } + // Play mode selector + h += ''; + // Loop / One-Shot mode selector + h += ''; + h += '\u2298 Clear'; + h += 'OVL'; + if (lane.morphMode) { + h += ''; + } + h += '' + (lane.muted ? '\u25CB' : '\u25CF') + ''; + h += '\u00D7'; + h += '
'; // ctrls + h += '
'; // hdr + + // Trigger controls row (only visible in oneshot mode) + if (lane.trigMode === 'oneshot') { + h += '
'; + h += 'Trigger:'; + h += ''; + if (lane.trigSource === 'manual' || !lane.trigSource) { + h += ''; + } + if (lane.trigSource === 'midi') { + h += ''; + h += ''; + h += ''; + } + if (lane.trigSource === 'audio') { + var thVal = lane.trigThreshold != null ? lane.trigThreshold : -12; + h += ''; + h += '' + thVal + ' dB'; + h += ''; + } + h += ''; + h += '
'; + } + + // Lane body (canvas + sidebars) + if (!lane.collapsed) { + h += '
'; + // Left sidebar — mode-dependent + h += '
'; + if (lane.morphMode) { + // Auto-select first snapshot if none selected (e.g. after loading from preset) + if (lane._selectedSnap == null && lane.morphSnapshots && lane.morphSnapshots.length > 0) { + lane._selectedSnap = 0; + } + // ── MORPH: Per-snapshot drift controls in left sidebar ── + var selSnapL = (lane._selectedSnap != null && lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]) ? lane.morphSnapshots[lane._selectedSnap] : null; + if (selSnapL) { + h += 'DRIFT'; + var mDriftVal = selSnapL.drift || 0; + var mDriftRng = selSnapL.driftRange != null ? selSnapL.driftRange : 5; + h += 'Drift ' + (mDriftVal >= 0 ? '+' : '') + mDriftVal + ''; + h += 'DftRng ' + mDriftRng + '%'; + var mDriftSc = selSnapL.driftScale || lane.driftScale || '1/1'; + var DS_M_OPTS = ['1/16', '1/8', '1/4', '1/2', '1/1', '2/1', '4/1', '8/1', '16/1', '32/1']; + h += ''; + } else { + h += 'DRIFT'; + } + } else { + // ── CURVE: Param list ── + h += 'PARAMS'; + h += '
'; + for (var pi = 0; pi < lane.pids.length; pi++) { + var pp = PMap[lane.pids[pi]]; + var shortName = pp ? pp.name : lane.pids[pi]; + if (shortName.length > 8) shortName = shortName.substring(0, 7) + '\u2026'; + // Auto-select first param if none selected + if (lane._selectedParamIdx == null && pi === 0) lane._selectedParamIdx = 0; + var isCurveSel = (lane._selectedParamIdx === pi); + h += '
'; + h += '' + shortName + ''; + if (isCurveSel) { + // Selected param: live value badge (updated by realtime) + var valText = pp && pp.disp ? pp.disp : (pp ? (pp.v * 100).toFixed(0) + '%' : ''); + h += '' + valText + ''; + } else { + // Non-selected: static range badge (populated async by C++) + h += ''; + } + h += '\u00D7'; + h += '
'; + } + h += '
'; + h += ''; + } + h += '
'; // lb-left + // Canvas + // Hide playhead for empty morph lanes (< 2 snaps) or empty curve lanes (no points + no targets) + var laneHasData = lane.morphMode + ? (lane.morphSnapshots && lane.morphSnapshots.length >= 2) + : (lane.pts && lane.pts.length > 0) || (lane.pids && lane.pids.length > 0); + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + // Right sidebar — mode-dependent + h += '
'; + if (lane.morphMode) { + // ── MORPH: per-snapshot effect knobs ── + var selSnap2 = (lane._selectedSnap != null && lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]) ? lane.morphSnapshots[lane._selectedSnap] : null; + if (selSnap2) { + h += 'EFFECTS'; + var sDepth = selSnap2.depth != null ? Math.round(selSnap2.depth * 100) : 100; + var sWarp = selSnap2.warp || 0; + var sSteps = selSnap2.steps || 0; + h += 'Dpth ' + sDepth + '%'; + h += 'Warp ' + (sWarp >= 0 ? '+' : '') + sWarp + ''; + h += 'Step ' + (sSteps || 'Off') + ''; + } + } else { + h += 'INTERP'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += 'SYNC'; + h += ''; + } + h += '
'; // lb-right + h += '
'; // lane-body + + // Lane footer — mode-dependent controls + h += ''; // lane-footer + + // ── MORPH: Snapshot + Param lists below footer ── + if (lane.morphMode) { + var MP_COLS = ['#ff6464', '#64b4ff', '#64dc8c', '#ffc850', '#c882ff', '#ff8cb4', '#50dcdc', '#dca064', '#a0ffa0', '#b48cdc', '#ffb478', '#78c8c8']; + h += '
'; + // Left half: Snapshots + h += '
'; + h += '
SNAPSHOTS
'; + h += '
'; + var snaps = lane.morphSnapshots || []; + for (var si = 0; si < snaps.length; si++) { + var isSel = (lane._selectedSnaps && lane._selectedSnaps.has(si)) || lane._selectedSnap === si; + var holdPct = Math.round((snaps[si].hold != null ? snaps[si].hold : 0.5) * 100); + var pCnt = Object.keys(snaps[si].values).length; + h += '
'; + h += '' + (si + 1) + ''; + h += '' + (snaps[si].name || 'S' + (si + 1)) + ''; + h += '' + pCnt + 'p ' + holdPct + '%'; + h += '\u00D7'; + h += '
'; + } + if (snaps.length === 0) { + h += 'No snapshots \u2014 use Capture'; + } + h += '
'; + h += '
'; + // Right half: Params + h += '
'; + h += '
PARAMETERS'; + h += '
'; + h += '
'; + if (lane.pids && lane.pids.length > 0) { + for (var mpi = 0; mpi < lane.pids.length; mpi++) { + var mpp = PMap[lane.pids[mpi]]; + var mpName = mpp ? mpp.name : lane.pids[mpi]; + var dotCol = MP_COLS[mpi % MP_COLS.length]; + var isMorphSel = (lane._selectedParamIdx === mpi); + h += '
'; + h += ''; + h += '' + mpName + ''; + if (isMorphSel) { + var mValText = mpp && mpp.disp ? mpp.disp : (mpp ? (mpp.v * 100).toFixed(0) + '%' : ''); + h += '' + mValText + ''; + } else { + h += ''; + } + h += '×'; + h += '
'; + } + } + h += '
'; + h += ''; + h += '
'; + h += '
'; // lane-morph-lists + } + } + h += '
'; // lane-item + } + h += '
'; // lane-stack + } + // Add Lane buttons — always visible at bottom of lane section + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; // block-section + return h; +} + +// -"-"- Lane canvas drawing engine -"-"- +function laneCanvasSetup(b) { + if (!b.lanes) return; + var dpr = window.devicePixelRatio || 1; + for (var li = 0; li < b.lanes.length; li++) { + var lane = b.lanes[li]; + if (lane.collapsed) continue; + var cvs = document.getElementById('lcv-' + b.id + '-' + li); + var wrap = document.getElementById('lcw-' + b.id + '-' + li); + if (!cvs || !wrap) continue; + // HiDPI: scale canvas buffer for crisp rendering + var cssW = wrap.clientWidth || 300; + var cssH = wrap.clientHeight || LANE_CANVAS_H; + cvs.width = cssW * dpr; + cvs.height = cssH * dpr; + cvs.style.width = cssW + 'px'; + cvs.style.height = cssH + 'px'; + var ctx = cvs.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + laneDrawCanvas(b, li); + laneSetupMouse(b, li); + laneSetupFooter(b, li); + if (lane.morphMode) laneSetupMorphSidebar(b, li); + } + // (Add Lane buttons are wired in wireBlocks — logic_blocks.js) + // Drop params onto lanes — drag from plugin rack onto a lane header or body + document.querySelectorAll('.lane-item[data-b="' + b.id + '"]').forEach(function (laneEl) { + laneEl.addEventListener('dragover', function (e) { + if (e.dataTransfer.types.indexOf('text/plain') === -1) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + // Use this lane's color for the highlight + var li = parseInt(laneEl.dataset.li); + var lane = b.lanes && b.lanes[li]; + var col = lane ? lane.color : 'var(--drag-highlight, var(--accent))'; + laneEl.style.outline = '2px dashed ' + col; + laneEl.style.outlineOffset = '-2px'; + laneEl.style.background = col + '12'; // subtle 7% tint via hex alpha + }); + laneEl.addEventListener('dragleave', function () { + laneEl.style.outline = ''; + laneEl.style.outlineOffset = ''; + laneEl.style.background = ''; + }); + laneEl.addEventListener('drop', function (e) { + e.preventDefault(); + e.stopPropagation(); + laneEl.style.outline = ''; + laneEl.style.outlineOffset = ''; + laneEl.style.background = ''; + var data = e.dataTransfer.getData('text/plain'); + if (!data || data.indexOf('params:') !== 0) return; + var pids = data.replace('params:', '').split(','); + var li = parseInt(laneEl.dataset.li); + var lane = b.lanes[li]; + if (!lane) return; + pids.forEach(function (pid) { + var pp = PMap[pid]; + if (pp && !pp.lk) { + assignTarget(b, pid); + if (lane.pids.indexOf(pid) < 0) lane.pids.push(pid); + } + }); + selectedParams.clear(); + renderSingleBlock(b.id); + renderAllPlugins(); + syncBlocksToHost(); + }); + }); +} + +// Morph lane sidebar wiring — snapshot list clicks, delete, library +var morphLaneLibTarget = null; // { blockId, laneIdx } — set when library is opened for a morph lane + +function laneSetupMorphSidebar(b, li) { + var lane = b.lanes[li]; + if (!lane || !lane.morphMode) return; + + // Snapshot items — click to select, double-click to rename + document.querySelectorAll('.lane-snap-item[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (el) { + el.onclick = function (e) { + if (e.target.classList.contains('lane-snap-del') || e.target.classList.contains('lane-snap-hold')) return; + var si = parseInt(el.dataset.si); + // Initialize multi-select set if needed + if (!lane._selectedSnaps) lane._selectedSnaps = new Set(); + if (e.ctrlKey || e.metaKey) { + // Toggle this snapshot in multi-select + if (lane._selectedSnaps.has(si)) { + lane._selectedSnaps.delete(si); + // Update primary to another selected, or -1 + if (lane._selectedSnaps.size > 0) { + var arr = Array.from(lane._selectedSnaps); + lane._selectedSnap = arr[arr.length - 1]; + } else { + lane._selectedSnap = -1; + } + } else { + lane._selectedSnaps.add(si); + lane._selectedSnap = si; + } + } else if (e.shiftKey && lane._selectedSnap != null && lane._selectedSnap >= 0) { + // Shift+click: range select from last primary to this + var from = lane._selectedSnap; + var to = si; + var lo = Math.min(from, to), hi = Math.max(from, to); + for (var ri = lo; ri <= hi; ri++) { + lane._selectedSnaps.add(ri); + } + lane._selectedSnap = si; + } else { + // Regular click → single select + lane._selectedSnaps.clear(); + lane._selectedSnaps.add(si); + lane._selectedSnap = si; + } + // Update visual highlights + document.querySelectorAll('.lane-snap-item[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (item) { + item.classList.toggle('sel', lane._selectedSnaps.has(parseInt(item.dataset.si))); + }); + // Apply primary snapshot values to hosted plugin (audition) + var snap = lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]; + if (snap && snap.values && window.__JUCE__ && window.__JUCE__.backend) { + var batch = []; + var keys = Object.keys(snap.values); + for (var ki = 0; ki < keys.length; ki++) { + var pp = PMap[keys[ki]]; + if (pp && pp.hostId !== undefined && pp.realIndex !== undefined) { + pp.v = snap.values[keys[ki]]; + batch.push({ p: pp.hostId, i: pp.realIndex, v: pp.v }); + } + } + if (batch.length > 0) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(batch)); + } + } + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + }; + el.ondblclick = function (e) { + e.stopPropagation(); + var si = parseInt(el.dataset.si); + var snap = lane.morphSnapshots[si]; + if (!snap) return; + var nameEl = el.querySelector('.lane-snap-name'); + if (!nameEl) return; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.value = snap.name || ''; + inp.className = 'morph-inline-edit'; + inp.style.cssText = 'width:100%;position:static;margin:0;'; + nameEl.replaceWith(inp); + inp.focus(); + inp.select(); + function commit() { + snap.name = inp.value || snap.name; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } + inp.onkeydown = function (ke) { if (ke.key === 'Enter') commit(); if (ke.key === 'Escape') { renderSingleBlock(b.id); } }; + inp.onblur = commit; + }; + // Right-click context menu on snapshot list items + el.oncontextmenu = function (e) { + e.preventDefault(); + e.stopPropagation(); + var si = parseInt(el.dataset.si); + var snap = lane.morphSnapshots[si]; + if (!snap) return; + if (!lane._selectedSnaps) lane._selectedSnaps = new Set(); + // If right-clicking outside current selection, make it the sole selection + if (!lane._selectedSnaps.has(si)) { + lane._selectedSnaps.clear(); + lane._selectedSnaps.add(si); + } + lane._selectedSnap = si; + laneDrawCanvas(b, li); + // Update selection visuals + document.querySelectorAll('.lane-snap-item[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (item) { + item.classList.toggle('sel', lane._selectedSnaps.has(parseInt(item.dataset.si))); + }); + // Remove existing menus + var old = document.querySelector('.morph-ctx-menu'); + if (old) old.remove(); + var menu = document.createElement('div'); + menu.className = 'morph-ctx-menu lane-add-menu'; + menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;z-index:9999;min-width:130px;'; + var selCount = lane._selectedSnaps.size; + var batchSuffix = selCount > 1 ? ' (' + selCount + ')' : ''; + var items = [ + { + label: 'Duplicate' + batchSuffix, key: selCount <= 1 ? 'Ctrl+D' : '', action: function () { + pushUndoSnapshot(); + var idxs = Array.from(lane._selectedSnaps).sort(function (a, c) { return a - c; }); + for (var di = idxs.length - 1; di >= 0; di--) { _morphSnapDuplicate(b, li, lane, idxs[di]); } + } + }, + { + label: 'Recapture' + batchSuffix, action: function () { + pushUndoSnapshot(); + lane._selectedSnaps.forEach(function (idx) { + var s = lane.morphSnapshots[idx]; if (!s) return; + (lane.pids || []).forEach(function (pid) { var pp = PMap[pid]; if (pp && !pp.lk) s.values[pid] = pp.v; }); + }); + laneDrawCanvas(b, li); syncBlocksToHost(); + } + } + ]; + // Only show Rename for single selection + if (selCount <= 1) { + items.splice(1, 0, { label: 'Rename', action: function () { var nameEl2 = el.querySelector('.lane-snap-name'); if (nameEl2) { el.ondblclick(e); } } }); + } + items.push({ label: '---' }); + // Curve options — apply to all selected + var curveLabels = ['Smooth', 'Linear', 'Sharp', 'Late']; + for (var ci = 0; ci < curveLabels.length; ci++) { + (function (cIdx) { + var allMatch = true; + lane._selectedSnaps.forEach(function (idx) { var s = lane.morphSnapshots[idx]; if (s && (s.curve || 0) !== cIdx) allMatch = false; }); + items.push({ + label: curveLabels[cIdx] + (allMatch ? ' \u2713' : '') + batchSuffix, action: function () { + lane._selectedSnaps.forEach(function (idx) { var s = lane.morphSnapshots[idx]; if (s) s.curve = cIdx; }); + laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); + } + }); + })(ci); + } + items.push({ label: '---' }); + items.push({ + label: 'Delete' + batchSuffix, action: function () { + pushUndoSnapshot(); + var idxs = Array.from(lane._selectedSnaps).sort(function (a, c) { return c - a; }); // reverse order + for (var di = 0; di < idxs.length; di++) { lane.morphSnapshots.splice(idxs[di], 1); } + if (lane.morphSnapshots.length > 1) { lane.morphSnapshots[0].position = 0; lane.morphSnapshots[lane.morphSnapshots.length - 1].position = 1; } + else if (lane.morphSnapshots.length === 1) { lane.morphSnapshots[0].position = 0; } + lane._selectedSnap = -1; lane._selectedSnaps.clear(); + laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); + } + }); + items.forEach(function (item) { + if (item.label === '---') { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + return; + } + var row = document.createElement('div'); + row.className = 'lane-add-menu-item'; + row.textContent = item.label; + if (item.key) { + var kbd = document.createElement('span'); + kbd.style.cssText = 'float:right;opacity:0.4;font-size:9px;margin-left:12px;'; + kbd.textContent = item.key; + row.appendChild(kbd); + } + row.onclick = function (me) { me.stopPropagation(); menu.remove(); item.action(); }; + menu.appendChild(row); + }); + document.body.appendChild(menu); + setTimeout(function () { + function dismiss(de) { if (menu.contains(de.target)) return; if (menu.parentNode) menu.remove(); document.removeEventListener('mousedown', dismiss); } + document.addEventListener('mousedown', dismiss); + }, 50); + }; + }); + + // Snapshot delete buttons + document.querySelectorAll('.lane-snap-del[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (el) { + el.onclick = function (e) { + e.stopPropagation(); + var si = parseInt(el.dataset.si); + if (!lane.morphSnapshots || !lane.morphSnapshots[si]) return; + pushUndoSnapshot(); + lane.morphSnapshots.splice(si, 1); + // Fix edge positions + if (lane.morphSnapshots.length > 1) { + lane.morphSnapshots[0].position = 0; + lane.morphSnapshots[lane.morphSnapshots.length - 1].position = 1; + } else if (lane.morphSnapshots.length === 1) { + lane.morphSnapshots[0].position = 0; + } + lane._selectedSnap = -1; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + }; + }); + + // Ctrl+A to select all snapshots + var snapList = document.querySelector('.lane-snap-list'); + if (snapList) { + // Make focusable so keydown fires + if (!snapList.getAttribute('tabindex')) snapList.setAttribute('tabindex', '-1'); + snapList.onkeydown = function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + if (!lane._selectedSnaps) lane._selectedSnaps = new Set(); + var snaps = lane.morphSnapshots || []; + for (var ai = 0; ai < snaps.length; ai++) lane._selectedSnaps.add(ai); + if (snaps.length > 0) lane._selectedSnap = snaps.length - 1; + // Update visual highlights + document.querySelectorAll('.lane-snap-item[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (item) { + item.classList.toggle('sel', lane._selectedSnaps.has(parseInt(item.dataset.si))); + }); + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + } + }; + } + + // Snapshot drag reorder in sidebar + var snapItems = document.querySelectorAll('.lane-snap-item[data-b="' + b.id + '"][data-li="' + li + '"]'); + function clearSnapDragIndicators() { + snapItems.forEach(function (s) { s.style.borderTop = ''; s.style.borderBottom = ''; s.style.opacity = ''; }); + } + snapItems.forEach(function (el) { + el.draggable = true; + el.ondragstart = function (e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/snap-reorder', el.dataset.si); + // Dim all selected items if dragging from a batch + var si = parseInt(el.dataset.si); + var selSet = lane._selectedSnaps; + if (selSet && selSet.size > 1 && selSet.has(si)) { + snapItems.forEach(function (s) { + if (selSet.has(parseInt(s.dataset.si))) s.style.opacity = '0.3'; + }); + } else { + el.style.opacity = '0.3'; + } + }; + el.ondragend = function () { clearSnapDragIndicators(); }; + el.ondragover = function (e) { + if (!e.dataTransfer.types.some(function (t) { return t === 'text/snap-reorder'; })) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + // Clear indicators on all items, then set on this one + snapItems.forEach(function (s) { s.style.borderTop = ''; s.style.borderBottom = ''; }); + var rect = el.getBoundingClientRect(); + var above = e.clientY < rect.top + rect.height / 2; + el.style.borderTop = above ? '2px solid var(--accent)' : ''; + el.style.borderBottom = above ? '' : '2px solid var(--accent)'; + }; + el.ondragleave = function () { el.style.borderTop = ''; el.style.borderBottom = ''; }; + el.ondrop = function (e) { + e.preventDefault(); + clearSnapDragIndicators(); + try { + var fromStr = e.dataTransfer.getData('text/snap-reorder'); + if (!fromStr && fromStr !== '0') return; + var from = parseInt(fromStr); + var dropTarget = parseInt(el.dataset.si); + if (isNaN(from) || isNaN(dropTarget)) return; + var snaps = lane.morphSnapshots; + if (!snaps || from < 0 || from >= snaps.length) return; + if (dropTarget < 0 || dropTarget >= snaps.length) return; + + // Determine insertion point + var rect = el.getBoundingClientRect(); + var insertBefore = e.clientY < rect.top + rect.height / 2; + + // Collect indices to move: if dragged item is in selection, move all selected; else just the one + var selSet = lane._selectedSnaps; + var dragIsSelected = selSet && selSet.size > 1 && selSet.has(from); + var moveIndices; + if (dragIsSelected) { + moveIndices = Array.from(selSet).sort(function (a, c) { return a - c; }); + } else { + moveIndices = [from]; + } + + // If drop target is within the selection, nothing to do + if (dragIsSelected && selSet.has(dropTarget)) return; + + pushUndoSnapshot(); + + // Extract the selected snapshots (in order), keeping track + var movedSnaps = []; + for (var mi = moveIndices.length - 1; mi >= 0; mi--) { + movedSnaps.unshift(snaps.splice(moveIndices[mi], 1)[0]); + } + + // Recalculate insertion index in the now-shortened array + // Find where the drop target ended up after removals + var newDropIdx; + if (insertBefore) { + // Insert before the drop target's new position + // Count how many selected items were before dropTarget + var removedBefore = 0; + for (var ri = 0; ri < moveIndices.length; ri++) { + if (moveIndices[ri] < dropTarget) removedBefore++; + } + newDropIdx = dropTarget - removedBefore; + } else { + // Insert after the drop target's new position + var removedBefore2 = 0; + for (var ri2 = 0; ri2 < moveIndices.length; ri2++) { + if (moveIndices[ri2] < dropTarget) removedBefore2++; + } + newDropIdx = dropTarget - removedBefore2 + 1; + } + if (newDropIdx < 0) newDropIdx = 0; + if (newDropIdx > snaps.length) newDropIdx = snaps.length; + + // Re-insert the batch at the computed position + for (var ii = 0; ii < movedSnaps.length; ii++) { + snaps.splice(newDropIdx + ii, 0, movedSnaps[ii]); + } + + // Redistribute positions evenly + if (snaps.length > 1) { + for (var si = 0; si < snaps.length; si++) + snaps[si].position = si / (snaps.length - 1); + } else if (snaps.length === 1) { + snaps[0].position = 0; + } + + // Update selection to new indices + if (selSet) { + selSet.clear(); + for (var ni = 0; ni < movedSnaps.length; ni++) { + selSet.add(newDropIdx + ni); + } + } + lane._selectedSnap = newDropIdx; + + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } catch (err) { + console.error('[MorphLane] Snapshot reorder failed:', err); + } + }; + }); + + // Morph params search filter + document.querySelectorAll('.lane-morph-search[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (inp) { + inp.oninput = function () { + var q = inp.value.toLowerCase(); + var container = document.getElementById('morph-params-' + b.id + '-' + li); + if (!container) return; + container.querySelectorAll('.lane-morph-param').forEach(function (chip) { + var nameEl = chip.querySelector('.lane-param-chip-name'); + var name = nameEl ? nameEl.textContent.toLowerCase() : ''; + chip.style.display = (q === '' || name.indexOf(q) >= 0) ? '' : 'none'; + }); + }; + // Prevent keyboard shortcuts from firing while typing + inp.onkeydown = function (e) { e.stopPropagation(); }; + }); + + // Library button — opens snapshot library targeting this morph lane + document.querySelectorAll('.lane-morph-lib-btn[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + morphLaneLibTarget = { blockId: b.id, laneIdx: li }; + if (typeof openSnapshotLibrary === 'function') openSnapshotLibrary(b.id); + }; + }); + + // Shared capture helper + function _morphDoCapture(b, lane, li, filterFn) { + pushUndoSnapshot(); + if (!lane.morphSnapshots) lane.morphSnapshots = []; + var vals = {}; + var hadPids = lane.pids && lane.pids.length > 0; + if (hadPids) { + // Lane already has assigned params — only capture values for those pids + lane.pids.forEach(function (pid) { + var p = PMap[pid]; + if (p && !p.lk) vals[pid] = p.v; + }); + } else if (typeof pluginBlocks !== 'undefined') { + // Lane is empty — add the filtered plugin's params as new pids + pluginBlocks.forEach(function (pb) { + if (!filterFn(pb)) return; + pb.params.forEach(function (p) { + if (!p.lk && !p.alk) { + vals[p.id] = p.v; + if (lane.pids.indexOf(p.id) < 0) lane.pids.push(p.id); + assignTarget(b, p.id); + } + }); + }); + } + var snap = { position: 0, hold: 0.5, curve: 0, name: 'S' + (lane.morphSnapshots.length + 1), source: '', values: vals }; + lane.morphSnapshots.push(snap); + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { lane.morphSnapshots[0].position = 0; } + lane._selectedSnap = lane.morphSnapshots.length - 1; + if (typeof selectedParams !== 'undefined') selectedParams.clear(); + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + syncBlocksToHost(); + } + + // Capture reset button (✕) — clears remembered source + document.querySelectorAll('.lane-cap-reset[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (el) { + el.onclick = function (e) { + e.stopPropagation(); + lane._captureSource = null; + renderSingleBlock(b.id); + }; + }); + + // Capture button — direct mode (plugin already selected) or dropdown picker + document.querySelectorAll('.lane-sidebar-capture[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + + // Direct capture mode — plugin already chosen + if (btn.classList.contains('lane-cap-direct') && lane._captureSource) { + var srcId = String(lane._captureSource); + _morphDoCapture(b, lane, li, function (pb) { return String(pb.id) === srcId; }); + return; + } + + // Dropdown picker mode + var old = document.querySelector('.morph-capture-menu'); + if (old) old.remove(); + + var menu = document.createElement('div'); + menu.className = 'morph-capture-menu lane-add-menu'; + menu.style.position = 'fixed'; + menu.style.zIndex = '999'; + var rect = btn.getBoundingClientRect(); + menu.style.left = rect.left + 'px'; + menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px'; + + // "Selected Params" option if any are selected + if (typeof selectedParams !== 'undefined' && selectedParams.size > 0) { + var selItem = document.createElement('div'); + selItem.className = 'lane-add-menu-item'; + selItem.textContent = 'Selected Params (' + selectedParams.size + ')'; + selItem.onclick = function (ev) { + ev.stopPropagation(); menu.remove(); + pushUndoSnapshot(); + if (!lane.morphSnapshots) lane.morphSnapshots = []; + var vals = {}; + selectedParams.forEach(function (pid) { + var p = PMap[pid]; + if (p && !p.lk) { + vals[pid] = p.v; + if (lane.pids.indexOf(pid) < 0) lane.pids.push(pid); + assignTarget(b, pid); + } + }); + var snap = { position: 0, hold: 0.5, curve: 0, name: 'S' + (lane.morphSnapshots.length + 1), source: '', values: vals }; + lane.morphSnapshots.push(snap); + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { lane.morphSnapshots[0].position = 0; } + lane._selectedSnap = lane.morphSnapshots.length - 1; + selectedParams.clear(); + laneDrawCanvas(b, li); renderSingleBlock(b.id); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + syncBlocksToHost(); + }; + menu.appendChild(selItem); + } + + // "Assigned Params" if lane already has pids + if (lane.pids && lane.pids.length > 0) { + var assItem = document.createElement('div'); + assItem.className = 'lane-add-menu-item'; + assItem.textContent = 'Assigned Params (' + lane.pids.length + ')'; + assItem.onclick = function (ev) { + ev.stopPropagation(); menu.remove(); + pushUndoSnapshot(); + if (!lane.morphSnapshots) lane.morphSnapshots = []; + var vals = {}; + lane.pids.forEach(function (pid) { var p = PMap[pid]; if (p && !p.lk) vals[pid] = p.v; }); + var snap = { position: 0, hold: 0.5, curve: 0, name: 'S' + (lane.morphSnapshots.length + 1), source: '', values: vals }; + lane.morphSnapshots.push(snap); + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { lane.morphSnapshots[0].position = 0; } + lane._selectedSnap = lane.morphSnapshots.length - 1; + laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); + }; + menu.appendChild(assItem); + } + + // Separator before plugin list + if (menu.children.length > 0) { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + } + + // Per-plugin items — picking one remembers it for direct capture + if (typeof pluginBlocks !== 'undefined') { + pluginBlocks.forEach(function (pb) { + if (!pb.params || pb.params.length === 0) return; + var item = document.createElement('div'); + item.className = 'lane-add-menu-item'; + item.textContent = pb.name || pb.id; + item.onclick = function (ev) { + ev.stopPropagation(); menu.remove(); + // Remember this plugin for direct capture + lane._captureSource = pb.id; + // Do the capture immediately + _morphDoCapture(b, lane, li, function (p) { return p.id === pb.id; }); + }; + menu.appendChild(item); + }); + } + + document.body.appendChild(menu); + setTimeout(function () { + var dismiss = function (de) { if (!menu.contains(de.target)) { menu.remove(); document.removeEventListener('mousedown', dismiss); } }; + document.addEventListener('mousedown', dismiss); + }, 10); + }; + }); +} + +// Setup footer action button handlers for a lane +function laneSetupFooter(b, li) { + var lane = b.lanes[li]; + if (!lane || lane.collapsed) return; + + // Default values if missing + if (lane.depth == null) lane.depth = 100; + if (lane.drift == null) lane.drift = 0; + if (lane.driftRange == null) lane.driftRange = 5; + if (lane.warp == null) lane.warp = 0; + if (lane.steps == null) lane.steps = 0; + + + + // Footer action buttons + document.querySelectorAll('.lane-ft-btn[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var act = btn.dataset.act; + if (act === 'random') { + pushUndoSnapshot(); + laneRandomize(lane, b.laneGrid); + if (lane._sel) lane._sel.clear(); + laneDrawCanvas(b, li); + syncBlocksToHost(); + } else if (act === 'invert') { + pushUndoSnapshot(); + for (var i = 0; i < lane.pts.length; i++) { + lane.pts[i].y = 1 - lane.pts[i].y; + } + laneDrawCanvas(b, li); + syncBlocksToHost(); + } else if (act === 'clear') { + pushUndoSnapshot(); + var edgeY = (lane.pts.length && lane.pts[0]) ? lane.pts[0].y : 0.5; + lane.pts = [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }]; + if (lane._sel) lane._sel.clear(); + laneDrawCanvas(b, li); + syncBlocksToHost(); + } else if (act === 'shapes') { + laneShowShapesMenu(b, li, btn); + } else if (act === 'capture-assigned' || act === 'capture-all') { + pushUndoSnapshot(); + if (!lane.morphSnapshots) lane.morphSnapshots = []; + var vals = {}; + if (act === 'capture-all') { + for (var pi = 0; pi < pluginBlocks.length; pi++) { + var pb = pluginBlocks[pi]; + if (!pb || !pb.params) continue; + for (var pj = 0; pj < pb.params.length; pj++) { + var p = pb.params[pj]; + if (!p.lk) vals[p.id] = p.v; + } + } + } else { + (lane.pids || []).forEach(function (pid) { + var p = PMap[pid]; + if (p && !p.lk) vals[pid] = p.v; + }); + } + var snap = { + position: 0, + hold: 0.5, + curve: 0, + name: 'S' + (lane.morphSnapshots.length + 1), + source: '', + values: vals + }; + lane.morphSnapshots.push(snap); + // Re-distribute evenly + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { + lane.morphSnapshots[0].position = 0; + } + // Auto-select the new snapshot + lane._selectedSnap = lane.morphSnapshots.length - 1; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } else if (act === 'delete-snap') { + var si = lane._selectedSnap; + if (si != null && lane.morphSnapshots && lane.morphSnapshots[si]) { + pushUndoSnapshot(); + lane.morphSnapshots.splice(si, 1); + // Fix positions + if (lane.morphSnapshots.length > 1) { + lane.morphSnapshots[0].position = 0; + lane.morphSnapshots[lane.morphSnapshots.length - 1].position = 1; + } else if (lane.morphSnapshots.length === 1) { + lane.morphSnapshots[0].position = 0; + } + lane._selectedSnap = -1; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } + } + }; + }); + // Morph curve dropdown + document.querySelectorAll('.lane-morph-curve-sel[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (sel) { + sel.onchange = function () { + var cVal = parseInt(sel.value); + if (lane._selectedSnap != null && lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]) { + lane.morphSnapshots[lane._selectedSnap].curve = cVal; + laneDrawCanvas(b, li); + syncBlocksToHost(); + } + }; + }); + // Per-snapshot DriftScale dropdown + document.querySelectorAll('.lane-morph-ds[data-b="' + b.id + '"][data-li="' + li + '"]').forEach(function (sel) { + sel.onchange = function () { + var dsVal = sel.value; + // Apply to all selected snapshots + var indices = []; + if (lane._selectedSnaps && lane._selectedSnaps.size > 0) { + lane._selectedSnaps.forEach(function (si) { indices.push(si); }); + } + if (indices.length === 0 && lane._selectedSnap != null && lane._selectedSnap >= 0) { + indices.push(lane._selectedSnap); + } + for (var si = 0; si < indices.length; si++) { + var snap = lane.morphSnapshots && lane.morphSnapshots[indices[si]]; + if (snap) snap.driftScale = dsVal; + } + laneDrawCanvas(b, li); + syncBlocksToHost(); + }; + }); + // Morph param chip multi-select (Ctrl+click, Shift+click) + highlight + right-click batch menu + var morphParamContainer = document.getElementById('morph-params-' + b.id + '-' + li); + var morphParamChips = document.querySelectorAll('.lane-morph-param[data-b="' + b.id + '"][data-li="' + li + '"]'); + if (!lane._selectedParamIndices) lane._selectedParamIndices = new Set(); + + function _updateParamChipVisuals() { + var allChips = document.querySelectorAll('.lane-morph-param[data-b="' + b.id + '"][data-li="' + li + '"]'); + var hasSel = lane._selectedParamIndices && lane._selectedParamIndices.size > 0; + allChips.forEach(function (c) { + var ci = parseInt(c.dataset.pidx); + var isSel = hasSel && lane._selectedParamIndices.has(ci); + c.style.outline = isSel ? '1px solid var(--accent)' : ''; + c.style.opacity = hasSel ? (isSel ? '1' : '0.4') : ''; + }); + } + + if (morphParamContainer && lane.morphMode && morphParamChips.length > 0) { + var delegateRoot = morphParamChips[0].parentNode; + if (delegateRoot) { + // Click handler: single/multi-select + delegateRoot.addEventListener('click', function (e) { + if (e.target.classList.contains('lane-param-chip-x')) return; + var chip = e.target.closest('.lane-morph-param[data-b="' + b.id + '"][data-li="' + li + '"]'); + if (!chip) return; + e.stopPropagation(); + var pidx = parseInt(chip.dataset.pidx); + if (isNaN(pidx)) return; + if (!lane._selectedParamIndices) lane._selectedParamIndices = new Set(); + + if (e.ctrlKey || e.metaKey) { + // Ctrl+click: toggle + if (lane._selectedParamIndices.has(pidx)) { + lane._selectedParamIndices.delete(pidx); + if (lane._selectedParamIndices.size > 0) { + var arr = Array.from(lane._selectedParamIndices); + lane._selectedParamIdx = arr[arr.length - 1]; + lane._highlightParam = lane._selectedParamIdx; + } else { + lane._selectedParamIdx = -1; + lane._highlightParam = -1; + } + } else { + lane._selectedParamIndices.add(pidx); + lane._selectedParamIdx = pidx; + lane._highlightParam = pidx; + } + } else if (e.shiftKey && lane._selectedParamIdx != null && lane._selectedParamIdx >= 0) { + // Shift+click: range + var from = lane._selectedParamIdx; + var lo = Math.min(from, pidx), hi = Math.max(from, pidx); + for (var ri = lo; ri <= hi; ri++) lane._selectedParamIndices.add(ri); + lane._selectedParamIdx = pidx; + lane._highlightParam = pidx; + } else { + // Regular click: single select (toggle if same) + if (lane._selectedParamIndices.size <= 1 && lane._selectedParamIndices.has(pidx)) { + lane._selectedParamIndices.clear(); + lane._selectedParamIdx = -1; + lane._highlightParam = -1; + } else { + lane._selectedParamIndices.clear(); + lane._selectedParamIndices.add(pidx); + lane._selectedParamIdx = pidx; + lane._highlightParam = pidx; + } + } + _updateParamChipVisuals(); + laneDrawCanvas(b, li); + }); + + // Right-click context menu for batch actions + delegateRoot.addEventListener('contextmenu', function (e) { + var chip = e.target.closest('.lane-morph-param[data-b="' + b.id + '"][data-li="' + li + '"]'); + if (!chip) return; + e.preventDefault(); + e.stopPropagation(); + var pidx = parseInt(chip.dataset.pidx); + if (isNaN(pidx)) return; + + // If right-clicking outside current selection, make it the sole selection + if (!lane._selectedParamIndices) lane._selectedParamIndices = new Set(); + if (!lane._selectedParamIndices.has(pidx)) { + lane._selectedParamIndices.clear(); + lane._selectedParamIndices.add(pidx); + lane._selectedParamIdx = pidx; + lane._highlightParam = pidx; + _updateParamChipVisuals(); + laneDrawCanvas(b, li); + } + + var selIndices = Array.from(lane._selectedParamIndices).sort(function (a, c) { return a - c; }); + var selCount = selIndices.length; + var selPids = selIndices.map(function (i) { return lane.pids[i]; }).filter(Boolean); + + // Remove existing menus + var old = document.querySelector('.morph-param-ctx-menu'); + if (old) old.remove(); + + var menu = document.createElement('div'); + menu.className = 'morph-param-ctx-menu lane-add-menu'; + menu.style.position = 'fixed'; + menu.style.zIndex = '9999'; + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + + // Header + var hdr = document.createElement('div'); + hdr.className = 'lane-add-menu-hdr'; + hdr.textContent = selCount + ' param' + (selCount > 1 ? 's' : '') + ' selected'; + menu.appendChild(hdr); + + // Delete selected + var delItem = document.createElement('div'); + delItem.className = 'lane-add-menu-item'; + delItem.textContent = '\u2716 Delete Selected'; + delItem.onclick = function (ev) { + ev.stopPropagation(); + menu.remove(); + pushUndoSnapshot(); + // Remove pids in reverse order to avoid index shifts + var toRemove = selIndices.slice().sort(function (a, c) { return c - a; }); + toRemove.forEach(function (idx) { + if (idx >= 0 && idx < lane.pids.length) { + // Also remove from all snapshot values + var removedPid = lane.pids[idx]; + lane.pids.splice(idx, 1); + if (lane.morphSnapshots) { + lane.morphSnapshots.forEach(function (s) { + if (s.values && s.values[removedPid] !== undefined) delete s.values[removedPid]; + }); + } + } + }); + lane._selectedParamIndices.clear(); + lane._selectedParamIdx = -1; + lane._highlightParam = -1; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + }; + menu.appendChild(delItem); + + // Move to lane (if other lanes exist) + if (b.lanes.length > 1) { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + + var moveHdr = document.createElement('div'); + moveHdr.className = 'lane-add-menu-hdr'; + moveHdr.textContent = '\u21C4 MOVE TO LANE'; + menu.appendChild(moveHdr); + + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi === li) continue; + var ol = b.lanes[oi]; + var oName = ol.morphMode ? 'Morph' : (ol.pids[0] ? (PMap[ol.pids[0]] ? PMap[ol.pids[0]].name : 'Lane') : 'Lane'); + var moveItem = document.createElement('div'); + moveItem.className = 'lane-add-menu-item'; + moveItem.textContent = 'L' + (oi + 1) + ': ' + oName; + moveItem.dataset.targetLane = oi; + moveItem.onclick = function (ev) { + ev.stopPropagation(); + menu.remove(); + pushUndoSnapshot(); + var tli = parseInt(this.dataset.targetLane); + var tLane = b.lanes[tli]; + if (!tLane) return; + selPids.forEach(function (pid) { + // Move pid to target lane + var idx = lane.pids.indexOf(pid); + if (idx >= 0) lane.pids.splice(idx, 1); + if (tLane.pids.indexOf(pid) < 0) tLane.pids.push(pid); + }); + lane._selectedParamIndices.clear(); + lane._selectedParamIdx = -1; + lane._highlightParam = -1; + laneDrawCanvas(b, li); + laneDrawCanvas(b, tli); + renderSingleBlock(b.id); + syncBlocksToHost(); + }; + menu.appendChild(moveItem); + } + } + + document.body.appendChild(menu); + // Clamp position + var mRect = menu.getBoundingClientRect(); + if (mRect.right > window.innerWidth - 4) menu.style.left = (window.innerWidth - mRect.width - 4) + 'px'; + if (mRect.bottom > window.innerHeight - 4) menu.style.top = (window.innerHeight - mRect.height - 4) + 'px'; + + setTimeout(function () { + document.addEventListener('mousedown', function closer(ev) { + if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', closer); } + }); + }, 10); + }); + + // Ctrl+A to select all params + if (!delegateRoot.getAttribute('tabindex')) delegateRoot.setAttribute('tabindex', '-1'); + delegateRoot.onkeydown = function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + if (!lane._selectedParamIndices) lane._selectedParamIndices = new Set(); + for (var ai = 0; ai < lane.pids.length; ai++) lane._selectedParamIndices.add(ai); + if (lane.pids.length > 0) { + lane._selectedParamIdx = lane.pids.length - 1; + lane._highlightParam = lane._selectedParamIdx; + } + _updateParamChipVisuals(); + laneDrawCanvas(b, li); + } + }; + } + } + + // Curve lane param chip click — select param for value readout + var curveParamContainer = document.getElementById('curve-params-' + b.id + '-' + li); + if (curveParamContainer && !lane.morphMode) { + curveParamContainer.addEventListener('click', function (e) { + if (e.target.classList.contains('lane-param-chip-x')) return; // don't intercept delete + var chip = e.target.closest('.lane-param-chip[data-pidx]'); + if (!chip) return; + e.stopPropagation(); + var pidx = parseInt(chip.dataset.pidx); + if (isNaN(pidx)) return; + // Toggle selection + if (lane._selectedParamIdx === pidx) { + lane._selectedParamIdx = -1; + } else { + lane._selectedParamIdx = pidx; + } + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + }); + } + + // Populate static range badges for non-selected params (curve + morph) + if (lane.pids) { + var fn = _ensureParamTextFn(); + if (fn) { + var rngPrefix = lane.morphMode ? 'mprng-' : 'cprng-'; + for (var ri = 0; ri < lane.pids.length; ri++) { + if (lane._selectedParamIdx === ri) continue; + var rngEl = document.getElementById(rngPrefix + b.id + '-' + li + '-' + ri); + if (!rngEl) continue; + var parts = lane.pids[ri].split(':'); + if (parts.length !== 2) continue; + (function (el, pId, pIdx) { + var minP = fn(pId, pIdx, 0.0); + var maxP = fn(pId, pIdx, 1.0); + Promise.all([minP, maxP]).then(function (vals) { + var minT = vals[0] || '0%', maxT = vals[1] || '100%'; + if (el) el.textContent = minT + '\u2026' + maxT; + }); + })(rngEl, parseInt(parts[0]), parseInt(parts[1])); + } + } + } +} + +// -"-"- Preset shapes menu -"-"- +// Each shape defines minimal control points directly (y: 0=bottom, 1=top in canvas coords inverted below) +var LANE_SHAPES = [ + { + name: 'Sine', pts: function () { + var p = []; for (var i = 0; i <= 16; i++) { var t = i / 16; p.push({ x: t, y: 0.5 + 0.5 * Math.sin(t * Math.PI * 2 - Math.PI / 2) }); } return p; + } + }, + { + name: 'Cosine', pts: function () { + var p = []; for (var i = 0; i <= 16; i++) { var t = i / 16; p.push({ x: t, y: 0.5 + 0.5 * Math.cos(t * Math.PI * 2) }); } return p; + } + }, + { + name: 'Triangle', pts: function () { + return [{ x: 0, y: 0 }, { x: 0.25, y: 1 }, { x: 0.75, y: 0 }, { x: 1, y: 0 }]; + } + }, + { + name: 'Saw Up', pts: function () { + return [{ x: 0, y: 0 }, { x: 1, y: 1 }]; + } + }, + { + name: 'Saw Down', pts: function () { + return [{ x: 0, y: 1 }, { x: 1, y: 0 }]; + } + }, + { + name: 'Square', pts: function () { + return [{ x: 0, y: 1 }, { x: 0.499, y: 1 }, { x: 0.5, y: 0 }, { x: 0.999, y: 0 }, { x: 1, y: 1 }]; + } + }, + { + name: 'Stairs Up', pts: function () { + return [{ x: 0, y: 0 }, { x: 0.249, y: 0 }, { x: 0.25, y: 0.33 }, { x: 0.499, y: 0.33 }, + { x: 0.5, y: 0.66 }, { x: 0.749, y: 0.66 }, { x: 0.75, y: 1 }, { x: 1, y: 1 }]; + } + }, + { + name: 'Stairs Down', pts: function () { + return [{ x: 0, y: 1 }, { x: 0.249, y: 1 }, { x: 0.25, y: 0.66 }, { x: 0.499, y: 0.66 }, + { x: 0.5, y: 0.33 }, { x: 0.749, y: 0.33 }, { x: 0.75, y: 0 }, { x: 1, y: 0 }]; + } + }, + { + name: 'Exp Rise', pts: function () { + var p = []; for (var i = 0; i <= 8; i++) { var t = i / 8; p.push({ x: t, y: Math.pow(t, 2.5) }); } return p; + } + }, + { + name: 'Exp Decay', pts: function () { + var p = []; for (var i = 0; i <= 8; i++) { var t = i / 8; p.push({ x: t, y: Math.pow(1 - t, 2.5) }); } return p; + } + }, + { + name: 'S-Curve', pts: function () { + var p = []; for (var i = 0; i <= 10; i++) { var t = i / 10; var s = t * 2 - 1; p.push({ x: t, y: 0.5 + 0.5 * Math.tanh(s * 3) / Math.tanh(3) }); } return p; + } + }, + { + name: 'Pulse', pts: function () { + return [{ x: 0, y: 0 }, { x: 0.15, y: 0 }, { x: 0.151, y: 1 }, { x: 0.35, y: 1 }, { x: 0.351, y: 0 }, + { x: 0.65, y: 0 }, { x: 0.651, y: 1 }, { x: 0.85, y: 1 }, { x: 0.851, y: 0 }, { x: 1, y: 0 }]; + } + } +]; + +function laneShowShapesMenu(b, li, anchorBtn) { + // Remove any existing shapes menu + var old = document.querySelector('.lane-shapes-menu'); + if (old) { old.remove(); return; } + + var lane = b.lanes[li]; + var gridDiv = { 'free': 32, '1/16': 16, '1/16T': 16, '1/8': 8, '1/8.': 12, '1/8T': 12, '1/4': 4, '1/4.': 6, '1/4T': 6, '1/2': 2, '1/2.': 3, '1/2T': 3, '1/1': 1, '2/1': 2, '4/1': 4 }; + var divs = gridDiv[b.laneGrid] || 8; + var numPts = Math.max(divs * 2, 16); // enough points for smooth shapes + + var menu = document.createElement('div'); + menu.className = 'lane-shapes-menu lane-add-menu'; + menu.style.position = 'absolute'; + menu.style.bottom = '100%'; + menu.style.right = '0'; + menu.style.zIndex = '200'; + menu.style.marginBottom = '2px'; + menu.style.minWidth = '120px'; + + LANE_SHAPES.forEach(function (shape) { + var item = document.createElement('div'); + item.className = 'lane-add-menu-item'; + item.textContent = shape.name; + item.onclick = function (e) { + e.stopPropagation(); + pushUndoSnapshot(); + // Invert y (canvas 0=top) and clone points + var raw = shape.pts(); + lane.pts = raw.map(function (p) { return { x: p.x, y: 1 - p.y }; }); + if (lane._sel) lane._sel.clear(); + laneDrawCanvas(b, li); + syncBlocksToHost(); + menu.remove(); + }; + menu.appendChild(item); + }); + + // Position relative to the shapes button wrapper + var wrapper = anchorBtn.closest('.lane-ft-shapes') || anchorBtn.parentElement; + wrapper.appendChild(menu); + + // Close on outside click + setTimeout(function () { + document.addEventListener('mousedown', function closer(ev) { + if (!menu.contains(ev.target)) { + menu.remove(); + document.removeEventListener('mousedown', closer); + } + }); + }, 10); +} +function laneDrawCanvas(b, li, selSet) { + var lane = b.lanes[li]; + var cvs = document.getElementById('lcv-' + b.id + '-' + li); + if (!cvs || !lane) return; + var ctx = cvs.getContext('2d'); + // Use CSS dimensions for drawing (HiDPI transform already applied) + var dpr = window.devicePixelRatio || 1; + var W = cvs.width / dpr, H = cvs.height / dpr; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + + // Read theme colors for canvas drawing + var cs = getComputedStyle(document.documentElement); + var gridColor = cs.getPropertyValue('--lane-grid').trim() || 'rgba(255,255,255,0.18)'; + var gridLabel = cs.getPropertyValue('--lane-grid-label').trim() || 'rgba(255,255,255,0.28)'; + + // -"-"- Grid: bar/beat structure -"-"- + var gridDiv = { 'free': 16, '1/16': 16, '1/16T': 16, '1/8': 8, '1/8.': 12, '1/8T': 12, '1/4': 4, '1/4.': 6, '1/4T': 6, '1/2': 2, '1/2.': 3, '1/2T': 3, '1/1': 1, '2/1': 2, '4/1': 4 }; + var divs = gridDiv[b.laneGrid] || 8; + ctx.lineWidth = 1; + + // Horizontal percentage lines (0%, 25%, 50%, 75%, 100%) + // If a curve lane has a selected param, show that param's value labels instead + var _selParamDisp = null; // holds display info when a curve param is selected + var _selPid = null; + if (!lane.morphMode && lane._selectedParamIdx != null && lane._selectedParamIdx >= 0 && lane.pids && lane.pids[lane._selectedParamIdx]) { + _selPid = lane.pids[lane._selectedParamIdx]; + var _sp = PMap[_selPid]; + if (_sp) _selParamDisp = _sp; + } + var percLabels = ['100%', '75%', '50%', '25%', '0%']; + if (_selParamDisp && _selPid) { + // Use cached axis labels if available (populated asynchronously by C++) + if (lane._cachedAxisPid === _selPid && lane._cachedAxisLabels) { + percLabels = lane._cachedAxisLabels; + } else { + // Show param name as placeholder while fetching + var dispName = _selParamDisp.name || ''; + if (dispName.length > 12) dispName = dispName.substring(0, 11) + '\u2026'; + percLabels = [dispName, '75%', '50%', '25%', '0%']; + // Fire async queries to C++ for real values at each grid position + var fn = _ensureParamTextFn(); + if (fn) { + var parts = _selPid.split(':'); + if (parts.length === 2) { + var pId = parseInt(parts[0]); + var pIdx = parseInt(parts[1]); + var queryPid = _selPid; // capture for closure + // Canvas Y: index 0=top=1.0, 1=0.75, 2=0.5, 3=0.25, 4=0.0 + var normVals = [1.0, 0.75, 0.5, 0.25, 0.0]; + var results = new Array(5); + var count = { done: 0 }; + for (var qi = 0; qi < 5; qi++) { + (function (idx, nv) { + fn(pId, pIdx, nv).then(function (txt) { + results[idx] = txt || (Math.round(nv * 100) + '%'); + count.done++; + if (count.done === 5 && lane._selectedParamIdx != null && lane.pids && lane.pids[lane._selectedParamIdx] === queryPid) { + lane._cachedAxisPid = queryPid; + lane._cachedAxisLabels = results; + laneDrawCanvas(b, li); // redraw with real labels + } + }); + })(qi, normVals[qi]); + } + } + } + } + } else { + // Clear cache when no param is selected + lane._cachedAxisPid = null; + lane._cachedAxisLabels = null; + } + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + for (var yi = 0; yi <= 4; yi++) { + var yp = yi / 4; + var yy = laneYtoCanvas(yp, H); + ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); + ctx.strokeStyle = gridColor; + if (yp === 0.5) ctx.globalAlpha = 1.0; + else if (yp === 0 || yp === 1) ctx.globalAlpha = 0.9; + else ctx.globalAlpha = 0.7; + ctx.stroke(); + ctx.globalAlpha = 1.0; + // Percentage labels on left edge + ctx.fillStyle = _selParamDisp ? 'rgba(120,180,255,0.5)' : gridLabel; + ctx.globalAlpha = 1.0; + ctx.fillText(percLabels[yi], 2, yy + (yi === 0 ? 6 : yi === 4 ? -4 : 0)); + } + + // Vertical beat/bar grid lines + var beatsPerLoop = divs; + for (var i = 0; i <= divs; i++) { + var x = (i / divs) * W; + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); + var isBoundary = (i === 0 || i === divs); + var isBar = (divs >= 4 && i % 4 === 0) || divs <= 2; + ctx.strokeStyle = gridColor; + if (isBoundary) { + ctx.globalAlpha = 1.0; + ctx.lineWidth = 2; + } else if (isBar) { + ctx.globalAlpha = 1.0; + ctx.lineWidth = 1.5; + } else { + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1; + } + ctx.stroke(); + ctx.globalAlpha = 1.0; + ctx.lineWidth = 1; + // Beat number labels at top + if (i < divs && divs <= 16) { + ctx.fillStyle = gridLabel; + ctx.textAlign = 'left'; + ctx.font = '7px Inter, sans-serif'; + ctx.fillText(String(i + 1), x + 2, 8); + } + } + + // ═══════════ MORPH LANE — draw snapshot columns ═══════════ + if (lane.morphMode) { + var snaps = lane.morphSnapshots || []; + var col = lane.color; + var r = parseInt(col.slice(1, 3), 16), g = parseInt(col.slice(3, 5), 16), bl = parseInt(col.slice(5, 7), 16); + var selIdx = lane._selectedSnap != null ? lane._selectedSnap : -1; + var selSet = lane._selectedSnaps || null; + var CURVE_LABELS = ['S', 'L', '/', ')']; + // Color palette for per-param lines — 12 distinct hues + var MORPH_PARAM_COLORS = [ + '255,100,100', '100,180,255', '100,220,140', '255,200,80', + '200,130,255', '255,140,180', '80,220,220', '220,160,100', + '160,255,160', '180,140,220', '255,180,120', '120,200,200' + ]; + + if (snaps.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = '11px Inter, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('Double-click canvas or hit Capture', W / 2, H / 2 - 8); + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.font = '9px Inter, sans-serif'; + ctx.fillText('Select params first, or capture all', W / 2, H / 2 + 8); + ctx.restore(); + return; + } + + // Draw hold zones and morph gradients between snapshots + for (var si = 0; si < snaps.length; si++) { + var snap = snaps[si]; + var xSnap = snap.position * W; + var isSel = si === selIdx || (selSet && selSet.has(si)); + + // Calculate hold zone width for this snapshot + var holdFraction = snap.hold != null ? snap.hold : 0.5; + var leftGap = si > 0 ? (snap.position - snaps[si - 1].position) * W : snap.position * W; + var rightGap = si < snaps.length - 1 ? (snaps[si + 1].position - snap.position) * W : (1 - snap.position) * W; + var holdLeft = leftGap * holdFraction * 0.5; + var holdRight = rightGap * holdFraction * 0.5; + + // Hold zone fill + var holdAlpha = isSel ? 0.35 : 0.22; + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + holdAlpha + ')'; + ctx.fillRect(xSnap - holdLeft, 0, holdLeft + holdRight, H); + + // Hold zone edge lines (draggable handles) + if (holdLeft > 2) { + ctx.beginPath(); + ctx.moveTo(xSnap - holdLeft, 0); ctx.lineTo(xSnap - holdLeft, H); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 0.6 : 0.35) + ')'; + ctx.lineWidth = isSel ? 2 : 1; + ctx.setLineDash([2, 3]); + ctx.stroke(); + ctx.setLineDash([]); + // Handle grip lines (3 short horizontal lines) + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 0.7 : 0.4) + ')'; + ctx.lineWidth = 1; + for (var gi = -1; gi <= 1; gi++) { + ctx.beginPath(); + ctx.moveTo(xSnap - holdLeft - 2, H / 2 + gi * 4); + ctx.lineTo(xSnap - holdLeft + 2, H / 2 + gi * 4); + ctx.stroke(); + } + } + if (holdRight > 2) { + ctx.beginPath(); + ctx.moveTo(xSnap + holdRight, 0); ctx.lineTo(xSnap + holdRight, H); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 0.6 : 0.35) + ')'; + ctx.lineWidth = isSel ? 2 : 1; + ctx.setLineDash([2, 3]); + ctx.stroke(); + ctx.setLineDash([]); + // Handle grip lines + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 0.7 : 0.4) + ')'; + ctx.lineWidth = 1; + for (var gi = -1; gi <= 1; gi++) { + ctx.beginPath(); + ctx.moveTo(xSnap + holdRight - 2, H / 2 + gi * 4); + ctx.lineTo(xSnap + holdRight + 2, H / 2 + gi * 4); + ctx.stroke(); + } + } + + // Hold % text (inside hold zone, near bottom) + if (holdLeft + holdRight > 18) { + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 0.55 : 0.3) + ')'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(Math.round(holdFraction * 100) + '%', xSnap, H - 14); + } + + // Snapshot column line + ctx.beginPath(); + ctx.moveTo(xSnap, 0); ctx.lineTo(xSnap, H); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 1.0 : 0.75) + ')'; + ctx.lineWidth = isSel ? 3 : 2; + ctx.stroke(); + ctx.lineWidth = 1; + + // Selected glow + if (isSel) { + ctx.beginPath(); + ctx.moveTo(xSnap, 0); ctx.lineTo(xSnap, H); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.2)'; + ctx.lineWidth = 8; + ctx.stroke(); + ctx.lineWidth = 1; + } + + // Name at top — larger, clearer + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', ' + (isSel ? 1.0 : 0.85) + ')'; + ctx.font = (isSel ? 'bold ' : '') + '10px Inter, sans-serif'; + ctx.textAlign = 'center'; + var label = snap.name || ('S' + (si + 1)); + ctx.fillText(label, xSnap, 12); + // Param count + var pCount = Object.keys(snap.values).length; + if (pCount > 0) { + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.45)'; + ctx.font = '7px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(pCount + 'p', xSnap, 22); + } + + // Per-snapshot settings indicator (D/W/S) + var sIndicators = ''; + if (snap.depth != null && snap.depth < 0.99) sIndicators += 'D'; + if (snap.warp && Math.abs(snap.warp) > 0) sIndicators += 'W'; + if (snap.steps && snap.steps >= 2) sIndicators += 'S'; + if (sIndicators) { + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.35)'; + ctx.font = '7px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(sIndicators, xSnap, H - 14); + } + + // Curve type indicator at bottom + var curveLabel = CURVE_LABELS[snap.curve || 0]; + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.4)'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(curveLabel, xSnap, H - 4); + + // Per-param value dots — cap at 16, always include selected + var paramKeys = Object.keys(snap.values); + var pCount = paramKeys.length; + var DOT_CAP = 16; + // Build visible key set: first 16, but swap in selected if needed + var selPidIdx = lane._selectedParamIdx; + var selPid = (selPidIdx != null && selPidIdx >= 0 && lane.pids) ? lane.pids[selPidIdx] : null; + var visKeys = pCount <= DOT_CAP ? paramKeys.slice() : paramKeys.slice(0, DOT_CAP); + if (selPid && visKeys.indexOf(selPid) < 0 && paramKeys.indexOf(selPid) >= 0) { + visKeys[DOT_CAP - 1] = selPid; // swap in selected + } + for (var pi = 0; pi < visKeys.length; pi++) { + var keyIdx = paramKeys.indexOf(visKeys[pi]); + var val = snap.values[visKeys[pi]]; + if (val == null) continue; + var yp = (1 - val) * H; + var pCol = MORPH_PARAM_COLORS[(keyIdx >= 0 ? keyIdx : pi) % MORPH_PARAM_COLORS.length]; + var isHi = selPid && visKeys[pi] === selPid; + var isDimmed = selPid && !isHi; + ctx.fillStyle = 'rgba(' + pCol + ', ' + (isDimmed ? 0.15 : (isSel ? 0.9 : 0.6)) + ')'; + ctx.beginPath(); + ctx.arc(xSnap, yp, isHi ? 4 : (isSel ? 3.5 : 2.5), 0, Math.PI * 2); + ctx.fill(); + } + + // Morph zone to next snapshot — with curve-shaped interpolation + if (si < snaps.length - 1) { + var nextSnap = snaps[si + 1]; + var nextHoldLeft = (nextSnap.position - snap.position) * W * ((nextSnap.hold != null ? nextSnap.hold : 0.5) * 0.5); + var morphStart = xSnap + holdRight; + var morphEnd = nextSnap.position * W - nextHoldLeft; + if (morphEnd > morphStart + 1) { + var morphW = morphEnd - morphStart; + // Background gradient + var grad = ctx.createLinearGradient(morphStart, 0, morphEnd, 0); + grad.addColorStop(0, 'rgba(' + r + ',' + g + ',' + bl + ', 0.06)'); + grad.addColorStop(0.5, 'rgba(' + r + ',' + g + ',' + bl + ', 0.015)'); + grad.addColorStop(1, 'rgba(' + r + ',' + g + ',' + bl + ', 0.06)'); + ctx.fillStyle = grad; + ctx.fillRect(morphStart, 0, morphW, H); + + // Transition curve shape (visual feedback for destination's curve type) + var curveType = nextSnap.curve || 0; + ctx.beginPath(); + ctx.moveTo(morphStart, H * 0.8); + var STEPS = 30; + for (var st = 0; st <= STEPS; st++) { + var t = st / STEPS; + var cVal; + if (curveType === 0) cVal = 0.5 - 0.5 * Math.cos(t * Math.PI); // Smooth (cosine S-curve, matches C++) + else if (curveType === 1) cVal = t; // Linear + else if (curveType === 2) cVal = t * t; // Sharp (ease-in, matches C++) + else cVal = 1 - (1 - t) * (1 - t); // Late (ease-out, matches C++) + var cx = morphStart + t * morphW; + var cy = H * 0.8 - cVal * H * 0.6; // draw in middle area + ctx.lineTo(cx, cy); + } + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.4)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Drift noise helpers — per-snapshot (matches C++ which uses snapB) + var snapDrift = (nextSnap.drift || 0) / 50; // -1..+1 + var snapDriftRng = (nextSnap.driftRange != null ? nextSnap.driftRange : 5) / 100; // 0..1 + var driftAmt = Math.abs(snapDrift); + var hasDrift = driftAmt > 0.001 && snapDriftRng > 0.001; + // Hash + hermite noise (matches C++ smoothNoise) + function _hashI(n) { + var h = (n | 0) >>> 0; + h ^= h >>> 16; h = Math.imul(h, 0x45d9f3b) >>> 0; h ^= h >>> 16; h = Math.imul(h, 0x45d9f3b) >>> 0; h ^= h >>> 16; + return ((h & 0xFFFF) / 32768.0) - 1.0; + } + function _smoothNoise(phase) { + var i0 = Math.floor(phase); + var frac = phase - i0; + var v0 = _hashI(i0 - 1), v1 = _hashI(i0), v2 = _hashI(i0 + 1), v3 = _hashI(i0 + 2); + var a = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3; + var b2 = v0 - 2.5 * v1 + 2.0 * v2 - 0.5 * v3; + var c2 = -0.5 * v0 + 0.5 * v2; + return ((a * frac + b2) * frac + c2) * frac + v1; + } + // Drift freq calculation (matches C++) + var driftBaseFreq = snapDrift > 0 ? (1 + driftAmt * 2) : (4 + driftAmt * 10); + var driftSharpness = Math.max(0, (driftAmt - 0.7) / 0.3); + // Parse drift scale beats (per-snapshot, fallback to lane-level) + var DS_BEAT_MAP_VIS = { '1/16': 0.25, '1/8': 0.5, '1/4': 1, '1/2': 2, '1/1': 4, '2/1': 8, '4/1': 16, '8/1': 32, '16/1': 64, '32/1': 128 }; + var driftScaleBeats = DS_BEAT_MAP_VIS[nextSnap.driftScale || lane.driftScale || '1/1'] || 4; + // Parse loop len beats + var LL_BEAT_MAP_VIS = { '1/16': 0.25, '1/8': 0.5, '1/4': 1, '1/2': 2, '1/1': 4, '2/1': 8, '4/1': 16, '8/1': 32, '16/1': 64, '32/1': 128, 'free': 4 }; + var loopBeatsVis = LL_BEAT_MAP_VIS[lane.loopLen || '1/1'] || 4; + var driftPhaseScale = loopBeatsVis / Math.max(0.25, driftScaleBeats); + var driftFreq = driftBaseFreq * (1 + driftSharpness * 2) * driftPhaseScale; + + // Per-snapshot destination effects + var dstDepth = nextSnap.depth != null ? nextSnap.depth : 1.0; + var dstWarp = nextSnap.warp || 0; + var dstSteps = nextSnap.steps || 0; + // Use the same capped visKeys set as dot rendering + var showKeys = visKeys.filter(function (k) { return nextSnap.values[k] !== undefined && snap.values[k] !== undefined; }); + for (var ki = 0; ki < showKeys.length; ki++) { + var vA = snap.values[showKeys[ki]]; + var vB = nextSnap.values[showKeys[ki]]; + var keyIdx = paramKeys.indexOf(showKeys[ki]); + var pCol = MORPH_PARAM_COLORS[(keyIdx >= 0 ? keyIdx : ki) % MORPH_PARAM_COLORS.length]; + var isHi = selPid && showKeys[ki] === selPid; + var isDimmed = selPid && !isHi; + // Per-param drift seed — must match C++: hashI(pluginId * 1000 + paramIndex) * 100 + var driftSeed = 0; + if (hasDrift) { + var pidParts = showKeys[ki].split(':'); + var pidId = parseInt(pidParts[0]) || 0; + var pidIdx = parseInt(pidParts[1]) || 0; + driftSeed = _hashI(pidId * 1000 + pidIdx) * 100; + } + ctx.beginPath(); + ctx.moveTo(morphStart, (1 - vA) * H); + for (var st = 1; st <= 20; st++) { + var t = st / 20; + var cVal; + if (curveType === 0) cVal = 0.5 - 0.5 * Math.cos(t * Math.PI); + else if (curveType === 1) cVal = t; + else if (curveType === 2) cVal = t * t; + else cVal = 1 - (1 - t) * (1 - t); + var interp = vA + (vB - vA) * cVal; + // Apply depth + interp = 0.5 + (interp - 0.5) * dstDepth; + // Apply warp + if (Math.abs(dstWarp) > 0.5) { + var w = dstWarp * 0.01; + if (w > 0) { + var tt = Math.tanh(w * 3 * (interp * 2 - 1)); + interp = 0.5 + 0.5 * tt / Math.tanh(w * 3); + } else { + var aw = -w; + var cen = interp * 2 - 1; + var sgn = cen >= 0 ? 1 : -1; + interp = 0.5 + 0.5 * sgn * Math.pow(Math.abs(cen), 1 / (1 + aw * 3)); + } + } + // Apply steps + if (dstSteps >= 2) { + interp = Math.round(interp * (dstSteps - 1)) / (dstSteps - 1); + } + // Apply drift noise (per-param seeded) + if (hasDrift) { + // Position in morph region mapped to playhead 0..1 + var driftPos = (snap.position + (nextSnap.position - snap.position) * t); + var dp1 = driftPos * driftFreq + driftSeed; + var dp2 = driftPos * driftFreq * 2.37 + 7.13 + driftSeed; + var dNoise = _smoothNoise(dp1) * 0.7 + _smoothNoise(dp2) * 0.3; + if (driftSharpness > 0.01) { + var dp3 = driftPos * driftFreq * 5.19 + 13.7 + driftSeed; + dNoise = dNoise * (1 - driftSharpness * 0.3) + _smoothNoise(dp3) * driftSharpness * 0.3; + } + interp = Math.max(0, Math.min(1, interp + dNoise * snapDriftRng)); + } + ctx.lineTo(morphStart + t * morphW, (1 - interp) * H); + } + ctx.strokeStyle = 'rgba(' + pCol + ', ' + (isDimmed ? 0.08 : (isHi ? 1.0 : 0.7)) + ')'; + ctx.lineWidth = isHi ? 2.5 : 1.5; + ctx.stroke(); + } + ctx.lineWidth = 1; + } + } + } + + // Curve label in morph mode + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.3)'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('MORPH', W - 4, H - 4); + + ctx.restore(); + return; + } + + // ═══════════ CURVE LANE — existing drawing code ═══════════ + var pts = lane.pts; + if (!pts || !pts.length) { + ctx.fillStyle = gridLabel; + ctx.font = '10px Inter, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('draw to shape automation curve', W / 2, H / 2); + ctx.restore(); + return; + } + + var depth = (lane.depth != null ? lane.depth : 100) / 100; + var warp = (lane.warp || 0) / 50; // -1..+1 range (from -50..+50) + var drift = (lane.drift || 0) / 50; // -1..+1 range (from -50..+50) + var driftRange = (lane.driftRange != null ? lane.driftRange : 5) / 100; // 0..0.5 + var stepsN = lane.steps || 0; + var col = lane.color; + var r = parseInt(col.slice(1, 3), 16), g = parseInt(col.slice(3, 5), 16), bl = parseInt(col.slice(5, 7), 16); + var hasEffect = (depth !== 1.0 || Math.abs(warp) > 0.001 || (Math.abs(drift) > 0.001 && driftRange > 0.001) || stepsN >= 2); + + // Center line reference when effects active + if (hasEffect) { + var midY = H * 0.5; + ctx.beginPath(); + ctx.moveTo(0, midY); ctx.lineTo(W, midY); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ', 0.4)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); + ctx.lineWidth = 1; + } + + // Evaluate raw curve y at any x (with wrapping) + function evalRawY(xPos) { + var sx = xPos - Math.floor(xPos); + if (pts.length <= 1) return pts[0].y; + if (sx <= pts[0].x) return pts[0].y; + if (sx >= pts[pts.length - 1].x) return pts[pts.length - 1].y; + for (var seg = 0; seg < pts.length - 1; seg++) { + if (sx >= pts[seg].x && sx < pts[seg + 1].x) { + var x0 = pts[seg].x, x1 = pts[seg + 1].x; + var y0 = pts[seg].y, y1 = pts[seg + 1].y; + var t = (x1 > x0) ? (sx - x0) / (x1 - x0) : 0; + if (lane.interp === 'step') return y0; + if (lane.interp === 'smooth') { var ts = t * t * (3 - 2 * t); return y0 + (y1 - y0) * ts; } + return y0 + (y1 - y0) * t; + } + } + return pts[pts.length - 1].y; + } + + // Apply depth + warp + steps to a y value + function processY(y) { + var v = 0.5 + (y - 0.5) * depth; // Depth: scale toward center + // Warp: S-curve contrast (tanh waveshaping), bipolar + if (Math.abs(warp) > 0.001) { + var centered = (v - 0.5) * 2; // -1..+1 + if (warp > 0) { + // Positive warp: compress (S-curve via tanh) + var k = 1 + warp * 8; + var shaped = Math.tanh(centered * k) / Math.tanh(k); + v = shaped * 0.5 + 0.5; + } else { + // Negative warp: expand (inverse S-curve — push extremes) + var aw = Math.abs(warp); + var sign = centered >= 0 ? 1 : -1; + var ac = Math.abs(centered); + var expanded = Math.pow(ac, 1 / (1 + aw * 3)) * sign; + v = expanded * 0.5 + 0.5; + } + } + // Steps: output quantization + if (stepsN >= 2) { + v = Math.round(v * stepsN) / stepsN; + } + return Math.max(0, Math.min(1, v)); + } + + // Determine effect X range from selection (2+ points selected → restrict effects to that range) + var selXMin = 0, selXMax = 1, hasSelRange = false; + if (lane._sel && lane._sel.size >= 2) { + selXMin = 1; selXMax = 0; + lane._sel.forEach(function (idx) { + if (pts[idx]) { + if (pts[idx].x < selXMin) selXMin = pts[idx].x; + if (pts[idx].x > selXMax) selXMax = pts[idx].x; + } + }); + if (selXMax > selXMin) hasSelRange = true; + else { selXMin = 0; selXMax = 1; } + } + + // Draw selection range highlight + if (hasSelRange) { + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ',0.07)'; + ctx.fillRect(selXMin * W, 0, (selXMax - selXMin) * W, H); + // Edge lines + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ',0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(selXMin * W, 0); ctx.lineTo(selXMin * W, H); + ctx.moveTo(selXMax * W, 0); ctx.lineTo(selXMax * W, H); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Build processed curve (resampled polyline) + var processedPts; + if (hasEffect) { + var STEPS = 256; + processedPts = []; + for (var si = 0; si <= STEPS; si++) { + var sx = si / STEPS; + var rawY = evalRawY(sx); + // If selection range active, only apply effects within that range + var py; + if (hasSelRange && (sx < selXMin || sx > selXMax)) { + py = rawY; // outside selection → no effects + } else { + py = processY(rawY); + } + processedPts.push({ x: sx, y: py }); + } + // Drift = WYSIWYG organic variation (hermite noise only) + // Sharpness above 70% = higher frequency, not discontinuous hash + // driftScale: musical period for one noise cycle (independent of loop length) + var driftAmt = Math.abs(drift); + if (driftAmt > 0.001 && driftRange > 0.001) { + // Phase scaling: drift operates on driftScale time, not loop time + var loopBeats = laneLoopBeats(lane); + var DS_BEAT_MAP = { '1/16': 0.25, '1/8': 0.5, '1/4': 1, '1/2': 2, '1/1': 4, '2/1': 8, '4/1': 16, '8/1': 32, '16/1': 64, '32/1': 128 }; + var driftScaleBeats = DS_BEAT_MAP[lane.driftScale || '1/1'] || 4; + var phaseScale = loopBeats / driftScaleBeats; // how much of the noise pattern fits in one loop + // Hash: integer → -1..+1 + var hashI = function (n) { + var h = n | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; + return ((h & 0xFFFF) / 32768.0) - 1.0; + }; + // Hermite-interpolated value noise (smooth & continuous) + var smoothNoise = function (phase) { + var i0 = Math.floor(phase); + var frac = phase - i0; + var v0 = hashI(i0 - 1), v1 = hashI(i0), v2 = hashI(i0 + 1), v3 = hashI(i0 + 2); + var a = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3; + var b2 = v0 - 2.5 * v1 + 2.0 * v2 - 0.5 * v3; + var c = -0.5 * v0 + 0.5 * v2; + return ((a * frac + b2) * frac + c) * frac + v1; + }; + // Base frequency: positive=very slow, negative=moderate jitter + var baseFreq = drift > 0 + ? (1.0 + driftAmt * 2.0) // slow: 1-3 cycles per scale period + : (4.0 + driftAmt * 10.0); // jitter: 4-14 cycles per scale period + // Above 70%: boost frequency for sharper character (up to 3x) + var sharpness = Math.max(0, (driftAmt - 0.7) / 0.3); // 0 at 70%, 1 at 100% + var freq = baseFreq * (1.0 + sharpness * 2.0) * phaseScale; + // Amplitude from driftRange + var amp = driftRange; + for (var fi = 0; fi < processedPts.length; fi++) { + if (hasSelRange && (processedPts[fi].x < selXMin || processedPts[fi].x > selXMax)) continue; + var p1 = processedPts[fi].x * freq; + var p2 = processedPts[fi].x * freq * 2.37 + 7.13; + var noise = smoothNoise(p1) * 0.7 + smoothNoise(p2) * 0.3; + // Add 3rd octave at high sharpness for extra texture + if (sharpness > 0.01) { + var p3 = processedPts[fi].x * freq * 5.19 + 13.7; + noise = noise * (1.0 - sharpness * 0.3) + smoothNoise(p3) * sharpness * 0.3; + } + processedPts[fi].y = Math.max(0, Math.min(1, processedPts[fi].y - noise * amp)); + } + } + } + + // --- Overlay: ghost of other lanes' shapes (multi-select) --- + var overlayList = lane._overlayLanes || []; + var labelY = 12; + for (var ovi = 0; ovi < overlayList.length; ovi++) { + var overlayIdx = overlayList[ovi]; + if (overlayIdx < 0 || overlayIdx >= b.lanes.length || overlayIdx === li) continue; + var olane = b.lanes[overlayIdx]; + + // Morph lane overlay: draw snapshot position markers + if (olane.morphMode) { + var oSnaps = olane.morphSnapshots || []; + if (oSnaps.length === 0) continue; + var oCol = olane.color; + var or_ = parseInt(oCol.slice(1, 3), 16); + var og = parseInt(oCol.slice(3, 5), 16); + var ob = parseInt(oCol.slice(5, 7), 16); + // Ratio for loop length scaling + var mRatio = laneLoopBeats(lane) / laneLoopBeats(olane); + for (var msi = 0; msi < oSnaps.length; msi++) { + var sPos = oSnaps[msi].position; + // Scale position by loop ratio (tile within 0..1) + var sx = sPos / mRatio; + if (sx > 1) continue; + var xPx = sx * W; + // Vertical dashed line + ctx.beginPath(); + ctx.moveTo(xPx, 0); ctx.lineTo(xPx, H); + ctx.strokeStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 4]); + ctx.stroke(); + ctx.setLineDash([]); + // Hold zone hint + var holdFrac = oSnaps[msi].hold != null ? oSnaps[msi].hold : 0.5; + var leftGap = msi > 0 ? (sPos - oSnaps[msi - 1].position) / mRatio * W : sPos / mRatio * W; + var rightGap = msi < oSnaps.length - 1 ? (oSnaps[msi + 1].position - sPos) / mRatio * W : (1 - sPos / mRatio) * W; + var hL = leftGap * holdFrac * 0.5; + var hR = rightGap * holdFrac * 0.5; + ctx.fillStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.04)'; + ctx.fillRect(xPx - hL, 0, hL + hR, H); + // Snapshot name label + ctx.font = '7px Inter, sans-serif'; + ctx.fillStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.45)'; + ctx.textAlign = 'center'; + ctx.fillText(oSnaps[msi].name || ('S' + (msi + 1)), xPx, H - 4); + } + // Overlay label + ctx.font = '8px Inter, sans-serif'; + ctx.fillStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.4)'; + ctx.textAlign = 'right'; + ctx.fillText('L' + (overlayIdx + 1) + ' morph (' + oSnaps.length + 's)', W - 4, labelY); + labelY += 10; + continue; + } + + if (!olane.pts || olane.pts.length < 2) continue; + + var oCol = olane.color; + var or_ = parseInt(oCol.slice(1, 3), 16); + var og = parseInt(oCol.slice(3, 5), 16); + var ob = parseInt(oCol.slice(5, 7), 16); + + // Scale points for different loop lengths + var ratio = laneLoopBeats(lane) / laneLoopBeats(olane); + var oPts = getOverlayPoints(olane, ratio); + var oInterp = olane.interp; + + if (oPts.length < 2) continue; + + // Apply overlayed lane's depth + warp + steps + drift to the ghost points + var oDepth = (olane.depth != null ? olane.depth : 100) / 100; + var oWarp = (olane.warp || 0) / 50; + var oSteps = olane.steps || 0; + var oDrift = (olane.drift || 0) / 50; // -1..+1 + var oDriftRange = (olane.driftRange != null ? olane.driftRange : 5) / 100; // 0..0.5 + var drawInterp = oInterp; // interpolation used for drawing + + var hasOverlayEffect = (oDepth !== 1.0 || Math.abs(oWarp) > 0.001 || oSteps >= 2 || (Math.abs(oDrift) > 0.001 && oDriftRange > 0.001)); + if (hasOverlayEffect) { + // Resample into a dense polyline so steps/depth/warp/drift are properly visible + var res = 200; + var resampled = []; + for (var ri = 0; ri <= res; ri++) { + var rx = ri / res; + // Evaluate raw curve at rx using overlay points + interpolation + var ry = oPts[0].y; + if (oPts.length > 1) { + if (rx <= oPts[0].x) { ry = oPts[0].y; } + else if (rx >= oPts[oPts.length - 1].x) { ry = oPts[oPts.length - 1].y; } + else { + for (var si = 0; si < oPts.length - 1; si++) { + if (rx >= oPts[si].x && rx < oPts[si + 1].x) { + var x0 = oPts[si].x, x1 = oPts[si + 1].x; + var y0p = oPts[si].y, y1p = oPts[si + 1].y; + var tp = (x1 > x0) ? (rx - x0) / (x1 - x0) : 0; + if (oInterp === 'step') { ry = y0p; } + else if (oInterp === 'smooth') { var tsp = tp * tp * (3 - 2 * tp); ry = y0p + (y1p - y0p) * tsp; } + else { ry = y0p + (y1p - y0p) * tp; } + break; + } + } + } + } + // Apply depth + var vy = 0.5 + (ry - 0.5) * oDepth; + // Apply warp + if (Math.abs(oWarp) > 0.001) { + var c = (vy - 0.5) * 2; + if (oWarp > 0) { + var kw = 1 + oWarp * 8; + vy = Math.tanh(c * kw) / Math.tanh(kw) * 0.5 + 0.5; + } else { + var aw = Math.abs(oWarp); + var sw = c >= 0 ? 1 : -1; + vy = sw * Math.pow(Math.abs(c), 1 / (1 + aw * 4)) * 0.5 + 0.5; + } + } + // Apply steps + if (oSteps >= 2) { + vy = Math.round(vy * oSteps) / oSteps; + } + resampled.push({ x: rx, y: Math.max(0, Math.min(1, vy)) }); + } + // Apply drift: smooth→sharp noise with driftRange amplitude + var oDriftAmt = Math.abs(oDrift); + if (oDriftAmt > 0.001 && oDriftRange > 0.001) { + var oHashI = function (n) { + var h = n | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; + return ((h & 0xFFFF) / 32768.0) - 1.0; + }; + var oSmoothNoise = function (phase) { + var i0 = Math.floor(phase); + var frac = phase - i0; + var v0 = oHashI(i0 - 1), v1 = oHashI(i0), v2 = oHashI(i0 + 1), v3 = oHashI(i0 + 2); + var a = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3; + var b2 = v0 - 2.5 * v1 + 2.0 * v2 - 0.5 * v3; + var c = -0.5 * v0 + 0.5 * v2; + return ((a * frac + b2) * frac + c) * frac + v1; + }; + var oBaseFreq = oDrift > 0 ? (1.0 + oDriftAmt * 2.0) : (4.0 + oDriftAmt * 10.0); + var oSharpness = Math.max(0, (oDriftAmt - 0.7) / 0.3); + var oFreq = oBaseFreq * (1.0 + oSharpness * 2.0); + for (var fi = 0; fi < resampled.length; fi++) { + var op1 = resampled[fi].x * oFreq; + var op2 = resampled[fi].x * oFreq * 2.37 + 7.13; + var oNoise = oSmoothNoise(op1) * 0.7 + oSmoothNoise(op2) * 0.3; + if (oSharpness > 0.01) { + var op3 = resampled[fi].x * oFreq * 5.19 + 13.7; + oNoise = oNoise * (1.0 - oSharpness * 0.3) + oSmoothNoise(op3) * oSharpness * 0.3; + } + resampled[fi].y = Math.max(0, Math.min(1, resampled[fi].y - oNoise * oDriftRange)); + } + } + oPts = resampled; + drawInterp = 'linear'; // resampled polyline — always linear + } + + // Filled ghost + ctx.beginPath(); + ctx.moveTo(oPts[0].x * W, H); + ctx.lineTo(oPts[0].x * W, laneYtoCanvas(oPts[0].y, H)); + laneTracePath(ctx, oPts, W, H, drawInterp); + ctx.lineTo(oPts[oPts.length - 1].x * W, H); + ctx.closePath(); + ctx.fillStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.06)'; + ctx.fill(); + + // Dashed stroke + ctx.beginPath(); + ctx.moveTo(oPts[0].x * W, laneYtoCanvas(oPts[0].y, H)); + laneTracePath(ctx, oPts, W, H, drawInterp); + ctx.strokeStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); + + // Label with ratio info + ctx.font = '8px Inter, sans-serif'; + ctx.fillStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.4)'; + ctx.textAlign = 'right'; + var oName = olane.pids[0] ? (PMap[olane.pids[0]] ? PMap[olane.pids[0]].name : '') : ''; + var ratioLabel = ''; + if (ratio > 1) { + ratioLabel = ' (' + olane.loopLen + ')'; + } else if (ratio < 1) { + var segCount = Math.round(1 / ratio); + var ph = olane._phPos || 0; + var segIdx = Math.min(Math.floor(ph / ratio), segCount - 1) + 1; + ratioLabel = ' (' + olane.loopLen + ' ' + segIdx + '/' + segCount + ')'; + } + ctx.fillText('L' + (overlayIdx + 1) + (oName ? ': ' + oName : '') + ratioLabel, W - 4, labelY); + labelY += 10; + + // Draw tile boundary markers when tiling + if (ratio > 1) { + var tiles = Math.ceil(ratio); + ctx.strokeStyle = 'rgba(' + or_ + ',' + og + ',' + ob + ',0.15)'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 4]); + for (var t = 1; t < tiles; t++) { + var tx = (t / ratio) * W; + ctx.beginPath(); ctx.moveTo(tx, 0); ctx.lineTo(tx, H); ctx.stroke(); + } + ctx.setLineDash([]); + } + } + + // Filled shape + var drawPts = processedPts || pts; + ctx.beginPath(); + ctx.moveTo(drawPts[0].x * W, H); + ctx.lineTo(drawPts[0].x * W, laneYtoCanvas(drawPts[0].y, H)); + if (processedPts) { + for (var di = 1; di < drawPts.length; di++) ctx.lineTo(drawPts[di].x * W, laneYtoCanvas(drawPts[di].y, H)); + } else { + laneTracePath(ctx, drawPts, W, H, lane.interp); + } + ctx.lineTo(drawPts[drawPts.length - 1].x * W, H); + ctx.closePath(); + var grd = ctx.createLinearGradient(0, 0, 0, H); + grd.addColorStop(0, 'rgba(' + r + ',' + g + ',' + bl + ',0.18)'); + grd.addColorStop(1, 'rgba(' + r + ',' + g + ',' + bl + ',0.02)'); + ctx.fillStyle = grd; + ctx.fill(); + + // Ghost of raw shape + if (hasEffect) { + ctx.beginPath(); + ctx.moveTo(pts[0].x * W, laneYtoCanvas(pts[0].y, H)); + laneTracePath(ctx, pts, W, H, lane.interp); + ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + bl + ',0.2)'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Main stroke (processed curve) + ctx.beginPath(); + if (processedPts) { + ctx.moveTo(processedPts[0].x * W, laneYtoCanvas(processedPts[0].y, H)); + for (var di = 1; di < processedPts.length; di++) ctx.lineTo(processedPts[di].x * W, laneYtoCanvas(processedPts[di].y, H)); + } else { + ctx.moveTo(pts[0].x * W, laneYtoCanvas(pts[0].y, H)); + laneTracePath(ctx, pts, W, H, lane.interp); + } + ctx.strokeStyle = col; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); + + + // Breakpoints -" edge points (first/last) drawn as bigger squares, others as circles + var sel = selSet || lane._sel; + for (var i = 0; i < pts.length; i++) { + var isSel = sel && sel.has(i); + var ppx = pts[i].x * W, ppy = laneYtoCanvas(pts[i].y, H); + var isEdgePt = (i === 0 || i === pts.length - 1); + if (isSel) { + ctx.beginPath(); ctx.arc(ppx, ppy, isEdgePt ? 12 : 8, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + bl + ',0.18)'; + ctx.fill(); + } + if (isEdgePt) { + // Bigger square for edge points (loop start/end) + var es = isSel ? 8 : 7; + ctx.fillStyle = isSel ? '#fff' : col; + ctx.fillRect(ppx - es, ppy - es, es * 2, es * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1.5; + ctx.strokeRect(ppx - es, ppy - es, es * 2, es * 2); + } else { + var rad = isSel ? 5 : 3; + ctx.beginPath(); ctx.arc(ppx, ppy, rad, 0, Math.PI * 2); + ctx.fillStyle = isSel ? '#fff' : col; + ctx.fill(); + ctx.strokeStyle = isSel ? col : 'rgba(0,0,0,0.4)'; + ctx.lineWidth = isSel ? 1.5 : 0.8; + ctx.stroke(); + } + } + ctx.restore(); + + // Cross-update: if other lanes are overlaying THIS lane, redraw them too + if (!laneDrawCanvas._redrawing) { + laneDrawCanvas._redrawing = true; + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi !== li && !b.lanes[oi].collapsed && b.lanes[oi]._overlayLanes && b.lanes[oi]._overlayLanes.indexOf(li) >= 0) { + laneDrawCanvas(b, oi); + } + } + laneDrawCanvas._redrawing = false; + } +} + +function laneTracePath(ctx, pts, W, H, interp) { + if (interp === 'smooth') { + for (var i = 0; i < pts.length - 1; i++) { + var cx = (pts[i].x * W + pts[i + 1].x * W) / 2; + ctx.bezierCurveTo(cx, laneYtoCanvas(pts[i].y, H), cx, laneYtoCanvas(pts[i + 1].y, H), pts[i + 1].x * W, laneYtoCanvas(pts[i + 1].y, H)); + } + } else if (interp === 'step') { + for (var i = 1; i < pts.length; i++) { + ctx.lineTo(pts[i].x * W, laneYtoCanvas(pts[i - 1].y, H)); + ctx.lineTo(pts[i].x * W, laneYtoCanvas(pts[i].y, H)); + } + } else { + for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x * W, laneYtoCanvas(pts[i].y, H)); + } +} + +// Rubber-band overlay +function laneDrawSelRect(b, li, x0, y0, x1, y1) { + var cvs = document.getElementById('lcv-' + b.id + '-' + li); + if (!cvs) return; + var ctx = cvs.getContext('2d'); + var dpr = window.devicePixelRatio || 1; + var W = cvs.width / dpr, H = cvs.height / dpr; + laneDrawCanvas(b, li); + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + var rx = Math.min(x0, x1) * W, ry = laneYtoCanvas(Math.min(y0, y1), H); + var rw = Math.abs(x1 - x0) * W, rh = Math.abs(y1 - y0) * (H - 2 * LANE_Y_PAD); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.strokeRect(rx, ry, rw, rh); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + ctx.fillRect(rx, ry, rw, rh); + ctx.restore(); +} + +// ---- Value tooltip for curve lane breakpoints ---- +// Shows real plugin parameter values near hovered/dragged points. +var _getParamTextFn = null; // lazy ref to native function +function _ensureParamTextFn() { + if (!_getParamTextFn && window.__JUCE__ && window.__JUCE__.backend) { + _getParamTextFn = window.__juceGetNativeFunction('getParamTextForValue'); + } + return _getParamTextFn; +} + +function _laneShowTip(wrapId, xPx, yPx, text) { + var wrap = document.getElementById(wrapId); + if (!wrap) return; + var tip = wrap.querySelector('.lane-value-tip'); + if (!tip) { + tip = document.createElement('div'); + tip.className = 'lane-value-tip'; + wrap.appendChild(tip); + } + tip.textContent = text; + // Measure tooltip size for proper clamping + var tipW = tip.offsetWidth || 60; + var tipH = tip.offsetHeight || 18; + var wW = wrap.clientWidth, wH = wrap.clientHeight; + // X: keep fully inside the wrapper + var tx = Math.max(2, Math.min(xPx - tipW / 2, wW - tipW - 2)); + // Y: prefer above the dot, but shift below if near top edge + var ty = yPx - tipH - 4; + if (ty < 2) ty = yPx + 8; + if (ty + tipH > wH - 2) ty = wH - tipH - 2; + tip.style.left = tx + 'px'; + tip.style.top = ty + 'px'; + tip.style.display = ''; +} +function _laneHideTip(wrapId) { + var wrap = document.getElementById(wrapId); + if (!wrap) return; + var tip = wrap.querySelector('.lane-value-tip'); + if (tip) tip.style.display = 'none'; +} + +// Show tooltip with real plugin value. Never blinks during drag: +// if tooltip is already visible and we miss cache, keep current text +// and only update position; update text when C++ responds. +var _tipTextCache = {}; // key: "pid:quantizedNormVal" → display text +function _laneShowTipWithValue(wrapId, xPx, yPx, lane, yNorm) { + var rawVal = 1 - yNorm; // canvas y: 0=top=100%, 1=bottom=0% + // Apply depth + warp processing so tooltip matches actual output + var depth = (lane.depth != null ? lane.depth : 100) / 100; + var warp = (lane.warp || 0) / 50; // -1..+1 + var normVal = 0.5 + (rawVal - 0.5) * depth; + if (Math.abs(warp) > 0.001) { + var centered = (normVal - 0.5) * 2; + if (warp > 0) { + var wk = 1 + warp * 8; + normVal = Math.tanh(centered * wk) / Math.tanh(wk) * 0.5 + 0.5; + } else { + var aw = Math.abs(warp); + var sign = centered >= 0 ? 1 : -1; + normVal = Math.pow(Math.abs(centered), 1 / (1 + aw * 3)) * sign * 0.5 + 0.5; + } + } + normVal = Math.max(0, Math.min(1, normVal)); + var paramName = ''; + var pid = null; + + // Determine which param to query + if (lane._selectedParamIdx != null && lane._selectedParamIdx >= 0 && lane.pids && lane.pids[lane._selectedParamIdx]) { + pid = lane.pids[lane._selectedParamIdx]; + } else if (lane.pids && lane.pids.length === 1) { + pid = lane.pids[0]; + } + + if (pid) { + var sp = PMap[pid]; + if (sp) { + paramName = sp.name || ''; + if (paramName.length > 12) paramName = paramName.substring(0, 11) + '\u2026'; + } + } + + // Quantize to 0.5% steps for cache key (200 unique values per param) + var qVal = Math.round(normVal * 200) / 200; + var cacheKey = pid ? pid + ':' + qVal.toFixed(3) : ''; + var cachedText = cacheKey ? _tipTextCache[cacheKey] : null; + + // Build display text: prefer cache, else percentage + var pctText = Math.round(normVal * 100) + '%'; + var displayText = cachedText + ? (paramName ? paramName + ': ' + cachedText : cachedText) + : (paramName ? paramName + ': ' + pctText : pctText); + + // Always use _laneShowTip for consistent positioning (no flicker) + _laneShowTip(wrapId, xPx, yPx, displayText); + + // Fire async request to C++ for real display text (populates cache for next frame) + if (pid && !cachedText) { + var fn = _ensureParamTextFn(); + if (fn) { + var parts = pid.split(':'); + if (parts.length === 2) { + var pluginId = parseInt(parts[0]); + var paramIndex = parseInt(parts[1]); + var _ck = cacheKey; + var _wrapId = wrapId; + var _pName = paramName; + fn(pluginId, paramIndex, normVal).then(function (realText) { + if (realText) { + _tipTextCache[_ck] = realText; + // Update tip if still visible + var w = document.getElementById(_wrapId); + if (w) { + var t = w.querySelector('.lane-value-tip'); + if (t && t.style.display !== 'none') { + t.textContent = _pName ? _pName + ': ' + realText : realText; + } + } + } + }); + } + } + } +} + +function laneSetupMouse(b, li) { + var cvs = document.getElementById('lcv-' + b.id + '-' + li); + if (!cvs) return; + var lane = b.lanes[li]; + if (!lane) return; + if (lane.morphMode) { + // ═══════════ MORPH LANE MOUSE ═══════════ + var dpr = window.devicePixelRatio || 1; + var SNAP_HIT = 12; + + function morphPos(e) { + var rect = cvs.getBoundingClientRect(); + return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height }; + } + + function findSnapAt(xNorm) { + var snaps = lane.morphSnapshots || []; + var W = cvs.width / dpr; + for (var i = 0; i < snaps.length; i++) { + if (Math.abs(snaps[i].position * W - xNorm * W) < SNAP_HIT) return i; + } + return -1; + } + + // Inline label editing + function startInlineEdit(sIdx) { + var snap = lane.morphSnapshots[sIdx]; + if (!snap) return; + var wrap = cvs.parentElement; + var xPx = snap.position * wrap.clientWidth; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.value = snap.name || ''; + inp.className = 'morph-inline-edit'; + inp.style.cssText = 'position:absolute;left:' + Math.max(0, xPx - 30) + 'px;top:2px;width:60px;'; + wrap.appendChild(inp); + inp.focus(); + inp.select(); + function commit() { + snap.name = inp.value || snap.name; + if (inp.parentElement) inp.remove(); + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } + inp.onkeydown = function (ke) { if (ke.key === 'Enter') commit(); if (ke.key === 'Escape') { inp.remove(); } }; + inp.onblur = commit; + } + + // Drag state + var dragSnap = -1, dragHoldSnap = -1, didDrag = false; + var HOLD_HIT = 6; + + // Find if click is near a hold zone edge \u2014 returns { snapIdx, side } or null + function findHoldHandleAt(xNorm) { + var snaps = lane.morphSnapshots || []; + var W = cvs.width / dpr; + var xPx = xNorm * W; + for (var i = 0; i < snaps.length; i++) { + var snap = snaps[i]; + var xSnap = snap.position * W; + var hold = snap.hold != null ? snap.hold : 0.5; + var leftGap = i > 0 ? (snap.position - snaps[i - 1].position) * W : snap.position * W; + var rightGap = i < snaps.length - 1 ? (snaps[i + 1].position - snap.position) * W : (1 - snap.position) * W; + var holdLeftEdge = xSnap - leftGap * hold * 0.5; + var holdRightEdge = xSnap + rightGap * hold * 0.5; + if (Math.abs(xPx - holdLeftEdge) < HOLD_HIT && leftGap > 4) return { snapIdx: i, side: 'left' }; + if (Math.abs(xPx - holdRightEdge) < HOLD_HIT && rightGap > 4) return { snapIdx: i, side: 'right' }; + } + return null; + } + + cvs.onmousedown = function (e) { + if (e.button === 2) return; + e.preventDefault(); + var p = morphPos(e); + didDrag = false; + + // Priority 1: Hold handle drag + var holdHit = findHoldHandleAt(p.x); + if (holdHit) { + dragHoldSnap = holdHit.snapIdx; + // If the hold handle is on a selected snapshot, keep multi-select; otherwise select just this one + if (!lane._selectedSnaps) lane._selectedSnaps = new Set(); + if (!lane._selectedSnaps.has(holdHit.snapIdx)) { + lane._selectedSnaps.clear(); + lane._selectedSnaps.add(holdHit.snapIdx); + } + lane._selectedSnap = holdHit.snapIdx; + pushUndoSnapshot(); + laneDrawCanvas(b, li); + var holdSide = holdHit.side; + + var onMoveHold = function (ev) { + didDrag = true; + var rect = cvs.getBoundingClientRect(); + var W = cvs.width / dpr; + var mx = (ev.clientX - rect.left) / rect.width; + var snap = lane.morphSnapshots[dragHoldSnap]; + if (!snap) return; + var xSnap = snap.position; + var leftGap = dragHoldSnap > 0 ? (xSnap - lane.morphSnapshots[dragHoldSnap - 1].position) : xSnap; + var rightGap = dragHoldSnap < lane.morphSnapshots.length - 1 ? (lane.morphSnapshots[dragHoldSnap + 1].position - xSnap) : (1 - xSnap); + // Calculate new hold from edge position + var newHold; + if (holdSide === 'left' && leftGap > 0.001) { + var edgeDist = (xSnap - mx); + newHold = Math.max(0, Math.min(1, (edgeDist / (leftGap * 0.5)))); + } else if (holdSide === 'right' && rightGap > 0.001) { + var edgeDist = (mx - xSnap); + newHold = Math.max(0, Math.min(1, (edgeDist / (rightGap * 0.5)))); + } + if (newHold !== undefined) { + // Apply to ALL selected snapshots + if (lane._selectedSnaps && lane._selectedSnaps.size > 0) { + lane._selectedSnaps.forEach(function (si) { + if (lane.morphSnapshots[si]) lane.morphSnapshots[si].hold = newHold; + }); + } else { + snap.hold = newHold; + } + laneDrawCanvas(b, li); + } + }; + var onUpHold = function () { + document.removeEventListener('mousemove', onMoveHold); + document.removeEventListener('mouseup', onUpHold); + dragHoldSnap = -1; + if (didDrag) syncBlocksToHost(); + renderSingleBlock(b.id); + }; + document.addEventListener('mousemove', onMoveHold); + document.addEventListener('mouseup', onUpHold); + return; + } + + // Priority 2: Snap column drag + var sIdx = findSnapAt(p.x); + if (sIdx >= 0) { + dragSnap = sIdx; + if (!lane._selectedSnaps) lane._selectedSnaps = new Set(); + if (e.ctrlKey || e.metaKey) { + if (lane._selectedSnaps.has(sIdx)) { + lane._selectedSnaps.delete(sIdx); + if (lane._selectedSnaps.size > 0) { + var arr = Array.from(lane._selectedSnaps); + lane._selectedSnap = arr[arr.length - 1]; + } else { + lane._selectedSnap = -1; + } + } else { + lane._selectedSnaps.add(sIdx); + lane._selectedSnap = sIdx; + } + } else { + lane._selectedSnaps.clear(); + lane._selectedSnaps.add(sIdx); + lane._selectedSnap = sIdx; + } + pushUndoSnapshot(); + laneDrawCanvas(b, li); + // NOTE: do NOT call renderSingleBlock here — it destroys the + // canvas and kills our drag closure. Sidebar/footer update on mouseup. + } else { + lane._selectedSnap = -1; + if (lane._selectedSnaps) lane._selectedSnaps.clear(); + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + return; + } + + var onMove = function (ev) { + didDrag = true; + var rect = cvs.getBoundingClientRect(); + var mx = (ev.clientX - rect.left) / rect.width; + var snaps = lane.morphSnapshots; + var newPos = Math.max(0, Math.min(1, mx)); + if (snaps.length > 1) { + if (dragSnap === 0) newPos = 0; + else if (dragSnap === snaps.length - 1) newPos = 1; + else newPos = Math.max(snaps[dragSnap - 1].position + 0.01, Math.min(snaps[dragSnap + 1].position - 0.01, newPos)); + } + snaps[dragSnap].position = newPos; + laneDrawCanvas(b, li); + }; + var onUp = function () { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (didDrag) syncBlocksToHost(); + dragSnap = -1; + renderSingleBlock(b.id); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + // Cursor + cvs.onmousemove = function (e) { + if (dragSnap >= 0) { cvs.style.cursor = 'grabbing'; return; } + if (dragHoldSnap >= 0) { cvs.style.cursor = 'ew-resize'; return; } + var p = morphPos(e); + if (findHoldHandleAt(p.x)) { cvs.style.cursor = 'ew-resize'; } + else if (findSnapAt(p.x) >= 0) { cvs.style.cursor = 'grab'; } + else { cvs.style.cursor = 'crosshair'; } + }; + + // Double-click: edit label on snap, or add snapshot on empty + cvs.ondblclick = function (e) { + e.preventDefault(); + var p = morphPos(e); + var sIdx = findSnapAt(p.x); + if (sIdx >= 0) { + startInlineEdit(sIdx); + } else { + // Add snapshot at click position + pushUndoSnapshot(); + if (!lane.morphSnapshots) lane.morphSnapshots = []; + var vals = {}; + (lane.pids || []).forEach(function (pid) { + var pp = PMap[pid]; + if (pp && !pp.lk) vals[pid] = pp.v; + }); + var newSnap = { position: p.x, hold: 0.5, curve: 0, name: 'S' + (lane.morphSnapshots.length + 1), source: '', values: vals }; + lane.morphSnapshots.push(newSnap); + lane.morphSnapshots.sort(function (a, bb) { return a.position - bb.position; }); + for (var ni = 0; ni < lane.morphSnapshots.length; ni++) { + if (lane.morphSnapshots[ni] === newSnap) { lane._selectedSnap = ni; break; } + } + laneDrawCanvas(b, li); + renderSingleBlock(b.id); + syncBlocksToHost(); + } + }; + + // Right-click context menu + cvs.oncontextmenu = function (e) { + e.preventDefault(); + e.stopPropagation(); + var p = morphPos(e); + var sIdx = findSnapAt(p.x); + var old = document.querySelector('.morph-ctx-menu'); + if (old) old.remove(); + if (sIdx < 0) return; + var snap = lane.morphSnapshots[sIdx]; + lane._selectedSnap = sIdx; + laneDrawCanvas(b, li); + + var menu = document.createElement('div'); + menu.className = 'morph-ctx-menu lane-add-menu'; + menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;z-index:9999;min-width:130px;'; + + var items = [ + { label: 'Duplicate', key: 'Ctrl+D', action: function () { _morphSnapDuplicate(b, li, lane, sIdx); } }, + { label: 'Rename', action: function () { startInlineEdit(sIdx); } }, + { label: 'Recapture', action: function () { pushUndoSnapshot(); (lane.pids || []).forEach(function (pid) { var pp = PMap[pid]; if (pp && !pp.lk) snap.values[pid] = pp.v; }); laneDrawCanvas(b, li); syncBlocksToHost(); } }, + { label: '---' }, + { label: 'Smooth' + (snap.curve === 0 ? ' \u2713' : ''), action: function () { snap.curve = 0; laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); } }, + { label: 'Linear' + (snap.curve === 1 ? ' \u2713' : ''), action: function () { snap.curve = 1; laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); } }, + { label: 'Sharp' + (snap.curve === 2 ? ' \u2713' : ''), action: function () { snap.curve = 2; laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); } }, + { label: 'Late' + (snap.curve === 3 ? ' \u2713' : ''), action: function () { snap.curve = 3; laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); } }, + { label: '---' }, + { label: 'Delete', action: function () { pushUndoSnapshot(); lane.morphSnapshots.splice(sIdx, 1); if (lane.morphSnapshots.length > 1) { lane.morphSnapshots[0].position = 0; lane.morphSnapshots[lane.morphSnapshots.length - 1].position = 1; } else if (lane.morphSnapshots.length === 1) { lane.morphSnapshots[0].position = 0; } lane._selectedSnap = -1; laneDrawCanvas(b, li); renderSingleBlock(b.id); syncBlocksToHost(); } } + ]; + + items.forEach(function (item) { + if (item.label === '---') { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + return; + } + var el = document.createElement('div'); + el.className = 'lane-add-menu-item'; + el.textContent = item.label; + el.onclick = function (ev) { ev.stopPropagation(); menu.remove(); item.action(); }; + menu.appendChild(el); + }); + + document.body.appendChild(menu); + setTimeout(function () { + var dismiss = function (de) { if (!menu.contains(de.target)) { menu.remove(); document.removeEventListener('mousedown', dismiss); } }; + document.addEventListener('mousedown', dismiss); + }, 10); + }; + + return; + } + // ═══════════ CURVE LANE MOUSE (existing) ═══════════ + if (!lane._sel) lane._sel = new Set(); + var active = false, rafPending = false; + var selDrag = null; + var _tipWrapId = 'lcw-' + b.id + '-' + li; + + // Edge points: first and last in the sorted array -" cannot be deleted or moved horizontally + function isEdge(idx) { + if (!lane.pts[idx]) return false; + return idx === 0 || idx === lane.pts.length - 1; + } + + function posRaw(e) { + var r = cvs.getBoundingClientRect(); + var pyPx = e.clientY - r.top; + var H = r.height; + return { + x: Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), + y: laneCanvasToY(pyPx, H) + }; + } + function getStep() { + if (b.laneGrid === 'free') return 0; + var parts = b.laneGrid.split('/'); + return parts.length === 2 ? Number(parts[0]) / Number(parts[1]) : 1; + } + function snap(p) { + var step = getStep(); + if (!step) return p; + return { x: Math.max(0, Math.min(1, Math.round(p.x / step) * step)), y: p.y }; + } + // Hard snap: lock x to nearest grid line + function softSnap(x) { + var step = getStep(); + if (!step) return x; // Free mode: no snap + return Math.round(x / step) * step; + } + function schedDraw() { + if (rafPending) return; + rafPending = true; + requestAnimationFrame(function () { rafPending = false; laneDrawCanvas(b, li); }); + } + function findNearest(p, radius) { + var best = -1, bestD = radius * radius; + for (var i = 0; i < lane.pts.length; i++) { + // Edge points (first/last) get 3x larger hit area for easy grabbing + var r2 = isEdge(i) ? (radius * 3) * (radius * 3) : radius * radius; + var dx = lane.pts[i].x - p.x, dy = lane.pts[i].y - p.y; + var d = dx * dx + dy * dy; + if (d < r2 && d < bestD) { bestD = d; best = i; } + } + return best; + } + + cvs.onmousedown = function (e) { + if (e.button === 2) return; // right-click handled by contextmenu + e.preventDefault(); e.stopPropagation(); + active = true; + pushUndoSnapshot(); + var pr = posRaw(e), ps = snap(pr); + + // Bind drag handlers to DOCUMENT so dragging outside canvas still works + document.addEventListener('mousemove', docMoveHandler); + document.addEventListener('mouseup', docUpHandler); + + if (b.laneTool === 'draw') { + var hit = findNearest(pr, 0.035); + if (hit >= 0) { + // capture before modifying _sel + var wasAlready = lane._sel.has(hit); + // Ctrl+click: toggle selection without clearing others + if (e.ctrlKey || e.metaKey) { + if (lane._sel.has(hit)) lane._sel.delete(hit); else lane._sel.add(hit); + } else if (!lane._sel.has(hit)) { + lane._sel.clear(); lane._sel.add(hit); + } + var anch = []; + lane._sel.forEach(function (idx) { + if (lane.pts[idx]) anch.push({ idx: idx, ox: lane.pts[idx].x, oy: lane.pts[idx].y }); + }); + selDrag = { + mode: 'grab', startX: pr.x, startY: pr.y, anchors: anch, hitIdx: hit, moved: false, + clickedHit: hit, wasAlreadySel: wasAlready + }; + } else { + lanePutPt(lane, ps, getStep()); + selDrag = { mode: 'draw', lastX: ps.x }; + } + schedDraw(); + } else if (b.laneTool === 'erase') { + lane.pts = lane.pts.filter(function (pt, i) { + if (isEdge(i)) return true; + return Math.abs(pt.x - pr.x) > 0.04 || Math.abs(pt.y - pr.y) > 0.15; + }); + lane._sel.clear(); + schedDraw(); + selDrag = { mode: 'erase' }; + } else if (b.laneTool === 'select') { + var hit = findNearest(pr, 0.035); + if (hit >= 0) { + var wasAlready = lane._sel.has(hit); + if (e.ctrlKey || e.metaKey) { + if (lane._sel.has(hit)) lane._sel.delete(hit); else lane._sel.add(hit); + } else if (!lane._sel.has(hit)) { + lane._sel.clear(); lane._sel.add(hit); + } + var anchors = []; + lane._sel.forEach(function (idx) { + if (lane.pts[idx]) anchors.push({ idx: idx, ox: lane.pts[idx].x, oy: lane.pts[idx].y }); + }); + selDrag = { mode: 'move', startX: pr.x, startY: pr.y, anchors: anchors, moved: false, clickedHit: hit, wasAlreadySel: wasAlready }; + } else { + if (!e.ctrlKey && !e.metaKey) lane._sel.clear(); + selDrag = { mode: 'box', x0: pr.x, y0: pr.y }; + } + schedDraw(); + } + }; + + // --- Document-level drag handlers (fix: drag outside canvas no longer drops) --- + function docMoveHandler(e) { + if (!active || !selDrag) return; + var pr = posRaw(e), ps = snap(pr); + + if (selDrag.mode === 'draw') { + var minD = b.laneGrid === 'free' ? 0.004 : 0.001; + if (Math.abs(ps.x - selDrag.lastX) >= minD) { + lanePutPt(lane, ps, getStep()); + selDrag.lastX = ps.x; + schedDraw(); + } + } else if (selDrag.mode === 'grab' || selDrag.mode === 'move') { + var dx = pr.x - selDrag.startX, dy = pr.y - selDrag.startY; + if (Math.abs(dx) > 0.002 || Math.abs(dy) > 0.002) selDrag.moved = true; + // Shift-constrain: lock to dominant axis + if (e.shiftKey && selDrag.moved) { + if (Math.abs(dx) > Math.abs(dy)) { dy = 0; } else { dx = 0; } + } + for (var i = 0; i < selDrag.anchors.length; i++) { + var a = selDrag.anchors[i]; + if (lane.pts[a.idx]) { + if (isEdge(a.idx)) { + lane.pts[a.idx].y = Math.max(0, Math.min(1, a.oy + dy)); + } else { + var rawX = Math.max(0, Math.min(1, a.ox + dx)); + // Clamp X between neighboring non-selected points + var prevX = 0, nextX = 1; + for (var pi = a.idx - 1; pi >= 0; pi--) { + if (!lane._sel.has(pi)) { prevX = lane.pts[pi].x + 0.001; break; } + } + for (var ni = a.idx + 1; ni < lane.pts.length; ni++) { + if (!lane._sel.has(ni)) { nextX = lane.pts[ni].x - 0.001; break; } + } + rawX = Math.max(prevX, Math.min(nextX, rawX)); + lane.pts[a.idx].x = softSnap(rawX); + lane.pts[a.idx].y = Math.max(0, Math.min(1, a.oy + dy)); + } + } + } + // Show value tooltip on the first dragged point + if (selDrag.anchors.length > 0) { + var _da = selDrag.anchors[0]; + var _dp = lane.pts[_da.idx]; + if (_dp) { + var _wrap = document.getElementById(_tipWrapId); + if (_wrap) { + _laneShowTipWithValue(_tipWrapId, _dp.x * _wrap.clientWidth, laneYtoCanvas(_dp.y, _wrap.clientHeight || LANE_CANVAS_H), lane, _dp.y); + } + } + } + schedDraw(); + } else if (selDrag.mode === 'erase') { + lane.pts = lane.pts.filter(function (pt, i) { + if (isEdge(i)) return true; + return Math.abs(pt.x - pr.x) > 0.04 || Math.abs(pt.y - pr.y) > 0.15; + }); + lane._sel.clear(); + schedDraw(); + } else if (selDrag.mode === 'box') { + if (!rafPending) { + rafPending = true; + var x0 = selDrag.x0, y0 = selDrag.y0, x1 = pr.x, y1 = pr.y; + requestAnimationFrame(function () { + rafPending = false; + var minX = Math.min(x0, x1), maxX = Math.max(x0, x1); + var minY = Math.min(y0, y1), maxY = Math.max(y0, y1); + lane._sel.clear(); + for (var i = 0; i < lane.pts.length; i++) { + if (lane.pts[i].x >= minX && lane.pts[i].x <= maxX && lane.pts[i].y >= minY && lane.pts[i].y <= maxY) { + lane._sel.add(i); + } + } + laneDrawSelRect(b, li, x0, y0, x1, y1); + }); + } + } + } + + function mouseUp() { + if (!active) return; + active = false; + _laneHideTip(_tipWrapId); + document.removeEventListener('mousemove', docMoveHandler); + document.removeEventListener('mouseup', docUpHandler); + if (selDrag && (selDrag.mode === 'move' || selDrag.mode === 'grab') && selDrag.moved) { + laneResortWithSel(lane); + } + // Deselect dot if it was already selected and we didn't drag (toggle behavior) + if (selDrag && (selDrag.mode === 'move' || selDrag.mode === 'grab') && !selDrag.moved && selDrag.clickedHit >= 0) { + if (selDrag.wasAlreadySel && lane._sel.size > 0) { + lane._sel.delete(selDrag.clickedHit); + } + } + selDrag = null; + schedDraw(); + if (laneSetupMouse._syncTimer) cancelAnimationFrame(laneSetupMouse._syncTimer); + laneSetupMouse._syncTimer = requestAnimationFrame(function () { laneSetupMouse._syncTimer = null; syncBlocksToHost(); }); + } + function docUpHandler() { mouseUp(); } + + cvs.onmouseup = mouseUp; + cvs.onmouseleave = function (e) { + // Don't drop the drag - document-level handlers keep working + if (!active) { + cvs.style.cursor = 'crosshair'; + _laneHideTip(_tipWrapId); + } + }; + + // Hover cursor: indicate grabbable dots + show value tooltip + var hoverHandler = function (e) { + if (active) return; + if (b.laneTool === 'erase') { cvs.style.cursor = 'crosshair'; _laneHideTip(_tipWrapId); return; } + var pr = posRaw(e); + var hit = findNearest(pr, 0.025); + if (hit >= 0) { + cvs.style.cursor = isEdge(hit) ? 'ns-resize' : 'grab'; + // Show value tooltip near the point + var pt = lane.pts[hit]; + if (pt) { + var _wrap = document.getElementById(_tipWrapId); + if (_wrap) { + _laneShowTipWithValue(_tipWrapId, pt.x * _wrap.clientWidth, laneYtoCanvas(pt.y, _wrap.clientHeight || LANE_CANVAS_H), lane, pt.y); + } + } + } else { + cvs.style.cursor = 'crosshair'; + _laneHideTip(_tipWrapId); + } + }; + cvs.addEventListener('mousemove', hoverHandler); + + // Double-click: add single breakpoint + cvs.ondblclick = function (e) { + e.preventDefault(); e.stopPropagation(); + var ps = snap(posRaw(e)); + pushUndoSnapshot(); + lanePutPt(lane, ps, getStep()); + lane._sel.clear(); + schedDraw(); + }; + + // Right-click context menu on points + cvs.oncontextmenu = function (e) { + e.preventDefault(); e.stopPropagation(); + var pr = posRaw(e); + var hit = findNearest(pr, 0.025); + if (hit < 0) return; + // Select the right-clicked point if not already selected + if (!lane._sel.has(hit)) { lane._sel.clear(); lane._sel.add(hit); } + schedDraw(); + laneShowCtxMenu(b, li, lane, e.clientX, e.clientY); + }; + + // Shift + scroll wheel: adjust depth (plain scroll passes through for page scrolling) + // Use the wrapper div (not canvas) so events fire without needing prior click focus + var wrapEl = cvs.parentElement; + if (wrapEl) wrapEl.addEventListener('wheel', function (e) { + if (!e.shiftKey) return; // let normal scroll pass through + e.preventDefault(); + var delta = e.deltaY > 0 ? -5 : 5; + lane.depth = Math.max(0, Math.min(200, (lane.depth != null ? lane.depth : 100) + delta)); + schedDraw(); + // Cross-update overlaying lanes + for (var ci = 0; ci < b.lanes.length; ci++) { + if (ci === li) continue; + var cl = b.lanes[ci]; + if (cl._overlayLanes && cl._overlayLanes.indexOf(li) >= 0) { + laneDrawCanvas(b, ci); + } + } + // Update footer knob display if present + var ftEl = cvs.closest('.lane-item'); + if (ftEl) { + var depthKnob = ftEl.querySelector('[data-lk="depth"]'); + if (depthKnob) depthKnob.textContent = 'Depth ' + Math.round(lane.depth) + '%'; + } + + syncBlocksToHost(); + }, { passive: false }); +} +laneSetupMouse._syncTimer = null; + +// Re-sort points and remap _sel indices after a move +function laneResortWithSel(lane) { + var tagged = lane.pts.map(function (p, i) { return { x: p.x, y: p.y, wasSel: lane._sel.has(i), isEdge: (p.x < 0.01 || p.x > 0.99) }; }); + tagged.sort(function (a, bb) { return a.x - bb.x; }); + lane._sel.clear(); + lane.pts = []; + for (var i = 0; i < tagged.length; i++) { + var px = tagged[i].isEdge ? tagged[i].x : tagged[i].x; // edge points keep their x + lane.pts.push({ x: px, y: tagged[i].y }); + if (tagged[i].wasSel) lane._sel.add(i); + } +} + +// Generate a random automation shape for a lane +function laneRandomize(lane, gridMode) { + var pts = []; + var startY = Math.random(); + var endY = Math.random(); + pts.push({ x: 0, y: startY }); + + if (gridMode === 'free') { + // Free mode: 6-12 random breakpoints + var count = 6 + Math.floor(Math.random() * 7); + for (var i = 0; i < count; i++) { + var x = 0.05 + Math.random() * 0.9; // avoid edges + pts.push({ x: x, y: Math.random() }); + } + } else { + // Grid mode: place breakpoints at grid positions + var parts = gridMode.split('/'); + var step = parts.length === 2 ? Number(parts[0]) / Number(parts[1]) : 1; + var divisions = Math.round(1 / step); + for (var i = 1; i < divisions; i++) { + var x = i * step; + // Skip some positions for variety (30% chance to skip) + if (divisions > 4 && Math.random() < 0.3) continue; + pts.push({ x: x, y: Math.random() }); + } + } + + pts.push({ x: 1, y: endY }); + pts.sort(function (a, bb) { return a.x - bb.x; }); + lane.pts = pts; +} + +function lanePutPt(lane, pt, gridStep) { + // Drawing near an edge point updates its y value directly + if (pt.x < 0.02 && lane.pts.length && lane.pts[0].x < 0.01) { + lane.pts[0].y = pt.y; + return; + } + var last = lane.pts.length - 1; + if (pt.x > 0.98 && last >= 0 && lane.pts[last].x > 0.99) { + lane.pts[last].y = pt.y; + return; + } + // Grid-aware removal: half the grid step, or 0.004 for free + var clearRadius = gridStep ? gridStep * 0.49 : 0.004; + // Protect edge points, remove points within the clear zone + lane.pts = lane.pts.filter(function (p) { + if (p.x < 0.01 || p.x > 0.99) return true; // never remove edge points + return Math.abs(p.x - pt.x) > clearRadius; + }); + lane.pts.push({ x: pt.x, y: pt.y }); + lane.pts.sort(function (a, bb) { return a.x - bb.x; }); +} + +// Right-click context menu for lane points +function laneShowCtxMenu(b, li, lane, cx, cy) { + // Remove any existing context menu + var old = document.querySelector('.lane-ctx-menu'); + if (old) old.remove(); + + var menu = document.createElement('div'); + menu.className = 'lane-ctx-menu lane-add-menu'; + var menuW = 140, menuH = 200; // estimated + var vw = window.innerWidth, vh = window.innerHeight; + var posLeft = cx, posTop = cy; + if (posLeft + menuW > vw - 4) posLeft = vw - menuW - 4; + if (posLeft < 4) posLeft = 4; + if (posTop + menuH > vh - 4) posTop = Math.max(4, cy - menuH); + menu.style.cssText = 'position:fixed;left:' + posLeft + 'px;top:' + posTop + 'px;z-index:9999;min-width:' + menuW + 'px;'; + + var items = [ + { + label: 'Delete', key: 'Del', action: function () { + pushUndoSnapshot(); + lane.pts = lane.pts.filter(function (p, i) { + if (p.x < 0.01 || p.x > 0.99) return true; + return !lane._sel.has(i); + }); + lane._sel.clear(); + } + }, + { + label: 'Duplicate', key: 'Ctrl+D', action: function () { + pushUndoSnapshot(); + _laneShapeDuplicate(b, li, lane); + } + }, + { label: '---' }, + { + label: 'Snap to Grid', action: function () { + pushUndoSnapshot(); + var gridParts = b.laneGrid === 'free' ? null : b.laneGrid.split('/'); + var step = gridParts && gridParts.length === 2 ? Number(gridParts[0]) / Number(gridParts[1]) : 0; + if (!step) return; + lane._sel.forEach(function (idx) { + if (lane.pts[idx] && idx > 0 && idx < lane.pts.length - 1) { + lane.pts[idx].x = Math.round(lane.pts[idx].x / step) * step; + } + }); + laneResortWithSel(lane); + } + }, + { + label: 'Set to 100%', action: function () { + pushUndoSnapshot(); + lane._sel.forEach(function (idx) { if (lane.pts[idx]) lane.pts[idx].y = 0; }); + } + }, + { + label: 'Set to 50%', action: function () { + pushUndoSnapshot(); + lane._sel.forEach(function (idx) { if (lane.pts[idx]) lane.pts[idx].y = 0.5; }); + } + }, + { + label: 'Set to 0%', action: function () { + pushUndoSnapshot(); + lane._sel.forEach(function (idx) { if (lane.pts[idx]) lane.pts[idx].y = 1; }); + } + } + ]; + + items.forEach(function (it) { + if (it.label === '---') { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + return; + } + var row = document.createElement('div'); + row.className = 'lane-add-menu-item'; + row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;'; + row.innerHTML = '' + it.label + '' + (it.key ? '' + it.key + '' : ''); + row.onclick = function () { + it.action(); + laneDrawCanvas(b, li); + syncBlocksToHost(); + menu.remove(); + }; + menu.appendChild(row); + }); + + document.body.appendChild(menu); + + // Close on outside click (next tick to avoid immediate close) + setTimeout(function () { + var closer = function (e) { + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('mousedown', closer); } + }; + document.addEventListener('mousedown', closer); + }, 0); +} + +// Shape-preserving duplicate helper (used by Ctrl+D and context menu) +function _laneShapeDuplicate(b, li, lane) { + if (!lane._sel || !lane._sel.size) return; + var selected = []; + lane._sel.forEach(function (idx) { + if (lane.pts[idx]) selected.push({ x: lane.pts[idx].x, y: lane.pts[idx].y }); + }); + if (!selected.length) return; + selected.sort(function (a, c) { return a.x - c.x; }); + var baseX = selected[0].x; + var offsets = selected.map(function (p) { return { dx: p.x - baseX, y: p.y }; }); + + // Find grid step + var gridParts = b.laneGrid === 'free' ? null : b.laneGrid.split('/'); + var gridStep = gridParts && gridParts.length === 2 ? Number(gridParts[0]) / Number(gridParts[1]) : 0.0625; + + // Anchor first duplicate point at the last selected point's position + // so the duplicate continues seamlessly from where the selection ends + var maxSelX = selected[selected.length - 1].x; + var pasteX = maxSelX; + + var pastedPts = []; + offsets.forEach(function (p) { + var nx = pasteX + p.dx; + if (nx > 0.99) return; + var np = { x: nx, y: p.y }; + lanePutPt(lane, np, gridStep); + pastedPts.push(np); + }); + + // Select the new points + lane._sel.clear(); + for (var i = 0; i < lane.pts.length; i++) { + for (var j = 0; j < pastedPts.length; j++) { + if (Math.abs(lane.pts[i].x - pastedPts[j].x) < 0.003 && Math.abs(lane.pts[i].y - pastedPts[j].y) < 0.003) { + lane._sel.add(i); break; + } + } + } + laneDrawCanvas(b, li); +} + +// Morph snapshot duplicate helper (used by Ctrl+D and context menu) +function _morphSnapDuplicate(b, li, lane, snapIdx) { + if (!lane.morphSnapshots || !lane.morphSnapshots[snapIdx]) return; + var src = lane.morphSnapshots[snapIdx]; + // Deep copy values + var valsCopy = {}; + for (var k in src.values) valsCopy[k] = src.values[k]; + var newSnap = { + position: src.position, + hold: src.hold != null ? src.hold : 0.5, + curve: src.curve || 0, + depth: src.depth != null ? src.depth : 1.0, + drift: src.drift || 0, + warp: src.warp || 0, + steps: src.steps || 0, + name: (src.name || 'S') + ' (copy)', + source: src.source || '', + values: valsCopy + }; + // Insert right after the source + lane.morphSnapshots.splice(snapIdx + 1, 0, newSnap); + // Redistribute positions evenly + var total = lane.morphSnapshots.length; + if (total > 1) { + for (var si = 0; si < total; si++) { + lane.morphSnapshots[si].position = si / (total - 1); + } + } + // Select the new snapshot + lane._selectedSnap = snapIdx + 1; + laneDrawCanvas(b, li); + renderSingleBlock(b.id); +} + +// Global keyboard handler for lane operations +var _laneCopiedPts = null; // module-level shape clipboard + +// Click outside lane → deselect all points +document.addEventListener('click', function (e) { + // Don't deselect when clicking inside the lane area, toolbar, footer, or context menus + if (e.target.closest('.lane-canvas-wrap') || e.target.closest('.lane-lb-left') || e.target.closest('.lane-lb-right') || + e.target.closest('.lane-toolbar') || e.target.closest('.lane-footer') || e.target.closest('.lane-ctx-menu') || + e.target.closest('.lane-add-menu') || e.target.closest('.lane-hdr') || e.target.closest('.lane-morph-sidebar')) return; + var ab = actId ? findBlock(actId) : null; + if (!ab || ab.mode !== 'lane') return; + var didClear = false; + ab.lanes.forEach(function (lane, li) { + if (lane._sel && lane._sel.size > 0) { lane._sel.clear(); didClear = true; laneDrawCanvas(ab, li); } + }); + if (didClear) renderSingleBlock(ab.id); +}); + +(function () { + document.addEventListener('keydown', function (e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + // Find active lane block + var ab = actId ? findBlock(actId) : null; + if (!ab || ab.mode !== 'lane') return; + + // Escape - deselect all points, or cancel active drag (undo) + if (e.key === 'Escape') { + var hadSel = false; + ab.lanes.forEach(function (lane, li) { + if (lane._sel && lane._sel.size > 0) { lane._sel.clear(); hadSel = true; laneDrawCanvas(ab, li); } + if (lane._selectedSnaps && lane._selectedSnaps.size > 0) { lane._selectedSnaps.clear(); hadSel = true; } + if (lane._selectedSnap != null && lane._selectedSnap >= 0) { lane._selectedSnap = -1; hadSel = true; } + }); + if (hadSel) { renderSingleBlock(ab.id); return; } + if (typeof undo === 'function') undo(); + return; + } + + // S - toggle select mode + if (e.key === 's' || e.key === 'S') { + if (e.ctrlKey || e.metaKey) return; // don't intercept Ctrl+S + e.preventDefault(); + ab.laneTool = ab.laneTool === 'select' ? 'draw' : 'select'; + // Update toolbar buttons visually (find buttons by data-b inside any lane-toolbar) + document.querySelectorAll('.lane-tbtn[data-b="' + ab.id + '"][data-lt]').forEach(function (t) { + if (t.dataset.lt !== 'clear' && t.dataset.lt !== 'random') t.classList.toggle('on', t.dataset.lt === ab.laneTool); + }); + return; + } + + // Delete - remove selected points (curve) or selected snapshot (morph) + if (e.key === 'Delete' || e.key === 'Backspace') { + var changed = false; + pushUndoSnapshot(); + ab.lanes.forEach(function (lane, li) { + if (lane.morphMode) { + // Morph: delete selected snapshot + var si = lane._selectedSnap; + if (si != null && si >= 0 && lane.morphSnapshots && lane.morphSnapshots[si]) { + lane.morphSnapshots.splice(si, 1); + if (lane.morphSnapshots.length > 1) { + lane.morphSnapshots[0].position = 0; + lane.morphSnapshots[lane.morphSnapshots.length - 1].position = 1; + } else if (lane.morphSnapshots.length === 1) { + lane.morphSnapshots[0].position = 0; + } + lane._selectedSnap = -1; + laneDrawCanvas(ab, li); + renderSingleBlock(ab.id); + changed = true; + } + } else { + // Curve: delete selected points + if (!lane._sel || !lane._sel.size) return; + lane.pts = lane.pts.filter(function (p, i) { + if (p.x < 0.01 || p.x > 0.99) return true; + return !lane._sel.has(i); + }); + lane._sel.clear(); + laneDrawCanvas(ab, li); + changed = true; + } + }); + if (changed) syncBlocksToHost(); + return; + } + + // Arrow keys - nudge selected points + if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + var step = e.shiftKey ? 0.01 : 0.05; + var dx = 0, dy = 0; + if (e.key === 'ArrowUp') dy = -step; + if (e.key === 'ArrowDown') dy = step; + if (e.key === 'ArrowLeft') dx = -step; + if (e.key === 'ArrowRight') dx = step; + + var nudged = false; + pushUndoSnapshot(); + ab.lanes.forEach(function (lane, li) { + if (!lane._sel || !lane._sel.size) return; + lane._sel.forEach(function (idx) { + if (!lane.pts[idx]) return; + var isE = (idx === 0 || idx === lane.pts.length - 1); + if (!isE) lane.pts[idx].x = Math.max(0, Math.min(1, lane.pts[idx].x + dx)); + lane.pts[idx].y = Math.max(0, Math.min(1, lane.pts[idx].y + dy)); + }); + laneResortWithSel(lane); + laneDrawCanvas(ab, li); + nudged = true; + }); + if (nudged) syncBlocksToHost(); + return; + } + + // Ctrl+C - copy selected points as relative shape + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C')) { + ab.lanes.forEach(function (lane) { + if (!lane._sel || !lane._sel.size) return; + var selected = []; + lane._sel.forEach(function (idx) { + if (lane.pts[idx]) selected.push({ x: lane.pts[idx].x, y: lane.pts[idx].y }); + }); + if (!selected.length) return; + selected.sort(function (a, c) { return a.x - c.x; }); + var baseX = selected[0].x; + _laneCopiedPts = selected.map(function (p) { + return { dx: p.x - baseX, y: p.y }; + }); + }); + return; + } + + // Ctrl+V - paste shape at next grid position + if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) { + if (!_laneCopiedPts || !_laneCopiedPts.length) return; + e.preventDefault(); + pushUndoSnapshot(); + ab.lanes.forEach(function (lane, li) { + // Find paste anchor + var anchorX = 0; + if (lane._sel && lane._sel.size) { + lane._sel.forEach(function (idx) { + if (lane.pts[idx] && lane.pts[idx].x > anchorX) anchorX = lane.pts[idx].x; + }); + } else { + for (var i = 0; i < lane.pts.length; i++) { + if (lane.pts[i].x > anchorX && lane.pts[i].x < 0.99) anchorX = lane.pts[i].x; + } + } + + // Grid step + var gridParts = ab.laneGrid === 'free' ? null : ab.laneGrid.split('/'); + var gridStep = gridParts && gridParts.length === 2 ? Number(gridParts[0]) / Number(gridParts[1]) : 0.0625; + var pasteX = Math.ceil((anchorX + 0.001) / gridStep) * gridStep; + + // Stamp the shape — batch all points to avoid clear-radius removing earlier pasted dots + var pastedPts = []; + _laneCopiedPts.forEach(function (p) { + var nx = pasteX + p.dx; + if (nx > 0.99 || nx < 0.01) return; + pastedPts.push({ x: nx, y: p.y }); + }); + if (pastedPts.length) { + // Single bulk clear: remove existing points near any paste position + var clearRadius = gridStep ? gridStep * 0.49 : 0.004; + lane.pts = lane.pts.filter(function (p) { + if (p.x < 0.01 || p.x > 0.99) return true; + for (var k = 0; k < pastedPts.length; k++) { + if (Math.abs(p.x - pastedPts[k].x) <= clearRadius) return false; + } + return true; + }); + // Insert all pasted points + for (var pi = 0; pi < pastedPts.length; pi++) { + lane.pts.push({ x: pastedPts[pi].x, y: pastedPts[pi].y }); + } + lane.pts.sort(function (a, c) { return a.x - c.x; }); + } + + // Select the pasted points + lane._sel.clear(); + for (var i = 0; i < lane.pts.length; i++) { + for (var j = 0; j < pastedPts.length; j++) { + if (Math.abs(lane.pts[i].x - pastedPts[j].x) < 0.003 && + Math.abs(lane.pts[i].y - pastedPts[j].y) < 0.003) { + lane._sel.add(i); break; + } + } + } + laneDrawCanvas(ab, li); + }); + syncBlocksToHost(); + return; + } + + // Ctrl+D - shape-preserving duplicate (curve lanes) / snapshot duplicate (morph lanes) + if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { + e.preventDefault(); + pushUndoSnapshot(); + var hasMorph = false; + ab.lanes.forEach(function (lane, li) { + if (lane.morphMode && lane._selectedSnap != null && lane._selectedSnap >= 0 && lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]) { + _morphSnapDuplicate(ab, li, lane, lane._selectedSnap); + hasMorph = true; + } else if (!lane.morphMode) { + _laneShapeDuplicate(ab, li, lane); + } + }); + syncBlocksToHost(); + return; + } + + // Ctrl+A - select all points in all lanes + if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { + e.preventDefault(); + ab.lanes.forEach(function (lane, li) { + if (!lane._sel) lane._sel = new Set(); + lane._sel.clear(); + for (var i = 0; i < lane.pts.length; i++) lane._sel.add(i); + laneDrawCanvas(ab, li); + }); + return; + } + }); +})(); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js b/plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js new file mode 100644 index 0000000..b55e27f --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js @@ -0,0 +1,2790 @@ +// ============================================================ +// LOGIC BLOCKS +// Block creation, rendering, wiring, randomize, sync to host +// ============================================================ +var internalBpm = 120; // Internal tempo (set in Settings) +// Build SVG arc knob for logic block params. mode: 'rand','env','smp','morph','shapes' +function buildBlockKnob(val, min, max, size, mode, field, blockId, label, unit, fmtFn, disabled, laneIdx) { + var norm = (val - min) / (max - min); + var r = size / 2, cx = r, cy = r, ir = r - 3; + var startAngle = 135 * Math.PI / 180, endAngle = 405 * Math.PI / 180; + var span = endAngle - startAngle; + var tPath = describeArc(cx, cy, ir, startAngle, endAngle); + var va = startAngle + norm * span; + var vPath = norm > 0.005 ? describeArc(cx, cy, ir, startAngle, va) : ''; + var dx = cx + ir * Math.cos(va), dy = cy + ir * Math.sin(va); + var tVar = 'var(--lk-' + mode + '-track, var(--knob-track))'; + var vVar = 'var(--lk-' + mode + '-value, var(--knob-value))'; + var dVar = 'var(--lk-' + mode + '-dot, var(--knob-dot))'; + var svg = ''; + svg += ''; + if (vPath) svg += ''; + svg += ''; + svg += ''; + var dispVal = fmtFn ? fmtFn(val) : (unit === 'ms' ? Math.round(val) + 'ms' : unit === 'dB' ? Math.round(val) + 'dB' : unit === '%' ? Math.round(val) + '%' : unit === '±%' ? '±' + Math.round(val) + '%' : unit === '±' ? (val > 0 ? '+' : '') + Math.round(val) + '%' : Math.round(val) + (unit || '')); + var liAttr = (laneIdx != null) ? ' data-li="' + laneIdx + '"' : ''; + var h = '
'; + h += '
' + svg + '
'; + h += '
' + dispVal + '
'; + h += '
' + label + '
'; + h += '
'; + return h; +} +function buildKnobRow(knobs) { return '
' + knobs + '
'; } + +// Shared shape selector options — used by Shapes, Shapes Range, and Morph Pad +function buildShapeOptions(field, b) { + var val = b[field] || 'circle'; + var sel = function (v) { return val === v ? ' selected' : ''; }; + var h = ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + return h; +} + +function clampToCircle(x, y) { + var dx = x - 0.5, dy = y - 0.5; + var dist = Math.sqrt(dx * dx + dy * dy); + var r = 0.45; // slightly inside the circle edge so dots don't overflow + if (dist > r) { var s = r / dist; dx *= s; dy *= s; } + return { x: 0.5 + dx, y: 0.5 + dy }; +} +// Get the fixed sector position for a snapshot by index (12 sectors like a clock) +function getSnapSectorPos(index) { + var angle = -Math.PI / 2 + (2 * Math.PI * index / 12); // 12 fixed sectors, start from top + var radius = 0.35; + return { x: 0.5 + radius * Math.cos(angle), y: 0.5 + radius * Math.sin(angle) }; +} +// Find plugin name by hostId +function getPluginName(hostId) { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].hostId === hostId || pluginBlocks[i].id === hostId) return pluginBlocks[i].name; + } + return ''; +} +// Shared beat division lists +var BEAT_DIVS = [ + { v: '1/1', label: '1/1' }, + { v: '1/2', label: '1/2' }, { v: '1/2.', label: '1/2.' }, { v: '1/2T', label: '1/2T' }, + { v: '1/4', label: '1/4' }, { v: '1/4.', label: '1/4.' }, { v: '1/4T', label: '1/4T' }, + { v: '1/8', label: '1/8' }, { v: '1/8.', label: '1/8.' }, { v: '1/8T', label: '1/8T' }, + { v: '1/16', label: '1/16' }, { v: '1/16.', label: '1/16.' }, { v: '1/16T', label: '1/16T' }, + { v: '1/32', label: '1/32' }, + { v: '1/64', label: '1/64' } +]; +var LANE_LOOP_OPTS = [ + { v: '1/16', label: '1/16' }, { v: '1/16T', label: '1/16T' }, + { v: '1/8', label: '1/8' }, { v: '1/8.', label: '1/8.' }, { v: '1/8T', label: '1/8T' }, + { v: '1/4', label: '1/4' }, { v: '1/4.', label: '1/4.' }, { v: '1/4T', label: '1/4T' }, + { v: '1/2', label: '1/2' }, { v: '1/2.', label: '1/2.' }, { v: '1/2T', label: '1/2T' }, + { v: '1/1', label: '1 bar' }, { v: '2/1', label: '2 bars' }, + { v: '4/1', label: '4 bars' }, { v: '8/1', label: '8 bars' }, + { v: '16/1', label: '16 bars' }, { v: '32/1', label: '32 bars' }, + { v: 'free', label: 'Free' } +]; +var LANE_COLORS = ['#6088CC', '#CC8050', '#50CC80', '#CC5078', '#70BBCC', '#CCB850', '#A070CC', '#50CCB0']; +var MORPH_DIVS = [ + { v: '8', label: '8 Bars' }, { v: '4', label: '4 Bars' }, { v: '2', label: '2 Bars' } +].concat(BEAT_DIVS); +// Render a '; + divList.forEach(function (d) { + h += ''; + }); + h += ''; + return h; +} +// Convert morphSpeed (0-100) to display string +function morphSpeedDisplay(sp) { + return Math.round(sp) + '%'; +} +// Logic blocks +function addBlock(mode) { + if (!mode) mode = 'randomize'; var id = ++bc; + var blk = { + id: id, mode: mode, enabled: true, targets: new Set(), targetBases: {}, targetRanges: {}, targetRangeBases: {}, colorIdx: bc - 1, trigger: 'manual', beatDiv: '1/4', midiMode: 'any_note', midiNote: 60, midiCC: 1, midiCh: 0, velScale: false, threshold: -12, audioSrc: 'main', rMin: 0, rMax: 100, rangeMode: 'relative', polarity: 'bipolar', quantize: false, qSteps: 12, movement: 'instant', glideMs: 200, envAtk: 10, envRel: 100, envSens: 50, envInvert: false, envFilterMode: 'flat', envFilterFreq: 50, envFilterBW: 5, loopMode: 'loop', sampleSpeed: 1.0, sampleReverse: false, jumpMode: 'restart', sampleName: '', sampleWaveform: null, expanded: true, clockSource: 'daw', + snapshots: [], playheadX: 0.5, playheadY: 0.5, morphMode: 'manual', exploreMode: 'wander', lfoShape: 'circle', lfoDepth: 80, lfoRotation: 0, morphSpeed: 50, morphAction: 'jump', stepOrder: 'cycle', morphSource: 'midi', jitter: 0, morphGlide: 200, morphTempoSync: false, morphSyncDiv: '1/4', snapRadius: 100, + shapeType: 'circle', shapeTracking: 'horizontal', shapeSize: 80, shapeSpin: 0, shapeSpeed: 50, shapePhaseOffset: 0, shapeRange: 'relative', shapePolarity: 'bipolar', shapeTempoSync: false, shapeSyncDiv: '1/4', shapeTrigger: 'free', + laneTool: 'draw', laneGrid: '1/8', lanes: [] + }; + blocks.push(blk); + // shapes_range defaults to unipolar (drag direction) — most intuitive for per-param ranges + if (mode === 'shapes_range') blk.shapePolarity = 'unipolar'; + actId = id; assignMode = null; renderBlocks(); renderAllPlugins(); updCounts(); syncBlocksToHost(); +} +// Assign a param to a block, capturing its current value as the relative base +function assignTarget(block, pid) { + if (!block || !pid) return; + var p = PMap[pid]; + if (!p || p.lk) return; + block.targets.add(pid); + // Capture value at assignment time if not already stored + if (!block.targetBases) block.targetBases = {}; + if (block.targetBases[pid] === undefined) { + block.targetBases[pid] = p.v; + } +} +// Build a single logic block card DOM element +function buildBlockCard(b, bi) { + var col = bColor(b.colorIdx), isAct = b.id === actId, isAs = assignMode === b.id; + var card = document.createElement('div'); + var modeClass = b.mode === 'randomize' ? ' mode-rand' : (b.mode === 'envelope' ? ' mode-env' : (b.mode === 'morph_pad' ? ' mode-morph' : (b.mode === 'shapes' || b.mode === 'shapes_range' ? ' mode-shapes' : (b.mode === 'lane' ? ' mode-lane' : ' mode-smp')))); + card.className = 'lcard' + modeClass + (isAct ? ' active' : '') + (b.mode === 'envelope' && isAct ? ' env-active' : '') + (b.mode === 'sample' && isAct ? ' smp-active' : '') + (b.mode === 'morph_pad' && isAct ? ' morph-active' : '') + ((b.mode === 'shapes' || b.mode === 'shapes_range') && isAct ? ' shapes-active' : '') + (b.mode === 'lane' && isAct ? ' lane-active' : '') + (!b.enabled ? ' disabled' : ''); + card.setAttribute('data-blockid', b.id); + var sum = ''; if (b.mode === 'envelope') sum = 'Atk ' + b.envAtk + 'ms / Rel ' + b.envRel + 'ms'; + else if (b.mode === 'sample') sum = (b.sampleName || 'No sample') + ' / ' + b.loopMode; + else if (b.mode === 'morph_pad') { var ml = { manual: 'Manual', auto: 'Auto', trigger: 'Trigger' }[b.morphMode] || 'Manual'; sum = 'Morph / ' + ml + ' / ' + (b.snapshots ? b.snapshots.length : 0) + ' snaps'; } + else if (b.mode === 'shapes') { sum = (b.shapeType || 'circle') + ' / ' + (b.shapeTracking || 'horizontal'); } + else if (b.mode === 'shapes_range') { var rc = 0; if (b.targetRanges) { for (var k in b.targetRanges) rc++; } sum = (b.shapeType || 'circle') + ' / ' + (b.shapeTracking || 'horizontal') + ' / ' + rc + ' ranges'; } + else if (b.mode === 'lane') { var lc = b.lanes ? b.lanes.length : 0; sum = 'Lane / ' + lc + ' lane' + (lc !== 1 ? 's' : '') + ' / ' + (b.laneGrid || '1/8'); } + else { sum = ({ manual: 'Manual', tempo: 'Tempo', midi: 'MIDI', audio: 'Audio' }[b.trigger]) + ' / ' + (b.movement === 'instant' ? 'Instant' : 'Smooth'); } + var tc = b.targets.size, tH = ''; + // Build pid → lane color + label map for lane-mode blocks + var pidLaneMap = {}; + var laneOrderedPids = []; + if (tc > 0 && b.mode === 'lane' && b.lanes) { + for (var li = 0; li < b.lanes.length; li++) { + var ln = b.lanes[li]; + for (var pi = 0; pi < ln.pids.length; pi++) { + pidLaneMap[ln.pids[pi]] = { color: ln.color, label: 'L' + (li + 1) }; + laneOrderedPids.push(ln.pids[pi]); + } + } + b.targets.forEach(function (pid) { + if (!pidLaneMap[pid]) laneOrderedPids.push(pid); + }); + } + // Cache ordered target array for virtual scroll + b._tgtArray = (b.mode === 'lane' && laneOrderedPids.length > 0) ? laneOrderedPids : Array.from(b.targets); + b._pidLaneMap = pidLaneMap; + if (tc === 0) tH = 'No params assigned'; + else { + if (tc > 6) tH += ''; + tH += '
'; + } + var abS = isAs ? 'background:' + col + ';color:white;border-color:' + col : '', abT = isAs ? 'Done' : 'Assign'; + var pwrCls = b.enabled ? 'pwr-btn on' : 'pwr-btn'; + var bH = ''; + // ── MODE — top section ── + bH += '
'; + // ── MODE BODY ── + if (b.mode === 'randomize') bH += renderRndBody(b); else if (b.mode === 'sample') bH += renderSampleBody(b); else if (b.mode === 'morph_pad') bH += renderMorphBody(b); else if (b.mode === 'shapes') bH += renderShapesBody(b); else if (b.mode === 'shapes_range') bH += renderShapesRangeBody(b); else if (b.mode === 'lane') bH += renderLaneBody(b); else bH += renderEnvBody(b); + // ── TARGETS ── + bH += '
' + tH + '
'; + // ── FIRE (randomize only) ── + if (b.mode === 'randomize') bH += '
'; + var ch = ''; + card.innerHTML = '
' + ch + 'Block ' + (bi + 1) + '' + sum + ' / ' + tc + ' params
' + bH + '
'; + // Populate target list with virtual scrolling for large target counts + if (tc > 0) { + var tgtList = card.querySelector('.tgt-list[data-b="' + b.id + '"]'); + if (tgtList) _fillTargetList(tgtList, b, col); + } + return card; +} + +// ── Target list virtual scrolling ── +var TGT_ROW_H = 22; +var TGT_VIRTUAL_THRESHOLD = 100; + +function _buildTargetRow(pid, b, col) { + var tp = PMap[pid]; if (!tp) return null; + var pn = paramPluginName(pid); + var rowCol = (b._pidLaneMap && b._pidLaneMap[pid]) ? b._pidLaneMap[pid].color : col; + var laneTag = (b._pidLaneMap && b._pidLaneMap[pid]) + ? '' + b._pidLaneMap[pid].label + '' : ''; + var row = document.createElement('div'); + row.className = 'tgt-row'; + row.setAttribute('data-pid', pid); + row.style.borderLeft = '3px solid ' + rowCol; + row.innerHTML = laneTag + '' + pn + ': ' + tp.name + '' + + 'x'; + // Wire handlers inline — critical for virtual-scroll rows created after wireBlocks() + var nameSpan = row.querySelector('.tgt-name'); + if (nameSpan) { + nameSpan.onclick = function (e) { + e.stopPropagation(); + var pp = PMap[pid]; if (!pp) return; + // Expand the plugin card if collapsed + for (var pbi = 0; pbi < pluginBlocks.length; pbi++) { + var pb = pluginBlocks[pbi]; + if (pb.id === pp.hostId && !pb.expanded) { pb.expanded = true; dirtyPluginParams(pp.hostId); renderAllPlugins(); break; } + } + // Retry until virtual scroll reveals the param row + var attempts = 0; + (function tryLocate() { + attempts++; + if (typeof scrollVirtualToParam === 'function') scrollVirtualToParam(pid); + var paramRow = document.querySelector('.pr[data-pid="' + pid + '"]'); + if (paramRow) { + paramRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + paramRow.classList.remove('touched'); void paramRow.offsetWidth; + paramRow.classList.add('touched'); + } else if (attempts < 15) { + requestAnimationFrame(tryLocate); + } + })(); + }; + } + var xBtn = row.querySelector('.tx'); + if (xBtn) { + xBtn.onclick = function (e) { + e.stopPropagation(); + b.targets.delete(pid); + if (typeof cleanBlockAfterUnassign === 'function') cleanBlockAfterUnassign(b, pid); + renderSingleBlock(b.id); renderAllPlugins(); syncBlocksToHost(); + }; + } + return row; +} + +function _fillTargetList(container, b, col) { + var ta = b._tgtArray || Array.from(b.targets); + var filter = (b._tgtFilter || '').toLowerCase(); + var filtered = []; + for (var i = 0; i < ta.length; i++) { + if (!PMap[ta[i]]) continue; + if (filter) { + var fullName = (paramPluginName(ta[i]) + ': ' + PMap[ta[i]].name).toLowerCase(); + if (fullName.indexOf(filter) < 0) continue; + } + filtered.push(ta[i]); + } + if (filtered.length <= TGT_VIRTUAL_THRESHOLD) { + container._vScroll = false; + container.style.position = ''; + container.innerHTML = ''; + for (var i = 0; i < filtered.length; i++) { + var row = _buildTargetRow(filtered[i], b, col); + if (row) container.appendChild(row); + } + container.onscroll = null; + return; + } + var savedScroll = container.scrollTop || 0; + container._vScroll = true; + container._vItems = filtered; + container._vBlock = b; + container._vCol = col; + container._vRendered = {}; + container._vStart = -1; + container._vEnd = -1; + container.style.position = 'relative'; + container.innerHTML = ''; + var sentinel = document.createElement('div'); + sentinel.style.height = (filtered.length * TGT_ROW_H) + 'px'; + sentinel.style.pointerEvents = 'none'; + container.appendChild(sentinel); + container.scrollTop = savedScroll; + _updateVirtualTargetRows(container); + container.onscroll = function () { + if (!container._vRaf) { + container._vRaf = requestAnimationFrame(function () { + container._vRaf = null; + _updateVirtualTargetRows(container); + }); + } + }; +} + +function _updateVirtualTargetRows(container) { + var items = container._vItems; + if (!items) return; + var scrollTop = container.scrollTop; + var viewH = container.clientHeight; + if (viewH <= 0) { + if (!container._vRetry) { + container._vRetry = requestAnimationFrame(function () { + container._vRetry = null; + _updateVirtualTargetRows(container); + }); + } + return; + } + var BUFFER = 5; + var startIdx = Math.max(0, Math.floor(scrollTop / TGT_ROW_H) - BUFFER); + var endIdx = Math.min(items.length - 1, Math.ceil((scrollTop + viewH) / TGT_ROW_H) + BUFFER); + if (startIdx === container._vStart && endIdx === container._vEnd) return; + var rendered = container._vRendered; + for (var pid in rendered) { + var entry = rendered[pid]; + if (entry.idx < startIdx || entry.idx > endIdx) { + entry.row.remove(); + delete rendered[pid]; + } + } + for (var i = startIdx; i <= endIdx; i++) { + var pid = items[i]; + if (rendered[pid]) continue; + var row = _buildTargetRow(pid, container._vBlock, container._vCol); + if (!row) continue; + row.style.position = 'absolute'; + row.style.top = (i * TGT_ROW_H) + 'px'; + row.style.left = '0'; + row.style.right = '0'; + row.style.height = TGT_ROW_H + 'px'; + row.style.boxSizing = 'border-box'; + container.appendChild(row); + rendered[pid] = { row: row, idx: i }; + } + container._vStart = startIdx; + container._vEnd = endIdx; +} +// Stamp for detecting structural changes in blocks (add/remove) +var _blockStamp = ''; +function getBlockStamp() { return blocks.map(function (b) { return b.id; }).join(','); } + +function renderBlocks() { + var c = document.getElementById('lpScroll'); + var newStamp = getBlockStamp(); + + // STRUCTURAL CHANGE: different blocks — full rebuild + if (newStamp !== _blockStamp || c.children.length !== blocks.length) { + _blockStamp = newStamp; + c.innerHTML = ''; + for (var bi = 0; bi < blocks.length; bi++) { + c.appendChild(buildBlockCard(blocks[bi], bi)); + } + wireBlocks(); wireBlockDropTargets(); updateAssignBanner(); + return; + } + + // PATCH: same structure — rebuild each card in-place + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + var oldCard = c.children[bi]; + if (!oldCard) continue; + var newCard = buildBlockCard(b, bi); + c.replaceChild(newCard, oldCard); + } + wireBlocks(); wireBlockDropTargets(); updateAssignBanner(); +} +// Wire block cards as drop targets for param drag +function wireBlockDropTargets() { + document.querySelectorAll('.lcard[data-blockid]').forEach(function (card) { + card.addEventListener('dragover', function (e) { + if (e.dataTransfer.types.indexOf('text/plain') === -1) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + // Color the highlight with this block's color + var bid = parseInt(card.getAttribute('data-blockid')); + var bl = findBlock(bid); + if (bl) { + var col = bColor(bl.colorIdx); + card.style.setProperty('--drag-color', col); + } + card.classList.add('drag-hover'); + }); + card.addEventListener('dragleave', function () { + card.classList.remove('drag-hover'); + card.style.removeProperty('--drag-color'); + }); + card.addEventListener('drop', function (e) { + e.preventDefault(); + card.classList.remove('drag-hover'); + card.style.removeProperty('--drag-color'); + var data = e.dataTransfer.getData('text/plain'); + if (!data || data.indexOf('params:') !== 0) return; + var pids = data.replace('params:', '').split(','); + var bid = parseInt(card.getAttribute('data-blockid')); + var bl = findBlock(bid); + if (!bl) return; + pids.forEach(function (pid) { + var pp = PMap[pid]; + if (pp && !pp.lk) assignTarget(bl, pid); + }); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + }); + }); +} +// Targeted single-block update (avoids rebuilding all blocks) +function renderSingleBlock(blockId) { + var c = document.getElementById('lpScroll'); + for (var bi = 0; bi < blocks.length; bi++) { + if (blocks[bi].id === blockId) { + var oldCard = c.children[bi]; + if (!oldCard) { renderBlocks(); return; } + var newCard = buildBlockCard(blocks[bi], bi); + c.replaceChild(newCard, oldCard); + wireBlocks(); wireBlockDropTargets(); updateAssignBanner(); + return; + } + } +} +function renderRndBody(b) { + var h = ''; + + // ── 1. BEHAVIOUR — trigger + movement combined in one row ── + h += '
'; + h += '
'; + h += '
'; + ['manual', 'tempo', 'midi', 'audio'].forEach(function (t) { h += ''; }); + h += '
'; + h += '
'; + h += '
'; + h += '
'; + // Sub-options for trigger types + h += '
Division' + renderBeatDivSelect(b.id, 'beatDiv', b.beatDiv) + '
'; + h += '
Mode
'; + h += '
Channel
'; + h += '
Thresh' + b.threshold + ' dB
Source
'; + // Glide knob (shown when smooth is selected) + if (b.movement === 'glide') h += buildKnobRow(buildBlockKnob(b.glideMs, 1, 2000, 36, 'rand', 'glideMs', b.id, 'Glide', 'ms')); + h += '
'; + + // ── 2. CONSTRAINTS — range + quantize in inset box ── + h += '
'; + h += '
'; + h += '
'; + // Range sliders + if (b.rangeMode === 'absolute') { + h += '
Min' + b.rMin + '%
'; + h += '
Max' + b.rMax + '%
'; + } else { + h += '
\u00b1' + b.rMax + '%
'; + } + // Quantize + h += '
Quantizesteps
'; + h += '
'; + + return h; +} +// Convert envelope filter dial position (0-100) to Hz (log scale 20-20000) +var ENV_BW_STEPS = [0.1, 0.33, 0.67, 1, 1.5, 2, 3, 5]; +var ENV_BW_LABELS = ['1/10', '1/3', '2/3', '1', '1.5', '2', '3', '5']; +function envDialToHz(dp) { return 20 * Math.pow(10, (dp != null ? dp : 50) * 0.03); } +function envFmtHz(dp) { var hz = envDialToHz(dp); return hz >= 1000 ? (hz / 1000).toFixed(1) + 'k' : Math.round(hz) + 'Hz'; } +function envBwIdxToOct(idx) { return ENV_BW_STEPS[Math.round(Math.max(0, Math.min(7, idx)))] || 2; } +function envFmtBw(idx) { return ENV_BW_LABELS[Math.round(Math.max(0, Math.min(7, idx)))] + ' oct'; } +// Build SVG frequency response visualization for envelope filter +function buildEnvFilterSvg(b) { + var w = 200, h = 42, pt = 3, pb = 10, plotH = h - pt - pb; + var mode = b.envFilterMode || 'flat'; + var freq = envDialToHz(b.envFilterFreq); + var bwOct = envBwIdxToOct(b.envFilterBW); + function fToX(f) { return (Math.log10(Math.max(20, f)) - Math.log10(20)) / 3 * w; } + function lpM(f, fc) { var r = f / fc; return 1 / Math.sqrt(1 + r * r * r * r); } + function hpM(f, fc) { var r = fc / f; return 1 / Math.sqrt(1 + r * r * r * r); } + function mag(f) { + if (mode === 'flat') return 1; + if (mode === 'lp') return lpM(f, freq); + if (mode === 'hp') return hpM(f, freq); + if (mode === 'bp') { return hpM(f, freq / Math.pow(2, bwOct / 2)) * lpM(f, freq * Math.pow(2, bwOct / 2)); } + return 1; + } + var pts = []; + for (var i = 0; i <= 60; i++) { + var f = 20 * Math.pow(1000, i / 60); + pts.push(fToX(f).toFixed(1) + ',' + (pt + plotH * (1 - mag(f))).toFixed(1)); + } + var svg = ''; + svg += ''; + var gf = [50, 200, 500, 2000, 5000]; + for (var gi = 0; gi < gf.length; gi++) { var gx = fToX(gf[gi]); svg += ''; } + svg += ''; + var lbs = [{ f: 20, l: '20' }, { f: 50, l: '50' }, { f: 200, l: '200' }, { f: 500, l: '500' }, { f: 2000, l: '2k' }, { f: 5000, l: '5k' }, { f: 20000, l: '20k' }]; + for (var li = 0; li < lbs.length; li++) { svg += '' + lbs[li].l + ''; } + if (mode !== 'flat') { var fx = fToX(freq); svg += ''; } + svg += ''; + return svg; +} +// Reusable Detection Band section — call from any block that uses audio analysis +function buildDetectionBandSection(b, knobMode) { + var fm = b.envFilterMode || 'flat'; + var isFlat = (fm === 'flat'); + var h = '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
' + buildEnvFilterSvg(b) + '
'; + if (!isFlat) { + var freqVal = b.envFilterFreq != null ? b.envFilterFreq : 50; + if (fm === 'bp') { + var bwIdx = b.envFilterBW != null ? b.envFilterBW : 5; + h += buildKnobRow( + buildBlockKnob(freqVal, 0, 100, 36, knobMode, 'envFilterFreq', b.id, 'Frequency', 'Hz', envFmtHz) + + buildBlockKnob(bwIdx, 0, 7, 36, knobMode, 'envFilterBW', b.id, 'Width', 'oct', envFmtBw) + ); + } else { + h += buildKnobRow( + buildBlockKnob(freqVal, 0, 100, 36, knobMode, 'envFilterFreq', b.id, 'Frequency', 'Hz', envFmtHz) + ); + } + } + h += '
'; + return h; +} +function renderEnvBody(b) { + // Envelope follower always operates in relative mode, no invert + b.rangeMode = 'relative'; + b.envInvert = false; + var h = ''; + + // ── 1. INPUT — meter + source in contained inset box ── + h += '
'; + h += '
'; + h += '
Envelope Follower
0%
'; + h += '
Source
'; + h += '
'; + + // ── 2. DETECTION BAND ── + h += buildDetectionBandSection(b, 'env'); + + // ── 3. ENVELOPE & DEPTH — polarity + attack/release/depth ── + b.rMax = 100; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += buildKnobRow( + buildBlockKnob(b.envAtk, 1, 500, 36, 'env', 'envAtk', b.id, 'Attack', 'ms') + + buildBlockKnob(b.envRel, 1, 2000, 36, 'env', 'envRel', b.id, 'Release', 'ms') + + buildBlockKnob(b.envSens, 0, 200, 36, 'env', 'envSens', b.id, 'Depth', '%') + ); + h += '
'; + + return h; +} +function renderSampleBody(b) { + var h = ''; + + // ── 1. SAMPLE PLAYBACK — waveform + loop/reverse/speed in inset box ── + h += '
'; + h += '
'; + if (b.sampleWaveform && b.sampleWaveform.length) { + h += ''; + h += '
'; + h += '
' + (b.sampleName || 'Loaded') + '
'; + } else { + h += '
No sample loaded
'; + } + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
Rev
'; + h += '
'; + h += '
Speed'; + h += ''; + h += '' + b.sampleSpeed.toFixed(1) + 'x
'; + h += '
'; + + // ── Detection Band (reusable) ── + h += buildDetectionBandSection(b, 'smp'); + + // -- ENVELOPE & DEPTH -- polarity + attack/release/depth -- + b.rangeMode = 'relative'; + b.rMax = 100; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += buildKnobRow( + buildBlockKnob(b.envAtk, 1, 500, 36, 'smp', 'envAtk', b.id, 'Attack', 'ms') + + buildBlockKnob(b.envRel, 1, 2000, 36, 'smp', 'envRel', b.id, 'Release', 'ms') + + buildBlockKnob(b.envSens, 0, 200, 36, 'smp', 'envSens', b.id, 'Depth', '%') + ); + h += '
'; + + // ── 4. JUMP TRIGGER — trigger + sub-options + on jump in inset box ── + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + // Trigger sub-options + if (b.trigger === 'tempo') { + h += '
Div' + renderBeatDivSelect(b.id, 'beatDiv', b.beatDiv); + h += '
'; + h += '
On jump
'; + } else if (b.trigger === 'midi') { + h += '
Mode'; + if (b.midiMode === 'specific_note') h += ''; + if (b.midiMode === 'cc') h += ''; + h += 'Ch
'; + h += '
On jump
'; + } else if (b.trigger === 'audio') { + h += '
Src'; + h += 'ThrdB
'; + h += '
On jump
'; + } else { + // None - still show on-jump inline + h += '
On jump
'; + } + h += '
'; + + return h; +} +// Shared shape computation — matches C++ computeShapeXY exactly +// Returns {dx, dy} offsets from center for a given shape, phase t, and radius R +function computeShapeDxDy(shape, t, R) { + var twoPi = Math.PI * 2; + var halfPi = Math.PI / 2; + var dx = 0, dy = 0; + if (shape === 'circle') { + dx = R * Math.cos(t); dy = R * Math.sin(t); + } else if (shape === 'figure8') { + dx = R * Math.sin(t); dy = R * Math.sin(t * 2); + } else if (shape === 'sweepX') { + dx = R * Math.sin(t); dy = 0; + } else if (shape === 'sweepY') { + dx = 0; dy = R * Math.sin(t); + } else if (shape === 'triangle' || shape === 'square' || shape === 'hexagon') { + var n = shape === 'triangle' ? 3 : (shape === 'square' ? 4 : 6); + var segF = t * n / twoPi; + var seg = Math.floor(segF) % n; + var segT = segF - Math.floor(segF); + var a0 = twoPi * seg / n - halfPi; + var a1 = twoPi * ((seg + 1) % n) / n - halfPi; + dx = R * (Math.cos(a0) + segT * (Math.cos(a1) - Math.cos(a0))); + dy = R * (Math.sin(a0) + segT * (Math.sin(a1) - Math.sin(a0))); + } else if (shape === 'pentagram') { + var order = [0, 2, 4, 1, 3]; + var segF = t * 5 / twoPi; + var seg = Math.floor(segF) % 5; + var segT = segF - Math.floor(segF); + var from = order[seg], to = order[(seg + 1) % 5]; + var a0 = twoPi * from / 5 - halfPi; + var a1 = twoPi * to / 5 - halfPi; + dx = R * (Math.cos(a0) + segT * (Math.cos(a1) - Math.cos(a0))); + dy = R * (Math.sin(a0) + segT * (Math.sin(a1) - Math.sin(a0))); + } else if (shape === 'hexagram') { + // Star of David: trace two interlocked triangles (0,2,4,1,3,5) + var starOrder = [0, 2, 4, 1, 3, 5]; + var segF = t * 6 / twoPi; + var seg = Math.floor(segF) % 6; + var segT = segF - Math.floor(segF); + var fromIdx = starOrder[seg], toIdx = starOrder[(seg + 1) % 6]; + var aFrom = twoPi * fromIdx / 6 - halfPi; + var aTo = twoPi * toIdx / 6 - halfPi; + dx = R * (Math.cos(aFrom) + segT * (Math.cos(aTo) - Math.cos(aFrom))); + dy = R * (Math.sin(aFrom) + segT * (Math.sin(aTo) - Math.sin(aFrom))); + } else if (shape === 'rose4') { + var r = R * Math.cos(2 * t); + dx = r * Math.cos(t); dy = r * Math.sin(t); + } else if (shape === 'lissajous') { + dx = R * 0.7 * Math.sin(3 * t); dy = R * 0.7 * Math.sin(2 * t); + } else if (shape === 'spiral') { + var progress = t / twoPi; + var rNorm = progress < 0.5 ? progress * 2 : (1 - progress) * 2; + var sR = R * (0.05 + 0.95 * rNorm); + var sA = t * 3; + dx = sR * Math.cos(sA); dy = sR * Math.sin(sA); + } else if (shape === 'cat') { + // Cat face: polar contour with ears, eyes, nose, mouth + var bodyR = R * 0.52; + var pi = Math.PI; + var angDist = function (a, b) { + var d = Math.abs(a - b); + return d > pi ? twoPi - d : d; + }; + var bump = 0; + var dE; + // -- Ears: sharp triangular bumps at ~55deg and ~125deg -- + var earR = R * 0.42, earW = 0.32, earTipW = 0.09; + dE = angDist(t, pi * 0.31); // right ear ~56deg + if (dE < earW) { + var x = 1 - dE / earW; + bump += earR * x * x; + if (dE < earTipW) bump += R * 0.18 * (1 - dE / earTipW); + } + dE = angDist(t, pi * 0.69); // left ear ~124deg + if (dE < earW) { + var x = 1 - dE / earW; + bump += earR * x * x; + if (dE < earTipW) bump += R * 0.18 * (1 - dE / earTipW); + } + // -- Eyes: small outward bumps at ~320deg and ~220deg -- + var eyeR = R * 0.08, eyeW = 0.18; + dE = angDist(t, pi * 1.78); // right eye ~320deg + if (dE < eyeW) bump += eyeR * Math.pow(1 - dE / eyeW, 2); + dE = angDist(t, pi * 1.22); // left eye ~220deg + if (dE < eyeW) bump += eyeR * Math.pow(1 - dE / eyeW, 2); + // -- Nose: small inward dip at ~270deg -- + dE = angDist(t, pi * 1.5); + if (dE < 0.12) bump -= R * 0.06 * (1 - dE / 0.12); + // -- Mouth: W-shape at bottom (~255deg and ~285deg bumps, ~270deg dip) -- + dE = angDist(t, pi * 1.42); // left mouth corner ~255deg + if (dE < 0.1) bump += R * 0.04 * (1 - dE / 0.1); + dE = angDist(t, pi * 1.58); // right mouth corner ~285deg + if (dE < 0.1) bump += R * 0.04 * (1 - dE / 0.1); + // -- Chin: slight flat tuck -- + dE = angDist(t, pi * 1.5); + if (dE < 0.35) bump -= R * 0.03 * Math.pow(1 - dE / 0.35, 2); + var totalR = bodyR + bump; + dx = totalR * Math.cos(t); dy = totalR * Math.sin(t); + } else if (shape === 'butterfly') { + // Butterfly curve: r = e^cos(t) - 2*cos(4t), closes in one 2pi cycle + var r = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t); + var scale = R * 0.21; + dx = scale * r * Math.sin(t); dy = -scale * r * Math.cos(t); + } else if (shape === 'infinityKnot') { + dx = R * 0.7 * (Math.sin(t) + 2 * Math.sin(2 * t)) / 3; + dy = R * 0.7 * (Math.cos(t) - 2 * Math.cos(2 * t)) / 3; + } else { + dx = R * Math.cos(t); dy = R * Math.sin(t); + } + return { dx: dx, dy: dy }; +} +// Build SVG path visualizing the LFO shape on the morph pad +function buildLfoPathSvg(b) { + var depth = (b.lfoDepth != null ? b.lfoDepth : 80) / 100; + var R = depth * 0.48; + var shape = b.lfoShape || 'circle'; + var twoPi = Math.PI * 2; + var N = 200; + var pts = []; + for (var i = 0; i <= N; i++) { + var t = (i / N) * twoPi; + var s = computeShapeDxDy(shape, t, R); + var px = (0.5 + s.dx) * 100; + var py = (1 - (0.5 + s.dy)) * 100; + pts.push(px.toFixed(1) + ',' + py.toFixed(1)); + } + return ''; +} +// Build SVG for shapes block - independent from LFO path +function buildShapePathSvg(b, overrideSize) { + var sz = overrideSize !== undefined ? overrideSize : (b.shapeSize != null ? b.shapeSize : 80); + var R = (sz / 100) * 0.48; // size 100 = fills the pad circle with margin for dot + var shape = b.shapeType || 'circle'; + var twoPi = Math.PI * 2; + var N = 200; + var pts = []; + for (var i = 0; i <= N; i++) { + var t = (i / N) * twoPi; + var s = computeShapeDxDy(shape, t, R); + var px = (0.5 + s.dx) * 100; + var py = (1 - (0.5 + s.dy)) * 100; + pts.push(px.toFixed(1) + ',' + py.toFixed(1)); + } + return ''; +} +function renderShapesBody(b) { + var h = ''; + + // ── 1. XY PAD ── + h += '
'; + h += '
'; + h += buildShapePathSvg(b); + var trackClass = 'shape-readout shape-readout-' + (b.shapeTracking || 'horizontal'); + h += '
'; + h += '
'; + h += '
'; + + // ── 2. RANGE — always relative (offset source) + polarity + retrigger ── + b.shapeRange = 'relative'; + h += '
'; + h += '
'; + h += '
'; + h += '
Retrigger
'; + h += '
'; + + // ── 3. SHAPE & TRACKING — shape select + tracking + knobs + sliders + sync ── + h += '
'; + h += '
'; + // Shape selector + h += ''; + // Tracking axis + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + // Speed + Spin + Size knobs + var sizeVal = b.shapeSize != null ? b.shapeSize : 80; + var speedVal = b.shapeSpeed || 50; + var spinVal = b.shapeSpin || 0; + var phaseVal = b.shapePhaseOffset || 0; + h += buildKnobRow( + buildBlockKnob(speedVal, 1, 100, 36, 'shapes', 'shapeSpeed', b.id, 'Speed', '%', null, b.shapeTempoSync) + + buildBlockKnob(spinVal, -100, 100, 36, 'shapes', 'shapeSpin', b.id, 'Spin', '±') + + buildBlockKnob(sizeVal, 1, 100, 36, 'shapes', 'shapeSize', b.id, 'Size', '%') + + buildBlockKnob(phaseVal, 0, 360, 36, 'shapes', 'shapePhaseOffset', b.id, 'Phase', '°') + ); + // Sync toggle + h += '
Sync'; + if (b.shapeTempoSync) { + var divs = [{ v: '4/1', label: '4 Bars' }, { v: '2/1', label: '2 Bars' }].concat(BEAT_DIVS); + h += renderBeatDivSelect(b.id, 'shapeSyncDiv', b.shapeSyncDiv, divs); + h += '
'; + } + h += '
'; + h += '
'; + + return h; +} +// ── SHAPES RANGE: per-param range variant of Shapes ── +function renderShapesRangeBody(b) { + var h = ''; + // ── 1. XY PAD (same as Shapes) ── + h += '
'; + h += '
'; + h += buildShapePathSvg(b, 100); // Always max size for shapes_range + var trackClass = 'shape-readout shape-readout-' + (b.shapeTracking || 'horizontal'); + h += '
'; + h += '
'; + h += '
'; + + // ── 2. POLARITY (no abs/rel — always relative) ── + h += '
'; + h += '
'; + h += '
'; + + // ── 3. PER-PARAM RANGES list ── + h += '
'; + h += '
'; + var rangeCount = 0; + if (b.targetRanges) { + var tArr = Array.from(b.targets); + for (var ri = 0; ri < tArr.length; ri++) { + var pid = tArr[ri], p = PMap[pid]; + if (!p) continue; + var range = b.targetRanges[pid] !== undefined ? b.targetRanges[pid] : 0; + rangeCount++; + var pct = Math.round(range * 100); + var sign = pct > 0 ? '+' : ''; + var base = b.targetRangeBases && b.targetRangeBases[pid] !== undefined ? b.targetRangeBases[pid] : p.v; + var basePct = Math.round(base * 100); + var rangeClass = pct > 0 ? ' sr-pos' : (pct < 0 ? ' sr-neg' : ''); + h += '
' + paramPluginName(pid) + ': ' + p.name + '' + sign + pct + '% @ ' + basePct + '%
'; + } + } + if (rangeCount === 0) h += '
Click Assign, then drag knobs to set ranges
'; + h += '
'; + + // ── 4. SHAPE & TRACKING (same as Shapes but no depth/size knobs) ── + h += '
'; + h += '
'; + // Shape selector + h += ''; + // Tracking axis + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + // Speed + Spin knobs (no Size knob — always max for shapes_range) + var speedVal = b.shapeSpeed || 50; + var spinVal = b.shapeSpin || 0; + var phaseVal = b.shapePhaseOffset || 0; + h += buildKnobRow( + buildBlockKnob(speedVal, 1, 100, 36, 'shapes', 'shapeSpeed', b.id, 'Speed', '%', null, b.shapeTempoSync) + + buildBlockKnob(spinVal, -100, 100, 36, 'shapes', 'shapeSpin', b.id, 'Spin', '±') + + buildBlockKnob(phaseVal, 0, 360, 36, 'shapes', 'shapePhaseOffset', b.id, 'Phase', '°') + ); + // Sync toggle + h += '
Sync'; + if (b.shapeTempoSync) { + var divs = [{ v: '4/1', label: '4 Bars' }, { v: '2/1', label: '2 Bars' }].concat(BEAT_DIVS); + h += renderBeatDivSelect(b.id, 'shapeSyncDiv', b.shapeSyncDiv, divs); + h += '
'; + } + h += '
'; + h += '
'; + return h; +} + +// Lane Mode functions are defined in lane_module.js +// (renderLaneBody, laneCanvasSetup, laneDrawCanvas, laneSetupMouse, etc.) + + +function renderMorphBody(b) { + var h = ''; + + // ── 1. XY PAD — pad + snap chips + add/library buttons ── + h += '
'; + h += '
'; + // LFO shape path visualization + if (b.morphMode === 'auto' && b.exploreMode === 'shapes') { + h += buildLfoPathSvg(b); + } + if (!b.snapshots || !b.snapshots.length) { + h += '
Add a snapshot to begin
'; + } else { + for (var si = 0; si < b.snapshots.length; si++) { + var s = b.snapshots[si]; + h += '
' + (s.name || ('S' + (si + 1))) + '
'; + } + var playManual = (b.morphMode === 'manual') ? ' manual' : ''; + h += '
'; + } + h += '
'; + // Snapshot chips + add/library buttons + h += '
'; + if (b.snapshots) { + for (var si = 0; si < b.snapshots.length; si++) { + var chipLabel = (b.snapshots[si].name || ('S' + (si + 1))); + if (b.snapshots[si].source) chipLabel += ' (' + b.snapshots[si].source + ')'; + h += '' + chipLabel + '×'; + } + } + var snapDisabled = (b.snapshots && b.snapshots.length >= 12) ? ' disabled' : ''; + h += ''; + var libDisabled = (b.snapshots && b.snapshots.length >= 12) ? ' disabled' : ''; + h += ''; + h += '
'; + + // ── 2. MOVEMENT — mode + explore/trigger + knobs + sync all in one inset box ── + h += '
'; + h += '
'; + // Mode selector + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + + // Auto sub-panel + if (b.morphMode === 'auto') { + // Explore mode + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + // Shape selector (shapes explore only) + var morphSynced = !!b.morphTempoSync; + if (b.exploreMode === 'shapes') { + h += ''; + // LFO knobs: Size, Spin, Speed + h += buildKnobRow( + buildBlockKnob(b.lfoDepth != null ? b.lfoDepth : 80, 0, 100, 36, 'morph', 'lfoDepth', b.id, 'Size', '%') + + buildBlockKnob(b.lfoRotation || 0, -100, 100, 36, 'morph', 'lfoRotation', b.id, 'Spin', '\u00b1') + + buildBlockKnob(b.morphSpeed, 0, 100, 36, 'morph', 'morphSpeed', b.id, 'Speed', '%', null, morphSynced) + ); + } + if (b.exploreMode !== 'shapes') { + // Speed slider for non-shapes auto explore modes + h += '
Speed' + b.morphSpeed + '%
'; + } + // Tempo Sync toggle + beat division + h += '
Sync'; + if (b.morphTempoSync) { + h += renderBeatDivSelect(b.id, 'morphSyncDiv', b.morphSyncDiv, MORPH_DIVS); + h += '
'; + } + h += '
'; + } + + // Trigger sub-panel + if (b.morphMode === 'trigger') { + h += '
Action
'; + h += ''; + h += ''; + h += '
'; + if (b.morphAction === 'step') { + h += '
'; + h += ''; + h += ''; + h += '
'; + } + h += '
'; + h += '
Source
'; + h += ''; + h += ''; + h += ''; + h += '
'; + // Source sub-options + if (b.morphSource === 'tempo') { + h += '
Div' + renderBeatDivSelect(b.id, 'beatDiv', b.beatDiv) + '
'; + } else if (b.morphSource === 'midi') { + h += '
Mode'; + if (b.midiMode === 'specific_note') h += ''; + if (b.midiMode === 'cc') h += ''; + h += 'Ch
'; + } else if (b.morphSource === 'audio') { + h += '
Src'; + h += 'ThrdB
'; + } + } + h += '
'; + + // ── 3. MODIFIERS — two columns: movement (jitter+glide) | snapshots (radius) ── + h += '
'; + h += '
'; + // Left: Movement modifiers + h += '
'; + h += buildKnobRow( + buildBlockKnob(b.jitter, 0, 100, 36, 'morph', 'jitter', b.id, 'Jitter', '%') + + buildBlockKnob(b.morphGlide, 1, 2000, 36, 'morph', 'morphGlide', b.id, 'Glide', 'ms') + ); + h += '
'; + // Divider + h += '
'; + // Right: Snapshot radius + h += '
'; + h += buildKnobRow( + buildBlockKnob(b.snapRadius || 100, 5, 100, 36, 'morph', 'snapRadius', b.id, 'Radius', '%') + ); + h += '
'; + h += '
'; + + return h; +} + +function wireBlocks() { + var syncTimer = null; + function debouncedSync() { if (syncTimer) cancelAnimationFrame(syncTimer); syncTimer = requestAnimationFrame(function () { syncTimer = null; syncBlocksToHost(); }); } + document.querySelectorAll('.lbody').forEach(function (b) { b.onclick = function (e) { e.stopPropagation(); }; }); + document.querySelectorAll('.lhead').forEach(function (h) { h.onclick = function (e) { if (e.target.closest('.assign-btn') || e.target.closest('.lclose') || e.target.closest('[data-pwr]')) return; var id = parseInt(h.dataset.id); var b = findBlock(id); if (b) { b.expanded = !b.expanded; renderSingleBlock(id); } }; }); + // Right-click context menu on block header — Duplicate / Delete + document.querySelectorAll('.lhead').forEach(function (h) { + h.addEventListener('contextmenu', function (e) { + e.preventDefault(); e.stopPropagation(); + var bId = parseInt(h.dataset.id); + var b = findBlock(bId); if (!b) return; + var old = document.querySelector('.block-ctx-menu'); + if (old) old.remove(); + var menu = document.createElement('div'); + menu.className = 'block-ctx-menu lane-add-menu'; + // Duplicate + var dup = document.createElement('div'); + dup.className = 'lane-add-menu-item'; + dup.textContent = '\u2398 Duplicate Block'; + dup.onclick = function (ev) { + ev.stopPropagation(); + menu.remove(); + pushUndoSnapshot(); + var newId = ++bc; + // Deep clone: JSON round-trip, then restore Set for targets + var clone = JSON.parse(JSON.stringify(b, function (key, val) { + if (val instanceof Set) return { __set: Array.from(val) }; + return val; + })); + clone.id = newId; + clone.colorIdx = newId - 1; + clone.expanded = true; + // Restore Sets + if (clone.targets && clone.targets.__set) clone.targets = new Set(clone.targets.__set); + else clone.targets = new Set(); + // Restore lane _sel Sets + if (clone.lanes) { + clone.lanes.forEach(function (l) { + if (!l._sel) l._sel = new Set(); + else if (l._sel.__set) l._sel = new Set(l._sel.__set); + else l._sel = new Set(); + }); + } + blocks.push(clone); + actId = newId; + renderBlocks(); renderAllPlugins(); updCounts(); syncBlocksToHost(); + }; + menu.appendChild(dup); + // Separator + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + // Delete + var del = document.createElement('div'); + del.className = 'lane-add-menu-item'; + del.textContent = '\u2716 Delete Block'; + del.style.color = '#e57373'; + del.onclick = function (ev) { + ev.stopPropagation(); + menu.remove(); + pushUndoSnapshot(); + blocks = blocks.filter(function (bl) { return bl.id !== bId; }); + if (actId === bId) actId = blocks.length ? blocks[0].id : null; + if (assignMode === bId) assignMode = null; + renderBlocks(); renderAllPlugins(); updCounts(); syncBlocksToHost(); + }; + menu.appendChild(del); + // Position with viewport clamp + menu.style.cssText = 'position:fixed;z-index:9999;visibility:hidden;'; + document.body.appendChild(menu); + var mw = menu.offsetWidth, mh = menu.offsetHeight; + var vw = window.innerWidth, vh = window.innerHeight; + var ml = e.clientX, mt = e.clientY; + if (ml + mw > vw - 4) ml = vw - mw - 4; + if (mt + mh > vh - 4) mt = Math.max(4, e.clientY - mh); + menu.style.left = ml + 'px'; + menu.style.top = mt + 'px'; + menu.style.visibility = ''; + setTimeout(function () { + var dismiss = function (de) { + if (menu.contains(de.target)) return; + menu.remove(); + document.removeEventListener('mousedown', dismiss); + }; + document.addEventListener('mousedown', dismiss); + }, 50); + }); + }); + document.querySelectorAll('.assign-btn').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); var id = parseInt(btn.dataset.id); if (assignMode === id) assignMode = null; else { assignMode = id; actId = id; var b = findBlock(id); if (b && !b.expanded) b.expanded = true; } renderBlocks(); renderAllPlugins(); }; }); + document.querySelectorAll('.lclose').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); var id = parseInt(btn.dataset.id); pushUndoSnapshot(); blocks = blocks.filter(function (b) { return b.id !== id; }); if (actId === id) actId = blocks.length ? blocks[0].id : null; if (assignMode === id) assignMode = null; renderBlocks(); renderAllPlugins(); updCounts(); syncBlocksToHost(); }; }); + document.querySelectorAll('[data-pwr]').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); var bId = parseInt(btn.dataset.pwr); var b = findBlock(bId); if (b) { b.enabled = !b.enabled; renderSingleBlock(bId); renderAllPlugins(); syncBlocksToHost(); } }; }); + document.querySelectorAll('.seg,.seg-inline').forEach(function (seg) { seg.querySelectorAll('button').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); var bId = parseInt(seg.dataset.b); var b = findBlock(bId); if (b) { var oldMode = b[seg.dataset.f]; b[seg.dataset.f] = btn.dataset.v; if (seg.dataset.f === 'mode' && oldMode !== btn.dataset.v) { /* Leaving shapes/shapes_range: restore params to stored bases */ if ((oldMode === 'shapes' || oldMode === 'shapes_range') && b.targets.size > 0) { var basesMap = oldMode === 'shapes_range' ? b.targetRangeBases : b.targetBases; var setFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setParam') : null; b.targets.forEach(function (pid) { var p = PMap[pid]; if (!p) return; var base = basesMap && basesMap[pid] !== undefined ? basesMap[pid] : p.v; p.v = base; if (setFn && p.hostId !== undefined) setFn(p.hostId, p.realIndex, base); }); } /* Entering shapes: capture current param values as bases for existing targets */ if (btn.dataset.v === 'shapes' && b.targets.size > 0) { if (!b.targetBases) b.targetBases = {}; b.targets.forEach(function (pid) { var p = PMap[pid]; if (p && b.targetBases[pid] === undefined) b.targetBases[pid] = p.v; }); } } renderSingleBlock(bId); if (seg.dataset.f === 'mode') renderAllPlugins(); syncBlocksToHost(); } }; }); }); + document.querySelectorAll('.tgl').forEach(function (t) { t.onclick = function (e) { e.stopPropagation(); var bId = parseInt(t.dataset.b); var b = findBlock(bId); if (b) { b[t.dataset.f] = !b[t.dataset.f]; renderSingleBlock(bId); syncBlocksToHost(); } }; }); + document.querySelectorAll('.sub-sel').forEach(function (s) { s.onchange = function () { var bId = parseInt(s.dataset.b); var b = findBlock(bId); if (b) { var val = s.value; if (s.dataset.f === 'midiCh') val = parseInt(val) || 0; else if (s.dataset.f === 'envFilterBW') val = parseFloat(val) || 2; b[s.dataset.f] = val; renderSingleBlock(bId); syncBlocksToHost(); } }; }); + // Default values for double-click reset + var knobDefaults = { rMin: 0, rMax: 100, threshold: -12, glideMs: 200, envAtk: 10, envRel: 100, envSens: 50, envFilterFreq: 50, envFilterBW: 5, morphSpeed: 50, morphGlide: 200, jitter: 0, snapRadius: 100, lfoDepth: 80, lfoRotation: 0, sampleSpeedPct: 100, qSteps: 12, shapeSize: 80, shapeSpin: 0, shapeSpeed: 50, shapePhaseOffset: 0 }; + document.querySelectorAll('.lbody input[type="range"]').forEach(function (sl) { + if (!sl.dataset.b) return; + // Capture state before slider drag for undo + var _sliderUndoSnap = null; + var _sliderStartVal = null; + sl.addEventListener('mousedown', function () { + var b = findBlock(parseInt(sl.dataset.b)); + if (!b) return; + _sliderUndoSnap = captureFullSnapshot(); + var f = sl.dataset.f; + _sliderStartVal = (f === 'sampleSpeedPct') ? (b.sampleSpeed * 100) : (b[f] != null ? b[f] : parseFloat(sl.value)); + }); + sl.addEventListener('change', function () { + var b = findBlock(parseInt(sl.dataset.b)); + if (!b || !_sliderUndoSnap) return; + var f = sl.dataset.f; + var curVal = (f === 'sampleSpeedPct') ? (b.sampleSpeed * 100) : b[f]; + if (_sliderStartVal !== null && curVal !== _sliderStartVal) { + undoStack.push({ type: 'full', snapshot: _sliderUndoSnap }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); + } + _sliderUndoSnap = null; _sliderStartVal = null; + }); + // Continuous update while dragging + sl.oninput = function () { var b = findBlock(parseInt(sl.dataset.b)); if (!b) return; var f = sl.dataset.f; if (f === 'sampleSpeedPct') { b.sampleSpeed = parseFloat(sl.value) / 100; } else { b[f] = parseFloat(sl.value); } var row = sl.closest('.sl-row,.sub-row'); if (row) { var v = row.querySelector('.sl-val'); if (v) { if (f === 'threshold') v.textContent = sl.value + ' dB'; else if (f === 'glideMs' || f === 'envAtk' || f === 'envRel' || f === 'morphGlide') v.textContent = sl.value + 'ms'; else if (f === 'sampleSpeedPct') v.textContent = b.sampleSpeed.toFixed(1) + 'x'; else if (f === 'morphSpeed') v.textContent = morphSpeedDisplay(parseFloat(sl.value)); else if (f === 'lfoRotation') { var rv = parseInt(sl.value); v.textContent = (rv > 0 ? '+' : '') + rv + '%'; } else v.textContent = sl.value + '%'; } } if (f === 'lfoDepth' || f === 'lfoRotation') { var pad = document.querySelector('.morph-pad[data-b="' + sl.dataset.b + '"]:not(.shapes-pad)'); if (pad) { var old = pad.querySelector('.lfo-path-svg'); if (old) old.remove(); pad.insertAdjacentHTML('afterbegin', buildLfoPathSvg(b)); } } if (f === 'shapeSize') { var pad = document.querySelector('.shapes-pad[data-b="' + sl.dataset.b + '"]'); if (pad) { var old = pad.querySelector('.lfo-path-svg'); if (old) old.remove(); pad.insertAdjacentHTML('afterbegin', buildShapePathSvg(b, b.mode === 'shapes_range' ? 100 : undefined)); } } debouncedSync(); }; + // Double-click to reset to default + sl.ondblclick = function (e) { e.preventDefault(); var b = findBlock(parseInt(sl.dataset.b)); if (!b) return; var f = sl.dataset.f; var def = knobDefaults[f]; if (def !== undefined) { var snap = captureFullSnapshot(); sl.value = def; sl.dispatchEvent(new Event('input')); undoStack.push({ type: 'full', snapshot: snap }); if (undoStack.length > maxUndo) undoStack.shift(); redoStack = []; updateUndoBadge(); } }; + }); + // Block knob drag interaction + // knobDefaults already declared above — reused for knob double-click reset + document.querySelectorAll('.bk').forEach(function (bk) { + if (!bk.dataset.b) return; + var bId = parseInt(bk.dataset.b), f = bk.dataset.f; + var mn = parseFloat(bk.dataset.min), mx = parseFloat(bk.dataset.max); + bk.addEventListener('mousedown', function (e) { + e.preventDefault(); e.stopPropagation(); + if (bk.dataset.disabled) return; + var b = findBlock(bId); if (!b) return; + var _knobUndoSnap = captureFullSnapshot(); + var startY = e.clientY; + var curVal = (f === 'sampleSpeedPct') ? (b.sampleSpeed * 100) : (b[f] != null ? b[f] : mn); + var _knobStartVal = curVal; + var range = mx - mn; + var sensitivity = 150; + function onMove(me) { + var dy = startY - me.clientY; + var nv = Math.max(mn, Math.min(mx, curVal + (dy / sensitivity) * range)); + nv = Math.round(nv); + if (f === 'sampleSpeedPct') b.sampleSpeed = nv / 100; + else b[f] = nv; + // Get mode from closest card + var card = bk.closest('.lcard'); + var mode = 'rand'; + if (card) { + if (card.classList.contains('mode-env')) mode = 'env'; + else if (card.classList.contains('mode-smp')) mode = 'smp'; + else if (card.classList.contains('mode-morph')) mode = 'morph'; + else if (card.classList.contains('mode-shapes')) mode = 'shapes'; + } + // Rebuild SVG + var norm = (nv - mn) / range; + var size = 36, r = size / 2, cx = r, cy = r, ir = r - 3; + var sa = 135 * Math.PI / 180, ea = 405 * Math.PI / 180, sp = ea - sa; + var va = sa + norm * sp; + var tPath = describeArc(cx, cy, ir, sa, ea); + var vPath = norm > 0.005 ? describeArc(cx, cy, ir, sa, va) : ''; + var dx = cx + ir * Math.cos(va), dy2 = cy + ir * Math.sin(va); + var tVar = 'var(--lk-' + mode + '-track, var(--knob-track))'; + var vVar = 'var(--lk-' + mode + '-value, var(--knob-value))'; + var dVar = 'var(--lk-' + mode + '-dot, var(--knob-dot))'; + var svg = ''; + svg += ''; + if (vPath) svg += ''; + svg += ''; + svg += ''; + var svgEl = bk.querySelector('.bk-svg'); if (svgEl) svgEl.innerHTML = svg; + // Update value display + var valEl = bk.querySelector('.bk-val'); + if (valEl) { + var unit = ''; + if (f === 'envAtk' || f === 'envRel' || f === 'glideMs' || f === 'morphGlide') valEl.textContent = nv + 'ms'; + else if (f === 'threshold') valEl.textContent = nv + 'dB'; + else if (f === 'shapeSpin' || f === 'lfoRotation') valEl.textContent = (nv > 0 ? '+' : '') + nv + '%'; + else if (f === 'shapePhaseOffset') valEl.textContent = nv + '°'; + else if (f === 'envFilterFreq') valEl.textContent = envFmtHz(nv); + else if (f === 'envFilterBW') valEl.textContent = envFmtBw(nv); + else if (f === 'morphSpeed') valEl.textContent = morphSpeedDisplay(nv); + else if (f === 'sampleSpeedPct') valEl.textContent = b.sampleSpeed.toFixed(1) + 'x'; + else valEl.textContent = nv + '%'; + } + // Live update pad visualizations + if (f === 'lfoDepth' || f === 'lfoRotation') { + var pad = document.querySelector('.morph-pad[data-b="' + bId + '"]:not(.shapes-pad)'); + if (pad) { var o = pad.querySelector('.lfo-path-svg'); if (o) o.remove(); pad.insertAdjacentHTML('afterbegin', buildLfoPathSvg(b)); } + } + if (f === 'shapeSize') { + var pad = document.querySelector('.shapes-pad[data-b="' + bId + '"]'); + if (pad) { var o = pad.querySelector('.lfo-path-svg'); if (o) o.remove(); pad.insertAdjacentHTML('afterbegin', buildShapePathSvg(b, b.mode === 'shapes_range' ? 100 : undefined)); } + } + // Live update filter visualization + if (f === 'envFilterFreq' || f === 'envFilterBW') { + var vizWrap = document.getElementById('envViz-' + bId); + if (vizWrap) vizWrap.innerHTML = buildEnvFilterSvg(b); + } + if (f === 'snapRadius') { + var pad = document.querySelector('.morph-pad[data-b="' + bId + '"]:not(.shapes-pad)'); + if (pad) { + pad.querySelectorAll('.radius-ring').forEach(function (r) { r.remove(); }); + var ringDiameter = (b.snapRadius || 100) * 2; + if (b.snapshots) b.snapshots.forEach(function (s) { + var ring = document.createElement('div'); + ring.className = 'radius-ring'; + ring.style.cssText = 'width:' + ringDiameter + '%;height:' + ringDiameter + '%;left:' + (s.x * 100) + '%;top:' + ((1 - s.y) * 100) + '%;'; + pad.appendChild(ring); + }); + } + } + debouncedSync(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + // Push undo if value changed + var endVal = (f === 'sampleSpeedPct') ? (b.sampleSpeed * 100) : b[f]; + if (endVal !== _knobStartVal && _knobUndoSnap) { + undoStack.push({ type: 'full', snapshot: _knobUndoSnap }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); + } + // Fade out radius rings + if (f === 'snapRadius') { + var pad = document.querySelector('.morph-pad[data-b="' + bId + '"]:not(.shapes-pad)'); + if (pad) pad.querySelectorAll('.radius-ring').forEach(function (r) { + r.classList.add('fading'); + setTimeout(function () { r.remove(); }, 400); + }); + } + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + // Double-click to reset + bk.addEventListener('dblclick', function (e) { + e.preventDefault(); + var b = findBlock(bId); if (!b) return; + var def = knobDefaults[f]; + if (def !== undefined) { + var snap = captureFullSnapshot(); + if (f === 'sampleSpeedPct') b.sampleSpeed = def / 100; + else b[f] = def; + undoStack.push({ type: 'full', snapshot: snap }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); + renderSingleBlock(bId); syncBlocksToHost(); + } + }); + // Scroll wheel on block knobs + bk.addEventListener('wheel', function (e) { + e.preventDefault(); e.stopPropagation(); + if (bk.dataset.disabled) return; + var b = findBlock(bId); if (!b) return; + var range = mx - mn; + var step = e.shiftKey ? range * 0.002 : range * 0.01; + var delta = e.deltaY < 0 ? step : -step; + var curVal = (f === 'sampleSpeedPct') ? (b.sampleSpeed * 100) : (b[f] != null ? b[f] : mn); + var nv = Math.round(Math.max(mn, Math.min(mx, curVal + delta))); + if (nv === Math.round(curVal)) return; + if (f === 'sampleSpeedPct') b.sampleSpeed = nv / 100; + else b[f] = nv; + renderSingleBlock(bId); debouncedSync(); + }, { passive: false }); + }); + document.querySelectorAll('.sub-input').forEach(function (inp) { if (!inp.dataset.b) return; inp.onchange = function () { var b = findBlock(parseInt(inp.dataset.b)); if (b) { b[inp.dataset.f] = Math.max(0, Math.min(128, parseInt(inp.value) || 0)); syncBlocksToHost(); } }; }); + document.querySelectorAll('.fire').forEach(function (btn) { btn.onclick = function (e) { e.stopPropagation(); btn.classList.add('flash'); setTimeout(function () { btn.classList.remove('flash'); }, 250); var bId = parseInt(btn.dataset.b); var blk = findBlock(bId); if (blk) { var ov = []; blk.targets.forEach(function (pid) { var p = PMap[pid]; if (p && !p.lk && !p.alk) ov.push({ id: pid, val: p.v }); }); randomize(bId); if (ov.length) pushMultiParamUndo(ov); } flashDot('midiD'); }; }); + // .tx (remove) and .tgt-name (locate) handlers are wired inline in _buildTargetRow + // Target search filter (compatible with virtual scroll) + document.querySelectorAll('.tgt-search').forEach(function (inp) { + inp.onclick = function (e) { e.stopPropagation(); }; + inp.onkeydown = function (e) { e.stopPropagation(); }; + inp.oninput = function () { + var bId = parseInt(inp.dataset.b); + var b = findBlock(bId); if (!b) return; + b._tgtFilter = inp.value; + var list = inp.parentElement.querySelector('.tgt-list[data-b="' + bId + '"]'); + if (!list) return; + _fillTargetList(list, b, bColor(b.colorIdx)); + }; + }); + // Sample load buttons + document.querySelectorAll('.load-smp').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b); + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('browseSample'); + fn(bId).then(function (result) { + if (!result) return; + var b = findBlock(bId); + if (!b) return; + b.sampleName = result.name || ''; + b.sampleWaveform = result.waveform || []; + renderSingleBlock(bId); + drawWaveform(bId); + }); + }; + }); + // Draw waveforms for sample blocks that already have data + blocks.forEach(function (b) { if (b.mode === 'sample' && b.sampleWaveform && b.sampleWaveform.length) drawWaveform(b.id); }); + // Morph pad wiring: snapshot add, delete, playhead drag, snapshot drag + document.querySelectorAll('.snap-add-btn').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b); + var b = findBlock(bId); if (!b) return; + if (!b.snapshots) b.snapshots = []; + if (b.snapshots.length >= 12) return; + // Capture ALL plugin params — not just assigned targets. + // This way snapshots work even if you assign params later. + var vals = {}; + var sourceName = ''; + for (var pid in PMap) { + var p = PMap[pid]; + if (p) { + vals[pid] = p.v; + if (!sourceName && p.hostId !== undefined) sourceName = getPluginName(p.hostId); + } + } + var spos = getSnapSectorPos(b.snapshots.length); + b.snapshots.push({ x: spos.x, y: spos.y, values: vals, name: 'S' + (b.snapshots.length + 1), source: sourceName }); + renderSingleBlock(bId); syncBlocksToHost(); + // Visual flash feedback on the pad + var pad = document.querySelector('.morph-pad[data-b="' + bId + '"]'); + if (pad) { pad.classList.remove('snap-flash'); void pad.offsetWidth; pad.classList.add('snap-flash'); } + // Glow on the newest chip + var chips = document.querySelectorAll('.snap-chip[data-b="' + bId + '"]'); + if (chips.length) { var last = chips[chips.length - 1]; last.classList.add('just-added'); setTimeout(function () { last.classList.remove('just-added'); }, 600); } + }; + }); + // Snapshot Library buttons (morph pad) + document.querySelectorAll('.snap-lib-btn').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b); + if (typeof openSnapshotLibrary === 'function') openSnapshotLibrary(bId); + }; + }); + document.querySelectorAll('.snap-del').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b), si = parseInt(btn.dataset.si); + var b = findBlock(bId); if (!b || !b.snapshots) return; + b.snapshots.splice(si, 1); + renderSingleBlock(bId); syncBlocksToHost(); + }; + }); + document.querySelectorAll('.snap-chip').forEach(function (chip) { + chip.onclick = function (e) { + if (e.target.classList.contains('snap-del')) return; + e.stopPropagation(); + var bId = parseInt(chip.dataset.b), si = parseInt(chip.dataset.si); + var b = findBlock(bId); if (!b || !b.snapshots || !b.snapshots[si]) return; + b.playheadX = b.snapshots[si].x; + b.playheadY = b.snapshots[si].y; + renderSingleBlock(bId); syncBlocksToHost(); + }; + }); + // Lightweight playhead updater — sends only blockId + x,y to C++ (no full JSON reparse) + var _morphPlayheadFn = null; + function sendPlayhead(bId, x, y) { + if (!_morphPlayheadFn) _morphPlayheadFn = window.__juceGetNativeFunction ? window.__juceGetNativeFunction('updateMorphPlayhead') : null; + if (_morphPlayheadFn) _morphPlayheadFn(bId, x, y); + } + // Playhead drag (manual mode) + document.querySelectorAll('.playhead-dot.manual').forEach(function (dot) { + dot.onmousedown = function (e) { + e.stopPropagation(); e.preventDefault(); + var pad = dot.closest('.morph-pad'); if (!pad) return; + var bId = parseInt(pad.dataset.b); + var b = findBlock(bId); if (!b) return; + var onMove = function (ev) { + var rect = pad.getBoundingClientRect(); + var rawX = (ev.clientX - rect.left) / rect.width; + var rawY = 1 - (ev.clientY - rect.top) / rect.height; + var c = clampToCircle(rawX, rawY); + b.playheadX = c.x; + b.playheadY = c.y; + dot.style.left = (c.x * 100) + '%'; + dot.style.top = ((1 - c.y) * 100) + '%'; + sendPlayhead(bId, c.x, c.y); // lightweight update — C++ runs IDW interpolation + }; + var onUp = function () { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + syncBlocksToHost(); // full sync on release for state persistence + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + // Pad click-to-jump + drag (manual mode) — clicking anywhere on the pad moves the playhead + document.querySelectorAll('.morph-pad:not(.shapes-pad)').forEach(function (pad) { + pad.onmousedown = function (e) { + if (e.target.classList.contains('snap-dot') || e.target.classList.contains('snap-label')) return; + if (e.target.classList.contains('playhead-dot')) return; + var bId = parseInt(pad.dataset.b); + var b = findBlock(bId); if (!b || b.morphMode !== 'manual') return; + if (!b.snapshots || !b.snapshots.length) return; + e.preventDefault(); + var dot = document.getElementById('morphHead-' + bId); + if (!dot) return; + // Jump to click position + var rect = pad.getBoundingClientRect(); + var c = clampToCircle((e.clientX - rect.left) / rect.width, 1 - (e.clientY - rect.top) / rect.height); + b.playheadX = c.x; b.playheadY = c.y; + dot.style.left = (c.x * 100) + '%'; dot.style.top = ((1 - c.y) * 100) + '%'; + sendPlayhead(bId, c.x, c.y); + // Enter drag mode + var onMove = function (ev) { + var r = pad.getBoundingClientRect(); + var mc = clampToCircle((ev.clientX - r.left) / r.width, 1 - (ev.clientY - r.top) / r.height); + b.playheadX = mc.x; b.playheadY = mc.y; + dot.style.left = (mc.x * 100) + '%'; dot.style.top = ((1 - mc.y) * 100) + '%'; + sendPlayhead(bId, mc.x, mc.y); + }; + var onUp = function () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); syncBlocksToHost(); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + document.querySelectorAll('.snap-dot').forEach(function (dot) { + dot.onmousedown = function (e) { + e.stopPropagation(); e.preventDefault(); + var pad = dot.closest('.morph-pad'); if (!pad) return; + var bId = parseInt(dot.dataset.b), si = parseInt(dot.dataset.si); + var b = findBlock(bId); if (!b || !b.snapshots || !b.snapshots[si]) return; + var onMove = function (ev) { + var rect = pad.getBoundingClientRect(); + var rawX = (ev.clientX - rect.left) / rect.width; + var rawY = 1 - (ev.clientY - rect.top) / rect.height; + var c = clampToCircle(rawX, rawY); + b.snapshots[si].x = c.x; + b.snapshots[si].y = c.y; + dot.style.left = (c.x * 100) + '%'; + dot.style.top = ((1 - c.y) * 100) + '%'; + }; + var onUp = function () { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + syncBlocksToHost(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + // ── Lane mode wiring ── + // Tool buttons + document.querySelectorAll('.lane-tbtn[data-lt]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b), b = findBlock(bId); + if (!b) return; + if (btn.dataset.lt === 'clear') { + b.lanes.forEach(function (l) { + var edgeY = (l.pts.length && l.pts[0].x < 0.01) ? l.pts[0].y : 0.5; + l.pts = [{ x: 0, y: edgeY }, { x: 1, y: edgeY }]; + if (l._sel) l._sel.clear(); + }); + renderSingleBlock(bId); syncBlocksToHost(); return; + } + if (btn.dataset.lt === 'random') { + b.lanes.forEach(function (l) { + laneRandomize(l, b.laneGrid); + if (l._sel) l._sel.clear(); + }); + renderSingleBlock(bId); syncBlocksToHost(); return; + } + b.laneTool = btn.dataset.lt; + // Update button states without full rebuild + btn.closest('.lane-toolbar').querySelectorAll('.lane-tbtn[data-lt]').forEach(function (t) { + if (t.dataset.lt !== 'clear' && t.dataset.lt !== 'random') t.classList.toggle('on', t.dataset.lt === b.laneTool); + }); + }; + }); + // Grid tabs + document.querySelectorAll('.lane-itabs').forEach(function (tabs) { + tabs.querySelectorAll('.lane-itab').forEach(function (tab) { + tab.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(tabs.dataset.b), b = findBlock(bId); + if (!b) return; + b.laneGrid = tab.dataset.v; + tabs.querySelectorAll('.lane-itab').forEach(function (t) { t.classList.toggle('on', t.dataset.v === b.laneGrid); }); + // Grid is a snap aid — switching does NOT move existing points + // Redraw all canvases with new grid + if (b.lanes) b.lanes.forEach(function (l, li) { laneDrawCanvas(b, li); }); + debouncedSync(); + }; + }); + }); + // Lane collapse arrows + document.querySelectorAll('.lane-hdr-arrow').forEach(function (arr) { + arr.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(arr.dataset.b), li = parseInt(arr.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].collapsed = !b.lanes[li].collapsed; + renderSingleBlock(bId); + }; + }); + + // Lane footer text knob drag — mousedown + vertical drag + dblclick reset + document.querySelectorAll('.lane-ft-knob').forEach(function (el) { + var bId = parseInt(el.dataset.b), li = parseInt(el.dataset.li); + var k = el.dataset.lk; + var isMorphSnap = (k === 'morphHold' || k === 'morphDepth' || k === 'morphSlew' || k === 'morphDrift' || k === 'morphDriftRange' || k === 'morphWarp' || k === 'morphSteps'); + var isDepth = (k === 'depth' || k === 'morphDepth'); + var isSteps = (k === 'steps' || k === 'morphSteps'); + // Range: min/max per knob type + var isDriftRange = (k === 'driftRange' || k === 'morphDriftRange'); + var mn, mx; + if (k === 'morphHold' || k === 'morphDepth') { mn = 0; mx = 100; } + else if (k === 'depth') { mn = 0; mx = 200; } + else if (isSteps) { mn = 0; mx = 32; } + else if (isDriftRange) { mn = 0; mx = 100; } + else { mn = -50; mx = 50; } // drift, warp, morphDrift, morphWarp + var defaults = { depth: 100, drift: 0, driftRange: 5, warp: 0, steps: 0, morphHold: 50, morphDepth: 100, morphDrift: 0, morphDriftRange: 5, morphWarp: 0, morphSteps: 0 }; + var labels = { depth: 'Depth ', drift: 'Drift ', driftRange: 'DftRng ', warp: 'Warp ', steps: 'Steps ', morphHold: 'Hold ', morphDepth: 'Dpth ', morphDrift: 'Drift ', morphDriftRange: 'DftRng ', morphWarp: 'Warp ', morphSteps: 'Step ' }; + // Snapshot property key for morph knobs + var snapKey = { morphHold: 'hold', morphDepth: 'depth', morphDrift: 'drift', morphDriftRange: 'driftRange', morphWarp: 'warp', morphSteps: 'steps' }; + function fmt(v) { + if (k === 'depth' || k === 'morphHold' || k === 'morphDepth') return labels[k] + v + '%'; + if (isSteps) return labels[k] + (v || 'Off'); + if (isDriftRange) return labels[k] + v + '%'; + return labels[k] + (v >= 0 ? '+' : '') + v; + } + function getSnap(lane) { + return (lane._selectedSnap != null && lane.morphSnapshots && lane.morphSnapshots[lane._selectedSnap]) ? lane.morphSnapshots[lane._selectedSnap] : null; + } + function readVal(lane) { + if (isMorphSnap) { + var s = getSnap(lane); + if (!s) return defaults[k]; + var raw = s[snapKey[k]]; + if (k === 'morphHold' || k === 'morphDepth') return Math.round((raw != null ? raw : (k === 'morphHold' ? 0.5 : 1.0)) * 100); + return raw != null ? raw : defaults[k]; + } + return lane[k] != null ? lane[k] : defaults[k]; + } + function writeVal(lane, nv) { + if (isMorphSnap) { + // Apply to all selected snapshots (multi-select) + var indices = []; + if (lane._selectedSnaps && lane._selectedSnaps.size > 0) { + lane._selectedSnaps.forEach(function (si) { indices.push(si); }); + } + // Always include the primary selected snap + if (lane._selectedSnap != null && indices.indexOf(lane._selectedSnap) < 0) { + indices.push(lane._selectedSnap); + } + for (var si = 0; si < indices.length; si++) { + var s = lane.morphSnapshots && lane.morphSnapshots[indices[si]]; + if (!s) continue; + if (k === 'morphHold' || k === 'morphDepth') s[snapKey[k]] = nv / 100; + else s[snapKey[k]] = nv; + } + } else { + lane[k] = nv; + } + } + el.addEventListener('mousedown', function (e) { + e.preventDefault(); e.stopPropagation(); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var lane = b.lanes[li]; + // Double-click detection + if (!lane._knobClicks) lane._knobClicks = {}; + var now = Date.now(); + if (lane._knobClicks[k] && now - lane._knobClicks[k] < 350) { + writeVal(lane, defaults[k]); + el.textContent = fmt(defaults[k]); + lane._knobClicks[k] = 0; + laneDrawCanvas(b, li); + renderSingleBlock(bId); debouncedSync(); + return; + } + lane._knobClicks[k] = now; + var startY = e.clientY; + var startVal = readVal(lane); + var dragged = false; + document.body.classList.add('knob-dragging'); + el.classList.add('dragging'); + function onMove(me) { + dragged = true; + var dy = startY - me.clientY; + var nv = Math.round(Math.max(mn, Math.min(mx, startVal + dy * 0.5))); + writeVal(lane, nv); + el.textContent = fmt(nv); + laneDrawCanvas(b, li); + // Continuous sync to C++ during drag + debouncedSync(); + // Cross-update: redraw any lane that has this lane as overlay + for (var ci = 0; ci < b.lanes.length; ci++) { + if (ci === li) continue; + var cl = b.lanes[ci]; + if (cl._overlayLanes && cl._overlayLanes.indexOf(li) >= 0) { + laneDrawCanvas(b, ci); + } + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.classList.remove('knob-dragging'); + el.classList.remove('dragging'); + // Bake effect into selected dots for curve lanes (not morph) + if (!isMorphSnap && dragged && lane._sel && lane._sel.size >= 2 && + (k === 'depth' || k === 'warp' || k === 'steps')) { + var depthV = (lane.depth != null ? lane.depth : 100) / 100; + var warpV = (lane.warp || 0) / 50; + var stepsV = lane.steps || 0; + // Find selection X range + var sxMin = 1, sxMax = 0; + lane._sel.forEach(function (idx) { + if (lane.pts[idx]) { + if (lane.pts[idx].x < sxMin) sxMin = lane.pts[idx].x; + if (lane.pts[idx].x > sxMax) sxMax = lane.pts[idx].x; + } + }); + // Apply processY to each point within selection range + for (var pi = 0; pi < lane.pts.length; pi++) { + if (lane.pts[pi].x >= sxMin && lane.pts[pi].x <= sxMax) { + var y = lane.pts[pi].y; + // Depth + var v = 0.5 + (y - 0.5) * depthV; + // Warp + if (Math.abs(warpV) > 0.001) { + var centered = (v - 0.5) * 2; + if (warpV > 0) { + var wk = 1 + warpV * 8; + v = Math.tanh(centered * wk) / Math.tanh(wk) * 0.5 + 0.5; + } else { + var aw = Math.abs(warpV); + var sign = centered >= 0 ? 1 : -1; + v = Math.pow(Math.abs(centered), 1 / (1 + aw * 3)) * sign * 0.5 + 0.5; + } + } + // Steps + if (stepsV >= 2) v = Math.round(v * stepsV) / stepsV; + lane.pts[pi].y = Math.max(0, Math.min(1, v)); + } + } + // Reset knobs to neutral + lane.depth = 100; + lane.warp = 0; + lane.steps = 0; + } + renderSingleBlock(bId); debouncedSync(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + // Morph curve selector + document.querySelectorAll('.lane-morph-curve-sel').forEach(function (sel) { + sel.onchange = function () { + var bId = parseInt(sel.dataset.b), li = parseInt(sel.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var lane = b.lanes[li]; + var curveVal = parseInt(sel.value) || 0; + // Apply to all selected snapshots + var indices = []; + if (lane._selectedSnaps && lane._selectedSnaps.size > 0) { + lane._selectedSnaps.forEach(function (si) { indices.push(si); }); + } + if (lane._selectedSnap != null && indices.indexOf(lane._selectedSnap) < 0) { + indices.push(lane._selectedSnap); + } + for (var si = 0; si < indices.length; si++) { + var snap = lane.morphSnapshots && lane.morphSnapshots[indices[si]]; + if (snap) snap.curve = curveVal; + } + laneDrawCanvas(b, li); + syncBlocksToHost(); + }; + }); + // Lane mute toggle + document.querySelectorAll('.lane-hdr-mute').forEach(function (sp) { + sp.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(sp.dataset.b), li = parseInt(sp.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].muted = !b.lanes[li].muted; + renderSingleBlock(bId); debouncedSync(); + }; + }); + // Header clear button + document.querySelectorAll('.lane-hdr-clear').forEach(function (sp) { + sp.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(sp.dataset.b), li = parseInt(sp.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + b.lanes[li].pts = [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }]; + if (b.lanes[li]._sel) b.lanes[li]._sel.clear(); + laneDrawCanvas(b, li); + syncBlocksToHost(); + }; + }); + document.querySelectorAll('.lane-del-btn').forEach(function (sp) { + sp.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(sp.dataset.b), li = parseInt(sp.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes || !b.lanes[li]) return; + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + var lane = b.lanes[li]; + // Remove the lane's assigned params from the block's target set + if (lane.pids) { + lane.pids.forEach(function (pid) { + // Only remove if no other lane also has this pid + var usedElsewhere = false; + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi !== li && b.lanes[oi].pids && b.lanes[oi].pids.indexOf(pid) >= 0) { + usedElsewhere = true; break; + } + } + if (!usedElsewhere) b.targets.delete(pid); + }); + } + b.lanes.splice(li, 1); + renderSingleBlock(bId); renderAllPlugins(); syncBlocksToHost(); + }; + }); + // Right-click context menu on lane param chips — Move to lane + document.querySelectorAll('.lane-param-chip[data-b][data-li][data-pid]').forEach(function (chip) { + chip.oncontextmenu = function (e) { + e.preventDefault(); + e.stopPropagation(); + var bId = parseInt(chip.dataset.b), li = parseInt(chip.dataset.li); + var pid = chip.dataset.pid; + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + // Remove existing menus + var old = document.querySelector('.lane-ctx-menu'); + if (old) old.remove(); + var menu = document.createElement('div'); + menu.className = 'lane-ctx-menu lane-add-menu'; + // Build items + var items = []; + // Move to new Param Lane + items.push({ + label: '\u2795 New Param Lane', action: function () { + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + // Remove from current lane + b.lanes[li].pids = b.lanes[li].pids.filter(function (p) { return p !== pid; }); + if (b.lanes[li].pids.length === 0 && !b.lanes[li].morphMode) b.lanes.splice(li, 1); + // Create new curve lane + var col = typeof LANE_COLORS !== 'undefined' ? LANE_COLORS[b.lanes.length % LANE_COLORS.length] : '#64b4ff'; + b.lanes.push({ + pids: [pid], color: col, + pts: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }], + loopLen: '1/1', freeSecs: 4, depth: 100, + drift: 0, driftRange: 5, driftScale: '1/1', warp: 0, steps: 0, + interp: 'smooth', playMode: 'forward', + synced: true, muted: false, collapsed: false, + trigMode: 'loop', trigSource: 'manual', + trigMidiNote: -1, trigMidiCh: 0, trigThreshold: -12, trigAudioSrc: 'main', + trigRetrigger: true, trigHold: false, + morphMode: false, morphSnapshots: [], + _overlayLanes: [], _userCreated: true + }); + b.targets.add(pid); + renderSingleBlock(bId); syncBlocksToHost(); + } + }); + // Move to new Morph Lane + items.push({ + label: '\u21CB New Morph Lane', action: function () { + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + b.lanes[li].pids = b.lanes[li].pids.filter(function (p) { return p !== pid; }); + if (b.lanes[li].pids.length === 0 && !b.lanes[li].morphMode) b.lanes.splice(li, 1); + var col = typeof LANE_COLORS !== 'undefined' ? LANE_COLORS[b.lanes.length % LANE_COLORS.length] : '#64b4ff'; + b.lanes.push({ + pids: [pid], color: col, + pts: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }], + loopLen: '1/1', freeSecs: 4, depth: 100, + drift: 0, driftRange: 5, driftScale: '1/1', warp: 0, steps: 0, + interp: 'smooth', playMode: 'forward', + synced: true, muted: false, collapsed: false, + trigMode: 'loop', trigSource: 'manual', + trigMidiNote: -1, trigMidiCh: 0, trigThreshold: -12, trigAudioSrc: 'main', + trigRetrigger: true, trigHold: false, + morphMode: true, morphSnapshots: [], + _overlayLanes: [], _userCreated: true + }); + b.targets.add(pid); + renderSingleBlock(bId); syncBlocksToHost(); + } + }); + // Move to existing lanes + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi === li) continue; + if (b.lanes[oi].pids.indexOf(pid) >= 0) continue; // already there + var oLane = b.lanes[oi]; + var oName = oLane.pids[0] ? (PMap[oLane.pids[0]] ? PMap[oLane.pids[0]].name : oLane.pids[0]) : (oLane.morphMode ? 'Morph' : 'Lane'); + var typeLabel = oLane.morphMode ? '\u21CB' : '\u270F'; + items.push({ + label: typeLabel + ' Lane ' + (oi + 1) + ': ' + oName, action: (function (targetLane) { + return function () { + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + b.lanes[li].pids = b.lanes[li].pids.filter(function (p) { return p !== pid; }); + if (b.lanes[li].pids.length === 0 && !b.lanes[li].morphMode) b.lanes.splice(li, 1); + if (targetLane.pids.indexOf(pid) < 0) targetLane.pids.push(pid); + b.targets.add(pid); + renderSingleBlock(bId); syncBlocksToHost(); + }; + })(oLane) + }); + } + // Separator + Remove + items.push({ sep: true }); + items.push({ + label: '\u2716 Remove from lane', danger: true, action: function () { + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + b.lanes[li].pids = b.lanes[li].pids.filter(function (p) { return p !== pid; }); + if (b.lanes[li].morphMode) { b.targets.delete(pid); cleanBlockAfterUnassign(b, pid); } + if (b.lanes[li].pids.length === 0 && !b.lanes[li].morphMode) b.lanes.splice(li, 1); + renderSingleBlock(bId); renderAllPlugins(); debouncedSync(); + } + }); + // Render menu items + items.forEach(function (item) { + if (item.sep) { + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + return; + } + var row = document.createElement('div'); + row.className = 'lane-add-menu-item'; + row.textContent = item.label; + if (item.danger) row.style.color = '#e57373'; + row.onclick = function (ev) { + ev.stopPropagation(); + menu.remove(); + item.action(); + }; + menu.appendChild(row); + }); + // Position with viewport clamping + menu.style.cssText = 'position:fixed;z-index:9999;visibility:hidden;'; + document.body.appendChild(menu); + var mw = menu.offsetWidth, mh = menu.offsetHeight; + var vw = window.innerWidth, vh = window.innerHeight; + var ml = e.clientX, mt = e.clientY; + if (ml + mw > vw - 4) ml = vw - mw - 4; + if (mt + mh > vh - 4) mt = Math.max(4, e.clientY - mh); + menu.style.left = ml + 'px'; + menu.style.top = mt + 'px'; + menu.style.visibility = ''; + // Dismiss on outside click + setTimeout(function () { + var dismiss = function (de) { + if (menu.contains(de.target)) return; + menu.remove(); + document.removeEventListener('mousedown', dismiss); + }; + document.addEventListener('mousedown', dismiss); + }, 50); + }; + }); + // Lane overlay picker (multi-select toggle) + document.querySelectorAll('.lane-hdr-overlay').forEach(function (sp) { + sp.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(sp.dataset.b), li = parseInt(sp.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + // Toggle existing menu + var old = document.querySelector('.lane-overlay-menu'); + if (old) { old.remove(); return; } + var lane = b.lanes[li]; + if (!lane._overlayLanes) lane._overlayLanes = []; + + function buildMenu() { + var menu = document.createElement('div'); + menu.className = 'lane-overlay-menu lane-add-menu'; + var rect = sp.getBoundingClientRect(); + var menuW = 170, menuH = (b.lanes.length) * 28 + 30; // estimated height + var vw = window.innerWidth, vh = window.innerHeight; + var posLeft = rect.left; + var posTop = rect.bottom + 2; + // Clamp right edge + if (posLeft + menuW > vw - 4) posLeft = vw - menuW - 4; + if (posLeft < 4) posLeft = 4; + // Flip upward if it overflows bottom + if (posTop + menuH > vh - 4) posTop = rect.top - menuH - 2; + menu.style.cssText = 'position:fixed;left:' + posLeft + 'px;top:' + posTop + 'px;z-index:9999;min-width:' + menuW + 'px;'; + + // "Clear All" option + var clr = document.createElement('div'); + clr.className = 'lane-add-menu-item'; + clr.style.opacity = lane._overlayLanes.length ? '1' : '0.4'; + clr.textContent = 'Clear All'; + clr.onclick = function () { + lane._overlayLanes = []; + menu.remove(); + renderSingleBlock(bId); + }; + menu.appendChild(clr); + + // Separator + var sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;'; + menu.appendChild(sep); + + // Other lanes as toggleable items + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi === li) continue; + var ol = b.lanes[oi]; + var oName = ol.pids[0] ? (PMap[ol.pids[0]] ? PMap[ol.pids[0]].name : ol.pids[0]) : 'Lane'; + var isActive = lane._overlayLanes.indexOf(oi) >= 0; + var item = document.createElement('div'); + item.className = 'lane-add-menu-item' + (isActive ? ' active' : ''); + item.style.cssText = 'display:flex;align-items:center;gap:6px;'; + item.innerHTML = '' + (isActive ? '\u2713' : '') + '' + + '' + + 'L' + (oi + 1) + ': ' + oName + '' + + '' + ol.loopLen + ''; + item.dataset.oi = oi; + item.onclick = function () { + var idx = parseInt(this.dataset.oi); + var pos = lane._overlayLanes.indexOf(idx); + if (pos >= 0) { + lane._overlayLanes.splice(pos, 1); + } else { + lane._overlayLanes.push(idx); + } + // Rebuild menu in-place to update checkmarks + var parent = menu.parentNode; + menu.remove(); + var newMenu = buildMenu(); + parent.appendChild(newMenu); + laneDrawCanvas(b, li); + }; + menu.appendChild(item); + } + return menu; + } + + var menu = buildMenu(); + document.body.appendChild(menu); + // Close on outside click + setTimeout(function () { + var closer = function (ev) { + var m = document.querySelector('.lane-overlay-menu'); + if (!m || !m.contains(ev.target)) { + if (m) m.remove(); + document.removeEventListener('mousedown', closer); + renderSingleBlock(bId); + } + }; + document.addEventListener('mousedown', closer); + }, 0); + }; + }); + // Lane param chip × remove + document.querySelectorAll('.lane-param-chip-x').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b), li = parseInt(btn.dataset.li); + var pid = btn.dataset.pid; + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var lane = b.lanes[li]; + lane.pids = lane.pids.filter(function (p) { return p !== pid; }); + // For morph lanes: also remove from b.targets so ensureLanes + // doesn't auto-create a new curve lane for the orphaned PID + if (lane.morphMode) { + b.targets.delete(pid); + cleanBlockAfterUnassign(b, pid); + } + if (lane.pids.length === 0 && !lane.morphMode) { + b.lanes.splice(li, 1); // remove empty curve lane (morph lanes keep existing) + } + if (lane._highlightParam != null) lane._highlightParam = -1; + renderSingleBlock(bId); renderAllPlugins(); debouncedSync(); + }; + }); + // Lane + Add param button + document.querySelectorAll('.lane-add-param-btn').forEach(function (btn) { + // Skip buttons that aren't actual add-param flows (capture, library, add-morph) + if (btn.classList.contains('lane-sidebar-capture') || btn.classList.contains('lane-morph-lib-btn') || btn.classList.contains('lane-add-morph-btn')) return; + btn.onclick = function (e) { + e.stopPropagation(); + // Toggle: if menu already open for this button, close it + var existingMenu = document.querySelector('.lane-add-menu[data-owner-b="' + btn.dataset.b + '"][data-owner-li="' + btn.dataset.li + '"]'); + if (existingMenu) { existingMenu.remove(); return; } + // Close any other add-param menu + var otherMenu = document.querySelector('.lane-add-menu[data-owner-b]'); + if (otherMenu) otherMenu.remove(); + var bId = parseInt(btn.dataset.b), li = parseInt(btn.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var lane = b.lanes[li]; + + function buildAddMenu() { + var assignedHere = {}; + lane.pids.forEach(function (p) { assignedHere[p] = true; }); + + // Build all assigned-in-any-lane set + var allAssigned = {}; + b.lanes.forEach(function (l) { l.pids.forEach(function (p) { allAssigned[p] = true; }); }); + + // Section 1: params in OTHER lanes (merge/move) — close on click + var moveOpts = []; + for (var oi = 0; oi < b.lanes.length; oi++) { + if (oi === li) continue; + b.lanes[oi].pids.forEach(function (pid) { + var pp = PMap[pid]; + moveOpts.push({ pid: pid, label: pp ? pp.name : pid, srcLane: oi }); + }); + } + + // Section 2: plugin params — toggleable (stays open) + var pluginOpts = []; + pluginBlocks.forEach(function (pb) { + pb.params.forEach(function (p) { + // Show all params not assigned to OTHER lanes (already-here ones show as checked) + if (allAssigned[p.id] && !assignedHere[p.id]) return; + pluginOpts.push({ pid: p.id, label: p.name, plugin: pb.name, added: !!assignedHere[p.id] }); + }); + }); + + if (moveOpts.length === 0 && pluginOpts.length === 0) return null; + + var menu = document.createElement('div'); + menu.className = 'lane-add-menu'; + menu.setAttribute('data-owner-b', bId); + menu.setAttribute('data-owner-li', li); + menu.style.position = 'fixed'; + menu.style.zIndex = '999'; + + // Search input for filtering + var totalOpts = moveOpts.length + pluginOpts.length; + var searchInput = null; + var allItems = []; + if (totalOpts > 8) { + searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'lane-add-menu-search'; + searchInput.placeholder = 'Search params\u2026'; + searchInput.onclick = function (ev) { ev.stopPropagation(); }; + menu.appendChild(searchInput); + } + + // Move section (close on click) + if (moveOpts.length > 0) { + var hdr = document.createElement('div'); + hdr.className = 'lane-add-menu-hdr'; + hdr.textContent = '\u21C4 MOVE FROM LANE'; + menu.appendChild(hdr); + moveOpts.forEach(function (opt) { + var item = document.createElement('div'); + item.className = 'lane-add-menu-item'; + item.textContent = opt.label + ' \u2190 Lane ' + (opt.srcLane + 1); + item.dataset.search = item.textContent.toLowerCase(); + item.onclick = function (ev) { + ev.stopPropagation(); + lane.pids.push(opt.pid); + if (opt.srcLane >= 0 && b.lanes[opt.srcLane]) { + b.lanes[opt.srcLane].pids = b.lanes[opt.srcLane].pids.filter(function (p) { return p !== opt.pid; }); + if (b.lanes[opt.srcLane].pids.length === 0) b.lanes.splice(opt.srcLane, 1); + } + menu.remove(); + renderSingleBlock(bId); debouncedSync(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + }; + menu.appendChild(item); + allItems.push(item); + }); + } + + // Plugin params section (toggle, stays open) + if (pluginOpts.length > 0) { + var hdr2 = document.createElement('div'); + hdr2.className = 'lane-add-menu-hdr'; + hdr2.textContent = '\u2795 PLUGIN PARAMS'; + menu.appendChild(hdr2); + pluginOpts.forEach(function (opt) { + var item = document.createElement('div'); + item.className = 'lane-add-menu-item' + (opt.added ? ' active' : ''); + item.style.cssText = 'display:flex;align-items:center;gap:6px;'; + item.innerHTML = '' + (opt.added ? '\u2713' : '') + '' + + '' + opt.plugin + ' / ' + opt.label + ''; + item.dataset.search = (opt.plugin + ' ' + opt.label).toLowerCase(); + item.onclick = function (ev) { + ev.stopPropagation(); + var idx = lane.pids.indexOf(opt.pid); + if (idx >= 0) { + // Remove + lane.pids.splice(idx, 1); + } else { + // Add + lane.pids.push(opt.pid); + b.targets.add(opt.pid); + } + // Rebuild menu in-place to update checkmarks + var parent = menu.parentNode; + var oldSearch = searchInput ? searchInput.value : ''; + menu.remove(); + var newMenu = buildAddMenu(); + if (newMenu) { + var rect2 = btn.getBoundingClientRect(); + newMenu.style.left = menu.style.left || (rect2.left + 'px'); + newMenu.style.top = menu.style.top || (rect2.bottom + 2 + 'px'); + parent.appendChild(newMenu); + // Restore search text + var newSearch = newMenu.querySelector('.lane-add-menu-search'); + if (newSearch && oldSearch) { + newSearch.value = oldSearch; + newSearch.dispatchEvent(new Event('input')); + } + } + renderSingleBlock(bId); debouncedSync(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + }; + menu.appendChild(item); + allItems.push(item); + }); + } + + // Wire up search filtering + if (searchInput) { + searchInput.oninput = function () { + var q = searchInput.value.toLowerCase(); + allItems.forEach(function (el) { + el.style.display = (el.dataset.search || '').indexOf(q) >= 0 ? '' : 'none'; + }); + }; + } + + return menu; + } + + var menu = buildAddMenu(); + if (!menu) return; + + // Position and append + menu.style.visibility = 'hidden'; + document.body.appendChild(menu); + var realH = menu.offsetHeight; + var realW = menu.offsetWidth || 200; + var rect = btn.getBoundingClientRect(); + var vw2 = window.innerWidth, vh2 = window.innerHeight; + var fLeft = rect.left, fTop = rect.bottom + 2; + if (fLeft + realW > vw2 - 4) fLeft = vw2 - realW - 4; + if (fLeft < 4) fLeft = 4; + if (fTop + realH > vh2 - 4) fTop = Math.max(4, rect.top - realH - 2); + menu.style.left = fLeft + 'px'; + menu.style.top = fTop + 'px'; + menu.style.visibility = ''; + var searchEl = menu.querySelector('.lane-add-menu-search'); + if (searchEl) searchEl.focus(); + // Close on outside click (like overlay menu) + setTimeout(function () { + var closer = function (ev) { + var m = document.querySelector('.lane-add-menu[data-owner-b]'); + if (!m || !m.contains(ev.target)) { + if (m) m.remove(); + document.removeEventListener('mousedown', closer); + } + }; + document.addEventListener('mousedown', closer); + }, 10); + }; + }); + // Lane interp buttons + document.querySelectorAll('.lane-ibtn[data-linterp]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b), li = parseInt(btn.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].interp = btn.dataset.linterp; + // Update buttons without re-render + var wrap = btn.closest('.lane-interp-stack'); + if (wrap) wrap.querySelectorAll('.lane-ibtn').forEach(function (t) { t.classList.toggle('on', t.dataset.linterp === b.lanes[li].interp); }); + laneDrawCanvas(b, li); + debouncedSync(); + }; + }); + // Lane sync pills + document.querySelectorAll('.lane-sync-pill').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b), li = parseInt(btn.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].synced = !b.lanes[li].synced; + btn.classList.toggle('on', b.lanes[li].synced); + btn.textContent = b.lanes[li].synced ? 'Host' : 'Int'; + debouncedSync(); + }; + }); + // Lane header selects (loop length, play mode, trigMode, trigSource, etc.) + document.querySelectorAll('.lane-hdr-sel').forEach(function (sel) { + sel.onchange = function () { + var bId = parseInt(sel.dataset.b), li = parseInt(sel.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var field = sel.dataset.lf; + var val = sel.value; + // Parse numeric fields + if (field === 'trigMidiNote' || field === 'trigMidiCh') val = parseInt(val); + b.lanes[li][field] = val; + if (field === 'loopLen' || field === 'trigMode' || field === 'trigSource') renderSingleBlock(bId); + debouncedSync(); + sel.blur(); + }; + }); + // Footer selects (drift scale etc.) + document.querySelectorAll('.lane-ft-sel[data-lf]').forEach(function (sel) { + sel.onchange = function () { + var bId = parseInt(sel.dataset.b), li = parseInt(sel.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li][sel.dataset.lf] = sel.value; + laneDrawCanvas(b, li); + debouncedSync(); + sel.blur(); + }; + }); + // Fire button (manual oneshot trigger) + document.querySelectorAll('.lane-fire-btn').forEach(function (btn) { + btn.onclick = function () { + var bId = parseInt(btn.dataset.b), li = parseInt(btn.dataset.li); + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('fireLaneTrigger'); + if (fn) fn(bId, li); + } + btn.classList.add('fired'); + setTimeout(function () { btn.classList.remove('fired'); }, 200); + }; + }); + // Trigger threshold slider + document.querySelectorAll('.lane-trig-slider').forEach(function (sl) { + sl.oninput = function () { + var bId = parseInt(sl.dataset.b), li = parseInt(sl.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].trigThreshold = parseInt(sl.value); + var dbLabel = sl.nextElementSibling; + if (dbLabel) dbLabel.textContent = sl.value + ' dB'; + debouncedSync(); + }; + }); + // Trigger checkboxes (trigRetrigger, trigHold) + document.querySelectorAll('.lane-trig-chk input[type="checkbox"]').forEach(function (chk) { + chk.onchange = function () { + var bId = parseInt(chk.dataset.b), li = parseInt(chk.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + var field = chk.dataset.lf; + if (field) b.lanes[li][field] = chk.checked; + debouncedSync(); + }; + }); + // Free seconds input + document.querySelectorAll('.lane-hdr-fsec').forEach(function (inp) { + inp.onchange = function () { + var bId = parseInt(inp.dataset.b), li = parseInt(inp.dataset.li); + var b = findBlock(bId); if (!b || !b.lanes[li]) return; + b.lanes[li].freeSecs = parseFloat(inp.value) || 4; + laneCanvasSetup(b); // redraw all lanes so overlays recalculate ratio + debouncedSync(); + }; + }); + // Wire Add Lane buttons (these use .lane-add-btn, not .lane-add-param-btn) + document.querySelectorAll('.lane-add-curve-btn').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b); + var b = findBlock(bId); if (!b) return; + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + if (!b.lanes) b.lanes = []; + b.lanes.push({ + pids: [], color: (typeof LANE_COLORS !== 'undefined' ? LANE_COLORS[b.lanes.length % LANE_COLORS.length] : '#64b4ff'), + pts: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }], + loopLen: '1/1', freeSecs: 4, depth: 100, + drift: 0, driftRange: 5, warp: 0, steps: 0, + interp: 'smooth', playMode: 'forward', + synced: true, muted: false, collapsed: false, + trigMode: 'loop', trigSource: 'manual', + trigMidiNote: -1, trigMidiCh: 0, + trigThreshold: -12, trigAudioSrc: 'main', + trigRetrigger: true, trigHold: false, + morphMode: false, morphSnapshots: [], + _overlayLanes: [], _userCreated: true + }); + renderSingleBlock(bId); syncBlocksToHost(); + }; + }); + document.querySelectorAll('.lane-add-morph-btn').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bId = parseInt(btn.dataset.b); + var b = findBlock(bId); if (!b) return; + if (typeof pushUndoSnapshot === 'function') pushUndoSnapshot(); + if (!b.lanes) b.lanes = []; + b.lanes.push({ + pids: [], color: (typeof LANE_COLORS !== 'undefined' ? LANE_COLORS[b.lanes.length % LANE_COLORS.length] : '#64b4ff'), + pts: [{ x: 0, y: 0.5 }, { x: 1, y: 0.5 }], + loopLen: '1/1', freeSecs: 4, depth: 100, + drift: 0, driftRange: 5, warp: 0, steps: 0, + interp: 'smooth', playMode: 'forward', + synced: true, muted: false, collapsed: false, + trigMode: 'loop', trigSource: 'manual', + trigMidiNote: -1, trigMidiCh: 0, + trigThreshold: -12, trigAudioSrc: 'main', + trigRetrigger: true, trigHold: false, + morphMode: true, morphSnapshots: [], + _overlayLanes: [], _userCreated: true + }); + renderSingleBlock(bId); syncBlocksToHost(); + }; + }); + // Initialize lane canvases + blocks.forEach(function (b) { + if (b.mode === 'lane') laneCanvasSetup(b); + }); +} +function drawWaveform(blockId) { + var b = findBlock(blockId); + if (!b || !b.sampleWaveform || !b.sampleWaveform.length) return; + var cv = document.getElementById('waveCv-' + blockId); + if (!cv) return; + var ctx = cv.getContext('2d'); + var w = cv.width, h = cv.height, peaks = b.sampleWaveform; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#AA44FF33'; + ctx.strokeStyle = '#AA44FF'; + ctx.lineWidth = 1; + ctx.beginPath(); + var step = w / peaks.length; + for (var i = 0; i < peaks.length; i++) { + var x = i * step, p = Math.min(1, peaks[i]); + var barH = p * h; + ctx.rect(x, h - barH, Math.max(1, step - 0.5), barH); + } + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(0, h); + for (var i = 0; i < peaks.length; i++) { + var x = i * step, p = Math.min(1, peaks[i]); + ctx.lineTo(x + step / 2, h - p * h); + } + ctx.lineTo(w, h); + ctx.stroke(); +} +function updateAssignBanner() { + var bn = document.getElementById('assignBanner'), lb = document.getElementById('assignTarget'); + if (assignMode) { var b = findBlock(assignMode); if (!b) { bn.classList.remove('vis'); return; } bn.classList.add('vis'); var col = bColor(b.colorIdx); bn.style.background = col + '22'; bn.style.borderColor = col + '66'; bn.style.color = col; lb.textContent = 'Block ' + (blocks.indexOf(b) + 1) + ' (' + b.mode + ')'; } + else { bn.classList.remove('vis'); } +} +// ========================================================== +// RANDOMIZE — handles all range modes, quantize, smooth glide +// ========================================================== +function randomize(bId) { + var b = findBlock(bId); if (!b) return; + var mn = b.rMin / 100, mx = b.rMax / 100; + // Guard: if Min > Max in absolute mode, swap so randomize stays valid + if (mn > mx) { var t = mn; mn = mx; mx = t; } + var isRelative = b.rangeMode === 'relative'; + var startGlideFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('startGlide') : null; + + // Collect all instant param changes into a batch for single IPC call (C4 fix) + var instantBatch = []; + + b.targets.forEach(function (id) { + var p = PMap[id]; if (!p || p.lk) return; + var newVal; + if (isRelative) { + // Relative: offset from current value by random amount between ±[rMin..rMax] + var offset = mn + Math.random() * (mx - mn); // magnitude in [rMin, rMax] + var sign = Math.random() < 0.5 ? -1 : 1; + newVal = p.v + sign * offset; + } else { + // Absolute: random between min and max + newVal = mn + Math.random() * (mx - mn); + } + // Quantize + if (b.quantize && b.qSteps > 1) { + newVal = Math.round(newVal * (b.qSteps - 1)) / (b.qSteps - 1); + } + newVal = Math.max(0, Math.min(1, newVal)); + + if (b.movement === 'glide' && b.glideMs > 0) { + // Send glide to C++ for per-buffer interpolation (no zipper noise) + if (startGlideFn && p.hostId !== undefined) { + startGlideFn(p.hostId, p.realIndex, newVal, b.glideMs); + } + // Update JS state to target so undo captures correct values + p.v = newVal; + _modDirty = true; + } else { + // Instant — collect for batch + p.v = newVal; + _modDirty = true; + if (p.hostId !== undefined) { + instantBatch.push({ p: p.hostId, i: p.realIndex, v: newVal }); + } + } + }); + + // Send all instant changes in a single IPC call (instead of N individual setParam calls) + if (instantBatch.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(instantBatch)); + } + + // Update modulation base anchors for any randomized params + var needSync = false; + b.targets.forEach(function (id) { + var p = PMap[id]; if (!p || p.lk) return; + updateModBases(id, p.v); + needSync = true; + }); + if (needSync) syncBlocksToHost(); + + // Defer display refresh to next animation frame (L2 fix) + requestAnimationFrame(function () { refreshParamDisplay(); }); +} + +// ========================================================== +// REAL-TIME DATA PROCESSING +// ========================================================== + +// Clean up all stale data for a PID removed from a block's targets. +// Must be called AFTER b.targets.delete(pid) for every unassign path. +function cleanBlockAfterUnassign(b, pid) { + // Lane mode: remove PID from all lane.pids and morph snapshot values + if (b.lanes) { + for (var li = 0; li < b.lanes.length; li++) { + var lane = b.lanes[li]; + var idx = lane.pids ? lane.pids.indexOf(pid) : -1; + if (idx >= 0) lane.pids.splice(idx, 1); + if (lane.morphSnapshots) { + for (var si = 0; si < lane.morphSnapshots.length; si++) { + delete lane.morphSnapshots[si].values[pid]; + } + } + } + } + // Clear stale readback so plugin_rack arcs stop showing modulation + if (b.laneModOutputs) delete b.laneModOutputs[pid]; + // Clean shapes/envelope base caches + if (b.targetBases) delete b.targetBases[pid]; + if (b.targetRanges) delete b.targetRanges[pid]; + if (b.targetRangeBases) delete b.targetRangeBases[pid]; + // Clean morph pad snapshot values + if (b.snapshots) { + for (var si = 0; si < b.snapshots.length; si++) { + if (b.snapshots[si].values) delete b.snapshots[si].values[pid]; + } + } +} +// ─── Sync logic block state to C++ backend ─── +function syncBlocksToHost() { + if (typeof markGpDirty === 'function') markGpDirty(); + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('updateBlocks'); + var data = blocks.map(function (b) { + // Build consistent target lists — tList for C++, tIds for snapshot value lookup + var tList = [], tIds = []; + b.targets.forEach(function (id) { + var p = PMap[id]; + if (!p || p.lk) return; // Skip locked params + tList.push({ hostId: p.hostId, paramIndex: p.realIndex }); + tIds.push(id); + }); + var obj = { + id: b.id, mode: b.mode, targets: tList, + trigger: b.trigger, beatDiv: b.beatDiv, + midiMode: b.midiMode, midiNote: b.midiNote || 60, midiCC: b.midiCC || 1, midiCh: parseInt(b.midiCh) || 0, + threshold: b.threshold || -12, audioSrc: b.audioSrc || 'main', + rMin: b.rMin / 100, rMax: b.rMax / 100, + rangeMode: (b.mode === 'randomize') ? (b.rangeMode || 'absolute') : 'relative', polarity: b.polarity || 'bipolar', + quantize: !!b.quantize, qSteps: b.qSteps || 12, + movement: b.movement || 'instant', glideMs: b.glideMs || 200, + envAtk: b.envAtk || 10, envRel: b.envRel || 100, envSens: b.envSens || 50, envInvert: !!b.envInvert, + envFilterMode: b.envFilterMode || 'flat', envFilterFreq: envDialToHz(b.envFilterFreq), envFilterBW: envBwIdxToOct(b.envFilterBW), + loopMode: b.loopMode || 'loop', sampleSpeed: b.sampleSpeed || 1.0, sampleReverse: !!b.sampleReverse, jumpMode: b.jumpMode || 'restart', + clockSource: b.clockSource || 'daw', internalBpm: internalBpm, + enabled: b.enabled !== false + }; + // Send base values for relative mode (captured at assignment time) + var bases = []; + for (var ti = 0; ti < tIds.length; ti++) { + var base = (b.targetBases && b.targetBases[tIds[ti]] !== undefined) + ? b.targetBases[tIds[ti]] + : (PMap[tIds[ti]] ? PMap[tIds[ti]].v : 0.5); + bases.push(base); + } + obj.targetBases = bases; + if (b.mode === 'morph_pad') { + obj.snapshots = (b.snapshots || []).map(function (s) { + var vals = []; + // Use tIds (same order as tList) so targets[i] aligns with targetValues[i] + for (var ti = 0; ti < tIds.length; ti++) { + // If snapshot has a stored value for this param, use it. + // Otherwise fall back to the param's CURRENT value (not 0.5) + // so IDW doesn't overwrite newly assigned params with a meaningless default. + var fallback = PMap[tIds[ti]] ? PMap[tIds[ti]].v : 0.5; + vals.push(s.values && s.values[tIds[ti]] !== undefined ? s.values[tIds[ti]] : fallback); + } + return { x: s.x, y: s.y, targetValues: vals }; + }); + obj.playheadX = b.playheadX; + obj.playheadY = b.playheadY; + obj.morphMode = b.morphMode || 'manual'; + obj.exploreMode = b.exploreMode || 'wander'; + obj.lfoShape = b.lfoShape || 'circle'; + obj.lfoDepth = (b.lfoDepth != null ? b.lfoDepth : 80) / 100; + obj.lfoRotation = (b.lfoRotation || 0) / 100; + obj.morphSpeed = (b.morphSpeed || 50) / 100; + obj.morphAction = b.morphAction || 'jump'; + obj.stepOrder = b.stepOrder || 'cycle'; + obj.morphSource = b.morphSource || 'midi'; + obj.jitter = (b.jitter || 0) / 100; + obj.morphGlide = b.morphGlide || 200; + obj.morphTempoSync = !!b.morphTempoSync; + obj.morphSyncDiv = b.morphSyncDiv || '1/4'; + obj.snapRadius = (b.snapRadius || 100) / 100; + } + if (b.mode === 'shapes' || b.mode === 'shapes_range') { + obj.shapeType = b.shapeType || 'circle'; + obj.shapeTracking = b.shapeTracking || 'horizontal'; + obj.shapeSize = b.mode === 'shapes_range' ? 1.0 : (b.shapeSize != null ? b.shapeSize : 80) / 100; + obj.shapeSpin = (b.shapeSpin || 0) / 100; + obj.shapeSpeed = (b.shapeSpeed || 50) / 100; + obj.shapeDepth = b.mode === 'shapes_range' ? 1.0 : (b.shapeSize != null ? b.shapeSize : 80) / 200; + obj.shapeRange = b.mode === 'shapes_range' ? 'relative' : (b.shapeRange || 'relative'); + obj.shapePolarity = b.shapePolarity || 'bipolar'; + obj.shapeTempoSync = !!b.shapeTempoSync; + obj.shapeSyncDiv = b.shapeSyncDiv || '1/4'; + obj.shapeTrigger = b.shapeTrigger || 'free'; + obj.shapePhaseOffset = (b.shapePhaseOffset || 0) / 360; + } + if (b.mode === 'shapes_range') { + // Send per-param ranges aligned with targets array + obj.targetRanges = tIds.map(function (pid) { + return b.targetRanges && b.targetRanges[pid] !== undefined ? b.targetRanges[pid] : 0; + }); + // Send per-param base values (anchor positions) aligned with targets array + // Use the JS-stored base (updated on knob drags) as the source of truth + obj.targetRangeBases = tIds.map(function (pid) { + return b.targetRangeBases && b.targetRangeBases[pid] !== undefined ? b.targetRangeBases[pid] : (PMap[pid] ? PMap[pid].v : 0.5); + }); + } + if (b.mode === 'lane') { + // Build O(1) lookup from pid → target info (avoids O(n²) linear scan) + var tIdMap = {}; + for (var ti = 0; ti < tIds.length; ti++) { + tIdMap[tIds[ti]] = { pluginId: tList[ti].hostId, paramIndex: tList[ti].paramIndex }; + } + obj.lanes = (b.lanes || []).map(function (lane, li) { + // Resolve all pids → targets using O(1) map lookup + var laneTargets = []; + (lane.pids || []).forEach(function (pid) { + var t = tIdMap[pid]; + if (t) laneTargets.push(t); + }); + // Morph lanes don't require pids — their params are stored in snapshot values + // Always send lane data to keep C++ lane indices aligned with JS indices + // (C++ skips empty lanes in processBlock via hasCurveData/hasMorphData check) + return { + targets: laneTargets, + pts: (lane.pts || []).map(function (p) { return { x: p.x, y: p.y }; }), + loopLen: lane.loopLen || '1/1', + steps: lane.steps || 0, + depth: (lane.depth != null ? lane.depth : 100) / 100.0, + drift: lane.drift || 0, + driftRange: lane.driftRange != null ? lane.driftRange : 5, + driftScale: lane.driftScale || '1/1', + warp: lane.warp || 0, + + interp: lane.interp || 'smooth', + playMode: lane.playMode || 'forward', + freeSecs: lane.freeSecs || 4, + synced: lane.synced !== false, + muted: !!lane.muted, + trigMode: lane.trigMode || 'loop', + trigSource: lane.trigSource || 'manual', + trigMidiNote: lane.trigMidiNote != null ? lane.trigMidiNote : -1, + trigMidiCh: lane.trigMidiCh || 0, + trigThreshold: lane.trigThreshold != null ? lane.trigThreshold : -12, + trigAudioSrc: lane.trigAudioSrc || 'main', + trigRetrigger: lane.trigRetrigger !== false, + trigHold: !!lane.trigHold, + morphMode: !!lane.morphMode, + morphSnapshots: (lane.morphSnapshots || []).map(function (s) { + return { position: s.position || 0, hold: s.hold != null ? s.hold : 0.5, curve: s.curve || 0, depth: s.depth != null ? s.depth : 1.0, drift: s.drift || 0, driftRange: s.driftRange != null ? s.driftRange : 5, driftScale: s.driftScale || '', warp: s.warp || 0, steps: s.steps || 0, name: s.name || '', source: s.source || '', values: s.values || {} }; + }) + }; + }); + } + return obj; + }); + fn(JSON.stringify(data)); + saveUiStateToHost(); +} + +// Get active morph pad blocks (for context menu submenu) +function getMorphBlocks() { + var result = []; + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].mode === 'morph_pad') result.push({ id: blocks[i].id, idx: i, colorIdx: blocks[i].colorIdx, snapCount: (blocks[i].snapshots || []).length }); + } + return result; +} + +// Add a snapshot to a morph block from a plugin's current param values +// pluginId: which plugin to capture values from (or null for all) +function addSnapshotToMorphBlock(blockId, pluginId) { + var b = findBlock(blockId); + if (!b || b.mode !== 'morph_pad') return; + if (!b.snapshots) b.snapshots = []; + if (b.snapshots.length >= 12) return; + + // Capture ALL plugin params — not just assigned targets. + // Snapshots are full state captures. You can assign params later + // and the snapshot will already have their values stored. + var vals = {}; + for (var pid in PMap) { + var p = PMap[pid]; + if (p) vals[pid] = p.v; + } + + // Source label: use the triggering plugin's name + var sourceName = getPluginName(pluginId) || 'Manual'; + + var spos = getSnapSectorPos(b.snapshots.length); + b.snapshots.push({ x: spos.x, y: spos.y, values: vals, name: 'S' + (b.snapshots.length + 1), source: sourceName }); + renderSingleBlock(blockId); + syncBlocksToHost(); + + // Flash feedback + var pad = document.querySelector('.morph-pad[data-b="' + blockId + '"]'); + if (pad) { pad.classList.remove('snap-flash'); void pad.offsetWidth; pad.classList.add('snap-flash'); } + var chips = document.querySelectorAll('.snap-chip[data-b="' + blockId + '"]'); + if (chips.length) { var last = chips[chips.length - 1]; last.classList.add('just-added'); setTimeout(function () { last.classList.remove('just-added'); }, 600); } +} + +// Get all morph lanes across all lane blocks (for snap menu) +function getMorphLanes() { + var result = []; + for (var i = 0; i < blocks.length; i++) { + var b = blocks[i]; + if (b.mode !== 'lane' || !b.lanes) continue; + for (var li = 0; li < b.lanes.length; li++) { + var lane = b.lanes[li]; + if (!lane.morphMode) continue; + result.push({ + blockId: b.id, laneIdx: li, blockIdx: i, + colorIdx: b.colorIdx, laneColor: lane.color, + snapCount: (lane.morphSnapshots || []).length + }); + } + } + return result; +} + +// Add a snapshot to a morph lane from current plugin param values +function addSnapshotToMorphLane(blockId, laneIdx, pluginId) { + var b = findBlock(blockId); + if (!b || b.mode !== 'lane' || !b.lanes || !b.lanes[laneIdx]) return; + var lane = b.lanes[laneIdx]; + if (!lane.morphMode) return; + if (!lane.morphSnapshots) lane.morphSnapshots = []; + + // Capture param values — only for params assigned to the lane, or all if none assigned + var vals = {}; + if (lane.pids && lane.pids.length > 0) { + for (var pi = 0; pi < lane.pids.length; pi++) { + var p = PMap[lane.pids[pi]]; + if (p) vals[lane.pids[pi]] = p.v; + } + } else { + // No params assigned yet — capture all from this plugin + for (var pid in PMap) { + var p = PMap[pid]; + if (p && (pluginId == null || p.hostId === pluginId)) vals[pid] = p.v; + } + } + + var n = lane.morphSnapshots.length; + var position = n === 0 ? 0 : 1; // first=0, rest=1 (will redistribute below) + var sourceName = (typeof getPluginName === 'function' && pluginId != null) ? (getPluginName(pluginId) || 'Manual') : 'Capture'; + + lane.morphSnapshots.push({ + position: position, hold: 0.5, curve: 0, + name: 'S' + (n + 1), source: sourceName, values: vals + }); + + // Auto-distribute positions evenly + var total = lane.morphSnapshots.length; + if (total > 1) { + for (var si = 0; si < total; si++) { + lane.morphSnapshots[si].position = si / (total - 1); + } + } + + renderSingleBlock(blockId); + syncBlocksToHost(); +} diff --git a/plugins/ModularRandomizer/Source/ui/public/js/persistence.js b/plugins/ModularRandomizer/Source/ui/public/js/persistence.js new file mode 100644 index 0000000..ecd6766 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/persistence.js @@ -0,0 +1,555 @@ +// ============================================================ +// STATE PERSISTENCE +// Save/restore UI state across editor close/reopen +// ============================================================ +// ========================================================== +// STATE PERSISTENCE — save/restore across editor close/reopen +// ========================================================== + +// Serialize full UI state to JSON and send to processor +function saveUiStateToHost() { + _stateDirty = false; // clear so auto-save doesn't redundantly fire + // Mark global preset as dirty (any state save implies a change was made) + if (typeof markGpDirty === 'function') markGpDirty(); + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('saveUiState'); + var state = { + blocks: blocks.map(function (b) { + return { + id: b.id, mode: b.mode, colorIdx: b.colorIdx, + targets: Array.from(b.targets), targetBases: b.targetBases || {}, targetRanges: b.targetRanges || {}, targetRangeBases: b.targetRangeBases || {}, + trigger: b.trigger, beatDiv: b.beatDiv, + midiMode: b.midiMode, midiNote: b.midiNote, midiCC: b.midiCC, midiCh: b.midiCh, + velScale: b.velScale, threshold: b.threshold, audioSrc: b.audioSrc, + rMin: b.rMin, rMax: b.rMax, rangeMode: b.rangeMode, + quantize: b.quantize, qSteps: b.qSteps, + movement: b.movement, glideMs: b.glideMs, + envAtk: b.envAtk, envRel: b.envRel, envSens: b.envSens, envInvert: b.envInvert, + envFilterMode: b.envFilterMode || 'flat', envFilterFreq: b.envFilterFreq != null ? b.envFilterFreq : 50, envFilterBW: b.envFilterBW != null ? b.envFilterBW : 5, + loopMode: b.loopMode, sampleSpeed: b.sampleSpeed, sampleReverse: b.sampleReverse, jumpMode: b.jumpMode, + sampleName: b.sampleName || '', sampleWaveform: b.sampleWaveform || null, + polarity: b.polarity || 'bipolar', clockSource: b.clockSource || 'daw', + snapshots: (b.snapshots || []).map(function (s) { return { x: s.x, y: s.y, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + playheadX: b.playheadX != null ? b.playheadX : 0.5, playheadY: b.playheadY != null ? b.playheadY : 0.5, + morphMode: b.morphMode || 'manual', exploreMode: b.exploreMode || 'wander', + lfoShape: b.lfoShape || 'circle', lfoDepth: b.lfoDepth != null ? b.lfoDepth : 80, lfoRotation: b.lfoRotation != null ? b.lfoRotation : 0, morphSpeed: b.morphSpeed != null ? b.morphSpeed : 50, + morphAction: b.morphAction || 'jump', stepOrder: b.stepOrder || 'cycle', + morphSource: b.morphSource || 'midi', jitter: b.jitter != null ? b.jitter : 0, + morphGlide: b.morphGlide != null ? b.morphGlide : 200, + morphTempoSync: !!b.morphTempoSync, morphSyncDiv: b.morphSyncDiv || '1/4', + snapRadius: b.snapRadius != null ? b.snapRadius : 100, + shapeType: b.shapeType || 'circle', shapeTracking: b.shapeTracking || 'horizontal', + shapeSize: b.shapeSize != null ? b.shapeSize : 80, shapeSpin: b.shapeSpin != null ? b.shapeSpin : 0, + shapeSpeed: b.shapeSpeed != null ? b.shapeSpeed : 50, shapePhaseOffset: b.shapePhaseOffset || 0, + shapeRange: b.shapeRange || 'relative', shapePolarity: b.shapePolarity || 'bipolar', + shapeTempoSync: !!b.shapeTempoSync, shapeSyncDiv: b.shapeSyncDiv || '1/4', shapeTrigger: b.shapeTrigger || 'free', + laneTool: b.laneTool || 'draw', laneGrid: b.laneGrid || '1/8', + lanes: (b.lanes || []).map(function (lane) { + return { + pids: lane.pids || (lane.pid ? [lane.pid] : []), color: lane.color || '', collapsed: !!lane.collapsed, + pts: (lane.pts || []).map(function (p) { return { x: p.x, y: p.y }; }), + loopLen: lane.loopLen || '1/1', steps: lane.steps != null ? lane.steps : 0, depth: lane.depth != null ? lane.depth : 100, + drift: lane.drift != null ? lane.drift : 0, driftRange: lane.driftRange != null ? lane.driftRange : 5, driftScale: lane.driftScale || '1/1', warp: lane.warp != null ? lane.warp : 0, interp: lane.interp || 'smooth', + playMode: lane.playMode || 'forward', freeSecs: lane.freeSecs != null ? lane.freeSecs : 4, + synced: lane.synced !== false, muted: !!lane.muted, + trigMode: lane.trigMode || 'loop', trigSource: lane.trigSource || 'manual', + trigMidiNote: lane.trigMidiNote != null ? lane.trigMidiNote : -1, trigMidiCh: lane.trigMidiCh || 0, + trigThreshold: lane.trigThreshold != null ? lane.trigThreshold : -12, + trigAudioSrc: lane.trigAudioSrc || 'main', trigRetrigger: lane.trigRetrigger !== false, + trigHold: !!lane.trigHold, + morphMode: !!lane.morphMode, + morphSnapshots: (lane.morphSnapshots || []).map(function (s) { return { position: s.position || 0, hold: s.hold != null ? s.hold : 0.5, curve: s.curve || 0, depth: s.depth != null ? s.depth : 1.0, drift: s.drift || 0, driftRange: s.driftRange != null ? s.driftRange : 5, driftScale: s.driftScale || '', warp: s.warp || 0, steps: s.steps || 0, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + overlayLanes: lane._overlayLanes || [] + }; + }), + enabled: b.enabled !== false, + expanded: b.expanded + }; + }), + bc: bc, actId: actId, + locks: (function () { + var lk = {}; + for (var id in PMap) { if (PMap[id].lk) lk[id] = true; } + return lk; + })(), + pluginOrder: pluginBlocks.filter(function (pb) { return !pb.isVirtual; }).map(function (pb) { return pb.id; }), + pluginExpanded: (function () { + var exp = {}; + pluginBlocks.forEach(function (pb) { if (!pb.isVirtual) exp[pb.id] = pb.expanded; }); + return exp; + })(), + pluginBypassed: (function () { + var byp = {}; + pluginBlocks.forEach(function (pb) { if (pb.bypassed && !pb.isVirtual) byp[pb.id] = true; }); + return byp; + }()), + uiScale: currentScale, + uiTheme: currentTheme, + autoLocate: autoLocate, + internalBpm: internalBpm, + routingMode: routingMode, + pluginBuses: (function () { + var buses = {}; + pluginBlocks.forEach(function (pb) { if (pb.busId && !pb.isVirtual) buses[pb.id] = pb.busId; }); + return buses; + }()), + busVolumes: busVolumes.slice(), + busMutes: busMutes.slice(), + busSolos: busSolos.slice(), + busCollapsed: busCollapsed.slice(), + scanPaths: scanPaths.slice(), + exposeState: typeof getExposeStateForSave === 'function' ? getExposeStateForSave() : null, + wrongEq: { + points: wrongEqPoints.map(function (p, idx) { + // During animation, save the stable base positions — not the animated jittering values + var saveX = (typeof weqAnimRafId !== 'undefined' && weqAnimRafId && typeof weqAnimBaseX !== 'undefined' && weqAnimBaseX.length > idx) ? weqAnimBaseX[idx] : p.x; + var saveY = (typeof weqAnimRafId !== 'undefined' && weqAnimRafId && typeof weqAnimBaseY !== 'undefined' && weqAnimBaseY.length > idx) ? weqAnimBaseY[idx] : p.y; + return { uid: p.uid, x: saveX, y: saveY, pluginIds: p.pluginIds || [], seg: p.seg || null, solo: p.solo || false, mute: p.mute || false, q: p.q != null ? p.q : 0.707, type: p.type || 'Bell', drift: p.drift || 0, preEq: p.preEq !== false, stereoMode: p.stereoMode || 0, slope: p.slope || 1 }; + }), + interp: typeof weqGlobalInterp !== 'undefined' ? weqGlobalInterp : 'smooth', + depth: typeof weqGlobalDepth !== 'undefined' ? weqGlobalDepth : 100, + warp: typeof weqGlobalWarp !== 'undefined' ? weqGlobalWarp : 0, + steps: typeof weqGlobalSteps !== 'undefined' ? weqGlobalSteps : 0, + tilt: typeof weqGlobalTilt !== 'undefined' ? weqGlobalTilt : 0, + + preEq: typeof weqPreEq !== 'undefined' ? weqPreEq : true, + bypass: typeof weqGlobalBypass !== 'undefined' ? weqGlobalBypass : false, + unassignedMode: typeof weqUnassignedMode !== 'undefined' ? weqUnassignedMode : 0, + animSpeed: typeof weqAnimSpeed !== 'undefined' ? weqAnimSpeed : 0, + animDepth: typeof weqAnimDepth !== 'undefined' ? weqAnimDepth : 6, + animShape: typeof weqAnimShape !== 'undefined' ? weqAnimShape : 'sine', + drift: typeof weqDrift !== 'undefined' ? weqDrift : 0, + driftRange: typeof weqDriftRange !== 'undefined' ? weqDriftRange : 5, + driftScale: typeof weqDriftScale !== 'undefined' ? weqDriftScale : '1/1', + driftContinuous: typeof weqDriftContinuous !== 'undefined' ? weqDriftContinuous : false, + driftMode: typeof weqDriftMode !== 'undefined' ? weqDriftMode : 'independent', + driftTexture: typeof weqDriftTexture !== 'undefined' ? weqDriftTexture : 'smooth', + gainLoCut: typeof weqGainLoCut !== 'undefined' ? weqGainLoCut : 20, + gainHiCut: typeof weqGainHiCut !== 'undefined' ? weqGainHiCut : 20000, + driftLoCut: typeof weqDriftLoCut !== 'undefined' ? weqDriftLoCut : 20, + driftHiCut: typeof weqDriftHiCut !== 'undefined' ? weqDriftHiCut : 20000, + qModSpeed: typeof weqQModSpeed !== 'undefined' ? weqQModSpeed : 0, + qModDepth: typeof weqQModDepth !== 'undefined' ? weqQModDepth : 50, + qModShape: typeof weqQModShape !== 'undefined' ? weqQModShape : 'sine', + qLoCut: typeof weqQLoCut !== 'undefined' ? weqQLoCut : 20, + qHiCut: typeof weqQHiCut !== 'undefined' ? weqQHiCut : 20000, + + dbRange: typeof weqDBRangeMax !== 'undefined' ? weqDBRangeMax : 24, + splitMode: typeof weqSplitMode !== 'undefined' ? weqSplitMode : false, + oversample: typeof weqOversample !== 'undefined' ? weqOversample : 1, + splitSavedGains: typeof _weqSplitSavedGains !== 'undefined' ? _weqSplitSavedGains : null + } + }; + fn(JSON.stringify(state)); +} + +// Restore state from processor (called once on editor open) +function restoreFromHost() { + if (!(window.__JUCE__ && window.__JUCE__.backend)) { + // No JUCE backend, just start fresh + addBlock('randomize'); + return; + } + var fn = window.__juceGetNativeFunction('getFullState'); + fn().then(function (result) { + if (!result) { addBlock('randomize'); processRealTimeData(); return; } + + var hasPlugins = result.plugins && result.plugins.length > 0; + var hasUiState = result.uiState && result.uiState.length > 0; + + if (!hasPlugins && !hasUiState) { + // Fresh session — start with default block + addBlock('randomize'); + processRealTimeData(); + return; + } + + // Rebuild pluginBlocks and PMap from hosted plugins + pluginBlocks = []; + PMap = {}; + if (result.plugins) { + result.plugins.forEach(function (plug) { + var params = (plug.params || []).map(function (p) { + var fid = plug.id + ':' + p.index; + var param = { id: fid, name: p.name, v: p.value, disp: p.disp || '', lk: false, alk: false, realIndex: p.index, hostId: plug.id }; + PMap[fid] = param; + return param; + }); + pluginBlocks.push({ id: plug.id, hostId: plug.id, name: plug.name, path: plug.path || '', manufacturer: plug.manufacturer || '', params: params, expanded: true, searchFilter: '' }); + }); + } + + // Restore UI state (blocks, mappings, locks) + if (hasUiState) { + try { + var saved = JSON.parse(result.uiState); + + // Restore locks + if (saved.locks) { + for (var lid in saved.locks) { + if (PMap[lid]) PMap[lid].lk = true; + } + } + + // Restore plugin expanded states + if (saved.pluginExpanded) { + pluginBlocks.forEach(function (pb) { + if (saved.pluginExpanded[pb.id] !== undefined) + pb.expanded = saved.pluginExpanded[pb.id]; + }); + } + + // Restore plugin order + if (saved.pluginOrder && saved.pluginOrder.length > 0) { + var ordered = []; + saved.pluginOrder.forEach(function (pid) { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === pid) { + ordered.push(pluginBlocks[i]); + break; + } + } + }); + // Add any plugins not in saved order (newly loaded) + pluginBlocks.forEach(function (pb) { + if (ordered.indexOf(pb) < 0) ordered.push(pb); + }); + pluginBlocks = ordered; + + // Sync restored order to C++ backend + var reorderFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('reorderPlugins') : null; + if (reorderFn) { + var ids = pluginBlocks.map(function (pb) { return pb.id; }); + reorderFn(ids); + } + } + + // Restore bypass state + if (saved.pluginBypassed) { + pluginBlocks.forEach(function (pb) { + if (saved.pluginBypassed[pb.id]) { + pb.bypassed = true; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setPluginBypass'); + fn(pb.hostId, true); + } + } + }); + } + // Restore blocks + if (saved.blocks && saved.blocks.length > 0) { + blocks = saved.blocks.map(function (sb) { + // Convert targets array back to Set + var tSet = new Set(); + if (sb.targets) sb.targets.forEach(function (t) { + // Only restore target if the param still exists + if (PMap[t]) tSet.add(t); + }); + return { + id: sb.id, mode: sb.mode || 'randomize', targets: tSet, targetBases: sb.targetBases || {}, targetRanges: sb.targetRanges || {}, targetRangeBases: sb.targetRangeBases || {}, + colorIdx: sb.colorIdx || 0, + trigger: sb.trigger || 'manual', beatDiv: sb.beatDiv || '1/4', + midiMode: sb.midiMode || 'any_note', midiNote: sb.midiNote != null ? sb.midiNote : 60, + midiCC: sb.midiCC != null ? sb.midiCC : 1, midiCh: sb.midiCh != null ? sb.midiCh : 0, + velScale: sb.velScale || false, threshold: sb.threshold != null ? sb.threshold : -12, + audioSrc: sb.audioSrc || 'main', + rMin: sb.rMin || 0, rMax: sb.rMax !== undefined ? sb.rMax : 100, + rangeMode: (sb.mode === 'randomize') ? (sb.rangeMode || 'absolute') : 'relative', + quantize: sb.quantize || false, qSteps: sb.qSteps != null ? sb.qSteps : 12, + movement: sb.movement || 'instant', glideMs: sb.glideMs != null ? sb.glideMs : 200, + envAtk: sb.envAtk != null ? sb.envAtk : 10, envRel: sb.envRel != null ? sb.envRel : 100, + envSens: sb.envSens != null ? sb.envSens : 50, envInvert: sb.envInvert || false, + envFilterMode: sb.envFilterMode || 'flat', envFilterFreq: sb.envFilterFreq != null ? sb.envFilterFreq : 50, envFilterBW: sb.envFilterBW != null ? sb.envFilterBW : 5, + loopMode: sb.loopMode || 'loop', sampleSpeed: sb.sampleSpeed != null ? sb.sampleSpeed : 1.0, + sampleReverse: sb.sampleReverse || false, jumpMode: sb.jumpMode || 'restart', + sampleName: sb.sampleName || '', sampleWaveform: sb.sampleWaveform || null, + polarity: sb.polarity || 'bipolar', clockSource: sb.clockSource || 'daw', + snapshots: (sb.snapshots || []).map(function (s) { return { x: s.x != null ? s.x : 0.5, y: s.y != null ? s.y : 0.5, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + playheadX: (function () { var c = clampToCircle(sb.playheadX != null ? sb.playheadX : 0.5, sb.playheadY != null ? sb.playheadY : 0.5); return c.x; })(), + playheadY: (function () { var c = clampToCircle(sb.playheadX != null ? sb.playheadX : 0.5, sb.playheadY != null ? sb.playheadY : 0.5); return c.y; })(), + morphMode: sb.morphMode || 'manual', exploreMode: (sb.exploreMode === 'lfo' ? 'shapes' : sb.exploreMode) || 'wander', + lfoShape: sb.lfoShape || 'circle', lfoDepth: sb.lfoDepth != null ? sb.lfoDepth : 80, lfoRotation: sb.lfoRotation != null ? sb.lfoRotation : 0, morphSpeed: sb.morphSpeed != null ? sb.morphSpeed : 50, + morphAction: sb.morphAction || 'jump', stepOrder: sb.stepOrder || 'cycle', + morphSource: sb.morphSource || 'midi', jitter: sb.jitter != null ? sb.jitter : 0, + morphGlide: sb.morphGlide != null ? sb.morphGlide : 200, + morphTempoSync: !!sb.morphTempoSync, morphSyncDiv: sb.morphSyncDiv || '1/4', + snapRadius: sb.snapRadius != null ? sb.snapRadius : 100, + shapeType: sb.shapeType || 'circle', shapeTracking: sb.shapeTracking || 'horizontal', + shapeSize: sb.shapeSize != null ? sb.shapeSize : 80, shapeSpin: sb.shapeSpin != null ? sb.shapeSpin : 0, + shapeSpeed: sb.shapeSpeed != null ? sb.shapeSpeed : 50, shapePhaseOffset: sb.shapePhaseOffset || 0, + shapeRange: sb.shapeRange || 'relative', shapePolarity: sb.shapePolarity || 'bipolar', + shapeTempoSync: !!sb.shapeTempoSync, shapeSyncDiv: sb.shapeSyncDiv || '1/4', shapeTrigger: sb.shapeTrigger || 'free', + laneTool: sb.laneTool || 'draw', laneGrid: sb.laneGrid || '1/8', + lanes: (sb.lanes || []).map(function (lane) { + return { + pids: lane.pids || (lane.pid ? [lane.pid] : []), color: lane.color || '', collapsed: !!lane.collapsed, + pts: (lane.pts || []).map(function (p) { return { x: p.x, y: p.y }; }), + loopLen: lane.loopLen || '1/1', steps: lane.steps != null ? lane.steps : 0, depth: lane.depth != null ? lane.depth : 100, + drift: lane.drift != null ? lane.drift : 0, driftRange: lane.driftRange != null ? lane.driftRange : 5, driftScale: lane.driftScale || '1/1', warp: lane.warp != null ? lane.warp : 0, interp: lane.interp || 'smooth', + playMode: lane.playMode || 'forward', freeSecs: lane.freeSecs != null ? lane.freeSecs : 4, + synced: lane.synced !== false, muted: !!lane.muted, + trigMode: lane.trigMode || 'loop', trigSource: lane.trigSource || 'manual', + trigMidiNote: lane.trigMidiNote != null ? lane.trigMidiNote : -1, trigMidiCh: lane.trigMidiCh || 0, + trigThreshold: lane.trigThreshold != null ? lane.trigThreshold : -12, + trigAudioSrc: lane.trigAudioSrc || 'main', trigRetrigger: lane.trigRetrigger !== false, + trigHold: !!lane.trigHold, + morphMode: !!lane.morphMode, + morphSnapshots: (lane.morphSnapshots || []).map(function (s) { return { position: s.position || 0, hold: s.hold != null ? s.hold : 0.5, curve: s.curve || 0, depth: s.depth != null ? s.depth : 1.0, drift: s.drift || 0, driftRange: s.driftRange != null ? s.driftRange : 5, driftScale: s.driftScale || '', warp: s.warp || 0, steps: s.steps || 0, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + _overlayLanes: lane.overlayLanes || [] + }; + }), + enabled: sb.enabled !== false, + expanded: sb.expanded !== undefined ? sb.expanded : true + }; + }); + bc = saved.bc || 0; + actId = saved.actId || (blocks.length > 0 ? blocks[0].id : null); + } else { + // Has plugins but no blocks — create default + addBlock('randomize'); + } + } catch (e) { + console.log('UI state restore error:', e); + addBlock('randomize'); + } + // Restore UI scale (outside try/catch so it always applies) + if (saved && saved.uiScale) { + applyScale(saved.uiScale); + } + // Restore theme + if (saved && saved.uiTheme && THEMES[saved.uiTheme]) { + applyTheme(saved.uiTheme); + } + // Restore auto-locate setting + if (saved && saved.autoLocate !== undefined) { + autoLocate = saved.autoLocate; + document.getElementById('autoLocateChk').checked = autoLocate; + } + if (saved && saved.internalBpm) { + internalBpm = saved.internalBpm; + document.getElementById('internalBpmInput').value = internalBpm; + } + // Restore routing mode + if (saved && saved.routingMode !== undefined) { + routingMode = saved.routingMode; + document.querySelectorAll('.routing-btn').forEach(function (b) { + b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); + }); + if (window.__JUCE__ && window.__JUCE__.backend) { + var rmFn = window.__juceGetNativeFunction('setRoutingMode'); + rmFn(routingMode); + } + } + // Restore WrongEQ state + if (saved && saved.wrongEq) { + var weq = saved.wrongEq; + if (weq.points) { + wrongEqPoints = weq.points.map(function (p) { + var pt = { x: p.x, y: p.y, pluginIds: p.pluginIds || [], seg: p.seg || null, solo: p.solo || false, mute: p.mute || false, q: p.q != null ? p.q : 0.707, type: p.type || 'Bell', drift: p.drift || 0, preEq: p.preEq !== undefined ? p.preEq : (weq.preEq !== undefined ? weq.preEq : true), stereoMode: p.stereoMode || 0, slope: p.slope || 1 }; + if (p.uid) pt.uid = p.uid; // restore saved uid + return pt; + }); + // Ensure all points have uids and sync the counter + var maxUid = 0; + wrongEqPoints.forEach(function (pt) { + _weqEnsureUid(pt); + if (pt.uid > maxUid) maxUid = pt.uid; + }); + if (maxUid >= _weqNextUid) _weqNextUid = maxUid + 1; + } + if (typeof weqGlobalInterp !== 'undefined' && weq.interp) weqGlobalInterp = weq.interp; + if (typeof weqGlobalDepth !== 'undefined' && weq.depth != null) weqGlobalDepth = weq.depth; + if (typeof weqGlobalWarp !== 'undefined' && weq.warp != null) weqGlobalWarp = weq.warp; + if (typeof weqGlobalSteps !== 'undefined' && weq.steps != null) weqGlobalSteps = weq.steps; + if (typeof weqGlobalTilt !== 'undefined' && weq.tilt != null) weqGlobalTilt = weq.tilt; + + if (typeof weqPreEq !== 'undefined' && weq.preEq != null) weqPreEq = weq.preEq; + if (typeof weqGlobalBypass !== 'undefined' && weq.bypass != null) weqGlobalBypass = weq.bypass; + if (typeof weqUnassignedMode !== 'undefined' && weq.unassignedMode != null) weqUnassignedMode = weq.unassignedMode; + if (typeof weqAnimSpeed !== 'undefined' && weq.animSpeed != null) weqAnimSpeed = weq.animSpeed; + if (typeof weqAnimDepth !== 'undefined' && weq.animDepth != null) weqAnimDepth = weq.animDepth; + if (typeof weqAnimShape !== 'undefined' && weq.animShape != null) weqAnimShape = weq.animShape; + if (typeof weqDrift !== 'undefined' && weq.drift != null) weqDrift = weq.drift; + if (typeof weqDriftRange !== 'undefined' && weq.driftRange != null) weqDriftRange = weq.driftRange; + if (typeof weqDriftScale !== 'undefined' && weq.driftScale != null) weqDriftScale = weq.driftScale; + if (typeof weqDriftContinuous !== 'undefined' && weq.driftContinuous != null) weqDriftContinuous = weq.driftContinuous; + if (typeof weqDriftMode !== 'undefined' && weq.driftMode != null) weqDriftMode = weq.driftMode; + if (typeof weqDriftTexture !== 'undefined' && weq.driftTexture != null) weqDriftTexture = weq.driftTexture; + if (typeof weqGainLoCut !== 'undefined' && weq.gainLoCut != null) weqGainLoCut = weq.gainLoCut; + if (typeof weqGainHiCut !== 'undefined' && weq.gainHiCut != null) weqGainHiCut = weq.gainHiCut; + if (typeof weqDriftLoCut !== 'undefined' && weq.driftLoCut != null) weqDriftLoCut = weq.driftLoCut; + if (typeof weqDriftHiCut !== 'undefined' && weq.driftHiCut != null) weqDriftHiCut = weq.driftHiCut; + if (typeof weqQModSpeed !== 'undefined' && weq.qModSpeed != null) weqQModSpeed = weq.qModSpeed; + if (typeof weqQModDepth !== 'undefined' && weq.qModDepth != null) weqQModDepth = weq.qModDepth; + if (typeof weqQModShape !== 'undefined' && weq.qModShape != null) weqQModShape = weq.qModShape; + if (typeof weqQLoCut !== 'undefined' && weq.qLoCut != null) weqQLoCut = weq.qLoCut; + if (typeof weqQHiCut !== 'undefined' && weq.qHiCut != null) weqQHiCut = weq.qHiCut; + + if (typeof weqDBRangeMax !== 'undefined' && weq.dbRange != null) weqDBRangeMax = weq.dbRange; + if (typeof weqSplitMode !== 'undefined' && weq.splitMode != null) weqSplitMode = weq.splitMode; + if (typeof weqOversample !== 'undefined' && weq.oversample != null) weqOversample = weq.oversample; + if (typeof _weqSplitSavedGains !== 'undefined' && weq.splitSavedGains != null) _weqSplitSavedGains = weq.splitSavedGains; + } + // Show WrongEQ button if mode 2, sync restored state to C++ + if (typeof weqSetVisible === 'function') weqSetVisible(routingMode === 2); + if (routingMode === 2 && typeof weqSyncToHost === 'function') { + weqSyncToHost(); + // Retry sync after delay — the initial call may fire before the + // JUCE backend is fully connected, silently dropping the EQ data. + // Without this, the EQ curve shows visually but has no audio effect. + setTimeout(function () { + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + }, 500); + setTimeout(function () { + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + }, 1500); + } + // Auto-start animation if it was running (speed or drift active) + var needsAnim = (weqAnimSpeed > 0) || (Math.abs(weqDrift) > 0 && weqDriftRange > 0) || (weqDriftContinuous && weqDriftRange > 0); + if (routingMode === 2 && needsAnim && typeof weqAnimStart === 'function') weqAnimStart(); + // Restore also from getFullState response + if (result.routingMode !== undefined && !(saved && saved.routingMode !== undefined)) { + routingMode = result.routingMode; + document.querySelectorAll('.routing-btn').forEach(function (b) { + b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); + }); + } + // Restore bus assignments + if (saved && saved.pluginBuses) { + var buses = saved.pluginBuses; + pluginBlocks.forEach(function (pb) { + if (buses[pb.id] !== undefined) { + pb.busId = buses[pb.id]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + busFn(pb.hostId || pb.id, pb.busId); + } + } + }); + } + // Restore bus mixer state + if (saved && saved.busVolumes) { + for (var bvi = 0; bvi < saved.busVolumes.length && bvi < busVolumes.length; bvi++) { + busVolumes[bvi] = saved.busVolumes[bvi]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var bvFn = window.__juceGetNativeFunction('setBusVolume'); + bvFn(bvi, busVolumes[bvi]); + } + } + } + if (saved && saved.busMutes) { + for (var bmi = 0; bmi < saved.busMutes.length && bmi < busMutes.length; bmi++) { + busMutes[bmi] = saved.busMutes[bmi]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var bmFn = window.__juceGetNativeFunction('setBusMute'); + bmFn(bmi, busMutes[bmi]); + } + } + } + if (saved && saved.busSolos) { + for (var bsi = 0; bsi < saved.busSolos.length && bsi < busSolos.length; bsi++) { + busSolos[bsi] = saved.busSolos[bsi]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var bsFn = window.__juceGetNativeFunction('setBusSolo'); + bsFn(bsi, busSolos[bsi]); + } + } + } + if (saved && saved.busCollapsed) { + for (var bci = 0; bci < saved.busCollapsed.length && bci < busCollapsed.length; bci++) { + busCollapsed[bci] = saved.busCollapsed[bci]; + } + } + // Restore scan paths + if (saved && saved.scanPaths && saved.scanPaths.length > 0) { + scanPaths = saved.scanPaths.slice(); + } + // Restore expose state + if (saved && saved.exposeState && typeof restoreExposeState === 'function') { + restoreExposeState(saved.exposeState); + } + // Also read busId from getFullState plugin data + if (result.plugins) { + result.plugins.forEach(function (dp) { + if (dp.busId) { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === dp.id) { + pluginBlocks[i].busId = dp.busId; + break; + } + } + } + }); + } + } else { + // Has plugins but no saved UI state — create default block + addBlock('randomize'); + } + + renderAllPlugins(); + renderBlocks(); + updCounts(); + syncBlocksToHost(); + syncExpandedPlugins(); + processRealTimeData(); + }).catch(function (e) { + console.log('getFullState error:', e); + addBlock('randomize'); + processRealTimeData(); + }); +} + +// ============================================================ +// AUTO-SAVE: Periodic + on-close state persistence +// Ensures processor always has current UI state, even if the +// editor is destroyed without an explicit save action. +// ============================================================ + +// Auto-save every 3 seconds — but only if state actually changed +var _stateDirty = false; +function markStateDirty() { + _stateDirty = true; + // Also mark global preset as dirty so the user knows there are unsaved changes + if (typeof markGpDirty === 'function') markGpDirty(); +} +setInterval(function () { + if (_stateDirty) { + _stateDirty = false; + saveUiStateToHost(); + } +}, 3000); + +// Last-ditch save when WebView is about to be destroyed +window.addEventListener('beforeunload', function () { + saveUiStateToHost(); +}); + +// Save when page becomes hidden (tab switch, minimize, etc.) +// Restore virtual scroll + canvases when page becomes visible again +document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') { + saveUiStateToHost(); + } else if (document.visibilityState === 'visible') { + // Tell the readback handler to silently adopt values for a few frames + // without marking params dirty. This prevents the catch-up burst after minimize. + if (typeof _laneSkipDirty !== 'undefined') _laneSkipDirty = 3; + + // Re-render virtual scroll rows — containers may have had zero height while hidden + document.querySelectorAll('.pcard-params').forEach(function (paramC) { + if (paramC._vScroll && typeof _updateVirtualRows === 'function') { + _updateVirtualRows(paramC); + } + }); + // Redraw lane canvases — they need a paint after being hidden + if (typeof blocks !== 'undefined') { + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + if (b.mode === 'lane' && b.lanes && b.expanded && typeof laneDrawCanvas === 'function') { + for (var li = 0; li < b.lanes.length; li++) { + if (!b.lanes[li].collapsed) laneDrawCanvas(b, li); + } + } + } + } + } +}); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/plugin_rack.js b/plugins/ModularRandomizer/Source/ui/public/js/plugin_rack.js new file mode 100644 index 0000000..4915b6e --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/plugin_rack.js @@ -0,0 +1,1896 @@ +// ============================================================ +// PLUGIN RACK +// Plugin loading, rendering, param display, drag & context menu +// ============================================================ + +// Visibility culling: tell C++ which plugin IDs are currently expanded. +// C++ skips polling params from collapsed plugins. +function syncExpandedPlugins() { + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('setExpandedPlugins'); + var ids = []; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].expanded && !pluginBlocks[i].isVirtual) ids.push(pluginBlocks[i].id); + } + fn(ids); +} + +// Force-dirty all params for a plugin so refreshParamDisplay repaints them. +// Called when a card is expanded or when scroll reveals new rows. +function dirtyPluginParams(pluginId) { + // Don't iterate 2000+ params — just mark modDirty + invalidate visPids cache. + // refreshParamDisplay will check all visible params on next frame. + _modDirty = true; + _visPidsDirty = true; +} + +// Load a REAL VST3 plugin via native function +function addPlugin(pluginPath) { + if (!window.__JUCE__ || !window.__JUCE__.backend) { + console.log('JUCE backend not available'); + return; + } + if (typeof pluginLoading !== 'undefined' && pluginLoading) return; // prevent double-click + var plugName = pluginPath.split(/[\\/]/).pop().replace(/\.vst3$/i, ''); + if (typeof setPluginLoading === 'function') setPluginLoading(true, plugName); + + // Show placeholder card immediately (visual feedback while loading) + var placeholderId = 'loading-' + Date.now(); + appendPlaceholderCard(placeholderId, plugName); + + console.log('Loading plugin: ' + pluginPath); + var loadFn = window.__juceGetNativeFunction('loadPlugin'); + loadFn(pluginPath).then(function (result) { + removePlaceholderCard(placeholderId); + if (typeof setPluginLoading === 'function') setPluginLoading(false); + if (!result || result.error) { + showLoadError(plugName, result ? result.error : 'Load failed'); + return; + } + // Plugin loaded successfully — build param map and card + try { + var hostedId = result.id; + var params = (result.params || []).map(function (p, i) { + var fid = hostedId + ':' + p.index; + var param = { id: fid, name: p.name, v: p.value, disp: p.disp || '', lk: false, alk: false, realIndex: p.index, hostId: hostedId }; + PMap[fid] = param; + return param; + }); + pluginBlocks.push({ id: hostedId, hostId: hostedId, name: result.name, path: pluginPath, manufacturer: result.manufacturer || '', params: params, expanded: true, searchFilter: '' }); + console.log('Loaded: ' + result.name + ' (' + params.length + ' params)'); + showToast(result.name + ' loaded (' + params.length + ' params)', 'success', 2500); + renderAllPlugins(); updCounts(); saveUiStateToHost(); syncExpandedPlugins(); + // Update WrongEQ routing panel so unassigned plugins appear in the global section + if (routingMode === 2 && typeof weqRenderPanel === 'function') weqRenderPanel(); + + // Auto-assign to WrongEQ band if load was triggered from a band card + if (typeof window._weqLoadTargetBand === 'number' && window._weqLoadTargetBand >= 0) { + var bandIdx = window._weqLoadTargetBand; + window._weqLoadTargetBand = -1; // consume + if (typeof wrongEqPoints !== 'undefined' && wrongEqPoints[bandIdx]) { + var ept = wrongEqPoints[bandIdx]; + if (!ept.pluginIds) ept.pluginIds = []; + if (typeof _weqEnsureUid === 'function') _weqEnsureUid(ept); + // Remove this plugin from any other band first + for (var oi = 0; oi < wrongEqPoints.length; oi++) { + if (!wrongEqPoints[oi].pluginIds) continue; + var oidx = wrongEqPoints[oi].pluginIds.indexOf(hostedId); + if (oidx >= 0) wrongEqPoints[oi].pluginIds.splice(oidx, 1); + } + ept.pluginIds.push(hostedId); + // Find the block we just pushed + var newBlock = pluginBlocks[pluginBlocks.length - 1]; + if (newBlock && newBlock.id === hostedId) { + newBlock.busId = ept.uid || 0; + } + // Tell C++ about the bus routing + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + busFn(hostedId, ept.uid || 0); + } + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + if (typeof markStateDirty === 'function') markStateDirty(); + showToast(result.name + ' assigned to Band ' + (bandIdx + 1), 'success', 2000); + } + } + } catch (uiErr) { + // Plugin IS loaded in C++ — UI error shouldn't hide that + console.error('Plugin loaded but UI update failed:', uiErr); + showToast(plugName + ' loaded (UI refresh error — reopen plugin)', 'info', 4000); + } + }).catch(function (err) { + removePlaceholderCard(placeholderId); + if (typeof setPluginLoading === 'function') setPluginLoading(false); + showLoadError(plugName, err); + }); +} +function removePlugin(pid) { + var pb; for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + if (pb.isVirtual) return; // cannot remove virtual blocks + pushUndoSnapshot(); + // Call native removePlugin + if (window.__JUCE__ && window.__JUCE__.backend) { + var removeFn = window.__juceGetNativeFunction('removePlugin'); + removeFn(pb.hostId !== undefined ? pb.hostId : pid); + } + pb.params.forEach(function (p) { delete PMap[p.id]; blocks.forEach(function (b) { b.targets.delete(p.id); cleanBlockAfterUnassign(b, p.id); }); }); + // Clean up lane data referencing this plugin's params + var pidPrefix = pb.hostId + ':'; + blocks.forEach(function (b) { + if (!b.lanes) return; + for (var li = b.lanes.length - 1; li >= 0; li--) { + var lane = b.lanes[li]; + if (!lane.pids) continue; + var before = lane.pids.length; + lane.pids = lane.pids.filter(function (p) { return p.indexOf(pidPrefix) !== 0; }); + if (lane.pids.length < before) { + // Also clean snapshot values + if (lane.morphSnapshots) { + lane.morphSnapshots.forEach(function (snap) { + if (!snap.values) return; + Object.keys(snap.values).forEach(function (k) { + if (k.indexOf(pidPrefix) === 0) delete snap.values[k]; + }); + }); + } + // Remove empty curve lanes (morph lanes kept — may have other plugin snapshots) + if (lane.pids.length === 0 && !lane.morphMode) { + b.lanes.splice(li, 1); + } + } + } + }); + pluginBlocks = pluginBlocks.filter(function (p) { return p.id !== pid; }); + renderAllPlugins(); renderBlocks(); updCounts(); syncBlocksToHost(); syncExpandedPlugins(); + // Update WrongEQ routing panel (plugin may have been in a band or global) + if (routingMode === 2 && typeof weqRenderPanel === 'function') weqRenderPanel(); +} +// Full build of a single plugin card DOM element +var plugCtxPluginId = null; // which plugin the context menu is targeting +function buildPluginCard(pb, pi, isA, aBlk, aCol) { + var card = document.createElement('div'); card.className = 'pcard' + (pb.bypassed ? ' bypassed' : '') + (pb.isVirtual ? ' pcard-virtual' : ''); + card.setAttribute('data-plugidx', pi); + card.setAttribute('data-plugid', pb.id); + + // Virtual blocks: not draggable, no close/bypass/open/preset + if (pb.isVirtual) { + var bulkBtns = isA ? '' : ''; + card.innerHTML = '
' + pb.name + '' + pb.params.length + ' params' + bulkBtns + '
'; + fillPluginParams(card.querySelector('[data-plugparams="' + pb.id + '"]'), pb, isA, aBlk, aCol); + return card; + } + + card.setAttribute('draggable', 'true'); + var bulkBtns = isA ? '' : ''; + // Bus dropdown (parallel mode and WrongEQ mode) + var busBadge = ''; + if (routingMode === 1) { + var busIdx = pb.busId || 0; + var busCol = BUS_COLORS[busIdx % BUS_COLORS.length]; + var opts = ''; + for (var bi = 0; bi < 7; bi++) { + opts += ''; + } + busBadge = ''; + } else if (routingMode === 2 && typeof wrongEqPoints !== 'undefined' && wrongEqPoints.length > 0) { + var busIdx2 = pb.busId != null ? pb.busId : 0; + var weqCol = typeof WEQ_BAND_COLORS !== 'undefined' ? WEQ_BAND_COLORS[Math.max(0, busIdx2) % WEQ_BAND_COLORS.length] : '#888'; + var opts2 = ''; + for (var ei = 0; ei < wrongEqPoints.length; ei++) { + var ePt = wrongEqPoints[ei]; + var eBusId = ePt.uid || (ei + 1); + // Show Q-based frequency range instead of filter type + var eRange = typeof weqBandRange === 'function' ? weqBandRange(ePt) : null; + var eLabel = eRange + ? 'B' + (ei + 1) + ' ' + weqFmtFreq(eRange.lo) + '–' + weqFmtFreq(eRange.hi) + : 'B' + (ei + 1) + ' ' + (ePt.type || 'Bell'); + opts2 += ''; + } + busBadge = ''; + } + // Footer toolbar + var footer = '
'; + footer += ''; + footer += '
'; + footer += '
'; + footer += '
'; + footer += '
'; + footer += '
'; + footer += '
'; + // Bypass icon pushed to the right + footer += ''; + footer += '
'; + card.innerHTML = '
' + busBadge + '' + pb.name + '' + pb.params.length + ' params' + bulkBtns + '
' + footer; + fillPluginParams(card.querySelector('[data-plugparams="' + pb.id + '"]'), pb, isA, aBlk, aCol); + return card; +} +// Fill param rows into a container element (used by both full build and patch) +function fillPluginParams(paramC, pb, isA, aBlk, aCol) { + var filter = (pb.searchFilter || '').toLowerCase(); + var srBlock = (isA && aBlk && aBlk.mode === 'shapes_range') ? aBlk : null; + + // Build filtered param list + var filteredParams = []; + for (var i = 0; i < pb.params.length; i++) { + var p = pb.params[i]; + if (filter && p.name.toLowerCase().indexOf(filter) === -1) continue; + filteredParams.push(p); + } + + // For small param counts, render all rows directly (no virtual scroll overhead) + var VIRTUAL_THRESHOLD = 100; + if (filteredParams.length <= VIRTUAL_THRESHOLD) { + paramC._vScroll = false; + paramC.innerHTML = ''; + for (var i = 0; i < filteredParams.length; i++) { + paramC.appendChild(_buildParamRow(filteredParams[i], isA, aBlk, aCol, srBlock)); + } + paramC.onscroll = null; + return; + } + + // Virtual scrolling for large param counts + var ROW_H = 36; // matches CSS .pr { height: 36px } + var savedScroll = paramC.scrollTop || 0; + + paramC._vScroll = true; + paramC._vParams = filteredParams; + paramC._vRowH = ROW_H; + paramC._vIsA = isA; + paramC._vABlk = aBlk; + paramC._vACol = aCol; + paramC._vSrBlock = srBlock; + paramC._vRendered = {}; // pid → { row, idx } + paramC._vStart = -1; + paramC._vEnd = -1; + paramC.innerHTML = ''; + + // Sentinel div provides scroll height + var sentinel = document.createElement('div'); + sentinel.style.height = (filteredParams.length * ROW_H) + 'px'; + sentinel.style.pointerEvents = 'none'; + paramC.appendChild(sentinel); + + // Restore scroll position (survives re-render from assign-mode toggle etc.) + paramC.scrollTop = savedScroll; + + // Render visible rows + _updateVirtualRows(paramC); + + // Scroll handler — debounced via rAF + paramC.onscroll = function () { + if (!paramC._vRaf) { + paramC._vRaf = requestAnimationFrame(function () { + paramC._vRaf = null; + _updateVirtualRows(paramC); + }); + } + }; +} + +// Build a single absolute-positioned or flow-positioned param row +function _buildParamRow(p, isA, aBlk, aCol, srBlock) { + var isTgt = isA && aBlk && aBlk.targets.has(p.id); + var row = document.createElement('div'); + row.className = 'pr' + (p.lk ? ' locked' : '') + (isA && !p.lk ? ' assign-highlight' : '') + (selectedParams.has(p.id) ? ' selected' : ''); + row.setAttribute('data-pid', p.id); + if (!p.lk) row.setAttribute('draggable', 'true'); + if (isTgt) { row.style.background = aCol + '18'; row.style.borderColor = aCol + '66'; } + var dots = ''; for (var bi = 0; bi < blocks.length; bi++) { if (blocks[bi].targets.has(p.id)) dots += ''; } + // SVG rotary knob — arc from assign-mode block (priority) or getModArcInfo + var rangeInfo = null; + if (srBlock && isTgt) { + var rng = srBlock.targetRanges && srBlock.targetRanges[p.id] !== undefined ? srBlock.targetRanges[p.id] : 0; + var base = srBlock.targetRangeBases && srBlock.targetRangeBases[p.id] !== undefined ? srBlock.targetRangeBases[p.id] : p.v; + rangeInfo = { range: rng, base: base, color: aCol, polarity: srBlock.shapePolarity || 'bipolar' }; + } else if (!srBlock) { + rangeInfo = getModArcInfo(p.id); + if (rangeInfo) { + var cur = computeModCurrent(rangeInfo, p.v); + if (cur !== null) rangeInfo.current = cur; + } + } + var knobVal = (rangeInfo && rangeInfo.base !== undefined) ? rangeInfo.base : p.v; + var knobSvg = buildParamKnob(knobVal, 30, rangeInfo); + var grip = p.lk ? '' : ''; + row.innerHTML = grip + '
' + knobSvg + '
' + p.name + '
' + dots + '
' + (p.disp || ((p.v * 100).toFixed(0) + '%')) + '
' + (p.lk ? '' + (p.alk ? '⚠' : '🔒') + '' : ''); + return row; +} + +// Update which rows are in the DOM for a virtual-scroll param container +function _updateVirtualRows(paramC) { + var params = paramC._vParams; + if (!params) return; + var ROW_H = paramC._vRowH; + var scrollTop = paramC.scrollTop; + var viewH = paramC.clientHeight; + if (viewH <= 0) { + // Container not laid out yet — retry after next paint + if (!paramC._vRetry) { + paramC._vRetry = requestAnimationFrame(function () { + paramC._vRetry = null; + _updateVirtualRows(paramC); + }); + } + return; + } + + var BUFFER = 5; // extra rows above/below viewport + var startIdx = Math.max(0, Math.floor(scrollTop / ROW_H) - BUFFER); + var endIdx = Math.min(params.length - 1, Math.ceil((scrollTop + viewH) / ROW_H) + BUFFER); + + if (startIdx === paramC._vStart && endIdx === paramC._vEnd) return; + + var rendered = paramC._vRendered; + + // Remove rows that scrolled out of range + for (var pid in rendered) { + var entry = rendered[pid]; + if (entry.idx < startIdx || entry.idx > endIdx) { + entry.row.remove(); + delete rendered[pid]; + } + } + + // Add rows that scrolled into range + for (var i = startIdx; i <= endIdx; i++) { + var p = params[i]; + if (rendered[p.id]) continue; + var row = _buildParamRow(p, paramC._vIsA, paramC._vABlk, paramC._vACol, paramC._vSrBlock); + row.style.position = 'absolute'; + row.style.top = (i * ROW_H) + 'px'; + row.style.left = '0'; + row.style.right = '0'; + row.style.height = ROW_H + 'px'; + row.style.boxSizing = 'border-box'; + paramC.appendChild(row); + rendered[p.id] = { row: row, idx: i }; + } + + paramC._vStart = startIdx; + paramC._vEnd = endIdx; +} + +// Scroll a virtual-scroll container to reveal a specific param ID +function scrollVirtualToParam(pid) { + var containers = document.querySelectorAll('.pcard-params'); + for (var ci = 0; ci < containers.length; ci++) { + var paramC = containers[ci]; + if (!paramC._vScroll || !paramC._vParams) continue; + for (var i = 0; i < paramC._vParams.length; i++) { + if (paramC._vParams[i].id === pid) { + paramC.scrollTop = i * paramC._vRowH; + _updateVirtualRows(paramC); + return true; + } + } + } + return false; +} +// Build a small SVG arc knob for value 0..1, with optional modulation arc +// rangeInfo: { range, color, polarity, base, current? } +// When current is provided: dot at base, live fill from base→current, faint range band +// When current is absent: dot at val, static range band only +function buildParamKnob(val, size, rangeInfo) { + var r = size / 2, cx = r, cy = r; + var ir = r - 3; // inner radius for arc + var startAngle = 135 * Math.PI / 180; // 7 o'clock + var endAngle = 405 * Math.PI / 180; // 5 o'clock + var span = endAngle - startAngle; // 270° + // Background track arc + var tPath = describeArc(cx, cy, ir, startAngle, endAngle); + // Determine knob dot position: base if modulating live, else val + var dotVal = (rangeInfo && rangeInfo.current !== undefined && rangeInfo.base !== undefined) ? rangeInfo.base : val; + var va = startAngle + dotVal * span; + // Value arc (white): from min to dot position + var vPath = dotVal > 0.005 ? describeArc(cx, cy, ir, startAngle, va) : ''; + // Indicator dot position + var dx = cx + ir * Math.cos(va), dy = cy + ir * Math.sin(va); + var svg = ''; + svg += ''; + if (vPath) svg += ''; + // Modulation arc rendering + if (rangeInfo && Math.abs(rangeInfo.range) > 0.001) { + var rng = rangeInfo.range; + var absRng = Math.abs(rng); + var base = rangeInfo.base !== undefined ? rangeInfo.base : val; + var pol = rangeInfo.polarity || 'bipolar'; + var arcCol = rangeInfo.color || 'var(--range-arc, #ff8c42)'; + var rInner = ir - 0.5; + var rOuter = ir + 3; + // 1) Static range band (faint) — shows where modulation CAN go + var rStart, rEnd; + if (pol === 'bipolar') { + rStart = Math.max(base - absRng, 0); + rEnd = Math.min(base + absRng, 1); + } else if (pol === 'up') { + rStart = base; + rEnd = Math.min(base + absRng, 1); + } else if (pol === 'down') { + rStart = Math.max(base - absRng, 0); + rEnd = base; + } else { + if (rng > 0) { rStart = base; rEnd = Math.min(base + rng, 1); } + else { rStart = Math.max(base + rng, 0); rEnd = base; } + } + var raStart = startAngle + rStart * span; + var raEnd = startAngle + rEnd * span; + if (raEnd > raStart + 0.01) { + var bandPath = describeArcBand(cx, cy, rInner, rOuter, raStart, raEnd); + svg += ''; + var outerArc = describeArc(cx, cy, rOuter, raStart, raEnd); + svg += ''; + } + // 2) Dynamic fill arc (bright) — shows where modulation IS right now + // Fills from base to current modulated position + if (rangeInfo.current !== undefined) { + var cur = Math.max(0, Math.min(1, rangeInfo.current)); + var fStart, fEnd; + if (cur >= base) { + fStart = base; + fEnd = cur; + } else { + fStart = cur; + fEnd = base; + } + var faStart = startAngle + fStart * span; + var faEnd = startAngle + fEnd * span; + if (faEnd > faStart + 0.005) { + var fillPath = describeArcBand(cx, cy, rInner, rOuter, faStart, faEnd); + svg += ''; + var fillEdge = describeArc(cx, cy, rOuter, faStart, faEnd); + svg += ''; + } + } + // 3) Base marker tick + var baseAngle = startAngle + base * span; + var bx1 = cx + (ir - 2) * Math.cos(baseAngle); + var by1 = cy + (ir - 2) * Math.sin(baseAngle); + var bx2 = cx + (rOuter + 1) * Math.cos(baseAngle); + var by2 = cy + (rOuter + 1) * Math.sin(baseAngle); + svg += ''; + } + svg += ''; + svg += ''; + return svg; +} +// Filled arc band between two radii (annular sector) +function describeArcBand(cx, cy, rInner, rOuter, start, end) { + var osx = cx + rOuter * Math.cos(start), osy = cy + rOuter * Math.sin(start); + var oex = cx + rOuter * Math.cos(end), oey = cy + rOuter * Math.sin(end); + var isx = cx + rInner * Math.cos(end), isy = cy + rInner * Math.sin(end); + var iex = cx + rInner * Math.cos(start), iey = cy + rInner * Math.sin(start); + var large = (end - start > Math.PI) ? 1 : 0; + return 'M ' + osx.toFixed(2) + ' ' + osy.toFixed(2) + + ' A ' + rOuter + ' ' + rOuter + ' 0 ' + large + ' 1 ' + oex.toFixed(2) + ' ' + oey.toFixed(2) + + ' L ' + isx.toFixed(2) + ' ' + isy.toFixed(2) + + ' A ' + rInner + ' ' + rInner + ' 0 ' + large + ' 0 ' + iex.toFixed(2) + ' ' + iey.toFixed(2) + + ' Z'; +} +function describeArc(cx, cy, r, start, end) { + var sx = cx + r * Math.cos(start), sy = cy + r * Math.sin(start); + var ex = cx + r * Math.cos(end), ey = cy + r * Math.sin(end); + var large = (end - start > Math.PI) ? 1 : 0; + return 'M ' + sx.toFixed(2) + ' ' + sy.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + ex.toFixed(2) + ' ' + ey.toFixed(2); +} +// ── Modulation Arc Registry ── +// Each continuous block type registers a descriptor: +// getDepth(block, pid) → number (0..1 range band for arc) +// getPolarity(block) → 'bipolar'|'up'|'down'|'unipolar' +// getOutput(block, pid) → number|null (readback value for fill animation) +// To add a new block type: add ONE entry here. Everything else is generic. +var MOD_ARC_REGISTRY = { + shapes: { + getDepth: function (b) { return (b.shapeSize != null ? b.shapeSize : 80) / 200; }, + getPolarity: function (b) { return b.shapePolarity || 'bipolar'; }, + // Readback: bipolar output -1..1 from morphHeads + getOutput: function (b) { return b.shapeModOutput || 0; }, + outputType: 'bipolar' // -1..1 + }, + shapes_range: { + getDepth: function (b, pid) { return Math.abs(b.targetRanges && b.targetRanges[pid] !== undefined ? b.targetRanges[pid] : 0); }, + getPolarity: function (b) { return b.shapePolarity || 'bipolar'; }, + getOutput: function (b) { return b.shapeModOutput || 0; }, + outputType: 'bipolar' + }, + envelope: { + getDepth: function (b) { return (b.rMax != null ? b.rMax : 100) / 100; }, + getPolarity: function (b) { return b.polarity || 'bipolar'; }, + // Readback: unipolar output 0..1 from envLevels + getOutput: function (b) { return b.envModOutput || 0; }, + outputType: 'unipolar' // 0..1 + }, + sample: { + getDepth: function (b) { return (b.rMax != null ? b.rMax : 100) / 100; }, + getPolarity: function (b) { return b.polarity || 'bipolar'; }, + getOutput: function (b) { return null; }, // No readback yet — falls back to p.v + outputType: 'unipolar' + }, + lane: { + // Lane: absolute mode — scan drawn points (curve) or morph snapshots to find min/max + getDepth: function (b, pid) { + var minVal = 1, maxVal = 0, found = false; + if (!b.lanes) return 0; + for (var li = 0; li < b.lanes.length; li++) { + var lane = b.lanes[li]; + if (lane.muted) continue; + // O(1) set lookup instead of O(n) array scan for pid membership + if (lane.pids) { + if (!lane._pidsSet || lane._pidsSet._ver !== lane.pids.length) { + lane._pidsSet = new Set(lane.pids); + lane._pidsSet._ver = lane.pids.length; + } + if (!lane._pidsSet.has(pid)) continue; + } else { continue; } + var ld = (lane.depth != null ? lane.depth : 100) / 100.0; + + if (lane.morphMode && lane.morphSnapshots && lane.morphSnapshots.length > 0) { + // Morph lane: scan all snapshot values for this param + for (var si = 0; si < lane.morphSnapshots.length; si++) { + var sv = lane.morphSnapshots[si].values[pid]; + if (sv === undefined) continue; + var paramVal = 0.5 + (sv - 0.5) * ld; + paramVal = Math.max(0, Math.min(1, paramVal)); + if (!found) { minVal = paramVal; maxVal = paramVal; found = true; } + else { if (paramVal < minVal) minVal = paramVal; if (paramVal > maxVal) maxVal = paramVal; } + } + } else if (lane.pts && lane.pts.length > 0) { + // Curve lane: scan drawn points + for (var pi = 0; pi < lane.pts.length; pi++) { + var paramVal = 1.0 - lane.pts[pi].y; + paramVal = 0.5 + (paramVal - 0.5) * ld; + paramVal = Math.max(0, Math.min(1, paramVal)); + if (!found) { minVal = paramVal; maxVal = paramVal; found = true; } + else { if (paramVal < minVal) minVal = paramVal; if (paramVal > maxVal) maxVal = paramVal; } + } + } + if (found) { + // Expand range to account for drift amplitude + var driftAmt = Math.abs(lane.drift || 0); + var driftRangeNorm = (lane.driftRange != null ? lane.driftRange : 5) / 100; + if (driftAmt > 0.001 && driftRangeNorm > 0.001) { + minVal = Math.max(0, minVal - driftRangeNorm); + maxVal = Math.min(1, maxVal + driftRangeNorm); + } + b._laneArcMin = minVal; + b._laneArcMax = maxVal; + } + } + if (!found) return 0; + return (maxVal - minVal) / 2; + }, + getPolarity: function (b) { return 'bipolar'; }, + // Readback: absolute 0..1 value — morph lanes compute per-param interpolation + getOutput: function (b, pid) { + if (!b.laneModOutputs || !pid) return undefined; + var rawVal = b.laneModOutputs[pid]; + if (rawVal === undefined) return undefined; + return rawVal; + }, + outputType: 'absolute' // 0..1 direct parameter value + }, + morph_pad: { + // Depth: scan all snapshot values for this param to find the modulation range + getDepth: function (b, pid) { + if (!b.snapshots || b.snapshots.length < 2) return 0; + var minVal = 1, maxVal = 0, found = false; + for (var si = 0; si < b.snapshots.length; si++) { + var sv = b.snapshots[si].values && b.snapshots[si].values[pid]; + if (sv === undefined) continue; + if (!found) { minVal = sv; maxVal = sv; found = true; } + else { if (sv < minVal) minVal = sv; if (sv > maxVal) maxVal = sv; } + } + if (!found) return 0; + b._morphPadArcMin = minVal; + b._morphPadArcMax = maxVal; + return (maxVal - minVal) / 2; + }, + getPolarity: function (b) { return 'bipolar'; }, + // Readback: absolute 0..1 value — IDW interpolation computed in realtime.js + getOutput: function (b, pid) { + if (!b.morphPadOutputs || !pid) return undefined; + var rawVal = b.morphPadOutputs[pid]; + if (rawVal === undefined) return undefined; + return rawVal; + }, + outputType: 'absolute' // 0..1 direct parameter value + } +}; + +// Convert a readback output + polarity to a signed offset for the arc fill +function modOutputToOffset(output, depth, polarity, outputType) { + if (output === null || output === undefined) return null; + if (outputType === 'bipolar') { + // -1..1 → signed offset + if (polarity === 'bipolar') return output * depth; + if (polarity === 'up') return Math.abs(output) * depth; + if (polarity === 'down') return -Math.abs(output) * depth; + return ((output + 1) * 0.5) * depth; // unipolar + } + if (outputType === 'unipolar') { + // 0..1 → signed offset + if (polarity === 'bipolar') return (output * 2 - 1) * depth; + if (polarity === 'up') return output * depth; + if (polarity === 'down') return -output * depth; + return output * depth; + } + if (outputType === 'centered') { + // 0..1 centered at 0.5 → signed offset + return (output - 0.5) * 2 * depth; + } + if (outputType === 'absolute') { + // 0..1 direct value — return as-is (caller handles positioning) + return output; + } + return null; +} + +// ── getModArcInfo(pid) ── +// Returns { range, base, color, polarity, sources, pid } or null. +function getModArcInfo(pid) { + // Special case: shapes_range as sole modulator uses per-param range + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + if (b.enabled === false || !b.targets.has(pid) || b.mode !== 'shapes_range') continue; + var reg = MOD_ARC_REGISTRY.shapes_range; + var d = reg.getDepth(b, pid); + if (d < 0.001) continue; + // Check if it's the only modulator + var alone = true; + for (var bi2 = 0; bi2 < blocks.length; bi2++) { + if (bi2 === bi) continue; + var b2 = blocks[bi2]; + if (b2.enabled !== false && b2.targets.has(pid) && b2.mode !== 'randomize') { alone = false; break; } + } + if (alone) { + var rng = b.targetRanges && b.targetRanges[pid] !== undefined ? b.targetRanges[pid] : 0; + var base = b.targetRangeBases && b.targetRangeBases[pid] !== undefined ? b.targetRangeBases[pid] : 0.5; + var pol = reg.getPolarity(b); + var sign = rng < 0 ? -1 : 1; + return { range: rng, base: base, color: bColor(b.colorIdx), polarity: pol, sources: [{ block: b, depth: d, polarity: pol, reg: reg, rangeSign: sign }], pid: pid }; + } + } + + // General pass: collect all continuous blocks targeting this param + var totalDepth = 0, firstBase = null, firstColor = null; + var sources = []; + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + if (b.enabled === false || !b.targets.has(pid)) continue; + var reg = MOD_ARC_REGISTRY[b.mode]; + if (!reg) continue; // Skip non-continuous blocks (randomize, morph_pad, unknown) + var d = reg.getDepth(b, pid); + if (d < 0.001) continue; + var pol = reg.getPolarity(b); + totalDepth += d; + var srcEntry = { block: b, depth: d, polarity: pol, reg: reg }; + // shapes_range: carry per-param range sign so fill arc flips for negative ranges + if (b.mode === 'shapes_range' && b.targetRanges && b.targetRanges[pid] !== undefined && b.targetRanges[pid] < 0) { + srcEntry.rangeSign = -1; + } + sources.push(srcEntry); + if (!firstColor) { + firstColor = bColor(b.colorIdx); + // For absolute (lane/morph_pad) blocks, use the midpoint of the value range as base + if (reg.outputType === 'absolute' && b._laneArcMin !== undefined) { + firstBase = (b._laneArcMin + b._laneArcMax) / 2; + } else if (reg.outputType === 'absolute' && b._morphPadArcMin !== undefined) { + firstBase = (b._morphPadArcMin + b._morphPadArcMax) / 2; + } else { + firstBase = b.targetBases && b.targetBases[pid] !== undefined ? b.targetBases[pid] : 0.5; + } + } + } + if (sources.length === 0 || totalDepth < 0.001) return null; + return { + range: totalDepth, base: firstBase, color: firstColor, + polarity: sources.length === 1 ? sources[0].polarity : 'bipolar', + sources: sources, pid: pid + }; +} + +// ── computeModCurrent(ri, paramVal) ── +// Sums all source readback offsets to get the combined fill position. +function computeModCurrent(ri, paramVal) { + if (!ri || !ri.sources || ri.sources.length === 0) return null; + var totalOffset = 0, hasReadback = false, hasAbsolute = false, absoluteVal = 0; + for (var si = 0; si < ri.sources.length; si++) { + var src = ri.sources[si]; + var out = src.reg.getOutput(src.block, ri.pid); + if (src.reg.outputType === 'absolute') { + // Absolute sources: readback IS the parameter position + if (out !== null && out !== undefined) { + absoluteVal = out; + hasAbsolute = true; + hasReadback = true; + } + } else { + var off = modOutputToOffset(out, src.depth, src.polarity, src.reg.outputType); + if (off !== null) { + // shapes_range: flip offset direction when per-param range is negative + if (src.rangeSign && src.rangeSign < 0) off = -off; + totalOffset += off; + hasReadback = true; + } + } + } + if (hasAbsolute) return Math.max(0, Math.min(1, absoluteVal + totalOffset)); + if (hasReadback) return Math.max(0, Math.min(1, ri.base + totalOffset)); + if (paramVal !== undefined) return paramVal; + return null; +} + +// ── updateModBases(pid, newVal) ── +// Update stored base for all modulation blocks targeting a param (called on user knob drag). +function updateModBases(pid, newVal) { + for (var bi = 0; bi < blocks.length; bi++) { + var b = blocks[bi]; + if (!b.targets.has(pid)) continue; + if (b.mode === 'shapes_range') { + if (!b.targetRangeBases) b.targetRangeBases = {}; + b.targetRangeBases[pid] = newVal; + } + if (!b.targetBases) b.targetBases = {}; + b.targetBases[pid] = newVal; + } +} +// Stamp for detecting structural changes (plugin add/remove/reorder) +var _pluginStamp = ''; +var _expectedChildCount = 0; +function getPluginStamp() { return pluginBlocks.map(function (pb) { return pb.id + ':' + (pb.busId || 0); }).join(',') + '|' + (assignMode !== null ? assignMode : '') + '|' + routingMode + '|' + busMutes.join(',') + '|' + busSolos.join(',') + '|' + busCollapsed.join(',') + '|weq' + wrongEqPoints.map(function (p) { return (p.mute ? 'm' : '') + (p.solo ? 's' : '') + (p.preEq !== false ? 'e' : ''); }).join(''); } +// dB <-> linear helpers for bus volume +function linToDb(lin) { return lin <= 0.001 ? -60 : 20 * Math.log10(lin); } +function dbToLin(db) { return db <= -59.9 ? 0 : Math.pow(10, db / 20); } +function fmtDb(db) { return db <= -59.9 ? '-\u221E' : (db >= 0 ? '+' : '') + db.toFixed(1); } + +function renderAllPlugins() { + // Don't rebuild during preset loading — would destroy placeholder cards + if (typeof gpLoadInProgress !== 'undefined' && gpLoadInProgress) return; + + var c = document.getElementById('pluginScroll'); + var isA = assignMode !== null, aBlk = isA ? findBlock(assignMode) : null, aCol = isA && aBlk ? bColor(aBlk.colorIdx) : ''; + var newStamp = getPluginStamp(); + + // Detach any loading placeholder cards so they survive the rebuild + var placeholders = Array.from(c.querySelectorAll('.pcard-loading')); + placeholders.forEach(function (ph) { ph.remove(); }); + + // Exclude placeholders from child count comparison + var realChildCount = c.childElementCount; + + // STRUCTURAL CHANGE: different plugins or order — full rebuild + if (newStamp !== _pluginStamp || realChildCount !== _expectedChildCount) { + _pluginStamp = newStamp; + var savedPlugScroll = c.scrollTop; + c.innerHTML = ''; + + if (routingMode === 1) { + // PARALLEL MODE: group plugins by bus, add bus header before each group + var busGroups = {}; + for (var pi = 0; pi < pluginBlocks.length; pi++) { + var bid = pluginBlocks[pi].busId || 0; + if (!busGroups[bid]) busGroups[bid] = []; + busGroups[bid].push(pi); + } + var sortedBuses = Object.keys(busGroups).map(Number).sort(); + for (var bi = 0; bi < sortedBuses.length; bi++) { + var bus = sortedBuses[bi]; + var col = BUS_COLORS[bus % BUS_COLORS.length]; + var linVol = busVolumes[bus] != null ? busVolumes[bus] : 1; + var dbVal = linToDb(linVol); + var muted = busMutes[bus] || false; + var soloed = busSolos[bus] || false; + var collapsed = busCollapsed[bus] || false; + var plugCount = busGroups[bus].length; + // Bus group container + var grp = document.createElement('div'); + grp.className = 'bus-group' + (muted ? ' bus-muted' : ''); + grp.style.setProperty('--bus-tint', col); + // Bus header strip + var hdr = document.createElement('div'); + hdr.className = 'bus-header' + (soloed ? ' bus-soloed' : ''); + hdr.dataset.bus = bus; + hdr.innerHTML = '' + + '' + + 'Bus ' + (bus + 1) + (collapsed ? ' (' + plugCount + ')' : '') + '' + + '' + + '' + fmtDb(dbVal) + '' + + '' + + ''; + grp.appendChild(hdr); + // Plugins in this bus (hidden when collapsed) + for (var gi = 0; gi < busGroups[bus].length; gi++) { + var idx = busGroups[bus][gi]; + var card = buildPluginCard(pluginBlocks[idx], idx, isA, aBlk, aCol); + if (collapsed) card.style.display = 'none'; + grp.appendChild(card); + } + c.appendChild(grp); + } + } else if (routingMode === 2 && typeof wrongEqPoints !== 'undefined' && wrongEqPoints.length > 0) { + // WRONGEQ MODE: group plugins by EQ band bus (UID-based) + // Bus IDs: 0 = unassigned, pt.uid = assigned to that EQ point + var weqBusGroups = { 0: [] }; // always have unassigned group + for (var pi = 0; pi < pluginBlocks.length; pi++) { + var bid = pluginBlocks[pi].busId || 0; + if (!weqBusGroups[bid]) weqBusGroups[bid] = []; + weqBusGroups[bid].push(pi); + } + // Ensure all EQ points have a group even if empty (by UID, not index) + for (var ei = 0; ei < wrongEqPoints.length; ei++) { + _weqEnsureUid(wrongEqPoints[ei]); + var ebid = wrongEqPoints[ei].uid; + if (!weqBusGroups[ebid]) weqBusGroups[ebid] = []; + } + var weqSorted = Object.keys(weqBusGroups).map(Number).sort(); + var wBandColors = typeof WEQ_BAND_COLORS !== 'undefined' ? WEQ_BAND_COLORS : ['#ff6464', '#64b4ff', '#64dc8c', '#ffc850', '#c882ff', '#ff8cb4', '#50dcdc']; + for (var wbi = 0; wbi < weqSorted.length; wbi++) { + var wbus = weqSorted[wbi]; + // Find the EQ point by UID (not by index) + var wPt = null; + var wPtIdx = -1; + for (var fpi = 0; fpi < wrongEqPoints.length; fpi++) { + if (wrongEqPoints[fpi].uid === wbus) { + wPt = wrongEqPoints[fpi]; + wPtIdx = fpi; + break; + } + } + var wCol = wPt ? _weqPointColor(wPt) : '#555'; + var wPlugs = weqBusGroups[wbus]; + var wCollapsed = busCollapsed[wbus] || false; + var wMuted = wPt ? (wPt.mute || false) : false; + var wSoloed = wPt ? (wPt.solo || false) : false; + var wPreEq = wPt ? (wPt.preEq !== false) : true; + // Band label + var wLabel; + if (!wPt) { + wLabel = 'Unassigned'; + } else { + // Label with Q-derived range: "B1 Bell 1kHz [500Hz–2kHz]" + var wFreq = typeof weqXToFreq === 'function' ? weqXToFreq(wPt.x) : 0; + var wFreqStr = wFreq >= 1000 ? (wFreq / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : Math.round(wFreq) + ''; + var wType = wPt.type || 'Bell'; + var wRangeStr = typeof weqFmtRange === 'function' ? ' [' + weqFmtRange(wPt) + ']' : ''; + wLabel = 'B' + (wPtIdx + 1) + ' ' + wType + ' ' + wFreqStr + wRangeStr; + } + // Bus group container + var wGrp = document.createElement('div'); + wGrp.className = 'bus-group weq-bus-group' + (wMuted ? ' bus-muted' : ''); + wGrp.style.setProperty('--bus-tint', wCol); + // Bus header + var wHdr = document.createElement('div'); + wHdr.className = 'bus-header weq-bus-header' + (wSoloed ? ' bus-soloed' : ''); + wHdr.dataset.bus = wbus; + wHdr.dataset.weqbus = wbus; + var wHdrHtml = '' + + '' + + '' + wLabel + (wCollapsed ? ' (' + wPlugs.length + ')' : '') + ''; + // Per-bus Pre/Post EQ toggle (only for real bands) + if (wPt) { + wHdrHtml += ''; + wHdrHtml += ''; + wHdrHtml += ''; + } + wHdr.innerHTML = wHdrHtml; + wGrp.appendChild(wHdr); + // Plugin cards + for (var wgi = 0; wgi < wPlugs.length; wgi++) { + var wIdx = wPlugs[wgi]; + var wCard = buildPluginCard(pluginBlocks[wIdx], wIdx, isA, aBlk, aCol); + if (wCollapsed) wCard.style.display = 'none'; + wGrp.appendChild(wCard); + } + c.appendChild(wGrp); + } + } else { + // SEQUENTIAL MODE: flat list + for (var pi = 0; pi < pluginBlocks.length; pi++) { + c.appendChild(buildPluginCard(pluginBlocks[pi], pi, isA, aBlk, aCol)); + } + } + + _expectedChildCount = c.childElementCount; + wirePluginCards(); wireBusHeaders(); updateAssignBanner(); + // Restore bypass visual + for (var bpi = 0; bpi < pluginBlocks.length; bpi++) { + if (pluginBlocks[bpi].bypassed) { + var bc2 = c.querySelector('[data-plugid="' + pluginBlocks[bpi].id + '"]'); + if (bc2) bc2.closest('.pcard').classList.add('bypassed'); + } + } + // Re-attach loading placeholders at the end + placeholders.forEach(function (ph) { c.appendChild(ph); }); + // Restore scroll position after full rebuild + if (savedPlugScroll > 0) c.scrollTop = savedPlugScroll; + return; + } + + // PATCH: same structure — update each card in-place + for (var pi = 0; pi < pluginBlocks.length; pi++) { + var pb = pluginBlocks[pi]; + var card = c.querySelector('.pcard[data-plugid="' + pb.id + '"]'); + if (!card) continue; + + // Update data-plugidx (may shift after drag reorder) + card.setAttribute('data-plugidx', pi); + + // Patch expand/collapse state + var chev = card.querySelector('.lchev'); + var body = card.querySelector('.pcard-body'); + if (chev) { if (pb.expanded) chev.classList.add('open'); else chev.classList.remove('open'); } + if (body) { if (pb.expanded) body.classList.remove('hide'); else body.classList.add('hide'); } + + // Reset any lingering browser drag styling + card.style.opacity = ''; + card.classList.remove('dragging', 'drag-over-top', 'drag-over-bottom'); + + // Patch bypass visual + if (pb.bypassed) card.classList.add('bypassed'); else card.classList.remove('bypassed'); + + // Patch param rows (search filter or assign state change) + var paramC = card.querySelector('[data-plugparams="' + pb.id + '"]'); + if (paramC) fillPluginParams(paramC, pb, isA, aBlk, aCol); + + // Restore search input value (fillPluginParams doesn't touch it) + var searchInp = card.querySelector('[data-plugsearch="' + pb.id + '"]'); + if (searchInp && searchInp.value !== (pb.searchFilter || '')) searchInp.value = pb.searchFilter || ''; + } + // Re-wire only needs to update assign highlights and event listeners + wirePluginCards(); wireBusHeaders(); updateAssignBanner(); + // Re-attach loading placeholders at the end + placeholders.forEach(function (ph) { c.appendChild(ph); }); +} +function wirePluginCards() { + document.querySelectorAll('.pcard-head').forEach(function (h) { + h.onclick = function (e) { + if (e.target.closest('[data-plugrm]') || e.target.closest('[data-pluged]') || e.target.closest('[data-plugbulk]') || e.target.closest('[data-plugpreset]') || e.target.closest('.pcard-bus-sel')) return; + var id = parseInt(h.dataset.plugid); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === id) { pb = pluginBlocks[i]; pb.expanded = !pb.expanded; break; } + } + // When expanding, force-dirty all params so visible rows repaint with current values + if (pb && pb.expanded) dirtyPluginParams(id); + renderAllPlugins(); + syncExpandedPlugins(); + }; + h.addEventListener('contextmenu', function (e) { + e.preventDefault(); e.stopPropagation(); + var id = parseInt(h.dataset.plugid); + plugCtxPluginId = id; + showPlugCtx(e.clientX, e.clientY, id); + }); + }); + // Bus dropdown — change bus assignment + document.querySelectorAll('.pcard-bus-sel').forEach(function (sel) { + sel.onchange = function (e) { + e.stopPropagation(); + var pid = parseInt(sel.dataset.plugbus); + var pb; for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + var next = parseInt(sel.value); + var prev = pb.busId || 0; + pb.busId = next; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setPluginBus'); + fn(pb.hostId !== undefined ? pb.hostId : pid, next); + } + // ── Sync EQ pluginIds with bus change ── + if (routingMode === 2 && typeof wrongEqPoints !== 'undefined') { + // Remove from old band's pluginIds + for (var ri = 0; ri < wrongEqPoints.length; ri++) { + var ids = wrongEqPoints[ri].pluginIds; + if (ids) { + var idx = ids.indexOf(pid); + if (idx >= 0) ids.splice(idx, 1); + } + } + // Add to new band's pluginIds (if assigning to a real band, not "No Band") + if (next > 0) { + for (var ri2 = 0; ri2 < wrongEqPoints.length; ri2++) { + if (wrongEqPoints[ri2].uid === next) { + if (!wrongEqPoints[ri2].pluginIds) wrongEqPoints[ri2].pluginIds = []; + if (wrongEqPoints[ri2].pluginIds.indexOf(pid) < 0) { + wrongEqPoints[ri2].pluginIds.push(pid); + } + break; + } + } + } + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + } + renderAllPlugins(); saveUiStateToHost(); + }; + // Prevent header collapse when clicking the select + sel.onclick = function (e) { e.stopPropagation(); }; + }); + document.querySelectorAll('[data-plugrm]').forEach(function (b) { b.onclick = function (e) { e.stopPropagation(); removePlugin(parseInt(b.dataset.plugrm)); }; }); + document.querySelectorAll('[data-pluged]').forEach(function (b) { + b.onclick = function (e) { + e.stopPropagation(); + var id = parseInt(b.dataset.pluged); + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('openPluginEditor'); + fn(id); + } + }; + }); + document.querySelectorAll('[data-plugpreset]').forEach(function (b) { + b.onclick = function (e) { + e.stopPropagation(); + openPresetBrowser(parseInt(b.dataset.plugpreset)); + }; + }); + document.querySelectorAll('[data-plugsearch]').forEach(function (inp) { + inp.onclick = function (e) { e.stopPropagation(); }; + inp.oninput = function () { + var id = parseInt(inp.dataset.plugsearch); + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === id) { pluginBlocks[i].searchFilter = inp.value; break; } } + renderAllPlugins(); + var ni = document.querySelector('[data-plugsearch="' + id + '"]'); + if (ni) { ni.focus(); ni.selectionStart = ni.selectionEnd = ni.value.length; } + }; + }); + // Scroll listener on param containers — repaint newly visible rows + document.querySelectorAll('.pcard-params').forEach(function (container) { + var scrollTimer = null; + container.addEventListener('scroll', function () { + if (scrollTimer) return; // debounce ~60ms + scrollTimer = setTimeout(function () { + scrollTimer = null; + var plugId = parseInt(container.getAttribute('data-plugparams')); + dirtyPluginParams(plugId); + requestAnimationFrame(refreshParamDisplay); + }, 60); + }, { passive: true }); + }); + // Drag and drop reordering + var dragSrcIdx = null; + document.querySelectorAll('.pcard').forEach(function (card) { + card.addEventListener('dragstart', function (e) { + // If the drag originates from a param row (.pr), skip plugin reorder drag + if (e.target.closest('.pr')) return; + dragSrcIdx = parseInt(card.dataset.plugidx); + card.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragSrcIdx); + }); + card.addEventListener('dragend', function () { + card.classList.remove('dragging'); + dragSrcIdx = null; + document.querySelectorAll('.pcard').forEach(function (c) { + c.classList.remove('dragging', 'drag-over-top', 'drag-over-bottom'); + }); + }); + card.addEventListener('dragover', function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + var rect = card.getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + document.querySelectorAll('.pcard').forEach(function (c) { + c.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + if (e.clientY < midY) card.classList.add('drag-over-top'); + else card.classList.add('drag-over-bottom'); + }); + card.addEventListener('dragleave', function () { + card.classList.remove('drag-over-top', 'drag-over-bottom'); + }); + card.addEventListener('drop', function (e) { + e.preventDefault(); + var fromIdx = parseInt(e.dataTransfer.getData('text/plain')); + var toIdx = parseInt(card.dataset.plugidx); + if (isNaN(fromIdx) || isNaN(toIdx) || fromIdx === toIdx) return; + var rect = card.getBoundingClientRect(); + var midY = rect.top + rect.height / 2; + if (e.clientY > midY && toIdx < fromIdx) toIdx++; + if (e.clientY < midY && toIdx > fromIdx) toIdx--; + var moved = pluginBlocks.splice(fromIdx, 1)[0]; + pluginBlocks.splice(toIdx, 0, moved); + // In parallel/WrongEQ mode: adopt target's bus assignment if different + if (routingMode === 1 || routingMode === 2) { + var targetPb = pluginBlocks[toIdx === 0 ? 1 : toIdx - 1] || pluginBlocks[toIdx + 1]; + if (targetPb && !targetPb.isVirtual && (moved.busId || 0) !== (targetPb.busId || 0)) { + moved.busId = targetPb.busId || 0; + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + busFn(moved.hostId !== undefined ? moved.hostId : moved.id, moved.busId); + } + } + } + // Sync new order to C++ so audio processing matches visual order + var reorderFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('reorderPlugins') : null; + if (reorderFn) { + var ids = pluginBlocks.map(function (pb) { return pb.id; }); + reorderFn(ids); + } + // Sync EQ pluginIds when bus changed via drag in WrongEQ mode + if (routingMode === 2 && typeof wrongEqPoints !== 'undefined') { + // Remove from all bands' pluginIds first + for (var ri = 0; ri < wrongEqPoints.length; ri++) { + var ids2 = wrongEqPoints[ri].pluginIds; + if (ids2) { + var rmIdx = ids2.indexOf(moved.id); + if (rmIdx >= 0) ids2.splice(rmIdx, 1); + } + } + // Add to the new band if assigned + if (moved.busId > 0) { + for (var ri2 = 0; ri2 < wrongEqPoints.length; ri2++) { + if (wrongEqPoints[ri2].uid === moved.busId) { + if (!wrongEqPoints[ri2].pluginIds) wrongEqPoints[ri2].pluginIds = []; + if (wrongEqPoints[ri2].pluginIds.indexOf(moved.id) < 0) { + wrongEqPoints[ri2].pluginIds.push(moved.id); + } + break; + } + } + } + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + } + renderAllPlugins(); + saveUiStateToHost(); + }); + }); +} +// Wire bus header controls (volume, mute, solo) +function wireBusHeaders() { + // Volume sliders + document.querySelectorAll('[data-busvol]').forEach(function (sl) { + sl.oninput = function () { + var bus = parseInt(sl.dataset.busvol); + var db = parseFloat(sl.value); + var lin = dbToLin(db); + busVolumes[bus] = lin; + var lbl = document.querySelector('[data-busvolval="' + bus + '"]'); + if (lbl) lbl.textContent = fmtDb(db); + sl.title = fmtDb(db) + ' dB'; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setBusVolume'); + fn(bus, lin); + } + }; + sl.onclick = function (e) { e.stopPropagation(); }; + sl.onchange = function () { saveUiStateToHost(); }; + // Double-click to reset to 0 dB + sl.ondblclick = function (e) { + e.stopPropagation(); + sl.value = '0'; + sl.oninput(); + saveUiStateToHost(); + }; + }); + // Mute buttons + document.querySelectorAll('[data-busmute]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bus = parseInt(btn.dataset.busmute); + busMutes[bus] = !busMutes[bus]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setBusMute'); + fn(bus, busMutes[bus]); + } + renderAllPlugins(); saveUiStateToHost(); + }; + }); + // Solo buttons + document.querySelectorAll('[data-bussolo]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bus = parseInt(btn.dataset.bussolo); + busSolos[bus] = !busSolos[bus]; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setBusSolo'); + fn(bus, busSolos[bus]); + } + renderAllPlugins(); saveUiStateToHost(); + }; + }); + // Bus header click — toggle collapse + document.querySelectorAll('.bus-header').forEach(function (hdr) { + hdr.onclick = function (e) { + if (e.target.closest('[data-busvol]') || e.target.closest('[data-busmute]') || e.target.closest('[data-bussolo]') || e.target.closest('[data-weqbusprq]') || e.target.closest('[data-weqbusmute]') || e.target.closest('[data-weqbussolo]')) return; + e.stopPropagation(); + var bus = parseInt(hdr.dataset.bus); + busCollapsed[bus] = !busCollapsed[bus]; + renderAllPlugins(); saveUiStateToHost(); + }; + }); + // Helper: find EQ point by UID + function _weqFindByUid(uid) { + if (typeof wrongEqPoints === 'undefined') return null; + for (var fi = 0; fi < wrongEqPoints.length; fi++) { + if (wrongEqPoints[fi].uid === uid) return wrongEqPoints[fi]; + } + return null; + } + // ── WrongEQ bus header: per-band preEq toggle ── + document.querySelectorAll('[data-weqbusprq]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var busUid = parseInt(btn.dataset.weqbusprq); + var pt = _weqFindByUid(busUid); + if (pt) { + pt.preEq = !(pt.preEq !== false); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + if (typeof markStateDirty === 'function') markStateDirty(); + renderAllPlugins(); saveUiStateToHost(); + } + }; + }); + // ── WrongEQ bus header: per-band mute ── + document.querySelectorAll('[data-weqbusmute]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var busUid = parseInt(btn.dataset.weqbusmute); + var pt = _weqFindByUid(busUid); + if (pt) { + pt.mute = !pt.mute; + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof markStateDirty === 'function') markStateDirty(); + renderAllPlugins(); saveUiStateToHost(); + } + }; + }); + // ── WrongEQ bus header: per-band solo (exclusive) ── + document.querySelectorAll('[data-weqbussolo]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var busUid = parseInt(btn.dataset.weqbussolo); + var pt = _weqFindByUid(busUid); + if (pt) { + var wasSoloed = pt.solo; + for (var si = 0; si < wrongEqPoints.length; si++) wrongEqPoints[si].solo = false; + pt.solo = !wasSoloed; + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof weqRenderPanel === 'function') weqRenderPanel(); + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof markStateDirty === 'function') markStateDirty(); + renderAllPlugins(); saveUiStateToHost(); + } + }; + }); +} +// Param click delegation +var pse = document.getElementById('pluginScroll'); +var lastClickedPid = null; // track for Shift+Click range selection +var lastClickedAction = 'add'; // 'add' or 'remove' — Shift+Click mirrors this +pse.addEventListener('click', function (e) { + // Bulk All/None buttons + var bulkBtn = e.target.closest('[data-plugbulk]'); + if (bulkBtn && assignMode) { + e.stopPropagation(); + var plugId = parseInt(bulkBtn.dataset.plugbulk); + var mode = bulkBtn.dataset.bulkmode; // 'all' or 'none' + var b = findBlock(assignMode); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugId) { pb = pluginBlocks[i]; break; } } + if (b && pb) { + pb.params.forEach(function (p) { + if (p.lk) return; // skip locked + if (mode === 'all') { + assignTarget(b, p.id); + // For shapes_range, set default range = current param value + if (b.mode === 'shapes_range') { + if (!b.targetRanges) b.targetRanges = {}; + if (!b.targetRangeBases) b.targetRangeBases = {}; + if (b.targetRanges[p.id] === undefined) { b.targetRanges[p.id] = 0; b.targetRangeBases[p.id] = p.v; } + } + } else { + b.targets.delete(p.id); + cleanBlockAfterUnassign(b, p.id); + if (b.mode === 'shapes_range') { if (b.targetRanges) delete b.targetRanges[p.id]; if (b.targetRangeBases) delete b.targetRangeBases[p.id]; } + } + }); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } + return; + } + var row = e.target.closest('.pr'); if (!row) return; + var pid = row.getAttribute('data-pid'); if (!pid) return; + var pp = PMap[pid]; if (!pp) return; + + if (assignMode) { + // === ASSIGN MODE: toggle targets on active block === + if (pp.lk) return; // locked params can't be assigned + var b = findBlock(assignMode); + if (!b) return; + + // Shapes Range: assign with range=0 (no arc until dragged) + if (b.mode === 'shapes_range') { + if (b.targets.has(pid)) { + b.targets.delete(pid); + cleanBlockAfterUnassign(b, pid); + lastClickedAction = 'remove'; + } else { + assignTarget(b, pid); + if (!b.targetRanges) b.targetRanges = {}; + if (!b.targetRangeBases) b.targetRangeBases = {}; + b.targetRanges[pid] = 0; + b.targetRangeBases[pid] = pp.v; // anchor = current position + lastClickedAction = 'add'; + } + } else if (e.shiftKey && lastClickedPid) { + var allRows = Array.prototype.slice.call(pse.querySelectorAll('.pr[data-pid]')); + var startIdx = -1, endIdx = -1; + for (var ri = 0; ri < allRows.length; ri++) { + var rpid = allRows[ri].getAttribute('data-pid'); + if (rpid === lastClickedPid) startIdx = ri; + if (rpid === pid) endIdx = ri; + } + if (startIdx !== -1 && endIdx !== -1) { + var lo = Math.min(startIdx, endIdx), hi = Math.max(startIdx, endIdx); + for (var ri = lo; ri <= hi; ri++) { + var rpid = allRows[ri].getAttribute('data-pid'); + var rp = PMap[rpid]; + if (rp && !rp.lk) { + if (lastClickedAction === 'add') { + assignTarget(b, rpid); + if (b.mode === 'shapes_range') { + if (!b.targetRanges) b.targetRanges = {}; + if (!b.targetRangeBases) b.targetRangeBases = {}; + if (b.targetRanges[rpid] === undefined) { b.targetRanges[rpid] = 0; b.targetRangeBases[rpid] = rp.v; } + } + } else { + b.targets.delete(rpid); + cleanBlockAfterUnassign(b, rpid); + } + } + } + } + } else if (e.ctrlKey || e.metaKey) { + if (b.targets.has(pid)) { b.targets.delete(pid); cleanBlockAfterUnassign(b, pid); lastClickedAction = 'remove'; } + else { assignTarget(b, pid); if (b.mode === 'shapes_range') { if (!b.targetRanges) b.targetRanges = {}; if (!b.targetRangeBases) b.targetRangeBases = {}; if (b.targetRanges[pid] === undefined) { b.targetRanges[pid] = 0; b.targetRangeBases[pid] = pp.v; } } if (b.mode === 'shapes') { if (!b.targetBases) b.targetBases = {}; b.targetBases[pid] = pp.v; } lastClickedAction = 'add'; } + } else { + if (b.targets.has(pid)) { b.targets.delete(pid); cleanBlockAfterUnassign(b, pid); lastClickedAction = 'remove'; } + else { assignTarget(b, pid); if (b.mode === 'shapes_range') { if (!b.targetRanges) b.targetRanges = {}; if (!b.targetRangeBases) b.targetRangeBases = {}; if (b.targetRanges[pid] === undefined) { b.targetRanges[pid] = 0; b.targetRangeBases[pid] = pp.v; } } if (b.mode === 'shapes') { if (!b.targetBases) b.targetBases = {}; b.targetBases[pid] = pp.v; } lastClickedAction = 'add'; } + } + lastClickedPid = pid; + renderAllPlugins(); + clearTimeout(renderAllPlugins._bt); + renderAllPlugins._bt = setTimeout(function () { renderBlocks(); syncBlocksToHost(); }, 80); + } else { + // === SELECTION MODE: select params for drag/right-click assign === + // Locked params CAN be selected here (for batch unlock via context menu) + if (e.shiftKey && lastClickedPid) { + var allRows = Array.prototype.slice.call(pse.querySelectorAll('.pr[data-pid]')); + var startIdx = -1, endIdx = -1; + for (var ri = 0; ri < allRows.length; ri++) { + var rpid = allRows[ri].getAttribute('data-pid'); + if (rpid === lastClickedPid) startIdx = ri; + if (rpid === pid) endIdx = ri; + } + if (startIdx !== -1 && endIdx !== -1) { + var lo = Math.min(startIdx, endIdx), hi = Math.max(startIdx, endIdx); + for (var ri = lo; ri <= hi; ri++) { + var rpid = allRows[ri].getAttribute('data-pid'); + var rp = PMap[rpid]; + if (rp) { + if (lastClickedAction === 'add') selectedParams.add(rpid); + else selectedParams.delete(rpid); + } + } + } + } else if (e.ctrlKey || e.metaKey) { + if (selectedParams.has(pid)) { selectedParams.delete(pid); lastClickedAction = 'remove'; } + else { selectedParams.add(pid); lastClickedAction = 'add'; } + } else { + // Plain click: clear selection and select just this one + selectedParams.clear(); + selectedParams.add(pid); + lastClickedAction = 'add'; + } + lastClickedPid = pid; + renderAllPlugins(); + } +}); +// Drag from selected params +pse.addEventListener('dragstart', function (e) { + var row = e.target.closest('.pr'); if (!row) return; + var pid = row.getAttribute('data-pid'); if (!pid) return; + // If dragging a non-selected param, select just that one + if (!selectedParams.has(pid)) { + selectedParams.clear(); + selectedParams.add(pid); + // Don't call renderAllPlugins() here — it destroys the drag source mid-drag. + // Instead, just toggle class directly on the row: + pse.querySelectorAll('.pr.selected').forEach(function (r) { r.classList.remove('selected'); }); + row.classList.add('selected'); + } + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', 'params:' + Array.from(selectedParams).join(',')); +}); +pse.addEventListener('dragend', function () { + // Re-render after drag completes to sync visual state + renderAllPlugins(); +}); +pse.addEventListener('contextmenu', function (e) { + var row = e.target.closest('.pr'); if (!row) return; + var pid = row.getAttribute('data-pid'); if (!pid) return; + var pp = PMap[pid]; if (!pp) return; e.preventDefault(); + // If right-clicking on a non-selected param, select just it + if (!selectedParams.has(pid)) { + selectedParams.clear(); + selectedParams.add(pid); + renderAllPlugins(); + } + ctxP = pp; showCtx(e.clientX, e.clientY, pp); +}); + +// ── Knob drag interaction ── +(function () { + var _setParamFn = null; + function getSetParam() { + if (!_setParamFn && window.__JUCE__ && window.__JUCE__.backend) + _setParamFn = window.__juceGetNativeFunction('setParam'); + return _setParamFn; + } + var _touchParamFn = null, _untouchParamFn = null; + function getTouchParam() { + if (!_touchParamFn && window.__juceGetNativeFunction) + _touchParamFn = window.__juceGetNativeFunction('touchParam'); + return _touchParamFn; + } + function getUntouchParam() { + if (!_untouchParamFn && window.__juceGetNativeFunction) + _untouchParamFn = window.__juceGetNativeFunction('untouchParam'); + return _untouchParamFn; + } + document.addEventListener('mousedown', function (e) { + var knob = e.target.closest('.pr-knob'); + if (!knob) return; + e.preventDefault(); + e.stopPropagation(); + var pid = knob.getAttribute('data-pid'); + var hid = parseInt(knob.getAttribute('data-hid')); + var ri = parseInt(knob.getAttribute('data-ri')); + var p = PMap[pid]; + if (!p || isNaN(hid) || isNaN(ri)) return; + + // ── SHAPES RANGE: drag to set range instead of value ── + if (assignMode) { + var srBlk = findBlock(assignMode); + if (srBlk && srBlk.mode === 'shapes_range') { + // Auto-assign if not yet assigned + if (!srBlk.targets.has(pid)) { + assignTarget(srBlk, pid); + if (!srBlk.targetRanges) srBlk.targetRanges = {}; + if (!srBlk.targetRangeBases) srBlk.targetRangeBases = {}; + srBlk.targetRanges[pid] = 0; + srBlk.targetRangeBases[pid] = p.v; // capture base position + } + // Ensure base exists + if (!srBlk.targetRangeBases) srBlk.targetRangeBases = {}; + if (srBlk.targetRangeBases[pid] === undefined) srBlk.targetRangeBases[pid] = p.v; + var baseVal = srBlk.targetRangeBases[pid]; + var startY = e.clientY; + var startRange = srBlk.targetRanges[pid] !== undefined ? srBlk.targetRanges[pid] : 0; + var aCol = bColor(srBlk.colorIdx); + _touchedByUI.add(pid); // block realtime from fighting our range-drag rebuild + function onMoveRange(me) { + var dy = startY - me.clientY; // positive = drag up = positive range + var newRange = startRange + dy / 200; + // Clamp so base + range stays within 0..1 + if (newRange > 0) { + newRange = Math.min(newRange, 1 - baseVal); + } else { + newRange = Math.max(newRange, -baseVal); + } + srBlk.targetRanges[pid] = newRange; + var ri2 = { range: newRange, base: baseVal, color: aCol, polarity: srBlk.shapePolarity || 'bipolar' }; + knob.innerHTML = buildParamKnob(p.v, 30, ri2); + } + function onUpRange() { + document.removeEventListener('mousemove', onMoveRange); + document.removeEventListener('mouseup', onUpRange); + _touchedByUI.delete(pid); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } + document.addEventListener('mousemove', onMoveRange); + document.addEventListener('mouseup', onUpRange); + return; + } + } + + // ── Normal knob drag (value adjustment) ── + var isVirtual = (hid === -100); // WEQ_VIRTUAL_ID + var tfn = isVirtual ? null : getTouchParam(); + if (tfn) tfn(hid, ri); + // Check if param has ACTIVE modulation (non-zero depth/range) + // Use getModArcInfo which already checks for non-zero values + var _arcInfo = getModArcInfo(pid); + var _hasModBlocks = _arcInfo !== null; + var startVal = (_hasModBlocks && _arcInfo.base !== undefined) ? _arcInfo.base : p.v; + // Non-modulated params: block realtime (drag handler renders them) + // Modulated params: let realtime.js render (it uses computeModCurrent — one path) + if (!_hasModBlocks) _touchedByUI.add(pid); + var startY = e.clientY; + var sensitivity = 200; + var _lastDragVal = startVal; + function onMove(me) { + var dy = startY - me.clientY; + var newVal = Math.max(0, Math.min(1, startVal + dy / sensitivity)); + _lastDragVal = newVal; + + if (isVirtual) { + // Virtual param: update EQ state directly + if (typeof weqApplyVirtualParam === 'function') weqApplyVirtualParam(pid, newVal); + p.v = newVal; + knob.innerHTML = buildParamKnob(newVal, 30, null); + var row = knob.closest('.pr'); + if (row) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = p.disp || ((newVal * 100).toFixed(0) + '%'); + var bf = row.querySelector('.pr-bar-f'); + if (bf) bf.style.width = (newVal * 100) + '%'; + } + // Redraw canvas + sync to host + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + } else { + var fn = getSetParam(); + if (fn) fn(hid, ri, newVal); + if (_hasModBlocks) { + // Update stored bases — realtime.js reads these for knob position + fill arc + updateModBases(pid, newVal); + // Only update value text — knob SVG is rendered by realtime.js + var row = knob.closest('.pr'); + if (row) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = (newVal * 100).toFixed(0) + '%'; + } + } else { + p.v = newVal; + knob.innerHTML = buildParamKnob(newVal, 30, null); + var row = knob.closest('.pr'); + if (row) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = p.disp || ((newVal * 100).toFixed(0) + '%'); + var bf = row.querySelector('.pr-bar-f'); + if (bf) bf.style.width = (newVal * 100) + '%'; + } + } + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (!_hasModBlocks) _touchedByUI.delete(pid); + if (_lastDragVal !== startVal) { + pushParamUndo(pid, startVal); + } + if (!isVirtual) { + var ufn = getUntouchParam(); + if (ufn) ufn(hid, ri); + } + if (_lastDragVal !== startVal && _hasModBlocks) { + syncBlocksToHost(); + renderAllPlugins(); + } + if (isVirtual && _lastDragVal !== startVal) { + if (typeof markStateDirty === 'function') markStateDirty(); + } + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, true); // capture phase to beat row click + + // ── Scroll wheel on param knobs ── + document.addEventListener('wheel', function (e) { + var knob = e.target.closest('.pr-knob'); + if (!knob) return; + e.preventDefault(); + var pid = knob.getAttribute('data-pid'); + var hid = parseInt(knob.getAttribute('data-hid')); + var ri = parseInt(knob.getAttribute('data-ri')); + var p = PMap[pid]; + if (!p || p.lk || isNaN(hid) || isNaN(ri)) return; + // Skip when in shapes_range assign mode (knob drag sets range, not value) + if (assignMode) { + var srBlk = findBlock(assignMode); + if (srBlk && srBlk.mode === 'shapes_range') return; + } + var step = e.shiftKey ? 0.002 : 0.01; // Shift = fine control + var delta = e.deltaY < 0 ? step : -step; + // For modulated params, scroll adjusts the BASE, not the modulated value + var _sri = getModArcInfo(pid); + var baseVal = (_sri && _sri.base !== undefined) ? _sri.base : p.v; + var oldVal = baseVal; + var newVal = Math.max(0, Math.min(1, baseVal + delta)); + if (newVal === oldVal) return; + p.v = newVal; + var isVW = (hid === -100); + if (isVW) { + if (typeof weqApplyVirtualParam === 'function') weqApplyVirtualParam(pid, newVal); + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + } else { + var fn = getSetParam(); + if (fn) fn(hid, ri, newVal); + } + // Update stored bases and rebuild knob + if (_sri) { + updateModBases(pid, newVal); + _sri = getModArcInfo(pid); + if (_sri) _sri.current = p.v; + } + var knobVal = (_sri && _sri.current !== undefined && _sri.base !== undefined) ? _sri.base : newVal; + knob.innerHTML = buildParamKnob(knobVal, 30, _sri); + var row = knob.closest('.pr'); + if (row) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = p.disp || ((newVal * 100).toFixed(0) + '%'); + var bf = row.querySelector('.pr-bar-f'); + if (bf) bf.style.width = (newVal * 100) + '%'; + } + // Debounced undo push — collect scroll ticks into one undo entry + if (!knob._wheelUndoTimer) { + knob._wheelOldVal = oldVal; + } else { + clearTimeout(knob._wheelUndoTimer); + } + knob._wheelUndoTimer = setTimeout(function () { + if (p.v !== knob._wheelOldVal) pushParamUndo(pid, knob._wheelOldVal); + knob._wheelUndoTimer = null; + }, 400); + }, { passive: false }); + + // ── Double-click to reset param knob to default (0.5) ── + document.addEventListener('dblclick', function (e) { + var knob = e.target.closest('.pr-knob'); + if (!knob) return; + e.preventDefault(); e.stopPropagation(); + var pid = knob.getAttribute('data-pid'); + var hid = parseInt(knob.getAttribute('data-hid')); + var ri = parseInt(knob.getAttribute('data-ri')); + var p = PMap[pid]; + if (!p || p.lk || isNaN(hid) || isNaN(ri)) return; + var oldVal = p.v; + var defaultVal = 0.5; // center position — universal default + if (oldVal === defaultVal) return; + p.v = defaultVal; + var isVW2 = (hid === -100); + if (isVW2) { + if (typeof weqApplyVirtualParam === 'function') weqApplyVirtualParam(pid, defaultVal); + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + } else { + var fn = getSetParam(); + if (fn) fn(hid, ri, defaultVal); + } + pushParamUndo(pid, oldVal); + // Update visuals + knob.innerHTML = buildParamKnob(defaultVal, 30, null); + var row = knob.closest('.pr'); + if (row) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = p.disp || ((defaultVal * 100).toFixed(0) + '%'); + var bf = row.querySelector('.pr-bar-f'); + if (bf) bf.style.width = '50%'; + } + }, true); +})(); + +// ── Plugin card footer toolbar handlers ── +document.addEventListener('click', function (e) { + // Randomize All + var randBtn = e.target.closest('[data-pfrand]'); + if (randBtn) { + e.stopPropagation(); + var pid = parseInt(randBtn.dataset.pfrand); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + var oldVals = []; + pb.params.forEach(function (p) { if (!p.lk && !p.alk) oldVals.push({ id: p.id, val: p.v }); }); + var setFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setParam') : null; + var isVirtRand = pb.isVirtual; + pb.params.forEach(function (p) { + if (p.lk || p.alk) return; + var nv = Math.random(); + p.v = nv; + if (isVirtRand) { + if (typeof weqApplyVirtualParam === 'function') weqApplyVirtualParam(p.id, nv); + } else { + if (setFn && p.hostId !== undefined) setFn(p.hostId, p.realIndex, nv); + } + // Update base anchor in all modulation blocks targeting this param + updateModBases(p.id, nv); + }); + if (isVirtRand) { + if (typeof weqDrawCanvas === 'function') weqDrawCanvas(); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + pushMultiParamUndo(oldVals); + renderAllPlugins(); + syncBlocksToHost(); + return; + } + // Bypass toggle + var bypBtn = e.target.closest('[data-pfbypass]'); + if (bypBtn) { + e.stopPropagation(); + var pid = parseInt(bypBtn.dataset.pfbypass); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } } + if (!pb) return; + pb.bypassed = !pb.bypassed; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setPluginBypass'); + fn(pb.hostId, pb.bypassed); + } + renderAllPlugins(); saveUiStateToHost(); + return; + } + // Snapshot dropdown toggle — build menu dynamically + var snapBtn = e.target.closest('[data-pfsnap]'); + if (snapBtn) { + e.stopPropagation(); + document.querySelectorAll('.pf-snap-menu.vis').forEach(function (m) { m.classList.remove('vis'); }); + var plugId = snapBtn.dataset.pfsnap; + var menu = snapBtn.parentElement.querySelector('.pf-snap-menu'); + if (!menu) return; + // Build menu content from morph pads AND morph lanes + var morphBlocks = (typeof getMorphBlocks === 'function') ? getMorphBlocks() : []; + var morphLanes = (typeof getMorphLanes === 'function') ? getMorphLanes() : []; + var mh = ''; + if (morphBlocks.length === 0 && morphLanes.length === 0) { + mh = '
No morph pads or lanes
'; + } else { + if (morphBlocks.length > 0) { + mh += '
Morph Pads
'; + for (var mi = 0; mi < morphBlocks.length; mi++) { + var mb = morphBlocks[mi]; + var full = mb.snapCount >= 12; + mh += '
Pad ' + (mb.idx + 1) + ' (' + mb.snapCount + '/12)' + (full ? ' Full' : '') + '
'; + } + } + if (morphLanes.length > 0) { + mh += '
Morph Lanes
'; + for (var mi = 0; mi < morphLanes.length; mi++) { + var ml = morphLanes[mi]; + mh += '
Lane ' + (ml.laneIdx + 1) + ' (' + ml.snapCount + ' snaps)
'; + } + } + } + menu.innerHTML = mh; + // Position and show + var rect = snapBtn.getBoundingClientRect(); + var posLeft = rect.left; + var vw = window.innerWidth; + var menuW = menu.offsetWidth || 130; + if (posLeft + menuW > vw - 4) posLeft = vw - menuW - 4; + if (posLeft < 4) posLeft = 4; + menu.style.left = posLeft + 'px'; + menu.classList.add('vis'); + var menuH = menu.offsetHeight; + if (rect.top - menuH - 4 > 0) { + menu.style.top = (rect.top - menuH - 4) + 'px'; + } else { + menu.style.top = (rect.bottom + 4) + 'px'; + } + return; + } + // Morph lane snapshot select + var snapLaneItem = e.target.closest('[data-pfsnaplaneblk]'); + if (snapLaneItem) { + e.stopPropagation(); + var bid = parseInt(snapLaneItem.dataset.pfsnaplaneblk); + var li = parseInt(snapLaneItem.dataset.pfsnaplaneli); + var pid = parseInt(snapLaneItem.dataset.pfsnappid); + if (typeof addSnapshotToMorphLane === 'function') addSnapshotToMorphLane(bid, li, pid); + var menu = snapLaneItem.closest('.pf-snap-menu'); + if (menu) menu.classList.remove('vis'); + renderAllPlugins(); + return; + } + // Snapshot select (morph pad) + var snapItem = e.target.closest('[data-pfsnapblock]'); + if (snapItem && !snapItem.classList.contains('disabled')) { + e.stopPropagation(); + var bid = parseInt(snapItem.dataset.pfsnapblock); + var pid = parseInt(snapItem.dataset.pfsnappid); + if (typeof addSnapshotToMorphBlock === 'function') addSnapshotToMorphBlock(bid, pid); + var menu = snapItem.closest('.pf-snap-menu'); + if (menu) menu.classList.remove('vis'); + renderAllPlugins(); + return; + } + // Assign dropdown + var assignBtn = e.target.closest('[data-pfassign]'); + if (assignBtn) { + e.stopPropagation(); + document.querySelectorAll('.pf-snap-menu.vis').forEach(function (m) { m.classList.remove('vis'); }); + var plugId = assignBtn.dataset.pfassign; + var menu = assignBtn.parentElement.querySelector('[data-pfassignmenu]'); + if (!menu || blocks.length === 0) return; + var mh = ''; + for (var bi = 0; bi < blocks.length; bi++) { + var bl = blocks[bi]; + mh += '
Block ' + (bi + 1) + ' (' + bl.mode + ')
'; + } + menu.innerHTML = mh; + var rect = assignBtn.getBoundingClientRect(); + var posLeft = rect.left, vw = window.innerWidth, menuW2 = menu.offsetWidth || 130; + if (posLeft + menuW2 > vw - 4) posLeft = vw - menuW2 - 4; + if (posLeft < 4) posLeft = 4; + menu.style.left = posLeft + 'px'; + menu.classList.add('vis'); + var menuH = menu.offsetHeight; + menu.style.top = (rect.top - menuH - 4 > 0) ? (rect.top - menuH - 4) + 'px' : (rect.bottom + 4) + 'px'; + return; + } + // Assign select + var assignItem = e.target.closest('[data-pfassignblk]'); + if (assignItem) { + e.stopPropagation(); + var bid = parseInt(assignItem.dataset.pfassignblk); + var plugId = parseInt(assignItem.dataset.pfassignplug); + var bl = findBlock(bid); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugId) { pb = pluginBlocks[i]; break; } } + if (bl && pb) { + var pids = selectedParams.size > 0 ? Array.from(selectedParams) : pb.params.filter(function (p) { return !p.lk; }).map(function (p) { return p.id; }); + pids.forEach(function (pid) { var pp = PMap[pid]; if (pp && !pp.lk) assignTarget(bl, pid); }); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } + var menu = assignItem.closest('.pf-snap-menu'); + if (menu) menu.classList.remove('vis'); + return; + } + // Unassign dropdown + var unassignBtn = e.target.closest('[data-pfunassign]'); + if (unassignBtn) { + e.stopPropagation(); + document.querySelectorAll('.pf-snap-menu.vis').forEach(function (m) { m.classList.remove('vis'); }); + var plugId = unassignBtn.dataset.pfunassign; + var menu = unassignBtn.parentElement.querySelector('[data-pfunassignmenu]'); + if (!menu || blocks.length === 0) return; + var mh = ''; + for (var bi = 0; bi < blocks.length; bi++) { + var bl = blocks[bi]; + mh += '
Block ' + (bi + 1) + ' (' + bl.mode + ')
'; + } + menu.innerHTML = mh; + var rect = unassignBtn.getBoundingClientRect(); + var posLeft = rect.left, vw = window.innerWidth, menuW2 = menu.offsetWidth || 130; + if (posLeft + menuW2 > vw - 4) posLeft = vw - menuW2 - 4; + if (posLeft < 4) posLeft = 4; + menu.style.left = posLeft + 'px'; + menu.classList.add('vis'); + var menuH = menu.offsetHeight; + menu.style.top = (rect.top - menuH - 4 > 0) ? (rect.top - menuH - 4) + 'px' : (rect.bottom + 4) + 'px'; + return; + } + // Unassign select + var unassignItem = e.target.closest('[data-pfunassignblk]'); + if (unassignItem) { + e.stopPropagation(); + var bid = parseInt(unassignItem.dataset.pfunassignblk); + var plugId = parseInt(unassignItem.dataset.pfunassignplug); + var bl = findBlock(bid); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === plugId) { pb = pluginBlocks[i]; break; } } + if (bl && pb) { + var pids = selectedParams.size > 0 ? Array.from(selectedParams) : pb.params.map(function (p) { return p.id; }); + pids.forEach(function (pid) { bl.targets.delete(pid); cleanBlockAfterUnassign(bl, pid); }); + selectedParams.clear(); + renderAllPlugins(); renderBlocks(); syncBlocksToHost(); + } + var menu = unassignItem.closest('.pf-snap-menu'); + if (menu) menu.classList.remove('vis'); + return; + } + // Close any open dropdown menus + document.querySelectorAll('.pf-snap-menu.vis').forEach(function (m) { m.classList.remove('vis'); }); +}); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js b/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js new file mode 100644 index 0000000..7c29cf8 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js @@ -0,0 +1,1739 @@ +// ============================================================ +// PRESET SYSTEM +// Plugin presets, snapshots, and global presets +// ============================================================ +// ── Preset Browser ── +var presetPluginId = null; +var presetSaveType = 'preset'; // 'preset' or 'snapshot' +var presetFilterType = 'all'; // 'all', 'preset', 'snapshot' +function getPresetPluginName(plugId) { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === plugId) return pluginBlocks[i].name; + } + return 'Unknown'; +} +function getPresetPluginManufacturer(plugId) { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === plugId) return pluginBlocks[i].manufacturer || ''; + } + return ''; +} +function openPresetBrowser(plugId, saveType) { + presetPluginId = plugId; + presetSaveType = saveType || 'preset'; + presetFilterType = 'all'; + var pName = getPresetPluginName(plugId); + document.getElementById('presetModalTitle').textContent = pName + ' \u2014 Library'; + document.getElementById('presetNameInput').value = ''; + // Reset search + var searchEl = document.getElementById('presetSearch'); + if (searchEl) { searchEl.value = ''; } + if (typeof presetSearchText !== 'undefined') presetSearchText = ''; + // Sync type toggle + document.querySelectorAll('.preset-type-btn').forEach(function (b) { + b.classList.toggle('on', b.dataset.savetype === presetSaveType); + }); + document.getElementById('presetModal').classList.add('vis'); + // Update filter tab visuals + document.querySelectorAll('#presetModal .preset-filter').forEach(function (btn) { + btn.classList.toggle('on', btn.dataset.filter === 'all'); + }); + refreshPresetList(); +} +function closePresetBrowser() { + document.getElementById('presetModal').classList.remove('vis'); + presetPluginId = null; +} +function refreshPresetList() { + var body = document.getElementById('presetBody'); + var info = document.getElementById('presetInfo'); + body.innerHTML = '
Loading...
'; + body._allItems = null; // Clear stale cache immediately + if (!(window.__JUCE__ && window.__JUCE__.backend)) { + body.innerHTML = '
No backend connected
'; + info.textContent = '0 items'; + return; + } + var requestPluginId = presetPluginId; // capture for stale check + var pName = getPresetPluginName(presetPluginId); + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === presetPluginId) { pb = pluginBlocks[i]; break; } + } + console.log('[PresetBrowser] refreshPresetList pluginId=' + presetPluginId + ' name=' + pName + ' hostId=' + (pb ? pb.hostId : 'null')); + var items = []; + var pending = 2; // user presets + factory presets + var checkDone = function () { + if (pending === 0) { + // Stale guard: if user switched plugin while we were loading, discard + if (presetPluginId !== requestPluginId) { + console.log('[PresetBrowser] discarding stale results for pluginId=' + requestPluginId); + return; + } + if (!items.length) { + body._allItems = null; // Ensure no stale data for filter tabs + body.innerHTML = '
No presets found
'; + info.textContent = '0 items'; + // Hide Factory tab when empty + var factoryTab = document.querySelector('#presetModal .preset-filter[data-filter="factory"]'); + if (factoryTab) factoryTab.style.display = 'none'; + } else { + renderPresetItems(items); + } + } + }; + // ── User presets/snapshots ── + var mfr = getPresetPluginManufacturer(presetPluginId); + var fn = window.__juceGetNativeFunction('getPluginPresets'); + fn(mfr, pName).then(function (names) { + if (!names || !names.length) { pending--; checkDone(); return; } + var loadFn = window.__juceGetNativeFunction('loadPluginPreset'); + var loaded = 0; + names.forEach(function (n) { + loadFn(mfr, pName, n).then(function (jsonStr) { + var type = 'preset'; // default for legacy presets without type field + if (jsonStr) { + try { var d = JSON.parse(jsonStr); if (d.type) type = d.type; } catch (e) { } + } + items.push({ name: n, type: type }); + loaded++; + if (loaded === names.length) { pending--; checkDone(); } + }).catch(function () { + items.push({ name: n, type: 'preset' }); + loaded++; + if (loaded === names.length) { pending--; checkDone(); } + }); + }); + }).catch(function () { pending--; checkDone(); }); + // ── Factory presets ── + var getFactoryFn = window.__juceGetNativeFunction('getFactoryPresets'); + if (getFactoryFn && pb) { + var factoryHostId = pb.hostId !== undefined ? pb.hostId : pb.id; + console.log('[PresetBrowser] fetching factory presets for hostId=' + factoryHostId); + getFactoryFn(factoryHostId).then(function (presets) { + var count = presets ? presets.length : 0; + var sampleNames = presets ? presets.slice(0, 5).map(function (p) { return p.name; }).join(', ') : ''; + console.log('[PresetBrowser] got ' + count + ' factory presets for hostId=' + factoryHostId + ' first: [' + sampleNames + ']'); + if (presets && presets.length) { + presets.forEach(function (fp) { + items.push({ name: fp.name, type: 'factory', factoryIndex: fp.index, hostId: factoryHostId, filePath: fp.filePath || '' }); + }); + } + pending--; + checkDone(); + }).catch(function () { pending--; checkDone(); }); + } else { + pending--; + checkDone(); + } +} +function renderPresetItems(items) { + var body = document.getElementById('presetBody'); + var info = document.getElementById('presetInfo'); + body._allItems = items; + // Show/hide Factory tab based on whether factory items exist + var hasFactory = items.some(function (it) { return it.type === 'factory'; }); + var factoryTab = document.querySelector('#presetModal .preset-filter[data-filter="factory"]'); + if (factoryTab) factoryTab.style.display = hasFactory ? '' : 'none'; + // Auto-fallback: if filter is 'factory' but no factory items exist, switch to 'all' + if (presetFilterType === 'factory' && !hasFactory) { + presetFilterType = 'all'; + document.querySelectorAll('#presetModal .preset-filter').forEach(function (b) { + b.classList.toggle('on', b.dataset.filter === 'all'); + }); + } + // Apply type filter + var filtered = items; + if (presetFilterType !== 'all') { + filtered = items.filter(function (it) { return it.type === presetFilterType; }); + } + // Apply search filter + if (typeof presetSearchText === 'string' && presetSearchText) { + filtered = filtered.filter(function (it) { return it.name.toLowerCase().indexOf(presetSearchText) >= 0; }); + } + var sorted = filtered.slice().sort(function (a, b) { return a.name.localeCompare(b.name); }); + info.textContent = sorted.length + ' of ' + items.length + ' item' + (items.length !== 1 ? 's' : ''); + if (!sorted.length) { + body.innerHTML = '
' + (presetSearchText ? 'No matches for "' + escHtml(presetSearchText) + '"' : 'No ' + (presetFilterType === 'all' ? 'saved items' : presetFilterType + 's')) + '
'; + return; + } + var h = ''; + sorted.forEach(function (it, idx) { + var badgeClass = it.type === 'snapshot' ? 'type-snapshot' : it.type === 'factory' ? 'type-factory' : 'type-preset'; + h += '
'; + h += '' + it.type + ''; + h += '' + escHtml(it.name) + ''; + if (it.type !== 'factory') { + h += ''; + h += ''; + } + h += '
'; + }); + body.innerHTML = h; + body.querySelectorAll('.preset-row').forEach(function (row) { + row.onclick = function (e) { + if (e.target.closest('[data-pdel]') || e.target.closest('[data-preveal]')) return; + var idx = parseInt(row.dataset.pidx); + var item = sorted[idx]; + if (item && item.type === 'factory' && item.factoryIndex !== undefined) { + loadFactoryPresetInPlace(item); + } else { + loadPreset(row.dataset.pname); + } + }; + }); + // Reveal in file explorer (per-plugin presets) + body.querySelectorAll('[data-preveal]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var revealFn = window.__juceGetNativeFunction('revealPresetFile'); + if (revealFn) { + var mfr = getPresetPluginManufacturer(presetPluginId); + var pName = getPresetPluginName(presetPluginId); + revealFn('snapshot', btn.dataset.preveal, mfr, pName); + } + }; + }); + // Delete with confirmation + body.querySelectorAll('[data-pdel]').forEach(function (btn) { + var confirmTimer = null; + btn.onclick = function (e) { + e.stopPropagation(); + if (btn._confirming) { + clearTimeout(confirmTimer); + btn._confirming = false; + deletePreset(btn.dataset.pdel); + } else { + btn._confirming = true; + btn.textContent = 'Delete?'; + btn.style.color = '#e55'; + btn.style.borderColor = '#e55'; + confirmTimer = setTimeout(function () { + btn._confirming = false; + btn.innerHTML = '×'; + btn.style.color = ''; + btn.style.borderColor = ''; + }, 2000); + } + }; + }); +} +function savePresetFromInput() { + var nameInput = document.getElementById('presetNameInput'); + var name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === presetPluginId) { pb = pluginBlocks[i]; break; } + } + if (!pb) return; + // Build preset data by param index + var data = { pluginName: pb.name, type: presetSaveType, params: {} }; + pb.params.forEach(function (p) { + data.params[p.realIndex] = { name: p.name, value: p.v, locked: p.lk || false, alk: p.alk || false }; + }); + var fn = window.__juceGetNativeFunction('savePluginPreset'); + fn(pb.manufacturer || '', pb.name, name, JSON.stringify(data)).then(function () { + nameInput.value = ''; + refreshPresetList(); + }); +} +function loadPreset(presetName) { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === presetPluginId) { pb = pluginBlocks[i]; break; } + } + if (!pb) return; + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('loadPluginPreset'); + fn(pb.manufacturer || '', pb.name, presetName).then(function (jsonStr) { + if (!jsonStr) return; + try { + var data = JSON.parse(jsonStr); + if (!data.params) return; + // Capture old values before applying preset + var oldVals = []; + pb.params.forEach(function (p) { oldVals.push({ id: p.id, val: p.v }); }); + var batch = []; + for (var idx in data.params) { + var entry = data.params[idx]; + var val = (typeof entry === 'object') ? entry.value : entry; + var savedLk = (typeof entry === 'object') ? entry.locked : false; + var savedAlk = (typeof entry === 'object') ? entry.alk : false; + for (var pi = 0; pi < pb.params.length; pi++) { + if (pb.params[pi].realIndex === parseInt(idx)) { + var p = pb.params[pi]; + p.lk = !!savedLk; + p.alk = !!savedAlk; + p.v = val; + if (p.hostId !== undefined) batch.push({ p: p.hostId, i: p.realIndex, v: p.v }); + break; + } + } + } + if (batch.length > 0) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(batch)); + } + pushMultiParamUndo(oldVals); + renderAllPlugins(); + closePresetBrowser(); + // Visual confirmation: flash the plugin card + toast + showToast('Preset loaded: ' + presetName, 'success', 2500); + var card = document.querySelector('.pcard[data-plugid="' + pb.id + '"]'); + if (card) { card.classList.remove('preset-flash'); void card.offsetWidth; card.classList.add('preset-flash'); } + } catch (e) { + console.log('Preset parse error:', e); + showToast('Failed to load preset: ' + e.message, 'error', 4000); + } + }); +} +function loadFactoryPresetInPlace(item) { + var pb = null; + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === presetPluginId) { pb = pluginBlocks[i]; break; } + } + if (!pb) return; + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var loadFn = window.__juceGetNativeFunction('loadFactoryPreset'); + loadFn(item.hostId, item.factoryIndex, item.filePath || '').then(function (paramArr) { + if (!paramArr || !paramArr.length) return; + // Capture old values for undo + var oldVals = []; + pb.params.forEach(function (p) { oldVals.push({ id: p.id, val: p.v }); }); + // Build index→value map + var valMap = {}; + paramArr.forEach(function (p) { valMap[p.index] = p.value; }); + // Apply to JS param state + pb.params.forEach(function (p) { + if (valMap[p.realIndex] !== undefined) { + p.v = valMap[p.realIndex]; + } + }); + pushMultiParamUndo(oldVals); + renderAllPlugins(); + closePresetBrowser(); + showToast('Factory preset loaded: ' + item.name, 'success', 2500); + var card = document.querySelector('.pcard[data-plugid="' + pb.id + '"]'); + if (card) { card.classList.remove('preset-flash'); void card.offsetWidth; card.classList.add('preset-flash'); } + }); +} +function deletePreset(presetName) { + var pName = getPresetPluginName(presetPluginId); + var mfr = getPresetPluginManufacturer(presetPluginId); + var fn = window.__juceGetNativeFunction('deletePluginPreset'); + fn(mfr, pName, presetName).then(function () { + refreshPresetList(); + }); +} +document.getElementById('presetModalClose').onclick = closePresetBrowser; +document.getElementById('presetModal').onclick = function (e) { + if (e.target === this) closePresetBrowser(); +}; +document.getElementById('presetSaveBtn').onclick = function () { + savePresetFromInput(); + // Flash save button green + var btn = document.getElementById('presetSaveBtn'); + btn.textContent = '\u2713 Saved'; + btn.style.background = '#4a8'; + setTimeout(function () { btn.textContent = 'Save'; btn.style.background = ''; }, 1200); +}; +document.getElementById('presetNameInput').onkeydown = function (e) { + if (e.key === 'Enter') savePresetFromInput(); +}; +// Type toggle wiring +document.querySelectorAll('.preset-type-btn').forEach(function (btn) { + btn.onclick = function () { + presetSaveType = btn.dataset.savetype; + document.querySelectorAll('.preset-type-btn').forEach(function (b) { b.classList.toggle('on', b === btn); }); + }; +}); +// Search wiring +var presetSearchText = ''; +document.getElementById('presetSearch').oninput = function () { + presetSearchText = this.value.toLowerCase(); + var body = document.getElementById('presetBody'); + if (body._allItems) renderPresetItems(body._allItems); +}; +// Filter tab wiring +document.querySelectorAll('#presetModal .preset-filter').forEach(function (btn) { + btn.onclick = function () { + presetFilterType = btn.dataset.filter; + document.querySelectorAll('#presetModal .preset-filter').forEach(function (b) { b.classList.toggle('on', b === btn); }); + var body = document.getElementById('presetBody'); + if (body._allItems) renderPresetItems(body._allItems); + }; +}); + +// Plugin context menu wiring — Save as Preset / Save as Snapshot +function syncTypeToggle(type) { + document.querySelectorAll('.preset-type-btn').forEach(function (b) { + b.classList.toggle('on', b.dataset.savetype === type); + }); +} +document.getElementById('pcSavePreset').onclick = function () { + presetSaveType = 'preset'; + syncTypeToggle('preset'); + openPresetBrowser(plugCtxPluginId, 'preset'); +}; +document.getElementById('pcSaveSnapshot').onclick = function () { + presetSaveType = 'snapshot'; + syncTypeToggle('snapshot'); + openPresetBrowser(plugCtxPluginId, 'snapshot'); +}; +document.getElementById('pcLoadState').onclick = function () { + openPresetBrowser(plugCtxPluginId, 'preset'); +}; + +// ============================================================ +// SNAPSHOT LIBRARY (for morph pad — loads presets/snapshots from any plugin) +// ============================================================ +var snapLibBlockId = null; // which morph pad block requested the library +var snapLibFilter = 'all'; +var snapLibSearch = ''; +var snapLibAllEntries = []; + +function openSnapshotLibrary(blockId) { + snapLibBlockId = blockId; + snapLibFilter = 'all'; + snapLibSearch = ''; + document.getElementById('snapLibSearch').value = ''; + document.getElementById('snapLibModal').classList.add('vis'); + // Reset filter tabs + document.querySelectorAll('#snapLibModal [data-slfilter]').forEach(function (btn) { + btn.classList.toggle('on', btn.dataset.slfilter === 'all'); + }); + refreshSnapshotLibrary(); +} + +function closeSnapshotLibrary() { + document.getElementById('snapLibModal').classList.remove('vis'); + snapLibBlockId = null; + if (typeof morphLaneLibTarget !== 'undefined') morphLaneLibTarget = null; +} + +function refreshSnapshotLibrary() { + var body = document.getElementById('snapLibBody'); + var info = document.getElementById('snapLibInfo'); + body.innerHTML = '
Loading...
'; + snapLibAllEntries = []; + if (!(window.__JUCE__ && window.__JUCE__.backend)) { + body.innerHTML = '
No backend connected
'; + info.textContent = '0 items'; + return; + } + // Determine which plugins to show presets for: + // If opened from a morph lane, only show plugins whose params are in that lane. + // Otherwise (morph pad), show all loaded plugins. + var relevantPlugins = pluginBlocks; + var laneTarget = (typeof morphLaneLibTarget !== 'undefined' && morphLaneLibTarget) ? morphLaneLibTarget : null; + if (laneTarget) { + var b = findBlock(laneTarget.blockId); + var lane = (b && b.lanes) ? b.lanes[laneTarget.laneIdx] : null; + if (lane && lane.pids && lane.pids.length > 0) { + // Collect hostIds of plugins that have params in this lane + var laneHostIds = {}; + lane.pids.forEach(function (pid) { + var p = PMap[pid]; + if (p && p.hostId !== undefined) laneHostIds[p.hostId] = true; + }); + relevantPlugins = pluginBlocks.filter(function (pb) { + return laneHostIds[pb.id] || laneHostIds[pb.hostId]; + }); + } + // Also check block-level targets for broader context + if (relevantPlugins.length === 0 && b && b.targets && b.targets.size > 0) { + var blockHostIds = {}; + b.targets.forEach(function (pid) { + var p = PMap[pid]; + if (p && p.hostId !== undefined) blockHostIds[p.hostId] = true; + }); + relevantPlugins = pluginBlocks.filter(function (pb) { + return blockHostIds[pb.id] || blockHostIds[pb.hostId]; + }); + } + // Fallback: if still empty, show all + if (relevantPlugins.length === 0) relevantPlugins = pluginBlocks; + } + // Each plugin gets 2 parallel fetches: user presets + factory presets + var pending = relevantPlugins.length * 2; + if (relevantPlugins.length === 0) { + body.innerHTML = '
No plugins loaded
'; + info.textContent = '0 items'; + return; + } + var checkDone = function () { if (pending === 0) renderSnapLibItems(); }; + var getPresetsFn = window.__juceGetNativeFunction('getPluginPresets'); + var loadPresetFn = window.__juceGetNativeFunction('loadPluginPreset'); + var getFactoryFn = window.__juceGetNativeFunction('getFactoryPresets'); + relevantPlugins.forEach(function (pb) { + // ── User presets/snapshots ── + getPresetsFn(pb.manufacturer || '', pb.name).then(function (names) { + if (!names || !names.length) { pending--; checkDone(); return; } + var subPending = names.length; + names.forEach(function (n) { + loadPresetFn(pb.manufacturer || '', pb.name, n).then(function (jsonStr) { + var type = 'preset'; + if (jsonStr) { + try { var d = JSON.parse(jsonStr); if (d.type) type = d.type; } catch (e) { } + } + snapLibAllEntries.push({ + name: n, + type: type, + pluginName: pb.name, + manufacturer: pb.manufacturer || '', + pluginId: pb.id, + hostId: pb.hostId + }); + subPending--; + if (subPending === 0) { pending--; checkDone(); } + }).catch(function () { + snapLibAllEntries.push({ name: n, type: 'preset', pluginName: pb.name, manufacturer: pb.manufacturer || '', pluginId: pb.id, hostId: pb.hostId }); + subPending--; + if (subPending === 0) { pending--; checkDone(); } + }); + }); + }).catch(function () { + pending--; + checkDone(); + }); + // ── Factory presets (from plugin programs) ── + if (getFactoryFn) { + getFactoryFn(pb.hostId !== undefined ? pb.hostId : pb.id).then(function (presets) { + if (presets && presets.length) { + presets.forEach(function (fp) { + snapLibAllEntries.push({ + name: fp.name, + type: 'factory', + pluginName: pb.name, + manufacturer: pb.manufacturer || '', + pluginId: pb.id, + hostId: pb.hostId, + factoryIndex: fp.index, + filePath: fp.filePath || '' + }); + }); + } + pending--; + checkDone(); + }).catch(function () { + pending--; + checkDone(); + }); + } else { + pending--; + checkDone(); + } + }); +} + +function renderSnapLibItems() { + var body = document.getElementById('snapLibBody'); + var info = document.getElementById('snapLibInfo'); + var filtered = snapLibAllEntries; + // Type filter + if (snapLibFilter !== 'all') { + filtered = filtered.filter(function (it) { return it.type === snapLibFilter; }); + } + // Search filter + if (snapLibSearch) { + var q = snapLibSearch.toLowerCase(); + filtered = filtered.filter(function (it) { + return it.name.toLowerCase().indexOf(q) >= 0 || + it.pluginName.toLowerCase().indexOf(q) >= 0; + }); + } + var sorted = filtered.slice().sort(function (a, b) { + if (a.pluginName !== b.pluginName) return a.pluginName.localeCompare(b.pluginName); + return a.name.localeCompare(b.name); + }); + info.textContent = sorted.length + ' item' + (sorted.length !== 1 ? 's' : ''); + if (!sorted.length) { + body.innerHTML = '
No matching items
'; + return; + } + var h = ''; + sorted.forEach(function (it, idx) { + var badgeClass = it.type === 'snapshot' ? 'type-snapshot' : it.type === 'factory' ? 'type-factory' : 'type-preset'; + h += '
'; + h += '' + it.type + ''; + h += '
' + escHtml(it.name) + '
'; + h += '
' + escHtml(it.pluginName) + '
'; + h += '
'; + }); + body.innerHTML = h; + body.querySelectorAll('.preset-row').forEach(function (row) { + row.onclick = function () { + var idx = parseInt(row.dataset.sli); + var entry = sorted[idx]; + if (!entry) return; + loadSnapshotFromLibrary(entry); + }; + }); +} + +// Helper: apply a param-index→value map as a snapshot (used by factory presets) +function _applySnapshotFromParamMap(entry, paramMap, laneTarget) { + if (laneTarget) { + // ── MORPH LANE ── + var b = findBlock(laneTarget.blockId); + var lane = (b && b.lanes) ? b.lanes[laneTarget.laneIdx] : null; + if (!b || !lane || !lane.morphMode) { closeSnapshotLibrary(); morphLaneLibTarget = null; return; } + if (!lane.morphSnapshots) lane.morphSnapshots = []; + // Only include values for params assigned to this lane + var lanePidSet = new Set(lane.pids); + var vals = {}; + lane.pids.forEach(function (pid) { + var p = PMap[pid]; + if (!p || p.lk) return; + vals[pid] = (paramMap[p.realIndex] !== undefined) ? paramMap[p.realIndex] : p.v; + }); + var snap = { + position: 0, hold: 0.5, curve: 0, + name: entry.name, source: entry.pluginName + ' (factory)', + values: vals + }; + lane.morphSnapshots.push(snap); + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { + lane.morphSnapshots[0].position = 0; + } + lane._selectedSnap = lane.morphSnapshots.length - 1; + renderSingleBlock(laneTarget.blockId); + syncBlocksToHost(); + closeSnapshotLibrary(); + morphLaneLibTarget = null; + } else { + // ── MORPH PAD ── + var b = findBlock(snapLibBlockId); + if (!b || b.mode !== 'morph_pad') return; + if (!b.snapshots) b.snapshots = []; + if (b.snapshots.length >= 12) { closeSnapshotLibrary(); return; } + var vals = {}; + b.targets.forEach(function (pid) { + var p = PMap[pid]; + if (!p) return; + if (p.hostId === entry.hostId || p.hostId === entry.pluginId) { + vals[pid] = (paramMap[p.realIndex] !== undefined) ? paramMap[p.realIndex] : p.v; + } else { + vals[pid] = p.v; + } + }); + var spos = getSnapSectorPos(b.snapshots.length); + b.snapshots.push({ + x: spos.x, y: spos.y, + values: vals, + name: entry.name, + source: entry.pluginName + ' (factory)' + }); + renderSingleBlock(snapLibBlockId); + syncBlocksToHost(); + var pad = document.querySelector('.morph-pad[data-b="' + snapLibBlockId + '"]'); + if (pad) { pad.classList.remove('snap-flash'); void pad.offsetWidth; pad.classList.add('snap-flash'); } + var chips = document.querySelectorAll('.snap-chip[data-b="' + snapLibBlockId + '"]'); + if (chips.length) { var last = chips[chips.length - 1]; last.classList.add('just-added'); setTimeout(function () { last.classList.remove('just-added'); }, 600); } + closeSnapshotLibrary(); + } +} + +function loadSnapshotFromLibrary(entry) { + // Load the preset data and create a snapshot — supports both morph pad and morph lanes + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + + // Check if targeting a morph lane + var laneTarget = (typeof morphLaneLibTarget !== 'undefined' && morphLaneLibTarget) ? morphLaneLibTarget : null; + + // ── FACTORY PRESET PATH ── + if (entry.type === 'factory' && entry.factoryIndex !== undefined) { + var loadFactoryFn = window.__juceGetNativeFunction('loadFactoryPreset'); + loadFactoryFn(entry.hostId !== undefined ? entry.hostId : entry.pluginId, entry.factoryIndex, entry.filePath || '').then(function (paramArr) { + if (!paramArr || !paramArr.length) return; + // Build param index→value map from C++ response + var paramMap = {}; + paramArr.forEach(function (p) { paramMap[p.index] = p.value; }); + _applySnapshotFromParamMap(entry, paramMap, laneTarget); + }); + return; + } + + // ── USER PRESET / SNAPSHOT PATH ── + if (laneTarget) { + // ── MORPH LANE TARGET ── + var b = findBlock(laneTarget.blockId); + var lane = (b && b.lanes) ? b.lanes[laneTarget.laneIdx] : null; + if (!b || !lane || !lane.morphMode) { closeSnapshotLibrary(); morphLaneLibTarget = null; return; } + if (!lane.morphSnapshots) lane.morphSnapshots = []; + + var loadFn = window.__juceGetNativeFunction('loadPluginPreset'); + loadFn(entry.manufacturer || '', entry.pluginName, entry.name).then(function (jsonStr) { + if (!jsonStr) return; + try { + var data = JSON.parse(jsonStr); + if (!data.params) return; + var vals = {}; + // Only include values for params assigned to this lane + lane.pids.forEach(function (pid) { + var p = PMap[pid]; + if (!p || p.lk) return; + var savedParam = data.params[p.realIndex]; + if (savedParam !== undefined) { + vals[pid] = (typeof savedParam === 'object') ? savedParam.value : savedParam; + } else { + vals[pid] = p.v; + } + }); + var snap = { + position: 0, + hold: 0.5, + curve: 0, + name: entry.name, + source: entry.pluginName, + values: vals + }; + lane.morphSnapshots.push(snap); + // Re-distribute evenly + if (lane.morphSnapshots.length > 1) { + for (var si = 0; si < lane.morphSnapshots.length; si++) + lane.morphSnapshots[si].position = si / (lane.morphSnapshots.length - 1); + } else { + lane.morphSnapshots[0].position = 0; + } + lane._selectedSnap = lane.morphSnapshots.length - 1; + renderSingleBlock(laneTarget.blockId); + syncBlocksToHost(); + closeSnapshotLibrary(); + } catch (e) { console.log('Snapshot load error:', e); } + morphLaneLibTarget = null; + }); + } else { + // ── MORPH PAD TARGET (original) ── + var b = findBlock(snapLibBlockId); + if (!b || b.mode !== 'morph_pad') return; + if (!b.snapshots) b.snapshots = []; + if (b.snapshots.length >= 12) { closeSnapshotLibrary(); return; } + + var loadFn = window.__juceGetNativeFunction('loadPluginPreset'); + loadFn(entry.manufacturer || '', entry.pluginName, entry.name).then(function (jsonStr) { + if (!jsonStr) return; + try { + var data = JSON.parse(jsonStr); + if (!data.params) return; + var vals = {}; + b.targets.forEach(function (pid) { + var p = PMap[pid]; + if (!p) return; + if (p.hostId === entry.hostId || p.hostId === entry.pluginId) { + var savedParam = data.params[p.realIndex]; + if (savedParam !== undefined) { + vals[pid] = (typeof savedParam === 'object') ? savedParam.value : savedParam; + } else { + vals[pid] = p.v; + } + } else { + vals[pid] = p.v; + } + }); + var spos = getSnapSectorPos(b.snapshots.length); + b.snapshots.push({ + x: spos.x, y: spos.y, + values: vals, + name: entry.name, + source: entry.pluginName + }); + renderSingleBlock(snapLibBlockId); + syncBlocksToHost(); + var pad = document.querySelector('.morph-pad[data-b="' + snapLibBlockId + '"]'); + if (pad) { pad.classList.remove('snap-flash'); void pad.offsetWidth; pad.classList.add('snap-flash'); } + var chips = document.querySelectorAll('.snap-chip[data-b="' + snapLibBlockId + '"]'); + if (chips.length) { var last = chips[chips.length - 1]; last.classList.add('just-added'); setTimeout(function () { last.classList.remove('just-added'); }, 600); } + closeSnapshotLibrary(); + } catch (e) { console.log('Snapshot load error:', e); } + }); + } +} + +// Snapshot Library modal wiring +document.getElementById('snapLibClose').onclick = closeSnapshotLibrary; +document.getElementById('snapLibModal').onclick = function (e) { + if (e.target === this) closeSnapshotLibrary(); +}; +document.getElementById('snapLibSearch').oninput = function () { + snapLibSearch = this.value; + renderSnapLibItems(); +}; +document.querySelectorAll('#snapLibModal [data-slfilter]').forEach(function (btn) { + btn.onclick = function () { + snapLibFilter = btn.dataset.slfilter; + document.querySelectorAll('#snapLibModal [data-slfilter]').forEach(function (b) { b.classList.toggle('on', b === btn); }); + renderSnapLibItems(); + }; +}); + +// ── Global Preset System ── +var currentGlobalPresetName = null; +function updateGpNameDisplay() { + document.getElementById('gpName').textContent = currentGlobalPresetName || '\u2014'; +} +function openGlobalPresetBrowser() { + document.getElementById('gpNameInput').value = currentGlobalPresetName || ''; + var gpSearchEl = document.getElementById('gpSearch'); + if (gpSearchEl) gpSearchEl.value = ''; + document.getElementById('globalPresetModal').classList.add('vis'); + refreshGlobalPresetList(); +} +var gpLoadInProgress = false; +var _gpPeekCache = {}; // Lazy cache: presetName → parsed JSON data +function closeGlobalPresetBrowser() { + if (gpLoadInProgress) return; // Prevent closing while plugins are loading + // Clean up any floating peek popup + var peek = document.getElementById('gpPeekPopup'); + if (peek) peek.remove(); + document.getElementById('globalPresetModal').classList.remove('vis'); +} + +function refreshGlobalPresetList() { + _gpPeekCache = {}; // Clear peek cache on refresh (presets may have changed) + var body = document.getElementById('gpBody'); + var info = document.getElementById('gpInfo'); + body.innerHTML = '
Loading...
'; + if (!(window.__JUCE__ && window.__JUCE__.backend)) { + body.innerHTML = '
No backend connected
'; + info.textContent = '0 presets'; + return; + } + var fn = window.__juceGetNativeFunction('getGlobalPresets'); + fn().then(function (entries) { + if (!entries || !entries.length) { + body.innerHTML = '
No saved global presets
'; + info.textContent = '0 presets'; + body._gpNames = []; + return; + } + // entries = [{ name, plugins: ["PluginA", ...] }, ...] + var sorted = entries.slice().sort(function (a, b) { + var na = (typeof a === 'string') ? a : a.name; + var nb = (typeof b === 'string') ? b : b.name; + return na.localeCompare(nb); + }); + body._gpNames = sorted.map(function (e) { return (typeof e === 'string') ? e : e.name; }); + // Build installed-names lookup once (for missing plugin detection in rows) + var hasScanCache = (typeof scannedPlugins !== 'undefined' && scannedPlugins.length > 0); + var installedNames = {}; + if (hasScanCache) { + scannedPlugins.forEach(function (sp) { + installedNames[sp.name.toLowerCase()] = true; + }); + } + // Apply search filter + var gpSearchText = document.getElementById('gpSearch').value.toLowerCase(); + var filtered = sorted; + if (gpSearchText) { + filtered = sorted.filter(function (e) { + var n = (typeof e === 'string') ? e : e.name; + return n.toLowerCase().indexOf(gpSearchText) >= 0; + }); + } + info.textContent = filtered.length + ' of ' + sorted.length + ' preset' + (sorted.length !== 1 ? 's' : ''); + if (!filtered.length) { + body.innerHTML = '
No matches for "' + escHtml(gpSearchText) + '"
'; + return; + } + var h = ''; + filtered.forEach(function (entry) { + var n = (typeof entry === 'string') ? entry : entry.name; + var pluginNames = (typeof entry === 'object' && entry.plugins) ? entry.plugins : []; + // Compute badge HTML inline — no IPC needed + var badgeHtml = ''; + if (pluginNames.length > 0) { + var missing = 0; + if (hasScanCache) { + pluginNames.forEach(function (pn) { + if (pn && !installedNames[('' + pn).toLowerCase()]) missing++; + }); + } + badgeHtml = '' + pluginNames.length + 'p'; + if (missing > 0) badgeHtml += '' + missing + ' missing'; + } + h += '
'; + h += '' + escHtml(n) + ''; + h += '' + badgeHtml + ''; + h += ''; + h += ''; + h += ''; + h += '
'; + }); + body.innerHTML = h; + // ── Single delegated click handler — no per-element onclick, no stopPropagation ── + body.onclick = function (e) { + var node = e.target; + var foundBtn = null; + var foundRow = null; + while (node && node !== body) { + if (!foundBtn && node.tagName === 'BUTTON') foundBtn = node; + if (!foundRow && node.classList && node.classList.contains('preset-row')) foundRow = node; + node = node.parentNode; + } + if (foundBtn) { + if (foundBtn.dataset.gppeek) { + _gpHandlePeek(foundBtn); + } else if (foundBtn.dataset.gpreveal) { + var revealFn = window.__juceGetNativeFunction('revealPresetFile'); + if (revealFn) revealFn('chain', foundBtn.dataset.gpreveal); + } else if (foundBtn.dataset.gpdel) { + _gpHandleDelete(foundBtn); + } + return; + } + if (foundRow) { + loadGlobalPreset(foundRow.dataset.gpname); + } + }; + }); +} +// ── Peek popup handler (extracted to avoid nesting) ── +function _gpHandlePeek(btn) { + var peekName = btn.dataset.gppeek; + // Toggle: if already open for this preset, close and return + var old = document.getElementById('gpPeekPopup'); + if (old) { + var wasForSame = old._peekName === peekName; + old.remove(); + if (wasForSame) return; + } + // Position below button + var rect = btn.getBoundingClientRect(); + var popup = document.createElement('div'); + popup.id = 'gpPeekPopup'; + popup.className = 'gp-peek-popup'; + popup._peekName = peekName; + popup.style.top = (rect.bottom + 4) + 'px'; + popup.style.left = Math.max(8, rect.left - 120) + 'px'; + document.body.appendChild(popup); + // Dismiss on click outside + setTimeout(function () { + document.addEventListener('click', function dismiss(ev) { + if (!popup.contains(ev.target) && !btn.contains(ev.target)) { + popup.remove(); + document.removeEventListener('click', dismiss); + } + }); + }, 50); + // Render from cache or load + if (_gpPeekCache[peekName]) { + _gpRenderPeekContent(popup, peekName, _gpPeekCache[peekName]); + } else { + popup.innerHTML = '
Loading...
'; + var loadFn = window.__juceGetNativeFunction('loadGlobalPreset'); + if (!loadFn) return; + loadFn(peekName).then(function (jsonStr) { + if (!jsonStr) { popup.innerHTML = '
Empty preset
'; return; } + try { + var data = JSON.parse(jsonStr); + _gpPeekCache[peekName] = data; + _gpRenderPeekContent(popup, peekName, data); + } catch (err) { + popup.innerHTML = '
Failed to parse preset
'; + } + }); + } +} +// ── Render peek popup content ── +function _gpRenderPeekContent(popup, peekName, data) { + var h = '
' + escHtml(peekName) + '
'; + if (data.routingMode !== undefined) { + var rLabel = data.routingMode === 2 ? 'WrongEQ' : (data.routingMode === 1 ? 'Parallel' : 'Sequential'); + h += '
' + rLabel + ' routing
'; + } + var realPlugins = (data.plugins || []).filter(function (p) { return p.path !== '__virtual__' && p.name !== '__virtual__'; }); + if (realPlugins.length) { + var hasScanCache = (typeof scannedPlugins !== 'undefined' && scannedPlugins.length > 0); + var installedNames = {}; + if (hasScanCache) { + scannedPlugins.forEach(function (sp) { + installedNames[sp.name.toLowerCase()] = true; + }); + } + var missingCount = 0; + h += '
'; + realPlugins.forEach(function (p, idx) { + var pName = p.name || p.path.split(/[\\/]/).pop().replace(/\.vst3$/i, '') || ('Plugin ' + (idx + 1)); + var isMissing = hasScanCache && !installedNames[pName.toLowerCase()]; + var lockedCount = 0; + if (p.params) { for (var k in p.params) { if (p.params[k].locked) lockedCount++; } } + h += '
'; + h += '' + (idx + 1) + ''; + h += '' + escHtml(pName) + ''; + if (isMissing) { h += 'MISSING'; missingCount++; } + if (p.bypassed) h += 'BYP'; + if (lockedCount > 0) h += '' + lockedCount + ' locked'; + h += '
'; + }); + h += '
'; + var summary = realPlugins.length + ' plugin' + (realPlugins.length !== 1 ? 's' : ''); + if (missingCount > 0) summary += ' · ' + missingCount + ' missing'; + h += '
' + summary + '
'; + } else { + h += '
No plugins in preset
'; + } + popup.innerHTML = h; +} +// ── Delete handler with confirmation ── +function _gpHandleDelete(btn) { + if (btn._confirming) { + if (btn._confirmTimer) clearTimeout(btn._confirmTimer); + btn._confirming = false; + deleteGlobalPreset(btn.dataset.gpdel); + } else { + btn._confirming = true; + btn.textContent = 'Delete?'; + btn.style.color = '#e55'; + btn.style.borderColor = '#e55'; + btn._confirmTimer = setTimeout(function () { + btn._confirming = false; + btn.innerHTML = '×'; + btn.style.color = ''; + btn.style.borderColor = ''; + }, 2000); + } +} +function buildGlobalPresetData() { + return { + version: 1, + routingMode: routingMode, + pluginOrder: pluginBlocks.filter(function (pb) { return !pb.isVirtual; }).map(function (pb) { return pb.id; }), + plugins: pluginBlocks.filter(function (pb) { return !pb.isVirtual; }).map(function (pb) { + var paramData = {}; + pb.params.forEach(function (p) { + paramData[p.realIndex] = { name: p.name, value: p.v, locked: p.lk || false }; + }); + return { name: pb.name, path: pb.path || '', manufacturer: pb.manufacturer || '', hostId: pb.hostId, params: paramData, bypassed: pb.bypassed || false, expanded: pb.expanded, busId: pb.busId || 0 }; + }), + blocks: blocks.map(function (b) { + return { + id: b.id, mode: b.mode, colorIdx: b.colorIdx, + targets: Array.from(b.targets), targetBases: b.targetBases || {}, targetRanges: b.targetRanges || {}, targetRangeBases: b.targetRangeBases || {}, + trigger: b.trigger, beatDiv: b.beatDiv, + midiMode: b.midiMode, midiNote: b.midiNote, midiCC: b.midiCC, midiCh: b.midiCh, + velScale: b.velScale, threshold: b.threshold, audioSrc: b.audioSrc, + rMin: b.rMin, rMax: b.rMax, rangeMode: (b.mode === 'randomize') ? b.rangeMode : 'relative', polarity: b.polarity || 'bipolar', + quantize: b.quantize, qSteps: b.qSteps, + movement: b.movement, glideMs: b.glideMs, + envAtk: b.envAtk, envRel: b.envRel, envSens: b.envSens, envInvert: b.envInvert, + envFilterMode: b.envFilterMode || 'flat', envFilterFreq: b.envFilterFreq != null ? b.envFilterFreq : 50, envFilterBW: b.envFilterBW != null ? b.envFilterBW : 5, + loopMode: b.loopMode, sampleSpeed: b.sampleSpeed, sampleReverse: b.sampleReverse, jumpMode: b.jumpMode, + sampleName: b.sampleName || '', sampleWaveform: b.sampleWaveform || null, + // Morph Pad fields + snapshots: (b.snapshots || []).map(function (s) { return { x: s.x, y: s.y, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + playheadX: b.playheadX != null ? b.playheadX : 0.5, playheadY: b.playheadY != null ? b.playheadY : 0.5, + morphMode: b.morphMode || 'manual', exploreMode: b.exploreMode || 'wander', + lfoShape: b.lfoShape || 'circle', lfoDepth: b.lfoDepth != null ? b.lfoDepth : 80, lfoRotation: b.lfoRotation != null ? b.lfoRotation : 0, morphSpeed: b.morphSpeed != null ? b.morphSpeed : 50, + morphAction: b.morphAction || 'jump', stepOrder: b.stepOrder || 'cycle', + morphSource: b.morphSource || 'midi', jitter: b.jitter != null ? b.jitter : 0, + morphGlide: b.morphGlide != null ? b.morphGlide : 200, + morphTempoSync: !!b.morphTempoSync, morphSyncDiv: b.morphSyncDiv || '1/4', + snapRadius: b.snapRadius != null ? b.snapRadius : 100, + shapeType: b.shapeType || 'circle', shapeTracking: b.shapeTracking || 'horizontal', + shapeSize: b.shapeSize != null ? b.shapeSize : 80, shapeSpin: b.shapeSpin != null ? b.shapeSpin : 0, + shapeSpeed: b.shapeSpeed != null ? b.shapeSpeed : 50, shapePhaseOffset: b.shapePhaseOffset || 0, + shapeRange: b.shapeRange || 'relative', shapePolarity: b.shapePolarity || 'bipolar', + shapeTempoSync: !!b.shapeTempoSync, shapeSyncDiv: b.shapeSyncDiv || '1/4', shapeTrigger: b.shapeTrigger || 'free', + clockSource: b.clockSource || 'daw', + laneTool: b.laneTool || 'draw', laneGrid: b.laneGrid || '1/8', + lanes: (b.lanes || []).map(function (lane) { + return { + pids: lane.pids || (lane.pid ? [lane.pid] : []), color: lane.color || '', collapsed: !!lane.collapsed, + pts: (lane.pts || []).map(function (p) { return { x: p.x, y: p.y }; }), + loopLen: lane.loopLen || '1/1', steps: lane.steps != null ? lane.steps : 0, depth: lane.depth != null ? lane.depth : 100, + drift: lane.drift != null ? lane.drift : 0, driftRange: lane.driftRange != null ? lane.driftRange : 5, driftScale: lane.driftScale || '1/1', warp: lane.warp != null ? lane.warp : 0, interp: lane.interp || 'smooth', + playMode: lane.playMode || 'forward', freeSecs: lane.freeSecs != null ? lane.freeSecs : 4, + synced: lane.synced !== false, muted: !!lane.muted, + morphMode: !!lane.morphMode, + morphSnapshots: (lane.morphSnapshots || []).map(function (s) { return { position: s.position || 0, hold: s.hold != null ? s.hold : 0.5, curve: s.curve || 0, depth: s.depth != null ? s.depth : 1.0, drift: s.drift || 0, driftRange: s.driftRange != null ? s.driftRange : 5, driftScale: s.driftScale || '', warp: s.warp || 0, steps: s.steps || 0, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + overlayLanes: lane._overlayLanes || [] + }; + }), + enabled: b.enabled !== false, expanded: b.expanded + }; + }), + bc: bc, actId: actId, + internalBpm: internalBpm, + autoLocate: autoLocate, + busVolumes: busVolumes.slice(), + busMutes: busMutes.slice(), + busSolos: busSolos.slice(), + wrongEq: { + points: wrongEqPoints.map(function (p, idx) { + var saveX = (typeof weqAnimRafId !== 'undefined' && weqAnimRafId && typeof weqAnimBaseX !== 'undefined' && weqAnimBaseX.length > idx) ? weqAnimBaseX[idx] : p.x; + var saveY = (typeof weqAnimRafId !== 'undefined' && weqAnimRafId && typeof weqAnimBaseY !== 'undefined' && weqAnimBaseY.length > idx) ? weqAnimBaseY[idx] : p.y; + return { x: saveX, y: saveY, uid: p.uid, pluginIds: p.pluginIds || [], seg: p.seg || null, solo: p.solo || false, mute: p.mute || false, q: p.q != null ? p.q : 0.707, type: p.type || 'Bell', drift: p.drift || 0, preEq: p.preEq !== false, stereoMode: p.stereoMode || 0, slope: p.slope || 1 }; + }), + interp: typeof weqGlobalInterp !== 'undefined' ? weqGlobalInterp : 'smooth', + depth: typeof weqGlobalDepth !== 'undefined' ? weqGlobalDepth : 100, + warp: typeof weqGlobalWarp !== 'undefined' ? weqGlobalWarp : 0, + steps: typeof weqGlobalSteps !== 'undefined' ? weqGlobalSteps : 0, + tilt: typeof weqGlobalTilt !== 'undefined' ? weqGlobalTilt : 0, + + preEq: typeof weqPreEq !== 'undefined' ? weqPreEq : true, + bypass: typeof weqGlobalBypass !== 'undefined' ? weqGlobalBypass : false, + animSpeed: typeof weqAnimSpeed !== 'undefined' ? weqAnimSpeed : 0, + animDepth: typeof weqAnimDepth !== 'undefined' ? weqAnimDepth : 6, + animShape: typeof weqAnimShape !== 'undefined' ? weqAnimShape : 'sine', + drift: typeof weqDrift !== 'undefined' ? weqDrift : 0, + driftRange: typeof weqDriftRange !== 'undefined' ? weqDriftRange : 5, + driftScale: typeof weqDriftScale !== 'undefined' ? weqDriftScale : '1/1', + driftContinuous: typeof weqDriftContinuous !== 'undefined' ? weqDriftContinuous : false, + driftMode: typeof weqDriftMode !== 'undefined' ? weqDriftMode : 'independent', + driftTexture: typeof weqDriftTexture !== 'undefined' ? weqDriftTexture : 'smooth', + gainLoCut: typeof weqGainLoCut !== 'undefined' ? weqGainLoCut : 20, + gainHiCut: typeof weqGainHiCut !== 'undefined' ? weqGainHiCut : 20000, + driftLoCut: typeof weqDriftLoCut !== 'undefined' ? weqDriftLoCut : 20, + driftHiCut: typeof weqDriftHiCut !== 'undefined' ? weqDriftHiCut : 20000, + qModSpeed: typeof weqQModSpeed !== 'undefined' ? weqQModSpeed : 0, + qModDepth: typeof weqQModDepth !== 'undefined' ? weqQModDepth : 50, + qModShape: typeof weqQModShape !== 'undefined' ? weqQModShape : 'sine', + qLoCut: typeof weqQLoCut !== 'undefined' ? weqQLoCut : 20, + qHiCut: typeof weqQHiCut !== 'undefined' ? weqQHiCut : 20000, + + dbRange: typeof weqDBRangeMax !== 'undefined' ? weqDBRangeMax : 24, + splitMode: typeof weqSplitMode !== 'undefined' ? weqSplitMode : false, + oversample: typeof weqOversample !== 'undefined' ? weqOversample : 1, + unassignedMode: typeof weqUnassignedMode !== 'undefined' ? weqUnassignedMode : 0, + eqPresetName: typeof _weqCurrentPreset !== 'undefined' ? _weqCurrentPreset : null + } + }; +} +function saveGlobalPresetFromInput() { + var nameInput = document.getElementById('gpNameInput'); + var name = nameInput.value.trim(); + if (!name) { nameInput.focus(); return; } + var data = buildGlobalPresetData(); + var fn = window.__juceGetNativeFunction('saveGlobalPreset'); + fn(name, JSON.stringify(data)).then(function () { + currentGlobalPresetName = name; + updateGpNameDisplay(); + clearGpDirty(); + nameInput.value = ''; + refreshGlobalPresetList(); + }); +} +function loadGlobalPreset(presetName) { + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('loadGlobalPreset'); + fn(presetName).then(function (jsonStr) { + if (!jsonStr) return; + try { + var data = JSON.parse(jsonStr); + applyGlobalPreset(data, presetName); + } catch (e) { console.log('Global preset parse error:', e); } + }); +} +function applyGlobalPreset(data, presetName) { + // Remove all current plugins + var removeFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('removePlugin') : null; + pluginBlocks.forEach(function (pb) { + if (removeFn && !pb.isVirtual) removeFn(pb.hostId !== undefined ? pb.hostId : pb.id); + pb.params.forEach(function (p) { delete PMap[p.id]; }); + }); + pluginBlocks = []; + blocks = []; + bc = 0; + actId = null; + assignMode = null; + + // Clear old UI immediately so placeholders appear in a clean rack + var plugScroll = document.getElementById('pluginScroll'); + if (plugScroll) plugScroll.innerHTML = ''; + // Show preset name immediately — instant feedback before plugins load + currentGlobalPresetName = presetName; + updateGpNameDisplay(); + closeGlobalPresetBrowser(); + + // Restore logic blocks immediately from preset data (targets stored as raw strings, + // validated against PMap later once plugins finish loading) + if (data.blocks && data.blocks.length) { + blocks = data.blocks.map(function (sb) { + var tSet = new Set(); + if (sb.targets) sb.targets.forEach(function (t) { tSet.add(t); }); + return { + id: sb.id, mode: sb.mode || 'randomize', targets: tSet, + targetBases: sb.targetBases || {}, targetRanges: sb.targetRanges || {}, targetRangeBases: sb.targetRangeBases || {}, + colorIdx: sb.colorIdx || 0, + trigger: sb.trigger || 'manual', beatDiv: sb.beatDiv || '1/4', + midiMode: sb.midiMode || 'any_note', midiNote: sb.midiNote != null ? sb.midiNote : 60, midiCC: sb.midiCC != null ? sb.midiCC : 1, midiCh: sb.midiCh != null ? sb.midiCh : 0, + velScale: sb.velScale || false, threshold: sb.threshold != null ? sb.threshold : -12, audioSrc: sb.audioSrc || 'main', + rMin: sb.rMin || 0, rMax: sb.rMax !== undefined ? sb.rMax : 100, + rangeMode: (sb.mode === 'randomize') ? (sb.rangeMode || 'absolute') : 'relative', polarity: sb.polarity || 'bipolar', + quantize: sb.quantize || false, qSteps: sb.qSteps != null ? sb.qSteps : 12, + movement: sb.movement || 'instant', glideMs: sb.glideMs != null ? sb.glideMs : 200, + envAtk: sb.envAtk != null ? sb.envAtk : 10, envRel: sb.envRel != null ? sb.envRel : 100, envSens: sb.envSens != null ? sb.envSens : 50, envInvert: sb.envInvert || false, + envFilterMode: sb.envFilterMode || 'flat', envFilterFreq: sb.envFilterFreq != null ? sb.envFilterFreq : 50, envFilterBW: sb.envFilterBW != null ? sb.envFilterBW : 5, + loopMode: sb.loopMode || 'loop', sampleSpeed: sb.sampleSpeed != null ? sb.sampleSpeed : 1.0, + sampleReverse: sb.sampleReverse || false, jumpMode: sb.jumpMode || 'restart', + sampleName: sb.sampleName || '', sampleWaveform: sb.sampleWaveform || null, + snapshots: (sb.snapshots || []).map(function (s) { return { x: s.x != null ? s.x : 0.5, y: s.y != null ? s.y : 0.5, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + playheadX: sb.playheadX != null ? sb.playheadX : 0.5, playheadY: sb.playheadY != null ? sb.playheadY : 0.5, + morphMode: sb.morphMode || 'manual', exploreMode: (sb.exploreMode === 'lfo' ? 'shapes' : sb.exploreMode) || 'wander', + lfoShape: sb.lfoShape || 'circle', lfoDepth: sb.lfoDepth != null ? sb.lfoDepth : 80, lfoRotation: sb.lfoRotation != null ? sb.lfoRotation : 0, morphSpeed: sb.morphSpeed != null ? sb.morphSpeed : 50, + morphAction: sb.morphAction || 'jump', stepOrder: sb.stepOrder || 'cycle', + morphSource: sb.morphSource || 'midi', jitter: sb.jitter != null ? sb.jitter : 0, + morphGlide: sb.morphGlide != null ? sb.morphGlide : 200, + morphTempoSync: !!sb.morphTempoSync, morphSyncDiv: sb.morphSyncDiv || '1/4', + snapRadius: sb.snapRadius != null ? sb.snapRadius : 100, + shapeType: sb.shapeType || 'circle', shapeTracking: sb.shapeTracking || 'horizontal', + shapeSize: sb.shapeSize != null ? sb.shapeSize : 80, shapeSpin: sb.shapeSpin != null ? sb.shapeSpin : 0, + shapeSpeed: sb.shapeSpeed != null ? sb.shapeSpeed : 50, shapePhaseOffset: sb.shapePhaseOffset || 0, + shapeRange: sb.shapeRange || 'relative', shapePolarity: sb.shapePolarity || 'bipolar', + shapeTempoSync: !!sb.shapeTempoSync, shapeSyncDiv: sb.shapeSyncDiv || '1/4', shapeTrigger: sb.shapeTrigger || 'free', + clockSource: sb.clockSource || 'daw', + laneTool: sb.laneTool || 'draw', laneGrid: sb.laneGrid || '1/8', + lanes: (sb.lanes || []).map(function (lane) { + var lPids = lane.pids || (lane.pid ? [lane.pid] : []); + return { + pids: lPids, color: lane.color || '', collapsed: !!lane.collapsed, + pts: (lane.pts || []).map(function (p) { return { x: p.x, y: p.y }; }), + loopLen: lane.loopLen || '1/1', steps: lane.steps != null ? lane.steps : 0, depth: lane.depth != null ? lane.depth : 100, + drift: lane.drift != null ? lane.drift : 0, driftRange: lane.driftRange != null ? lane.driftRange : 5, driftScale: lane.driftScale || '1/1', warp: lane.warp != null ? lane.warp : 0, interp: lane.interp || 'smooth', + playMode: lane.playMode || 'forward', freeSecs: lane.freeSecs != null ? lane.freeSecs : 4, + synced: lane.synced !== false, muted: !!lane.muted, + morphMode: !!lane.morphMode, + morphSnapshots: (lane.morphSnapshots || []).map(function (s) { return { position: s.position || 0, hold: s.hold != null ? s.hold : 0.5, curve: s.curve || 0, depth: s.depth != null ? s.depth : 1.0, drift: s.drift || 0, driftRange: s.driftRange != null ? s.driftRange : 5, driftScale: s.driftScale || '', warp: s.warp || 0, steps: s.steps || 0, name: s.name || '', source: s.source || '', values: s.values || {} }; }), + _overlayLanes: lane.overlayLanes || [] + }; + }), + enabled: sb.enabled !== false, expanded: sb.expanded !== false + }; + }); + bc = data.bc || blocks.reduce(function (m, b) { return Math.max(m, b.id); }, 0); + actId = data.actId || (blocks.length > 0 ? blocks[0].id : null); + } else { + addBlock('randomize'); + } + renderBlocks(); + updCounts(); + + if (!data.plugins || !data.plugins.length) { + renderAllPlugins(); + return; + } + + // Load plugins in parallel — each loadPlugin fires immediately. + // Phase 1 (disk scan) runs on background threads and overlaps. + // Phase 2 (COM instantiation) is serialized by JUCE's message thread automatically. + var loadPluginFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('loadPlugin') : null; + var setBypassFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setPluginBypass') : null; + var getStateFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('getFullState') : null; + + // Filter out virtual blocks that may have been saved in older presets + data.plugins = data.plugins.filter(function (p) { return p.path !== '__virtual__' && p.name !== '__virtual__'; }); + + var pluginPaths = data.plugins.map(function (p) { return p.path; }); + var loadFailures = []; + var loadedCount = 0; + var totalCount = pluginPaths.length; + gpLoadInProgress = true; + + // Set header loading state + if (typeof setPluginLoading === 'function') setPluginLoading(true, 'preset'); + + // Create placeholder cards for all plugins upfront (same visual as addPlugin) + var placeholderIds = []; + for (var pi = 0; pi < pluginPaths.length; pi++) { + var phId = 'gp-loading-' + Date.now() + '-' + pi; + var phName = pluginPaths[pi].split(/[\\/]/).pop().replace(/\.vst3$/i, ''); + placeholderIds.push(phId); + appendPlaceholderCard(phId, phName); + } + + function onPluginDone(idx, failed) { + removePlaceholderCard(placeholderIds[idx]); + if (failed) { + loadFailures.push(pluginPaths[idx].split(/[\\/]/).pop().replace(/\.vst3$/i, '')); + } + loadedCount++; + if (loadedCount >= totalCount) { + onAllPluginsLoaded(); + } + } + + function onAllPluginsLoaded() { + gpLoadInProgress = false; + + // All plugins loaded — now fetch state and apply params + if (getStateFn) { + getStateFn().then(function (result) { + if (result && result.plugins) { + // Rebuild pluginBlocks from hosted plugins + pluginBlocks = []; + PMap = {}; + var gpBatch = []; + result.plugins.forEach(function (plug, idx) { + var savedPlug = data.plugins[idx] || {}; + // Build name→saved param lookup for name-based fallback matching + var savedByName = {}; + if (savedPlug.params) { + for (var si in savedPlug.params) { + var sp = savedPlug.params[si]; + if (sp && typeof sp === 'object' && sp.name) { + savedByName[sp.name.toLowerCase()] = sp; + } + } + } + var params = (plug.params || []).map(function (p) { + var fid = plug.id + ':' + p.index; + var savedParam = savedPlug.params ? savedPlug.params[p.index] : null; + // If index-matched param has a different name, try name-based fallback + if (savedParam && typeof savedParam === 'object' && savedParam.name + && savedParam.name.toLowerCase() !== p.name.toLowerCase()) { + var byName = savedByName[p.name.toLowerCase()]; + if (byName) savedParam = byName; + } + // If no index match at all, try name-based fallback + if (!savedParam) { + var byName = savedByName[p.name.toLowerCase()]; + if (byName) savedParam = byName; + } + var val = savedParam ? (typeof savedParam === 'object' ? savedParam.value : savedParam) : p.value; + var locked = savedParam && savedParam.locked ? true : false; + var param = { id: fid, name: p.name, v: val, disp: p.disp || '', lk: locked, alk: false, realIndex: p.index, hostId: plug.id }; + PMap[fid] = param; + gpBatch.push({ p: plug.id, i: p.index, v: val }); + return param; + }); + pluginBlocks.push({ + id: plug.id, hostId: plug.id, + name: plug.name, path: savedPlug.path || '', + manufacturer: plug.manufacturer || savedPlug.manufacturer || '', + params: params, + expanded: savedPlug.expanded !== false, + bypassed: savedPlug.bypassed || false, + searchFilter: '' + }); + // Apply bypass + if (savedPlug.bypassed && setBypassFn) setBypassFn(plug.id, true); + }); + // Send all param values in one IPC call + if (gpBatch.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(gpBatch)); + } + + // Build old→new plugin ID map for remapping references + var idMap = {}; + for (var mi = 0; mi < data.plugins.length && mi < result.plugins.length; mi++) { + var oldId = data.plugins[mi].hostId; + var newId = result.plugins[mi].id; + if (oldId !== undefined && oldId !== newId) { + idMap[oldId] = newId; + } + } + + // Restore blocks with re-mapped target IDs + // Remap block targets from old IDs to new IDs now that plugins are loaded + if (data.blocks && data.blocks.length) { + // Re-validate targets against populated PMap + remap IDs + blocks.forEach(function (b) { + var newSet = new Set(); + b.targets.forEach(function (t) { + var parts = t.split(':'); + var remapped = t; + if (parts.length === 2 && idMap[parseInt(parts[0])] !== undefined) { + remapped = idMap[parseInt(parts[0])] + ':' + parts[1]; + } + if (PMap[remapped]) newSet.add(remapped); + }); + b.targets = newSet; + // Remap keyed maps + function remapKeyedMap(obj) { + if (!obj) return {}; + var out = {}; + for (var k in obj) { + var kp = k.split(':'); + var nk = k; + if (kp.length === 2 && idMap[parseInt(kp[0])] !== undefined) { + nk = idMap[parseInt(kp[0])] + ':' + kp[1]; + } + out[nk] = obj[k]; + } + return out; + } + b.targetBases = remapKeyedMap(b.targetBases); + b.targetRanges = remapKeyedMap(b.targetRanges); + b.targetRangeBases = remapKeyedMap(b.targetRangeBases); + // Remap snapshots + if (b.snapshots) { + b.snapshots.forEach(function (s) { + if (s.values) { + var newVals = {}; + for (var k in s.values) { + var kp = k.split(':'); + var nk = k; + if (kp.length === 2 && idMap[parseInt(kp[0])] !== undefined) { + nk = idMap[parseInt(kp[0])] + ':' + kp[1]; + } + newVals[nk] = s.values[k]; + } + s.values = newVals; + } + }); + } + // Remap lane pids + if (b.lanes) { + b.lanes.forEach(function (lane) { + if (lane.pids) { + lane.pids = lane.pids.map(function (pid) { + if (!pid) return pid; + var pp = pid.split(':'); + if (pp.length === 2 && idMap[parseInt(pp[0])] !== undefined) { + return idMap[parseInt(pp[0])] + ':' + pp[1]; + } + return pid; + }); + } + // Remap morph lane snapshot values + if (lane.morphSnapshots) { + lane.morphSnapshots.forEach(function (s) { + if (s.values) { + var newVals = {}; + for (var k in s.values) { + var kp = k.split(':'); + var nk = k; + if (kp.length === 2 && idMap[parseInt(kp[0])] !== undefined) { + nk = idMap[parseInt(kp[0])] + ':' + kp[1]; + } + newVals[nk] = s.values[k]; + } + s.values = newVals; + } + }); + } + }); + } + }); + } + } + // Preset name already shown — just restore global settings + if (data.internalBpm) { + internalBpm = data.internalBpm; + var bpmInp = document.getElementById('internalBpmInput'); + if (bpmInp) bpmInp.value = internalBpm; + } + if (data.autoLocate !== undefined) { + autoLocate = data.autoLocate; + var alChk = document.getElementById('autoLocateChk'); + if (alChk) alChk.checked = autoLocate; + } + + // Restore routing mode (sequential/parallel/wrongeq) + if (data.routingMode !== undefined) { + routingMode = data.routingMode; + var setRoutFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setRoutingMode') : null; + if (setRoutFn) setRoutFn(routingMode); + // Update routing toggle buttons + document.querySelectorAll('.routing-btn').forEach(function (b) { + b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); + }); + if (typeof weqSetVisible === 'function') weqSetVisible(routingMode === 2); + } + + // Restore WrongEQ state from preset + if (data.wrongEq) { + var weq = data.wrongEq; + if (weq.points) { + wrongEqPoints = weq.points.map(function (p) { + var pt = { x: p.x, y: p.y, pluginIds: p.pluginIds || [], seg: p.seg || null, solo: p.solo || false, mute: p.mute || false, q: p.q != null ? p.q : 0.707, type: p.type || 'Bell', drift: p.drift || 0, preEq: p.preEq !== undefined ? p.preEq : (weq.preEq !== undefined ? weq.preEq : true), stereoMode: p.stereoMode || 0, slope: p.slope || 1 }; + if (p.uid) pt.uid = p.uid; + else if (typeof _weqAllocUid === 'function') pt.uid = _weqAllocUid(); + return pt; + }); + // Sync UID counter to prevent collisions + if (typeof _weqNextUid !== 'undefined') { + var maxUid = 0; + wrongEqPoints.forEach(function (pt) { if (pt.uid > maxUid) maxUid = pt.uid; }); + if (maxUid >= _weqNextUid) _weqNextUid = maxUid + 1; + } + } + if (typeof weqGlobalInterp !== 'undefined' && weq.interp) weqGlobalInterp = weq.interp; + if (typeof weqGlobalDepth !== 'undefined' && weq.depth != null) weqGlobalDepth = weq.depth; + if (typeof weqGlobalWarp !== 'undefined' && weq.warp != null) weqGlobalWarp = weq.warp; + if (typeof weqGlobalSteps !== 'undefined' && weq.steps != null) weqGlobalSteps = weq.steps; + if (typeof weqGlobalTilt !== 'undefined' && weq.tilt != null) weqGlobalTilt = weq.tilt; + + if (typeof weqPreEq !== 'undefined' && weq.preEq != null) weqPreEq = weq.preEq; + if (typeof weqGlobalBypass !== 'undefined' && weq.bypass != null) weqGlobalBypass = weq.bypass; + // Animation params + if (typeof weqAnimStop === 'function') weqAnimStop(); + if (typeof weqAnimSpeed !== 'undefined' && weq.animSpeed != null) weqAnimSpeed = weq.animSpeed; + if (typeof weqAnimDepth !== 'undefined' && weq.animDepth != null) weqAnimDepth = weq.animDepth; + if (typeof weqAnimShape !== 'undefined' && weq.animShape != null) weqAnimShape = weq.animShape; + // Drift params + if (typeof weqDrift !== 'undefined' && weq.drift != null) weqDrift = weq.drift; + if (typeof weqDriftRange !== 'undefined' && weq.driftRange != null) weqDriftRange = weq.driftRange; + if (typeof weqDriftScale !== 'undefined' && weq.driftScale != null) weqDriftScale = weq.driftScale; + if (typeof weqDriftContinuous !== 'undefined' && weq.driftContinuous != null) weqDriftContinuous = weq.driftContinuous; + if (typeof weqDriftMode !== 'undefined' && weq.driftMode != null) weqDriftMode = weq.driftMode; + if (typeof weqDriftTexture !== 'undefined' && weq.driftTexture != null) weqDriftTexture = weq.driftTexture; + if (typeof weqGainLoCut !== 'undefined' && weq.gainLoCut != null) weqGainLoCut = weq.gainLoCut; + if (typeof weqGainHiCut !== 'undefined' && weq.gainHiCut != null) weqGainHiCut = weq.gainHiCut; + if (typeof weqDriftLoCut !== 'undefined' && weq.driftLoCut != null) weqDriftLoCut = weq.driftLoCut; + if (typeof weqDriftHiCut !== 'undefined' && weq.driftHiCut != null) weqDriftHiCut = weq.driftHiCut; + if (typeof weqQModSpeed !== 'undefined' && weq.qModSpeed != null) weqQModSpeed = weq.qModSpeed; + if (typeof weqQModDepth !== 'undefined' && weq.qModDepth != null) weqQModDepth = weq.qModDepth; + if (typeof weqQModShape !== 'undefined' && weq.qModShape != null) weqQModShape = weq.qModShape; + if (typeof weqQLoCut !== 'undefined' && weq.qLoCut != null) weqQLoCut = weq.qLoCut; + if (typeof weqQHiCut !== 'undefined' && weq.qHiCut != null) weqQHiCut = weq.qHiCut; + + if (typeof weqDBRangeMax !== 'undefined' && weq.dbRange != null) weqDBRangeMax = weq.dbRange; + if (typeof weqSplitMode !== 'undefined' && weq.splitMode != null) weqSplitMode = weq.splitMode; + if (typeof weqOversample !== 'undefined' && weq.oversample != null) weqOversample = weq.oversample; + if (typeof weqUnassignedMode !== 'undefined' && weq.unassignedMode != null) weqUnassignedMode = weq.unassignedMode; + if (typeof _weqCurrentPreset !== 'undefined') _weqCurrentPreset = weq.eqPresetName || null; + + // Remap WrongEQ pluginIds from old host IDs to new IDs + if (idMap && Object.keys(idMap).length > 0) { + wrongEqPoints.forEach(function (pt) { + if (!pt.pluginIds || !pt.pluginIds.length) return; + pt.pluginIds = pt.pluginIds.map(function (pid) { + return (idMap[pid] !== undefined) ? idMap[pid] : pid; + }); + }); + } + + if (routingMode === 2 && typeof weqRenderPanel === 'function') weqRenderPanel(); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + // Retry sync after delay — backend may not be ready + setTimeout(function () { + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (routingMode === 2 && typeof weqRenderPanel === 'function') weqRenderPanel(); + }, 500); + // Restart animation if speed > 0 or drift active + var needsAnim = (typeof _weqNeedsAnim === 'function') ? _weqNeedsAnim() : (weqAnimSpeed > 0); + if (needsAnim && typeof weqAnimStart === 'function') weqAnimStart(); + } + // Restore per-plugin bus assignments + var setBusFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setPluginBus') : null; + if (setBusFn && data.plugins) { + for (var bi = 0; bi < data.plugins.length && bi < pluginBlocks.length; bi++) { + var savedBus = data.plugins[bi].busId || 0; + pluginBlocks[bi].busId = savedBus; + if (setBusFn) setBusFn(pluginBlocks[bi].id, savedBus); + } + } + + // Restore bus mixer state + if (data.busVolumes) { + var setBvFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setBusVolume') : null; + for (var bvi = 0; bvi < data.busVolumes.length && bvi < busVolumes.length; bvi++) { + busVolumes[bvi] = data.busVolumes[bvi]; + if (setBvFn) setBvFn(bvi, busVolumes[bvi]); + } + } + if (data.busMutes) { + var setBmFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setBusMute') : null; + for (var bmi = 0; bmi < data.busMutes.length && bmi < busMutes.length; bmi++) { + busMutes[bmi] = data.busMutes[bmi]; + if (setBmFn) setBmFn(bmi, busMutes[bmi]); + } + } + if (data.busSolos) { + var setBsFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setBusSolo') : null; + for (var bsi = 0; bsi < data.busSolos.length && bsi < busSolos.length; bsi++) { + busSolos[bsi] = data.busSolos[bsi]; + if (setBsFn) setBsFn(bsi, busSolos[bsi]); + } + } + + // Restore plugin order + var reorderFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('reorderPlugins') : null; + if (reorderFn) { + var ids = pluginBlocks.map(function (pb) { return pb.id; }); + reorderFn(ids); + } + + if (typeof setPluginLoading === 'function') setPluginLoading(false); + + // Restore routing mode (Sequential=0, Parallel=1, WrongEQ=2) + if (data.routingMode != null && data.routingMode !== routingMode) { + routingMode = data.routingMode; + // Update C++ backend + if (window.__JUCE__ && window.__JUCE__.backend) { + var setRmFn = window.__juceGetNativeFunction('setRoutingMode'); + setRmFn(routingMode); + } + // Update routing toggle buttons + document.querySelectorAll('.routing-btn').forEach(function (b) { + b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); + }); + // Show/hide WrongEQ panel + if (typeof weqSetVisible === 'function') weqSetVisible(routingMode === 2); + // Sync EQ state when entering WrongEQ mode + if (routingMode === 2 && typeof weqSyncToHost === 'function') weqSyncToHost(); + } + + // Refresh targetBases from actual param values so arcs are correct + blocks.forEach(function (b) { + b.targets.forEach(function (pid) { + var p = PMap[pid]; + if (p) { + if (!b.targetBases) b.targetBases = {}; + b.targetBases[pid] = p.v; + if (b.mode === 'shapes_range') { + if (!b.targetRangeBases) b.targetRangeBases = {}; + b.targetRangeBases[pid] = p.v; + } + } + }); + }); + + renderAllPlugins(); renderBlocks(); updCounts(); syncBlocksToHost(); saveUiStateToHost(); syncExpandedPlugins(); + clearGpDirty(); + closeGlobalPresetBrowser(); + + // Show confirmation toast + report failures + if (loadFailures.length > 0) { + showToast('Preset loaded with errors. Could not load: ' + loadFailures.join(', '), 'error', 6000); + } else { + showToast('Preset loaded: ' + presetName, 'success', 3000); + } + }); + } + } + + // Fire all plugin loads in parallel with name-based fallback + // If exact path fails (different OS, different install location), retry by plugin name. + // C++ findPluginDescription matches d.name == pluginPath, so passing just the name works. + if (loadPluginFn) { + for (var li = 0; li < pluginPaths.length; li++) { + (function (idx) { + loadPluginFn(pluginPaths[idx]).then(function (result) { + if (!result || result.error) { + // Path failed — try fallback by plugin name + var pluginName = data.plugins[idx] ? data.plugins[idx].name : ''; + if (pluginName && pluginName !== pluginPaths[idx]) { + loadPluginFn(pluginName).then(function (result2) { + onPluginDone(idx, !result2 || result2.error); + }).catch(function () { + onPluginDone(idx, true); + }); + } else { + onPluginDone(idx, true); + } + } else { + onPluginDone(idx, false); + } + }).catch(function () { + // Path threw — try fallback by plugin name + var pluginName = data.plugins[idx] ? data.plugins[idx].name : ''; + if (pluginName && pluginName !== pluginPaths[idx]) { + loadPluginFn(pluginName).then(function (result2) { + onPluginDone(idx, !result2 || result2.error); + }).catch(function () { + onPluginDone(idx, true); + }); + } else { + onPluginDone(idx, true); + } + }); + })(li); + } + } else { + // No backend — just clean up placeholders + for (var ni = 0; ni < placeholderIds.length; ni++) { + removePlaceholderCard(placeholderIds[ni]); + } + gpLoadInProgress = false; + if (typeof setPluginLoading === 'function') setPluginLoading(false); + } +} +function deleteGlobalPreset(presetName) { + var fn = window.__juceGetNativeFunction('deleteGlobalPreset'); + fn(presetName).then(function () { + if (currentGlobalPresetName === presetName) { + currentGlobalPresetName = null; + updateGpNameDisplay(); + } + refreshGlobalPresetList(); + }); +} + +// Dirty state tracking +var gpDirty = false; +function markGpDirty() { + if (!gpDirty && currentGlobalPresetName) { + gpDirty = true; + var el = document.getElementById('gpDirty'); + if (el) el.classList.add('on'); + } +} +function clearGpDirty() { + gpDirty = false; + var el = document.getElementById('gpDirty'); + if (el) el.classList.remove('on'); +} + +// Wire header buttons +document.getElementById('gpBrowse').onclick = openGlobalPresetBrowser; +document.getElementById('gpSave').onclick = function () { + // Quick save: if we have a current name, overwrite. Otherwise open browser. + if (currentGlobalPresetName) { + var data = buildGlobalPresetData(); + var fn = window.__juceGetNativeFunction('saveGlobalPreset'); + fn(currentGlobalPresetName, JSON.stringify(data)); + clearGpDirty(); + // Flash feedback + var btn = document.getElementById('gpSave'); + btn.textContent = '\u2713'; + setTimeout(function () { btn.textContent = 'Save'; }, 800); + } else { + openGlobalPresetBrowser(); + } +}; + +// Nav arrows — cycle through global presets +document.getElementById('gpPrev').onclick = function () { navigateGlobalPreset(-1); }; +document.getElementById('gpNext').onclick = function () { navigateGlobalPreset(1); }; +function navigateGlobalPreset(dir) { + if (!(window.__JUCE__ && window.__JUCE__.backend)) return; + var fn = window.__juceGetNativeFunction('getGlobalPresets'); + fn().then(function (entries) { + if (!entries || !entries.length) return; + var sorted = entries.slice().sort(function (a, b) { + var na = (typeof a === 'string') ? a : a.name; + var nb = (typeof b === 'string') ? b : b.name; + return na.localeCompare(nb); + }); + var names = sorted.map(function (e) { return (typeof e === 'string') ? e : e.name; }); + var idx = names.indexOf(currentGlobalPresetName); + if (idx < 0) { + idx = dir > 0 ? 0 : names.length - 1; + } else { + idx = (idx + dir + names.length) % names.length; + } + loadGlobalPreset(names[idx]); + }); +} + +document.getElementById('gpModalClose').onclick = closeGlobalPresetBrowser; +document.getElementById('globalPresetModal').onclick = function (e) { + if (e.target === this) closeGlobalPresetBrowser(); +}; +document.getElementById('gpSaveBtn').onclick = function () { + saveGlobalPresetFromInput(); + var btn = document.getElementById('gpSaveBtn'); + btn.textContent = '\u2713 Saved'; + btn.style.background = '#4a8'; + setTimeout(function () { btn.textContent = 'Save'; btn.style.background = ''; }, 1200); +}; +document.getElementById('gpNameInput').onkeydown = function (e) { + if (e.key === 'Enter') saveGlobalPresetFromInput(); +}; +// Global preset search +document.getElementById('gpSearch').oninput = function () { + refreshGlobalPresetList(); +}; +// Open presets root folder +document.getElementById('gpOpenFolder').onclick = function () { + var revealFn = window.__juceGetNativeFunction('revealPresetFile'); + if (revealFn) revealFn('root', ''); +}; diff --git a/plugins/ModularRandomizer/Source/ui/public/js/realtime.js b/plugins/ModularRandomizer/Source/ui/public/js/realtime.js new file mode 100644 index 0000000..b351108 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/realtime.js @@ -0,0 +1,782 @@ +var _lastBpmDisplay = 0, _bpmThrottle = 0, _lastLocateTime = 0; +var _laneSkipDirty = 0; // frames to silently adopt lane values after visibility restore +var _rtTick = 0; // frame counter for throttling (badge updates etc.) +function processRealTimeData() { + // Clear consumed MIDI events + rtData.midi = []; + + // Update live BPM display (~4 Hz throttle) + if (++_bpmThrottle >= 15) { + _bpmThrottle = 0; + var bpm = Math.round(rtData.bpm || 0); + if (bpm !== _lastBpmDisplay) { + _lastBpmDisplay = bpm; + var el = document.getElementById('bpmDisplay'); + if (el) el.textContent = (bpm > 0 ? bpm : '\u2014') + ' BPM'; + } + } + + // Decrement skip-dirty counter (set by visibilitychange handler) + if (_laneSkipDirty > 0) _laneSkipDirty--; + _rtTick++; + + // Update lane param value badges (selected-only for curve, sliding for morph) + // Curve: only the selected param has a live badge → O(1) per lane + // Morph: sliding window max 20 per tick for 2000-param safety + if ((_rtTick % 3) === 0 && typeof blocks !== 'undefined') { + for (var bi = 0; bi < blocks.length; bi++) { + var blk = blocks[bi]; + if (!blk || blk.mode !== 'lane' || !blk.lanes) continue; + for (var lii = 0; lii < blk.lanes.length; lii++) { + var ln = blk.lanes[lii]; + if (!ln || !ln.pids || ln.collapsed) continue; + if (!ln.morphMode) { + // Curve lane: only update the selected param's live badge + if (ln._selectedParamIdx != null && ln._selectedParamIdx >= 0 && ln.pids[ln._selectedParamIdx]) { + var cp = PMap[ln.pids[ln._selectedParamIdx]]; + var ctxt = cp && cp.disp ? cp.disp : (cp ? (cp.v * 100).toFixed(0) + '%' : ''); + var cbdg = document.getElementById('cpvb-' + blk.id + '-' + lii); + if (cbdg && cbdg.textContent !== ctxt) cbdg.textContent = ctxt; + } + } + } + } + // Morph lanes: same pattern — only update the selected param's live badge + for (var mbi = 0; mbi < blocks.length; mbi++) { + var mb = blocks[mbi]; + if (!mb || mb.mode !== 'lane' || !mb.lanes) continue; + for (var mli = 0; mli < mb.lanes.length; mli++) { + var ml = mb.lanes[mli]; + if (!ml || !ml.morphMode || !ml.pids || ml.collapsed) continue; + if (ml._selectedParamIdx != null && ml._selectedParamIdx >= 0 && ml.pids[ml._selectedParamIdx]) { + var mp = PMap[ml.pids[ml._selectedParamIdx]]; + var mt = mp && mp.disp ? mp.disp : (mp ? (mp.v * 100).toFixed(0) + '%' : ''); + var mbd = document.getElementById('mpvb-' + mb.id + '-' + mli); + if (mbd && mbd.textContent !== mt) mbd.textContent = mt; + } + } + } + } + + requestAnimationFrame(processRealTimeData); +} + +// _modDirty: boolean flag for continuous modulation (avoids O(n) Set.add loops) +var _modDirty = false; +// Params currently being dragged via our UI knobs — skip polling updates +var _touchedByUI = new Set(); +// Cached visible PIDs — rebuilt at most every 100ms to avoid layout reflows +var _visPidsCache = null, _visPidsTime = 0, _visPidsDirty = true; +var _setVisFn = null; // lazy ref to setVisibleParams native function + +// Shared visible-pids-cache rebuild — called by both refreshParamDisplay and lane processing +function _rebuildVisPids() { + var now = Date.now(); + if (_visPidsCache && !_visPidsDirty && now - _visPidsTime <= 100) return; + _visPidsDirty = false; + _visPidsTime = now; + _visPidsCache = new Set(); + if (typeof pluginBlocks !== 'undefined') { + for (var pi = 0; pi < pluginBlocks.length; pi++) { + var pb = pluginBlocks[pi]; + if (!pb.expanded) continue; + var container = document.querySelector('[data-plugparams="' + pb.id + '"]'); + if (!container) continue; + if (container._vScroll) { + for (var ci = 0; ci < container.children.length; ci++) { + var cpid = container.children[ci].getAttribute('data-pid'); + if (cpid) _visPidsCache.add(cpid); + } + } else { + var st = container.scrollTop, ch = container.clientHeight; + if (ch > 0) { + for (var ci = 0; ci < container.children.length; ci++) { + var child = container.children[ci]; + if (child.offsetHeight === 0) continue; + var ot = child.offsetTop; + if (ot + child.offsetHeight < st) continue; + if (ot > st + ch) break; + var cpid = child.getAttribute('data-pid'); + if (cpid) _visPidsCache.add(cpid); + } + } + } + } + } + // Notify C++ about visible PIDs so Tier 1 only polls params on screen + if (_visPidsCache.size > 0 && window.__JUCE__ && window.__JUCE__.backend) { + if (!_setVisFn) _setVisFn = window.__juceGetNativeFunction('setVisibleParams'); + if (_setVisFn) _setVisFn(Array.from(_visPidsCache)); + } +} + +function refreshParamDisplay() { + // Always update ALL visible params — unconditionally. + // With ~8 visible params this costs <0.5ms. No micro-optimizations. + // Any conditional gating here causes modulated params to silently stop updating. + _modDirty = false; + + // Pre-compute shapes_range info for arc display (assign-mode only) + var srBlk = null, srCol = ''; + if (assignMode) { + var ab = findBlock(assignMode); + if (ab && ab.mode === 'shapes_range') { + srBlk = ab; + srCol = bColor(ab.colorIdx); + } + } + + // ── Build the set of PIDs that are actually visible on screen ── + _rebuildVisPids(); + var _visPids = _visPidsCache; + + // ── Update only visible dirty params ── + // Iterate the VISIBLE set (tiny, ~8 params) and check if dirty, + // not the dirty set (potentially 2000+) checking if visible. + // For non-visible PIDs: PMap is already updated, so when they scroll into + // view or card expands, dirtyPluginParams() will repaint them. + _visPids.forEach(function (pid) { + + var p = PMap[pid]; + if (!p) return; + + var row = document.querySelector('.pr[data-pid="' + pid + '"]'); + if (!row) return; + + // Determine if this param is modulated + var ri = null; + var isModulated = false; + if (srBlk && srBlk.targets.has(pid)) { + var rng = srBlk.targetRanges && srBlk.targetRanges[pid] !== undefined ? srBlk.targetRanges[pid] : 0; + var base = srBlk.targetRangeBases && srBlk.targetRangeBases[pid] !== undefined ? srBlk.targetRangeBases[pid] : p.v; + ri = { range: rng, base: base, color: srCol, polarity: srBlk.shapePolarity || 'bipolar' }; + } else if (!srBlk) { + ri = getModArcInfo(pid); + if (ri) { + isModulated = true; + var cur = computeModCurrent(ri, p.v); + if (cur !== null) ri.current = cur; + } + } + + // For non-modulated params being dragged, skip visual updates entirely + if (_touchedByUI.has(pid) && !isModulated) return; + + // Update value text (skip for modulated params being dragged — drag handler does it) + if (!_touchedByUI.has(pid)) { + var ve = row.querySelector('.pr-val'); + if (ve) ve.textContent = p.disp || ((p.v * 100).toFixed(0) + '%'); + } + + // Update knob SVG — always, even during drag of modulated params + var knobEl = row.querySelector('.pr-knob'); + if (knobEl && typeof buildParamKnob === 'function') { + var knobVal = (ri && ri.base !== undefined) ? ri.base : p.v; + knobEl.innerHTML = buildParamKnob(knobVal, 30, ri); + } + + // Update bar (skip for modulated during drag) + if (!_touchedByUI.has(pid)) { + var be = row.querySelector('.pr-bar-f'); + if (be) be.style.width = (p.v * 100) + '%'; + } + }); +} + +// Listen for real-time data from C++ backend +function setupRtDataListener() { + if (window.__JUCE__ && window.__JUCE__.backend) { + window.__JUCE__.backend.addEventListener('__rt_data__', function (data) { + if (data) { + rtData.rms = data.rms || 0; + rtData.scRms = data.scRms || 0; + rtData.bpm = data.bpm || 120; + rtData.playing = data.playing || false; + rtData.ppq = data.ppq || 0; + // Sync actual sample rate from C++ for EQ curve accuracy + if (data.sr && data.sr > 0 && typeof _WEQ_REF_FS !== 'undefined') + _WEQ_REF_FS = data.sr; + if (data.spectrum && data.spectrum.length && typeof weqSetSpectrum === 'function') { + weqSetSpectrum(data.spectrum); + } + if (data.midi && data.midi.length) { + rtData.midi = rtData.midi.concat(data.midi); + } + + // Envelope follower levels from C++ (visual display) + if (data.envLevels && data.envLevels.length) { + for (var ei = 0; ei < data.envLevels.length; ei++) { + var en = data.envLevels[ei]; + var cl = Math.max(0, Math.min(1, en.level)); + var pct = (cl * 100); + // Store readback on block for arc animation (like shapeModOutput) + var eb = findBlock(en.id); + if (eb && eb.mode === 'envelope') { + eb.envModOutput = cl; + // Mark targets dirty so arcs keep animating + if (eb.targets && eb.targets.size > 0) _modDirty = true; + } + // Fill bar — direct set, no CSS transition + var fl = document.getElementById('envFill-' + en.id); + if (fl) fl.style.height = pct + '%'; + // Label + var lb = document.getElementById('envLbl-' + en.id); + if (lb) lb.textContent = pct.toFixed(0) + '%'; + // Peak hold line — jumps up instantly, decays slowly via CSS transition + var pk = document.getElementById('envPeak-' + en.id); + if (pk) { + var curPeak = parseFloat(pk._peak || 0); + if (pct >= curPeak) { + // New peak — snap up instantly (disable transition momentarily) + pk.style.transition = 'none'; + pk.style.bottom = pct + '%'; + pk.style.opacity = '0.9'; + pk._peak = pct; + pk._peakTime = Date.now(); + // Force reflow then re-enable transition for decay + void pk.offsetWidth; + pk.style.transition = 'bottom 1.2s ease-out, opacity 1.5s ease-out'; + } else if (Date.now() - (pk._peakTime || 0) > 300) { + // Hold for 300ms then start decay + pk._peak = pct; + pk.style.bottom = pct + '%'; + pk.style.opacity = '0.3'; + } + } + // Active dot — brightness driven by actual level + var dot = document.getElementById('envDot-' + en.id); + if (dot) dot.style.opacity = (0.3 + cl * 0.7).toFixed(2); + } + } + + // Sample playhead positions from C++ + if (data.sampleHeads && data.sampleHeads.length) { + for (var si = 0; si < data.sampleHeads.length; si++) { + var sh = data.sampleHeads[si]; + var head = document.getElementById('waveHead-' + sh.id); + if (head) { + // Use cached parent width to avoid layout reflow every frame + if (!head._pw) { + var cv = document.getElementById('waveCv-' + sh.id); + head._pw = cv ? cv.width : 260; + } + head.style.transform = 'translateX(' + (sh.pos * head._pw).toFixed(1) + 'px)'; + } + } + } + + // Trigger fire events from C++ (visual flash + undo snapshot) + if (data.trigFired && data.trigFired.length) { + // Capture old values of affected params before they're updated + var oldVals = []; + data.trigFired.forEach(function (tf) { + var blk = findBlock(tf.id || tf); + if (blk && blk.targets) { + blk.targets.forEach(function (pid) { + var p = PMap[pid]; + if (p && !p.lk && !p.alk) oldVals.push({ id: pid, val: p.v }); + }); + } + }); + if (oldVals.length) pushMultiParamUndo(oldVals); + data.trigFired.forEach(function () { flashDot('midiD'); }); + + // ── WrongEQ trigger randomization is now handled by C++ ── + // C++ setParamDirect(-100, ...) writes directly to eqPoints atomics. + // weqReadback pushes updated values back to JS for canvas sync. + } + + // Sync hosted plugin parameter values into PMap + // C++ is the single source of truth for all param values + if (data.params && data.params.length) { + for (var i = 0; i < data.params.length; i++) { + var up = data.params[i]; + var p = PMap[up.id]; + if (!p) continue; + var isTouched = _touchedByUI.has(up.id); + // Skip VALUE update for params being dragged (prevents snapping) + // but always accept display text so we show real values during drag + if (!isTouched && Math.abs(p.v - up.v) > 0.001) { + p.v = up.v; + _modDirty = true; + } + // Always sync display text from C++ (plugin is source of truth) + if (up.disp !== undefined && up.disp !== p.disp) { + p.disp = up.disp; + _modDirty = true; + } + } + // Do NOT call refreshParamDisplay here — lane/morph processing + // below also sets _modDirty. One refresh at end of tick. + } + + // Auto-locate: scroll to and flash the touched param + if (autoLocate && data.touchedParam && !assignMode) { + var pid = data.touchedParam; + var now = Date.now(); + if (now - _lastLocateTime > 200) { + _lastLocateTime = now; + // Ensure the plugin card containing this param is expanded + var pp = PMap[pid]; + if (pp) { + for (var pbi = 0; pbi < pluginBlocks.length; pbi++) { + var pb = pluginBlocks[pbi]; + if (pb.id === pp.hostId && !pb.expanded) { + pb.expanded = true; + renderAllPlugins(); + break; + } + } + } + // Find the row and scroll + flash + var row = document.querySelector('.pr[data-pid="' + pid + '"]'); + if (!row && typeof scrollVirtualToParam === 'function') { + // Row not in DOM — virtual scroll; scroll to it first + scrollVirtualToParam(pid); + row = document.querySelector('.pr[data-pid="' + pid + '"]'); + } + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + row.classList.remove('touched'); + void row.offsetWidth; // force reflow to restart animation + row.classList.add('touched'); + } + } + } + // Morph pad playhead readback + if (data.morphHeads) { + for (var mi = 0; mi < data.morphHeads.length; mi++) { + var mh = data.morphHeads[mi]; + var mb = findBlock(mh.id); + if (mb && mb.mode === 'morph_pad' && mb.morphMode !== 'manual') { + mb.playheadX = mh.x; + mb.playheadY = mh.y; + var dot = document.getElementById('morphHead-' + mh.id); + if (dot) { + dot.style.left = (mh.x * 100) + '%'; + dot.style.top = ((1 - mh.y) * 100) + '%'; + } + // Sync SVG rotation with actual C++ rotation angle + if (mh.rot !== undefined) { + var svg = document.querySelector('.morph-pad[data-b="' + mh.id + '"]:not(.shapes-pad) .lfo-path-svg'); + if (svg) { + var deg = (mh.rot * 180 / Math.PI) * -1; + svg.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)'; + } + } + // ── IDW interpolation for arc animation (VISIBLE params only) ── + // Skip if playhead hasn't moved + var lastMpX = mb._lastMorphPadX, lastMpY = mb._lastMorphPadY; + if (lastMpX === undefined || Math.abs(mh.x - lastMpX) > 0.0001 || Math.abs(mh.y - lastMpY) > 0.0001) { + mb._lastMorphPadX = mh.x; + mb._lastMorphPadY = mh.y; + if (mb.snapshots && mb.snapshots.length > 0 && mb.targets && mb.targets.size > 0) { + if (!mb.morphPadOutputs) mb.morphPadOutputs = {}; + // Compute IDW weights (same algorithm as C++) + var radius = (mb.snapRadius != null ? mb.snapRadius : 100) / 100.0 * 0.48; + var numSnaps = Math.min(mb.snapshots.length, 12); + var mpWeights = []; + var mpTotalW = 0; + for (var si = 0; si < numSnaps; si++) { + var sdx = mh.x - mb.snapshots[si].x; + var sdy = mh.y - mb.snapshots[si].y; + var dist = Math.sqrt(sdx * sdx + sdy * sdy); + var w = 0; + if (dist < radius) { + var t = 1.0 - dist / radius; + w = t * t; + } + mpWeights.push(w); + mpTotalW += w; + } + if (mpTotalW > 0) { + for (var si = 0; si < numSnaps; si++) mpWeights[si] /= mpTotalW; + // Only compute for VISIBLE params + _rebuildVisPids(); + if (_visPidsCache && _visPidsCache.size > 0) { + _visPidsCache.forEach(function (pid) { + if (!mb.targets.has(pid)) return; + var mixed = 0; + for (var si = 0; si < numSnaps; si++) { + var sv = mb.snapshots[si].values && mb.snapshots[si].values[pid]; + if (sv !== undefined) mixed += mpWeights[si] * sv; + } + mb.morphPadOutputs[pid] = Math.max(0, Math.min(1, mixed)); + }); + _modDirty = true; + } + } + } + } + } + // Shapes block dot + SVG rotation + readout line + if (mb && (mb.mode === 'shapes' || mb.mode === 'shapes_range')) { + if (mh.out !== undefined) { + mb.shapeModOutput = mh.out; + // Mark all targets dirty so fill arc updates every frame + // (refreshParamDisplay filters by visibility internally) + if (mb.targets && mb.targets.size > 0) _modDirty = true; + } + var dot = document.getElementById('shapeHead-' + mh.id); + if (dot) { + dot.style.left = (mh.x * 100) + '%'; + dot.style.top = ((1 - mh.y) * 100) + '%'; + } + if (mh.rot !== undefined) { + var svg = document.querySelector('.shapes-pad[data-b="' + mh.id + '"] .lfo-path-svg'); + if (svg) { + var deg = (mh.rot * 180 / Math.PI) * -1; + svg.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)'; + } + } + // Update readout line position + var readout = document.getElementById('shapeReadout-' + mh.id); + if (readout) { + var tracking = mb.shapeTracking || 'horizontal'; + if (tracking === 'horizontal') { + readout.style.left = (mh.x * 100) + '%'; + } else if (tracking === 'vertical') { + readout.style.top = ((1 - mh.y) * 100) + '%'; + } else { + // Distance: circle radius = distance from center + var ddx = mh.x - 0.5, ddy = mh.y - 0.5; + var dist = Math.sqrt(ddx * ddx + ddy * ddy) * 2; // diameter as fraction + var pxSize = dist * 200; // pad is 200px + readout.style.width = pxSize + 'px'; + readout.style.height = pxSize + 'px'; + } + } + } + } + } + if (data.laneHeads) { + // Ensure visible-pids cache is fresh for lane interpolation + _rebuildVisPids(); + for (var li = 0; li < data.laneHeads.length; li++) { + var lh = data.laneHeads[li]; + var ph = document.getElementById('lph-' + lh.id + '-' + lh.li); + if (ph) { + // Cache parent width to avoid layout reflow every frame + if (!ph._wPx) { + var wrap = ph.parentElement; + ph._wPx = wrap ? wrap.clientWidth : 300; + } + ph.style.transform = 'translateX(' + (lh.ph * ph._wPx) + 'px)'; + } + var vi = document.getElementById('lvi-' + lh.id + '-' + lh.li); + if (vi) vi.style.height = (lh.val * 100) + '%'; + // Oneshot idle state: dim canvas when not active + var cwrap = document.getElementById('lcw-' + lh.id + '-' + lh.li); + if (cwrap) { + var isActive = lh.act !== false; + var lnbChk = findBlock(lh.id); + var isOneshot = lnbChk && lnbChk.lanes && lnbChk.lanes[lh.li] && lnbChk.lanes[lh.li].trigMode === 'oneshot'; + if (isOneshot) { + cwrap.style.opacity = isActive ? '1' : '0.4'; + if (ph) ph.style.opacity = isActive ? '1' : '0.3'; + } else if (cwrap.style.opacity !== '') { + cwrap.style.opacity = ''; + if (ph) ph.style.opacity = ''; + } + } + // Store readback on block for arc animation (per-PID) + var lnb = findBlock(lh.id); + if (lnb && lnb.mode === 'lane' && lnb.lanes && lnb.lanes[lh.li]) { + var lane = lnb.lanes[lh.li]; + // Store playhead position for overlay dynamic window + lane._phPos = lh.ph; + if (!lnb.laneModOutputs) lnb.laneModOutputs = {}; + if (lane.pids) { + if (lane.morphMode && lane.morphSnapshots && lane.morphSnapshots.length >= 2) { + // Skip if playhead hasn't moved (hold zone / static) + var lastPh = lane._lastMorphPh; + if (lastPh !== undefined && Math.abs(lh.ph - lastPh) < 0.0001) { + // No movement — skip the full 2000-param interpolation loop + } else { + lane._lastMorphPh = lh.ph; + // MORPH LANE: compute per-param interpolated values + var snaps = lane.morphSnapshots; + var pos = lh.ph; + var ld = (lane.depth != null ? lane.depth : 100) / 100.0; + // Find bracketing snapshots + var idx = snaps.length - 2; + for (var si = 0; si < snaps.length - 1; si++) { + if (pos <= snaps[si + 1].position) { idx = si; break; } + } + var snapA = snaps[idx], snapB = snaps[idx + 1]; + var gap = snapB.position - snapA.position; + var blend = 0.0; + if (gap > 0.0001) { + var holdA = gap * ((snapA.hold != null ? snapA.hold : 0.5) * 0.5); + var holdB = gap * ((snapB.hold != null ? snapB.hold : 0.5) * 0.5); + var morphZone = gap - holdA - holdB; + if (morphZone < 0) { holdA = gap * 0.5; holdB = gap * 0.5; morphZone = 0; } + var localPh = pos - snapA.position; + if (localPh <= holdA) blend = 0.0; + else if (localPh >= gap - holdB) blend = 1.0; + else { + blend = (localPh - holdA) / Math.max(0.0001, morphZone); + // Apply per-snapshot transition curve (destination defines arrival shape) + var curve = snapB.curve || 0; + if (curve === 0) blend = 0.5 - 0.5 * Math.cos(blend * Math.PI); // smooth + else if (curve === 2) blend = blend * blend; // sharp (ease-in) + else if (curve === 3) blend = 1 - (1 - blend) * (1 - blend); // late (ease-out) + // curve 1 = linear, no change + } + } + // Only interpolate VISIBLE pids — the rest are handled by C++ audio thread. + // laneModOutputs is only read by getModArcInfo() for visible params. + var _anyChanged = false; + if (_visPidsCache && _visPidsCache.size > 0) { + _visPidsCache.forEach(function (pid) { + // Skip pids not in this lane + var vA = snapA.values[pid], vB = snapB.values[pid]; + if (vA === undefined) return; + if (vB !== undefined) { + var morphed = vA + (vB - vA) * blend; + var sDepth = snapB.depth != null ? snapB.depth : 1.0; + morphed = 0.5 + (morphed - 0.5) * sDepth; + var sWarp = snapB.warp || 0; + if (Math.abs(sWarp) > 0.5) { + var w = sWarp * 0.01; + if (w > 0) { + var t = Math.tanh(w * 3 * (morphed * 2 - 1)); + morphed = 0.5 + 0.5 * t / Math.tanh(w * 3); + } else { + var aw = -w; + var centered = morphed * 2 - 1; + var sign = centered >= 0 ? 1 : -1; + morphed = 0.5 + 0.5 * sign * Math.pow(Math.abs(centered), 1 / (1 + aw * 3)); + } + } + var sSteps = snapB.steps || 0; + if (sSteps >= 2) { + morphed = Math.round(morphed * (sSteps - 1)) / (sSteps - 1); + } + lnb.laneModOutputs[pid] = Math.max(0, Math.min(1, morphed)); + _anyChanged = true; + } else { + lnb.laneModOutputs[pid] = Math.max(0, Math.min(1, 0.5 + (vA - 0.5) * ld)); + _anyChanged = true; + } + }); + } + if (_anyChanged && !_laneSkipDirty) _modDirty = true; + } // end else (playhead moved) + } else { + // CURVE LANE: only update visible params + var _curveChanged = false; + if (_visPidsCache && _visPidsCache.size > 0) { + _visPidsCache.forEach(function (cpid) { + if (lnb.laneModOutputs[cpid] !== lh.val) { + lnb.laneModOutputs[cpid] = lh.val; + _curveChanged = true; + } + }); + } + if (_curveChanged && !_laneSkipDirty) _modDirty = true; + } + } + // Dynamic overlay: if any other lane overlays this one + // and ratio < 1, check if we entered a new segment + for (var oi = 0; oi < lnb.lanes.length; oi++) { + if (oi === lh.li) continue; + var ol = lnb.lanes[oi]; + if (!ol._overlayLanes || ol._overlayLanes.indexOf(lh.li) < 0) continue; + var ratio = laneLoopBeats(ol) / laneLoopBeats(lane); + if (ratio >= 1) continue; // tiling doesn't need dynamic update + // Check if segment changed + var seg = Math.floor(lh.ph / ratio); + if (seg !== (lane._lastOverlaySeg || 0)) { + lane._lastOverlaySeg = seg; + if (!ol.collapsed) laneDrawCanvas(lnb, oi); + } + } + } + } + } + // ── WrongEQ modulation is now handled at audio rate by C++ ── + // C++ setParamDirect(-100, band*4+field, normValue) writes directly to eqPoints atomics. + // The weqReadback mechanism below syncs C++ values back to JS for canvas display. + + // ── WrongEQ readback: sync C++ eqPoints to JS wrongEqPoints ── + if (data.weqReadback && data.weqReadback.length && typeof wrongEqPoints !== 'undefined') { + var _weqRbChanged = false; + for (var wi = 0; wi < data.weqReadback.length && wi < wrongEqPoints.length; wi++) { + var rb = data.weqReadback[wi]; + var pt = wrongEqPoints[wi]; + // Update JS point position from C++ freq/gain + if (typeof weqFreqToX === 'function') { + var newX = weqFreqToX(rb.freq); + if (Math.abs(pt.x - newX) > 0.0001) { + pt.x = newX; + _weqRbChanged = true; + } + } + if (typeof weqDBtoY === 'function') { + var newY = weqDBtoY(rb.gain); + if (Math.abs(pt.y - newY) > 0.001) { + pt.y = newY; + _weqRbChanged = true; + } + } + if (rb.q !== undefined && Math.abs((pt.q || 0.707) - rb.q) > 0.001) { + pt.q = rb.q; + _weqRbChanged = true; + } + if (rb.drift !== undefined && Math.abs((pt.drift || 0) - rb.drift) > 0.5) { + pt.drift = rb.drift; + _weqRbChanged = true; + } + } + if (_weqRbChanged) { + // Update virtual param displays + if (typeof weqSyncVirtualParams === 'function') weqSyncVirtualParams(); + // Redraw canvas if visible + if (typeof weqDrawCanvas === 'function') { + var _rbOverlay = document.getElementById('weqOverlay'); + if (_rbOverlay && _rbOverlay.classList.contains('visible')) weqDrawCanvas(); + } + } + } + + // ── WrongEQ global param readback (depth, warp, steps, tilt from C++ modulation) ── + if (data.weqGlobals && typeof weqGlobalDepth !== 'undefined') { + var g = data.weqGlobals; + var _gChanged = false; + if (g.depth !== undefined && Math.abs(weqGlobalDepth - g.depth) > 0.5) { + weqGlobalDepth = Math.round(g.depth); + _gChanged = true; + } + if (g.warp !== undefined && Math.abs(weqGlobalWarp - g.warp) > 0.5) { + weqGlobalWarp = Math.round(g.warp); + _gChanged = true; + } + if (g.steps !== undefined && weqGlobalSteps !== g.steps) { + weqGlobalSteps = g.steps; + _gChanged = true; + } + if (g.tilt !== undefined && Math.abs(weqGlobalTilt - g.tilt) > 0.5) { + weqGlobalTilt = Math.round(g.tilt); + _gChanged = true; + } + if (_gChanged) { + if (typeof weqSyncVirtualParams === 'function') weqSyncVirtualParams(); + if (typeof weqDrawCanvas === 'function') { + var _gOverlay = document.getElementById('weqOverlay'); + if (_gOverlay && _gOverlay.classList.contains('visible')) weqDrawCanvas(); + } + } + } + + // SINGLE refresh per tick — unconditional. + // Modulated params need continuous arc updates even when + // nothing "changed" (C++ moves values the JS side must display). + refreshParamDisplay(); + } + }); + return true; + } + return false; +} +if (!setupRtDataListener()) { + var rtRetry = setInterval(function () { if (setupRtDataListener()) clearInterval(rtRetry); }, 100); +} + +// ============================================================ +// CRASH NOTIFICATION LISTENER +// Shows a toast when a hosted plugin crashes during audio processing +// ============================================================ +function setupCrashListener() { + if (window.__JUCE__ && window.__JUCE__.backend) { + window.__JUCE__.backend.addEventListener('__plugin_crashed__', function (data) { + if (!data) return; + showCrashToast(data.pluginId, data.pluginName, data.reason); + }); + return true; + } + return false; +} + +function showCrashToast(pluginId, pluginName, reason) { + // Create toast container if it doesn't exist + var container = document.getElementById('crash-toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'crash-toast-container'; + container.style.cssText = 'position:fixed;top:12px;right:12px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;'; + document.body.appendChild(container); + } + + var toast = document.createElement('div'); + toast.className = 'crash-toast'; + toast.style.cssText = 'pointer-events:auto;background:linear-gradient(135deg,#4a1010,#2a0808);border:1px solid #ff3333;border-radius:8px;padding:12px 16px;color:#fff;font-size:12px;font-family:inherit;box-shadow:0 4px 24px rgba(255,0,0,0.3);display:flex;align-items:center;gap:10px;animation:crashSlideIn 0.3s ease-out;max-width:380px;'; + + var icon = document.createElement('span'); + icon.textContent = '\u26A0'; + icon.style.cssText = 'font-size:20px;color:#ff4444;flex-shrink:0;'; + + var textDiv = document.createElement('div'); + textDiv.style.cssText = 'flex:1;'; + + var title = document.createElement('div'); + title.style.cssText = 'font-weight:600;font-size:13px;color:#ff6666;margin-bottom:3px;'; + title.textContent = pluginName + ' crashed'; + + var body = document.createElement('div'); + body.style.cssText = 'font-size:11px;color:#ccc;line-height:1.3;'; + body.textContent = 'Auto-bypassed to protect your session.'; + + textDiv.appendChild(title); + textDiv.appendChild(body); + + var btnWrap = document.createElement('div'); + btnWrap.style.cssText = 'display:flex;gap:6px;flex-shrink:0;'; + + var reEnBtn = document.createElement('button'); + reEnBtn.textContent = 'Re-enable'; + reEnBtn.style.cssText = 'background:#333;border:1px solid #ff6666;color:#ff8888;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px;font-family:inherit;'; + reEnBtn.onmouseenter = function () { reEnBtn.style.background = '#4a1515'; }; + reEnBtn.onmouseleave = function () { reEnBtn.style.background = '#333'; }; + reEnBtn.onclick = function () { + var fn = window.__juceGetNativeFunction('resetPluginCrash'); + if (fn) fn(pluginId); + toast.style.animation = 'crashSlideOut 0.2s ease-in forwards'; + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); + }; + + var dismissBtn = document.createElement('button'); + dismissBtn.textContent = '\u2715'; + dismissBtn.style.cssText = 'background:none;border:none;color:#888;cursor:pointer;font-size:16px;padding:0 4px;line-height:1;'; + dismissBtn.onclick = function () { + toast.style.animation = 'crashSlideOut 0.2s ease-in forwards'; + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); + }; + + btnWrap.appendChild(reEnBtn); + btnWrap.appendChild(dismissBtn); + + toast.appendChild(icon); + toast.appendChild(textDiv); + toast.appendChild(btnWrap); + container.appendChild(toast); + + // Auto-dismiss after 10s + setTimeout(function () { + if (toast.parentNode) { + toast.style.animation = 'crashSlideOut 0.2s ease-in forwards'; + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); + } + }, 10000); +} + +// Inject crash toast animation keyframes +(function () { + var style = document.createElement('style'); + style.textContent = '@keyframes crashSlideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes crashSlideOut{from{transform:translateX(0);opacity:1}to{transform:translateX(100%);opacity:0}}'; + document.head.appendChild(style); +})(); + +if (!setupCrashListener()) { + var crashRetry = setInterval(function () { if (setupCrashListener()) clearInterval(crashRetry); }, 100); +} diff --git a/plugins/ModularRandomizer/Source/ui/public/js/state.js b/plugins/ModularRandomizer/Source/ui/public/js/state.js new file mode 100644 index 0000000..89d4ff9 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/state.js @@ -0,0 +1,71 @@ +// ============================================================ +// GLOBAL STATE DECLARATIONS +// Shared variables used across all modules +// ============================================================ + +// Block color palette +var BCOLORS = ['#C8983C', '#2D6B3F', '#8B3030', '#5880A0', '#A87030', '#507860', '#986050', '#688848']; +function bColor(i) { return BCOLORS[i % BCOLORS.length]; } + +// Plugin scanner state +var scannedPlugins = []; +var scanPaths = []; // populated from backend via getDefaultScanPaths +var scanInProgress = false; + +// Fetch platform-appropriate default scan paths from C++ backend +(function initScanPaths() { + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('getDefaultScanPaths'); + if (fn) { + fn().then(function (paths) { + if (paths && paths.length && scanPaths.length === 0) { + scanPaths = paths.slice(); + } + }); + } + } + // Fallback if backend not ready yet — will be overridden by getFullState restore + if (scanPaths.length === 0) { + if (navigator.platform && navigator.platform.indexOf('Mac') >= 0) { + scanPaths = ['/Library/Audio/Plug-Ins/VST3']; + } else if (navigator.platform && navigator.platform.indexOf('Linux') >= 0) { + scanPaths = ['~/.vst3', '/usr/lib/vst3']; + } else { + scanPaths = ['C:\\Program Files\\Common Files\\VST3', 'C:\\Program Files\\VSTPlugins']; + } + } +})(); + +// Plugin rack state +var PMap = {}, pluginBlocks = [], plugBC = 0; + +// Logic blocks state +var blocks = [], bc = 0, actId = null, assignMode = null, ctxP = null; + +// Routing mode: 0=sequential, 1=parallel, 2=wrongeq +var routingMode = 0; +// WrongEQ state (mode 2) +var wrongEqPoints = []; // Array of {x, y, busId, seg} +var BUS_COLORS = ['#17B2A0', '#4ECDC4', '#FF6B6B', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD']; +// Per-bus mixer state (parallel mode) +var busVolumes = [1, 1, 1, 1, 1, 1, 1]; +var busMutes = [false, false, false, false, false, false, false]; +var busCollapsed = [false, false, false, false, false, false, false]; +var busSolos = [false, false, false, false, false, false, false]; + +// Real-time data from processor +var rtData = { rms: 0, scRms: 0, bpm: 120, playing: false, ppq: 0, midi: [] }; + +// MIDI note names +var NN = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; +function midiName(n) { return NN[n % 12] + (Math.floor(n / 12) - 1); } + +// Multi-select for drag/right-click assign +var selectedParams = new Set(); + +// Utility lookups +function findBlock(id) { for (var i = 0; i < blocks.length; i++) { if (blocks[i].id === id) return blocks[i]; } return null; } +function allParams() { var a = []; pluginBlocks.forEach(function (pb) { a = a.concat(pb.params); }); return a; } +function paramPluginName(pid) { var pi = parseInt(pid.split(':')[0]); for (var i = 0; i < pluginBlocks.length; i++) { if (pluginBlocks[i].id === pi) return pluginBlocks[i].name; } return '?'; } +// HTML escape for safe innerHTML injection of user-supplied strings +function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } diff --git a/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js b/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js new file mode 100644 index 0000000..9dbe3d3 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js @@ -0,0 +1,2090 @@ +// ============================================================ +// THEME SYSTEM +// Theme definitions and switching logic +// ============================================================ + +var THEMES = { + studio_midnight: { + name: 'Studio Midnight', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + // Deep blue-grey — like a dimmed control room. + '--bg-app': '#0C0E14', + '--bg-panel': '#141822', + '--bg-cell': '#1A1F2C', + '--bg-cell-hover': '#232938', + '--bg-inset': '#080A10', + + // ── BORDERS ─────────────────────────────────────────────── + '--border': '#2A3040', + '--border-strong': '#3A4258', + '--border-focus': '#7CA8D9', // soft steel-blue focus — visible but not harsh + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#E8ECF2', // warm white — less eye strain than pure #FFF + '--text-secondary': '#8A94A8', + '--text-muted': '#6B7488', + '--input-text': '#E8ECF2', + + // ── ACCENT ──────────────────────────────────────────────── + // Warm amber — like a lit VU meter needle or channel strip LED. + '--accent': '#E8A840', + '--accent-hover': '#F0BC60', + '--accent-light': 'rgba(232,168,64,0.10)', + '--accent-border': 'rgba(232,168,64,0.30)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#44DD88', + + '--locked-bg': 'rgba(220,60,80,0.12)', + '--locked-border': 'rgba(220,60,80,0.35)', + '--locked-icon': '#DC3C50', + + '--auto-lock-bg': 'rgba(232,168,64,0.10)', + '--auto-lock-border': 'rgba(232,168,64,0.28)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Slightly desaturated vs Pitch Black — sit better on blue-grey. + '--rand-color': '#4A9EE0', // steel blue + '--env-color': '#E08840', // warm amber-orange + '--sample-color': '#44BB70', // studio green (think SSL meters) + '--morph-color': '#40B8A0', // teal + '--shapes-color': '#D85040', // muted red — like a clip LED + + '--thumb-color': '#8A94A8', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#1A1F2C', + '--knob-value': '#E8A840', // amber sweep — classic VU aesthetic + '--knob-dot': '#E8ECF2', + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#0C0E14', + '--pf-border': '#2A3040', + '--pf-text': '#6B7488', + + '--ph-bg': '#0C0E14', + '--ph-border': '#2A3040', + '--ph-text': '#E8ECF2', + + // ── LINKED KNOBS (mode-tinted dark bg) ─────────────────── + '--lk-rand-track': '#0E1620', '--lk-rand-value': '#4A9EE0', '--lk-rand-dot': '#6CB4EA', + '--lk-env-track': '#1C1208', '--lk-env-value': '#E08840', '--lk-env-dot': '#E8A060', + '--lk-smp-track': '#0C1A10', '--lk-smp-value': '#44BB70', '--lk-smp-dot': '#66CC8C', + '--lk-morph-track': '#0C1C18', '--lk-morph-value': '#40B8A0', '--lk-morph-dot': '#60CCBB', + '--lk-shapes-track': '#1C0C0A', '--lk-shapes-value': '#D85040', '--lk-shapes-dot': '#E07060', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(74,158,224,0.12)', + '--si-env-bg': 'rgba(224,136,64,0.12)', + '--si-smp-bg': 'rgba(68,187,112,0.12)', + '--si-morph-bg': 'rgba(64,184,160,0.12)', + '--si-shapes-bg': 'rgba(216,80,64,0.12)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#0C0E14', + '--fire-active-bg': '#E8A840', // amber fire — unmistakable + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#2A3040', + '--slider-thumb': '#E8A840', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#0C0E14', + '--bar-fill': '#E8A840', + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#0C0E14', + '--card-btn-border': '#2A3040', + '--card-btn-text': '#6B7488', + '--card-btn-hover': '#141822', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#E8A840', + '--snap-ring-opacity': '0.55', + + // ── LANE / AUTOMATION ───────────────────────────────────── + '--lane-color': '#E8A840', // amber curve — reads like an envelope on a console + '--lane-grid': 'rgba(200,210,230,0.05)', + '--lane-grid-label': 'rgba(200,210,230,0.20)', + '--lane-playhead': 'rgba(232,236,242,0.90)', + '--lane-active': '#44BB70', + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#4A9EE0', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#3A4258', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#C03030', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#D4A820', '--bus-solo-text': '#000000', + '--bus-group-tint': '12%', + '--bus-header-tint': '20%', + '--bus-badge-text': '#000000', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#0A1A10,#060E08)', + '--toast-success-border': '#44BB70', + '--toast-error-bg': 'linear-gradient(135deg,#200C0A,#140606)', + '--toast-error-border': '#D85040', + '--toast-info-bg': 'linear-gradient(135deg,#10141C,#080A10)', + '--toast-info-border': '#4A9EE0', + '--toast-text': '#E8ECF2', + + '--preset-flash-color': '#E8A840', + '--preset-flash-glow': 'rgba(232,168,64,0.40)', + }, + + bcolors: [ + '#E8ECF2', // warm white — primary + '#4A9EE0', // steel blue — rand + '#E08840', // amber — env + '#44BB70', // green — sample + '#40B8A0', // teal — morph + '#D85040', // red — shapes + '#6B7488', // muted grey + '#3A4258', // dark slate + ], + + busColors: [ + '#3A4258', + '#E8ECF2', + '#4A9EE0', + '#E08840', + '#44BB70', + '#40B8A0', + '#D85040', + '#6B7488', + ], + + swatches: ['#0C0E14', '#1A1F2C', '#E8A840', '#4A9EE0', '#D85040'], + }, + // ═══════════════════════════════════════════════════════════ + // MODULAR RANDOMIZER — DAW HIERARCHY THEME + // + // LAYER MODEL (darkest → brightest): + // L0 #0C1416 app chrome / background — recedes completely + // L1 #182022 panel surfaces — plugin blocks, block headers + // L2 #1E2A2C content areas — lane canvas bg, param lists + // L3 #263436 interactive cells — hovered rows, active cells + // L4 #0A1214 inset / sunken — dark wells, below-surface + // + // COLOR ROLES (one meaning per color): + // #00C8B4 teal → primary action / brand / rand mode + // #E8A030 amber → envelope mode / time-based / warm + // #4AC8E8 sky → sample mode / digital / cool + // #A0D860 lime → morph mode / organic transition + // #E87040 coral → shapes mode / geometric + // #44DD66 green → status good / running / active handle + // #EE5555 red → error / locked / mute + // #C8A000 yellow → solo / warning + // #FFFFFF white → playhead (highest-contrast element when playing) + // ═══════════════════════════════════════════════════════════ + + daw_teal: { + name: 'DAW Teal', + vars: { + // BACKGROUNDS + '--bg-app': '#0C1416', + '--bg-panel': '#182022', + '--bg-cell': '#1E2A2C', + '--bg-cell-hover': '#263436', + '--bg-inset': '#0A1214', + + // BORDERS + '--border': '#627476', + '--border-strong': '#748E90', + '--border-focus': '#00C8B4', + + // TEXT + '--text-primary': '#E8F4F4', + '--text-secondary': '#8ABCBE', + '--text-muted': '#7A9496', + '--input-text': '#E8F4F4', + + // ACCENT + '--accent': '#00C8B4', + '--accent-hover': '#00E8D0', + '--accent-light': 'rgba(0,200,180,0.10)', + '--accent-border': 'rgba(0,200,180,0.35)', + + // STATUS + '--midi-dot': '#44DD66', + '--locked-bg': 'rgba(238,85,85,0.12)', + '--locked-border': 'rgba(238,85,85,0.35)', + '--locked-icon': '#EE5555', + '--auto-lock-bg': 'rgba(232,160,48,0.12)', + '--auto-lock-border': 'rgba(232,160,48,0.35)', + + // MODE COLORS + '--rand-color': '#00C8B4', + '--env-color': '#E8A030', + '--sample-color': '#4AC8E8', + '--morph-color': '#A0D860', + '--shapes-color': '#E87040', + + '--thumb-color': '#B8D0D0', + + // KNOBS + '--knob-track': '#1A2C2E', + '--knob-value': '#00C8B4', + '--knob-dot': '#80EEE0', + + // PLUGIN CARD + '--pf-bg': '#0E1A1C', + '--pf-border': '#627476', + '--pf-text': '#8ABCBE', + '--ph-bg': '#0E1A1C', + '--ph-border': '#627476', + '--ph-text': '#E8F4F4', + + // PER-MODE LINKED KNOBS + '--lk-rand-track': '#0E1E1C', '--lk-rand-value': '#00C8B4', '--lk-rand-dot': '#60EED8', + '--lk-env-track': '#1E1808', '--lk-env-value': '#E8A030', '--lk-env-dot': '#FFCC70', + '--lk-smp-track': '#0C1820', '--lk-smp-value': '#4AC8E8', '--lk-smp-dot': '#90E4F8', + '--lk-morph-track': '#141C08', '--lk-morph-value': '#A0D860', '--lk-morph-dot': '#C8F080', + '--lk-shapes-track': '#1C1008', '--lk-shapes-value': '#E87040', '--lk-shapes-dot': '#FFA070', + + // SOURCE INDICATORS + '--si-rand-bg': 'rgba(0,200,180,0.12)', + '--si-env-bg': 'rgba(232,160,48,0.12)', + '--si-smp-bg': 'rgba(74,200,232,0.12)', + '--si-morph-bg': 'rgba(160,216,96,0.12)', + '--si-shapes-bg': 'rgba(232,112,64,0.12)', + + // FIRE BUTTON + '--fire-text': '#0A1414', + '--fire-active-bg': '#00C8B4', + + // ARC / RANGE + + + // SLIDER + '--slider-track': '#3A5C60', + '--slider-thumb': '#D0E8E8', + + // PARAM BAR + '--bar-track': '#0E1E20', + '--bar-fill': '#00A898', + + // CARD BUTTONS + '--card-btn-bg': '#0E1A1C', + '--card-btn-border': '#627476', + '--card-btn-text': '#8ABCBE', + '--card-btn-hover': '#182428', + + // SNAP RING + '--snap-ring-color': '#00C8B4', + '--snap-ring-opacity': '0.55', + + // LANE / AUTOMATION + '--lane-color': '#4AC8E8', + '--lane-grid': 'rgba(232,244,244,0.06)', + '--lane-grid-label': 'rgba(232,244,244,0.18)', + '--lane-playhead': 'rgba(255,255,255,0.80)', + '--lane-active': '#44DD66', + + // RANGE ARC + '--range-arc': '#E8A040', + + // SCROLLBAR + '--scrollbar-thumb': '#627476', + '--scrollbar-track': 'transparent', + + // BUS CONTROLS + '--bus-mute-bg': '#CC2222', + '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#C8A000', + '--bus-solo-text': '#000000', + '--bus-group-tint': '12%', + '--bus-header-tint': '22%', + '--bus-badge-text': '#0A1414', + + // TOAST / FLASH + '--toast-success-bg': 'linear-gradient(135deg,#0A2018,#061410)', + '--toast-success-border': '#44DD66', + '--toast-error-bg': 'linear-gradient(135deg,#2A0808,#1A0404)', + '--toast-error-border': '#EE5555', + '--toast-info-bg': 'linear-gradient(135deg,#081820,#041014)', + '--toast-info-border': '#00C8B4', + '--toast-text': '#E8F4F4', + '--preset-flash-color': '#44DD66', + '--preset-flash-glow': 'rgba(68,221,102,0.45)', + '--drag-highlight': '#00C8B4', + }, + + bcolors: [ + '#00C8B4', + '#E8A030', + '#4AC8E8', + '#A0D860', + '#E87040', + '#44DD66', + '#8ABCBE', + '#C8D8D8', + ], + + busColors: [ + '#627476', + '#00C8B4', + '#E87040', + '#4AC8E8', + '#E8A030', + '#A0D860', + '#D06CC0', + ], + + swatches: ['#0C1416', '#1E2A2C', '#E8F4F4', '#00C8B4', '#E8A030'] + }, + + // ───────────────────────────────────────────────────────────────── + // GREY + // Flat light theme. Steel blue as the single accent color. + // No purple, no pink. Every mode gets a distinct muted hue. + // ───────────────────────────────────────────────────────────────── + grey: { + name: 'Grey', + vars: { + '--bg-app': '#C8C8C8', + '--bg-panel': '#E8E8E8', + '--bg-cell': '#F4F4F4', + '--bg-cell-hover': '#EEEEEE', + '--bg-inset': '#DEDEDE', + + '--border': '#AAAAAA', + '--border-strong': '#787878', + '--border-focus': '#1A1A1A', + + '--text-primary': '#0A0A0A', + '--text-secondary': '#3A3A3A', + // FIX: was #787878 — fails on both panel (3.60) and cell (4.01). Now #606060 = 5.13/5.72 ✅ + '--text-muted': '#606060', + '--input-text': '#0A0A0A', + + '--accent': '#1F5FA6', + '--accent-hover': '#174E8C', + '--accent-light': 'rgba(31,95,166,0.10)', + '--accent-border': 'rgba(31,95,166,0.35)', + + '--locked-bg': 'rgba(160,20,20,0.10)', + '--locked-border': 'rgba(160,20,20,0.30)', + '--locked-icon': '#991212', + + '--auto-lock-bg': 'rgba(140,100,0,0.10)', + '--auto-lock-border': 'rgba(140,100,0,0.28)', + + '--midi-dot': '#1F5FA6', + '--rand-color': '#1F5FA6', + + // Mode colors — all distinct hues, all 4.5:1+ on bg-panel #E8E8E8 + '--env-color': '#1A6E5A', // deep teal-green 5.01:1 ✅ + // FIX: was #C05000 — fails at 3.91:1. Now #A04000 = 5.31:1 ✅ + '--sample-color': '#A04000', // burnt sienna 5.31:1 ✅ + // FIX: was #1F5FA6 — same as rand/accent, no distinction. Now olive = 6.42:1 ✅ + '--morph-color': '#425830', // muted olive 6.42:1 ✅ + '--shapes-color': '#2A2A2A', // near-black 11.71:1 ✅ + + '--thumb-color': '#F4F4F4', + + '--knob-track': '#AAAAAA', + '--knob-value': '#1F5FA6', + '--knob-dot': '#3A7CC4', + + '--pf-bg': '#DEDEDE', + '--pf-border': '#AAAAAA', + // FIX: was #646464 — fails at 4.40:1 on #DEDEDE. Now #505050 = 5.99:1 ✅ + '--pf-text': '#505050', + + '--ph-bg': '#E8E8E8', + '--ph-border': '#AAAAAA', + '--ph-text': '#0A0A0A', + + '--lk-rand-track': '#AAAAAA', '--lk-rand-value': '#1F5FA6', '--lk-rand-dot': '#3A7CC4', + '--lk-env-track': '#8AB0A8', '--lk-env-value': '#1A6E5A', '--lk-env-dot': '#2A8870', + // FIX: was #7A48A0 (purple) — inconsistent with sample-color orange. Now matches #A04000 + '--lk-smp-track': '#B88858', '--lk-smp-value': '#A04000', '--lk-smp-dot': '#C86820', + // FIX: was #1F5FA6 — same blue as rand. Now olive to match morph-color + '--lk-morph-track': '#8A9870', '--lk-morph-value': '#425830', '--lk-morph-dot': '#5A7040', + '--lk-shapes-track': '#AAAAAA', '--lk-shapes-value': '#2A2A2A', '--lk-shapes-dot': '#3A3A3A', + + '--si-rand-bg': 'rgba(31,95,166,0.14)', + '--si-env-bg': 'rgba(26,110,90,0.14)', + // FIX: was rgba(192,80,0,0.12) but sample was still old value — updated to match #A04000 + '--si-smp-bg': 'rgba(160,64,0,0.12)', + // FIX: was rgba(31,95,166,0.14) — same as rand. Now olive + '--si-morph-bg': 'rgba(66,88,48,0.14)', + '--si-shapes-bg': 'rgba(10,10,10,0.10)', + + '--fire-text': '#F4F4F4', + '--fire-active-bg': '#1A4F8A', + + + + '--slider-track': '#909090', + '--slider-thumb': '#F0F0F0', + '--bar-track': '#C8C8C8', + '--bar-fill': '#2A5A8A', + + '--card-btn-bg': '#E8E8E8', + '--card-btn-border': '#AAAAAA', + '--card-btn-text': '#3A3A3A', + '--card-btn-hover': '#F0F0F0', + + '--snap-ring-color': '#1F5FA6', + '--snap-ring-opacity': '0.45', + + '--lane-color': '#1A6E5A', + '--lane-grid': 'rgba(10,10,10,0.08)', + '--lane-grid-label': 'rgba(10,10,10,0.20)', + '--lane-playhead': 'rgba(10,10,10,0.60)', + '--lane-active': '#1F5FA6', + + '--range-arc': '#E87040', + + '--scrollbar-thumb': '#AAAAAA', + '--scrollbar-track': '#D8D8D8', + + '--bus-mute-bg': '#AA1818', '--bus-mute-text': '#fff', + // FIX: was #fff — fails at 3.30:1 on #B08800. Black text = 6.37:1 ✅ + '--bus-solo-bg': '#B08800', '--bus-solo-text': '#000000', + '--bus-group-tint': '8%', '--bus-header-tint': '14%', + '--bus-badge-text': '#0A0A0A', + '--toast-success-bg': 'linear-gradient(135deg,#E0F0E0,#D0E8D0)', '--toast-success-border': '#1F5FA6', + '--toast-error-bg': 'linear-gradient(135deg,#F4E0E0,#E8D0D0)', '--toast-error-border': '#991212', + '--toast-info-bg': 'linear-gradient(135deg,#E0E8F4,#D4DCE8)', '--toast-info-border': '#1F5FA6', + '--toast-text': '#0A0A0A', + '--preset-flash-color': '#1F5FA6', '--preset-flash-glow': 'rgba(31,95,166,0.4)', + '--drag-highlight': '#1F5FA6' + }, + bcolors: [ + '#1F5FA6', // steel blue — rand + '#1A6E5A', // deep teal — env + '#A04000', // burnt sienna — sample (fixed from #C05000) + '#2A2A2A', // near black — shapes + '#4A8AB0', // sky steel + '#425830', // olive — morph (fixed from duplicate steel blue) + '#8A6030', // bronze + '#0A4A7A', // deep navy + ], + busColors: ['#787878', '#1F5FA6', '#A04000', '#1A6E5A', '#4A8AB0', '#B08800', '#8A6030', '#425830'], + swatches: ['#C8C8C8', '#E8E8E8', '#0A0A0A', '#1F5FA6', '#1A6E5A'] + }, + + earth_tone: { + name: 'Earth Tone', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + // Warm dark brown — like stained walnut or a dimmed tube amp chassis. + '--bg-app': '#12100C', + '--bg-panel': '#1C1814', + '--bg-cell': '#252019', + '--bg-cell-hover': '#302A22', + '--bg-inset': '#0A0908', + + // ── BORDERS ─────────────────────────────────────────────── + '--border': '#3A3228', + '--border-strong': '#504538', + '--border-focus': '#C8A870', // warm gold focus ring + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#E8E0D4', // parchment white — warm, easy on the eyes + '--text-secondary': '#A0947E', + '--text-muted': '#887A66', + '--input-text': '#E8E0D4', + + // ── ACCENT ──────────────────────────────────────────────── + // Warm ochre gold — like aged brass hardware or a lit filament. + '--accent': '#C8A050', + '--accent-hover': '#D8B468', + '--accent-light': 'rgba(200,160,80,0.10)', + '--accent-border': 'rgba(200,160,80,0.30)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#6AAD5C', // muted organic green + + '--locked-bg': 'rgba(180,60,50,0.12)', + '--locked-border': 'rgba(180,60,50,0.35)', + '--locked-icon': '#B43C32', + + '--auto-lock-bg': 'rgba(200,160,80,0.10)', + '--auto-lock-border': 'rgba(200,160,80,0.28)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Natural palette — clay, moss, copper, rust. Every color could + // come from pigment or mineral. + '--rand-color': '#5C98B8', // slate blue — like weathered copper patina + '--env-color': '#C87830', // burnt sienna + '--sample-color': '#6AAD5C', // moss green + '--morph-color': '#5CA888', // sage / eucalyptus + '--shapes-color': '#B85040', // terracotta red + + '--thumb-color': '#A0947E', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#252019', + '--knob-value': '#C8A050', // ochre sweep + '--knob-dot': '#E8E0D4', + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#12100C', + '--pf-border': '#3A3228', + '--pf-text': '#887A66', + + '--ph-bg': '#12100C', + '--ph-border': '#3A3228', + '--ph-text': '#E8E0D4', + + // ── LINKED KNOBS (mode-tinted dark bg) ─────────────────── + '--lk-rand-track': '#101820', '--lk-rand-value': '#5C98B8', '--lk-rand-dot': '#78ACC8', + '--lk-env-track': '#1E1208', '--lk-env-value': '#C87830', '--lk-env-dot': '#D89450', + '--lk-smp-track': '#101A0E', '--lk-smp-value': '#6AAD5C', '--lk-smp-dot': '#84BE76', + '--lk-morph-track': '#0E1C18', '--lk-morph-value': '#5CA888', '--lk-morph-dot': '#78BCA0', + '--lk-shapes-track': '#1E0E0C', '--lk-shapes-value': '#B85040', '--lk-shapes-dot': '#CC6858', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(92,152,184,0.12)', + '--si-env-bg': 'rgba(200,120,48,0.12)', + '--si-smp-bg': 'rgba(106,173,92,0.12)', + '--si-morph-bg': 'rgba(92,168,136,0.12)', + '--si-shapes-bg': 'rgba(184,80,64,0.12)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#12100C', + '--fire-active-bg': '#C8A050', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#3A3228', + '--slider-thumb': '#C8A050', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#12100C', + '--bar-fill': '#C8A050', + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#12100C', + '--card-btn-border': '#3A3228', + '--card-btn-text': '#887A66', + '--card-btn-hover': '#1C1814', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#C8A050', + '--snap-ring-opacity': '0.55', + + // ── LANE / AUTOMATION ───────────────────────────────────── + '--lane-color': '#C8A050', // ochre curve + '--lane-grid': 'rgba(232,224,212,0.05)', + '--lane-grid-label': 'rgba(232,224,212,0.18)', + '--lane-playhead': 'rgba(232,224,212,0.88)', + '--lane-active': '#6AAD5C', + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#5C98B8', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#504538', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#A83830', '--bus-mute-text': '#E8E0D4', + '--bus-solo-bg': '#C8A050', '--bus-solo-text': '#12100C', + '--bus-group-tint': '12%', + '--bus-header-tint': '20%', + '--bus-badge-text': '#12100C', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#10180C,#080E06)', + '--toast-success-border': '#6AAD5C', + '--toast-error-bg': 'linear-gradient(135deg,#1E0E0C,#140806)', + '--toast-error-border': '#B85040', + '--toast-info-bg': 'linear-gradient(135deg,#141210,#0C0A08)', + '--toast-info-border': '#C8A050', + '--toast-text': '#E8E0D4', + + '--preset-flash-color': '#6AAD5C', + '--preset-flash-glow': 'rgba(106,173,92,0.40)', + }, + + bcolors: [ + '#E8E0D4', // parchment — primary + '#5C98B8', // patina blue — rand + '#C87830', // burnt sienna — env + '#6AAD5C', // moss — sample + '#5CA888', // sage — morph + '#B85040', // terracotta — shapes + '#887A66', // warm grey + '#504538', // dark umber + ], + + busColors: [ + '#504538', + '#E8E0D4', + '#5C98B8', + '#C87830', + '#6AAD5C', + '#5CA888', + '#B85040', + '#887A66', + ], + + swatches: ['#12100C', '#252019', '#C8A050', '#6AAD5C', '#B85040'], + }, + + medical_vintage: { + name: 'Medical Vintage', + vars: { + '--bg-app': '#1E1E1C', '--bg-panel': '#2C2C2A', '--bg-cell': '#383835', + '--bg-cell-hover': '#424240', '--bg-inset': '#181816', + '--border': '#505050', '--border-strong': '#787870', '--border-focus': '#C8B890', + + '--text-primary': '#F0E8D0', + '--text-secondary': '#C0B090', + // FIX: was #8A8070 — fails on panel (3.60) and cell (3.03). Now #A8A090 = 5.39/4.54 ✅ + '--text-muted': '#A8A090', + '--input-text': '#F0E8D0', + + '--accent': '#B8C840', '--accent-hover': '#A0B030', + '--accent-light': 'rgba(184,200,64,0.14)', '--accent-border': '#8A9A30', + + '--locked-bg': '#38201E', '--locked-border': '#783030', + '--locked-icon': '#DD6644', + '--auto-lock-bg': '#38280E', '--auto-lock-border': '#785020', + + '--midi-dot': '#88CC44', + '--rand-color': '#B8C840', + '--env-color': '#D4943A', + '--sample-color': '#7ABCCC', + // FIX: was #C8785A — identical to shapes-color, no visual distinction between modes. + // Now phosphor green — thematically correct (CRT oscilloscope trace on vintage instruments) + '--morph-color': '#88CC44', + '--shapes-color': '#C8785A', + + '--thumb-color': '#D8D0B8', + '--knob-track': '#202020', '--knob-value': '#B8C840', '--knob-dot': '#D4E050', + + '--pf-bg': '#141412', + '--pf-border': '#505050', + '--pf-text': '#B0A888', + '--ph-bg': '#141412', '--ph-border': '#505050', '--ph-text': '#F0E8D0', + + '--lk-rand-track': '#383A24', '--lk-rand-value': '#B8C840', '--lk-rand-dot': '#D4E050', + '--lk-env-track': '#382E18', '--lk-env-value': '#D4943A', '--lk-env-dot': '#EAB060', + '--lk-smp-track': '#1E2E38', '--lk-smp-value': '#7ABCCC', '--lk-smp-dot': '#A0D4E0', + // FIX: was #C8785A / #E09878 — same coral as shapes. Now phosphor green to match morph-color + '--lk-morph-track': '#283818', '--lk-morph-value': '#88CC44', '--lk-morph-dot': '#AAEE66', + '--lk-shapes-track': '#362420', '--lk-shapes-value': '#C8785A', '--lk-shapes-dot': '#E09878', + + '--si-rand-bg': 'rgba(184,200,64,0.14)', + '--si-env-bg': 'rgba(212,148,58,0.14)', + '--si-smp-bg': 'rgba(122,188,204,0.14)', + // FIX: was rgba(200,120,90,0.14) — same coral as shapes. Now green to match morph-color + '--si-morph-bg': 'rgba(136,204,68,0.14)', + '--si-shapes-bg': 'rgba(200,120,90,0.14)', + + '--fire-text': '#1A1A18', '--fire-active-bg': '#A0B030', + + '--slider-track': '#444438', '--slider-thumb': '#E0D8C0', + '--bar-track': '#1A1A18', '--bar-fill': '#A0B830', + '--card-btn-bg': '#141412', '--card-btn-border': '#505050', '--card-btn-text': '#B0A888', '--card-btn-hover': '#242420', + '--snap-ring-color': '#C8785A', '--snap-ring-opacity': '0.6', + '--lane-color': '#7ABCCC', '--lane-grid': 'rgba(240,232,208,0.07)', '--lane-grid-label': 'rgba(240,232,208,0.14)', + '--lane-playhead': 'rgba(240,232,208,0.6)', '--lane-active': '#88CC44', + '--range-arc': '#60C0E0', '--scrollbar-thumb': '#505050', '--scrollbar-track': 'transparent', + '--bus-mute-bg': '#AA3322', '--bus-mute-text': '#F0E8D0', + '--bus-solo-bg': '#B8A020', '--bus-solo-text': '#1A1A18', + '--bus-group-tint': '10%', '--bus-header-tint': '18%', + '--bus-badge-text': '#1A1A18', + '--toast-success-bg': 'linear-gradient(135deg,#1A2810,#0E1808)', '--toast-success-border': '#88CC44', + '--toast-error-bg': 'linear-gradient(135deg,#3A1810,#2A0808)', '--toast-error-border': '#DD6644', + '--toast-info-bg': 'linear-gradient(135deg,#1C1A0C,#100E06)', '--toast-info-border': '#B8C840', + '--toast-text': '#F0E8D0', + '--preset-flash-color': '#88CC44', '--preset-flash-glow': 'rgba(136,204,68,0.45)', + '--drag-highlight': '#B8C840' + }, + // FIX: bcolors updated — morph slot was #C8785A (same as shapes). Now phosphor green + bcolors: ['#B8C840', '#D4943A', '#7ABCCC', '#CC6644', '#C8785A', '#88CC44', '#C0B090', '#D8D0B8'], + busColors: ['#686858', '#B8C840', '#D4943A', '#7ABCCC', '#CC6644', '#88CC44', '#C8785A', '#88AA80'], + swatches: ['#1E1E1C', '#383835', '#F0E8D0', '#B8C840', '#D4943A'] + }, + + slate_studio: { + name: 'Slate Studio', + vars: { + '--bg-app': '#111116', + '--bg-panel': '#1A1A1E', + '--bg-cell': '#222228', + '--bg-cell-hover': '#2A2A32', + '--bg-inset': '#0E0E12', + + '--border': '#7A7A90', + '--border-strong': '#909098', + '--border-focus': '#D4A840', + + '--text-primary': '#F0EEF8', + '--text-secondary': '#9090A8', + '--text-muted': '#888898', + '--input-text': '#F0EEF8', + + '--accent': '#D4A840', + '--accent-hover': '#F0C050', + '--accent-light': 'rgba(212,168,64,0.10)', + '--accent-border': 'rgba(212,168,64,0.35)', + + '--midi-dot': '#44DD66', + + '--locked-bg': 'rgba(220,60,60,0.12)', + '--locked-border': 'rgba(220,60,60,0.35)', + '--locked-icon': '#EE5050', + + '--auto-lock-bg': 'rgba(220,160,40,0.12)', + '--auto-lock-border': 'rgba(220,160,40,0.35)', + + '--rand-color': '#D4A840', + '--env-color': '#E85A30', + '--sample-color': '#3AAAE0', + '--morph-color': '#50D890', + '--shapes-color': '#E05A40', + + '--thumb-color': '#B0B0C8', + + '--knob-track': '#1E1E24', + '--knob-value': '#D4A840', + '--knob-dot': '#F0CC70', + + '--pf-bg': '#111116', + '--pf-border': '#7A7A90', + '--pf-text': '#9090A8', + + '--ph-bg': '#111116', + '--ph-border': '#7A7A90', + '--ph-text': '#F0EEF8', + + '--lk-rand-track': '#201C08', '--lk-rand-value': '#D4A840', '--lk-rand-dot': '#F0CC70', + '--lk-env-track': '#201008', '--lk-env-value': '#E85A30', '--lk-env-dot': '#FF8860', + '--lk-smp-track': '#081820', '--lk-smp-value': '#3AAAE0', '--lk-smp-dot': '#70CCF8', + '--lk-morph-track': '#0C201A', '--lk-morph-value': '#50D890', '--lk-morph-dot': '#88F8B8', + '--lk-shapes-track': '#201008', '--lk-shapes-value': '#E05A40', '--lk-shapes-dot': '#F08868', + + '--si-rand-bg': 'rgba(212,168,64,0.12)', + '--si-env-bg': 'rgba(232,90,48,0.12)', + '--si-smp-bg': 'rgba(58,170,224,0.12)', + '--si-morph-bg': 'rgba(80,216,144,0.12)', '--si-shapes-bg': 'rgba(224,90,64,0.12)', + + '--fire-text': '#111110', + '--fire-active-bg': '#D4A840', + + + + '--slider-track': '#3A3A48', + '--slider-thumb': '#C8C8E0', + '--bar-track': '#141418', + '--bar-fill': '#B89030', + + '--card-btn-bg': '#111116', + '--card-btn-border': '#7A7A90', + '--card-btn-text': '#9090A8', + '--card-btn-hover': '#1A1A22', + + '--snap-ring-color': '#D4A840', + '--snap-ring-opacity': '0.55', + + '--lane-color': '#3AAAE0', + '--lane-grid': 'rgba(240,238,248,0.05)', + '--lane-grid-label': 'rgba(240,238,248,0.16)', + '--lane-playhead': 'rgba(255,255,255,0.82)', + '--lane-active': '#44DD66', + + '--range-arc': '#60A0D4', + '--scrollbar-thumb': '#7A7A90', + '--scrollbar-track': 'transparent', + + '--bus-mute-bg': '#CC2222', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#C8A000', '--bus-solo-text': '#000000', + '--bus-group-tint': '12%', '--bus-header-tint': '20%', + '--bus-badge-text': '#111110', + '--toast-success-bg': 'linear-gradient(135deg,#0C1C10,#061008)', '--toast-success-border': '#44DD66', + '--toast-error-bg': 'linear-gradient(135deg,#2A0808,#1A0404)', '--toast-error-border': '#EE5050', + '--toast-info-bg': 'linear-gradient(135deg,#181410,#0E0A04)', '--toast-info-border': '#D4A840', + '--toast-text': '#F0EEF8', + '--preset-flash-color': '#44DD66', '--preset-flash-glow': 'rgba(68,221,102,0.45)', + '--drag-highlight': '#D4A840', + }, + bcolors: ['#D4A840', '#E85A30', '#3AAAE0', '#50D890', '#E05A40', '#44DD66', '#9090A8', '#E0E0F0'], + busColors: ['#3A3A48', '#D4A840', '#E85A30', '#3AAAE0', '#50D890', '#E05A40', '#44DD66', '#9090A8'], + swatches: ['#111116', '#222228', '#F0EEF8', '#D4A840', '#E85A30'], + }, + + // ───────────────────────────────────────────────────────────────── + // VINTAGE CONSOLE + // 1970s broadcast console — Neve 8078, SSL 4000. + // Warm ivory on dark walnut. Amber VU needle as accent. + // ───────────────────────────────────────────────────────────────── + vintage_console: { + name: 'Vintage Console', + vars: { + '--bg-app': '#120E08', + '--bg-panel': '#1C1810', + '--bg-cell': '#252018', + '--bg-cell-hover': '#2E2820', + '--bg-inset': '#0E0A06', + + '--border': '#787060', + '--border-strong': '#908070', + '--border-focus': '#C8781E', + + '--text-primary': '#F0E8C8', + '--text-secondary': '#B8A878', + '--text-muted': '#9A8A60', + '--input-text': '#F0E8C8', + + '--accent': '#C8781E', + '--accent-hover': '#E89030', + '--accent-light': 'rgba(200,120,30,0.12)', + '--accent-border': 'rgba(200,120,30,0.38)', + + '--midi-dot': '#88CC44', + + '--locked-bg': 'rgba(200,40,40,0.12)', + '--locked-border': 'rgba(200,40,40,0.35)', + '--locked-icon': '#EE5544', + + '--auto-lock-bg': 'rgba(200,160,40,0.10)', + '--auto-lock-border': 'rgba(200,160,40,0.30)', + + '--rand-color': '#C8781E', + '--env-color': '#D4A030', + '--sample-color': '#78BCCC', + '--morph-color': '#A0C840', + '--shapes-color': '#D46050', + + '--thumb-color': '#C8B888', + + '--knob-track': '#201808', + '--knob-value': '#C8781E', + '--knob-dot': '#F0A050', + + '--pf-bg': '#100C06', + '--pf-border': '#787060', + '--pf-text': '#9A8A60', + + '--ph-bg': '#100C06', + '--ph-border': '#787060', + '--ph-text': '#F0E8C8', + + '--lk-rand-track': '#201408', '--lk-rand-value': '#C8781E', '--lk-rand-dot': '#F0A050', + '--lk-env-track': '#201808', '--lk-env-value': '#D4A030', '--lk-env-dot': '#F0C860', + '--lk-smp-track': '#0C1820', '--lk-smp-value': '#78BCCC', '--lk-smp-dot': '#A8D8E8', + '--lk-morph-track': '#141C06', '--lk-morph-value': '#A0C840', '--lk-morph-dot': '#C8EE60', + '--lk-shapes-track': '#1C100A', '--lk-shapes-value': '#D46050', '--lk-shapes-dot': '#F09078', + + '--si-rand-bg': 'rgba(200,120,30,0.12)', + '--si-env-bg': 'rgba(212,160,48,0.12)', + '--si-smp-bg': 'rgba(120,188,204,0.12)', + '--si-morph-bg': 'rgba(160,200,64,0.12)', + '--si-shapes-bg': 'rgba(212,96,80,0.12)', + + '--fire-text': '#100C06', + '--fire-active-bg': '#C8781E', + + + + '--slider-track': '#4A3820', + '--slider-thumb': '#D8C898', + '--bar-track': '#181008', + '--bar-fill': '#B06818', + + '--card-btn-bg': '#100C06', + '--card-btn-border': '#787060', + '--card-btn-text': '#9A8A60', + '--card-btn-hover': '#1C1810', + + '--snap-ring-color': '#C8781E', + '--snap-ring-opacity': '0.55', + + '--lane-color': '#78BCCC', + '--lane-grid': 'rgba(240,232,200,0.06)', + '--lane-grid-label': 'rgba(240,232,200,0.18)', + '--lane-playhead': 'rgba(255,248,220,0.82)', + '--lane-active': '#88CC44', + + '--range-arc': '#4A9EC8', + '--scrollbar-thumb': '#787060', + '--scrollbar-track': 'transparent', + + '--bus-mute-bg': '#BB2222', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#B89000', '--bus-solo-text': '#000000', + '--bus-group-tint': '12%', '--bus-header-tint': '20%', + '--bus-badge-text': '#100C06', + '--toast-success-bg': 'linear-gradient(135deg,#0E1C08,#060E04)', '--toast-success-border': '#88CC44', + '--toast-error-bg': 'linear-gradient(135deg,#3A1008,#2A0804)', '--toast-error-border': '#EE5544', + '--toast-info-bg': 'linear-gradient(135deg,#1A1408,#0E0A04)', '--toast-info-border': '#C8781E', + '--toast-text': '#F0E8C8', + '--preset-flash-color': '#88CC44', '--preset-flash-glow': 'rgba(136,204,68,0.45)', + '--drag-highlight': '#C8781E', + }, + bcolors: ['#C8781E', '#D4A030', '#78BCCC', '#A0C840', '#D46050', '#88CC44', '#B8A878', '#F0E8C8'], + busColors: ['#584838', '#C8781E', '#D46050', '#78BCCC', '#D4A030', '#A0C840', '#88CC44', '#B8A878'], + swatches: ['#120E08', '#252018', '#F0E8C8', '#C8781E', '#D4A030'], + }, + + win98: { + name: 'Win 98', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + // The classic silver-grey desktop and raised panel look. + '--bg-app': '#C0C0C0', + '--bg-panel': '#D4D0C8', + '--bg-cell': '#FFFFFF', + '--bg-cell-hover': '#E8E8E0', + '--bg-inset': '#808080', + + // ── BORDERS ─────────────────────────────────────────────── + // The iconic beveled 3D look — dark bottom-right, light top-left. + '--border': '#808080', + '--border-strong': '#404040', + '--border-focus': '#000080', // navy focus — just like a selected title bar + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#000000', + '--text-secondary': '#404040', + '--text-muted': '#808080', + '--input-text': '#000000', + + // ── ACCENT ──────────────────────────────────────────────── + // The unmistakable navy-blue of a selected title bar / highlighted menu item. + '--accent': '#000080', + '--accent-hover': '#0000AA', + '--accent-light': 'rgba(0,0,128,0.10)', + '--accent-border': 'rgba(0,0,128,0.40)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#008000', // classic Windows green + + '--locked-bg': 'rgba(192,0,0,0.12)', + '--locked-border': 'rgba(192,0,0,0.40)', + '--locked-icon': '#C00000', + + '--auto-lock-bg': 'rgba(128,128,0,0.12)', + '--auto-lock-border': 'rgba(128,128,0,0.30)', + + // ── MODE COLORS ─────────────────────────────────────────── + // The Windows 16-color palette — unapologetically 4-bit. + '--rand-color': '#0000FF', // blue + '--env-color': '#FF8000', // orange (dark yellow + red) + '--sample-color': '#008000', // green + '--morph-color': '#008080', // teal + '--shapes-color': '#C00000', // dark red + + '--thumb-color': '#808080', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#C0C0C0', + '--knob-value': '#000080', // navy sweep + '--knob-dot': '#000000', + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#D4D0C8', + '--pf-border': '#808080', + '--pf-text': '#404040', + + '--ph-bg': '#000080', // title bar blue + '--ph-border': '#404040', + '--ph-text': '#FFFFFF', // white text on blue title bar + + // ── LINKED KNOBS (mode-tinted light bg) ────────────────── + '--lk-rand-track': '#D0D0E8', '--lk-rand-value': '#0000FF', '--lk-rand-dot': '#0000CC', + '--lk-env-track': '#E8DCC8', '--lk-env-value': '#FF8000', '--lk-env-dot': '#CC6600', + '--lk-smp-track': '#C8E0C8', '--lk-smp-value': '#008000', '--lk-smp-dot': '#006600', + '--lk-morph-track': '#C8E0DC', '--lk-morph-value': '#008080', '--lk-morph-dot': '#006666', + '--lk-shapes-track': '#E0C8C8', '--lk-shapes-value': '#C00000', '--lk-shapes-dot': '#990000', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(0,0,255,0.10)', + '--si-env-bg': 'rgba(255,128,0,0.10)', + '--si-smp-bg': 'rgba(0,128,0,0.10)', + '--si-morph-bg': 'rgba(0,128,128,0.10)', + '--si-shapes-bg': 'rgba(192,0,0,0.10)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#FFFFFF', + '--fire-active-bg': '#000080', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#808080', + '--slider-thumb': '#D4D0C8', // raised button look + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#C0C0C0', + '--bar-fill': '#000080', + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#D4D0C8', + '--card-btn-border': '#808080', + '--card-btn-text': '#000000', + '--card-btn-hover': '#E8E8E0', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#000080', + '--snap-ring-opacity': '0.60', + + // ── LANE / AUTOMATION ───────────────────────────────────── + '--lane-color': '#000080', + '--lane-grid': 'rgba(0,0,0,0.08)', + '--lane-grid-label': 'rgba(0,0,0,0.30)', + '--lane-playhead': 'rgba(0,0,0,0.80)', + '--lane-active': '#008000', + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#0000FF', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#D4D0C8', + '--scrollbar-track': '#C0C0C0', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#C00000', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#C0C000', '--bus-solo-text': '#000000', + '--bus-group-tint': '8%', + '--bus-header-tint': '15%', + '--bus-badge-text': '#FFFFFF', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#D4E8D4,#C8DCC8)', + '--toast-success-border': '#008000', + '--toast-error-bg': 'linear-gradient(135deg,#E8D0D0,#DCC4C4)', + '--toast-error-border': '#C00000', + '--toast-info-bg': 'linear-gradient(135deg,#D4D0C8,#C8C4BC)', + '--toast-info-border': '#000080', + '--toast-text': '#000000', + + '--preset-flash-color': '#000080', + '--preset-flash-glow': 'rgba(0,0,128,0.35)', + }, + + bcolors: [ + '#000000', // black — primary + '#0000FF', // blue — rand + '#FF8000', // orange — env + '#008000', // green — sample + '#008080', // teal — morph + '#C00000', // red — shapes + '#808080', // grey + '#C0C0C0', // silver + ], + + busColors: [ + '#808080', + '#000000', + '#0000FF', + '#FF8000', + '#008000', + '#008080', + '#C00000', + '#C0C0C0', + ], + + swatches: ['#C0C0C0', '#D4D0C8', '#000080', '#008000', '#C00000'], + }, + + // ───────────────────────────────────────────────────────────────── + // WARM TAPE + // Analog tape / Neve / SSL mastering suite. + // Dark mahogany panels, warm ivory text, VU yellow accent. + // ───────────────────────────────────────────────────────────────── + warm_tape: { + name: 'Warm Tape', + vars: { + '--bg-app': '#160E06', + '--bg-panel': '#211B12', + '--bg-cell': '#2C2418', + '--bg-cell-hover': '#363020', + '--bg-inset': '#120A04', + + '--border': '#887860', + '--border-strong': '#A89070', + '--border-focus': '#E8C040', + + '--text-primary': '#F4ECD8', + '--text-secondary': '#C0A870', + '--text-muted': '#B09060', + '--input-text': '#F4ECD8', + + '--accent': '#E8C040', + '--accent-hover': '#FFD850', + '--accent-light': 'rgba(232,192,64,0.10)', + '--accent-border': 'rgba(232,192,64,0.35)', + + '--midi-dot': '#88CC44', + + '--locked-bg': 'rgba(196,32,32,0.12)', + '--locked-border': 'rgba(196,32,32,0.35)', + '--locked-icon': '#E84040', + + '--auto-lock-bg': 'rgba(200,160,40,0.10)', + '--auto-lock-border': 'rgba(200,160,40,0.30)', + + '--rand-color': '#E8C040', + '--env-color': '#E06828', + '--sample-color': '#60B8D8', + '--morph-color': '#98D058', + '--shapes-color': '#D86848', + + '--thumb-color': '#C0A870', + + '--knob-track': '#1C1408', + '--knob-value': '#E8C040', + '--knob-dot': '#FFE880', + + '--pf-bg': '#140C04', + '--pf-border': '#887860', + '--pf-text': '#B09060', + + '--ph-bg': '#140C04', + '--ph-border': '#887860', + '--ph-text': '#F4ECD8', + + '--lk-rand-track': '#201808', '--lk-rand-value': '#E8C040', '--lk-rand-dot': '#FFE880', + '--lk-env-track': '#1C1008', '--lk-env-value': '#E06828', '--lk-env-dot': '#FF9858', + '--lk-smp-track': '#0C1820', '--lk-smp-value': '#60B8D8', '--lk-smp-dot': '#98D8F0', + '--lk-morph-track': '#141C08', '--lk-morph-value': '#98D058', '--lk-morph-dot': '#C8F078', + '--lk-shapes-track': '#1C1008', '--lk-shapes-value': '#D86848', '--lk-shapes-dot': '#F89878', + + '--si-rand-bg': 'rgba(232,192,64,0.12)', + '--si-env-bg': 'rgba(224,104,40,0.12)', + '--si-smp-bg': 'rgba(96,184,216,0.12)', + '--si-morph-bg': 'rgba(152,208,88,0.12)', + '--si-shapes-bg': 'rgba(216,104,72,0.12)', + + '--fire-text': '#120A04', + '--fire-active-bg': '#E8C040', + + + + '--slider-track': '#4A3818', + '--slider-thumb': '#D8C090', + '--bar-track': '#140C04', + '--bar-fill': '#D0A830', + + '--card-btn-bg': '#140C04', + '--card-btn-border': '#887860', + '--card-btn-text': '#B09060', + '--card-btn-hover': '#201810', + + '--snap-ring-color': '#E8C040', + '--snap-ring-opacity': '0.55', + + '--lane-color': '#60B8D8', + '--lane-grid': 'rgba(244,236,216,0.06)', + '--lane-grid-label': 'rgba(244,236,216,0.18)', + '--lane-playhead': 'rgba(255,248,220,0.85)', + '--lane-active': '#88CC44', + + '--range-arc': '#60B0E8', + '--scrollbar-thumb': '#887860', + '--scrollbar-track': 'transparent', + + '--bus-mute-bg': '#C42020', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#C8A000', '--bus-solo-text': '#000000', + '--bus-group-tint': '12%', '--bus-header-tint': '20%', + '--bus-badge-text': '#120A04', + '--toast-success-bg': 'linear-gradient(135deg,#102008,#081004)', '--toast-success-border': '#88CC44', + '--toast-error-bg': 'linear-gradient(135deg,#380808,#200404)', '--toast-error-border': '#E84040', + '--toast-info-bg': 'linear-gradient(135deg,#1A1808,#100C04)', '--toast-info-border': '#E8C040', + '--toast-text': '#F4ECD8', + '--preset-flash-color': '#88CC44', '--preset-flash-glow': 'rgba(136,204,68,0.45)', + '--drag-highlight': '#D0A838', + }, + bcolors: ['#E8C040', '#E06828', '#60B8D8', '#98D058', '#D86848', '#88CC44', '#C0A870', '#F4ECD8'], + busColors: ['#40341A', '#E8C040', '#E06828', '#60B8D8', '#98D058', '#D86848', '#88CC44', '#C0A870'], + swatches: ['#160E06', '#2C2418', '#F4ECD8', '#E8C040', '#E06828'], + }, + // ───────────────────────────────────────────────────────────────── + // DEEP SPACE + // + // Concept: the inside of a precision scientific instrument. + // Particle physics control room. Telescope array ops. + // The kind of screen where the data IS the aesthetic. + // + // Design principles: + // 1. Chrome recedes. Content comes forward. + // 2. One warm color in a cold environment commands attention. + // 3. Structure comes from elevation, not borders. + // 4. Mode colors are a language — five distinct hue families, + // one meaning each, nothing shared. + // 5. The playhead is the brightest thing on screen when playing. + // + // Layer model: + // L0 #0D1117 outer shell — nearly black, slight blue cast + // L1 #161B22 panel surfaces — plugin blocks, section headers + // L2 #1C2333 content areas — canvas, param lists, inset cards + // L3 #222D3F interactive rows — hovered cells, active states + // L4 #090D12 sunken wells — inset boxes, below-surface + // + // Color language: + // #E8D9A0 desaturated gold → accent / primary action / transport + // #00B4D8 ice blue → rand / probability / cold chance + // #E06C1A burn orange → envelope / attack / energy + // #2CB67D terminal green → sample / playback / data stream + // #40B8CC bright teal → morph / transformation / spectrum + // #E8553A warm vermilion → shapes / geometric / precision + // #3DD68C signal green → status ok / running / active handle + // #F85149 alert red → error / locked / danger + // #D4A500 caution amber → solo / warning + // ───────────────────────────────────────────────────────────────── + + deep_space: { + name: 'Deep Space', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + '--bg-app': '#0D1117', // L0 + '--bg-panel': '#161B22', // L1 — 14.64:1 text contrast ✅ + '--bg-cell': '#1C2333', // L2 + '--bg-cell-hover': '#222D3F', // L3 + '--bg-inset': '#090D12', // L4 + + // ── BORDERS ─────────────────────────────────────────────── + // Structure comes from bg elevation differences, not drawn lines. + // These borders appear only on explicit frames and input outlines. + '--border': '#21262D', // subtle structural edge + '--border-strong': '#404858', // emphasized separators + '--border-focus': '#E8D9A0', // gold focus ring — unmissable + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#E6EDF3', // 14.64:1 on panel ✅ + '--text-secondary': '#8B949E', // 5.62:1 on panel ✅ + '--text-muted': '#848E9C', // 5.21:1 panel, 4.73:1 cell ✅ + '--input-text': '#E6EDF3', + + // ── ACCENT ──────────────────────────────────────────────── + // Desaturated gold. The only warm thing on screen. + // Used for: active controls, knob arcs, transport, focus rings. + '--accent': '#E8D9A0', // 12.24:1 on panel ✅ + '--accent-hover': '#F4ECC4', + '--accent-light': 'rgba(232,217,160,0.10)', + '--accent-border': 'rgba(232,217,160,0.30)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#3DD68C', // signal green = running + + '--locked-bg': 'rgba(248,81,73,0.10)', + '--locked-border': 'rgba(248,81,73,0.30)', + '--locked-icon': '#F85149', // 4.71:1 on panel ✅ + + '--auto-lock-bg': 'rgba(212,165,0,0.10)', + '--auto-lock-border': 'rgba(212,165,0,0.28)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Five maximally distinct hues. One meaning each. + '--rand-color': '#00B4D8', // 7.02:1 — ice blue, cold randomness + '--env-color': '#E06C1A', // 5.21:1 — burn orange, attack heat + '--sample-color': '#2CB67D', // 6.67:1 — terminal green, data stream + '--morph-color': '#40B8CC', // 8.35:1 — bright teal, transformation + '--shapes-color': '#E8553A', // 5.62:1 — warm vermilion, geometric + + '--thumb-color': '#8B949E', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#1C2333', // matches bg_cell — sunken appearance + '--knob-value': '#E8D9A0', // gold arc + '--knob-dot': '#F4ECC4', // bright tip + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#0D1117', // card footer = bg_app — recedes maximally + '--pf-border': '#21262D', + '--pf-text': '#848E9C', // 5.70:1 on pf-bg ✅ + + '--ph-bg': '#0D1117', // card header = same dark shell + '--ph-border': '#21262D', + '--ph-text': '#E6EDF3', // 15.18:1 ✅ + + // ── LINKED KNOBS (per-mode tinted) ──────────────────────── + '--lk-rand-track': '#0B2933', '--lk-rand-value': '#00B4D8', '--lk-rand-dot': '#3FC6E1', + '--lk-env-track': '#2C1E17', '--lk-env-value': '#E06C1A', '--lk-env-dot': '#E79053', + '--lk-smp-track': '#112926', '--lk-smp-value': '#2CB67D', '--lk-smp-dot': '#60C89D', + '--lk-morph-track': '#132430', '--lk-morph-value': '#40B8CC', '--lk-morph-dot': '#6CD0E0', + '--lk-shapes-track': '#2E1810', '--lk-shapes-value': '#E8553A', '--lk-shapes-dot': '#F07858', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(0,180,216,0.12)', + '--si-env-bg': 'rgba(224,108,26,0.12)', + '--si-smp-bg': 'rgba(44,182,125,0.12)', + '--si-morph-bg': 'rgba(64,184,204,0.12)', + '--si-shapes-bg': 'rgba(255,107,157,0.12)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#0D1117', // 13.39:1 on gold ✅ + '--fire-active-bg': '#E8D9A0', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#222D3F', + '--slider-thumb': '#8B949E', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#0D1117', + '--bar-fill': '#C8BB80', // slightly less saturated than accent + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#0D1117', + '--card-btn-border': '#21262D', + '--card-btn-text': '#848E9C', + '--card-btn-hover': '#161B22', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#E8D9A0', + '--snap-ring-opacity': '0.50', + + // ── LANE / AUTOMATION ───────────────────────────────────── + // Canvas is bg_cell — content area, slightly lighter than panel. + // Curve is ice blue: distinct from gold accent, reads as data. + // Playhead is near-white — the brightest moving element on screen. + '--lane-color': '#00B4D8', // ice blue curve — data visualization + '--lane-grid': 'rgba(230,237,243,0.04)', // barely visible, doesn't compete + '--lane-grid-label': 'rgba(230,237,243,0.18)', // bar numbers legible but subordinate + '--lane-playhead': 'rgba(255,255,255,0.90)', // near-white — unmissable when playing + '--lane-active': '#3DD68C', // signal green control point + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#7AB0E8', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#404858', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#C62828', // red — universal mute convention + '--bus-mute-text': '#FFFFFF', // 5.62:1 ✅ + '--bus-solo-bg': '#D4A500', // amber — universal solo convention + '--bus-solo-text': '#000000', // 9.18:1 ✅ + '--bus-group-tint': '12%', + '--bus-header-tint': '20%', + '--bus-badge-text': '#0D1117', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#0C2018,#071410)', + '--toast-success-border': '#3DD68C', + '--toast-error-bg': 'linear-gradient(135deg,#280A08,#1A0404)', + '--toast-error-border': '#F85149', + '--toast-info-bg': 'linear-gradient(135deg,#0E1624,#090D18)', + '--toast-info-border': '#E8D9A0', + '--toast-text': '#E6EDF3', + + '--preset-flash-color': '#3DD68C', + '--preset-flash-glow': 'rgba(61,214,140,0.40)', + '--drag-highlight': '#E8D9A0', + }, + + bcolors: [ + '#E8D9A0', // gold — primary accent + '#00B4D8', // ice — rand + '#E06C1A', // orange — env + '#2CB67D', // green — sample + '#40B8CC', // teal — morph + '#E8553A', // red — shapes + '#3DD68C', // lime — active/status + '#8B949E', // steel — neutral + ], + + busColors: [ + '#404858', // neutral + '#E8D9A0', // gold + '#00B4D8', // ice blue + '#E06C1A', // orange + '#2CB67D', // green + '#40B8CC', // teal + '#E8553A', // red + '#3DD68C', // signal green + ], + + swatches: ['#0D1117', '#1C2333', '#E6EDF3', '#E8D9A0', '#00B4D8'], + }, + pitch_black: { + name: 'Pitch Black', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + '--bg-app': '#000000', + '--bg-panel': '#111111', + '--bg-cell': '#1A1A1A', + '--bg-cell-hover': '#252525', + '--bg-inset': '#080808', + + // ── BORDERS ─────────────────────────────────────────────── + // Pure greyscale. Structure comes from bg elevation. + '--border': '#2A2A2A', + '--border-strong': '#3D3D3D', + '--border-focus': '#FFFFFF', // white focus ring — unmissable on black + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#FFFFFF', // 18.88:1 ✅ + '--text-secondary': '#999999', // 6.63:1 ✅ + '--text-muted': '#888888', // 5.33:1 panel, 4.91:1 cell ✅ + '--input-text': '#FFFFFF', + + // ── ACCENT ──────────────────────────────────────────────── + // White is the accent. In an all-black UI, white commands attention. + '--accent': '#FFFFFF', + '--accent-hover': '#DDDDDD', + '--accent-light': 'rgba(255,255,255,0.08)', + '--accent-border': 'rgba(255,255,255,0.25)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#00CC66', // green — signal present (functional color) + + '--locked-bg': 'rgba(255,34,102,0.12)', + '--locked-border': 'rgba(255,34,102,0.35)', + '--locked-icon': '#FF4400', + + '--auto-lock-bg': 'rgba(255,180,0,0.10)', + '--auto-lock-border': 'rgba(255,180,0,0.28)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Pure saturated hues — maximum pop against black. + '--rand-color': '#00AAFF', // 7.37:1 electric blue + '--env-color': '#FF6600', // 6.43:1 pure orange + '--sample-color': '#00CC66', // 8.84:1 pure green + '--morph-color': '#00CCAA', // 8.89:1 mint + '--shapes-color': '#FF4400', // 5.24:1 electric red-orange + + '--thumb-color': '#999999', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#1A1A1A', + '--knob-value': '#FFFFFF', + '--knob-dot': '#FFFFFF', + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#000000', + '--pf-border': '#2A2A2A', + '--pf-text': '#888888', // 5.92:1 on #000000 ✅ + + '--ph-bg': '#000000', + '--ph-border': '#2A2A2A', + '--ph-text': '#FFFFFF', + + // ── LINKED KNOBS (mode-tinted dark bg) ─────────────────── + '--lk-rand-track': '#001723', '--lk-rand-value': '#00AAFF', '--lk-rand-dot': '#38BCFF', + '--lk-env-track': '#230E00', '--lk-env-value': '#FF6600', '--lk-env-dot': '#FF8738', + '--lk-smp-track': '#001C0E', '--lk-smp-value': '#00CC66', '--lk-smp-dot': '#38D787', + '--lk-morph-track': '#002820', '--lk-morph-value': '#00CCAA', '--lk-morph-dot': '#33DDBB', + '--lk-shapes-track': '#230800', '--lk-shapes-value': '#FF4400', '--lk-shapes-dot': '#FF6633', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(0,170,255,0.12)', + '--si-env-bg': 'rgba(255,102,0,0.12)', + '--si-smp-bg': 'rgba(0,204,102,0.12)', + '--si-morph-bg': 'rgba(0,204,170,0.12)', + '--si-shapes-bg': 'rgba(255,34,102,0.12)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#000000', + '--fire-active-bg': '#FFFFFF', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#2A2A2A', + '--slider-thumb': '#FFFFFF', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#000000', + '--bar-fill': '#FFFFFF', + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#000000', + '--card-btn-border': '#2A2A2A', + '--card-btn-text': '#888888', + '--card-btn-hover': '#111111', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#FFFFFF', + '--snap-ring-opacity': '0.50', + + // ── LANE / AUTOMATION ───────────────────────────────────── + '--lane-color': '#FFFFFF', // white curve on black canvas + '--lane-grid': 'rgba(255,255,255,0.05)', + '--lane-grid-label': 'rgba(255,255,255,0.22)', + '--lane-playhead': 'rgba(255,255,255,0.95)', // maximum brightness when playing + '--lane-active': '#00CC66', // green handle — functional color + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#00AAFF', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#3D3D3D', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#CC0000', '--bus-mute-text': '#FFFFFF', // 5.89:1 ✅ + '--bus-solo-bg': '#DDAA00', '--bus-solo-text': '#000000', // 9.82:1 ✅ + '--bus-group-tint': '12%', + '--bus-header-tint': '20%', + '--bus-badge-text': '#000000', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#0A1F0A,#040C04)', + '--toast-success-border': '#00CC66', + '--toast-error-bg': 'linear-gradient(135deg,#230408,#130204)', + '--toast-error-border': '#FF4400', + '--toast-info-bg': 'linear-gradient(135deg,#0A0A0A,#030303)', + '--toast-info-border': '#FFFFFF', + '--toast-text': '#FFFFFF', + + '--preset-flash-color': '#00CC66', + '--preset-flash-glow': 'rgba(0,204,102,0.40)', + + // ── DRAG HIGHLIGHT ─────────────────────────────────────── + '--drag-highlight': '#FFFFFF', // white dashes on black — maximum contrast + }, + + bcolors: [ + '#FFFFFF', // white — primary + '#00AAFF', // blue — rand + '#FF6600', // orange — env + '#00CC66', // green — sample + '#00CCAA', // mint — morph + '#FF4400', // red — shapes + '#888888', // grey + '#444444', // dark grey + ], + + busColors: [ + '#3D3D3D', + '#FFFFFF', + '#00AAFF', + '#FF6600', + '#00CC66', + '#00CCAA', + '#FF4400', + '#888888', + ], + + swatches: ['#000000', '#1A1A1A', '#FFFFFF', '#00AAFF', '#FF4400'], + }, + obsidian: { + name: 'Obsidian', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + // Ultra-dark with the faintest violet undertone — reads as premium + // without looking "themed". Think iZotope, FabFilter, Arturia. + '--bg-app': '#0A0A10', + '--bg-panel': '#121218', + '--bg-cell': '#1A1A22', + '--bg-cell-hover': '#22222C', + '--bg-inset': '#06060A', + + // ── BORDERS ─────────────────────────────────────────────── + // Barely-there edges — structure comes from elevation, not lines. + '--border': '#28283A', + '--border-strong': '#383850', + '--border-focus': '#8070FF', // violet focus ring — instant premium signal + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#ECEAF4', // cool white with a hint of lavender + '--text-secondary': '#8884A0', + '--text-muted': '#6A6680', + '--input-text': '#ECEAF4', + + // ── ACCENT ──────────────────────────────────────────────── + // Electric violet — the color that screams "this plugin costs $200". + // Sits between FabFilter's orange and iZotope's blue — owns its lane. + '--accent': '#8070FF', + '--accent-hover': '#9688FF', + '--accent-light': 'rgba(128,112,255,0.08)', + '--accent-border': 'rgba(128,112,255,0.28)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#50E880', // clean bright green — unmistakable signal + + '--locked-bg': 'rgba(255,56,80,0.10)', + '--locked-border': 'rgba(255,56,80,0.30)', + '--locked-icon': '#FF3850', + + '--auto-lock-bg': 'rgba(255,190,40,0.08)', + '--auto-lock-border': 'rgba(255,190,40,0.24)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Luminous, slightly neon — pop hard on the dark base but stay + // refined because the base is near-black, not mid-grey. + '--rand-color': '#4CB0FF', // crisp sky blue + '--env-color': '#FF9030', // warm amber-orange + '--sample-color': '#50E880', // vivid green + '--morph-color': '#30D8C0', // electric cyan-mint + '--shapes-color': '#FF4060', // hot pink-red — the "danger" color + + '--thumb-color': '#8884A0', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#1A1A22', + '--knob-value': '#8070FF', // violet arc — the hero element + '--knob-dot': '#ECEAF4', + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#0A0A10', + '--pf-border': '#28283A', + '--pf-text': '#6A6680', + + '--ph-bg': '#0A0A10', + '--ph-border': '#28283A', + '--ph-text': '#ECEAF4', + + // ── LINKED KNOBS (mode-tinted dark bg) ─────────────────── + '--lk-rand-track': '#0C1420', '--lk-rand-value': '#4CB0FF', '--lk-rand-dot': '#70C2FF', + '--lk-env-track': '#1C1208', '--lk-env-value': '#FF9030', '--lk-env-dot': '#FFA858', + '--lk-smp-track': '#0A1C10', '--lk-smp-value': '#50E880', '--lk-smp-dot': '#70F098', + '--lk-morph-track': '#081C1A', '--lk-morph-value': '#30D8C0', '--lk-morph-dot': '#58E4D0', + '--lk-shapes-track': '#1C0810', '--lk-shapes-value': '#FF4060', '--lk-shapes-dot': '#FF6880', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(76,176,255,0.10)', + '--si-env-bg': 'rgba(255,144,48,0.10)', + '--si-smp-bg': 'rgba(80,232,128,0.10)', + '--si-morph-bg': 'rgba(48,216,192,0.10)', + '--si-shapes-bg': 'rgba(255,64,96,0.10)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#FFFFFF', + '--fire-active-bg': '#8070FF', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#28283A', + '--slider-thumb': '#8070FF', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#0A0A10', + '--bar-fill': '#8070FF', + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#0A0A10', + '--card-btn-border': '#28283A', + '--card-btn-text': '#6A6680', + '--card-btn-hover': '#121218', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#8070FF', + '--snap-ring-opacity': '0.55', + + // ── LANE / AUTOMATION ───────────────────────────────────── + '--lane-color': '#8070FF', // violet curve — looks incredible on dark bg + '--lane-grid': 'rgba(236,234,244,0.04)', + '--lane-grid-label': 'rgba(236,234,244,0.16)', + '--lane-playhead': 'rgba(236,234,244,0.85)', + '--lane-active': '#50E880', // green handle pops against violet + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#4CB0FF', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#383850', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#D02040', '--bus-mute-text': '#FFFFFF', + '--bus-solo-bg': '#D8AE20', '--bus-solo-text': '#000000', + '--bus-group-tint': '10%', + '--bus-header-tint': '18%', + '--bus-badge-text': '#000000', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#081C10,#040E08)', + '--toast-success-border': '#50E880', + '--toast-error-bg': 'linear-gradient(135deg,#1C080C,#100406)', + '--toast-error-border': '#FF4060', + '--toast-info-bg': 'linear-gradient(135deg,#0E0E18,#08080E)', + '--toast-info-border': '#8070FF', + '--toast-text': '#ECEAF4', + + '--preset-flash-color': '#8070FF', + '--preset-flash-glow': 'rgba(128,112,255,0.45)', + }, + + bcolors: [ + '#ECEAF4', // cool white — primary + '#4CB0FF', // sky blue — rand + '#FF9030', // amber — env + '#50E880', // vivid green — sample + '#30D8C0', // cyan-mint — morph + '#FF4060', // hot pink-red — shapes + '#6A6680', // muted lavender-grey + '#383850', // deep slate + ], + + busColors: [ + '#383850', + '#ECEAF4', + '#4CB0FF', + '#FF9030', + '#50E880', + '#30D8C0', + '#FF4060', + '#6A6680', + ], + + swatches: ['#0A0A10', '#1A1A22', '#8070FF', '#50E880', '#FF4060'], + }, + deep_forest: { + name: 'Deep Forest', + vars: { + // ── BACKGROUNDS ────────────────────────────────────────── + '--bg-app': '#080F0A', // L0 — near black, strong green cast + '--bg-panel': '#101A12', // L1 — first visible green + '--bg-cell': '#162214', // L2 — clearly dark green + '--bg-cell-hover': '#1C2C1E', // L3 — perceptibly lighter + '--bg-inset': '#050A06', // L4 — deepest well + + // ── BORDERS ─────────────────────────────────────────────── + // Structure comes from elevation, not drawn lines. + '--border': '#1A2A1C', // subtle green-tinted edge + '--border-strong': '#2E4830', // emphasized separators + '--border-focus': '#D4B86A', // gold focus ring + + // ── TEXT ────────────────────────────────────────────────── + '--text-primary': '#E8F0E8', // 15.32:1 on panel ✅ — slightly green-white + '--text-secondary': '#7A9E7E', // 5.96:1 on panel ✅ — muted forest green + '--text-muted': '#6E9472', // 5.21:1 panel, 4.82:1 cell ✅ + '--input-text': '#E8F0E8', + + // ── ACCENT ──────────────────────────────────────────────── + // Warm gold — the only warm color on a cold green surface. + // Draws the eye exactly as a VU needle does on a dark panel. + '--accent': '#D4B86A', // 9.22:1 ✅ + '--accent-hover': '#E8CC88', + '--accent-light': 'rgba(212,184,106,0.12)', + '--accent-border': 'rgba(212,184,106,0.32)', + + // ── STATUS ──────────────────────────────────────────────── + '--midi-dot': '#44DD88', // bright signal green — 10.13:1 ✅ + + '--locked-bg': 'rgba(255,68,68,0.12)', + '--locked-border': 'rgba(255,68,68,0.32)', + '--locked-icon': '#FF4444', // 5.23:1 ✅ + + '--auto-lock-bg': 'rgba(212,165,0,0.10)', + '--auto-lock-border': 'rgba(212,165,0,0.28)', + + // ── MODE COLORS ─────────────────────────────────────────── + // Five hues chosen to be distinct from each other AND + // from the dark green surfaces. No green mode color — + // the entire UI is green. + '--rand-color': '#44AAFF', // 7.16:1 — electric blue (clearly non-green) + '--env-color': '#E87020', // 5.74:1 — burn orange + '--sample-color': '#2ECCAA', // 8.77:1 — seafoam (green-shifted, data family) + '--morph-color': '#44CCAA', // 8.77:1 — seafoam teal + '--shapes-color': '#E85030', // 5.54:1 — warm red + + '--thumb-color': '#7A9E7E', + + // ── KNOBS ───────────────────────────────────────────────── + '--knob-track': '#162214', // matches bg_cell — sunken + '--knob-value': '#D4B86A', // gold arc + '--knob-dot': '#E8CC88', // bright gold tip + + // ── PLUGIN CARD ─────────────────────────────────────────── + '--pf-bg': '#080F0A', // card chrome = L0, recedes maximally + '--pf-border': '#1A2A1C', + '--pf-text': '#7A9E7E', // 6.48:1 on pf-bg ✅ + + '--ph-bg': '#080F0A', + '--ph-border': '#1A2A1C', + '--ph-text': '#E8F0E8', // 16.59:1 ✅ + + // ── LINKED KNOBS (mode-tinted dark green bg) ───────────── + '--lk-rand-track': '#10242C', '--lk-rand-value': '#44AAFF', '--lk-rand-dot': '#6DBCFF', + '--lk-env-track': '#271C0D', '--lk-env-value': '#E87020', '--lk-env-dot': '#ED8F51', + '--lk-smp-track': '#0D2920', '--lk-smp-value': '#2ECCAA', '--lk-smp-dot': '#5BD7BC', + '--lk-morph-track': '#0D2920', '--lk-morph-value': '#44CCAA', '--lk-morph-dot': '#66DDBB', + '--lk-shapes-track': '#2A1008', '--lk-shapes-value': '#E85030', '--lk-shapes-dot': '#F07850', + + // ── SOURCE INDICATORS ───────────────────────────────────── + '--si-rand-bg': 'rgba(68,170,255,0.14)', + '--si-env-bg': 'rgba(232,112,32,0.14)', + '--si-smp-bg': 'rgba(46,204,170,0.14)', + '--si-morph-bg': 'rgba(68,204,170,0.14)', + '--si-shapes-bg': 'rgba(255,85,128,0.14)', + + // ── FIRE BUTTON ─────────────────────────────────────────── + '--fire-text': '#080F0A', // 10.03:1 on gold ✅ + '--fire-active-bg': '#D4B86A', + + // ── ARC / RANGE ─────────────────────────────────────────── + + + // ── SLIDER ──────────────────────────────────────────────── + '--slider-track': '#1C2C1E', + '--slider-thumb': '#7A9E7E', + + // ── PARAM BAR ───────────────────────────────────────────── + '--bar-track': '#080F0A', + '--bar-fill': '#B89A50', // slightly deeper gold + + // ── CARD BUTTONS ────────────────────────────────────────── + '--card-btn-bg': '#080F0A', + '--card-btn-border': '#1A2A1C', + '--card-btn-text': '#6E9472', + '--card-btn-hover': '#101A12', + + // ── SNAP RING ───────────────────────────────────────────── + '--snap-ring-color': '#D4B86A', + '--snap-ring-opacity': '0.50', + + // ── LANE / AUTOMATION ───────────────────────────────────── + // Canvas bg is bg_cell — dark green content area. + // Lane curve: warm gold so it reads as the actively drawn data, + // distinct from the green surface. Playhead: near-white max brightness. + '--lane-color': '#D4B86A', // gold curve — data on green + '--lane-grid': 'rgba(232,240,232,0.05)', + '--lane-grid-label': 'rgba(232,240,232,0.20)', + '--lane-playhead': 'rgba(255,255,255,0.88)', // brightest element when playing + '--lane-active': '#44DD88', // bright green handle — status color + + // ── RANGE ARC ───────────────────────────────────────────── + '--range-arc': '#44AAFF', + + // ── SCROLLBAR ───────────────────────────────────────────── + '--scrollbar-thumb': '#2E4830', + '--scrollbar-track': 'transparent', + + // ── BUS CONTROLS ────────────────────────────────────────── + '--bus-mute-bg': '#C62828', '--bus-mute-text': '#FFFFFF', // 5.62:1 ✅ + '--bus-solo-bg': '#D4A500', '--bus-solo-text': '#000000', // 9.18:1 ✅ + '--bus-group-tint': '12%', + '--bus-header-tint': '20%', + '--bus-badge-text': '#080F0A', + + // ── TOASTS ──────────────────────────────────────────────── + '--toast-success-bg': 'linear-gradient(135deg,#0A1E0E,#060E08)', + '--toast-success-border': '#44DD88', + '--toast-error-bg': 'linear-gradient(135deg,#240808,#140404)', + '--toast-error-border': '#FF4444', + '--toast-info-bg': 'linear-gradient(135deg,#0E1A10,#080F0A)', + '--toast-info-border': '#D4B86A', + '--toast-text': '#E8F0E8', + + '--preset-flash-color': '#44DD88', + '--preset-flash-glow': 'rgba(68,221,136,0.40)', + '--drag-highlight': '#44DD88', + }, + + bcolors: [ + '#D4B86A', // gold — accent + '#44AAFF', // blue — rand + '#E87020', // orange — env + '#2ECCAA', // mint — sample + '#44CCAA', // teal — morph + '#E85030', // red — shapes + '#44DD88', // green — active/status + '#7A9E7E', // muted — neutral + ], + + busColors: [ + '#2E4830', // dark green neutral + '#D4B86A', // gold + '#44AAFF', // blue + '#E87020', // orange + '#2ECCAA', // mint + '#44CCAA', // teal + '#E85030', // red + '#44DD88', // bright green + ], + + swatches: ['#080F0A', '#162214', '#E8F0E8', '#D4B86A', '#44AAFF'], + } +}; + +var currentTheme = 'medical_vintage'; + +function applyTheme(themeId) { + var t = THEMES[themeId]; + if (!t) return; + currentTheme = themeId; + var root = document.documentElement; + for (var k in t.vars) { root.style.setProperty(k, t.vars[k]); } + BCOLORS = t.bcolors; + if (t.busColors) BUS_COLORS = t.busColors; + + // Inject dynamic slider styles — WebView2 pseudo-elements don't reliably + // inherit CSS custom properties, so we write hardcoded color values. + // This is now the SOLE source of slider track/thumb styling. + var sTrack = t.vars['--slider-track'] || t.vars['--border'] || '#555'; + var sThumb = t.vars['--slider-thumb'] || t.vars['--thumb-color'] || '#ccc'; + var sAccent = t.vars['--accent'] || '#E8A244'; + var sAccentL = t.vars['--accent-light'] || 'rgba(232,162,68,0.20)'; + var dynEl = document.getElementById('dyn-slider'); + if (!dynEl) { + dynEl = document.createElement('style'); + dynEl.id = 'dyn-slider'; + document.head.appendChild(dynEl); + } + dynEl.textContent = + // Track — 4px for visibility + 'input[type="range"]::-webkit-slider-runnable-track{' + + 'height:4px;background:' + sTrack + ';border-radius:2px}' + + // Thumb — 14px circle with shadow for visibility + 'input[type="range"]::-webkit-slider-thumb{' + + '-webkit-appearance:none;width:14px;height:14px;' + + 'background:' + sThumb + ';' + + 'border:2px solid ' + sTrack + ';' + + 'border-radius:50%;cursor:pointer;margin-top:-5px;' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3);' + + 'transition:border-color 80ms,box-shadow 80ms}' + + // Hover — accent border + glow + 'input[type="range"]::-webkit-slider-thumb:hover{' + + 'border-color:' + sAccent + ';' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 0 2px ' + sAccentL + '}' + + // Active — stronger glow + 'input[type="range"]:active::-webkit-slider-thumb{' + + 'border-color:' + sAccent + ';' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 0 3px ' + sAccentL + '}'; + + // Update rendered blocks/plugins with new colors + if (typeof renderBlocks === 'function') renderBlocks(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + // Render theme cards to update active state + renderThemeGrid(); + if (typeof saveUiStateToHost === 'function') saveUiStateToHost(); +} + +function renderThemeGrid() { + var grid = document.getElementById('themeGrid'); + if (!grid) return; + var defTheme = null; + try { defTheme = localStorage.getItem('mrDefaultTheme'); } catch (e) { } + var h = ''; + for (var id in THEMES) { + var t = THEMES[id]; + h += '
'; + h += '
'; + for (var si = 0; si < t.swatches.length; si++) { + h += ''; + } + h += '
'; + h += '' + t.name + ''; + if (id === defTheme) { + h += ''; + } + h += '
'; + } + grid.innerHTML = h; + grid.querySelectorAll('.theme-card').forEach(function (card) { + card.onclick = function () { + applyTheme(this.getAttribute('data-theme')); + }; + }); +} + +// Load default theme from localStorage (used for new instances before restoreFromHost runs) +(function () { + var def = null; + try { def = localStorage.getItem('mrDefaultTheme'); } catch (e) { } + if (def && THEMES[def]) currentTheme = def; +})(); + +// Settings panel toggle +(function () { + var btn = document.getElementById('settingsBtn'); + var drop = document.getElementById('settingsDrop'); + btn.onclick = function (e) { + e.stopPropagation(); + drop.classList.toggle('vis'); + if (drop.classList.contains('vis') && typeof renderSettingsScanPaths === 'function') { + renderSettingsScanPaths(); + } + }; + document.addEventListener('click', function (e) { + if (!drop.contains(e.target) && e.target !== btn) { + drop.classList.remove('vis'); + } + }); + + // Make Default button + var defBtn = document.getElementById('themeDefaultBtn'); + if (defBtn) { + defBtn.onclick = function (e) { + e.stopPropagation(); + try { localStorage.setItem('mrDefaultTheme', currentTheme); } catch (ex) { } + renderThemeGrid(); + if (typeof showToast === 'function') { + showToast(THEMES[currentTheme].name + ' set as default theme', 'success'); + } + }; + } + + renderThemeGrid(); + // Apply the default theme CSS vars on load — without this, variables.css defaults show (Earthy) + // Only inject vars; skip renderBlocks/renderAllPlugins/save — those aren't ready yet + // and restoreFromHost will call applyTheme() again with the user's saved preference + var _initTheme = THEMES[currentTheme]; + if (_initTheme) { + var root = document.documentElement; + for (var k in _initTheme.vars) root.style.setProperty(k, _initTheme.vars[k]); + BCOLORS = _initTheme.bcolors; + if (_initTheme.busColors) BUS_COLORS = _initTheme.busColors; + + // Inject dynamic slider styles immediately — WebView2 pseudo-elements + // don't resolve CSS custom properties, so we must write hardcoded colors. + // Without this, sliders appear unstyled until the user clicks a theme. + var sTrack = _initTheme.vars['--slider-track'] || _initTheme.vars['--border'] || '#555'; + var sThumb = _initTheme.vars['--slider-thumb'] || _initTheme.vars['--thumb-color'] || '#ccc'; + var sAccent = _initTheme.vars['--accent'] || '#E8A244'; + var sAccentL = _initTheme.vars['--accent-light'] || 'rgba(232,162,68,0.20)'; + var dynEl = document.getElementById('dyn-slider'); + if (!dynEl) { + dynEl = document.createElement('style'); + dynEl.id = 'dyn-slider'; + document.head.appendChild(dynEl); + } + dynEl.textContent = + 'input[type="range"]::-webkit-slider-runnable-track{' + + 'height:4px;background:' + sTrack + ';border-radius:2px}' + + 'input[type="range"]::-webkit-slider-thumb{' + + '-webkit-appearance:none;width:14px;height:14px;' + + 'background:' + sThumb + ';' + + 'border:2px solid ' + sTrack + ';' + + 'border-radius:50%;cursor:pointer;margin-top:-5px;' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3);' + + 'transition:border-color 80ms,box-shadow 80ms}' + + 'input[type="range"]::-webkit-slider-thumb:hover{' + + 'border-color:' + sAccent + ';' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 0 2px ' + sAccentL + '}' + + 'input[type="range"]:active::-webkit-slider-thumb{' + + 'border-color:' + sAccent + ';' + + 'box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 0 3px ' + sAccentL + '}'; + } +})(); + +// Help panel moved to help_panel.js + +// Expose button toggle +(function () { + var btn = document.getElementById('exposeBtn'); + if (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + // Close settings dropdown if open + var sd = document.getElementById('settingsDrop'); + if (sd) sd.classList.remove('vis'); + // Toggle expose dropdown + var existing = document.getElementById('exposeDrop'); + if (existing) { + closeExposeDropdown(); + } else { + openExposeDropdown(btn); + } + }; + } +})(); diff --git a/plugins/ModularRandomizer/Source/ui/public/js/undo_system.js b/plugins/ModularRandomizer/Source/ui/public/js/undo_system.js new file mode 100644 index 0000000..b85ee41 --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/undo_system.js @@ -0,0 +1,187 @@ +// ============================================================ +// UNDO / REDO SYSTEM +// Granular: param changes store only what changed +// ============================================================ + +var undoStack = [], redoStack = [], maxUndo = 80; + +// ── Single param change (knob drag) ── +function pushParamUndo(paramId, oldVal) { + undoStack.push({ type: 'param', id: paramId, val: oldVal }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); +} + +// ── Multiple param changes (randomize, preset load) ── +// changes: [{id: paramId, val: oldValue}, ...] +function pushMultiParamUndo(changes) { + if (!changes || !changes.length) return; + undoStack.push({ type: 'multiParam', changes: changes }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); +} + +// ── Full state snapshot (structural: blocks, locks, bypass) ── +function captureFullSnapshot() { + var paramSnap = {}; + var ap = allParams(); + for (var i = 0; i < ap.length; i++) { + var p = ap[i]; + paramSnap[p.id] = { v: p.v, lk: !!p.lk, alk: !!p.alk }; + } + var blockSnap = blocks.map(function (b) { + return JSON.parse(JSON.stringify(b, function (k, v) { + if (v instanceof Set) return { __set__: Array.from(v) }; + return v; + })); + }); + var pluginSnap = pluginBlocks.map(function (pb) { + return { id: pb.id, bypassed: !!pb.bypassed, expanded: pb.expanded, busId: pb.busId || 0 }; + }); + return { params: paramSnap, blocks: blockSnap, plugins: pluginSnap, actId: actId }; +} + +function pushUndoSnapshot() { + undoStack.push({ type: 'full', snapshot: captureFullSnapshot() }); + if (undoStack.length > maxUndo) undoStack.shift(); + redoStack = []; + updateUndoBadge(); +} + +// ── Undo ── +function performUndo() { + if (undoStack.length === 0) return; + var entry = undoStack.pop(); + + if (entry.type === 'param') { + var p = PMap[entry.id]; + if (p) { + redoStack.push({ type: 'param', id: entry.id, val: p.v }); + p.v = entry.val; + if (window.__JUCE__ && window.__JUCE__.backend) { + var setParamFn = window.__juceGetNativeFunction('setParam'); + if (setParamFn) setParamFn(p.hostId, p.realIndex, p.v); + } + _modDirty = true; + refreshParamDisplay(); + } + } else if (entry.type === 'multiParam') { + var redoChanges = []; + var batch = []; + entry.changes.forEach(function (c) { + var p = PMap[c.id]; + if (p) { + redoChanges.push({ id: c.id, val: p.v }); + p.v = c.val; + batch.push({ p: p.hostId, i: p.realIndex, v: p.v }); + } + }); + if (batch.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(batch)); + } + redoStack.push({ type: 'multiParam', changes: redoChanges }); + renderAllPlugins(); + } else if (entry.type === 'full') { + redoStack.push({ type: 'full', snapshot: captureFullSnapshot() }); + applyFullSnapshot(entry.snapshot); + } + updateUndoBadge(); +} + +// ── Redo ── +function performRedo() { + if (redoStack.length === 0) return; + var entry = redoStack.pop(); + + if (entry.type === 'param') { + var p = PMap[entry.id]; + if (p) { + undoStack.push({ type: 'param', id: entry.id, val: p.v }); + p.v = entry.val; + if (window.__JUCE__ && window.__JUCE__.backend) { + var setParamFn = window.__juceGetNativeFunction('setParam'); + if (setParamFn) setParamFn(p.hostId, p.realIndex, p.v); + } + _modDirty = true; + refreshParamDisplay(); + } + } else if (entry.type === 'multiParam') { + var undoChanges = []; + var batch = []; + entry.changes.forEach(function (c) { + var p = PMap[c.id]; + if (p) { + undoChanges.push({ id: c.id, val: p.v }); + p.v = c.val; + batch.push({ p: p.hostId, i: p.realIndex, v: p.v }); + } + }); + if (batch.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(batch)); + } + undoStack.push({ type: 'multiParam', changes: undoChanges }); + renderAllPlugins(); + } else if (entry.type === 'full') { + undoStack.push({ type: 'full', snapshot: captureFullSnapshot() }); + applyFullSnapshot(entry.snapshot); + } + updateUndoBadge(); +} + +// ── Apply full snapshot (for structural undo) ── +function applyFullSnapshot(snap) { + var batch = []; + for (var id in snap.params) { + var p = PMap[id]; + if (!p) continue; + var s = snap.params[id]; + p.v = s.v; p.lk = s.lk; p.alk = s.alk; + batch.push({ p: p.hostId, i: p.realIndex, v: p.v }); + } + if (batch.length > 0 && window.__JUCE__ && window.__JUCE__.backend) { + var batchFn = window.__juceGetNativeFunction('applyParamBatch'); + if (batchFn) batchFn(JSON.stringify(batch)); + } + if (snap.blocks) { + blocks = snap.blocks.map(function (b) { + var restored = JSON.parse(JSON.stringify(b), function (k, v) { + if (v && v.__set__) return new Set(v.__set__); + return v; + }); + if (restored.targets && !(restored.targets instanceof Set)) { + restored.targets = new Set(Array.isArray(restored.targets) ? restored.targets : []); + } + return restored; + }); + } + if (snap.plugins) { + for (var pi = 0; pi < snap.plugins.length && pi < pluginBlocks.length; pi++) { + var ps = snap.plugins[pi]; + var pb = pluginBlocks[pi]; + if (pb.id === ps.id) { + pb.bypassed = ps.bypassed; + pb.expanded = ps.expanded; + pb.busId = ps.busId; + } + } + } + if (snap.actId !== undefined) actId = snap.actId; + renderAllPlugins(); renderBlocks(); updCounts(); syncBlocksToHost(); +} + +function updateUndoBadge() { + var btn = document.getElementById('undoBtn'); + var badge = document.getElementById('undoCount'); + badge.textContent = undoStack.length; + btn.disabled = undoStack.length === 0; + var redoBtn = document.getElementById('redoBtn'); + var redoBadge = document.getElementById('redoCount'); + if (redoBtn) { + redoBadge.textContent = redoStack.length; + redoBtn.disabled = redoStack.length === 0; + } +} diff --git a/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js new file mode 100644 index 0000000..8b05c8b --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js @@ -0,0 +1,5093 @@ +/* + * WrongEQ Canvas Module — drawable frequency-band EQ + * Each breakpoint creates a bus slot for plugin routing. + * Adapted from lane_module.js with logarithmic frequency axis. + * Depends on: state.js (wrongEqPoints, routingMode) + */ + +// ── Constants ── +var WEQ_CANVAS_H = 360; +var WEQ_Y_PAD = 16; +var WEQ_MIN_FREQ = 20; +var WEQ_MAX_FREQ = 20000; +var WEQ_MIN_DB = -24; // DEPRECATED: use -weqDBRangeMax instead (dynamic range) +var WEQ_MAX_DB = 24; // DEPRECATED: use weqDBRangeMax instead (dynamic range) +var WEQ_DB_RANGE = WEQ_MAX_DB - WEQ_MIN_DB; // DEPRECATED +var WEQ_BAND_COLORS = ['#ff6464', '#64b4ff', '#64dc8c', '#ffc850', '#c882ff', '#ff8cb4', '#50dcdc']; +var WEQ_TYPES = ['Bell', 'LP', 'HP', 'Notch', 'LShf', 'HShf']; + +// Hex color to rgba string (safe against non-hex inputs) +function weqHexRgba(hex, alpha) { + if (!hex || hex.charAt(0) !== '#' || hex.length < 7) return 'rgba(128,128,128,' + alpha + ')'; + var r = parseInt(hex.slice(1, 3), 16); + var g = parseInt(hex.slice(3, 5), 16); + var b = parseInt(hex.slice(5, 7), 16); + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; +} + +// Read computed accent color from CSS variable for canvas drawing +function _weqAccentRgba(alpha) { + try { + var accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); + if (accent && accent.charAt(0) === '#' && accent.length >= 7) return weqHexRgba(accent, alpha); + } catch (e) { } + return 'rgba(130,180,130,' + alpha + ')'; +} +// Read computed color variable for canvas +function _weqCssColorRgba(varName, alpha) { + try { + var c = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + if (c && c.charAt(0) === '#' && c.length >= 7) return weqHexRgba(c, alpha); + } catch (e) { } + return 'rgba(128,128,128,' + alpha + ')'; +} + +// Resolve plugin names for a point's assigned plugins +function _weqPlugNames(pt) { + if (!pt) return ''; + var ids = pt.pluginIds; + // Backward compat: old busId → convert + if (!ids && pt.busId != null && pt.busId >= 0) ids = [pt.busId]; + if (!ids || ids.length === 0) return ''; + if (typeof pluginBlocks === 'undefined' || !pluginBlocks.length) { + return ids.map(function (id) { return 'Plugin ' + id; }).join(' → '); + } + var names = []; + ids.forEach(function (id) { + for (var pi = 0; pi < pluginBlocks.length; pi++) { + if (pluginBlocks[pi].id === id) { names.push(pluginBlocks[pi].name); return; } + } + names.push('Plugin ' + id); + }); + return names.join(' → '); +} + +// Resolve a CSS variable for use in canvas context +var _weqStyleCache = {}; +function weqCssVar(name, fallback) { + if (_weqStyleCache[name]) return _weqStyleCache[name]; + var val = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (!val) val = fallback || '#1a1a20'; + _weqStyleCache[name] = val; + return val; +} +// Invalidate cache on theme change +function weqInvalidateStyleCache() { _weqStyleCache = {}; } + +// Frequency ↔ normalized position (log scale) +function weqFreqToX(hz) { + return Math.log2(Math.max(WEQ_MIN_FREQ, Math.min(WEQ_MAX_FREQ, hz)) / WEQ_MIN_FREQ) / Math.log2(WEQ_MAX_FREQ / WEQ_MIN_FREQ); +} +function weqXToFreq(x) { + return WEQ_MIN_FREQ * Math.pow(WEQ_MAX_FREQ / WEQ_MIN_FREQ, Math.max(0, Math.min(1, x))); +} + +// dB ↔ normalized Y (0=top=+maxDB, 1=bottom=-maxDB) — uses dynamic weqDBRangeMax +function weqDBtoY(db) { + return 1.0 - (db - (-weqDBRangeMax)) / (weqDBRangeMax * 2); +} +function weqYToDB(y) { + return (-weqDBRangeMax) + (1.0 - y) * (weqDBRangeMax * 2); +} + +// Y position ↔ canvas pixel (with padding) +function weqYtoCanvas(y, H) { return WEQ_Y_PAD + y * (H - 2 * WEQ_Y_PAD); } +function weqCanvasToY(py, H) { return Math.max(0, Math.min(1, (py - WEQ_Y_PAD) / (H - 2 * WEQ_Y_PAD))); } + +// Format frequency for display +function weqFmtFreq(hz) { + if (hz >= 10000) return (hz / 1000).toFixed(1) + 'k'; + if (hz >= 1000) return (hz / 1000).toFixed(1) + 'k'; + return Math.round(hz) + ''; +} +function weqFmtDB(db) { + return (db >= 0 ? '+' : '') + db.toFixed(1); +} + +// Compute Q-based band frequency range for a point +// Returns { lo: Hz, hi: Hz } based on filter type and Q +function weqBandRange(pt) { + var f0 = weqXToFreq(pt.x); + + // ── Split mode: band range from prev point to this point ── + if (weqSplitMode) { + var sorted = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var idx = -1; + for (var i = 0; i < sorted.length; i++) { + if (sorted[i] === pt) { idx = i; break; } + } + if (idx < 0) { + // Fallback: find by x position + for (var j = 0; j < sorted.length; j++) { + if (Math.abs(sorted[j].x - pt.x) < 0.001) { idx = j; break; } + } + } + var lo = (idx > 0) ? weqXToFreq(sorted[idx - 1].x) : 20; + var hi = f0; + return { lo: Math.max(20, lo), hi: Math.min(20000, hi) }; + } + + var Q = Math.max(0.025, pt.q || 0.707); + var type = pt.type || 'Bell'; + if (type === 'LP') return { lo: 20, hi: f0 }; + if (type === 'HP') return { lo: f0, hi: 20000 }; + if (type === 'LShf') return { lo: 20, hi: f0 }; + if (type === 'HShf') return { lo: f0, hi: 20000 }; + // Bell/Notch: exact bandwidth from Cookbook analog prototype relationship: + // 1/Q = 2*sinh(ln(2)/2 * BW) → BW = 2/ln(2) * asinh(1/(2*Q)) + // The old approximation BW ≈ 1/Q was 35% wrong at Q=0.707 (Butterworth). + var bwOct = (2 / Math.LN2) * Math.asinh(1 / (2 * Q)); + var lo = f0 / Math.pow(2, bwOct / 2); + var hi = f0 * Math.pow(2, bwOct / 2); + return { lo: Math.max(20, lo), hi: Math.min(20000, hi) }; +} + +// Format a band range for display: "500Hz–2kHz" +function weqFmtRange(pt) { + var r = weqBandRange(pt); + return weqFmtFreq(r.lo) + '–' + weqFmtFreq(r.hi) + 'Hz'; +} + +// Update legend chip ranges in-place (no full re-render) +function _weqUpdateLegendRanges() { + var chips = document.querySelectorAll('.weq-band-range'); + if (chips.length === 0) return; + var sorted = wrongEqPoints.map(function (p, idx) { + return { x: p.x, ref: p, origIdx: idx }; + }); + sorted.sort(function (a, b) { return a.x - b.x; }); + var qr = sorted.map(function (lp) { return weqBandRange(lp.ref); }); + for (var oi = 0; oi < qr.length - 1; oi++) { + if (qr[oi].hi > qr[oi + 1].lo) { + var mid = Math.sqrt(qr[oi].hi * qr[oi + 1].lo); + qr[oi].hi = mid; + qr[oi + 1].lo = mid; + } + } + for (var ci = 0; ci < qr.length && ci < chips.length; ci++) { + chips[ci].textContent = weqFmtFreq(qr[ci].lo) + '–' + weqFmtFreq(qr[ci].hi); + } +} + +// ── State ── +var weqTool = 'draw'; +var weqGrid = 'free'; // 'free','oct','1/3oct','semi' +var weqSelectedPt = -1; +var weqDragPt = -1; +var weqDragAxis = null; // 'h','v' when shift held during drag +var weqGlobalInterp = 'smooth'; +var weqGlobalDepth = 100; +var weqGlobalWarp = 0; +var weqGlobalSteps = 0; +var weqGlobalTilt = 0; // -100 to +100: tilt spectrum (+ = boost highs, cut lows) + + +var weqGlobalBypass = false; +var weqUnassignedMode = 1; // Always 1 (global post-EQ inserts). Per-plugin bypass handles individual skipping. +var weqPreEq = true; // DEPRECATED: now per-point (pt.preEq). Kept for backward compat loading. +var weqFocusBand = -1; // which band row is focused/highlighted on canvas (-1 = none) +var weqDBRangeMax = 24; // max dB for canvas display/limits: 6, 12, 18, 24, 36, 48 +var weqSplitMode = false; // Split mode: show visible band zones on canvas with draggable crossover dividers +var weqOversample = 1; // EQ oversampling: 1=off, 2=2×, 4=4× +var _weqSplitSavedGains = null; // Saved gains before entering split mode (for undo-like restore) + +// ── EQ Undo/Redo System ── +// Snapshots the full EQ state (points, globals, split mode) so Ctrl+Z works. +var _weqUndoStack = []; +var _weqRedoStack = []; +var _weqMaxUndo = 40; + +function _weqSnapshotState() { + return { + points: wrongEqPoints.map(function (p) { + return { uid: p.uid, x: p.x, y: p.y, q: p.q, type: p.type, solo: p.solo, mute: p.mute, drift: p.drift, preEq: p.preEq, stereoMode: p.stereoMode, slope: p.slope || 1, pluginIds: (p.pluginIds || []).slice(), seg: p.seg }; + }), + splitMode: weqSplitMode, + depth: weqGlobalDepth, + warp: weqGlobalWarp, + steps: weqGlobalSteps, + tilt: weqGlobalTilt, + bypass: weqGlobalBypass, + dbRange: weqDBRangeMax + }; +} + +function _weqRestoreSnapshot(snap) { + wrongEqPoints = snap.points.map(function (p) { + return { uid: p.uid, x: p.x, y: p.y, q: p.q, type: p.type, solo: p.solo || false, mute: p.mute || false, drift: p.drift || 0, preEq: p.preEq !== false, stereoMode: p.stereoMode || 0, slope: p.slope || 1, pluginIds: (p.pluginIds || []).slice(), seg: p.seg || null }; + }); + // Sync uid counter + var maxUid = 0; + wrongEqPoints.forEach(function (pt) { if (pt.uid > maxUid) maxUid = pt.uid; }); + if (maxUid >= _weqNextUid) _weqNextUid = maxUid + 1; + weqSplitMode = snap.splitMode; + if (snap.depth != null) weqGlobalDepth = snap.depth; + if (snap.warp != null) weqGlobalWarp = snap.warp; + if (snap.steps != null) weqGlobalSteps = snap.steps; + if (snap.tilt != null) weqGlobalTilt = snap.tilt; + if (snap.bypass != null) weqGlobalBypass = snap.bypass; + if (snap.dbRange != null) weqDBRangeMax = snap.dbRange; + // Update animation bases + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + _weqSyncPluginBusIds(); + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); +} + +function _weqPushUndo() { + _weqUndoStack.push(_weqSnapshotState()); + if (_weqUndoStack.length > _weqMaxUndo) _weqUndoStack.shift(); + _weqRedoStack = []; // new action clears redo +} + +function _weqPerformUndo() { + if (_weqUndoStack.length === 0) return; + _weqRedoStack.push(_weqSnapshotState()); + var snap = _weqUndoStack.pop(); + _weqRestoreSnapshot(snap); +} + +function _weqPerformRedo() { + if (_weqRedoStack.length === 0) return; + _weqUndoStack.push(_weqSnapshotState()); + var snap = _weqRedoStack.pop(); + _weqRestoreSnapshot(snap); +} + +// Sync pluginBlocks[].busId from wrongEqPoints[].pluginIds. +// Called after any EQ routing change to keep the main plugin rack in sync. +function _weqSyncPluginBusIds() { + if (typeof pluginBlocks === 'undefined' || !pluginBlocks.length) return; + // Build pluginId → band UID map from EQ points + var idToUid = {}; + for (var pi = 0; pi < wrongEqPoints.length; pi++) { + var ids = wrongEqPoints[pi].pluginIds || []; + var uid = wrongEqPoints[pi].uid || 0; + for (var ii = 0; ii < ids.length; ii++) { + idToUid[ids[ii]] = uid; + } + } + var busFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setPluginBus') : null; + for (var bi = 0; bi < pluginBlocks.length; bi++) { + var pb = pluginBlocks[bi]; + var newBus = idToUid[pb.id] != null ? idToUid[pb.id] : 0; + if ((pb.busId || 0) !== newBus) { + pb.busId = newBus; + if (busFn) busFn(pb.hostId !== undefined ? pb.hostId : pb.id, newBus); + } + } + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof saveUiStateToHost === 'function') saveUiStateToHost(); +} +var weqSegSel = new Set(); // segment selection: set of band indices for multi-select operations + +// ── EQ Preset state ── +var _weqCurrentPreset = null; // current loaded preset name (null = Init) +var _weqPresetList = []; // cached list of preset names from disk + +// ── Stable point UIDs ── +// Each EQ point gets a unique, permanent uid on creation. +// This uid is used as busId for plugin routing — never changes when points are +// added, removed, or reordered. Prevents the bug where adding a new point +// caused assigned plugins to jump to a different band. +var _weqNextUid = 1; +function _weqAllocUid() { return _weqNextUid++; } +// Ensure a point has a uid (backward compat for loaded data without uids) +function _weqEnsureUid(pt) { + if (!pt.uid) pt.uid = _weqAllocUid(); + return pt; +} +// Stable color for a point — based on its UID, never changes on reorder. +// Band 0 (below first crossover) has no owning point, so use index 0 color. +function _weqPointColor(pt) { + _weqEnsureUid(pt); + return WEQ_BAND_COLORS[(pt.uid - 1) % WEQ_BAND_COLORS.length]; +} +// Color for a band by its owning point (or band-0 fallback) +// sortedPts = array of point objects sorted by X. Band i+1 is owned by sortedPts[i]. +function _weqBandColor(bandIdx, sortedPts) { + if (bandIdx === 0) return WEQ_BAND_COLORS[0]; // sub-band below all crossovers + var pt = sortedPts[bandIdx - 1]; + return pt ? _weqPointColor(pt) : WEQ_BAND_COLORS[bandIdx % WEQ_BAND_COLORS.length]; +} + +// ── Spectrum analyzer ── +var weqSpectrumBins = null; // Float32Array of dB values (log-spaced bins 20-20kHz) +var weqSpectrumSmooth = null; // smoothed version for drawing + +// Called from C++ to provide FFT data: array of dB values mapped to log-spaced freq bins +function weqSetSpectrum(binArray) { + if (!binArray || binArray.length === 0) return; + if (!weqSpectrumBins || weqSpectrumBins.length !== binArray.length) { + weqSpectrumBins = new Float32Array(binArray); + weqSpectrumSmooth = new Float32Array(binArray); + } else { + for (var i = 0; i < binArray.length; i++) { + weqSpectrumBins[i] = binArray[i]; + // Exponential smoothing (fall slower than rise) + var target = binArray[i]; + var curr = weqSpectrumSmooth[i]; + weqSpectrumSmooth[i] = target > curr ? curr + (target - curr) * 0.6 : curr + (target - curr) * 0.15; + } + } +} +// ── Animation state ── +var weqAnimSpeed = 0; // Hz — 0 = static, >0 = animate curve +var weqAnimDepth = 6; // dB modulation depth (how much gains oscillate) +var weqAnimPhase = 0; // current phase 0-1 (wraps) +var weqAnimRafId = null; // requestAnimationFrame ID +var weqAnimLastTime = 0; // last frame timestamp +var weqAnimBaseY = []; // snapshot of base Y values (user-drawn positions) +var weqAnimShape = 'sine'; // LFO waveform shape + +// ── LFO Shape Definitions ── +// Each shape takes phase (0-1) and returns bipolar value (-1 to +1) +var WEQ_LFO_SHAPES = { + 'sine': { label: 'Sine', icon: '∿', fn: function (p) { return Math.sin(p * Math.PI * 2); } }, + 'tri': { label: 'Triangle', icon: '△', fn: function (p) { return 1 - 4 * Math.abs(((p + 0.25) % 1) - 0.5); } }, + 'saw': { label: 'Saw Up', icon: '⟋', fn: function (p) { return 2 * (p % 1) - 1; } }, + 'sawdn': { label: 'Saw Down', icon: '⟍', fn: function (p) { return 1 - 2 * (p % 1); } }, + 'square': { label: 'Square', icon: '⊓', fn: function (p) { return (p % 1) < 0.5 ? 1 : -1; } }, + 'pulse': { label: 'Pulse 25%', icon: '⌐', fn: function (p) { return (p % 1) < 0.25 ? 1 : -1; } }, + 'tanhsat': { label: 'Tanh Sat', icon: '⌢', fn: function (p) { var s = Math.sin(p * Math.PI * 2); return Math.tanh(s * 2.5) / Math.tanh(2.5); } }, + 'rectified': { label: 'Rectified', icon: '⌒', fn: function (p) { return Math.abs(Math.sin(p * Math.PI * 2)) * 2 - 1; } }, + 'harm2': { label: 'Sine+2nd', icon: '⏝', fn: function (p) { return Math.sin(p * Math.PI * 2) + 0.3 * Math.sin(p * Math.PI * 4); } }, + 'steps4': { label: 'Stepped 4', icon: '⊟', fn: function (p) { return Math.round(Math.sin(p * Math.PI * 2) * 4) / 4; } }, + 'steps8': { label: 'Stepped 8', icon: '⊞', fn: function (p) { return Math.round(Math.sin(p * Math.PI * 2) * 8) / 8; } }, + 'sah': { label: 'S&H', icon: '⫾', fn: function (p) { return _weqHashI(Math.floor(p * 4) * 73 + 17); } }, + 'noise': { label: 'Smooth Noise', icon: '⁘', fn: function (p) { return _weqSmoothNoise(p * 4); } }, + 'multilayer': { label: 'Multi-Layer', icon: '★', fn: function (p) { return _weqSmoothNoise(p * 3) * 0.5 + _weqSmoothNoise(p * 7.3 + 3.1) * 0.3 + _weqHashI(Math.floor(p * 6) * 41) * 0.2; } }, + 'cubic': { label: 'Cubic Sine', icon: '◠', fn: function (p) { var s = Math.sin(p * Math.PI * 2); return s * s * s; } } +}; +var WEQ_LFO_SHAPE_KEYS = Object.keys(WEQ_LFO_SHAPES); + +// ── Drift Texture Modes ── +var weqDriftTexture = 'smooth'; // drift character / texture +var WEQ_DRIFT_TEXTURES = { + 'smooth': { label: 'Smooth', desc: 'Dual sine, low harmonics' }, + 'wander': { label: 'Wander', desc: 'Hermite noise, slow rate' }, + 'jitter': { label: 'Jitter', desc: 'High-rate noise + hash' }, + 'drunk': { label: 'Drunk', desc: 'Layered noise, low correlation' }, + 'stutter': { label: 'Stutter', desc: 'Quantized hold + glide' }, + 'chaos': { label: 'Chaos', desc: '5-layer noise + sine + hash' } +}; +var WEQ_DRIFT_TEXTURE_KEYS = Object.keys(WEQ_DRIFT_TEXTURES); + +// Evaluate drift texture at given phase with per-point seed +function _weqDriftEval(texture, phase, seed) { + var p = phase; + switch (texture) { + case 'smooth': + return Math.sin(p * Math.PI * 2) * 0.7 + Math.sin(p * Math.PI * 2 * 2.17 + 1.3) * 0.3; + case 'wander': + return _weqSmoothNoise(p * 2 + seed * 0.1) * 0.6 + _weqSmoothNoise(p * 0.7 + seed * 0.3) * 0.4; + case 'jitter': + return _weqSmoothNoise(p * 9.3 + seed) * 0.4 + _weqSmoothNoise(p * 17 + seed * 2) * 0.35 + _weqHashI(Math.floor(p * 12) * 41 + seed) * 0.25; + case 'drunk': + // Brownian-style: accumulate small random steps (simulated via layered noise) + return _weqSmoothNoise(p * 1.5 + seed * 0.7) * 0.5 + _weqSmoothNoise(p * 3.7 + seed * 1.3) * 0.3 + _weqSmoothNoise(p * 0.4 + seed * 2.1) * 0.2; + case 'stutter': + // Stepped random: hold value for a period then jump + var stepIdx = Math.floor(p * 6); + var stepVal = _weqHashI(stepIdx * 73 + seed * 11); + var nextVal = _weqHashI((stepIdx + 1) * 73 + seed * 11); + var stepFrac = (p * 6) - stepIdx; + // Quick cubic-eased glide at step boundaries + var glide = stepFrac < 0.15 ? stepFrac / 0.15 : 1; + var eased = 1 - Math.pow(1 - glide, 3); + return stepVal + (nextVal - stepVal) * eased; + case 'chaos': + return _weqSmoothNoise(p * 4 + seed) * 0.3 + + _weqSmoothNoise(p * 9.3 + seed + 5.7) * 0.25 + + _weqSmoothNoise(p * 17.1 + seed + 11.2) * 0.2 + + _weqHashI(Math.floor(p * 8) * 31 + seed) * 0.15 + + Math.sin(p * Math.PI * 2 * 3.14 + seed) * 0.1; + default: + return Math.sin(p * Math.PI * 2); + } +} + +// ── Drift state (from lane module) ── +var weqDrift = 0; // speed/character: -50..+50 (+slow / -jitter, >70% = sharp) +var weqDriftRange = 5; // amplitude as % of gain range (0..50) +var weqDriftScale = '1/1'; // musical period for one drift cycle +var weqDriftContinuous = false; // continuous mode: also modulate gain with cursed noise +var weqDriftMode = 'independent'; // 'independent' = each point has own noise (kept for compat) + +// ── Modulation Zone (separate frequency limits for gain LFO vs drift) ── +var weqGainLoCut = 20; // Hz — LFO gain: no modulation below this +var weqGainHiCut = 20000; // Hz — LFO gain: no modulation above this +var weqDriftLoCut = 20; // Hz — Drift: no modulation below this +var weqDriftHiCut = 20000; // Hz — Drift: no modulation above this + +// Returns 0 or 1: hard cut at the boundary. +function _weqGainZoneScale(pointX) { + if (weqGainLoCut <= 20 && weqGainHiCut >= 20000) return 1; + var freq = weqXToFreq(pointX); + if (weqGainLoCut > 20 && freq < weqGainLoCut) return 0; + if (weqGainHiCut < 20000 && freq > weqGainHiCut) return 0; + return 1; +} +function _weqDriftZoneScale(pointX) { + if (weqDriftLoCut <= 20 && weqDriftHiCut >= 20000) return 1; + var freq = weqXToFreq(pointX); + if (weqDriftLoCut > 20 && freq < weqDriftLoCut) return 0; + if (weqDriftHiCut < 20000 && freq > weqDriftHiCut) return 0; + return 1; +} + +// ── Q Modulation ── +var weqQModSpeed = 0; // Hz (0 = off) +var weqQModDepth = 50; // 0..100 — how much Q changes +var weqQModShape = 'sine'; // shape key +var weqQLoCut = 20; // Hz — Q mod low cut +var weqQHiCut = 20000; // Hz — Q mod high cut +var weqAnimBaseQ = []; // snapshot of base Q values + +var WEQ_QMOD_SHAPES = { + sine: { label: 'Sine', fn: function (p) { return Math.sin(p * Math.PI * 2); } }, + tri: { label: 'Triangle', fn: function (p) { return 2 * Math.abs(2 * (p % 1) - 1) - 1; } }, + saw: { label: 'Saw', fn: function (p) { return 2 * (p % 1) - 1; } }, + square: { label: 'Square', fn: function (p) { return (p % 1) < 0.5 ? 1 : -1; } }, + pulse: { label: 'Pulse', fn: function (p) { var t = p % 1; return t < 0.1 ? Math.sin(t / 0.1 * Math.PI) : 0; } }, + noise: { label: 'Noise', fn: function (p, s) { return _weqSmoothNoise(p * 1.7 + (s || 0) * 3.1) * 0.6 + _weqSmoothNoise(p * 4.3 + (s || 0) * 7.7) * 0.4; } }, + steps: { label: 'Steps', fn: function (p, s) { return _weqHashI(Math.floor(p * 4) * 17 + (s || 0)) * 2 - 1; } }, + scatter: { label: 'Scatter', fn: function (p, s) { return _weqHashI(Math.floor(p * 8) * 31 + (s || 0)) * 2 - 1; } }, + breathe: { label: 'Breathe', fn: function (p) { var t = p % 1; return Math.pow(Math.sin(t * Math.PI), 3); } }, + comb: { label: 'Comb', fn: function (p) { var t = p % 1; return Math.abs(Math.sin(t * Math.PI * 6)) * 2 - 1; } }, + ratchet: { label: 'Ratchet', fn: function (p) { var cyc = Math.floor(p) % 4; var t = p % 1; return (cyc / 3) * Math.abs(Math.sin(t * Math.PI * 2)); } }, + formant: { label: 'Formant', fn: function (p, s) { var f1 = Math.sin(p * Math.PI * 2); var f2 = Math.sin(p * Math.PI * 5.3 + (s || 0)); return f1 * 0.6 + f2 * 0.4; } } +}; +var WEQ_QMOD_SHAPE_KEYS = Object.keys(WEQ_QMOD_SHAPES); + +function _weqQZoneScale(pointX) { + if (weqQLoCut <= 20 && weqQHiCut >= 20000) return 1; + var freq = weqXToFreq(pointX); + if (weqQLoCut > 20 && freq < weqQLoCut) return 0; + if (weqQHiCut < 20000 && freq > weqQHiCut) return 0; + return 1; +} + +// ── Drift noise helpers (Hermite-interpolated value noise — matches lane_module.js) ── +function _weqHashI(n) { + var h = n | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; h = Math.imul(h, 0x45d9f3b) | 0; + h = ((h >>> 16) ^ h) | 0; + return ((h & 0xFFFF) / 32768.0) - 1.0; +} +function _weqSmoothNoise(phase) { + var i0 = Math.floor(phase); + var frac = phase - i0; + var v0 = _weqHashI(i0 - 1), v1 = _weqHashI(i0), v2 = _weqHashI(i0 + 1), v3 = _weqHashI(i0 + 2); + var a = -0.5 * v0 + 1.5 * v1 - 1.5 * v2 + 0.5 * v3; + var b2 = v0 - 2.5 * v1 + 2.0 * v2 - 0.5 * v3; + var c = -0.5 * v0 + 0.5 * v2; + return ((a * frac + b2) * frac + c) * frac + v1; +} + +// Start/stop animation loop +var weqAnimBaseX = []; // snapshot of base X values (for frequency drift travel) + +// Single source of truth: does any modulation source need the animation loop? +function _weqNeedsAnim() { + return weqAnimSpeed > 0 + || (Math.abs(weqDrift) > 0 && weqDriftRange > 0) + || (weqDriftContinuous && weqDriftRange > 0) + || (weqQModSpeed > 0 && weqQModDepth > 0); +} + +function weqAnimStart() { + if (weqAnimRafId) return; + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + weqAnimBaseQ = wrongEqPoints.map(function (p) { return p.q || 0.707; }); + weqAnimLastTime = performance.now(); + _weqDriftTimeAccum = 0; + weqAnimRafId = requestAnimationFrame(weqAnimTick); +} +function weqAnimStop() { + if (weqAnimRafId) { + cancelAnimationFrame(weqAnimRafId); + weqAnimRafId = null; + } + // Restore base positions (both X and Y) + for (var i = 0; i < wrongEqPoints.length; i++) { + if (i < weqAnimBaseY.length) wrongEqPoints[i].y = weqAnimBaseY[i]; + if (i < weqAnimBaseX.length) wrongEqPoints[i].x = weqAnimBaseX[i]; + if (i < weqAnimBaseQ.length) wrongEqPoints[i].q = weqAnimBaseQ[i]; + } + weqAnimPhase = 0; + _weqDriftTimeAccum = 0; + weqDrawCanvas(); + weqSyncToHost(); +} + +var _weqAnimSyncCounter = 0; +var _weqDriftTimeAccum = 0; // accumulated drift time in seconds +function weqAnimTick(now) { + try { + var hasSine = weqAnimSpeed > 0; + var driftAmt = Math.abs(weqDrift) / 50; + var driftRangeNorm = weqDriftRange / 100; + var hasDrift = driftAmt > 0.001 && driftRangeNorm > 0.001; + var hasContinuous = weqDriftContinuous && driftRangeNorm > 0.001; + var hasQMod = weqQModSpeed > 0 && weqQModDepth > 0; + + if (!hasSine && !hasDrift && !hasContinuous && !hasQMod) { weqAnimStop(); return; } + + var dt = (now - weqAnimLastTime) / 1000; + if (dt > 0.1) dt = 0.016; // cap delta to prevent huge jumps + weqAnimLastTime = now; + + // Advance sine phase + if (hasSine) { + weqAnimPhase += dt * weqAnimSpeed; + if (weqAnimPhase > 1) weqAnimPhase -= Math.floor(weqAnimPhase); + } + + // Advance drift time + _weqDriftTimeAccum += dt; + + var nPts = wrongEqPoints.length; + if (nPts === 0) { weqAnimRafId = requestAnimationFrame(weqAnimTick); return; } + + if (weqAnimBaseY.length !== nPts || weqAnimBaseX.length !== nPts || weqAnimBaseQ.length !== nPts) { + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + weqAnimBaseQ = wrongEqPoints.map(function (p) { return p.q || 0.707; }); + } + + // Compute drift rate and range + var driftSweepRate = 0, driftSweepWidth = 0; + if (hasDrift || hasContinuous) { + var DS_BEAT_MAP = { '1/16': 0.25, '1/8': 0.5, '1/4': 1, '1/2': 2, '1/1': 4, '2/1': 8, '4/1': 16, '8/1': 32, '16/1': 64, '32/1': 128 }; + var driftScaleBeats = DS_BEAT_MAP[weqDriftScale || '1/1'] || 4; + var driftPeriodSec = driftScaleBeats * 0.5; + + // Rate scales linearly with drift amount (texture is independent) + driftSweepRate = hasDrift ? (0.05 + driftAmt * 1.95) : 0.15; + driftSweepRate /= driftPeriodSec; + driftSweepWidth = (weqDriftRange / 50) * 4.0; + } + + + // Modulate each point + for (var i = 0; i < nPts; i++) { + var baseDB = weqYToDB(weqAnimBaseY[i]); + var baseX = weqAnimBaseX[i]; + var totalModDB = 0; + + // Separate zone scales: gain (vertical) vs drift (horizontal) + var gainZone = _weqGainZoneScale(baseX); + var driftZone = _weqDriftZoneScale(baseX); + + if (hasSine && gainZone > 0) { + var phaseOffset = nPts > 1 ? (i / (nPts - 1)) * 0.5 : 0; + var shapeFn = (WEQ_LFO_SHAPES[weqAnimShape] || WEQ_LFO_SHAPES.sine).fn; + totalModDB += shapeFn(weqAnimPhase + phaseOffset) * weqAnimDepth; + } + + // 2a) Drift = frequency sweep LFO (modulates X position) + if (hasDrift && driftZone > 0) { + var ptPhaseOff = _weqHashI(i * 73 + 11) * 0.5; + var sweepPhase = _weqDriftTimeAccum * driftSweepRate + ptPhaseOff; + + var sweep = _weqDriftEval(weqDriftTexture, sweepPhase, i * 73 + 11); + + var baseFreq = weqXToFreq(baseX); + var sweepOctaves = sweep * driftSweepWidth; + var newFreq = baseFreq * Math.pow(2, sweepOctaves); + newFreq = Math.max(WEQ_MIN_FREQ, Math.min(WEQ_MAX_FREQ, newFreq)); + wrongEqPoints[i].x = weqFreqToX(newFreq); + } else { + wrongEqPoints[i].x = baseX; + } + + // 2b) Continuous = gain noise (modulates Y) — uses gain zone + if (hasContinuous && gainZone > 0) { + var gainRate = hasDrift ? driftSweepRate : 0.15; + var gainSeed = i * 137 + 47; + var gainPhaseOff = _weqHashI(gainSeed) * 0.7; + var gainPhase = _weqDriftTimeAccum * gainRate * 0.8 + gainPhaseOff; + + var gainNoise = + _weqSmoothNoise(gainPhase * 1.0 + _weqHashI(gainSeed + 1) * 3.0) * 0.35 + + _weqSmoothNoise(gainPhase * 2.71 + _weqHashI(gainSeed + 2) * 7.0) * 0.30 + + _weqSmoothNoise(gainPhase * 6.28 + _weqHashI(gainSeed + 3) * 13.0) * 0.20 + + _weqSmoothNoise(gainPhase * 13.7 + _weqHashI(gainSeed + 4) * 19.0) * 0.15; + + var gainModDB = gainNoise * (weqDriftRange / 50) * 18.0; + if (hasDrift) gainModDB *= driftAmt; + totalModDB += gainModDB; + } + + var newDB = Math.max(-weqDBRangeMax, Math.min(weqDBRangeMax, baseDB + totalModDB)); + wrongEqPoints[i].y = weqDBtoY(newDB); + + // 3) Q modulation + if (hasQMod && _weqQZoneScale(baseX) > 0) { + var baseQ = weqAnimBaseQ[i]; + var qSeed = i * 211 + 59; + var qPhaseOff = nPts > 1 ? (i / (nPts - 1)) * 0.3 : 0; + var qPhase = _weqDriftTimeAccum * weqQModSpeed + qPhaseOff; + var qShapeFn = (WEQ_QMOD_SHAPES[weqQModShape] || WEQ_QMOD_SHAPES.sine).fn; + var qMod = qShapeFn(qPhase, qSeed); + var qDepthMul = weqQModDepth / 100; + var qMultiplier = Math.pow(2, qMod * qDepthMul * 2); + var newQ = Math.max(0.1, Math.min(30, baseQ * qMultiplier)); + wrongEqPoints[i].q = newQ; + } else if (hasQMod) { + wrongEqPoints[i].q = weqAnimBaseQ[i]; + } + } + + // Redraw canvas only when popup is visible (save CPU) + var overlay = document.getElementById('weqOverlay'); + if (overlay && overlay.classList.contains('visible')) { + weqDrawCanvas(); + } + + // Sync to C++ at ~10Hz (every 6th frame) — drift only + _weqAnimSyncCounter++; + if (_weqAnimSyncCounter >= 6) { + _weqAnimSyncCounter = 0; + weqSyncToHost(); + weqSyncVirtualParams(); + } + } catch (err) { + // Don't let animation errors break all event handlers + if (typeof console !== 'undefined') console.warn('weqAnimTick error:', err); + } + + weqAnimRafId = requestAnimationFrame(weqAnimTick); +} + +// ── Render the WrongEQ panel HTML ── +var _weqLastPtCount = -1; +function weqRenderPanel() { + var el = document.getElementById('weqPanel'); + if (!el) return; + + // Rebuild virtual block when point count changes + if (_weqVirtualBlock && wrongEqPoints.length !== _weqLastPtCount) { + _weqLastPtCount = wrongEqPoints.length; + weqRebuildVirtualBlock(); + } + + // Update plugin card bus dropdowns when band data changes (freq/Q/type) + // Uses a stamp to avoid expensive re-renders on every animation frame + if (routingMode === 2 && typeof renderAllPlugins === 'function') { + var bandStamp = wrongEqPoints.map(function (p) { + return (p.uid || 0) + ':' + p.x.toFixed(3) + ':' + (p.q || 0.707).toFixed(3) + ':' + (p.type || 'Bell'); + }).join('|'); + if (bandStamp !== weqRenderPanel._lastBandStamp) { + weqRenderPanel._lastBandStamp = bandStamp; + renderAllPlugins(); + } + } + + var h = ''; + + // Header + h += '
'; + h += '
'; + h += 'WRONGEQ'; + h += '' + wrongEqPoints.length + ' points'; + h += ''; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + var osLabel = weqOversample === 4 ? '4×' : weqOversample === 2 ? '2×' : 'Off'; + h += ''; + h += ''; + h += '
'; + h += '
'; + + h += '
'; + h += 'Grid'; + h += '
'; + ['free', 'oct', '1/3', 'semi'].forEach(function (g) { + h += ''; + }); + h += '
'; + h += '
'; + var splitLabel = '⫿ Split'; + if (weqSplitMode && wrongEqPoints.length > 0) splitLabel += ' ×' + (wrongEqPoints.length + 1); + h += ''; + h += ''; + h += '
'; + + // Canvas area + h += '
'; + h += '
'; + h += '
'; + // Canvas (axis labels are drawn directly on the canvas — no HTML duplicates) + h += '
'; + h += ''; + h += '
'; + h += '
'; // end weq-canvas-area + + // Band legend — Q-based ranges matching C++ crossover splits + if (wrongEqPoints.length > 0) { + // Sort points by position — use base X during animation to avoid jittery labels + var legendPts = wrongEqPoints.map(function (p, idx) { + var stableX = (weqAnimRafId && weqAnimBaseX.length > idx) ? weqAnimBaseX[idx] : p.x; + return { x: stableX, ref: p, origIdx: idx }; + }); + legendPts.sort(function (a, b) { return a.x - b.x; }); + var legendRefs = legendPts.map(function (lp) { return lp.ref; }); + + // Compute Q-based [lo, hi] for each sorted point (matching C++ ptLo/ptHi) + var qRanges = legendPts.map(function (lp) { return weqBandRange(lp.ref); }); + + // Handle overlapping Q ranges — same logic as C++: + // when ptHi[i] > ptLo[i+1], split at geometric midpoint + for (var oi = 0; oi < qRanges.length - 1; oi++) { + if (qRanges[oi].hi > qRanges[oi + 1].lo) { + var mid = Math.sqrt(qRanges[oi].hi * qRanges[oi + 1].lo); + qRanges[oi].hi = mid; + qRanges[oi + 1].lo = mid; + } + } + + h += '
'; + for (var i = 0; i < legendPts.length; i++) { + var lo = qRanges[i].lo; + var hi = qRanges[i].hi; + var col = _weqBandColor(i + 1, legendRefs); + var hasPlug = legendPts[i].ref.pluginIds && legendPts[i].ref.pluginIds.length > 0; + h += ''; + h += ''; + h += 'B' + i + ''; + if (!weqSplitMode) { + var bType = legendPts[i].ref.type || 'Bell'; + h += '' + bType + ''; + } + h += '' + weqFmtFreq(lo) + '\u2013' + weqFmtFreq(hi) + ''; + if (hasPlug) h += ''; + h += ''; + } + // In split mode: add passthrough zone chip + if (weqSplitMode && legendPts.length > 0) { + var lastPtFreq = weqXToFreq(legendPts[legendPts.length - 1].x); + h += ''; + h += '' + weqFmtFreq(lastPtFreq) + '\u2013' + weqFmtFreq(20000) + ' pass'; + h += ''; + } + h += '
'; + } + + h += '
'; // end weq-body-main + + // ── SIDE PANEL — Modulation Controls ── + h += '
'; + + // ─── CURVE section ─── + h += '
'; + h += '
Curve
'; + h += '
Dep' + weqGlobalDepth + '%
'; + h += '
Wrp' + (weqGlobalWarp >= 0 ? '+' : '') + weqGlobalWarp + '
'; + h += '
Stp' + (weqGlobalSteps || 'Off') + '
'; + h += '
Tlt' + (weqGlobalTilt >= 0 ? '+' : '') + weqGlobalTilt + '
'; + h += '
'; + + + + // ─── DRIFT section (freq sweep only — independent operation) ─── + var driftActive = Math.abs(weqDrift) > 0 && weqDriftRange > 0; + h += '
'; + h += '
Drift
'; + h += '
Spd' + (weqDrift >= 0 ? '+' : '') + weqDrift + '
'; + h += '
Rng' + weqDriftRange + '%
'; + h += '
Scl
'; + h += '
Tex
'; + h += '
'; + h += ''; + h += '
'; + h += '
Lo' + (weqDriftLoCut > 20 ? weqFmtFreq(weqDriftLoCut) : 'Off') + '
'; + h += '
Hi' + (weqDriftHiCut < 20000 ? weqFmtFreq(weqDriftHiCut) : 'Off') + '
'; + h += '
'; + + // ─── LFO section ─── + h += '
'; + h += '
LFO
'; + var _spdDisp = weqAnimSpeed > 0 ? (weqAnimSpeed < 1 ? weqAnimSpeed.toFixed(2) + 'Hz' : weqAnimSpeed.toFixed(1) + 'Hz') : 'Off'; + h += '
Rate' + _spdDisp + '
'; + h += '
Dep' + weqAnimDepth + 'dB
'; + h += '
Shp
'; + h += '
Lo' + (weqGainLoCut > 20 ? weqFmtFreq(weqGainLoCut) : 'Off') + '
'; + h += '
Hi' + (weqGainHiCut < 20000 ? weqFmtFreq(weqGainHiCut) : 'Off') + '
'; + h += '
'; + + // ─── Q MOD section ─── + var qModActive = weqQModSpeed > 0 && weqQModDepth > 0; + h += '
'; + h += '
Q Mod
'; + var _qSpdDisp = weqQModSpeed > 0 ? (weqQModSpeed < 1 ? weqQModSpeed.toFixed(2) + 'Hz' : weqQModSpeed.toFixed(1) + 'Hz') : 'Off'; + h += '
Rate' + _qSpdDisp + '
'; + h += '
Dep' + weqQModDepth + '%
'; + h += '
Shp
'; + h += '
Lo' + (weqQLoCut > 20 ? weqFmtFreq(weqQLoCut) : 'Off') + '
'; + h += '
Hi' + (weqQHiCut < 20000 ? weqFmtFreq(weqQHiCut) : 'Off') + '
'; + h += '
'; + + // ─── RANGE section ─── + h += '
'; + h += '
Range
'; + h += '
dB
'; + h += '
'; + + h += '
'; // end weq-side-panel + h += '
'; // end weq-body-wrap + + // ── Band Cards (vertical box per band, horizontal scroll) ── + if (wrongEqPoints.length > 0) { + var anySoloRow = false; + for (var sri = 0; sri < wrongEqPoints.length; sri++) if (wrongEqPoints[sri].solo) anySoloRow = true; + + h += '
'; + // Segment toolbar inline when 2+ selected (no title header) + if (weqSegSel.size >= 2) { + h += '
'; + h += '
'; + h += '' + weqSegSel.size + ' sel'; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + h += '
'; + } + + // ── Row container: routing sidebar + band cards ── + h += '
'; + + // ── Routing sidebar: vertical tab + collapsible panel ── + var _rpGlobalPlugins = []; + var _rpGlobalCount = 0; + if (typeof pluginBlocks !== 'undefined' && pluginBlocks.length > 0) { + var _assignedIds = new Set(); + for (var _ai = 0; _ai < wrongEqPoints.length; _ai++) { + var _aids = wrongEqPoints[_ai].pluginIds || []; + for (var _aii = 0; _aii < _aids.length; _aii++) _assignedIds.add(_aids[_aii]); + } + for (var _gpi = 0; _gpi < pluginBlocks.length; _gpi++) { + var _gp = pluginBlocks[_gpi]; + if (_gp && _gp.id != null && !_gp.isVirtual && !_assignedIds.has(_gp.id)) { + _rpGlobalPlugins.push(_gp); + } + } + _rpGlobalCount = _rpGlobalPlugins.length; + } + + var rpOpen = window._weqRoutingPanelOpen || false; + h += '
'; + h += '
'; + h += 'R O U T I N G'; + if (_rpGlobalCount > 0) h += '' + _rpGlobalCount + ''; + h += '
'; + h += '
'; + // ── Signal chain header ── + h += '
'; + h += 'Signal Chain'; + h += '
'; + + // ── WrongEQ — always first in chain, non-removable ── + var _weqBypassed = typeof weqGlobalBypass !== 'undefined' && weqGlobalBypass; + h += '
'; + h += '
'; + h += ''; + h += 'WrongEQ'; + h += '' + wrongEqPoints.length + ' band' + (wrongEqPoints.length !== 1 ? 's' : '') + ''; + h += '
'; + + // ── Chain arrow ── + h += '
'; + + // ── Global Inserts section ── + h += '
'; + h += ''; + h += '
'; + + if (_rpGlobalCount > 0) { + for (var _gli = 0; _gli < _rpGlobalPlugins.length; _gli++) { + var _glp = _rpGlobalPlugins[_gli]; + var _glpByp = !!_glp.bypassed; + h += '
'; + h += '' + _glp.name + ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + } + } else { + h += '
No post-EQ plugins
'; + } + h += '
'; // end chain list + // Add plugin button for global section + h += '
'; + h += ''; + h += '
'; + h += '
'; // end panel content + h += '
'; // end routing sidebar + + h += '
'; + + // Sort band cards by frequency (left-to-right matches canvas) + var _bandOrder = []; + for (var _bi = 0; _bi < wrongEqPoints.length; _bi++) _bandOrder.push(_bi); + _bandOrder.sort(function (a, b) { + var ax = (weqAnimRafId && weqAnimBaseX.length > a) ? weqAnimBaseX[a] : wrongEqPoints[a].x; + var bx = (weqAnimRafId && weqAnimBaseX.length > b) ? weqAnimBaseX[b] : wrongEqPoints[b].x; + return ax - bx; + }); + + for (var _boi = 0; _boi < _bandOrder.length; _boi++) { + var ri = _bandOrder[_boi]; + var pt = wrongEqPoints[ri]; + var col = _weqPointColor(pt); + var isSoloed = pt.solo; + var isMuted = pt.mute; + var dimmed = isMuted || (anySoloRow && !isSoloed); + + var displayX = (weqAnimRafId && weqAnimBaseX.length > ri) ? weqAnimBaseX[ri] : pt.x; + var displayY = (weqAnimRafId && weqAnimBaseY.length > ri) ? weqAnimBaseY[ri] : pt.y; + var rFreq = weqXToFreq(displayX); + var rGain = weqYToDB(displayY); + var rQ = pt.q != null ? pt.q : 0.707; + var rType = pt.type || 'Bell'; + var isSegSelected = weqSegSel.has(ri); + var ptPreEq = pt.preEq !== false; + + var classes = 'weq-band-card'; + if (dimmed) classes += ' dimmed'; + if (isMuted) classes += ' muted'; + if (isSoloed) classes += ' soloed'; + if (weqSelectedPt === ri) classes += ' focused'; + if (isSegSelected) classes += ' seg-sel'; + + // Wrap card + strip in a unit container + h += '
'; + h += '
'; + + // ── Top accent bar ── + h += '
'; + + // ── Card header: band number + controls ── + h += '
'; + h += '' + (ri + 1) + ''; + h += ''; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + + // ── Type selector ── + var types = ['LP', 'HP', 'Bell', 'Notch', 'LS', 'HS']; + var typeMap = { 'LP': 'LP', 'HP': 'HP', 'Bell': 'Bell', 'Notch': 'Notch', 'LS': 'LShf', 'HS': 'HShf' }; + h += '
'; + for (var ti = 0; ti < types.length; ti++) { + var tLabel = types[ti]; + var tVal = typeMap[tLabel]; + var isActive = (rType === tVal) ? ' active' : ''; + h += ''; + } + h += '
'; + + // ── Slope selector (12/24/48 dB/oct) ── + var ptSlope = pt.slope || 1; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + + // ── Frequency — hero value (draggable) ── + h += '
' + weqFmtFreq(rFreq) + '
'; + + // ── Gain + Q — labeled param boxes ── + h += '
'; + if (rType === 'LP' || rType === 'HP') { + var slopeDB = (ptSlope || 1) * 12; + h += '
GAIN' + slopeDB + 'dB/o
'; + } else { + var gCls = rGain > 0.1 ? ' boost' : (rGain < -0.1 ? ' cut' : ''); + h += '
GAIN' + weqFmtDB(rGain) + '
'; + } + h += '
Q' + rQ.toFixed(2) + '
'; + h += '
'; + + // ── Stereo + Mode row ── + h += '
'; + var sm = pt.stereoMode || 0; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + h += ''; + h += '
'; // end weq-card-mode-row + + h += '
'; // end card + + // ── Vertical strip (+ button, attached to card right) ── + var bandPlugins = pt.pluginIds || []; + var plugCount = bandPlugins.length; + var stripActive = (window._weqRoutingOpen === ri) ? ' active' : ''; + h += '
'; + h += '+'; + if (plugCount > 0) h += '' + plugCount + ''; + h += '
'; + + h += '
'; // end weq-band-unit + + // ── Routing panel (inline, hidden by default) ── + var panelOpen = (window._weqRoutingOpen === ri); + h += '
'; + h += '
ROUTINGBand ' + (ri + 1) + '
'; + h += '
'; + if (plugCount > 0) { + for (var bpi = 0; bpi < bandPlugins.length; bpi++) { + var bpId = bandPlugins[bpi]; + var bpName = 'Plugin ' + bpId; + var bpBypassed = false; + for (var pbi = 0; pbi < pluginBlocks.length; pbi++) { + if (pluginBlocks[pbi].id === bpId) { bpName = pluginBlocks[pbi].name; bpBypassed = !!pluginBlocks[pbi].bypassed; break; } + } + h += '
'; + h += '' + (bpi + 1) + ''; + // Reorder arrows + h += ''; + if (bpi > 0) h += ''; + if (bpi < bandPlugins.length - 1) h += ''; + h += ''; + h += '' + bpName + ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + } + } else { + h += '
No plugins routed
'; + } + h += '
'; // end scroll + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; // end routing panel + } + + h += '
'; // end weq-bands-scroll + h += '
'; // end weq-bands-row + h += '
'; // end weq-bands-section + } + + // Save scroll positions before DOM rebuild + var savedScroll = el.scrollTop; + var bandsScroll = el.querySelector('.weq-bands-scroll'); + var savedBandsScroll = bandsScroll ? bandsScroll.scrollLeft : 0; + + el.innerHTML = h; + + // Setup canvas + events + weqCanvasSetup(); + weqSetupEvents(); + + // Restore scroll positions after DOM rebuild + if (savedScroll > 0) el.scrollTop = savedScroll; + if (savedBandsScroll > 0) { + var newBands = el.querySelector('.weq-bands-scroll'); + if (newBands) newBands.scrollLeft = savedBandsScroll; + } + + // Trigger plugin rack re-render so bus dropdowns reflect EQ point frequencies + if (typeof renderAllPlugins === 'function') renderAllPlugins(); +} + +// ── Canvas sizing and initial draw ── +function weqCanvasSetup() { + var wrap = document.getElementById('weqCanvasWrap'); + var canvas = document.getElementById('weqCanvas'); + if (!wrap || !canvas) return; + + function sizeCanvas() { + var rect = wrap.getBoundingClientRect(); + var W = Math.round(rect.width); + if (W < 50) return; // not laid out yet + var H = WEQ_CANVAS_H; + wrap.style.height = H + 'px'; + + var dpr = window.devicePixelRatio || 1; + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + weqDrawCanvas(); + } + + sizeCanvas(); + + // ResizeObserver for responsive canvas + if (typeof ResizeObserver !== 'undefined' && !wrap._weqRO) { + wrap._weqRO = new ResizeObserver(function () { + sizeCanvas(); + }); + wrap._weqRO.observe(wrap); + } +} + +// Actual sample rate from C++ (updated via __rt_data__.sr). Default 48kHz until first update. +// Previously hardcoded — caused wrong curve shape at 44.1kHz/96kHz near Nyquist. +var _WEQ_REF_FS = 48000; + +function _weqBiquadDB(b0, b1, b2, a0, a1, a2, w) { + // H(e^jw) = (b0 + b1*e^-jw + b2*e^-2jw) / (a0 + a1*e^-jw + a2*e^-2jw) + var cw = Math.cos(w), sw = Math.sin(w); + var c2w = Math.cos(2 * w), s2w = Math.sin(2 * w); + + var nr = b0 + b1 * cw + b2 * c2w; // real part of numerator + var ni = -b1 * sw - b2 * s2w; // imag part of numerator + var dr = a0 + a1 * cw + a2 * c2w; // real part of denominator + var di = -a1 * sw - a2 * s2w; // imag part of denominator + + var numMagSq = nr * nr + ni * ni; + var denMagSq = dr * dr + di * di; + if (denMagSq < 1e-30) return 0; + + var magSq = numMagSq / denMagSq; + if (magSq < 1e-30) return -200; + return 10 * Math.log10(magSq); +} + +// ── Evaluate single band's dB contribution ── +function _weqBandDB(xPos, band) { + var gainDB = weqYToDB(band.y); + var type = band.type || 'Bell'; + var Q = Math.max(0.025, band.q || 0.707); + var f0 = weqXToFreq(band.x); + var f = weqXToFreq(xPos); + + // LP/HP are unity-gain filters — gain has no effect on DSP. + // Skip the gain check for LP/HP; always evaluate their response. + if ((type === 'Bell' || type === 'LShf' || type === 'HShf') && Math.abs(gainDB) < 0.1) return 0; + + var w0 = 2 * Math.PI * f0 / _WEQ_REF_FS; + var w = 2 * Math.PI * f / _WEQ_REF_FS; + var sw0 = Math.sin(w0), cw0 = Math.cos(w0); + var b0, b1, b2, a0, a1, a2; + + if (type === 'Bell') { + // Peaking EQ — A = 10^(dBgain/40) + var A = Math.pow(10, gainDB / 40); + var alpha = sw0 / (2 * Q); + b0 = 1 + alpha * A; + b1 = -2 * cw0; + b2 = 1 - alpha * A; + a0 = 1 + alpha / A; + a1 = -2 * cw0; + a2 = 1 - alpha / A; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + if (type === 'LP') { + // Low-pass: unity-gain, gain parameter is ignored (matches C++ DSP). + // Q controls resonance at cutoff. + var alphaLP = sw0 / (2 * Q); + b0 = (1 - cw0) / 2; + b1 = 1 - cw0; + b2 = (1 - cw0) / 2; + a0 = 1 + alphaLP; + a1 = -2 * cw0; + a2 = 1 - alphaLP; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + if (type === 'HP') { + // High-pass: unity-gain, gain parameter is ignored (matches C++ DSP). + // Q controls resonance at cutoff. + var alphaHP = sw0 / (2 * Q); + b0 = (1 + cw0) / 2; + b1 = -(1 + cw0); + b2 = (1 + cw0) / 2; + a0 = 1 + alphaHP; + a1 = -2 * cw0; + a2 = 1 - alphaHP; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + if (type === 'Notch') { + // Band-reject: full depth, Q controls width + var alphaN = sw0 / (2 * Q); + b0 = 1; + b1 = -2 * cw0; + b2 = 1; + a0 = 1 + alphaN; + a1 = -2 * cw0; + a2 = 1 - alphaN; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + if (type === 'LShf') { + // Low Shelf — Audio EQ Cookbook S (slope) form + // Q knob value is reinterpreted as shelf slope S. + // S=1 = steepest monotonic shelf, S>1 = shelf bump/overshoot. + // Cookbook: 2*sqrt(A)*alpha = sin(w0) * sqrt((A+1/A)*(1/S-1)+2) + var A = Math.pow(10, gainDB / 40); + var S = Q; // reinterpret Q as slope + var twoSqrtAalpha = sw0 * Math.sqrt(Math.max(0, (A + 1 / A) * (1 / S - 1) + 2)); + if (!isFinite(twoSqrtAalpha) || twoSqrtAalpha < 1e-10) twoSqrtAalpha = 1e-10; + b0 = A * ((A + 1) - (A - 1) * cw0 + twoSqrtAalpha); + b1 = 2 * A * ((A - 1) - (A + 1) * cw0); + b2 = A * ((A + 1) - (A - 1) * cw0 - twoSqrtAalpha); + a0 = (A + 1) + (A - 1) * cw0 + twoSqrtAalpha; + a1 = -2 * ((A - 1) + (A + 1) * cw0); + a2 = (A + 1) + (A - 1) * cw0 - twoSqrtAalpha; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + if (type === 'HShf') { + // High Shelf — Audio EQ Cookbook S (slope) form + var A = Math.pow(10, gainDB / 40); + var S = Q; // reinterpret Q as slope + var twoSqrtAalpha = sw0 * Math.sqrt(Math.max(0, (A + 1 / A) * (1 / S - 1) + 2)); + if (!isFinite(twoSqrtAalpha) || twoSqrtAalpha < 1e-10) twoSqrtAalpha = 1e-10; + b0 = A * ((A + 1) + (A - 1) * cw0 + twoSqrtAalpha); + b1 = -2 * A * ((A - 1) + (A + 1) * cw0); + b2 = A * ((A + 1) + (A - 1) * cw0 - twoSqrtAalpha); + a0 = (A + 1) - (A - 1) * cw0 + twoSqrtAalpha; + a1 = 2 * ((A - 1) - (A + 1) * cw0); + a2 = (A + 1) - (A - 1) * cw0 - twoSqrtAalpha; + return _weqBiquadDB(b0, b1, b2, a0, a1, a2, w); + } + return 0; +} + +// ── Evaluate total curve dB at a given X position (0-1 log freq) — additive bands ── +// Warp/Steps/Tilt are applied to the SUMMED gain-based curve (post-sum). +// C++ matches this: depth per-biquad, warp/steps per-biquad (close approx), +// tilt as a separate post-EQ filter. +function weqEvalAtX(xPos) { + var pts = wrongEqPoints; + if (!pts || pts.length === 0) return 0; + + // Total curve always includes ALL non-muted points regardless of solo state. + // Solo is an audio-only concept handled by C++ ProcessBlock — the visual curve + // must always reflect the full EQ shape. + var depthScale = weqGlobalDepth / 100; + var gainDB = 0; // All gain-based: EQ bands — subject to depth, warp, steps, tilt + var unityDB = 0; // LP, HP, Notch — always at full strength, no warp + for (var i = 0; i < pts.length; i++) { + if (pts[i].mute) continue; + var pt = pts[i].type || 'Bell'; + var isGainBased = (pt === 'Bell' || pt === 'LShf' || pt === 'HShf'); + if (isGainBased) { + gainDB += _weqBandDB(xPos, pts[i]) * depthScale; + } else { + unityDB += _weqBandDB(xPos, pts[i]); + } + } + + // Apply global warp to gain-based portion + if (Math.abs(weqGlobalWarp) > 0.5) { + var norm = (gainDB - (-weqDBRangeMax)) / (weqDBRangeMax * 2); + var w = weqGlobalWarp / 100; + if (w > 0) { + var mid = norm * 2 - 1; + norm = 0.5 + 0.5 * Math.tanh(w * 3 * mid) / Math.tanh(w * 3); + } else { + var aw = -w; + var c = norm * 2 - 1; + var sv = c >= 0 ? 1 : -1; + norm = 0.5 + 0.5 * sv * Math.pow(Math.abs(c), 1 / (1 + aw * 3)); + } + gainDB = (-weqDBRangeMax) + norm * (weqDBRangeMax * 2); + } + + // Apply global steps + if (weqGlobalSteps >= 2) { + var stepSize = (weqDBRangeMax * 2) / (weqGlobalSteps - 1); + gainDB = Math.round(gainDB / stepSize) * stepSize; + } + + // Apply global tilt: frequency-dependent gain offset across the whole curve + if (Math.abs(weqGlobalTilt) > 0.5) { + var xFreq = weqXToFreq(xPos); + var logPos = Math.log2(xFreq / 632); + var tiltDB = logPos * (weqGlobalTilt / 100) * 12; + gainDB = Math.max(-weqDBRangeMax, Math.min(weqDBRangeMax, gainDB + tiltDB)); + } + + return gainDB + unityDB; +} + +// ── Main canvas draw ── +function weqDrawCanvas() { + var canvas = document.getElementById('weqCanvas'); + if (!canvas) return; + var ctx = canvas.getContext('2d'); + var dpr = window.devicePixelRatio || 1; + var W = canvas.width / dpr; + var H = canvas.height / dpr; + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + try { + // Background + ctx.fillStyle = weqCssVar('--bg-inset', '#1a1a20'); + ctx.fillRect(0, 0, W, H); + + // Bypass overlay: dim the whole canvas + if (weqGlobalBypass) { + ctx.fillStyle = 'rgba(160, 48, 48, 0.08)'; + ctx.fillRect(0, 0, W, H); + } + + // ── Spectrum analyzer (behind everything) ── + if (weqSpectrumSmooth && weqSpectrumSmooth.length > 0) { + var specBins = weqSpectrumSmooth.length; + var zeroY = (1 - (0 - (-weqDBRangeMax)) / (weqDBRangeMax * 2)) * H; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(0, H); + for (var si = 0; si < specBins; si++) { + var sx = (si / (specBins - 1)) * W; + var sdb = Math.max(-weqDBRangeMax, Math.min(weqDBRangeMax, weqSpectrumSmooth[si])); + var sy = (1 - (sdb - (-weqDBRangeMax)) / (weqDBRangeMax * 2)) * H; + if (si === 0) ctx.moveTo(sx, sy); + else ctx.lineTo(sx, sy); + } + ctx.lineTo(W, H); + ctx.lineTo(0, H); + ctx.closePath(); + var specGrad = ctx.createLinearGradient(0, 0, 0, H); + specGrad.addColorStop(0, 'rgba(60, 180, 200, 0.15)'); + specGrad.addColorStop(0.5, 'rgba(40, 120, 160, 0.08)'); + specGrad.addColorStop(1, 'rgba(20, 60, 100, 0.03)'); + ctx.fillStyle = specGrad; + ctx.fill(); + // Spectrum line + ctx.beginPath(); + for (var si2 = 0; si2 < specBins; si2++) { + var sx2 = (si2 / (specBins - 1)) * W; + var sdb2 = Math.max(-weqDBRangeMax, Math.min(weqDBRangeMax, weqSpectrumSmooth[si2])); + var sy2 = (1 - (sdb2 - (-weqDBRangeMax)) / (weqDBRangeMax * 2)) * H; + if (si2 === 0) ctx.moveTo(sx2, sy2); + else ctx.lineTo(sx2, sy2); + } + ctx.strokeStyle = 'rgba(80, 200, 220, 0.25)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + } + + // ── Split mode: colored band zones ── + if (weqSplitMode && wrongEqPoints.length > 0) { + var splitPts = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var nBands = splitPts.length + 1; + var edges = [0]; // start at x=0 (20Hz) + for (var ei = 0; ei < splitPts.length; ei++) edges.push(splitPts[ei].x); + edges.push(1); // end at x=1 (20kHz) + + for (var bi = 0; bi < nBands; bi++) { + var x0 = edges[bi] * W; + var x1 = edges[bi + 1] * W; + var bandW = x1 - x0; + var isPassthrough = (bi === nBands - 1); // last zone = above highest point + var bandCol = isPassthrough ? weqCssVar('--text-muted', '#666') : _weqBandColor(bi + 1, splitPts); + + // Gradient fill — stronger at edges for depth + ctx.save(); + if (bandW > 4) { + var bandGrad = ctx.createLinearGradient(x0, 0, x1, 0); + var fillAlpha = isPassthrough ? 0.03 : 0.10; + bandGrad.addColorStop(0, weqHexRgba(bandCol, fillAlpha * 0.5)); + bandGrad.addColorStop(0.15, weqHexRgba(bandCol, fillAlpha)); + bandGrad.addColorStop(0.85, weqHexRgba(bandCol, fillAlpha)); + bandGrad.addColorStop(1, weqHexRgba(bandCol, fillAlpha * 0.5)); + ctx.fillStyle = bandGrad; + } else { + ctx.globalAlpha = isPassthrough ? 0.03 : 0.08; + ctx.fillStyle = bandCol; + } + ctx.fillRect(x0, 0, bandW, H); + ctx.restore(); + + // Band label (centered) + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + if (bandW > 24) { + if (isPassthrough) { + // Passthrough zone label + ctx.font = '600 9px ' + weqCssVar('--font-mono', 'monospace'); + ctx.fillStyle = bandCol; + ctx.globalAlpha = 0.3; + ctx.fillText('PASS', (x0 + x1) / 2, H / 2 - 6); + ctx.font = '400 8px ' + weqCssVar('--font-mono', 'monospace'); + ctx.fillText('▸ ' + weqFmtFreq(weqXToFreq(edges[bi])), (x0 + x1) / 2, H / 2 + 6); + } else { + // Band number + plugin indicator + var bandPt = splitPts[bi > 0 ? bi - 1 : 0]; + var hasPlugins = bandPt && bandPt.pluginIds && bandPt.pluginIds.length > 0; + ctx.font = '700 12px ' + weqCssVar('--font-mono', 'monospace'); + ctx.fillStyle = bandCol; + ctx.globalAlpha = hasPlugins ? 0.55 : 0.25; + ctx.fillText('B' + bi, (x0 + x1) / 2, H / 2 - (hasPlugins ? 7 : 0)); + + // Show plugin count badge + if (hasPlugins && bandW > 40) { + ctx.font = '500 8px ' + weqCssVar('--font-mono', 'monospace'); + ctx.globalAlpha = 0.45; + var plugLabel = bandPt.pluginIds.length + ' fx'; + ctx.fillText(plugLabel, (x0 + x1) / 2, H / 2 + 8); + } + } + } + ctx.restore(); + } + + // Divider lines at crossover frequencies + for (var di = 0; di < splitPts.length; di++) { + var dx = splitPts[di].x * W; + var divCol = _weqBandColor(di + 1, splitPts); + + // Glow behind divider + ctx.save(); + var glowGrad = ctx.createLinearGradient(dx - 8, 0, dx + 8, 0); + glowGrad.addColorStop(0, 'rgba(0,0,0,0)'); + glowGrad.addColorStop(0.5, weqHexRgba(divCol, 0.08)); + glowGrad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = glowGrad; + ctx.fillRect(dx - 8, 0, 16, H); + ctx.restore(); + + // Divider line + ctx.save(); + ctx.strokeStyle = divCol; + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(dx, 0); + ctx.lineTo(dx, H); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + // Frequency label at divider (pill background for readability) + var divFreq = weqXToFreq(splitPts[di].x); + var divLabel = divFreq >= 1000 ? (divFreq / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : Math.round(divFreq) + ''; + ctx.save(); + ctx.font = '600 9px ' + weqCssVar('--font-mono', 'monospace'); + var lblW = ctx.measureText(divLabel).width + 8; + // Pill background + ctx.fillStyle = weqCssVar('--bg-card', '#1a1a20'); + ctx.globalAlpha = 0.7; + ctx.beginPath(); + ctx.roundRect(dx - lblW / 2, 4, lblW, 14, 3); + ctx.fill(); + // Text + ctx.fillStyle = divCol; + ctx.globalAlpha = 0.9; + ctx.textAlign = 'center'; + ctx.fillText(divLabel, dx, 14); + ctx.restore(); + } + } + + var sorted = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + + // Check for solo state + var anySolo = false; + for (var si = 0; si < sorted.length; si++) if (sorted[si].solo) anySolo = true; + + // ── Solo band highlight overlay ── + if (anySolo && sorted.length > 0) { + // Dim the entire canvas first + ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.fillRect(0, 0, W, H); + + // Highlight soloed band regions using Q-based frequency ranges + for (var sbi = 0; sbi < sorted.length; sbi++) { + if (!sorted[sbi].solo) continue; + var soloColor = _weqBandColor(sbi + 1, sorted); + // Use Q-based frequency range + var soloRange = weqBandRange(sorted[sbi]); + var regionLeft = weqFreqToX(soloRange.lo) * W; + var regionRight = weqFreqToX(soloRange.hi) * W; + + // Restore the soloed region (un-dim it) + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; + ctx.fillRect(regionLeft, 0, regionRight - regionLeft, H); + ctx.restore(); + + // Add colored glow within Q-based range + var soloGrad = ctx.createLinearGradient(regionLeft, 0, regionRight, 0); + soloGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); + soloGrad.addColorStop(0.1, weqHexRgba(soloColor, 0.10)); + soloGrad.addColorStop(0.5, weqHexRgba(soloColor, 0.14)); + soloGrad.addColorStop(0.9, weqHexRgba(soloColor, 0.10)); + soloGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = soloGrad; + ctx.fillRect(regionLeft, 0, regionRight - regionLeft, H); + + // Draw vertical boundary lines at Q edges + ctx.strokeStyle = weqHexRgba(soloColor, 0.35); + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(regionLeft, 0); ctx.lineTo(regionLeft, H); + ctx.moveTo(regionRight, 0); ctx.lineTo(regionRight, H); + ctx.stroke(); + ctx.setLineDash([]); + + // Label at top showing Q range + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = weqHexRgba(soloColor, 0.7); + var rangeLbl = weqFmtRange(sorted[sbi]); + ctx.fillText(rangeLbl, (regionLeft + regionRight) / 2, 12); + } + } + + // ── Band regions removed — additive EQ uses per-point bands ── + + // ── Grid lines ── + // Horizontal: dB lines (dynamic based on weqDBRangeMax) + var dbStep = weqDBRangeMax <= 6 ? 2 : (weqDBRangeMax <= 12 ? 3 : (weqDBRangeMax <= 24 ? 6 : 12)); + var dbMinorStep = dbStep / 2; + var dbGridValues = []; + var dbMinorValues = []; + for (var dgi = -weqDBRangeMax; dgi <= weqDBRangeMax; dgi += dbStep) dbGridValues.push(dgi); + for (var dmi = -weqDBRangeMax + dbMinorStep; dmi < weqDBRangeMax; dmi += dbStep) dbMinorValues.push(dmi); + + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + // Minor grid + ctx.lineWidth = 0.3; + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + dbMinorValues.forEach(function (dbm) { + var gym = weqYtoCanvas(weqDBtoY(dbm), H); + ctx.beginPath(); + ctx.moveTo(0, gym); + ctx.lineTo(W, gym); + ctx.stroke(); + }); + + // Major grid with labels + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.7)'; + ctx.shadowBlur = 3; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + dbGridValues.forEach(function (dbv) { + var gy = weqYtoCanvas(weqDBtoY(dbv), H); + ctx.strokeStyle = dbv === 0 ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.08)'; + ctx.lineWidth = dbv === 0 ? 1.5 : 0.5; + ctx.beginPath(); + ctx.moveTo(32, gy); + ctx.lineTo(W, gy); + ctx.stroke(); + // dB label — prominent + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = dbv === 0 ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.55)'; + var label = dbv === 0 ? ' 0 dB' : (dbv > 0 ? '+' + dbv : '' + dbv); + ctx.fillText(label, 3, gy); + }); + ctx.restore(); + + // Vertical: frequency lines with labels + var freqGrid = [20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 400, 500, 600, 800, + 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 15000, 20000]; + var freqLabels = { + 20: '20', 50: '50', 100: '100', 200: '200', 500: '500', + 1000: '1k', 2000: '2k', 5000: '5k', 10000: '10k', 20000: '20k' + }; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.7)'; + ctx.shadowBlur = 3; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + freqGrid.forEach(function (f) { + var gx = weqFreqToX(f) * W; + var isLabel = freqLabels[f] != null; + ctx.strokeStyle = isLabel ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.04)'; + ctx.lineWidth = isLabel ? 0.6 : 0.3; + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.lineTo(gx, H - (isLabel ? 16 : 0)); + ctx.stroke(); + // Frequency label at bottom — prominent + if (isLabel) { + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillText(freqLabels[f], gx, H - 3); + } + }); + ctx.restore(); + + // ── Draw per-band individual curves (ghost) ── + if (sorted.length > 0) { + // Use lower resolution during animation for performance + var isAnimating = weqAnimRafId != null; + var resBase = isAnimating ? Math.max(120, Math.floor(W / 2)) : Math.max(200, W); + var resolution = resBase; + + // Draw per-band ghost curves + var ghostRes = isAnimating ? Math.max(80, Math.floor(W / 3)) : resolution; + for (var bi = 0; bi < sorted.length; bi++) { + var band = sorted[bi]; + var realBandIdx = wrongEqPoints.indexOf(band); + if (band.mute) continue; + // Don't skip non-soloed bands — always draw all ghost curves + var bandCol = _weqBandColor(bi + 1, sorted); + var isSelBand = (realBandIdx === weqSelectedPt); + var isSoloed = band.solo && anySolo; + ctx.globalAlpha = isSoloed ? 0.7 : (isSelBand ? 0.3 : (isAnimating ? 0.04 : 0.06)); + ctx.strokeStyle = bandCol; + ctx.lineWidth = isSoloed ? 2.5 : (isSelBand ? 1.5 : 1); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.beginPath(); + for (var bpx = 0; bpx <= ghostRes; bpx++) { + var bxN = bpx / ghostRes; + var bGainBased = ((band.type || 'Bell') === 'Bell' || band.type === 'LShf' || band.type === 'HShf'); + var bandDB = _weqBandDB(bxN, band) * (bGainBased ? (weqGlobalDepth / 100) : 1); + var bandY = weqDBtoY(bandDB); + var bcy = weqYtoCanvas(bandY, H); + if (bpx === 0) ctx.moveTo(bxN * W, bcy); + else ctx.lineTo(bxN * W, bcy); + } + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // ── Compute total curve ONCE, cache for reuse ── + var depthMul = weqGlobalDepth / 100; + var curveX = new Float32Array(resolution + 1); + var curveY = new Float32Array(resolution + 1); + for (var px = 0; px <= resolution; px++) { + var xNorm = px / resolution; + // weqEvalAtX already applies global depth, warp, and steps internally + var db = weqEvalAtX(xNorm); + curveX[px] = xNorm * W; + curveY[px] = weqYtoCanvas(weqDBtoY(db), H); + } + + // ── Draw total EQ curve (from cache) ── + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.strokeStyle = weqCssVar('--accent', '#2D6B3F'); + ctx.beginPath(); + for (var cx = 0; cx <= resolution; cx++) { + if (cx === 0) ctx.moveTo(curveX[cx], curveY[cx]); + else ctx.lineTo(curveX[cx], curveY[cx]); + } + ctx.stroke(); + + // Filled area under/over 0dB (reuse cached curve) + var zeroY = weqYtoCanvas(weqDBtoY(0), H); + ctx.globalAlpha = 0.08; + ctx.beginPath(); + for (var fx = 0; fx <= resolution; fx++) { + if (fx === 0) ctx.moveTo(curveX[fx], curveY[fx]); + else ctx.lineTo(curveX[fx], curveY[fx]); + } + ctx.lineTo(W, zeroY); + ctx.lineTo(0, zeroY); + ctx.closePath(); + ctx.fillStyle = weqCssVar('--accent', '#2D6B3F'); + ctx.fill(); + ctx.globalAlpha = 1; + + // biquad contributions. What you see IS what you hear — WYSIWYG. + } else { + // No points: flat 0dB line + var zy = weqYtoCanvas(weqDBtoY(0), H); + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, zy); + ctx.lineTo(W, zy); + ctx.stroke(); + ctx.setLineDash([]); + } + + // ── Draw breakpoints ── + // First pass: draw segment selection connection line + if (weqSegSel.size >= 2) { + var segPts = _weqSortedSel().map(function (i) { return wrongEqPoints[i]; }); + ctx.beginPath(); + ctx.strokeStyle = _weqAccentRgba(0.5); + ctx.lineWidth = 2; + ctx.setLineDash([5, 3]); + for (var si = 0; si < segPts.length; si++) { + var sx = segPts[si].x * W, sy = weqYtoCanvas(segPts[si].y, H); + if (si === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); + } + ctx.stroke(); + ctx.setLineDash([]); + } + + for (var pi = 0; pi < sorted.length; pi++) { + var pt = sorted[pi]; + var realIdx = wrongEqPoints.indexOf(pt); + var cx = pt.x * W; + var cy = weqYtoCanvas(pt.y, H); + var isSel = (realIdx === weqSelectedPt); + var isSegSel = weqSegSel.has(realIdx); + var col = _weqPointColor(pt); + var isMutedPt = pt.mute; + var isNonSoloed = anySolo && !pt.solo; + + // Dim non-soloed or muted dots + if (isMutedPt) { + ctx.globalAlpha = 0.15; + } else if (isNonSoloed) { + ctx.globalAlpha = 0.25; + } else { + ctx.globalAlpha = 1.0; + } + + // Frequency marker line (only for selected point) + if (isSel) { + ctx.strokeStyle = weqHexRgba(col, 0.3); + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(cx, 0); + ctx.lineTo(cx, H); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Segment selection glow ring + if (isSegSel) { + ctx.beginPath(); + ctx.arc(cx, cy, 10, 0, Math.PI * 2); + ctx.strokeStyle = _weqAccentRgba(0.6); + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Point shape based on filter type (FabFilter convention) + var ptType = pt.type || 'Bell'; + var r = isSel ? 7 : (isSegSel ? 6 : 5); + ctx.beginPath(); + if (ptType === 'Bell' || ptType === 'Notch') { + // Circle (Bell) or Diamond (Notch) + if (ptType === 'Notch') { + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + } else { + ctx.arc(cx, cy, r, 0, Math.PI * 2); + } + } else if (ptType === 'LP') { + // Left-pointing triangle (cuts highs) + ctx.moveTo(cx + r, cy - r); + ctx.lineTo(cx - r, cy); + ctx.lineTo(cx + r, cy + r); + ctx.closePath(); + } else if (ptType === 'HP') { + // Right-pointing triangle (cuts lows) + ctx.moveTo(cx - r, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx - r, cy + r); + ctx.closePath(); + } else if (ptType === 'LShf') { + // Left half-circle (shelf below) + ctx.arc(cx, cy, r, Math.PI * 0.5, Math.PI * 1.5); + ctx.closePath(); + } else if (ptType === 'HShf') { + // Right half-circle (shelf above) + ctx.arc(cx, cy, r, -Math.PI * 0.5, Math.PI * 0.5); + ctx.closePath(); + } else { + ctx.arc(cx, cy, r, 0, Math.PI * 2); + } + ctx.fillStyle = isSel ? '#fff' : (isSegSel ? _weqAccentRgba(0.7) : col); + ctx.fill(); + ctx.lineWidth = isSel ? 2 : (isSegSel ? 2 : 1.5); + ctx.strokeStyle = isSel ? col : (isSegSel ? _weqAccentRgba(0.8) : 'rgba(0,0,0,0.5)'); + ctx.stroke(); + + // Q bandwidth visualization for selected point + if (isSel && (ptType === 'Bell' || ptType === 'Notch')) { + var q = pt.q || 0.707; + // Exact Cookbook BW: 1/Q = 2*sinh(ln(2)/2*BW) → BW = 2/ln(2)*asinh(1/(2*Q)) + var bwOctaves = (2 / Math.LN2) * Math.asinh(1 / (2 * q)); + var centerFreq = weqXToFreq(pt.x); + var loFreq = centerFreq / Math.pow(2, bwOctaves / 2); + var hiFreq = centerFreq * Math.pow(2, bwOctaves / 2); + // Unclamped positions for bell shape — let canvas clip at edges + var xLoFull = weqFreqToX(loFreq) * W; + var xHiFull = weqFreqToX(hiFreq) * W; + // Clamped positions for dashed edge lines (only draw if visible) + var xLo = Math.max(0, xLoFull); + var xHi = Math.min(W, xHiFull); + ctx.save(); + ctx.globalAlpha = 0.12; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(xLoFull, zeroY); + // Bell curve approximation — uses full (unclamped) range + var bellSteps = 32; + for (var bs = 0; bs <= bellSteps; bs++) { + var bx = xLoFull + (xHiFull - xLoFull) * (bs / bellSteps); + var bf = (bs / bellSteps) * 2 - 1; // -1 to 1 + var bellY = Math.exp(-bf * bf * 2); + var by = zeroY + (cy - zeroY) * bellY; + ctx.lineTo(bx, by); + } + ctx.lineTo(xHiFull, zeroY); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 0.4; + ctx.strokeStyle = col; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + // Only draw edge lines if they're within view + if (xLo > 0) { + ctx.beginPath(); + ctx.moveTo(xLo, 0); ctx.lineTo(xLo, H); + ctx.stroke(); + } + if (xHi < W) { + ctx.beginPath(); + ctx.moveTo(xHi, 0); ctx.lineTo(xHi, H); + ctx.stroke(); + } + ctx.setLineDash([]); + ctx.restore(); + } + + // Frequency + type label at top + var freq = weqXToFreq(pt.x); + var typeStr = pt.type || 'Bell'; + ctx.fillStyle = isSel ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.5)'; + ctx.font = (isSel ? 'bold ' : '') + '9px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.fillText((pi + 1) + ' ' + typeStr + ' ' + weqFmtFreq(freq), cx, 10); + + // dB + Q label near point + var dbVal = weqYToDB(pt.y); + var ptQ = pt.q || 0.707; + var detailLabel; + if (typeStr === 'LP' || typeStr === 'HP') { + detailLabel = '12dB/oct'; + } else { + detailLabel = weqFmtDB(dbVal) + 'dB Q' + ptQ.toFixed(2); + } + ctx.fillStyle = isSel ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.35)'; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.textAlign = cx > W - 50 ? 'right' : 'left'; + var xOff = cx > W - 50 ? -8 : 8; + ctx.fillText(detailLabel, cx + xOff, cy - 8); + } + + ctx.globalAlpha = 1; // reset after dot loop + + // ── Modulation zone boundaries (separate gain vs drift) ── + var _isModulating = weqAnimSpeed > 0 || (Math.abs(weqDrift) > 0 && weqDriftRange > 0) || (weqDriftContinuous && weqDriftRange > 0); + if (_isModulating) { + ctx.save(); + ctx.font = '8px "Share Tech Mono", monospace'; + + // ── Gain zone (LFO + continuous) — solid green lines ── + var _gLo = weqGainLoCut > 20, _gHi = weqGainHiCut < 20000; + if (_gLo || _gHi) { + var gLoX = _gLo ? weqFreqToX(weqGainLoCut) * W : -1; + var gHiX = _gHi ? weqFreqToX(weqGainHiCut) * W : W + 1; + ctx.globalAlpha = 0.06; + ctx.fillStyle = '#000'; + if (_gLo) ctx.fillRect(0, 0, gLoX, H); + if (_gHi) ctx.fillRect(gHiX, 0, W - gHiX, H); + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.strokeStyle = 'rgba(80, 220, 120, 0.7)'; + if (_gLo) { ctx.beginPath(); ctx.moveTo(gLoX, 0); ctx.lineTo(gLoX, H); ctx.stroke(); } + if (_gHi) { ctx.beginPath(); ctx.moveTo(gHiX, 0); ctx.lineTo(gHiX, H); ctx.stroke(); } + ctx.globalAlpha = 1; + ctx.fillStyle = 'rgba(80, 220, 120, 0.6)'; + if (_gLo) { ctx.textAlign = 'left'; ctx.fillText('G ' + weqFmtFreq(weqGainLoCut), gLoX + 3, H - 4); } + if (_gHi) { ctx.textAlign = 'right'; ctx.fillText(weqFmtFreq(weqGainHiCut) + ' G', gHiX - 3, H - 4); } + } + + // ── Drift zone — short-dash orange lines ── + var _dLo = weqDriftLoCut > 20, _dHi = weqDriftHiCut < 20000; + if (_dLo || _dHi) { + var dLoX = _dLo ? weqFreqToX(weqDriftLoCut) * W : -1; + var dHiX = _dHi ? weqFreqToX(weqDriftHiCut) * W : W + 1; + ctx.globalAlpha = 0.06; + ctx.fillStyle = '#000'; + if (_dLo) ctx.fillRect(0, 0, dLoX, H); + if (_dHi) ctx.fillRect(dHiX, 0, W - dHiX, H); + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.strokeStyle = 'rgba(255, 170, 50, 0.7)'; + if (_dLo) { ctx.beginPath(); ctx.moveTo(dLoX, 0); ctx.lineTo(dLoX, H); ctx.stroke(); } + if (_dHi) { ctx.beginPath(); ctx.moveTo(dHiX, 0); ctx.lineTo(dHiX, H); ctx.stroke(); } + ctx.setLineDash([]); + ctx.globalAlpha = 1; + ctx.fillStyle = 'rgba(255, 170, 50, 0.6)'; + if (_dLo) { ctx.textAlign = 'left'; ctx.fillText('D ' + weqFmtFreq(weqDriftLoCut), dLoX + 3, H - 14); } + if (_dHi) { ctx.textAlign = 'right'; ctx.fillText(weqFmtFreq(weqDriftHiCut) + ' D', dHiX - 3, H - 14); } + } + + ctx.restore(); + } + + // ── Q zone boundaries — dotted cyan lines ── + var _hasQZone = weqQLoCut > 20 || weqQHiCut < 20000; + var _qModActive = weqQModSpeed > 0 && weqQModDepth > 0; + if (_hasQZone && _qModActive) { + ctx.save(); + ctx.font = '8px "Share Tech Mono", monospace'; + var _qLo = weqQLoCut > 20, _qHi = weqQHiCut < 20000; + var qLoX = _qLo ? weqFreqToX(weqQLoCut) * W : -1; + var qHiX = _qHi ? weqFreqToX(weqQHiCut) * W : W + 1; + ctx.globalAlpha = 0.06; + ctx.fillStyle = '#000'; + if (_qLo) ctx.fillRect(0, 0, qLoX, H); + if (_qHi) ctx.fillRect(qHiX, 0, W - qHiX, H); + ctx.globalAlpha = 0.6; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.strokeStyle = 'rgba(100, 200, 255, 0.7)'; + if (_qLo) { ctx.beginPath(); ctx.moveTo(qLoX, 0); ctx.lineTo(qLoX, H); ctx.stroke(); } + if (_qHi) { ctx.beginPath(); ctx.moveTo(qHiX, 0); ctx.lineTo(qHiX, H); ctx.stroke(); } + ctx.setLineDash([]); + ctx.globalAlpha = 1; + ctx.fillStyle = 'rgba(100, 200, 255, 0.6)'; + if (_qLo) { ctx.textAlign = 'left'; ctx.fillText('Q ' + weqFmtFreq(weqQLoCut), qLoX + 3, 12); } + if (_qHi) { ctx.textAlign = 'right'; ctx.fillText(weqFmtFreq(weqQHiCut) + ' Q', qHiX - 3, 12); } + ctx.restore(); + } + + // ── Animation indicator ── + if (weqAnimSpeed > 0 && weqAnimRafId) { + // Show "ANIM" badge + var pulseAlpha = 0.4 + 0.3 * Math.sin(weqAnimPhase * Math.PI * 2); + ctx.fillStyle = 'rgba(45, 200, 90, ' + pulseAlpha.toFixed(2) + ')'; + ctx.font = 'bold 9px "Share Tech Mono", monospace'; + ctx.textAlign = 'right'; + ctx.fillText('● LFO ' + (WEQ_LFO_SHAPES[weqAnimShape] || WEQ_LFO_SHAPES.sine).icon + ' ' + weqAnimSpeed.toFixed(1) + 'Hz', W - 6, 12); + + // Ghost: draw base position dots (where points rest without animation) + if (weqAnimBaseY.length === sorted.length) { + ctx.globalAlpha = 0.2; + for (var gi = 0; gi < sorted.length; gi++) { + var gx = sorted[gi].x * W; + var gy = weqYtoCanvas(weqAnimBaseY[wrongEqPoints.indexOf(sorted[gi])], H); + ctx.beginPath(); + ctx.arc(gx, gy, 2.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fill(); + } + ctx.globalAlpha = 1; + } + } + + } catch (drawErr) { + if (typeof console !== 'undefined') console.warn('weqDrawCanvas error:', drawErr); + } + ctx.restore(); +} + +// ── Event setup ── +function weqSetupEvents() { + var wrap = document.getElementById('weqCanvasWrap'); + if (!wrap) return; + + + // Close button + var closeBtn = document.getElementById('weqClose'); + if (closeBtn) closeBtn.onclick = function () { weqClose(); }; + + // Header drag-to-move + var headerEl = document.querySelector('.weq-header'); + var popupEl = document.getElementById('weqPanel'); + if (headerEl && popupEl) { + headerEl.onmousedown = function (e) { + // Don't drag if clicking a button inside header + if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return; + e.preventDefault(); + + // On first drag, convert from centered transform to pixel position + if (popupEl.style.transform === '' || popupEl.style.transform.indexOf('translate') >= 0) { + var rect = popupEl.getBoundingClientRect(); + popupEl.style.left = rect.left + 'px'; + popupEl.style.top = rect.top + 'px'; + popupEl.style.transform = 'none'; + } + + var startX = e.clientX; + var startY = e.clientY; + var startLeft = parseInt(popupEl.style.left) || 0; + var startTop = parseInt(popupEl.style.top) || 0; + + function onDragMove(ev) { + var dx = ev.clientX - startX; + var dy = ev.clientY - startY; + popupEl.style.left = (startLeft + dx) + 'px'; + popupEl.style.top = (startTop + dy) + 'px'; + } + function onDragUp() { + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('mouseup', onDragUp); + } + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', onDragUp); + }; + } + + // Grid buttons + document.querySelectorAll('[data-wg]').forEach(function (btn) { + btn.onclick = function () { + weqGrid = btn.dataset.wg; + document.querySelectorAll('[data-wg]').forEach(function (b) { b.classList.toggle('on', b.dataset.wg === weqGrid); }); + weqDrawCanvas(); + }; + }); + + // Clear / Random + var clearBtn = document.getElementById('weqClear'); + if (clearBtn) clearBtn.onclick = function () { + _weqPushUndo(); + wrongEqPoints = []; + weqAnimBaseY = []; weqAnimBaseX = []; + weqSelectedPt = -1; + weqFocusBand = -1; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + + var randBtn = document.getElementById('weqRandom'); + if (randBtn) randBtn.onclick = function () { + _weqPushUndo(); + weqRandomize(); + }; + + // ── WrongEQ Undo System ── + // Mirror All: flip every point's gain across 0dB + var mirrorAllBtn = document.getElementById('weqMirrorAll'); + if (mirrorAllBtn) mirrorAllBtn.onclick = function () { + _weqPushUndo(); + wrongEqPoints.forEach(function (pt, i) { + var newY = weqDBtoY(-weqYToDB(pt.y)); + pt.y = newY; + if (weqAnimBaseY.length > i) weqAnimBaseY[i] = newY; + }); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + + // Smooth All: halve every point's gain toward 0dB + var smoothAllBtn = document.getElementById('weqSmoothAll'); + if (smoothAllBtn) smoothAllBtn.onclick = function () { + _weqPushUndo(); + wrongEqPoints.forEach(function (pt, i) { + var newY = weqDBtoY(weqYToDB(pt.y) * 0.5); + pt.y = newY; + if (weqAnimBaseY.length > i) weqAnimBaseY[i] = newY; + }); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + + + + // (Pre/Post EQ is now per-bus — handled in plugin_rack.js wireBusHeaders) + + // Bypass toggle + var bypassBtn = document.getElementById('weqBypass'); + if (bypassBtn) bypassBtn.onclick = function () { + weqGlobalBypass = !weqGlobalBypass; + bypassBtn.classList.toggle('on', weqGlobalBypass); + bypassBtn.classList.toggle('weq-bypass-on', weqGlobalBypass); + weqSyncToHost(); + }; + + // Continuous drift toggle + var contBtn = document.getElementById('weqContinuous'); + if (contBtn) contBtn.onclick = function () { + weqDriftContinuous = !weqDriftContinuous; + contBtn.classList.toggle('on', weqDriftContinuous); + contBtn.classList.toggle('weq-anim-on', weqDriftContinuous); + var needsLoop = _weqNeedsAnim(); + if (needsLoop && !weqAnimRafId) weqAnimStart(); + else if (!needsLoop && weqAnimRafId) weqAnimStop(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + + // Oversampling cycle button: Off → 2× → 4× → Off + var osBtn = document.getElementById('weqOversampleBtn'); + if (osBtn) osBtn.onclick = function () { + if (weqOversample === 1) weqOversample = 2; + else if (weqOversample === 2) weqOversample = 4; + else weqOversample = 1; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + + + + + + + // Band card click-to-focus: highlight band region on canvas + // Shift+click adds to segment selection + document.querySelectorAll('.weq-band-card').forEach(function (row) { + row.onclick = function (e) { + // Don't trigger focus if clicking a button/control inside the card + if (e.target.tagName === 'BUTTON' || e.target.hasAttribute('data-weqgain') || + e.target.hasAttribute('data-weqq') || e.target.hasAttribute('data-weqdrift') || + e.target.hasAttribute('data-weqfreq') || e.target.hasAttribute('data-weqpointpreq')) return; + var idx = parseInt(row.dataset.bandidx); + if (e.shiftKey || e.ctrlKey) { + if (weqSegSel.has(idx)) weqSegSel.delete(idx); + else weqSegSel.add(idx); + weqRenderPanel(); + return; + } + var wasSelected = (weqFocusBand === idx && weqSelectedPt === idx); + weqFocusBand = wasSelected ? -1 : idx; + weqSelectedPt = wasSelected ? -1 : idx; + document.querySelectorAll('.weq-band-card').forEach(function (r) { + r.classList.toggle('focused', parseInt(r.dataset.bandidx) === weqFocusBand); + }); + weqDrawCanvas(); + }; + }); + + // Segment selection checkbox buttons + document.querySelectorAll('[data-weqseg]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var idx = parseInt(btn.dataset.weqseg); + if (weqSegSel.has(idx)) weqSegSel.delete(idx); + else weqSegSel.add(idx); + weqRenderPanel(); + }; + }); + + // Segment toolbar operations + document.querySelectorAll('[data-segop]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var op = btn.dataset.segop; + if (op === 'clear') { weqSegSel.clear(); weqRenderPanel(); return; } + if (op === 'shape') { _weqSegShapeMenu(e); return; } + _weqSegApply(op); + }; + }); + + // Routing panel toggle (+ button on each card) + document.querySelectorAll('[data-weqrouting]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var bandIdx = parseInt(btn.dataset.weqrouting); + if (window._weqRoutingOpen === bandIdx) { + window._weqRoutingOpen = -1; // close + } else { + window._weqRoutingOpen = bandIdx; // open this one + } + weqRenderPanel(); + }; + }); + + // Plugin open-editor buttons on band cards and routing panels + document.querySelectorAll('[data-weqplugopen]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var pid = parseInt(btn.dataset.weqplugopen); + // Find the plugin block to get hostId + var pb = null; + if (typeof pluginBlocks !== 'undefined') { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } + } + } + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('openPluginEditor'); + fn(pb && pb.hostId !== undefined ? pb.hostId : pid); + } + }; + }); + + // Per-plugin bypass toggle in routing panels + document.querySelectorAll('[data-weqplugbypass]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var pid = parseInt(btn.dataset.weqplugbypass); + var pb = null; + if (typeof pluginBlocks !== 'undefined') { + for (var i = 0; i < pluginBlocks.length; i++) { + if (pluginBlocks[i].id === pid) { pb = pluginBlocks[i]; break; } + } + } + if (!pb) return; + pb.bypassed = !pb.bypassed; + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setPluginBypass'); + fn(pb.hostId, pb.bypassed); + } + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof saveUiStateToHost === 'function') saveUiStateToHost(); + weqRenderPanel(); + }; + }); + + // Plugin assign buttons on band cards — reuses existing context menu + document.querySelectorAll('[data-weqplugassign]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var ptIdx = parseInt(btn.dataset.weqplugassign); + weqShowPluginAssign(ptIdx, e); + }; + }); + + // Plugin load buttons — opens the main plugin browser, + // auto-assigns the loaded plugin to this band on completion + document.querySelectorAll('[data-weqplugload]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var ptIdx = parseInt(btn.dataset.weqplugload); + // Store the target band so the load callback can auto-assign + window._weqLoadTargetBand = ptIdx; + if (typeof openPluginBrowser === 'function') openPluginBrowser(); + }; + }); + + // Remove plugin from band + document.querySelectorAll('[data-weqplugremove]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var parts = btn.dataset.weqplugremove.split(':'); + var bandIdx = parseInt(parts[0]); + var plugId = parseInt(parts[1]); + if (bandIdx < 0 || bandIdx >= wrongEqPoints.length) return; + var pt = wrongEqPoints[bandIdx]; + if (!pt.pluginIds) return; + var idx = pt.pluginIds.indexOf(plugId); + if (idx >= 0) pt.pluginIds.splice(idx, 1); + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + _weqSyncPluginBusIds(); + weqRenderPanel(); + }; + }); + + // Reorder plugins within a band (move up / move down) + document.querySelectorAll('[data-weqplugmove]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var parts = btn.dataset.weqplugmove.split(':'); + var bandIdx = parseInt(parts[0]); + var plugIdx = parseInt(parts[1]); + var dir = parts[2]; // 'up' or 'down' + if (bandIdx < 0 || bandIdx >= wrongEqPoints.length) return; + var pt = wrongEqPoints[bandIdx]; + if (!pt.pluginIds || plugIdx < 0 || plugIdx >= pt.pluginIds.length) return; + var swapIdx = dir === 'up' ? plugIdx - 1 : plugIdx + 1; + if (swapIdx < 0 || swapIdx >= pt.pluginIds.length) return; + // Swap + var tmp = pt.pluginIds[plugIdx]; + pt.pluginIds[plugIdx] = pt.pluginIds[swapIdx]; + pt.pluginIds[swapIdx] = tmp; + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + weqRenderPanel(); + }; + }); + + // Move plugin from band to global (unassign from band) + document.querySelectorAll('[data-weqplugtoglobal]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var parts = btn.dataset.weqplugtoglobal.split(':'); + var bandIdx = parseInt(parts[0]); + var plugId = parseInt(parts[1]); + if (bandIdx < 0 || bandIdx >= wrongEqPoints.length) return; + var pt = wrongEqPoints[bandIdx]; + if (!pt.pluginIds) return; + var idx = pt.pluginIds.indexOf(plugId); + if (idx >= 0) pt.pluginIds.splice(idx, 1); + // Auto-enable global mode when moving first plugin there + if (weqUnassignedMode === 0) weqUnassignedMode = 1; + if (typeof weqSyncToHost === 'function') weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + _weqSyncPluginBusIds(); + weqRenderPanel(); + }; + }); + + // Assign global plugin to a band (shows context menu with band choices) + document.querySelectorAll('[data-weqglobalassign]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var plugId = parseInt(btn.dataset.weqglobalassign); + if (wrongEqPoints.length === 0) return; + // Build context menu items + var items = wrongEqPoints.map(function (pt, idx) { + var col = _weqBandColor(idx + 1, wrongEqPoints); + var freq = weqFmtFreq(weqXToFreq(pt.x)); + return { + label: 'Band ' + (idx + 1) + ' — ' + freq, + color: col, + action: function () { + if (!wrongEqPoints[idx].pluginIds) wrongEqPoints[idx].pluginIds = []; + if (wrongEqPoints[idx].pluginIds.indexOf(plugId) < 0) { + wrongEqPoints[idx].pluginIds.push(plugId); + } + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + _weqSyncPluginBusIds(); + weqRenderPanel(); + } + }; + }); + _weqShowCtxMenu(items, e); + }; + }); + + // Routing sidebar tab toggle + var routingTab = document.getElementById('weqRoutingTab'); + if (routingTab) routingTab.onclick = function (e) { + e.stopPropagation(); + window._weqRoutingPanelOpen = !window._weqRoutingPanelOpen; + var sidebar = routingTab.closest('.weq-routing-sidebar'); + if (sidebar) sidebar.classList.toggle('open', window._weqRoutingPanelOpen); + }; + + // Global load button — load plugin as unassigned (global) + var globalLoadBtn = document.getElementById('weqGlobalLoad'); + if (globalLoadBtn) globalLoadBtn.onclick = function (e) { + e.stopPropagation(); + window._weqLoadTargetBand = -1; // ensure no band auto-assign + if (typeof openPluginBrowser === 'function') openPluginBrowser(); + }; + + // Delete plugin from global routing section + document.querySelectorAll('[data-weqglobalrm]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var plugId = parseInt(btn.dataset.weqglobalrm); + if (typeof removePlugin === 'function') removePlugin(plugId); + }; + }); + + // Per-band Solo buttons + document.querySelectorAll('[data-weqsolo]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var idx = parseInt(btn.dataset.weqsolo); + if (idx < 0 || idx >= wrongEqPoints.length) return; + var wasSoloed = wrongEqPoints[idx].solo; + // Exclusive solo: unsolo all others, toggle this one + for (var si = 0; si < wrongEqPoints.length; si++) wrongEqPoints[si].solo = false; + wrongEqPoints[idx].solo = !wasSoloed; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Per-band Mute buttons + document.querySelectorAll('[data-weqmute]').forEach(function (btn) { + btn.onclick = function (e) { + e.stopPropagation(); + var idx = parseInt(btn.dataset.weqmute); + if (idx < 0 || idx >= wrongEqPoints.length) return; + wrongEqPoints[idx].mute = !wrongEqPoints[idx].mute; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Band row: Gain drag + double-click reset + document.querySelectorAll('[data-weqgain]').forEach(function (el) { + var idx = parseInt(el.dataset.weqgain); + el.ondblclick = function (e) { + e.stopPropagation(); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].y = weqDBtoY(0); // reset to 0dB + if (weqAnimRafId) weqAnimBaseY[idx] = weqDBtoY(0); + weqRenderPanel(); weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + el.onmousedown = function (e) { + e.preventDefault(); e.stopPropagation(); + var startY = e.clientY; + var baseYVal = (weqAnimRafId && weqAnimBaseY[idx] != null) ? weqAnimBaseY[idx] : (idx >= 0 && idx < wrongEqPoints.length ? wrongEqPoints[idx].y : weqDBtoY(0)); + var startDB = weqYToDB(baseYVal); + function onMove(ev) { + var dy = startY - ev.clientY; + var newDB = Math.max(-weqDBRangeMax, Math.min(weqDBRangeMax, startDB + dy * 0.3)); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].y = weqDBtoY(newDB); + if (weqAnimRafId) weqAnimBaseY[idx] = weqDBtoY(newDB); + el.textContent = weqFmtDB(newDB); + el.className = 'weq-card-pval' + (newDB > 0.1 ? ' boost' : (newDB < -0.1 ? ' cut' : '')); + weqDrawCanvas(); + weqSyncToHost(); // real-time sync + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + // Skip full re-render during animation — text was updated inline during drag + if (!weqAnimRafId) weqRenderPanel(); + weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + + // Band card: Frequency drag + double-click reset + document.querySelectorAll('[data-weqfreq]').forEach(function (el) { + var idx = parseInt(el.dataset.weqfreq); + el.style.cursor = 'ns-resize'; + el.ondblclick = function (e) { + e.stopPropagation(); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].x = weqFreqToX(1000); // reset to 1kHz + if (weqAnimRafId && weqAnimBaseX[idx] != null) weqAnimBaseX[idx] = wrongEqPoints[idx].x; + weqRenderPanel(); weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + el.onmousedown = function (e) { + e.preventDefault(); e.stopPropagation(); + var startY = e.clientY; + var startFreq = (idx >= 0 && idx < wrongEqPoints.length) ? weqXToFreq(wrongEqPoints[idx].x) : 1000; + function onMove(ev) { + var dy = startY - ev.clientY; // up = higher freq + var newFreq = Math.max(WEQ_MIN_FREQ, Math.min(WEQ_MAX_FREQ, startFreq * Math.pow(1.006, dy))); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].x = weqFreqToX(newFreq); + if (weqAnimRafId && weqAnimBaseX[idx] != null) weqAnimBaseX[idx] = wrongEqPoints[idx].x; + el.textContent = weqFmtFreq(newFreq); + weqDrawCanvas(); + weqSyncToHost(); + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (!weqAnimRafId) weqRenderPanel(); + weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + + // Band row: Q drag + double-click reset + document.querySelectorAll('[data-weqq]').forEach(function (el) { + var idx = parseInt(el.dataset.weqq); + var _qDragPending = null; // timeout ID for deferred drag start + var _qDragActive = false; + + el.ondblclick = function (e) { + e.stopPropagation(); e.preventDefault(); + // Cancel any pending drag setup + if (_qDragPending) { clearTimeout(_qDragPending); _qDragPending = null; } + _qDragActive = false; + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].q = 0.707; + if (weqAnimRafId && weqAnimBaseQ[idx] != null) weqAnimBaseQ[idx] = 0.707; + el.textContent = '0.71'; + weqDrawCanvas(); + weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + el.onmousedown = function (e) { + e.preventDefault(); e.stopPropagation(); + var startY = e.clientY; + var startQ = (idx >= 0 && idx < wrongEqPoints.length && wrongEqPoints[idx].q != null) ? wrongEqPoints[idx].q : 0.707; + _qDragActive = false; + + function onMove(ev) { + _qDragActive = true; + var dy = startY - ev.clientY; + var newQ = Math.max(0.025, Math.min(40, startQ * Math.pow(1.01, dy))); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].q = newQ; + if (weqAnimRafId && weqAnimBaseQ[idx] != null) weqAnimBaseQ[idx] = newQ; + el.textContent = newQ.toFixed(2); + weqDrawCanvas(); + _weqUpdateLegendRanges(); + weqSyncToHost(); + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (_qDragActive) { + if (!weqAnimRafId) weqRenderPanel(); + weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + _qDragActive = false; + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + + // Band row: Type cycle + document.querySelectorAll('[data-weqtype]').forEach(function (btn) { + var idx = parseInt(btn.dataset.weqtype); + btn.onclick = function (e) { + e.stopPropagation(); + if (idx < 0 || idx >= wrongEqPoints.length) return; + var current = wrongEqPoints[idx].type || 'Bell'; + var ci = WEQ_TYPES.indexOf(current); + var newType = WEQ_TYPES[(ci + 1) % WEQ_TYPES.length]; + wrongEqPoints[idx].type = newType; + // LP/HP always sit at 0dB — snap Y to center + if (newType === 'LP' || newType === 'HP') { + wrongEqPoints[idx].y = weqDBtoY(0); + } + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + // Band strip: direct type set via inline radio toggles + document.querySelectorAll('[data-weqtypeset]').forEach(function (btn) { + var parts = btn.dataset.weqtypeset.split(':'); + var idx = parseInt(parts[0]); + var newType = parts[1]; + btn.onclick = function (e) { + e.stopPropagation(); + if (idx < 0 || idx >= wrongEqPoints.length) return; + if (wrongEqPoints[idx].type === newType) return; // already active + wrongEqPoints[idx].type = newType; + if (newType === 'LP' || newType === 'HP') { + wrongEqPoints[idx].y = weqDBtoY(0); + } + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + // Band row: per-point slope selector (12/24/48 dB/oct) + document.querySelectorAll('[data-weqslope]').forEach(function (btn) { + var parts = btn.dataset.weqslope.split(':'); + var idx = parseInt(parts[0]); + var newSlope = parseInt(parts[1]); + btn.onclick = function (e) { + e.stopPropagation(); + if (idx < 0 || idx >= wrongEqPoints.length) return; + if ((wrongEqPoints[idx].slope || 1) === newSlope) return; + wrongEqPoints[idx].slope = newSlope; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + // Band row: per-point Pre/Post EQ toggle + document.querySelectorAll('[data-weqpointpreq]').forEach(function (btn) { + var idx = parseInt(btn.dataset.weqpointpreq); + btn.onclick = function (e) { + e.stopPropagation(); + if (idx < 0 || idx >= wrongEqPoints.length) return; + wrongEqPoints[idx].preEq = !(wrongEqPoints[idx].preEq !== false); + weqRenderPanel(); + weqSyncToHost(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Band strip: Stereo mode selector (LR / M / S) + document.querySelectorAll('[data-weqstereo]').forEach(function (btn) { + var parts = btn.dataset.weqstereo.split(':'); + var idx = parseInt(parts[0]); + var mode = parseInt(parts[1]); + btn.onclick = function (e) { + e.stopPropagation(); + if (idx < 0 || idx >= wrongEqPoints.length) return; + if ((wrongEqPoints[idx].stereoMode || 0) === mode) return; // already set + wrongEqPoints[idx].stereoMode = mode; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Band row: Drift drag + double-click reset + document.querySelectorAll('[data-weqdrift]').forEach(function (el) { + var idx = parseInt(el.dataset.weqdrift); + el.ondblclick = function (e) { + e.stopPropagation(); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].drift = 0; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + el.onmousedown = function (e) { + e.preventDefault(); e.stopPropagation(); + var startY = e.clientY; + var startDrift = (idx >= 0 && idx < wrongEqPoints.length && wrongEqPoints[idx].drift != null) ? wrongEqPoints[idx].drift : 0; + function onMove(ev) { + var dy = startY - ev.clientY; + var newDrift = Math.max(0, Math.min(100, Math.round(startDrift + dy * 0.5))); + if (idx >= 0 && idx < wrongEqPoints.length) { + wrongEqPoints[idx].drift = newDrift; + el.textContent = 'Drift ' + newDrift + '%'; + weqSyncToHost(); // real-time sync + } + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + // Skip full re-render during animation — drift text was updated inline + if (!weqAnimRafId) weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + + // Band row: Delete button + document.querySelectorAll('[data-weqdel]').forEach(function (btn) { + var idx = parseInt(btn.dataset.weqdel); + btn.onclick = function (e) { + e.stopPropagation(); + if (idx >= 0 && idx < wrongEqPoints.length) { + _weqPushUndo(); + wrongEqPoints.splice(idx, 1); + if (weqAnimRafId && weqAnimBaseY.length > idx) { weqAnimBaseY.splice(idx, 1); weqAnimBaseX.splice(idx, 1); } + weqSelectedPt = -1; + weqFocusBand = -1; + weqSegSel.clear(); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + }); + + // Footer knobs (drag vertical + double-click reset) + document.querySelectorAll('[data-wk]').forEach(function (knob) { + var key = knob.dataset.wk; + // Double-click: reset to default + knob.ondblclick = function (e) { + e.preventDefault(); e.stopPropagation(); + if (key === 'depth') { weqGlobalDepth = 100; knob.textContent = '100%'; } + else if (key === 'warp') { weqGlobalWarp = 0; knob.textContent = '+0'; } + else if (key === 'steps') { weqGlobalSteps = 0; knob.textContent = 'Off'; } + else if (key === 'tilt') { weqGlobalTilt = 0; knob.textContent = '+0'; } + + else if (key === 'drift') { weqDrift = 0; knob.textContent = '+0'; knob.classList.remove('weq-anim-on'); if (!_weqNeedsAnim()) weqAnimStop(); } + else if (key === 'driftRange') { weqDriftRange = 5; knob.textContent = '5%'; } + else if (key === 'speed') { weqAnimSpeed = 0; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); if (!_weqNeedsAnim()) weqAnimStop(); } + else if (key === 'mod') { weqAnimDepth = 6; knob.textContent = '6dB'; } + else if (key === 'gainLo') { weqGainLoCut = 20; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + else if (key === 'gainHi') { weqGainHiCut = 20000; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + else if (key === 'driftLo') { weqDriftLoCut = 20; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + else if (key === 'driftHi') { weqDriftHiCut = 20000; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + else if (key === 'qSpeed') { weqQModSpeed = 0; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); if (!_weqNeedsAnim()) weqAnimStop(); } + else if (key === 'qDepth') { weqQModDepth = 50; knob.textContent = '50%'; } + else if (key === 'qLo') { weqQLoCut = 20; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + else if (key === 'qHi') { weqQHiCut = 20000; knob.textContent = 'Off'; knob.classList.remove('weq-anim-on'); } + + weqDrawCanvas(); weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + knob.onmousedown = function (e) { + e.preventDefault(); + var startY = e.clientY; + var startVal; + if (key === 'depth') startVal = weqGlobalDepth; + else if (key === 'warp') startVal = weqGlobalWarp; + else if (key === 'steps') startVal = weqGlobalSteps; + else if (key === 'tilt') startVal = weqGlobalTilt; + + else if (key === 'drift') startVal = weqDrift; + else if (key === 'driftRange') startVal = weqDriftRange; + else if (key === 'speed') startVal = weqAnimSpeed; + else if (key === 'mod') startVal = weqAnimDepth; + // Freq knobs: normalized 0..1 over 20–20000 Hz (log scale) + else if (key === 'gainLo') startVal = Math.log(weqGainLoCut / 20) / Math.log(20000 / 20); + else if (key === 'gainHi') startVal = Math.log(weqGainHiCut / 20) / Math.log(20000 / 20); + else if (key === 'driftLo') startVal = Math.log(weqDriftLoCut / 20) / Math.log(20000 / 20); + else if (key === 'driftHi') startVal = Math.log(weqDriftHiCut / 20) / Math.log(20000 / 20); + else if (key === 'qSpeed') startVal = weqQModSpeed; + else if (key === 'qDepth') startVal = weqQModDepth; + else if (key === 'qLo') startVal = Math.log(weqQLoCut / 20) / Math.log(20000 / 20); + else if (key === 'qHi') startVal = Math.log(weqQHiCut / 20) / Math.log(20000 / 20); + + + + // Shared freq-drag helper (returns clamped Hz from normalized value) + function _freqFromNorm(norm) { + return Math.round(20 * Math.pow(20000 / 20, Math.max(0, Math.min(1, norm)))); + } + + function onMove(ev) { + var dy = startY - ev.clientY; + if (key === 'depth') { + weqGlobalDepth = Math.max(0, Math.min(200, startVal + dy)); + knob.textContent = weqGlobalDepth + '%'; + } else if (key === 'warp') { + weqGlobalWarp = Math.max(-100, Math.min(100, startVal + dy)); + knob.textContent = (weqGlobalWarp >= 0 ? '+' : '') + weqGlobalWarp; + } else if (key === 'steps') { + weqGlobalSteps = Math.max(0, Math.min(32, Math.round(startVal + dy / 5))); + knob.textContent = (weqGlobalSteps || 'Off'); + } else if (key === 'tilt') { + weqGlobalTilt = Math.max(-100, Math.min(100, Math.round(startVal + dy * 0.5))); + knob.textContent = (weqGlobalTilt >= 0 ? '+' : '') + weqGlobalTilt; + } else if (key === 'drift') { + weqDrift = Math.max(-50, Math.min(50, Math.round(startVal + dy * 0.5))); + knob.textContent = (weqDrift >= 0 ? '+' : '') + weqDrift; + knob.classList.toggle('weq-anim-on', Math.abs(weqDrift) > 0 && weqDriftRange > 0); + var nl = _weqNeedsAnim(); + if (nl && !weqAnimRafId) weqAnimStart(); + else if (!nl && weqAnimRafId) weqAnimStop(); + } else if (key === 'driftRange') { + weqDriftRange = Math.max(0, Math.min(50, Math.round(startVal + dy * 0.3))); + knob.textContent = weqDriftRange + '%'; + knob.classList.toggle('weq-anim-on', Math.abs(weqDrift) > 0 && weqDriftRange > 0); + var nl2 = _weqNeedsAnim(); + if (nl2 && !weqAnimRafId) weqAnimStart(); + else if (!nl2 && weqAnimRafId) weqAnimStop(); + } else if (key === 'speed') { + var rawSpeed = startVal + dy * 0.05; + if (rawSpeed < 1) { + weqAnimSpeed = Math.max(0, Math.round(rawSpeed * 100) / 100); + } else { + weqAnimSpeed = Math.max(0, Math.min(10, Math.round(rawSpeed * 10) / 10)); + } + knob.textContent = weqAnimSpeed > 0 ? (weqAnimSpeed < 1 ? weqAnimSpeed.toFixed(2) + 'Hz' : weqAnimSpeed.toFixed(1) + 'Hz') : 'Off'; + knob.classList.toggle('weq-anim-on', weqAnimSpeed > 0); + var nl3 = _weqNeedsAnim(); + if (nl3 && !weqAnimRafId) weqAnimStart(); + else if (!nl3 && weqAnimRafId) weqAnimStop(); + } else if (key === 'mod') { + weqAnimDepth = Math.max(0, Math.min(24, Math.round(startVal + dy * 0.3))); + knob.textContent = weqAnimDepth + 'dB'; + knob.classList.toggle('weq-anim-on', weqAnimDepth > 0 && weqAnimSpeed > 0); + } else if (key === 'gainLo') { + weqGainLoCut = _freqFromNorm(startVal + dy * 0.003); + if (weqGainLoCut <= 25) weqGainLoCut = 20; + if (weqGainLoCut >= weqGainHiCut) weqGainLoCut = weqGainHiCut - 1; + knob.textContent = weqGainLoCut > 20 ? weqFmtFreq(weqGainLoCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqGainLoCut > 20); + } else if (key === 'gainHi') { + weqGainHiCut = _freqFromNorm(startVal + dy * 0.003); + if (weqGainHiCut >= 19500) weqGainHiCut = 20000; + if (weqGainHiCut <= weqGainLoCut) weqGainHiCut = weqGainLoCut + 1; + knob.textContent = weqGainHiCut < 20000 ? weqFmtFreq(weqGainHiCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqGainHiCut < 20000); + } else if (key === 'driftLo') { + weqDriftLoCut = _freqFromNorm(startVal + dy * 0.003); + if (weqDriftLoCut <= 25) weqDriftLoCut = 20; + if (weqDriftLoCut >= weqDriftHiCut) weqDriftLoCut = weqDriftHiCut - 1; + knob.textContent = weqDriftLoCut > 20 ? weqFmtFreq(weqDriftLoCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqDriftLoCut > 20); + } else if (key === 'driftHi') { + weqDriftHiCut = _freqFromNorm(startVal + dy * 0.003); + if (weqDriftHiCut >= 19500) weqDriftHiCut = 20000; + if (weqDriftHiCut <= weqDriftLoCut) weqDriftHiCut = weqDriftLoCut + 1; + knob.textContent = weqDriftHiCut < 20000 ? weqFmtFreq(weqDriftHiCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqDriftHiCut < 20000); + } else if (key === 'qSpeed') { + var rawQSpd = startVal + dy * 0.05; + if (rawQSpd < 1) { + weqQModSpeed = Math.max(0, Math.round(rawQSpd * 100) / 100); + } else { + weqQModSpeed = Math.max(0, Math.min(10, Math.round(rawQSpd * 10) / 10)); + } + knob.textContent = weqQModSpeed > 0 ? (weqQModSpeed < 1 ? weqQModSpeed.toFixed(2) + 'Hz' : weqQModSpeed.toFixed(1) + 'Hz') : 'Off'; + knob.classList.toggle('weq-anim-on', weqQModSpeed > 0 && weqQModDepth > 0); + var nlq = _weqNeedsAnim(); + if (nlq && !weqAnimRafId) weqAnimStart(); + else if (!nlq && weqAnimRafId) weqAnimStop(); + } else if (key === 'qDepth') { + weqQModDepth = Math.max(0, Math.min(100, Math.round(startVal + dy * 0.5))); + knob.textContent = weqQModDepth + '%'; + knob.classList.toggle('weq-anim-on', weqQModSpeed > 0 && weqQModDepth > 0); + var nlqd = _weqNeedsAnim(); + if (nlqd && !weqAnimRafId) weqAnimStart(); + else if (!nlqd && weqAnimRafId) weqAnimStop(); + } else if (key === 'qLo') { + weqQLoCut = _freqFromNorm(startVal + dy * 0.003); + if (weqQLoCut <= 25) weqQLoCut = 20; + if (weqQLoCut >= weqQHiCut) weqQLoCut = weqQHiCut - 1; + knob.textContent = weqQLoCut > 20 ? weqFmtFreq(weqQLoCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqQLoCut > 20); + } else if (key === 'qHi') { + weqQHiCut = _freqFromNorm(startVal + dy * 0.003); + if (weqQHiCut >= 19500) weqQHiCut = 20000; + if (weqQHiCut <= weqQLoCut) weqQHiCut = weqQLoCut + 1; + knob.textContent = weqQHiCut < 20000 ? weqFmtFreq(weqQHiCut) : 'Off'; + knob.classList.toggle('weq-anim-on', weqQHiCut < 20000); + } + weqDrawCanvas(); + weqSyncToHost(); // real-time sync during drag + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + weqSyncToHost(); weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + }); + + // Drift Scale dropdown + document.querySelectorAll('[data-wf="driftScale"]').forEach(function (sel) { + sel.onchange = function () { + weqDriftScale = sel.value; + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Drift Texture dropdown + document.querySelectorAll('[data-wf="driftTexture"]').forEach(function (sel) { + sel.onchange = function () { + weqDriftTexture = sel.value; + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // LFO Shape dropdown + document.querySelectorAll('[data-wf="lfoShape"]').forEach(function (sel) { + sel.onchange = function () { + weqAnimShape = sel.value; + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + document.querySelectorAll('[data-wf="qShape"]').forEach(function (sel) { + sel.onchange = function () { + weqQModShape = sel.value; + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // dB Range dropdown + document.querySelectorAll('[data-wf="dbRange"]').forEach(function (sel) { + sel.onchange = function () { + weqDBRangeMax = parseInt(sel.value); + weqRenderPanel(); // re-render to update axis + canvas + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + }); + + // Split mode toggle + var splitBtn = document.getElementById('weqSplitBtn'); + if (splitBtn) splitBtn.onclick = function () { + _weqPushUndo(); // Always snapshot before toggle + + if (!weqSplitMode) { + // ── Entering split mode ── + weqSplitMode = true; + + if (wrongEqPoints.length > 0) { + // Save current gains so we can restore them when leaving split mode + _weqSplitSavedGains = wrongEqPoints.map(function (p) { return p.y; }); + + // Smart redistribute: only spread points if any are too close together + // (less than 5% apart in normalized X). This preserves intentional positions. + var sorted = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var needsDistribute = false; + for (var si = 1; si < sorted.length; si++) { + if (sorted[si].x - sorted[si - 1].x < 0.05) { + needsDistribute = true; + break; + } + } + // Also distribute if any point is too close to the edges + if (sorted.length > 0 && (sorted[0].x < 0.03 || sorted[sorted.length - 1].x > 0.97)) { + needsDistribute = true; + } + + if (needsDistribute) { + var n = sorted.length; + for (var di = 0; di < n; di++) { + sorted[di].x = (di + 1) / (n + 1); + } + } + + // Set gains to 0dB in split mode — crossovers work best flat + for (var gi = 0; gi < wrongEqPoints.length; gi++) { + wrongEqPoints[gi].y = 0.5; + } + } + + if (typeof showToast === 'function') showToast('Split mode — bands act as frequency dividers for plugin routing', 'info', 2500); + } else { + // ── Leaving split mode ── + weqSplitMode = false; + + // Restore saved gains from before entering split mode + if (_weqSplitSavedGains && _weqSplitSavedGains.length === wrongEqPoints.length) { + for (var ri = 0; ri < wrongEqPoints.length; ri++) { + wrongEqPoints[ri].y = _weqSplitSavedGains[ri]; + } + } + _weqSplitSavedGains = null; + } + + // Update animation bases + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + + weqSyncToHost(); + markStateDirty(); + weqRenderPanel(); + weqDrawCanvas(); + }; + + // Shapes menu + var shapesBtn = document.getElementById('weqShapes'); + if (shapesBtn) shapesBtn.onclick = function () { weqShowShapesMenu(shapesBtn); }; + + // EQ Preset buttons + var presetNameBtn = document.getElementById('weqPresetName'); + if (presetNameBtn) presetNameBtn.onclick = function () { _weqShowPresetBrowser(presetNameBtn); }; + var presetSaveBtn = document.getElementById('weqPresetSave'); + if (presetSaveBtn) presetSaveBtn.onclick = function () { _weqSavePresetPrompt(); }; + var presetPrevBtn = document.getElementById('weqPresetPrev'); + if (presetPrevBtn) presetPrevBtn.onclick = function () { _weqNavPreset(-1); }; + var presetNextBtn = document.getElementById('weqPresetNext'); + if (presetNextBtn) presetNextBtn.onclick = function () { _weqNavPreset(1); }; + + // Keyboard shortcuts (when popup is visible) + if (!wrap._weqKeyBound) { + wrap._weqKeyBound = true; + document.addEventListener('keydown', function (e) { + var overlay = document.getElementById('weqOverlay'); + if (!overlay || !overlay.classList.contains('visible')) return; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if (e.key === 'Escape') { + // Close any open context menu + var ctxMenu = document.querySelector('.weq-ctx'); + if (ctxMenu) ctxMenu.remove(); + // Deselect + weqSelectedPt = -1; + weqFocusBand = -1; + weqDrawCanvas(); + e.preventDefault(); + } + // Ctrl+Z: undo last EQ action + else if (e.key === 'z' && e.ctrlKey && !e.shiftKey) { + _weqPerformUndo(); + e.preventDefault(); + e.stopPropagation(); // prevent global undo from also firing + } + // Delete selected point + else if ((e.key === 'Delete' || e.key === 'Backspace') && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + _weqPushUndo(); + wrongEqPoints.splice(weqSelectedPt, 1); + if (weqAnimRafId && weqAnimBaseY.length > weqSelectedPt) { weqAnimBaseY.splice(weqSelectedPt, 1); weqAnimBaseX.splice(weqSelectedPt, 1); } + weqSegSel.clear(); + weqSelectedPt = -1; + weqFocusBand = -1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + // Clear all + else if (e.key === 'X' && e.ctrlKey && e.shiftKey) { + _weqPushUndo(); + wrongEqPoints = []; weqAnimBaseY = []; weqAnimBaseX = []; weqSelectedPt = -1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + // Ctrl+A select all (set weqSelectedPt to last — visual feedback) + else if (e.key === 'a' && e.ctrlKey) { + if (wrongEqPoints.length > 0) weqSelectedPt = 0; + weqDrawCanvas(); + e.preventDefault(); + } + // Ctrl+D duplicate selected point + else if (e.key === 'd' && e.ctrlKey) { + if (weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var src = wrongEqPoints[weqSelectedPt]; + var dupFreq = Math.min(WEQ_MAX_FREQ, weqXToFreq(src.x) * 1.1); + var dup = { uid: _weqAllocUid(), x: weqFreqToX(dupFreq), y: src.y, pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: src.q || 0.707, type: src.type || 'Bell', drift: src.drift || 0 }; + wrongEqPoints.push(dup); + if (weqAnimRafId) { weqAnimBaseY.push(dup.y); weqAnimBaseX.push(dup.x); } + weqSelectedPt = wrongEqPoints.length - 1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + e.preventDefault(); + } + // Arrow keys: gain nudge (Up/Down), freq nudge (Left/Right) + else if (e.key === 'ArrowUp' && !e.altKey && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var step = e.shiftKey ? 6 : 1; + var curDB = weqYToDB((weqAnimRafId && weqAnimBaseY[weqSelectedPt] != null) ? weqAnimBaseY[weqSelectedPt] : wrongEqPoints[weqSelectedPt].y); + var newY = weqDBtoY(Math.min(weqDBRangeMax, curDB + step)); + wrongEqPoints[weqSelectedPt].y = newY; + if (weqAnimRafId && weqAnimBaseY[weqSelectedPt] != null) weqAnimBaseY[weqSelectedPt] = newY; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + else if (e.key === 'ArrowDown' && !e.altKey && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var stepD = e.shiftKey ? 6 : 1; + var curDBd = weqYToDB((weqAnimRafId && weqAnimBaseY[weqSelectedPt] != null) ? weqAnimBaseY[weqSelectedPt] : wrongEqPoints[weqSelectedPt].y); + var newYd = weqDBtoY(Math.max(-weqDBRangeMax, curDBd - stepD)); + wrongEqPoints[weqSelectedPt].y = newYd; + if (weqAnimRafId && weqAnimBaseY[weqSelectedPt] != null) weqAnimBaseY[weqSelectedPt] = newYd; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + else if (e.key === 'ArrowRight' && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var curFreq = weqXToFreq((weqAnimRafId && weqAnimBaseX[weqSelectedPt] != null) ? weqAnimBaseX[weqSelectedPt] : wrongEqPoints[weqSelectedPt].x); + var mult = e.shiftKey ? Math.pow(2, 1 / 3) : Math.pow(2, 1 / 12); + var newFreq = Math.min(WEQ_MAX_FREQ, curFreq * mult); + var newXr = weqFreqToX(newFreq); + wrongEqPoints[weqSelectedPt].x = newXr; + if (weqAnimRafId && weqAnimBaseX[weqSelectedPt] != null) weqAnimBaseX[weqSelectedPt] = newXr; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + else if (e.key === 'ArrowLeft' && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var curFreqL = weqXToFreq((weqAnimRafId && weqAnimBaseX[weqSelectedPt] != null) ? weqAnimBaseX[weqSelectedPt] : wrongEqPoints[weqSelectedPt].x); + var multL = e.shiftKey ? Math.pow(2, 1 / 3) : Math.pow(2, 1 / 12); + var newFreqL = Math.max(WEQ_MIN_FREQ, curFreqL / multL); + var newXl = weqFreqToX(newFreqL); + wrongEqPoints[weqSelectedPt].x = newXl; + if (weqAnimRafId && weqAnimBaseX[weqSelectedPt] != null) weqAnimBaseX[weqSelectedPt] = newXl; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + // Alt+Up/Down: adjust Q of selected point + else if (e.key === 'ArrowUp' && e.altKey && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var pt = wrongEqPoints[weqSelectedPt]; + var qFact = e.shiftKey ? 1.5 : 1.15; + pt.q = Math.min(40, (pt.q || 0.707) * qFact); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + else if (e.key === 'ArrowDown' && e.altKey && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + var ptD = wrongEqPoints[weqSelectedPt]; + var qFactD = e.shiftKey ? 1.5 : 1.15; + ptD.q = Math.max(0.025, (ptD.q || 0.707) / qFactD); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + // Tab: cycle through points + else if (e.key === 'Tab' && wrongEqPoints.length > 0) { + if (e.shiftKey) { + weqSelectedPt = weqSelectedPt <= 0 ? wrongEqPoints.length - 1 : weqSelectedPt - 1; + } else { + weqSelectedPt = weqSelectedPt >= wrongEqPoints.length - 1 ? 0 : weqSelectedPt + 1; + } + weqDrawCanvas(); + e.preventDefault(); + } + // 0 key: reset selected point gain to 0dB + else if (e.key === '0' && !e.ctrlKey && weqSelectedPt >= 0 && weqSelectedPt < wrongEqPoints.length) { + wrongEqPoints[weqSelectedPt].y = weqDBtoY(0); + if (weqAnimRafId && weqAnimBaseY[weqSelectedPt] != null) weqAnimBaseY[weqSelectedPt] = weqDBtoY(0); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + e.preventDefault(); + } + // E key: equal-distribute points across spectrum (split mode power tool) + else if ((e.key === 'e' || e.key === 'E') && !e.ctrlKey && !e.altKey && wrongEqPoints.length > 1) { + _weqPushUndo(); + var eqSorted = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var eqN = eqSorted.length; + for (var eqi = 0; eqi < eqN; eqi++) { + eqSorted[eqi].x = (eqi + 1) / (eqN + 1); + } + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + if (typeof showToast === 'function') showToast('Points equally distributed', 'info', 1500); + e.preventDefault(); + } + // S key: toggle split mode + else if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey) { + var splitBtnK = document.getElementById('weqSplitBtn'); + if (splitBtnK) splitBtnK.click(); + e.preventDefault(); + } + }); + } + + // Canvas mouse interaction + weqSetupMouse(wrap); + // Context menus (canvas + band rows) + weqSetupContextMenu(wrap); +} + +// ── Mouse interaction on canvas ── +function weqSetupMouse(wrap) { + var canvas = document.getElementById('weqCanvas'); + if (!canvas) return; + + function pos(e) { + var rect = canvas.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + return { + x: (e.clientX - rect.left) / rect.width, + y: weqCanvasToY(e.clientY - rect.top, WEQ_CANVAS_H) + }; + } + + function snapFreq(x) { + if (weqGrid === 'free') return x; + var freq = weqXToFreq(x); + var lines; + if (weqGrid === 'oct') { + lines = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; + } else if (weqGrid === '1/3') { + lines = []; + for (var f = 25; f <= 20000; f *= Math.pow(2, 1 / 3)) lines.push(f); + } else { // semi + lines = []; + for (var f2 = 27.5; f2 <= 20000; f2 *= Math.pow(2, 1 / 12)) lines.push(f2); + } + var best = freq, bestDist = Infinity; + lines.forEach(function (fl) { + var d = Math.abs(Math.log2(freq) - Math.log2(fl)); + if (d < bestDist) { bestDist = d; best = fl; } + }); + return weqFreqToX(best); + } + + function findNearest(p, radius) { + var bestIdx = -1, bestD = radius; + var rectW = canvas.getBoundingClientRect().width; + for (var i = 0; i < wrongEqPoints.length; i++) { + var dx = (wrongEqPoints[i].x - p.x) * rectW; + var ptType = wrongEqPoints[i].type || 'Bell'; + var d; + if (ptType === 'LP' || ptType === 'HP') { + // LP/HP points sit at 0dB — use X-distance only so the user + // can grab them from anywhere along their frequency column. + d = Math.abs(dx); + } else { + var dy = (wrongEqPoints[i].y - p.y) * WEQ_CANVAS_H; + d = Math.sqrt(dx * dx + dy * dy); + } + if (d < bestD) { bestD = d; bestIdx = i; } + } + return bestIdx; + } + + wrap.onmousedown = function (e) { + if (e.button !== 0) return; + var p = pos(e); + var dragOrigin = { x: p.x, y: p.y }; + weqDragAxis = null; + + if (weqTool === 'draw') { + var existing = findNearest(p, 14); + if (existing >= 0) { + // Grab existing point + _weqPushUndo(); // snapshot before drag + weqDragPt = existing; + weqSelectedPt = existing; + weqDrawCanvas(); + } else { + // Create new point + _weqPushUndo(); + // In split mode, new points are crossover dividers — force 0dB + var newY = weqSplitMode ? 0.5 : p.y; + var newPt = { uid: _weqAllocUid(), x: snapFreq(p.x), y: newY, pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: 0.707, type: 'Bell', drift: 0 }; + wrongEqPoints.push(newPt); + weqSelectedPt = wrongEqPoints.length - 1; + weqDragPt = weqSelectedPt; + if (weqAnimRafId) { weqAnimBaseY.push(newY); weqAnimBaseX.push(snapFreq(p.x)); } + weqDrawCanvas(); + weqSyncToHost(); + } + } + + function onMove(ev) { + if (weqDragPt < 0) return; + var pm = pos(ev); + + // Clamp X to valid frequency range (20Hz – 20kHz) + var xMin = weqFreqToX(WEQ_MIN_FREQ); + var xMax = weqFreqToX(WEQ_MAX_FREQ); + var newX = Math.max(xMin + 0.001, Math.min(xMax - 0.001, snapFreq(pm.x))); + var newY = Math.max(0, Math.min(1, pm.y)); + if (ev.shiftKey) { + if (!weqDragAxis) { + var dx = Math.abs(pm.x - dragOrigin.x); + var dy = Math.abs(pm.y - dragOrigin.y); + if (dx > 0.01 || dy > 0.01) weqDragAxis = dx > dy ? 'h' : 'v'; + } + if (weqDragAxis === 'h') newY = wrongEqPoints[weqDragPt].y; + else if (weqDragAxis === 'v') newX = wrongEqPoints[weqDragPt].x; + } else { + weqDragAxis = null; + } + wrongEqPoints[weqDragPt].x = newX; + wrongEqPoints[weqDragPt].y = newY; + + // Split mode: clamp X between neighbors (can't cross) + if (weqSplitMode) { + var _spSorted = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var _spIdx = _spSorted.indexOf(wrongEqPoints[weqDragPt]); + var _spMinX = _spIdx > 0 ? _spSorted[_spIdx - 1].x + 0.02 : 0.02; + var _spMaxX = _spIdx < _spSorted.length - 1 ? _spSorted[_spIdx + 1].x - 0.02 : 0.98; + wrongEqPoints[weqDragPt].x = Math.max(_spMinX, Math.min(_spMaxX, wrongEqPoints[weqDragPt].x)); + } + // LP/HP: vertical drag controls Q (resonance/slope), Y stays at 0dB + var dragType = wrongEqPoints[weqDragPt].type || 'Bell'; + if (dragType === 'LP' || dragType === 'HP') { + // Map vertical drag distance from 0dB center to Q: up = higher Q, down = lower Q + var dragDeltaY = weqDBtoY(0) - newY; // positive when dragged up + var newQ = 0.707 + dragDeltaY * 8; // scale: full drag = ~4 Q range + wrongEqPoints[weqDragPt].q = Math.max(0.025, Math.min(40, newQ)); + wrongEqPoints[weqDragPt].y = weqDBtoY(0); // keep at 0dB + } + // Update animation base so drift/anim is relative to dragged position + if (weqAnimRafId) { + if (weqAnimBaseY[weqDragPt] != null) weqAnimBaseY[weqDragPt] = wrongEqPoints[weqDragPt].y; + if (weqAnimBaseX[weqDragPt] != null) weqAnimBaseX[weqDragPt] = wrongEqPoints[weqDragPt].x; + if (weqAnimBaseQ[weqDragPt] != null) weqAnimBaseQ[weqDragPt] = wrongEqPoints[weqDragPt].q; + } + weqDrawCanvas(); + // Update band card freq + gain in real-time during drag + if (wrongEqPoints[weqDragPt]) { + var dragFreq = weqXToFreq(wrongEqPoints[weqDragPt].x); + var dragDB = weqYToDB(wrongEqPoints[weqDragPt].y); + // Update band card frequency hero value + var freqEl = document.querySelector('[data-weqfreq="' + weqDragPt + '"]'); + if (freqEl) freqEl.textContent = weqFmtFreq(dragFreq); + // Update band card gain value + var gainEl = document.querySelector('[data-weqgain="' + weqDragPt + '"]'); + if (gainEl) { + gainEl.textContent = weqFmtDB(dragDB); + gainEl.className = 'weq-card-pval' + (dragDB > 0.1 ? ' boost' : (dragDB < -0.1 ? ' cut' : '')); + } + // Update legend chip ranges + _weqUpdateLegendRanges(); + weqSyncToHost(); + weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + } + function onUp() { + weqDragPt = -1; + weqDragAxis = null; + wrap.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + // Update animation base positions if animation is running + if (weqAnimRafId) { + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + } + weqRenderPanel(); // full re-render on mouseup to update band rows + weqSyncToHost(); + weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + // Double-click: on point = reset gain to 0dB, on empty = add point at 0dB + wrap.ondblclick = function (e) { + var p = pos(e); + var hit = findNearest(p, 12); + if (hit >= 0) { + // Reset point gain to 0dB (pro EQ behavior) + _weqPushUndo(); + wrongEqPoints[hit].y = weqDBtoY(0); + if (weqAnimRafId && weqAnimBaseY[hit] != null) weqAnimBaseY[hit] = weqDBtoY(0); + weqSelectedPt = hit; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } else { + // Add new point at 0dB at clicked frequency + _weqPushUndo(); + var newPt = { uid: _weqAllocUid(), x: snapFreq(p.x), y: weqDBtoY(0), pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: 0.707, type: 'Bell', drift: 0 }; + wrongEqPoints.push(newPt); + if (weqAnimRafId) { weqAnimBaseY.push(newPt.y); weqAnimBaseX.push(newPt.x); } + weqSelectedPt = wrongEqPoints.length - 1; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + + // Hover cursor + tooltip + wrap.onmousemove = function (e) { + var p = pos(e); + // Floating tooltip + var tip = wrap.querySelector('.weq-tip'); + if (!tip) { + tip = document.createElement('div'); + tip.className = 'weq-tip'; + wrap.appendChild(tip); + } + + // During drag: show live tooltip following the dragged point + if (weqDragPt >= 0 && weqDragPt < wrongEqPoints.length) { + var dpt = wrongEqPoints[weqDragPt]; + var dFreq = weqXToFreq(dpt.x); + var dDB = weqYToDB(dpt.y); + tip.textContent = 'P' + (weqDragPt + 1) + ' ' + weqFmtFreq(dFreq) + 'Hz ' + (dDB >= 0 ? '+' : '') + weqFmtDB(dDB) + 'dB Q=' + (dpt.q || 0.707).toFixed(2); + var rect = canvas.getBoundingClientRect(); + tip.style.left = ((dpt.x * rect.width) | 0) + 'px'; + tip.style.top = (weqYtoCanvas(dpt.y, WEQ_CANVAS_H) - 24) + 'px'; + tip.style.display = ''; + tip.style.opacity = '1'; + tip.style.borderColor = 'var(--accent)'; + return; + } + + // Reset border color when not dragging + tip.style.borderColor = ''; + + var near = findNearest(p, 14); + if (weqTool === 'draw') { + wrap.style.cursor = near >= 0 ? 'grab' : 'crosshair'; + } + if (near >= 0) { + var pt = wrongEqPoints[near]; + var freq = weqXToFreq(pt.x); + var db = weqYToDB(pt.y); + tip.textContent = 'P' + (near + 1) + ' ' + weqFmtFreq(freq) + 'Hz ' + (db >= 0 ? '+' : '') + weqFmtDB(db) + 'dB Q=' + (pt.q || 0.707).toFixed(2); + var rect = canvas.getBoundingClientRect(); + tip.style.left = ((pt.x * rect.width) | 0) + 'px'; + tip.style.top = (weqYtoCanvas(pt.y, WEQ_CANVAS_H) - 24) + 'px'; + tip.style.display = ''; + } else { + // Show crosshair position + var freq2 = weqXToFreq(p.x); + var db2 = weqYToDB(p.y); + tip.textContent = weqFmtFreq(freq2) + 'Hz ' + (db2 >= 0 ? '+' : '') + weqFmtDB(db2) + 'dB'; + tip.style.left = ((e.clientX - canvas.getBoundingClientRect().left) | 0) + 'px'; + tip.style.top = ((e.clientY - canvas.getBoundingClientRect().top) - 24) + 'px'; + tip.style.display = ''; + tip.style.opacity = '0.4'; + } + if (near >= 0) tip.style.opacity = '1'; + }; + + wrap.onmouseleave = function () { + var tip = wrap.querySelector('.weq-tip'); + if (tip) tip.style.display = 'none'; + }; + + // ── Scroll wheel: adjust Q of nearest point (FabFilter-style) ── + wrap.onwheel = function (e) { + e.preventDefault(); + var p = pos(e); + var near = findNearest(p, 18); + if (near < 0) return; + var pt = wrongEqPoints[near]; + // Logarithmic Q scroll: multiply/divide by factor (Pro-Q 3 style) + var qFactor = e.shiftKey ? 1.5 : 1.15; + var dir = e.deltaY < 0 ? 1 : -1; + var curQ = pt.q || 0.707; + pt.q = Math.max(0.025, Math.min(40, dir > 0 ? curQ * qFactor : curQ / qFactor)); + weqSelectedPt = near; + // Update tooltip live + var tip = wrap.querySelector('.weq-tip'); + var freq = weqXToFreq(pt.x); + var db = weqYToDB(pt.y); + if (tip) tip.textContent = weqFmtFreq(freq) + 'Hz ' + weqFmtDB(db) + 'dB Q=' + pt.q.toFixed(2); + weqDrawCanvas(); + _weqUpdateLegendRanges(); + weqSyncToHost(); + weqSyncVirtualParams(); + }; + // Context menu uses global _weqShowCtxMenu (defined below) +} + +// ── Global context menu helper ── +function _weqShowCtxMenu(items, e) { + var old = document.querySelector('.weq-ctx'); + if (old) old.remove(); + + var menu = document.createElement('div'); + menu.className = 'ctx weq-ctx'; + menu.style.cssText = 'display:block;position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;z-index:9999'; + + items.forEach(function (item) { + if (item.sep) { + var sepEl = document.createElement('div'); + sepEl.className = 'ctx-sep'; + menu.appendChild(sepEl); + return; + } + var el = document.createElement('div'); + el.className = 'ctx-i' + (item.disabled ? ' disabled' : ''); + el.textContent = item.label; + if (!item.disabled && item.action) { + el.onclick = function (ev) { + ev.stopPropagation(); + closeMenu(); + item.action(); + }; + } + menu.appendChild(el); + }); + document.body.appendChild(menu); + + var rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px'; + if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px'; + + function closeMenu() { + if (menu.parentNode) menu.remove(); + document.removeEventListener('mousedown', onOutside); + document.removeEventListener('keydown', onEsc); + } + function onOutside(de) { + if (!menu.contains(de.target)) closeMenu(); + } + function onEsc(ke) { + if (ke.key === 'Escape') { closeMenu(); ke.preventDefault(); } + } + setTimeout(function () { + document.addEventListener('mousedown', onOutside); + document.addEventListener('keydown', onEsc); + }, 10); +} + +// ── Build point context menu items (shared by canvas & band rows) ── +function _weqBuildPointMenu(hit, e) { + var pt = wrongEqPoints[hit]; + var freq = weqXToFreq(pt.x); + weqSelectedPt = hit; + + var typeItems = WEQ_TYPES.map(function (t) { + return { + label: (pt.type === t ? '● ' : ' ') + t, action: function () { + wrongEqPoints[hit].type = t; + if (t === 'LP' || t === 'HP') wrongEqPoints[hit].y = weqDBtoY(0); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }; + }); + + var items = [ + { label: 'P' + (hit + 1) + ': ' + weqFmtFreq(freq) + 'Hz ' + weqFmtDB(weqYToDB(pt.y)) + 'dB Q=' + (pt.q || 0.707).toFixed(2), disabled: true }, + { sep: true }, + { + label: 'Reset to 0 dB', action: function () { + _weqPushUndo(); + wrongEqPoints[hit].y = weqDBtoY(0); + if (weqAnimRafId && weqAnimBaseY[hit] != null) weqAnimBaseY[hit] = weqDBtoY(0); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }, + { + label: 'Reset Q to 0.707', action: function () { + _weqPushUndo(); + wrongEqPoints[hit].q = 0.707; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }, + { + label: 'Duplicate Point', action: function () { + _weqPushUndo(); + var src = wrongEqPoints[hit]; + var dupFreq = Math.min(WEQ_MAX_FREQ, weqXToFreq(src.x) * 1.1); + var dup = { uid: _weqAllocUid(), x: weqFreqToX(dupFreq), y: src.y, pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: src.q || 0.707, type: src.type || 'Bell', drift: src.drift || 0 }; + wrongEqPoints.push(dup); + if (weqAnimRafId) { weqAnimBaseY.push(dup.y); weqAnimBaseX.push(dup.x); } + weqSelectedPt = wrongEqPoints.length - 1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }, + { sep: true } + ]; + + items.push({ label: 'Filter Type:', disabled: true }); + typeItems.forEach(function (ti) { items.push(ti); }); + + items.push({ sep: true }); + items.push({ + label: pt.solo ? '✦ Unsolo' : '✦ Solo', action: function () { + var was = wrongEqPoints[hit].solo; + for (var si = 0; si < wrongEqPoints.length; si++) wrongEqPoints[si].solo = false; + wrongEqPoints[hit].solo = !was; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }); + items.push({ + label: pt.mute ? '🔊 Unmute' : '🔇 Mute', action: function () { + wrongEqPoints[hit].mute = !wrongEqPoints[hit].mute; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }); + items.push({ sep: true }); + items.push({ label: 'Assign Plugins...', action: function () { weqShowPluginAssign(hit, e); } }); + items.push({ sep: true }); + items.push({ + label: '⌫ Delete Point', action: function () { + _weqPushUndo(); + wrongEqPoints.splice(hit, 1); + weqSegSel.clear(); + if (weqAnimRafId && weqAnimBaseY.length > hit) { weqAnimBaseY.splice(hit, 1); weqAnimBaseX.splice(hit, 1); } + weqSelectedPt = -1; + weqFocusBand = -1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }); + + return items; +} +// ── Setup canvas & band row right-click context menus ── +function weqSetupContextMenu(wrap) { + // Helper functions from canvas setup + var cvs = wrap.querySelector('canvas'); + function pos(e) { + var r = cvs.getBoundingClientRect(); + return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; + } + function findNearest(p, maxDist) { + var best = -1, bestD = Infinity; + for (var i = 0; i < wrongEqPoints.length; i++) { + var px = wrongEqPoints[i].x, py = wrongEqPoints[i].y; + var r = cvs.getBoundingClientRect(); + var dx = (p.x - px) * r.width, dy = (p.y - py) * r.height; + var d = Math.sqrt(dx * dx + dy * dy); + if (d < bestD) { bestD = d; best = i; } + } + return bestD <= maxDist ? best : -1; + } + function snapFreq(x) { + return Math.max(0.001, Math.min(0.999, x)); + } + + // Canvas right-click + wrap.oncontextmenu = function (e) { + e.preventDefault(); + var p = pos(e); + var hit = findNearest(p, 14); + + if (hit < 0) { + // Empty space menu + var freqHere = weqXToFreq(p.x); + var dbHere = weqYToDB(p.y); + var emptyItems = [ + { label: weqFmtFreq(freqHere) + 'Hz ' + weqFmtDB(dbHere) + 'dB', disabled: true }, + { sep: true }, + { + label: '+ Add Point Here', action: function () { + var newPt = { uid: _weqAllocUid(), x: snapFreq(p.x), y: p.y, pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: 0.707, type: 'Bell', drift: 0 }; + wrongEqPoints.push(newPt); + if (weqAnimRafId) { weqAnimBaseY.push(p.y); weqAnimBaseX.push(newPt.x); } + weqSelectedPt = wrongEqPoints.length - 1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + }, + { + label: '+ Add at 0 dB', action: function () { + var newPt = { uid: _weqAllocUid(), x: snapFreq(p.x), y: weqDBtoY(0), pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: 0.707, type: 'Bell', drift: 0 }; + wrongEqPoints.push(newPt); + if (weqAnimRafId) { weqAnimBaseY.push(newPt.y); weqAnimBaseX.push(newPt.x); } + weqSelectedPt = wrongEqPoints.length - 1; + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + } + } + ]; + _weqShowCtxMenu(emptyItems, e); + return; + } + + // Point context menu + _weqShowCtxMenu(_weqBuildPointMenu(hit, e), e); + }; + + // Band card right-click + document.querySelectorAll('.weq-band-card').forEach(function (row) { + row.oncontextmenu = function (e) { + e.preventDefault(); + e.stopPropagation(); + var idx = parseInt(row.dataset.bandidx); + if (idx >= 0 && idx < wrongEqPoints.length) { + _weqShowCtxMenu(_weqBuildPointMenu(idx, e), e); + } + }; + }); +} + +// ── Plugin assignment toggle menu (multi-plugin per band) ── +function weqShowPluginAssign(ptIdx, evt) { + var pt = wrongEqPoints[ptIdx]; + if (!pt) return; + if (!pt.pluginIds) pt.pluginIds = []; + + var old = document.querySelector('.weq-ctx'); + if (old) old.remove(); + + var menu = document.createElement('div'); + menu.className = 'ctx weq-ctx'; + menu.style.cssText = 'display:block;position:fixed;left:' + evt.clientX + 'px;top:' + evt.clientY + 'px;z-index:9999;min-width:180px'; + + // Title + var title = document.createElement('div'); + title.className = 'ctx-i disabled'; + title.textContent = 'Plugins for Band ' + (ptIdx + 1); + menu.appendChild(title); + + var sep = document.createElement('div'); + sep.className = 'ctx-sep'; + menu.appendChild(sep); + + if (typeof pluginBlocks === 'undefined' || pluginBlocks.length === 0) { + var none = document.createElement('div'); + none.className = 'ctx-i disabled'; + none.textContent = 'No plugins loaded'; + menu.appendChild(none); + } else { + // One row per plugin — toggle on/off + for (var pi = 0; pi < pluginBlocks.length; pi++) { + if (pluginBlocks[pi].isVirtual) continue; // skip WrongEQ virtual block + (function (pb) { + var isOn = pt.pluginIds.indexOf(pb.id) >= 0; + var chainPos = isOn ? (pt.pluginIds.indexOf(pb.id) + 1) : 0; + var el = document.createElement('div'); + el.className = 'ctx-i' + (isOn ? ' on' : ''); + el.textContent = (isOn ? '✓ [' + chainPos + '] ' : '○ ') + pb.name; + el.style.cursor = 'pointer'; + el.onclick = function (ev) { + ev.stopPropagation(); + var idx = pt.pluginIds.indexOf(pb.id); + _weqEnsureUid(pt); // ensure stable uid exists + var bandBusId = pt.uid; // Stable UID — survives point reordering + if (idx >= 0) { + pt.pluginIds.splice(idx, 1); // remove from band + pb.busId = 0; // unassign + // Tell C++ this plugin is no longer on any bus + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + busFn(pb.hostId !== undefined ? pb.hostId : pb.id, 0); + } + } else { + // Remove from any other band first + for (var oi = 0; oi < wrongEqPoints.length; oi++) { + if (!wrongEqPoints[oi].pluginIds) continue; + var oidx = wrongEqPoints[oi].pluginIds.indexOf(pb.id); + if (oidx >= 0) wrongEqPoints[oi].pluginIds.splice(oidx, 1); + } + pt.pluginIds.push(pb.id); // add to this band's chain + pb.busId = bandBusId; + // Tell C++ to route this plugin to this band's bus + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + busFn(pb.hostId !== undefined ? pb.hostId : pb.id, bandBusId); + } + } + // Re-render the menu in place + menu.remove(); + weqShowPluginAssign(ptIdx, evt); + weqDrawCanvas(); + weqSyncToHost(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + menu.appendChild(el); + })(pluginBlocks[pi]); + } + } + + // Clear all + if (pt.pluginIds.length > 0) { + var sep2 = document.createElement('div'); + sep2.className = 'ctx-sep'; + menu.appendChild(sep2); + var clearEl = document.createElement('div'); + clearEl.className = 'ctx-i'; + clearEl.textContent = '✕ Clear All'; + clearEl.onclick = function () { + // Unassign all plugins from this band in C++ + var oldIds = pt.pluginIds.slice(); + pt.pluginIds = []; + if (window.__JUCE__ && window.__JUCE__.backend) { + var busFn = window.__juceGetNativeFunction('setPluginBus'); + oldIds.forEach(function (pid) { + // Find the plugin block and update its busId + for (var pi = 0; pi < pluginBlocks.length; pi++) { + if (pluginBlocks[pi].id === pid) { + pluginBlocks[pi].busId = 0; + busFn(pluginBlocks[pi].hostId !== undefined ? pluginBlocks[pi].hostId : pid, 0); + break; + } + } + }); + } + menu.remove(); + weqRenderPanel(); weqSyncToHost(); + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + menu.appendChild(clearEl); + } + + // Close + var sep3 = document.createElement('div'); + sep3.className = 'ctx-sep'; + menu.appendChild(sep3); + var closeEl = document.createElement('div'); + closeEl.className = 'ctx-i'; + closeEl.textContent = 'Done'; + closeEl.onclick = function () { + menu.remove(); + weqRenderPanel(); + }; + menu.appendChild(closeEl); + + document.body.appendChild(menu); + + // Clamp to viewport + var rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px'; + if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px'; + + function onEsc(ke) { if (ke.key === 'Escape') { menu.remove(); weqRenderPanel(); document.removeEventListener('keydown', onEsc); } } + document.addEventListener('keydown', onEsc); +} + +// ── Per-segment settings popup ── +function weqShowSegSettings(ptIdx, evt) { + var pt = wrongEqPoints[ptIdx]; + if (!pt) return; + if (!pt.seg) pt.seg = {}; + var seg = pt.seg; + + var existing = document.querySelector('.weq-seg-popup'); + if (existing) existing.remove(); + + var popup = document.createElement('div'); + popup.className = 'weq-seg-popup visible'; + popup.style.left = evt.clientX + 'px'; + popup.style.top = evt.clientY + 'px'; + popup.style.position = 'fixed'; + + var freq = weqXToFreq(pt.x); + var h = '
Segment from P' + (ptIdx + 1) + ' (' + weqFmtFreq(freq) + 'Hz)
'; + + + // Warp + h += '
Warp' + (seg.warp != null ? seg.warp : 'G') + '
'; + // Steps + h += '
Steps' + (seg.steps != null ? seg.steps : 'G') + '
'; + // Per-segment curve effects + h += '
Effects
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += ''; + h += '
'; + // Reset + h += '
'; + + popup.innerHTML = h; + document.body.appendChild(popup); + + + // Drag knobs + popup.querySelectorAll('[data-segk]').forEach(function (knob) { + var key = knob.dataset.segk; + knob.onmousedown = function (me) { + me.preventDefault(); + var startY = me.clientY; + var startVal = seg[key] != null ? seg[key] : (key === 'warp' ? weqGlobalWarp : weqGlobalSteps); + function onMove(ev) { + var dy = startY - ev.clientY; + if (key === 'warp') { + seg.warp = Math.max(-100, Math.min(100, startVal + dy)); + knob.textContent = seg.warp; + } else if (key === 'steps') { + seg.steps = Math.max(0, Math.min(32, Math.round(startVal + dy / 5))); + knob.textContent = seg.steps || 'Off'; + } + weqDrawCanvas(); + } + function onUp2() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp2); + weqSyncToHost(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp2); + }; + }); + + // Per-segment effects — apply to BOTH endpoints of the segment + // (current point = start, next sorted point = end) + popup.querySelectorAll('[data-segfx]').forEach(function (btn) { + btn.onclick = function (fxe) { + fxe.stopPropagation(); + var fx = btn.dataset.segfx; + + // Find the next point after this one in sorted order + var sortedPts = wrongEqPoints.slice().sort(function (a, b) { return a.x - b.x; }); + var sortedIdx = sortedPts.indexOf(pt); + var endPt = (sortedIdx >= 0 && sortedIdx < sortedPts.length - 1) ? sortedPts[sortedIdx + 1] : null; + + // Apply effect to start point + var curDB = weqYToDB(pt.y); + if (fx === 'mirror') { + pt.y = weqDBtoY(-curDB); + if (endPt) endPt.y = weqDBtoY(-weqYToDB(endPt.y)); + } else if (fx === 'invert') { + pt.y = weqDBtoY(-curDB); + if (endPt) endPt.y = weqDBtoY(-weqYToDB(endPt.y)); + } else if (fx === 'smooth') { + pt.y = weqDBtoY(curDB * 0.5); + if (endPt) endPt.y = weqDBtoY(weqYToDB(endPt.y) * 0.5); + } else if (fx === 'random') { + pt.y = weqDBtoY((Math.random() - 0.5) * 24); + if (endPt) endPt.y = weqDBtoY((Math.random() - 0.5) * 24); + } else if (fx === 'zero') { + pt.y = weqDBtoY(0); + if (endPt) endPt.y = weqDBtoY(0); + } + weqRenderPanel(); // full re-render so band rows update + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + // Close the popup after applying + popup.remove(); + document.removeEventListener('mousedown', dismiss); + }; + }); + + // Reset + var resetBtn = popup.querySelector('#weqSegReset'); + if (resetBtn) resetBtn.onclick = function () { + pt.seg = null; + popup.remove(); + weqDrawCanvas(); + weqSyncToHost(); + }; + + // Dismiss + function dismiss(de) { + if (!popup.contains(de.target)) { + popup.remove(); + document.removeEventListener('mousedown', dismiss); + } + } + setTimeout(function () { document.addEventListener('mousedown', dismiss); }, 10); +} + +// ── Shapes menu ── +var WEQ_SHAPES = [ + { name: 'Flat', pts: function () { return []; } }, + { + name: 'Tilt +', pts: function () { + return [ + { x: weqFreqToX(80), db: -4, type: 'LShf', q: 0.707 }, + { x: weqFreqToX(8000), db: 4, type: 'HShf', q: 0.707 } + ]; + } + }, + { + name: 'Tilt −', pts: function () { + return [ + { x: weqFreqToX(80), db: 4, type: 'LShf', q: 0.707 }, + { x: weqFreqToX(8000), db: -4, type: 'HShf', q: 0.707 } + ]; + } + }, + { + name: 'Smile', pts: function () { + return [ + { x: weqFreqToX(60), db: 5, type: 'LShf', q: 0.707 }, + { x: weqFreqToX(400), db: -3, type: 'Bell', q: 0.8 }, + { x: weqFreqToX(2500), db: -3, type: 'Bell', q: 0.8 }, + { x: weqFreqToX(10000), db: 5, type: 'HShf', q: 0.707 } + ]; + } + }, + { + name: 'Scoop', pts: function () { + return [ + { x: weqFreqToX(60), db: -2, type: 'LShf', q: 0.707 }, + { x: weqFreqToX(400), db: 4, type: 'Bell', q: 1.0 }, + { x: weqFreqToX(2500), db: 4, type: 'Bell', q: 1.0 }, + { x: weqFreqToX(10000), db: -2, type: 'HShf', q: 0.707 } + ]; + } + }, + { + name: 'Presence', pts: function () { + return [ + { x: weqFreqToX(2500), db: 5, type: 'Bell', q: 1.5 }, + { x: weqFreqToX(5000), db: 3, type: 'Bell', q: 1.0 } + ]; + } + }, + { + name: 'Air', pts: function () { + return [ + { x: weqFreqToX(10000), db: 6, type: 'HShf', q: 0.707 } + ]; + } + }, + { + name: 'Low Cut', pts: function () { + return [ + { x: weqFreqToX(80), db: 0, type: 'HP', q: 0.707 } + ]; + } + }, + { + name: 'High Cut', pts: function () { + return [ + { x: weqFreqToX(12000), db: 0, type: 'LP', q: 0.707 } + ]; + } + }, + { + name: 'Telephone', pts: function () { + return [ + { x: weqFreqToX(300), db: 0, type: 'HP', q: 1.0 }, + { x: weqFreqToX(1000), db: 3, type: 'Bell', q: 0.5 }, + { x: weqFreqToX(3500), db: 0, type: 'LP', q: 1.0 } + ]; + } + }, + { + name: 'Sub Boost', pts: function () { + return [ + { x: weqFreqToX(50), db: 6, type: 'LShf', q: 0.707 }, + { x: weqFreqToX(120), db: 3, type: 'Bell', q: 1.5 } + ]; + } + }, + { + name: 'De-Mud', pts: function () { + return [ + { x: weqFreqToX(250), db: -4, type: 'Bell', q: 0.8 }, + { x: weqFreqToX(500), db: -3, type: 'Bell', q: 1.0 } + ]; + } + }, + { + name: 'Vocal', pts: function () { + return [ + { x: weqFreqToX(100), db: 0, type: 'HP', q: 0.707 }, + { x: weqFreqToX(250), db: -2, type: 'Bell', q: 1.0 }, + { x: weqFreqToX(3000), db: 4, type: 'Bell', q: 1.2 }, + { x: weqFreqToX(12000), db: 3, type: 'HShf', q: 0.707 } + ]; + } + } +]; + +function weqShowShapesMenu(anchor) { + var menu = document.createElement('div'); + menu.className = 'ctx'; + var rect = anchor.getBoundingClientRect(); + menu.style.cssText = 'display:block;position:fixed;left:' + rect.left + 'px;top:' + (rect.bottom + 2) + 'px;z-index:999'; + + WEQ_SHAPES.forEach(function (shape) { + var el = document.createElement('div'); + el.className = 'ctx-i'; + el.textContent = shape.name; + el.onclick = function () { + menu.remove(); + _weqPushUndo(); + var raw = shape.pts(); + wrongEqPoints = raw.map(function (p) { + var pType = p.type || 'Bell'; + var pQ = p.q != null ? p.q : 0.707; + // LP/HP sit at 0dB — ignore db from shape + var pY = (pType === 'LP' || pType === 'HP') ? weqDBtoY(0) : weqDBtoY(p.db); + return { + uid: _weqAllocUid(), x: p.x, y: pY, + pluginIds: [], preEq: true, seg: null, + solo: false, mute: false, + q: pQ, type: pType, drift: 0 + }; + }); + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + weqSelectedPt = -1; + weqFocusBand = -1; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + menu.appendChild(el); + }); + + document.body.appendChild(menu); + function dismiss(de) { if (!menu.contains(de.target)) { menu.remove(); document.removeEventListener('click', dismiss); } } + setTimeout(function () { document.addEventListener('click', dismiss); }, 10); +} + +// ── Randomize ── +function weqRandomize() { + var n = 4 + Math.floor(Math.random() * 5); // 4-8 points + wrongEqPoints = []; + for (var i = 0; i < n; i++) { + wrongEqPoints.push({ + uid: _weqAllocUid(), + x: 0.05 + (i / (n - 1)) * 0.9, + y: weqDBtoY((Math.random() - 0.5) * 24), + pluginIds: [], preEq: true, + seg: null, + solo: false, + mute: false, + q: 0.707, + type: 'Bell', + drift: 0 + }); + } + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + weqSelectedPt = -1; + weqRenderPanel(); + weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); +} + +// ── Sync EQ curve to C++ (send evaluated bin gains) ── +var _weqSyncPending = false; +var _weqSyncTimer = null; +var _weqSyncMinInterval = 0; // no throttle — every event syncs immediately for zero-stepping + +function weqSyncToHost() { + if (!window.__JUCE__ || !window.__JUCE__.backend) return; + // No throttle — sync immediately on every call for zero-stepping EQ + _weqDoSync(); +} + +function _weqDoSync() { + try { + var setEqFn = window.__juceGetNativeFunction('setEqCurve'); + if (!setEqFn) return; + + var data = { + globalBypass: weqGlobalBypass, + preEq: weqPreEq, + points: wrongEqPoints.map(function (p, i) { + _weqEnsureUid(p); + return { + freqHz: weqXToFreq(p.x), + gainDB: weqYToDB(p.y), + busId: p.uid, + pluginIds: p.pluginIds || [], + solo: p.solo || false, + mute: p.mute || false, + q: p.q != null ? p.q : 0.707, + type: p.type || 'Bell', + drift: p.drift || 0, + preEq: p.preEq !== false, + stereoMode: p.stereoMode || 0, + slope: p.slope || 1, + seg: p.seg || null + }; + }), + globalDepth: weqGlobalDepth, + globalWarp: weqGlobalWarp, + globalSteps: weqGlobalSteps, + globalTilt: weqGlobalTilt, + unassignedMode: weqUnassignedMode, + animSpeed: weqAnimSpeed, + animDepth: weqAnimDepth, + animShape: weqAnimShape, + drift: weqDrift, + driftRange: weqDriftRange, + driftScale: weqDriftScale, + driftContinuous: weqDriftContinuous, + driftMode: weqDriftMode, + driftTexture: weqDriftTexture, + gainLoCut: weqGainLoCut, + gainHiCut: weqGainHiCut, + driftLoCut: weqDriftLoCut, + driftHiCut: weqDriftHiCut, + qModSpeed: weqQModSpeed, + qModDepth: weqQModDepth, + qModShape: weqQModShape, + qLoCut: weqQLoCut, + qHiCut: weqQHiCut, + dbRange: weqDBRangeMax, + splitMode: weqSplitMode, + oversample: weqOversample + }; + setEqFn(JSON.stringify(data)); + weqSyncVirtualParams(); + } catch (e) { /* native not ready */ } +} + +// ── Visibility toggle (called when routing mode changes) ── +function weqSetVisible(visible) { + var overlay = document.getElementById('weqOverlay'); + var openBtn = document.getElementById('weqOpenBtn'); + // Show/hide the open button based on routing mode + if (openBtn) openBtn.style.display = visible ? '' : 'none'; + // Close popup if switching away from WrongEQ mode + if (!visible && overlay) overlay.classList.remove('visible'); + + // Virtual plugin block: create when entering WrongEQ mode, destroy when leaving + if (visible) { + weqCreateVirtualBlock(); + } else { + weqDestroyVirtualBlock(); + } +} + +// ════════════════════════════════════════════════════════════ +// SEGMENT EFFECTS — operations on selected band groups +// ════════════════════════════════════════════════════════════ + +// Get sorted selected indices (by frequency, low to high) +function _weqSortedSel() { + var arr = Array.from(weqSegSel).filter(function (idx) { + return idx >= 0 && idx < wrongEqPoints.length; + }); + arr.sort(function (a, b) { return wrongEqPoints[a].x - wrongEqPoints[b].x; }); + return arr; +} + +// ── Apply a segment operation ── +function _weqSegApply(op) { + if (weqSegSel.size < 2) return; + var sorted = _weqSortedSel(); + _weqPushUndo(); + switch (op) { + case 'fill': _weqSegFill(sorted); break; + case 'mirror': _weqSegMirror(sorted); break; + case 'randomize': _weqSegRand(sorted); break; + case 'fade': _weqSegFade(sorted); break; + case 'normalize': _weqSegNormalize(sorted); break; + case 'flatten': _weqSegFlatten(sorted); break; + default: break; + } + + weqRenderPanel(); + weqSyncToHost(); + weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); +} + +// FILL: Generate evenly-spaced points between the two outermost selected bands +function _weqSegFill(sorted) { + var first = wrongEqPoints[sorted[0]]; + var last = wrongEqPoints[sorted[sorted.length - 1]]; + var xMin = first.x, xMax = last.x; + var yMin = first.y, yMax = last.y; + var qFirst = first.q || 0.707, qLast = last.q || 0.707; + + // How many to add? prompt or use count. Use count = max(2, selected-2) to fill gaps + var count = Math.max(2, sorted.length); + for (var i = 1; i < count; i++) { + var t = i / count; + var newX = xMin + (xMax - xMin) * t; + var newY = yMin + (yMax - yMin) * t; + var newQ = qFirst + (qLast - qFirst) * t; + // Check if a point already exists near this frequency + var fNew = weqXToFreq(newX); + var exists = false; + for (var j = 0; j < wrongEqPoints.length; j++) { + var fExist = weqXToFreq(wrongEqPoints[j].x); + if (Math.abs(fExist - fNew) / fNew < 0.03) { exists = true; break; } // within 3% + } + if (!exists && wrongEqPoints.length < 32) { + var pt = { uid: _weqAllocUid(), x: newX, y: newY, pluginIds: [], preEq: true, seg: null, solo: false, mute: false, q: Math.round(newQ * 100) / 100, type: 'Bell', drift: 0 }; + wrongEqPoints.push(pt); + if (weqAnimRafId) { weqAnimBaseY.push(pt.y); weqAnimBaseX.push(pt.x); } + } + } +} + +// MIRROR: Flip gains across 0 dB +function _weqSegMirror(sorted) { + for (var i = 0; i < sorted.length; i++) { + var pt = wrongEqPoints[sorted[i]]; + var db = weqYToDB(pt.y); + pt.y = weqDBtoY(-db); + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = pt.y; + } + } +} + +// RANDOMIZE: Scatter gains randomly within ±range +function _weqSegRand(sorted) { + for (var i = 0; i < sorted.length; i++) { + var pt = wrongEqPoints[sorted[i]]; + // Random gain within ±maxDB range + var db = (Math.random() * 2 - 1) * weqDBRangeMax * 0.75; + pt.y = weqDBtoY(db); + // Random Q between 0.3 and 8 + pt.q = Math.round((0.3 + Math.random() * 7.7) * 100) / 100; + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = pt.y; + } + } +} + +// FADE: Linear interpolation of gain between first and last selected band +function _weqSegFade(sorted) { + if (sorted.length < 2) return; + var first = wrongEqPoints[sorted[0]]; + var last = wrongEqPoints[sorted[sorted.length - 1]]; + var dbStart = weqYToDB(first.y); + var dbEnd = weqYToDB(last.y); + + for (var i = 0; i < sorted.length; i++) { + var t = i / (sorted.length - 1); + var db = dbStart + (dbEnd - dbStart) * t; + wrongEqPoints[sorted[i]].y = weqDBtoY(db); + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = weqDBtoY(db); + } + } +} + +// NORMALIZE: Scale all selected gains so the peak reaches ±maxDB * 0.8 +function _weqSegNormalize(sorted) { + var maxAbs = 0; + for (var i = 0; i < sorted.length; i++) { + var db = Math.abs(weqYToDB(wrongEqPoints[sorted[i]].y)); + if (db > maxAbs) maxAbs = db; + } + if (maxAbs < 0.1) return; // all flat, nothing to normalize + var target = weqDBRangeMax * 0.8; + var scale = target / maxAbs; + for (var i = 0; i < sorted.length; i++) { + var db = weqYToDB(wrongEqPoints[sorted[i]].y); + wrongEqPoints[sorted[i]].y = weqDBtoY(db * scale); + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = weqDBtoY(db * scale); + } + } +} + +// FLATTEN: Set all selected bands to 0 dB +function _weqSegFlatten(sorted) { + for (var i = 0; i < sorted.length; i++) { + wrongEqPoints[sorted[i]].y = weqDBtoY(0); + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = weqDBtoY(0); + } + } +} + +// SHAPE APPLY: Apply a waveform shape across the spectral range of selection +function _weqSegShapeApply(sorted, shapeFn) { + if (sorted.length < 2) return; + for (var i = 0; i < sorted.length; i++) { + var t = i / (sorted.length - 1); // 0 to 1 across selection + var shapeVal = shapeFn(t); // -1 to 1 + var db = shapeVal * weqDBRangeMax * 0.7; // scale to 70% of range + wrongEqPoints[sorted[i]].y = weqDBtoY(db); + if (weqAnimRafId && weqAnimBaseY[sorted[i]] != null) { + weqAnimBaseY[sorted[i]] = weqDBtoY(db); + } + } + weqRenderPanel(); + weqSyncToHost(); + weqSyncVirtualParams(); + if (typeof markStateDirty === 'function') markStateDirty(); +} + +// Shape selection popup menu +function _weqSegShapeMenu(e) { + var sorted = _weqSortedSel(); + if (sorted.length < 2) return; + + var shapes = [ + { label: '∿ Sine', fn: function (t) { return Math.sin(t * Math.PI * 2); } }, + { label: '∿ Sine ×2', fn: function (t) { return Math.sin(t * Math.PI * 4); } }, + { label: '∿ Sine ×4', fn: function (t) { return Math.sin(t * Math.PI * 8); } }, + { sep: true }, + { label: '⟋ Saw Up', fn: function (t) { return t * 2 - 1; } }, + { label: '⟍ Saw Down', fn: function (t) { return 1 - t * 2; } }, + { label: '△ Triangle', fn: function (t) { return t < 0.5 ? t * 4 - 1 : 3 - t * 4; } }, + { label: '⊓ Square', fn: function (t) { return t < 0.5 ? 1 : -1; } }, + { sep: true }, + { label: '⤴ Rise', fn: function (t) { return Math.pow(t, 1.5) * 2 - 1; } }, + { label: '⤵ Fall', fn: function (t) { return (1 - Math.pow(t, 1.5)) * 2 - 1; } }, + { label: '∩ Bump', fn: function (t) { return Math.sin(t * Math.PI); } }, + { label: '∪ Dip', fn: function (t) { return -Math.sin(t * Math.PI); } }, + { sep: true }, + { label: '⚡ Noise', fn: function () { return Math.random() * 2 - 1; } }, + { label: '↝ Smooth Noise', fn: function (t) { return Math.sin(t * 7.3 + 1.2) * 0.6 + Math.sin(t * 13.1 + 0.7) * 0.4; } } + ]; + + var items = shapes.map(function (s) { + if (s.sep) return { sep: true }; + return { label: s.label, action: function () { _weqSegShapeApply(sorted, s.fn); } }; + }); + + _weqShowCtxMenu(items, e); +} + +// ── Open/close helpers ── +function weqOpen() { + var overlay = document.getElementById('weqOverlay'); + if (overlay) { + overlay.classList.add('visible'); + var popup = document.getElementById('weqPanel'); + if (popup && !popup.classList.contains('weq-dragged')) { + // First open: center it + popup.style.left = '50%'; + popup.style.top = '50%'; + popup.style.transform = 'translate(-50%, -50%)'; + } + weqInvalidateStyleCache(); + weqRenderPanel(); + } +} +function weqClose() { + var overlay = document.getElementById('weqOverlay'); + if (overlay) overlay.classList.remove('visible'); +} + +// ── Drag-to-move via header bar ── +var _weqDragState = null; +function _weqInitDrag() { + document.addEventListener('mousedown', function (e) { + // Only drag from the header bar (not buttons inside it) + var hdr = e.target.closest('.weq-header'); + if (!hdr) return; + if (e.target.tagName === 'BUTTON' || e.target.tagName === 'SELECT') return; + var popup = document.getElementById('weqPanel'); + if (!popup) return; + + e.preventDefault(); + + // If this is the first drag, switch from centered to absolute positioning + if (!popup.classList.contains('weq-dragged')) { + var rect = popup.getBoundingClientRect(); + popup.style.left = rect.left + 'px'; + popup.style.top = rect.top + 'px'; + popup.classList.add('weq-dragged'); + } + + var startX = e.clientX; + var startY = e.clientY; + var startLeft = parseInt(popup.style.left) || 0; + var startTop = parseInt(popup.style.top) || 0; + + function onMove(ev) { + var dx = ev.clientX - startX; + var dy = ev.clientY - startY; + var newLeft = startLeft + dx; + var newTop = startTop + dy; + // Clamp so at least 80px of header stays visible horizontally, + // and 30px vertically — prevents losing the popup off-screen + var pw = popup.offsetWidth; + var ph = popup.offsetHeight; + var vw = window.innerWidth; + var vh = window.innerHeight; + var minVisible = 80; + newLeft = Math.max(-pw + minVisible, Math.min(vw - minVisible, newLeft)); + newTop = Math.max(-30, Math.min(vh - 30, newTop)); + popup.style.left = newLeft + 'px'; + popup.style.top = newTop + 'px'; + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); +} +_weqInitDrag(); + +// Wire up the open button + Escape key +(function () { + document.addEventListener('click', function (e) { + if (e.target && e.target.id === 'weqOpenBtn') weqOpen(); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + var overlay = document.getElementById('weqOverlay'); + if (overlay && overlay.classList.contains('visible')) { + weqClose(); + e.preventDefault(); + e.stopPropagation(); + } + } + }); +})(); + +// ════════════════════════════════════════════════════════════ +// WrongEQ VIRTUAL PLUGIN BLOCK +// Exposes EQ parameters in the plugin rack for modulation +// ════════════════════════════════════════════════════════════ + +var WEQ_VIRTUAL_ID = -100; // negative to avoid collision with real hosted plugin IDs +var _weqVirtualBlock = null; // reference to the virtual plugin block in pluginBlocks[] + +// ── Parameter definitions ── +// Each param: { key, name, toNorm(val), fromNorm(v), display(val), get(), set(val) } +function _weqParamDefs() { + var defs = []; + + // Global params — cppIndex starts at 100 to avoid collision with per-band (band*4+field) + // Must match C++ kWeqGlobalBase = 100 + defs.push({ + key: 'depth', name: 'Depth', min: 0, max: 200, + cppIndex: 100, + get: function () { return weqGlobalDepth; }, + set: function (v) { weqGlobalDepth = v; }, + toNorm: function (v) { return v / 200; }, + fromNorm: function (n) { return Math.round(n * 200); }, + display: function (v) { return v + '%'; } + }); + defs.push({ + key: 'warp', name: 'Warp', min: -100, max: 100, + cppIndex: 101, + get: function () { return weqGlobalWarp; }, + set: function (v) { weqGlobalWarp = v; }, + toNorm: function (v) { return (v + 100) / 200; }, + fromNorm: function (n) { return Math.round(n * 200 - 100); }, + display: function (v) { return (v >= 0 ? '+' : '') + v; } + }); + defs.push({ + key: 'steps', name: 'Steps', min: 0, max: 32, + cppIndex: 102, + get: function () { return weqGlobalSteps; }, + set: function (v) { weqGlobalSteps = v; }, + toNorm: function (v) { return v / 32; }, + fromNorm: function (n) { return Math.round(n * 32); }, + display: function (v) { return v === 0 ? 'Off' : '' + v; } + }); + defs.push({ + key: 'tilt', name: 'Tilt', min: -100, max: 100, + cppIndex: 103, + get: function () { return weqGlobalTilt; }, + set: function (v) { weqGlobalTilt = v; }, + toNorm: function (v) { return (v + 100) / 200; }, + fromNorm: function (n) { return Math.round(n * 200 - 100); }, + display: function (v) { return (v >= 0 ? '+' : '') + v; } + }); + + + defs.push({ + key: 'driftSpd', name: 'Drift Speed', min: -50, max: 50, + cppIndex: 107, + get: function () { return weqDrift; }, + set: function (v) { weqDrift = v; }, + toNorm: function (v) { return (v + 50) / 100; }, + fromNorm: function (n) { return Math.round(n * 100 - 50); }, + display: function (v) { return (v >= 0 ? '+' : '') + v; } + }); + defs.push({ + key: 'driftRng', name: 'Drift Range', min: 0, max: 50, + cppIndex: 108, + get: function () { return weqDriftRange; }, + set: function (v) { weqDriftRange = v; }, + toNorm: function (v) { return v / 50; }, + fromNorm: function (n) { return Math.round(n * 50); }, + display: function (v) { return v + '%'; } + }); + defs.push({ + key: 'lfoRate', name: 'LFO Rate', min: 0, max: 10, + cppIndex: 109, + get: function () { return weqAnimSpeed; }, + set: function (v) { weqAnimSpeed = v; }, + toNorm: function (v) { return v / 10; }, + fromNorm: function (n) { return Math.round(n * 100) / 10; }, + display: function (v) { return v > 0 ? v.toFixed(1) + 'Hz' : 'Off'; } + }); + defs.push({ + key: 'lfoDep', name: 'LFO Depth', min: 0, max: 24, + cppIndex: 110, + get: function () { return weqAnimDepth; }, + set: function (v) { weqAnimDepth = v; }, + toNorm: function (v) { return v / 24; }, + fromNorm: function (n) { return Math.round(n * 24); }, + display: function (v) { return v + 'dB'; } + }); + + + // Per-band params (one set per EQ point) + // cppIndex maps to C++ layout: band*4 + field (0=freq, 1=gain, 2=q, 3=drift) + for (var i = 0; i < wrongEqPoints.length && i < 8; i++) { + (function (idx) { + var pt = wrongEqPoints[idx]; + defs.push({ + key: 'freq_' + idx, name: 'Band ' + (idx + 1) + ' Freq', + cppIndex: idx * 4 + 0, + get: function () { return weqXToFreq(wrongEqPoints[idx].x); }, + set: function (v) { wrongEqPoints[idx].x = weqFreqToX(v); }, + toNorm: function (v) { return weqFreqToX(v); }, + fromNorm: function (n) { return weqXToFreq(n); }, + display: function (v) { return weqFmtFreq(v) + 'Hz'; } + }); + defs.push({ + key: 'gain_' + idx, name: 'Band ' + (idx + 1) + ' Gain', + cppIndex: idx * 4 + 1, + get: function () { return weqYToDB(wrongEqPoints[idx].y); }, + set: function (v) { wrongEqPoints[idx].y = weqDBtoY(v); }, + toNorm: function (v) { return (v + weqDBRangeMax) / (weqDBRangeMax * 2); }, + fromNorm: function (n) { return n * weqDBRangeMax * 2 - weqDBRangeMax; }, + display: function (v) { return weqFmtDB(v); } + }); + defs.push({ + key: 'q_' + idx, name: 'Band ' + (idx + 1) + ' Q', + cppIndex: idx * 4 + 2, + get: function () { return wrongEqPoints[idx].q != null ? wrongEqPoints[idx].q : 0.707; }, + set: function (v) { wrongEqPoints[idx].q = v; }, + toNorm: function (v) { return (v - 0.025) / 39.975; }, + fromNorm: function (n) { return Math.round((n * 39.975 + 0.025) * 100) / 100; }, + display: function (v) { return 'Q ' + v.toFixed(2); } + }); + defs.push({ + key: 'drift_' + idx, name: 'Band ' + (idx + 1) + ' Drift', + cppIndex: idx * 4 + 3, + get: function () { return wrongEqPoints[idx].drift || 0; }, + set: function (v) { wrongEqPoints[idx].drift = v; }, + toNorm: function (v) { return v / 100; }, + fromNorm: function (n) { return Math.round(n * 100); }, + display: function (v) { return v + '%'; } + }); + })(i); + } + + return defs; +} + +// ── Create the virtual plugin block ── +function weqCreateVirtualBlock() { + weqDestroyVirtualBlock(); // clean up any existing + + var defs = _weqParamDefs(); + var params = []; + for (var i = 0; i < defs.length; i++) { + var d = defs[i]; + var fid = WEQ_VIRTUAL_ID + ':' + d.key; + var currentVal = d.get(); + var normVal = d.toNorm(currentVal); + // cppIndex maps to C++ paramIndex: per-band uses band*4+field, + // global params use 100+ (kWeqGlobalBase) to avoid collision. + var paramIdx = d.cppIndex != null ? d.cppIndex : i; + var param = { + id: fid, + name: d.name, + v: normVal, + disp: d.display(currentVal), + lk: false, + alk: false, + realIndex: paramIdx, + hostId: WEQ_VIRTUAL_ID, + _weqDef: d // private reference to definition + }; + PMap[fid] = param; + params.push(param); + } + + _weqVirtualBlock = { + id: WEQ_VIRTUAL_ID, + hostId: WEQ_VIRTUAL_ID, + name: '⬡ WrongEQ', + path: '__virtual__', + manufacturer: 'Noizefield', + params: params, + expanded: true, + searchFilter: '', + isVirtual: true // flag for special rendering + }; + + // Insert at position 0 (before real plugins) + pluginBlocks.unshift(_weqVirtualBlock); + + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof updCounts === 'function') updCounts(); +} + +// ── Destroy the virtual plugin block ── +function weqDestroyVirtualBlock() { + if (!_weqVirtualBlock) return; + + // Remove params from PMap and from any block targets + _weqVirtualBlock.params.forEach(function (p) { + delete PMap[p.id]; + if (typeof blocks !== 'undefined') { + blocks.forEach(function (b) { + b.targets.delete(p.id); + if (typeof cleanBlockAfterUnassign === 'function') cleanBlockAfterUnassign(b, p.id); + }); + } + }); + + // Remove from pluginBlocks + pluginBlocks = pluginBlocks.filter(function (pb) { return pb.id !== WEQ_VIRTUAL_ID; }); + _weqVirtualBlock = null; + + if (typeof renderAllPlugins === 'function') renderAllPlugins(); + if (typeof updCounts === 'function') updCounts(); +} + +// ── Rebuild the virtual block (when EQ points are added/removed) ── +function weqRebuildVirtualBlock() { + if (!_weqVirtualBlock) return; + // Preserve any block assignment references by matching param keys + var oldTargets = {}; + if (typeof blocks !== 'undefined') { + blocks.forEach(function (b) { + _weqVirtualBlock.params.forEach(function (p) { + if (b.targets.has(p.id)) oldTargets[p.id] = true; + }); + }); + } + weqCreateVirtualBlock(); + // Re-assign old targets (keys match since they use stable key format) + if (typeof blocks !== 'undefined') { + blocks.forEach(function (b) { + for (var pid in oldTargets) { + if (PMap[pid]) b.targets.add(pid); + } + }); + } +} + +// ── Sync virtual param values (called from modulation system) ── +// When modulation changes a virtual param, apply it to the EQ state +function weqApplyVirtualParam(pid, normVal) { + var p = PMap[pid]; + if (!p || !p._weqDef) return; + var def = p._weqDef; + var realVal = def.fromNorm(normVal); + def.set(realVal); + p.v = normVal; + p.disp = def.display(realVal); +} + +// ── Read current values back into virtual params + update DOM ── +function weqSyncVirtualParams() { + if (!_weqVirtualBlock) return; + try { + for (var i = 0; i < _weqVirtualBlock.params.length; i++) { + var p = _weqVirtualBlock.params[i]; + if (!p._weqDef) continue; + var currentVal = p._weqDef.get(); + var newNorm = p._weqDef.toNorm(currentVal); + var newDisp = p._weqDef.display(currentVal); + p.v = newNorm; + p.disp = newDisp; + + // Live DOM update: find the knob and value elements + var knobEl = document.querySelector('.pr-knob[data-pid="' + p.id + '"]'); + if (knobEl) { + // Regenerate SVG from the canonical buildParamKnob + if (typeof buildParamKnob === 'function') { + knobEl.innerHTML = buildParamKnob(newNorm, 30, null); + } + // Update value text and bar fill + var row = knobEl.closest('.pr-row'); + if (row) { + var valEl = row.querySelector('.pr-val'); + if (valEl) valEl.textContent = newDisp; + var barF = row.querySelector('.pr-bar-f'); + if (barF) barF.style.width = (newNorm * 100) + '%'; + } + } + } + } catch (err) { + if (typeof console !== 'undefined') console.warn('weqSyncVirtualParams error:', err); + } +} + +// ════════════════════════════════════════════════════════════ +// EQ PRESET SYSTEM — save/load/browse EQ-only presets +// ════════════════════════════════════════════════════════════ + +// Build EQ preset data (EQ state only — no routing/plugins) +function _weqBuildPresetData() { + return { + version: 1, + points: wrongEqPoints.map(function (p, idx) { + var sx = (weqAnimRafId && weqAnimBaseX.length > idx) ? weqAnimBaseX[idx] : p.x; + var sy = (weqAnimRafId && weqAnimBaseY.length > idx) ? weqAnimBaseY[idx] : p.y; + return { + x: sx, y: sy, + q: p.q != null ? p.q : 0.707, + type: p.type || 'Bell', + preEq: p.preEq !== false, + stereoMode: p.stereoMode || 0, + drift: p.drift || 0, + solo: p.solo || false, + mute: p.mute || false, + slope: p.slope || 1 + }; + }), + depth: weqGlobalDepth, + warp: weqGlobalWarp, + steps: weqGlobalSteps, + tilt: weqGlobalTilt, + bypass: weqGlobalBypass, + unassignedMode: weqUnassignedMode, + animSpeed: weqAnimSpeed, + animDepth: weqAnimDepth, + animShape: weqAnimShape, + drift: weqDrift, + driftRange: weqDriftRange, + driftScale: weqDriftScale, + driftContinuous: weqDriftContinuous, + driftMode: weqDriftMode, + driftTexture: weqDriftTexture, + gainLoCut: weqGainLoCut, + gainHiCut: weqGainHiCut, + driftLoCut: weqDriftLoCut, + driftHiCut: weqDriftHiCut, + qModSpeed: weqQModSpeed, + qModDepth: weqQModDepth, + qModShape: weqQModShape, + qLoCut: weqQLoCut, + qHiCut: weqQHiCut, + dbRange: weqDBRangeMax, + splitMode: weqSplitMode, + oversample: weqOversample + }; +} + +// Apply EQ preset data (restores state, re-renders, syncs) +function _weqApplyPresetData(data) { + if (!data) return; + _weqPushUndo(); + + // Stop animation before restoring + if (typeof weqAnimStop === 'function') weqAnimStop(); + + // Restore points + if (data.points) { + wrongEqPoints = data.points.map(function (p) { + return { + uid: _weqAllocUid(), + x: p.x, y: p.y, + pluginIds: [], // don't restore routing — instance-specific + seg: null, + solo: p.solo || false, + mute: p.mute || false, + q: p.q != null ? p.q : 0.707, + type: p.type || 'Bell', + drift: p.drift || 0, + preEq: p.preEq !== false, + stereoMode: p.stereoMode || 0, + slope: p.slope || 1 + }; + }); + } + weqAnimBaseY = wrongEqPoints.map(function (p) { return p.y; }); + weqAnimBaseX = wrongEqPoints.map(function (p) { return p.x; }); + + // Restore globals + if (data.depth != null) weqGlobalDepth = data.depth; + if (data.warp != null) weqGlobalWarp = data.warp; + if (data.steps != null) weqGlobalSteps = data.steps; + if (data.tilt != null) weqGlobalTilt = data.tilt; + if (data.bypass != null) weqGlobalBypass = data.bypass; + if (data.unassignedMode != null) weqUnassignedMode = data.unassignedMode; + if (data.animSpeed != null) weqAnimSpeed = data.animSpeed; + if (data.animDepth != null) weqAnimDepth = data.animDepth; + if (data.animShape != null) weqAnimShape = data.animShape; + if (data.drift != null) weqDrift = data.drift; + if (data.driftRange != null) weqDriftRange = data.driftRange; + if (data.driftScale != null) weqDriftScale = data.driftScale; + if (data.driftContinuous != null) weqDriftContinuous = data.driftContinuous; + if (data.driftMode != null) weqDriftMode = data.driftMode; + if (data.driftTexture != null) weqDriftTexture = data.driftTexture; + if (data.gainLoCut != null) weqGainLoCut = data.gainLoCut; + if (data.gainHiCut != null) weqGainHiCut = data.gainHiCut; + if (data.driftLoCut != null) weqDriftLoCut = data.driftLoCut; + if (data.driftHiCut != null) weqDriftHiCut = data.driftHiCut; + if (data.qModSpeed != null) weqQModSpeed = data.qModSpeed; + if (data.qModDepth != null) weqQModDepth = data.qModDepth; + if (data.qModShape != null) weqQModShape = data.qModShape; + if (data.qLoCut != null) weqQLoCut = data.qLoCut; + if (data.qHiCut != null) weqQHiCut = data.qHiCut; + if (data.dbRange != null) weqDBRangeMax = data.dbRange; + if (data.splitMode != null) weqSplitMode = data.splitMode; + if (data.oversample != null) weqOversample = data.oversample; + + weqSelectedPt = -1; + weqFocusBand = -1; + weqRenderPanel(); + weqSyncToHost(); + + // Restart animation if needed + var needsAnim = _weqNeedsAnim(); + if (needsAnim && typeof weqAnimStart === 'function') weqAnimStart(); + if (typeof markStateDirty === 'function') markStateDirty(); +} + +// Save preset — prompt with overlay input +function _weqSavePresetPrompt() { + // Remove existing prompt if any + var old = document.querySelector('.weq-preset-save-prompt'); + if (old) old.remove(); + + var overlay = document.createElement('div'); + overlay.className = 'weq-preset-save-prompt'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:6px;padding:16px 20px;min-width:280px;display:flex;flex-direction:column;gap:10px;'; + + var label = document.createElement('span'); + label.textContent = 'Save EQ Preset'; + label.style.cssText = 'font-family:var(--font-mono);font-size:13px;color:var(--text-primary);font-weight:600'; + box.appendChild(label); + + var input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Preset name…'; + input.value = _weqCurrentPreset || ''; + input.style.cssText = 'font-family:var(--font-mono);font-size:12px;padding:6px 10px;background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);outline:none'; + box.appendChild(input); + + var btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end'; + + var cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.cssText = 'font-family:var(--font-mono);font-size:11px;padding:4px 12px;background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-muted);cursor:pointer'; + cancelBtn.onclick = function () { overlay.remove(); }; + btnRow.appendChild(cancelBtn); + + var saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + saveBtn.style.cssText = 'font-family:var(--font-mono);font-size:11px;padding:4px 12px;background:var(--accent);border:1px solid var(--accent);border-radius:4px;color:var(--fire-text);cursor:pointer;font-weight:600'; + saveBtn.onclick = function () { + var name = input.value.trim(); + if (!name) { input.focus(); return; } + overlay.remove(); + _weqDoSavePreset(name); + }; + btnRow.appendChild(saveBtn); + box.appendChild(btnRow); + overlay.appendChild(box); + document.body.appendChild(overlay); + + overlay.onclick = function (e) { if (e.target === overlay) overlay.remove(); }; + input.onkeydown = function (e) { + if (e.key === 'Enter') saveBtn.onclick(); + if (e.key === 'Escape') overlay.remove(); + e.stopPropagation(); // prevent EQ keyboard shortcuts + }; + setTimeout(function () { input.focus(); input.select(); }, 50); +} + +function _weqDoSavePreset(name) { + if (!window.__JUCE__ || !window.__JUCE__.backend) return; + var fn = window.__juceGetNativeFunction('saveEqPreset'); + var data = _weqBuildPresetData(); + fn(name, JSON.stringify(data)).then(function () { + _weqCurrentPreset = name; + _weqRefreshPresetList(); + weqRenderPanel(); + }); +} + +// Refresh cached preset list from disk +function _weqRefreshPresetList(cb) { + if (!window.__JUCE__ || !window.__JUCE__.backend) { if (cb) cb(); return; } + var fn = window.__juceGetNativeFunction('getEqPresets'); + fn().then(function (list) { + _weqPresetList = Array.isArray(list) ? list : []; + if (cb) cb(); + }); +} + +// Show preset browser dropdown +function _weqShowPresetBrowser(anchor) { + // Remove existing + var old = document.querySelector('.weq-preset-dropdown'); + if (old) { old.remove(); return; } + + // Refresh list first, then show + _weqRefreshPresetList(function () { + var menu = document.createElement('div'); + menu.className = 'weq-preset-dropdown'; + var rect = anchor.getBoundingClientRect(); + menu.style.left = rect.left + 'px'; + menu.style.top = (rect.bottom + 2) + 'px'; + + // Init option + var initEl = document.createElement('div'); + initEl.className = 'weq-pd-row' + (!_weqCurrentPreset ? ' active' : ''); + initEl.textContent = 'Init'; + initEl.onclick = function () { + menu.remove(); + _weqCurrentPreset = null; + wrongEqPoints = []; + weqAnimBaseY = []; weqAnimBaseX = []; + weqGlobalDepth = 100; weqGlobalWarp = 0; weqGlobalSteps = 0; weqGlobalTilt = 0; + weqGlobalBypass = false; + weqAnimSpeed = 0; weqAnimDepth = 6; weqAnimShape = 'sine'; + weqDrift = 0; weqDriftRange = 5; weqDriftContinuous = false; weqDriftTexture = 'smooth'; + weqGainLoCut = 20; weqGainHiCut = 20000; weqDriftLoCut = 20; weqDriftHiCut = 20000; + weqQModSpeed = 0; weqQModDepth = 50; weqQModShape = 'sine'; weqQLoCut = 20; weqQHiCut = 20000; + weqDBRangeMax = 24; weqOversample = 1; + weqSelectedPt = -1; weqFocusBand = -1; + if (typeof weqAnimStop === 'function') weqAnimStop(); + weqRenderPanel(); weqSyncToHost(); + if (typeof markStateDirty === 'function') markStateDirty(); + }; + menu.appendChild(initEl); + + // Separator + if (_weqPresetList.length > 0) { + var sep = document.createElement('div'); + sep.className = 'weq-pd-sep'; + menu.appendChild(sep); + } + + _weqPresetList.forEach(function (name) { + var row = document.createElement('div'); + row.className = 'weq-pd-row' + (name === _weqCurrentPreset ? ' active' : ''); + + var label = document.createElement('span'); + label.className = 'weq-pd-label'; + label.textContent = name; + row.appendChild(label); + + var delBtn = document.createElement('span'); + delBtn.className = 'weq-pd-del'; + delBtn.textContent = '×'; + delBtn.title = 'Delete'; + delBtn.onclick = function (e) { + e.stopPropagation(); + if (!window.__JUCE__ || !window.__JUCE__.backend) return; + var fn = window.__juceGetNativeFunction('deleteEqPreset'); + fn(name).then(function () { + if (_weqCurrentPreset === name) _weqCurrentPreset = null; + row.remove(); + _weqRefreshPresetList(); + weqRenderPanel(); + }); + }; + row.appendChild(delBtn); + + row.onclick = function () { + menu.remove(); + _weqLoadPreset(name); + }; + menu.appendChild(row); + }); + + document.body.appendChild(menu); + function dismiss(de) { if (!menu.contains(de.target)) { menu.remove(); document.removeEventListener('click', dismiss); } } + setTimeout(function () { document.addEventListener('click', dismiss); }, 10); + }); +} + +// Load a preset by name +function _weqLoadPreset(name) { + if (!window.__JUCE__ || !window.__JUCE__.backend) return; + var fn = window.__juceGetNativeFunction('loadEqPreset'); + fn(name).then(function (jsonStr) { + if (!jsonStr) return; + try { + var data = JSON.parse(jsonStr); + _weqCurrentPreset = name; + _weqApplyPresetData(data); + } catch (e) { console.log('EQ preset parse error:', e); } + }); +} + +// Navigate presets (prev/next) +function _weqNavPreset(dir) { + _weqRefreshPresetList(function () { + if (_weqPresetList.length === 0) return; + var idx = _weqCurrentPreset ? _weqPresetList.indexOf(_weqCurrentPreset) : -1; + var next = idx + dir; + if (next < 0) next = _weqPresetList.length - 1; + if (next >= _weqPresetList.length) next = 0; + _weqLoadPreset(_weqPresetList[next]); + }); +} diff --git a/plugins/ModularRandomizer/Source/ui/public/style.css b/plugins/ModularRandomizer/Source/ui/public/style.css new file mode 100644 index 0000000..b75fbaa --- /dev/null +++ b/plugins/ModularRandomizer/Source/ui/public/style.css @@ -0,0 +1,1482 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg-app: #F0F0F0; + --bg-panel: #FAFAFA; + --bg-cell: #FFF; + --bg-cell-hover: #F5F5F5; + --bg-inset: #E8E8E8; + --border: #DEDEDE; + --border-strong: #C0C0C0; + --border-focus: #999; + --text-primary: #333; + --text-secondary: #777; + --text-muted: #AAA; + --accent: #FF5500; + --accent-hover: #E64D00; + --accent-light: #FFF0E6; + --accent-border: #FFB380; + --locked-bg: #FFF0F0; + --locked-border: #FFCCCC; + --locked-icon: #CC3333; + --auto-lock-bg: #FFF5E6; + --auto-lock-border: #FFD699; + --midi-dot: #33CC33; + --env-color: #22AAFF; + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Consolas', monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box +} + +body { + background: var(--bg-app); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 11px; + width: 100%; + height: 100vh; + overflow: hidden; + user-select: none +} + +.app { + display: flex; + flex-direction: column; + height: 100% +} + +/* ── HEADER + Top-lit gradient — lid of the device. Shadow underneath creates + physical separation from the rack below. */ +.header { + display: flex; + align-items: center; + height: 38px; + padding: 0 12px; + background: linear-gradient(to bottom, + var(--bg-panel) 0%, + color-mix(in srgb, var(--bg-panel) 85%, var(--bg-inset) 15%) 100%); + border-bottom: 1px solid var(--border-strong); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.6) inset, + 0 2px 5px rgba(0, 0, 0, 0.07); + gap: 10px; + flex-shrink: 0 +} + +.brand { + display: flex; + align-items: center; + gap: 6px +} + +/* Indicator light — raised nub */ +.brand-mark { + width: 10px; + height: 10px; + background: var(--accent); + border-radius: 2px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.22) inset, + 0 1px 3px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(0, 0, 0, 0.09) +} + +.brand-name { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px +} + +/* Engraved groove — dark pixel + light pixel = carved line */ +.h-div { + width: 2px; + height: 18px; + background: linear-gradient(to right, + var(--border-strong) 0px, + var(--border-strong) 1px, + rgba(255, 255, 255, 0.7) 1px, + rgba(255, 255, 255, 0.7) 2px) +} + +.h-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto +} + +/* ── BUTTONS + Physically raised — top-lit, outer drop shadow, presses down on :active */ +.sm-btn { + background: linear-gradient(to bottom, + var(--bg-cell) 0%, + color-mix(in srgb, var(--bg-cell) 82%, var(--bg-inset) 18%) 100%); + border: 1px solid var(--border-strong); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.8) inset, + 0 1px 3px rgba(0, 0, 0, 0.10); + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 9px; + padding: 3px 8px; + border-radius: 3px; + cursor: pointer; + transition: box-shadow 55ms, transform 55ms, color 55ms +} + +.sm-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.8) inset, + 0 2px 5px rgba(0, 0, 0, 0.13) +} + +.sm-btn:active { + transform: translateY(1px); + box-shadow: + 0 1px 0 rgba(0, 0, 0, 0.07) inset, + 0 1px 1px rgba(0, 0, 0, 0.06) +} + +.bypass { + width: 24px; + height: 22px; + background: linear-gradient(to bottom, + var(--bg-cell) 0%, + color-mix(in srgb, var(--bg-cell) 82%, var(--bg-inset) 18%) 100%); + border: 1px solid var(--border-strong); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.8) inset, + 0 1px 3px rgba(0, 0, 0, 0.10); + border-radius: 3px; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: box-shadow 55ms, transform 55ms +} + +.bypass:active { + transform: translateY(1px); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.09) inset +} + +.bypass.on { + background: linear-gradient(to bottom, + var(--accent) 0%, + var(--accent-hover) 100%); + border-color: var(--accent-hover); + color: white; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.18) inset, + 0 1px 3px rgba(0, 0, 0, 0.22) +} + +.mix-area { + display: flex; + align-items: center; + gap: 5px +} + +.mix-area label { + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .5px +} + +.mix-area .mix-val { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + min-width: 26px +} + +.mix-area input[type="range"] { + width: 60px +} + +/* ── RACK */ +.rack { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden +} + +/* ── PLUGIN BLOCK (left) + Panel wall — slightly cooler than header, recedes behind the cards */ +.plugin-block { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-right: 1px solid var(--border-strong); + box-shadow: inset -2px 0 4px rgba(0, 0, 0, 0.04) +} + +/* Section label strip — darkest surface in the panel, reads as faceplate */ +.pb-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-inset) 40%, var(--bg-panel) 60%) 0%, + var(--bg-inset) 100%); + border-bottom: 1px solid var(--border-strong); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.5) inset +} + +.pb-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted) +} + +.assign-banner { + display: none; + padding: 4px 10px; + font-size: 10px; + font-weight: 500; + text-align: center +} + +.assign-banner.vis { + display: block +} + +/* ── PLUGIN SCROLL */ +.plugin-scroll { + flex: 1; + overflow-y: auto; + padding: 4px +} + +.plugin-scroll::-webkit-scrollbar { + width: 4px +} + +.plugin-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* ── PLUGIN CARD + Lifted off the panel surface — highlight on top edge, shadow below */ +.pcard { + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 3px; + margin-bottom: 5px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.85) inset, + 0 1px 4px rgba(0, 0, 0, 0.09), + 0 0 0 1px rgba(0, 0, 0, 0.02); + transition: opacity 120ms, transform 80ms +} + +.pcard.dragging { + opacity: 0.4; + transform: scale(0.97) +} + +.pcard.drag-over-top { + border-top: 2px solid var(--accent); + margin-top: -1px +} + +.pcard.drag-over-bottom { + border-bottom: 2px solid var(--accent); + margin-bottom: -1px +} + +/* Card header — darker than card body, like a rack unit faceplate strip */ +.pcard-head { + display: flex; + align-items: center; + padding: 4px 6px; + cursor: grab; + gap: 4px; + border-bottom: 1px solid var(--border-strong); + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-cell) 90%, var(--bg-inset) 10%) 0%, + color-mix(in srgb, var(--bg-cell) 76%, var(--bg-inset) 24%) 100%); + border-radius: 3px 3px 0 0 +} + +.pcard-head:active { + cursor: grabbing +} + +.pcard-head:hover { + background: linear-gradient(to bottom, + var(--bg-cell-hover) 0%, + color-mix(in srgb, var(--bg-cell-hover) 78%, var(--bg-inset) 22%) 100%) +} + +.pcard-name { + font-size: 10px; + font-weight: 600; + flex: 1 +} + +.pcard-info { + font-size: 9px; + color: var(--text-muted); + margin-left: 4px +} + +.pcard-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 1px 3px +} + +.pcard-close:hover { + color: var(--text-primary) +} + +.pcard-body { + display: flex; + flex-direction: column +} + +.pcard-body.hide { + display: none +} + +.pcard-search { + padding: 3px 6px; + border-bottom: 1px solid var(--border) +} + +/* Input field: recessed into the surface */ +.pcard-search input { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: 2px; + padding: 2px 5px; + font-family: var(--font-sans); + font-size: 9px; + background: var(--bg-panel); + outline: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.09) +} + +.pcard-search input:focus { + border-color: var(--accent) +} + +.pcard-params { + max-height: 180px; + overflow-y: auto; + padding: 2px 3px +} + +.pcard-params::-webkit-scrollbar { + width: 3px +} + +.pcard-params::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* ── PARAMETER ROW */ +.pr { + display: flex; + align-items: center; + padding: 3px 6px; + border-radius: 2px; + cursor: pointer; + gap: 4px; + min-height: 22px; + border: 1px solid transparent; + transition: background 55ms, border-color 55ms +} + +.pr:hover { + background: var(--bg-cell-hover) +} + +.pr.locked { + background: var(--locked-bg); + border-color: var(--locked-border); + cursor: default; + opacity: .6 +} + +.pr.locked:hover { + background: var(--locked-bg) +} + +.pr.assign-highlight { + border: 1px dashed var(--border-focus) +} + +.pr.assign-highlight:hover { + background: var(--accent-light); + border-color: var(--accent) +} + +.pr-name { + font-size: 10px; + font-weight: 500; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.pr-val { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-secondary); + min-width: 28px; + text-align: right +} + +/* Parameter bar: recessed groove */ +.pr-bar { + width: 30px; + height: 3px; + background: var(--bar-track, var(--bg-inset)); + border-radius: 1px; + overflow: hidden; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.13) +} + +.pr-bar-f { + height: 100%; + background: var(--bar-fill, var(--knob-value)); + border-radius: 1px; + opacity: 0.7 +} + +.pr-dots { + display: flex; + gap: 2px; + margin-left: 2px +} + +.pr-dot { + width: 5px; + height: 5px; + border-radius: 50% +} + +.pr-lock { + font-size: 7px +} + +/* ── LOGIC BLOCKS (right) */ +.logic-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0 +} + +/* Same faceplate treatment as pb-head */ +.lp-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-inset) 40%, var(--bg-panel) 60%) 0%, + var(--bg-inset) 100%); + border-bottom: 1px solid var(--border-strong); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.5) inset; + height: 28px +} + +.lp-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted) +} + +.lp-scroll { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-content: flex-start +} + +.lp-scroll::-webkit-scrollbar { + width: 4px +} + +.lp-scroll::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +/* ── BLOCK CARD */ +.lcard { + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 4px; + width: calc(50% - 4px); + min-width: 320px; + flex-shrink: 0; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.85) inset, + 0 2px 6px rgba(0, 0, 0, 0.09), + 0 0 0 1px rgba(0, 0, 0, 0.02) +} + +.lcard.active {} + +.lcard.active.env-active {} + +/* Block header — faceplate, darkest surface of the card */ +.lhead { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 8px; + border-bottom: 1px solid var(--border-strong); + cursor: pointer; + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-cell) 88%, var(--bg-inset) 12%) 0%, + color-mix(in srgb, var(--bg-cell) 72%, var(--bg-inset) 28%) 100%); + border-radius: 4px 4px 0 0 +} + +.lhead:hover { + background: linear-gradient(to bottom, + var(--bg-cell-hover) 0%, + color-mix(in srgb, var(--bg-cell-hover) 76%, var(--bg-inset) 24%) 100%) +} + +.ltitle { + font-size: 11px; + font-weight: 600 +} + +.lsum { + font-size: 9px; + color: var(--text-secondary); + margin-left: 6px +} + +.lclose { + background: none; + border: none; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + padding: 2px 4px +} + +.lclose:hover { + color: var(--text-primary) +} + +.lbody { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px +} + +.lbody.hide { + display: none +} + +/* Color swatch: slightly raised chip */ +.block-color { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 4px; + flex-shrink: 0; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.22), + 0 0 0 1px rgba(0, 0, 0, 0.07) +} + +/* ── SHARED CONTROLS */ +.blbl { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--text-muted) +} + +.brow { + display: flex; + flex-direction: column; + gap: 3px +} + +/* Segmented control: recessed slot — active buttons pop out of it */ +.seg { + display: flex; + background: var(--bg-inset); + border-radius: 3px; + overflow: hidden; + border: 1px solid var(--border-strong); + box-shadow: + inset 0 1px 3px rgba(0, 0, 0, 0.11), + inset 0 1px 0 rgba(0, 0, 0, 0.05) +} + +.seg button { + flex: 1; + padding: 4px 2px; + background: none; + border: none; + border-right: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: color 55ms, background 55ms +} + +.seg button:last-child { + border-right: none +} + +.seg button:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.45) +} + +/* Active segment: pops out of the slot with gradient + shadow */ +.seg button.on { + background: linear-gradient(to bottom, + var(--accent) 0%, + var(--accent-hover) 100%); + color: white; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.18) inset, + 0 1px 3px rgba(0, 0, 0, 0.18) +} + +.seg button.env-on { + background: linear-gradient(to bottom, + var(--env-color) 0%, + color-mix(in srgb, var(--env-color) 78%, #000 22%) 100%); + color: white; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.18) inset, + 0 1px 3px rgba(0, 0, 0, 0.18) +} + +/* ── SUB-CONTROLS */ +.sub { + display: none; + padding-top: 4px +} + +.sub.vis { + display: flex; + flex-direction: column; + gap: 4px +} + +.sub-row { + display: flex; + align-items: center; + gap: 6px +} + +.sub-lbl { + font-size: 9px; + color: var(--text-secondary); + min-width: 36px +} + +/* Inset fields — physically recessed */ +.sub-sel { + background: var(--bg-cell); + border: 1px solid var(--border-strong); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 18px 2px 6px; + border-radius: 2px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23999'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 4px center; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) +} + +.sub-input { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-primary); + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 2px; + padding: 2px 4px; + width: 36px; + text-align: center; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) +} + +.sub-input:focus { + outline: none; + border-color: var(--accent) +} + +.note-disp { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 2px; + padding: 2px 6px; + min-width: 30px; + text-align: center; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) +} + +/* ── TOGGLE + Track: recessed slot. Thumb: physical raised nub with metallic gradient. */ +.tgl-row { + display: flex; + align-items: center; + gap: 6px +} + +.tgl { + width: 26px; + height: 13px; + background: var(--bg-inset); + border: 1px solid var(--border-strong); + border-radius: 7px; + cursor: pointer; + position: relative; + flex-shrink: 0; + box-shadow: + inset 0 1px 3px rgba(0, 0, 0, 0.14), + inset 0 1px 0 rgba(0, 0, 0, 0.07) +} + +.tgl::after { + content: ''; + position: absolute; + width: 9px; + height: 9px; + background: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%); + border: 1px solid var(--border-strong); + border-radius: 50%; + top: 1px; + left: 1px; + transition: left 80ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.20), + 0 1px 0 rgba(255, 255, 255, 0.65) inset +} + +.tgl.on { + background: linear-gradient(to bottom, + var(--accent) 0%, + var(--accent-hover) 100%); + border-color: var(--accent-hover); + box-shadow: + inset 0 1px 3px rgba(0, 0, 0, 0.20), + inset 0 1px 0 rgba(0, 0, 0, 0.10) +} + +.tgl.on::after { + left: 14px; + border-color: color-mix(in srgb, var(--accent-hover) 70%, #000 30%) +} + +.tgl-lbl { + font-size: 9px; + color: var(--text-secondary) +} + +/* ── SLIDERS + Track: recessed groove with inset shadow. + Thumb: metallic cap — raised with drop shadow. */ +.sl-row { + display: flex; + align-items: center; + gap: 6px +} + +.sl-row input[type="range"] { + flex: 1 +} + +.sl-lbl { + font-size: 11px; + color: var(--text-secondary); + min-width: 38px; + white-space: nowrap +} + +.sl-val { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + min-width: 32px; + text-align: right +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 14px; + background: transparent; + outline: none; + cursor: pointer +} + +input[type="range"]::-webkit-slider-runnable-track { + height: 4px; + background: var(--bg-inset); + border-radius: 2px; + border: 1px solid var(--border-strong); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.14) +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 13px; + height: 13px; + background: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%); + border: 1px solid var(--border-strong); + border-radius: 50%; + cursor: pointer; + margin-top: -5px; + transition: border-color 80ms; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.22), + 0 1px 0 rgba(255, 255, 255, 0.7) inset +} + +input[type="range"]::-webkit-slider-thumb:hover { + border-color: var(--accent) +} + +input[type="range"]:active::-webkit-slider-thumb { + border-color: var(--accent); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.15), + 0 0 0 3px var(--accent-light), + 0 1px 0 rgba(255, 255, 255, 0.7) inset +} + +/* ── TARGETS + Recessed well — content sits below the surface */ +.tgt-box { + display: flex; + flex-wrap: wrap; + gap: 2px; + min-height: 20px; + padding: 3px; + background: var(--bg-inset); + border: 1px solid var(--border-strong); + border-radius: 3px; + max-height: 44px; + overflow-y: auto; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.09) +} + +.tgt-box::-webkit-scrollbar { + width: 3px +} + +.tgt-box::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.tg { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 8px; + font-weight: 500; + padding: 1px 5px; + border-radius: 2px +} + +.tg .tx { + cursor: pointer; + opacity: .5; + font-size: 9px +} + +.tg .tx:hover { + opacity: 1 +} + +.tg-empty { + font-size: 9px; + color: var(--text-muted); + padding: 1px 4px +} + +/* ── FIRE BUTTON + Primary action key — raised above everything, presses down on :active */ +.fire { + width: 100%; + height: 28px; + background: linear-gradient(to bottom, + var(--accent) 0%, + var(--accent-hover) 100%); + border: 1px solid var(--accent-hover); + border-radius: 3px; + color: white; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + cursor: pointer; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.20) inset, + 0 2px 5px rgba(0, 0, 0, 0.20), + 0 1px 0 rgba(0, 0, 0, 0.10); + transition: box-shadow 55ms, transform 55ms +} + +.fire:hover { + background: linear-gradient(to bottom, + color-mix(in srgb, var(--accent) 88%, #fff 12%) 0%, + var(--accent) 100%); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.20) inset, + 0 3px 8px rgba(0, 0, 0, 0.25), + 0 1px 0 rgba(0, 0, 0, 0.10) +} + +.fire:active { + transform: translateY(1px); + box-shadow: + 0 1px 0 rgba(0, 0, 0, 0.12) inset, + 0 1px 2px rgba(0, 0, 0, 0.12) +} + +.fire.flash { + animation: fl 250ms ease-out +} + +@keyframes fl { + 0% { + box-shadow: 0 0 14px var(--accent), 0 2px 5px rgba(0, 0, 0, 0.20) + } + + 100% { + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.20) + } +} + +/* ── ENVELOPE METER */ +.env-meter { + height: 32px; + background: var(--bg-inset); + border: 1px solid var(--border-strong); + border-radius: 3px; + position: relative; + overflow: hidden; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.12) +} + +.env-meter-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, var(--env-color), rgba(34, 170, 255, .3)); + transition: height 30ms linear +} + +.env-label { + position: absolute; + right: 4px; + top: 2px; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-secondary) +} + +.env-active-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--env-color); + margin-right: 3px; + animation: ep 1s ease-in-out infinite +} + +@keyframes ep { + + 0%, + 100% { + opacity: .4 + } + + 50% { + opacity: 1 + } +} + +/* ── ADD BUTTONS */ +.add-wrap { + display: flex; + gap: 6px; + padding: 8px +} + +.add-blk { + flex: 1; + height: 32px; + background: none; + border: 1px dashed var(--border-strong); + border-radius: 4px; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 80ms, color 80ms +} + +.add-blk:hover { + border-color: var(--border-focus); + color: var(--text-secondary) +} + +/* ── STATUS BAR + Baseplate — inverted gradient, slightly darker at top edge. + Inner top shadow gives it a recessed ledge feel. */ +.status { + display: flex; + align-items: center; + height: 20px; + padding: 0 12px; + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-panel) 82%, var(--bg-inset) 18%) 0%, + var(--bg-panel) 100%); + border-top: 1px solid var(--border-strong); + box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.55) inset; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); + gap: 14px; + flex-shrink: 0 +} + +/* Indicator LEDs — subtle depth ring */ +.sd { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + margin-right: 2px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.18) +} + +.sd.on { + background: var(--midi-dot); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.10) +} + +.sd.off { + background: var(--border-strong) +} + +.sd.env { + background: var(--env-color); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.10) +} + +/* ── CONTEXT MENU */ +.ctx { + display: none; + position: fixed; + background: var(--bg-cell); + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 4px 0; + min-width: 120px; + z-index: 100; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.14), + 0 1px 4px rgba(0, 0, 0, 0.08), + 0 1px 0 rgba(255, 255, 255, 0.7) inset +} + +.ctx.vis { + display: block +} + +.ctx-i { + padding: 4px 10px; + font-size: 10px; + cursor: pointer +} + +.ctx-i:hover { + background: var(--accent-light) +} + +/* ── CHEVRON */ +.lchev { + font-size: 8px; + color: var(--text-muted); + margin-left: 4px; + transition: transform 120ms cubic-bezier(0.25, 0.46, 0.45, 0.94) +} + +.lchev.open { + transform: rotate(90deg) +} + +/* ── MODAL */ +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, .35); + z-index: 200; + align-items: center; + justify-content: center +} + +.modal-overlay.vis { + display: flex +} + +.modal { + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: 6px; + width: 520px; + max-height: 480px; + display: flex; + flex-direction: column; + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.20), + 0 4px 12px rgba(0, 0, 0, 0.10), + 0 1px 0 rgba(255, 255, 255, 0.65) inset +} + +/* Modal header — same faceplate gradient */ +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-strong); + background: linear-gradient(to bottom, + color-mix(in srgb, var(--bg-cell) 88%, var(--bg-inset) 12%) 0%, + color-mix(in srgb, var(--bg-cell) 72%, var(--bg-inset) 28%) 100%); + border-radius: 6px 6px 0 0 +} + +.modal-title { + font-size: 12px; + font-weight: 700; + letter-spacing: .5px +} + +.modal-close { + background: none; + border: none; + font-size: 16px; + color: var(--text-muted); + cursor: pointer; + padding: 2px 6px +} + +.modal-close:hover { + color: var(--text-primary) +} + +.modal-toolbar { + display: flex; + gap: 6px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + align-items: center +} + +.modal-search { + flex: 1; + border: 1px solid var(--border-strong); + border-radius: 3px; + padding: 5px 8px; + font-family: var(--font-sans); + font-size: 11px; + background: var(--bg-cell); + outline: none; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.09) +} + +.modal-search:focus { + border-color: var(--accent) +} + +.modal-tabs { + display: flex; + gap: 0; + border: 1px solid var(--border-strong); + border-radius: 3px; + overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.09) +} + +.modal-tab { + padding: 4px 10px; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + background: var(--bg-inset); + border: none; + border-right: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: color 55ms, background 55ms +} + +.modal-tab:last-child { + border-right: none +} + +.modal-tab:hover { + color: var(--text-secondary); + background: var(--bg-cell-hover) +} + +.modal-tab.on { + background: linear-gradient(to bottom, var(--accent) 0%, var(--accent-hover) 100%); + color: white; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.18) inset +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 4px 0; + min-height: 200px +} + +.modal-body::-webkit-scrollbar { + width: 5px +} + +.modal-body::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px +} + +.plug-row { + display: flex; + align-items: center; + padding: 6px 14px; + cursor: pointer; + gap: 8px; + border-bottom: 1px solid var(--bg-inset) +} + +.plug-row:hover { + background: var(--accent-light) +} + +.plug-icon { + width: 24px; + height: 24px; + border-radius: 4px; + background: var(--bg-inset); + border: 1px solid var(--border-strong); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + flex-shrink: 0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.09) +} + +.plug-info { + flex: 1; + min-width: 0 +} + +.plug-name { + font-size: 11px; + font-weight: 600 +} + +.plug-meta { + font-size: 9px; + color: var(--text-muted); + margin-top: 1px +} + +.plug-type { + font-size: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + padding: 1px 5px; + border-radius: 2px; + background: var(--bg-inset); + color: var(--text-muted) +} + +.plug-type.synth { + background: #E8F0FF; + color: #3366CC +} + +.plug-type.fx { + background: #F0E8FF; + color: #7733CC +} + +.plug-type.sampler { + background: #E8FFE8; + color: #339933 +} + +.plug-type.utility { + background: #FFF5E6; + color: #CC8800 +} + +/* Modal footer — slightly darker, like the underside of a panel */ +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + border-top: 1px solid var(--border-strong); + background: linear-gradient(to bottom, + var(--bg-inset) 0%, + color-mix(in srgb, var(--bg-inset) 85%, var(--bg-panel) 15%) 100%); + border-radius: 0 0 6px 6px +} + +.modal-footer-info { + font-size: 9px; + color: var(--text-muted) +} + +.scan-btn { + font-family: var(--font-sans); + font-size: 9px; + background: linear-gradient(to bottom, + var(--bg-cell) 0%, + color-mix(in srgb, var(--bg-cell) 82%, var(--bg-inset) 18%) 100%); + border: 1px solid var(--border-strong); + color: var(--text-secondary); + padding: 3px 8px; + border-radius: 3px; + cursor: pointer; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.8) inset, + 0 1px 2px rgba(0, 0, 0, 0.09) +} + +.scan-btn:hover { + border-color: var(--border-focus); + color: var(--text-primary) +} + +.no-results { + text-align: center; + padding: 30px; + color: var(--text-muted); + font-size: 11px +} + +/* ── SCAN PATHS */ +.scan-paths { + display: none; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-app) +} + +.scan-paths.vis { + display: block +} + +.scan-paths-title { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted); + margin-bottom: 4px +} + +.scan-path-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 3px +} + +.scan-path-row input { + flex: 1; + border: 1px solid var(--border-strong); + border-radius: 2px; + padding: 3px 6px; + font-family: var(--font-mono); + font-size: 9px; + background: var(--bg-cell); + color: var(--text-secondary); + outline: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08) +} + +.scan-path-rm { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + padding: 2px +} + +.scan-path-rm:hover { + color: var(--text-primary) +} \ No newline at end of file diff --git a/plugins/ModularRandomizer/installer/LICENSE.txt b/plugins/ModularRandomizer/installer/LICENSE.txt new file mode 100644 index 0000000..d4536cd --- /dev/null +++ b/plugins/ModularRandomizer/installer/LICENSE.txt @@ -0,0 +1,50 @@ +================================================================================ + MODULAR RANDOMIZER — END USER LICENSE AGREEMENT +================================================================================ + +IMPORTANT: PLEASE READ THIS LICENSE CAREFULLY BEFORE USING THIS SOFTWARE. + +By installing, copying, or otherwise using ModularRandomizer ("the Software"), +you agree to be bound by the terms of this agreement. + +1. GRANT OF LICENSE + Noizefield grants you a non-exclusive, non-transferable license to install + and use the Software on any number of computers you personally own or control. + +2. PERMITTED USE + - You may install and use the Software on multiple computers + - You may use the Software for commercial and non-commercial audio production + - You may create and distribute audio content produced using the Software + - You may make backup copies for archival purposes + +3. RESTRICTIONS + - You may not reverse engineer, decompile, or disassemble the Software + - You may not redistribute, resell, sublicense, or share the Software + - You may not remove or alter any copyright notices or branding + +4. INTELLECTUAL PROPERTY + The Software is protected by copyright laws and international treaties. + All rights not expressly granted are reserved by Noizefield. + +5. DISCLAIMER OF WARRANTY + THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. + +6. LIMITATION OF LIABILITY + IN NO EVENT SHALL NOIZEFIELD BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES ARISING FROM THE USE + OR INABILITY TO USE THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY + OF SUCH DAMAGES. + +7. TERMINATION + This license is effective until terminated. It will terminate automatically + if you fail to comply with any term of this agreement. Upon termination, + you must destroy all copies of the Software. + +================================================================================ +By installing this Software, you acknowledge that you have read, understood, +and agree to be bound by these terms. + +Copyright (c) 2026 Noizefield — https://noizefield.com +================================================================================ diff --git a/plugins/ModularRandomizer/installer/windows.iss b/plugins/ModularRandomizer/installer/windows.iss new file mode 100644 index 0000000..0aaf03e --- /dev/null +++ b/plugins/ModularRandomizer/installer/windows.iss @@ -0,0 +1,99 @@ +; ModularRandomizer Windows Installer +; Inno Setup Script — Professional VST3/Standalone installer +; Build with: ISCC.exe windows.iss + +#define PluginName "ModularRandomizer" +#define PluginVersion "1.0.0" +#define Publisher "Noizefield" +#define PublisherURL "https://noizefield.com" + +; These paths are relative to the .iss file location +; Override via /D on command line for CI builds: +; ISCC.exe /DBuildDir="..\..\..\build" windows.iss +#ifndef BuildDir + #define BuildDir "..\..\..\build" +#endif + +[Setup] +AppId={{F3A8C1D2-7B4E-4F5A-9C6D-8E2F1A3B5C7D} +AppName={#PluginName} +AppVersion={#PluginVersion} +AppPublisher={#Publisher} +AppPublisherURL={#PublisherURL} +DefaultDirName={autopf}\{#Publisher}\{#PluginName} +DefaultGroupName={#Publisher}\{#PluginName} +OutputBaseFilename={#PluginName}-{#PluginVersion}-Windows-Setup +OutputDir=..\dist +Compression=lzma2/ultra64 +SolidCompression=yes +ArchitecturesInstallIn64BitModeOnly=x64compatible +PrivilegesRequired=admin +WizardStyle=modern +LicenseFile=LICENSE.txt +SetupIconFile=icon.ico +UninstallDisplayIcon={app}\{#PluginName}.exe +DisableProgramGroupPage=yes +DisableDirPage=no +MinVersion=10.0 + +[Types] +Name: "full"; Description: "Full installation (VST3 + Standalone)" +Name: "vst3only"; Description: "VST3 plugin only" +Name: "custom"; Description: "Custom installation"; Flags: iscustom + +[Components] +Name: "vst3"; Description: "VST3 Plugin"; Types: full vst3only custom; Flags: fixed +Name: "standalone"; Description: "Standalone App"; Types: full custom + +[Files] +; VST3 plugin — always installed to Common Files\VST3 +Source: "{#BuildDir}\plugins\ModularRandomizer\ModularRandomizer_artefacts\Release\VST3\ModularRandomizer.vst3\*"; \ + DestDir: "{commoncf64}\VST3\ModularRandomizer.vst3"; \ + Components: vst3; Flags: ignoreversion recursesubdirs createallsubdirs + +; Standalone app — installed to user-chosen directory +Source: "{#BuildDir}\plugins\ModularRandomizer\ModularRandomizer_artefacts\Release\Standalone\ModularRandomizer.exe"; \ + DestDir: "{app}"; \ + Components: standalone; Flags: ignoreversion + +[Icons] +; Start Menu shortcut for standalone +Name: "{group}\{#PluginName}"; \ + Filename: "{app}\{#PluginName}.exe"; \ + Components: standalone + +; Desktop shortcut (optional) +Name: "{autodesktop}\{#PluginName}"; \ + Filename: "{app}\{#PluginName}.exe"; \ + Components: standalone; \ + Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; \ + Description: "Create a desktop shortcut"; \ + Components: standalone; \ + Flags: unchecked + +[Run] +; Launch standalone after install (optional) +Filename: "{app}\{#PluginName}.exe"; \ + Description: "Launch {#PluginName}"; \ + Components: standalone; \ + Flags: nowait postinstall skipifsilent unchecked + +[UninstallDelete] +; Clean up the VST3 folder on uninstall +Type: filesandordirs; Name: "{commoncf64}\VST3\ModularRandomizer.vst3" + +[Messages] +BeveledLabel={#PluginName} v{#PluginVersion} — {#Publisher} + +[Code] +// Show VST3 install location in the finish page +procedure CurPageChanged(CurPageID: Integer); +begin + if CurPageID = wpFinished then + begin + WizardForm.RunList.Visible := True; + end; +end; diff --git a/plugins/ModularRandomizer/status.json b/plugins/ModularRandomizer/status.json new file mode 100644 index 0000000..7d491a0 --- /dev/null +++ b/plugins/ModularRandomizer/status.json @@ -0,0 +1,553 @@ +{ + "plugin_name": "ModularRandomizer", + "version": "v0.5.0", + "current_phase": "code_complete", + "ui_framework": "webview", + "complexity_score": 9, + "created_at": "2026-02-24T10:19:00Z", + "last_modified": "2026-03-02T00:15:00Z", + "description": "Multi-plugin parameter randomizer VST3/Standalone. Hosts multiple VST3 plugins in series or parallel (bus-based routing), exposes all their parameters, and modulates them via configurable logic blocks (randomize, envelope follower, sample modulator, morph pad, shapes LFO, shapes range, lane automation). WebView2-based UI with real-time visualization, modulation arc display, 10 themes, granular undo/redo, visibility-culled rendering, async plugin loading, and hosted-plugin gesture detection.", + "build": { + "formats": [ + "VST3", + "Standalone" + ], + "platform": "Windows", + "webview_backend": "WebView2", + "vst3_path": "build/plugins/ModularRandomizer/ModularRandomizer_artefacts/Release/VST3/ModularRandomizer.vst3", + "standalone_path": "build/plugins/ModularRandomizer/ModularRandomizer_artefacts/Release/Standalone/ModularRandomizer.exe", + "last_build": "2026-03-02T00:15:00Z", + "build_errors": 0, + "dependencies": [ + "JUCE 8", + "CMake 3.22+", + "Visual Studio 2022", + "Microsoft WebView2 SDK" + ] + }, + "architecture": { + "source_files": { + "PluginProcessor.cpp": "1370+ lines — Constructor, lifecycle, createParameterLayout, updateLogicBlocks (with paramTouched cleanup), state save/restore, sample modulator API, GestureListener registration, rebuildPluginSlots with safe listener removal, JUCE boilerplate", + "ProcessBlock.cpp": "1862 lines — processBlock (audio pipeline), SEH crash guard, logic block engine (all 7 block types incl. lane), glide processing, NaN sanitization, dry/wet mix, depth-zero skip for shapes_range", + "PluginHosting.cpp": "540+ lines — Plugin scan, findPluginDescription (thread-safe), instantiatePlugin (COM-safe), remove, param access, proxy parameter pool, DAW automation bridge, glide FIFO API", + "PluginProcessor.h": "820+ lines — All data structures (HostedPlugin, LogicBlock, GestureListener, ProxyParameter, readback FIFOs, crash event FIFO, pluginSlots[32] O(1) lookup, paramTouched atomic flags)", + "PluginEditor.cpp": "1300+ lines — WebView2 bridge, 20+ native functions, async plugin loading, 2-tier real-time polling with visibility culling, crash notification relay", + "PluginEditor.h": "130+ lines — Editor class with WebView, relays, attachments, expandedPluginIds, paramIdentCache, modulatedParamKeys", + "ParameterIDs.hpp": "Parameter ID string constants (MIX, BYPASS)" + }, + "ui_modules": { + "index.html": "Main HTML shell with all panels and modals", + "js/state.js": "Global state variables and data structures", + "js/juce_bridge.js": "JUCE WebView native function bridge helpers", + "js/juce_integration.js": "JUCE relay/attachment integration for MIX and BYPASS", + "js/theme_system.js": "10 themes with comprehensive CSS variable system (100+ vars per theme), settings panel, dynamic slider styling", + "js/undo_system.js": "Granular undo/redo: single param (setParam), multi-param (applyParamBatch), full-state (applyParamBatch) entry types", + "js/plugin_rack.js": "Plugin card rendering, param display, knob SVGs, drag-to-assign, bulk select, loading feedback, visibility culling, scroll-based dirty tracking", + "js/logic_blocks.js": "Block card rendering, morph pad, shapes pad, wiring, all block UIs, batch randomize (applyParamBatch)", + "js/context_menus.js": "Right-click menus for params and plugins (lock/unlock, bypass, presets)", + "js/preset_system.js": "Per-plugin presets (batch IPC), global presets (batch IPC), search, type toggle, delete confirmation, dirty indicator", + "js/realtime.js": "RT data listener, envelope/sample/morph readback, auto-locate, crash toast, live BPM display, visibility-culled param refresh", + "js/controls.js": "Header controls, keyboard shortcuts, collapse/expand all, plugin loading state, themed toast notifications", + "js/persistence.js": "State save/restore to/from processor (survives editor close/reopen)" + }, + "css_modules": [ + "css/variables.css — 110+ CSS custom properties with defaults", + "css/base.css", + "css/header.css — Bus group/header/mute/solo styling with CSS variable theming", + "css/plugin_rack.css — Loading cards, preset flash, parameter rows (6-row fixed layout)", + "css/logic_blocks.css", + "css/dialogs.css", + "css/themes.css", + "css/overrides.css" + ] + }, + "features": { + "plugin_hosting": { + "vst3_scanning": "Recursive filesystem scan of user-configured directories", + "plugin_loading": { + "async": "Two-phase async loading: disk scan on background thread, COM instantiation on message thread", + "findPluginDescription": "Phase 1 — thread-safe cache lookup + disk scan (no UI freeze)", + "instantiatePlugin": "Phase 2 — createPluginInstance + bus layout + prepareToPlay (message thread, COM-safe)", + "loadPlugin": "Convenience wrapper calling both phases in sequence (used by setStateInformation)", + "crash_protection": "try/catch around createPluginInstance and prepareToPlay" + }, + "serial_processing": "Hosted plugins process audio in series (chain order)", + "parallel_processing": { + "routing_modes": [ + "Sequential", + "Parallel" + ], + "bus_system": "Up to 8 parallel buses, plugins assigned to buses via UI badge", + "bus_ui": { + "color_coding": "Per-theme bus color palettes (8 colors per theme), 10px color dot with glow", + "header_strip": "Bus name, volume slider, mute/solo buttons, collapse chevron", + "tint_system": "color-mix() based background tinting (8-12% group, 16-22% header) via --bus-tint CSS variable", + "badge_dropdown": "Colored bus assignment dropdown on plugin cards with --bus-badge-text theming" + }, + "equal_gain": "Output summed with 1/sqrt(N) equal-gain compensation per active bus", + "single_bus_optimization": "Falls back to sequential path when only 1 bus is active", + "zero_allocation": "8 pre-allocated bus buffers in prepareToPlay, atomic routing mode toggle", + "crash_rollback": "Uses reserved 8th buffer as scratch for crash rollback (no audio-thread allocation)" + }, + "plugin_editor_windows": "Native floating editor windows for each hosted plugin", + "plugin_bypass": "Per-plugin bypass toggle", + "plugin_removal": "Clean unload with resource release", + "crash_protection": { + "seh_guard": "Windows Structured Exception Handling (__try/__except) catches access violations and stack overflows in processBlock", + "cpp_exception_guard": "try/catch(...) wraps all hosted plugin processBlock calls", + "nan_inf_sanitization": "Post-plugin output buffer scan replaces NaN/Inf with 0.0f", + "auto_bypass": "Crashed plugins are immediately bypassed, buffer rolled back to pre-crash state", + "crash_notification": "Lock-free FIFO relays crash events to UI as themed toast notifications with re-enable button", + "reset_crash": "Users can re-enable crashed plugins via UI button (re-prepares the plugin)" + } + }, + "logic_blocks": { + "types": { + "randomize": "Discrete parameter randomization with range, quantize, glide. Batch IPC via applyParamBatch (1 call for N params)", + "envelope": "Continuous audio-reactive envelope follower (attack/release/sensitivity/invert)", + "sample": "Audio file modulator (WAV/AIFF/FLAC/MP3/OGG), loop/oneshot/pingpong modes, speed control, reverse, jump modes", + "morph_pad": "XY pad with up to 12 snapshots, IDW interpolation, manual/auto/trigger modes", + "shapes": "Geometric LFO with 11 shape types, depth/speed/spin controls, tracking modes", + "shapes_range": "Per-parameter variant of shapes with individual range controls per target" + }, + "common_features": { + "targets": "Any block can target any combination of hosted plugin parameters", + "assign_mode": "Click block → click params to assign (Ctrl/Shift for multi-select, All/None bulk buttons)", + "colored_dots": "8-color palette for visual parameter-to-block mapping", + "enable_bypass": "Per-block enable/disable toggle", + "polarity": "Bipolar / Unipolar Up / Unipolar Down modes for relative modulation", + "clock_source": "Per-block choice of DAW tempo or internal tempo" + }, + "trigger_types": [ + "manual", + "tempo (beat divisions)", + "MIDI (any note / specific note / CC)", + "audio threshold (main or sidechain)" + ], + "beat_divisions": [ + "4/1", + "2/1", + "1/1", + "1/2", + "1/2.", + "1/2T", + "1/4", + "1/4.", + "1/4T", + "1/8", + "1/8.", + "1/8T", + "1/16", + "1/16T", + "1/32", + "1/64" + ], + "range_modes": [ + "absolute", + "relative" + ], + "movement_modes": [ + "instant", + "glide (with configurable duration in ms)" + ] + }, + "morph_pad": { + "modes": [ + "manual (XY drag)", + "auto (algorithmic exploration)", + "trigger (MIDI/tempo/audio-driven stepping)" + ], + "auto_explore_modes": [ + "wander (Perlin noise)", + "bounce (angle-based)", + "shapes (geometric LFO)", + "orbit (around snapshots)", + "path (sequential segment travel)" + ], + "lfo_shapes": [ + "circle", + "figure8", + "sweepX", + "sweepY", + "pentagram", + "hexagram", + "rose4", + "lissajous", + "spiral" + ], + "snapshot_features": [ + "12 sector positions (clock-face)", + "capture/restore param values", + "snapshot library (load from presets)", + "snap radius control (IDW falloff)" + ], + "tempo_sync": "BPM-synced speed with configurable beat divisions (8 bars down to 1/64)", + "jitter": "Random perturbation amount (0-100%)", + "glide": "Smooth interpolation between positions (configurable ms)" + }, + "shapes_block": { + "shape_types": [ + "circle", + "figure8", + "sweepX", + "sweepY", + "pentagram", + "hexagram", + "rose4", + "lissajous", + "spiral" + ], + "tracking_modes": [ + "horizontal (X projection)", + "vertical (Y projection)", + "distance (radius from center)" + ], + "controls": [ + "size (shape radius)", + "spin (rotation speed, CW/CCW)", + "speed (dot velocity)", + "depth (modulation amount)" + ], + "tempo_sync": "BPM-synced speed with beat divisions", + "trigger_mode": "Free-running or MIDI-triggered phase reset", + "per_param_ranges": "shapes_range mode: individual depth per target parameter with anchor positions", + "modulation_arc": "Real-time fill arc visualization on knobs showing current modulation position, driven by computeModCurrent() with unified single-path rendering" + }, + "lane_block": { + "description": "Per-parameter automation lanes with drawable envelope curves", + "features": [ + "Multi-param lanes with independent envelopes", + "Draw/erase/line tools with configurable grid", + "Forward/reverse/pingpong play modes", + "Smooth/step/ramp interpolation", + "Per-lane depth, slew, warp, phase controls", + "Loop length with beat-synced or free-run timing", + "Per-lane mute and collapse" + ] + }, + "parameter_management": { + "parameter_locking": "Lock/unlock individual params (locked params excluded from randomization and assignment)", + "auto_locate": "Scroll-to and flash parameters touched in hosted plugin's own editor", + "self_write_filtering": "Logic blocks, glides, DAW automation, and JS calls are excluded from auto-locate detection", + "param_touch_tracking": "Suspend modulation while user is grabbing a parameter via UI knob, hosted plugin gesture listener, or DAW automation (3-layer touch detection)", + "gesture_listeners": "Per-plugin GestureListener structs detect hosted plugin UI interactions via JUCE's AudioProcessorParameter::Listener, setting paramTouched atomics for conflict-free modulation suspension", + "proxy_parameter_pool": "256-slot pool exposing hosted plugin params to DAW automation with dynamic names", + "o1_lookup": "pluginSlots[32] array for O(1) parameter access by plugin ID (replacing O(N) linear scans)" + }, + "preset_system": { + "per_plugin_presets": "Save/load parameter snapshots per hosted plugin with search and type toggle. Uses applyParamBatch for loading.", + "per_plugin_snapshots": "Save/load snapshots (lighter-weight, for morph pad)", + "snapshot_library": "Browse and load presets/snapshots from any plugin into morph pad", + "global_presets": "Save/load entire session with dirty indicator, nav arrows, search. Uses applyParamBatch for param restore.", + "delete_confirmation": "Two-step delete confirmation for all preset types", + "save_feedback": "Visual flash confirmation on successful save (themed via --preset-flash-color/--preset-flash-glow)" + }, + "performance": { + "visibility_culling_js": { + "description": "refreshParamDisplay pre-computes visible parameter IDs by walking expanded card DOM children against scroll bounds", + "collapsed_cost": "Zero — collapsed cards contribute no DOM queries", + "scroll_debounce": "60ms debounce with dirtyPluginParams + requestAnimationFrame" + }, + "visibility_culling_cpp": { + "description": "Tier 1 (60Hz) and Tier 2 (6Hz) polling skip plugins not in expandedPluginIds set", + "resync_on_expand": "lastParamValues cleared when plugin expands → forces full value resync", + "cache_rebuild": "paramIdentCache rebuild (every 1s) also skips collapsed plugins" + }, + "batch_ipc": { + "applyParamBatch": "Single IPC call for N parameter changes (JSON array of {p, i, v})", + "used_by": [ + "randomize()", + "performUndo() multiParam", + "performRedo() multiParam", + "applyFullSnapshot()", + "preset load (per-plugin)", + "preset load (global)" + ] + }, + "o1_param_lookup": "pluginSlots[32] array replaces O(N) linear scans in getParamValueFast, getParamDisplayTextFast, glide pool, randomize", + "persistent_rng": "static juce::Random messageThreadRng (no identical sequences on rapid fire)" + }, + "ui_system": { + "themes": { + "count": 10, + "list": [ + "Earthy (warm dark)", + "DAW Teal (dark teal/cyan)", + "Grey (neutral mid-tone, light variant)", + "Light (bright Ableton-style)", + "Grey (clinical, second variant)", + "Medical Vintage (phosphor chartreuse)", + "Slate Studio (deep purple-gray)", + "Vintage Console (warm amber)", + "Win98 (retro Windows classic)", + "Warm Tape (analog mahogany/VU yellow)" + ], + "variable_categories": [ + "Background layers (app/panel/cell/hover/inset)", + "Border (normal/strong/focus)", + "Text (primary/secondary/muted/input)", + "Accent (base/hover/light/border)", + "Mode colors (rand/env/sample/morph/shapes)", + "Knob (track/value/dot) + per-mode variants", + "Preset/plugin headers (bg/border/text)", + "Status indicator backgrounds (per-mode)", + "Fire button (text/active-bg)", + "Arc/snap ring colors + opacity", + "Slider/bar (track/thumb/fill)", + "Card buttons (bg/border/text/hover)", + "Lane/automation (color/grid/label/playhead/active)", + "Scrollbar (thumb/track)", + "Bus (mute-bg/mute-text/solo-bg/solo-text/group-tint/header-tint/badge-text)", + "Toast notifications (success/error/info bg+border, text)", + "Preset flash (color/glow)" + ] + }, + "settings_panel": "Dropdown with theme picker grid, UI scale slider, auto-locate toggle, internal tempo BPM", + "ui_scale": "Adjustable from 0.25x to 3x via settings dropdown", + "real_time_visualization": { + "envelope_levels": "Per-block envelope follower level bars (from C++ at 60Hz)", + "sample_playheads": "Per-block waveform playhead position", + "morph_playheads": "Per-block XY dot position synced from C++ auto modes", + "shape_dots": "Per-block shape dot position + SVG rotation synced from C++", + "shape_readout_lines": "Visual tracking indicator (horizontal/vertical line or distance circle)", + "trigger_flash": "Visual flash on MIDI/tempo/audio triggers", + "param_knob_svgs": "Real-time SVG arc knobs with range arc overlay (visibility-culled)", + "param_value_text": "Real-time parameter display text from plugin getText() (visibility-culled)", + "rms_levels": "Main and sidechain audio RMS for UI" + }, + "plugin_rack_layout": { + "param_rows": "Exactly 6 rows visible per plugin card (36px row height, 216px max-height)", + "scroll_culling": "Only visible rows within scroll viewport are updated" + }, + "drag_and_drop": "Drag params onto blocks, drag blocks to reorder", + "context_menus": "Right-click params (lock/unlock/assign) and plugins (bypass/open editor/presets/remove)", + "keyboard_shortcuts": { + "ctrl_z": "Undo (works globally, even in inputs)", + "ctrl_shift_z": "Redo", + "ctrl_y": "Redo (alternative)", + "ctrl_s": "Quick-save global preset", + "escape": "Close modals / exit assign mode / clear selection", + "space": "Trigger active randomizer block", + "delete": "Remove selected params from active block", + "ctrl_a": "Select all unlocked params (in assign mode)", + "r": "Apply range to selected params (shapes_range)" + }, + "undo_redo": { + "type": "Granular 3-tier system with batch IPC", + "param_undo": "Single knob change → setParam (1 IPC call)", + "multi_param_undo": "Randomize/preset load → applyParamBatch (1 IPC call for N params)", + "full_state_undo": "Structural changes → applyParamBatch for params + block/plugin restore" + }, + "live_bpm": "Status bar shows actual DAW BPM at ~4Hz refresh", + "plugin_loading_feedback": { + "loading_card": "Placeholder card with pulse animation and progress bar", + "loading_dots": "Animated dots text indicator", + "double_click_guard": "pluginLoading flag prevents double-clicks", + "async_loading": "Background thread scan → message thread instantiate (UI stays responsive)" + }, + "toast_notifications": { + "types": [ + "success (green)", + "error (red)", + "info (blue)" + ], + "theming": "Colors driven by CSS variables (--toast-success-bg/border, etc.)", + "animation": "Slide-in/slide-out with auto-dismiss" + }, + "preset_flash": "Border glow animation on preset load, themed via --preset-flash-color/--preset-flash-glow", + "collapse_expand_all": "Buttons to collapse/expand all plugin cards at once", + "crash_toast": "Red slide-in notification when hosted plugin crashes, with re-enable button" + }, + "audio_engine": { + "dry_wet_mix": "0-100% mix control with per-sample crossfade", + "global_bypass": "Full plugin bypass (audio passthrough)", + "sidechain_input": "Optional sidechain bus for audio-triggered blocks", + "midi_input": "Full MIDI note and CC support with channel filtering", + "glide_engine": "Lock-free FIFO (512 slots) for smooth per-buffer parameter interpolation", + "internal_tempo": "Independent BPM clock (per-block selectable: DAW or internal)", + "sample_accurate_triggers": "Tempo-synced triggers use PPQ position for beat-accurate firing", + "audio_thread_safety": { + "mutex_pattern": "try_to_lock in processBlock prevents priority inversion", + "zombie_modulation_fix": "Enum comparison (BlockMode::Shapes) instead of string comparison for mode checks", + "persistent_rng": "static Random in randomizeParams, member Random in processor for audio thread" + } + }, + "state_persistence": { + "processor_state": "Saves/restores all hosted plugins (paths + param values) and UI state in DAW project", + "ui_state": "Full JSON serialization of blocks, mappings, locks, theme, scale — survives editor close/reopen", + "plugin_id_patching": "Restored plugin IDs match saved IDs to preserve block target references", + "null_safe_restore": "All numeric fields use != null checks to prevent data loss when user value is 0 (fixes 15+ silent restore bugs from || operator)" + } + }, + "native_functions": [ + "scanPlugins(scanPaths) → ScannedPlugin[]", + "loadPlugin(pluginPath) → { id, name, params[] } (async: bg thread scan → msg thread instantiate)", + "removePlugin(pluginId)", + "setParam(pluginId, paramIndex, value)", + "applyParamBatch(jsonString) — [{p, i, v}, ...] in one IPC call", + "touchParam(pluginId, paramIndex)", + "untouchParam(pluginId, paramIndex)", + "getParams(pluginId) → params[]", + "fireRandomize(pluginId, paramIndices[], min, max) → updatedParams[]", + "updateBlocks(jsonString)", + "updateMorphPlayhead(blockId, x, y)", + "startGlide(pluginId, paramIndex, targetValue, durationMs)", + "openPluginEditor(pluginId)", + "browseSample(blockId) → { name, path, waveform[] }", + "setPluginBypass(pluginId, bypass)", + "resetPluginCrash(pluginId)", + "setExpandedPlugins(pluginIdsJson) — controls C++ visibility culling", + "savePluginPreset(pluginName, presetName, jsonData)", + "getPluginPresets(pluginName) → presetNames[]", + "deletePluginPreset(pluginName, presetName)", + "loadPluginPreset(pluginName, presetName) → jsonData", + "saveGlobalPreset(presetName, jsonData)", + "getGlobalPresets() → presetNames[]", + "loadGlobalPreset(presetName) → jsonData", + "deleteGlobalPreset(presetName)", + "setEditorScale(scale)" + ], + "rt_events": [ + "__rt_data__ → { rms, scRms, bpm, playing, ppq, midi[], envLevels[], trigFired[], sampleHeads[], morphHeads[], params[], touchedParam }", + "__plugin_crashed__ → { pluginId, pluginName, reason }" + ], + "phase_history": [ + { + "phase": "ideation_complete", + "completed_at": "2026-02-24T10:19:00Z" + }, + { + "phase": "plan_complete", + "completed_at": "2026-02-24T10:20:55Z", + "framework_selected": "webview" + }, + { + "phase": "design_complete", + "completed_at": "2026-02-24T15:27:00Z", + "design_version": "v5", + "notes": "Multi-plugin support with collapsible, drag-reorderable plugin cards. Plugin Browser modal with search, category filtering. Relative randomization mode. Persistent block colors. Scoped parameter IDs." + }, + { + "phase": "code_complete", + "completed_at": "2026-02-24T18:38:00Z", + "notes": "Full C++/WebView2 implementation. VST3 + Standalone build successful." + }, + { + "phase": "post_v1_features", + "completed_at": "2026-02-25T14:34:00Z", + "notes": "Polarity control (bipolar/unipolar up/down), auto-locate with self-write filtering, per-plugin bypass." + }, + { + "phase": "morph_pad_implementation", + "completed_at": "2026-02-25T20:25:00Z", + "notes": "Morph Pad logic block: XY pad, snapshots, IDW interpolation, manual/auto/trigger modes, 5 explore modes (wander/bounce/shapes/orbit/path), tempo sync, snap radius, snapshot library." + }, + { + "phase": "tempo_sync_and_shapes", + "completed_at": "2026-02-27T03:25:00Z", + "notes": "Internal tempo control, per-block clock source (DAW/internal), Shapes block and Shapes Range block with 11 geometric shapes, tracking modes, spin, per-param ranges." + }, + { + "phase": "debugging_and_bug_fixes", + "completed_at": "2026-02-27T16:28:00Z", + "notes": "Static analysis pass: normalization fixes, SVG rendering fixes, scope issues, dead code removal." + }, + { + "phase": "crash_protection", + "completed_at": "2026-02-27T17:38:00Z", + "notes": "SEH crash guard (Windows), C++ exception guard, NaN/Inf output sanitization, auto-bypass with UI toast notification, safe plugin loading, plugin crash reset." + }, + { + "phase": "ux_improvements", + "completed_at": "2026-02-28T04:19:00Z", + "notes": "Preset UX overhaul (search, nav arrows, dirty indicator, delete confirmation, save feedback, type toggle). Granular undo/redo system. Keyboard shortcuts. Live BPM display. Plugin loading feedback. Collapse/expand all. Parallel plugin routing with bus-based processing." + }, + { + "phase": "theme_system_completion", + "completed_at": "2026-02-28T18:27:00Z", + "notes": "CSS variable audit: all missing defaults added to variables.css. All 10 themes verified for complete coverage. Added Slate Studio, Vintage Console, Win98, Warm Tape themes." + }, + { + "phase": "audio_thread_refactor", + "completed_at": "2026-03-01T02:01:00Z", + "notes": "Lock-free audio thread: pluginSlots[32] O(1) lookup, persistent RNG, zombie modulation fix (enum comparison), batch IPC for randomize." + }, + { + "phase": "performance_optimization", + "completed_at": "2026-03-01T15:10:00Z", + "notes": "Two-layer visibility culling (JS DOM skip + C++ polling skip for collapsed plugins). Tier 2 getText fix (only calls getText on changed params). Batch IPC for undo/redo/preset load (applyParamBatch). Cache rebuild skips collapsed plugins. Async plugin loading (background thread scan + message thread instantiate). Bus UI overhaul (thicker borders, stronger tints, bigger dots, --bus-badge-text). Toast/preset flash theming (10 new CSS variables across all themes)." + }, + { + "phase": "modulation_arc_and_knob_vis", + "completed_at": "2026-03-01T18:32:00Z", + "notes": "Modulation arc visualization on parameter knobs: real-time fill arc showing current modulation position. computeModCurrent() unified rendering path. getModArcInfo() range/base/color extraction from blocks." + }, + { + "phase": "modulation_touch_and_persistence_fixes", + "completed_at": "2026-03-02T00:15:00Z", + "notes": "Hosted plugin UI gesture detection (GestureListener per-plugin with safe removeListener before destroy). Fixed crash from dangling listener pointers. Shapes_range depth-zero skip prevents blocking unmodulated params. Targeted paramTouched cleanup on block config changes (mode/target changes only). requestAnimationFrame for drag arc (replaces setInterval). Dirty-marking modulated params from morphHeads for continuous fill arc updates. State persistence audit: fixed 15+ data-loss bugs from || operator treating 0 as falsy (midiNote, threshold, morphSpeed, glideMs, etc.) in persistence.js and preset_system.js." + } + ], + "validation": { + "creative_brief_exists": true, + "parameter_spec_exists": true, + "architecture_defined": true, + "ui_framework_selected": true, + "design_complete": true, + "code_complete": true, + "build_completed": true, + "build_timestamp": "2026-03-02T00:15:00Z", + "build_errors": 0, + "tests_passed": false, + "ship_ready": false + }, + "framework_selection": { + "decision": "webview", + "rationale": "Multi-plugin hosting with dynamic parameter lists, plugin browser modal, drag-and-drop, morph pad XY visualization, shapes SVG animation, and real-time envelope display best served by HTML/CSS/JS via WebView2.", + "implementation_strategy": "phased" + }, + "design": { + "current_version": "v5", + "style": "Theme-switchable (10 themes), two-panel rack layout", + "themes": [ + "Earthy", + "DAW Teal", + "Grey", + "Light", + "Grey (clinical)", + "Medical Vintage", + "Slate Studio", + "Vintage Console", + "Win98", + "Warm Tape" + ], + "files": { + "ui_spec": "Design/v5-ui-spec.md", + "preview": "Design/v5-test.html", + "morph_plan": "Design/morph-pad-plan.md" + } + }, + "file_sizes": { + "PluginProcessor.cpp": "57KB (1370+ lines)", + "ProcessBlock.cpp": "100KB (1862 lines)", + "PluginHosting.cpp": "19KB (540+ lines)", + "PluginProcessor.h": "36KB (820+ lines)", + "PluginEditor.cpp": "60KB (1300+ lines)", + "PluginEditor.h": "5KB (130+ lines)", + "logic_blocks.js": "161KB (2807 lines)", + "plugin_rack.js": "63KB (1192 lines)", + "preset_system.js": "51KB (1038 lines)", + "theme_system.js": "57KB (1108 lines)", + "realtime.js": "23KB (457 lines)", + "undo_system.js": "6.5KB (179 lines)", + "persistence.js": "21KB (391 lines)", + "controls.js": "15KB (344 lines)", + "context_menus.js": "9.7KB (203 lines)", + "total_source_estimate": "~710KB across 20 source files" + }, + "error_recovery": { + "last_backup": null, + "rollback_available": false, + "error_log": [] + } +} \ No newline at end of file From d169c98b970a3f315625b98e4cc0a70c340ad524 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 19:18:57 +0100 Subject: [PATCH 05/14] fix: install WebView2 NuGet package on Windows CI --- .github/workflows/build-release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 891ee0e..59623c8 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -52,6 +52,13 @@ jobs: - name: Setup MSVC uses: ilammy/msvc-dev-cmd@v1 + - name: Install WebView2 SDK + shell: pwsh + run: | + # JUCE needs the WebView2 NuGet package for static linking + Register-PackageSource -provider NuGet -name nugetRepository -location https://www.nuget.org/api/v2 -ErrorAction SilentlyContinue + Install-Package Microsoft.Web.WebView2 -Scope CurrentUser -RequiredVersion 1.0.3485.44 -Source nugetRepository -Force + - name: Configure CMake run: cmake -S . -B build -G "Visual Studio 17 2022" -A x64 From 0abf78ccfc1558eaae71d0ab1478633f7b73d2a9 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 19:34:46 +0100 Subject: [PATCH 06/14] fix: macOS compilation - non-Windows SEH fallbacks + platform-specific VST3 paths --- .../Source/PluginHosting.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/plugins/ModularRandomizer/Source/PluginHosting.cpp b/plugins/ModularRandomizer/Source/PluginHosting.cpp index a597f92..8755848 100644 --- a/plugins/ModularRandomizer/Source/PluginHosting.cpp +++ b/plugins/ModularRandomizer/Source/PluginHosting.cpp @@ -99,6 +99,37 @@ bool sehDestroyInstance (juce::AudioPluginInstance* rawInstance) return false; } } +#else +// Non-Windows: no SEH, use regular try/catch fallbacks +static bool sehSetState (juce::AudioPluginInstance* instance, const void* data, int size) +{ + try { + instance->setStateInformation (data, size); + return true; + } catch (...) { + return false; + } +} + +bool sehReleaseResources (juce::AudioPluginInstance* instance) +{ + try { + instance->releaseResources(); + return true; + } catch (...) { + return false; + } +} + +bool sehDestroyInstance (juce::AudioPluginInstance* rawInstance) +{ + try { + delete rawInstance; + return true; + } catch (...) { + return false; + } +} #endif std::vector ModularRandomizerAudioProcessor::scanForPlugins ( @@ -996,7 +1027,17 @@ void ModularRandomizerAudioProcessor::buildPresetIndex() // Scan inside installed VST3 bundles juce::Array vst3Dirs; +#if JUCE_WINDOWS vst3Dirs.add (commonFiles.getChildFile ("VST3")); +#elif JUCE_MAC + vst3Dirs.add (juce::File ("/Library/Audio/Plug-Ins/VST3")); + vst3Dirs.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile ("Library/Audio/Plug-Ins/VST3")); +#elif JUCE_LINUX + vst3Dirs.add (juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile (".vst3")); + vst3Dirs.add (juce::File ("/usr/lib/vst3")); +#endif for (const auto& vst3Dir : vst3Dirs) { if (! vst3Dir.isDirectory()) continue; From bcdfac31d46ad9aa2e87b6afaff7836c3300d159 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 19:35:35 +0100 Subject: [PATCH 07/14] fix: Inno Setup 6.x compatibility - directive name + remove missing icon --- plugins/ModularRandomizer/installer/windows.iss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/ModularRandomizer/installer/windows.iss b/plugins/ModularRandomizer/installer/windows.iss index 0aaf03e..fa834c9 100644 --- a/plugins/ModularRandomizer/installer/windows.iss +++ b/plugins/ModularRandomizer/installer/windows.iss @@ -26,11 +26,10 @@ OutputBaseFilename={#PluginName}-{#PluginVersion}-Windows-Setup OutputDir=..\dist Compression=lzma2/ultra64 SolidCompression=yes -ArchitecturesInstallIn64BitModeOnly=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=admin WizardStyle=modern LicenseFile=LICENSE.txt -SetupIconFile=icon.ico UninstallDisplayIcon={app}\{#PluginName}.exe DisableProgramGroupPage=yes DisableDirPage=no From 493133e4942b57ebdbf3a0f3e78a82076d4eaf15 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 19:55:10 +0100 Subject: [PATCH 08/14] fix: add libgtk-3-dev for Linux CI build --- .github/workflows/build-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 59623c8..ca4a1fc 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -283,6 +283,7 @@ jobs: libxinerama-dev \ libxrandr-dev \ libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ libjack-jackd2-dev \ xvfb From 7945684df5c546850bd54c13cfc1c6386b2113b9 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 20:06:51 +0100 Subject: [PATCH 09/14] rebrand: replace Noizefield with Dimitar Petrov + add installer icon --- plugins/ModularRandomizer/CMakeLists.txt | 8 ++++---- plugins/ModularRandomizer/README.md | 2 +- .../Source/ui/public/js/wrongeq_canvas.js | 2 +- plugins/ModularRandomizer/installer/LICENSE.txt | 8 ++++---- plugins/ModularRandomizer/installer/icon.ico | Bin 0 -> 14818 bytes plugins/ModularRandomizer/installer/windows.iss | 5 +++-- 6 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 plugins/ModularRandomizer/installer/icon.ico diff --git a/plugins/ModularRandomizer/CMakeLists.txt b/plugins/ModularRandomizer/CMakeLists.txt index 0c20ca0..3293af7 100644 --- a/plugins/ModularRandomizer/CMakeLists.txt +++ b/plugins/ModularRandomizer/CMakeLists.txt @@ -19,7 +19,7 @@ elseif(UNIX) set(NEEDS_WEBVIEW2 FALSE) set(NEEDS_WEB_BROWSER TRUE) set(WEBVIEW_BACKEND "WebKitGTK") - set(LV2_URI "https://github.com/noizefield/audio-plugin-coder/ModularRandomizer") + set(LV2_URI "https://dimitarp.com/audio-plugin-coder/ModularRandomizer") else() message(FATAL_ERROR "Unsupported platform") endif() @@ -73,18 +73,18 @@ set_target_properties(ModularRandomizerWebUI PROPERTIES # PLUGIN TARGET # ============================================ juce_add_plugin(ModularRandomizer - COMPANY_NAME "Noizefield" + COMPANY_NAME "Dimitar Petrov" IS_SYNTH FALSE NEEDS_MIDI_INPUT TRUE NEEDS_MIDI_OUTPUT FALSE IS_MIDI_EFFECT FALSE EDITOR_WANTS_KEYBOARD_FOCUS TRUE COPY_PLUGIN_AFTER_BUILD FALSE - PLUGIN_MANUFACTURER_CODE Nzfd + PLUGIN_MANUFACTURER_CODE DmPt PLUGIN_CODE MdRn FORMATS ${PLUGIN_FORMATS} PRODUCT_NAME "ModularRandomizer" - BUNDLE_ID "com.noizefield.modularrandomizer" + BUNDLE_ID "com.dimitarp.modularrandomizer" PLUGIN_NAME "ModularRandomizer" DESCRIPTION "Multi-Plugin Parameter Randomizer" diff --git a/plugins/ModularRandomizer/README.md b/plugins/ModularRandomizer/README.md index 9976345..09748e1 100644 --- a/plugins/ModularRandomizer/README.md +++ b/plugins/ModularRandomizer/README.md @@ -135,4 +135,4 @@ cmake --build build --config Release --target ModularRandomizer_VST3 ## License -Copyright © Noizefield +Copyright © Dimitar Petrov diff --git a/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js index 8b05c8b..a5e9790 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js @@ -4675,7 +4675,7 @@ function weqCreateVirtualBlock() { hostId: WEQ_VIRTUAL_ID, name: '⬡ WrongEQ', path: '__virtual__', - manufacturer: 'Noizefield', + manufacturer: 'Dimitar Petrov', params: params, expanded: true, searchFilter: '', diff --git a/plugins/ModularRandomizer/installer/LICENSE.txt b/plugins/ModularRandomizer/installer/LICENSE.txt index d4536cd..70e47e4 100644 --- a/plugins/ModularRandomizer/installer/LICENSE.txt +++ b/plugins/ModularRandomizer/installer/LICENSE.txt @@ -8,7 +8,7 @@ By installing, copying, or otherwise using ModularRandomizer ("the Software"), you agree to be bound by the terms of this agreement. 1. GRANT OF LICENSE - Noizefield grants you a non-exclusive, non-transferable license to install + Dimitar Petrov grants you a non-exclusive, non-transferable license to install and use the Software on any number of computers you personally own or control. 2. PERMITTED USE @@ -24,7 +24,7 @@ you agree to be bound by the terms of this agreement. 4. INTELLECTUAL PROPERTY The Software is protected by copyright laws and international treaties. - All rights not expressly granted are reserved by Noizefield. + All rights not expressly granted are reserved by Dimitar Petrov. 5. DISCLAIMER OF WARRANTY THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS @@ -32,7 +32,7 @@ you agree to be bound by the terms of this agreement. FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. 6. LIMITATION OF LIABILITY - IN NO EVENT SHALL NOIZEFIELD BE LIABLE FOR ANY DIRECT, INDIRECT, + IN NO EVENT SHALL DIMITAR PETROV BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES ARISING FROM THE USE OR INABILITY TO USE THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. @@ -46,5 +46,5 @@ you agree to be bound by the terms of this agreement. By installing this Software, you acknowledge that you have read, understood, and agree to be bound by these terms. -Copyright (c) 2026 Noizefield — https://noizefield.com +Copyright (c) 2026 Dimitar Petrov — https://dimitarp.com ================================================================================ diff --git a/plugins/ModularRandomizer/installer/icon.ico b/plugins/ModularRandomizer/installer/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..245afefd95236e9f0d5629a9e8241e17090948d1 GIT binary patch literal 14818 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 DoZM~* literal 0 HcmV?d00001 diff --git a/plugins/ModularRandomizer/installer/windows.iss b/plugins/ModularRandomizer/installer/windows.iss index fa834c9..3ecf02a 100644 --- a/plugins/ModularRandomizer/installer/windows.iss +++ b/plugins/ModularRandomizer/installer/windows.iss @@ -4,8 +4,8 @@ #define PluginName "ModularRandomizer" #define PluginVersion "1.0.0" -#define Publisher "Noizefield" -#define PublisherURL "https://noizefield.com" +#define Publisher "Dimitar Petrov" +#define PublisherURL "https://dimitarp.com" ; These paths are relative to the .iss file location ; Override via /D on command line for CI builds: @@ -30,6 +30,7 @@ ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=admin WizardStyle=modern LicenseFile=LICENSE.txt +SetupIconFile=icon.ico UninstallDisplayIcon={app}\{#PluginName}.exe DisableProgramGroupPage=yes DisableDirPage=no From b77ac03a8330210453dbd84b8209bb3faebde62d Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 20:09:44 +0100 Subject: [PATCH 10/14] ci: bump actions to v5 for Node.js 24 compatibility --- .github/workflows/build-release.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index ca4a1fc..ca49efc 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -44,7 +44,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: recursive fetch-depth: 0 @@ -125,7 +125,7 @@ jobs: } - name: Upload Windows Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: windows-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | @@ -143,7 +143,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: recursive fetch-depth: 0 @@ -245,7 +245,7 @@ jobs: ls -la "$DIST_DIR/" - name: Upload macOS Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: macos-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | @@ -263,7 +263,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: submodules: recursive fetch-depth: 0 @@ -331,7 +331,7 @@ jobs: ls -la - name: Upload Linux Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: linux-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | @@ -354,7 +354,7 @@ jobs: contents: write steps: - name: Download All Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: path: release-artifacts merge-multiple: false @@ -370,7 +370,7 @@ jobs: - name: Create GitHub Release if: github.event_name == 'push' && hashFiles('release-artifacts/**/*') != '' - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: release-artifacts/**/* generate_release_notes: true @@ -389,7 +389,7 @@ jobs: - name: Upload combined release (manual trigger) if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-all-platforms path: release-artifacts/ From 0b99da70ea8bb9618cf913e37db286d63cd5c70d Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 20:27:37 +0100 Subject: [PATCH 11/14] ship change --- .agent/workflows/ship.md | 54 ++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/.agent/workflows/ship.md b/.agent/workflows/ship.md index 1f37776..24595a8 100644 --- a/.agent/workflows/ship.md +++ b/.agent/workflows/ship.md @@ -1,39 +1,45 @@ --- -description: "PHASE 5: Packaging - Build release and create installers" +description: "Ship a new release — sync code to CI repo, push, and trigger build" --- -# Ship Phase +# Ship Workflow -**Prerequisites:** -```powershell -. "$PSScriptRoot\..\scripts\state-management.ps1" +Push the latest ModularRandomizer code to the CI repo and trigger a cross-platform build. + +## Steps -$state = Get-PluginState -PluginPath "plugins\$PluginName" +1. Ask the user for a version number (e.g. "1.0.1") and a short description of changes. -if ($state.current_phase -ne "code_complete") { - Write-Error "Implementation not complete. Run /impl first." - exit 1 -} +// turbo +2. Stage all ModularRandomizer changes in the parent repo: +```powershell +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" add plugins/ModularRandomizer ``` -**Execute Skill:** -Load and execute `...agent\skills\skill_packaging\SKILL.md` +3. Commit with the user's description: +```powershell +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" commit -m "release: v{VERSION} - {DESCRIPTION}" +``` -**Validation:** -- Verify all formats built (VST3/AU/CLAP) -- Verify tests passed -- Verify installer created in dist/ -- Verify GitHub commit successful +4. Push to the fork: +```powershell +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push fork main +``` -**Completion:** +5. Tag the release and push the tag (this auto-triggers the CI build): +```powershell +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" tag v{VERSION} +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push fork v{VERSION} ``` -🎉 Plugin shipped successfully! -Formats: VST3, AU, CLAP -Location: dist\[Name]-v[version].zip +6. Report success and provide the link: +``` +🚀 Release v{VERSION} shipped! -GitHub: Committed and tagged +Build running at: https://github.com/DimitarPetrov77/audio-plugin-coder/actions +Installers will be available in ~10 minutes. -Plugin development complete! +When complete, download from: + → Actions → latest run → Artifacts section + → Or from the GitHub Release page (permanent links) ``` - From b318ffec095f1c571e52dcb0aa1b2d7474f2f2e7 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 22:11:04 +0100 Subject: [PATCH 12/14] release: v1.0.1 - routing dropdown, bypass glow, grey theme canvas fix, portable ZIP artifacts --- .github/workflows/build-release.yml | 121 ++++++++++++++++-- .../Source/ui/public/css/header.css | 90 ++++++++++++- .../Source/ui/public/css/logic_blocks.css | 2 +- .../Source/ui/public/css/wrongeq.css | 45 +++---- .../Source/ui/public/index.html | 17 +-- .../Source/ui/public/js/controls.js | 34 ++--- .../Source/ui/public/js/help_panel.js | 10 +- .../Source/ui/public/js/persistence.js | 10 +- .../Source/ui/public/js/preset_system.js | 14 +- .../Source/ui/public/js/theme_system.js | 54 ++++++-- .../Source/ui/public/js/wrongeq_canvas.js | 2 +- 11 files changed, 310 insertions(+), 89 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index ca49efc..e5f6197 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -124,6 +124,54 @@ jobs: Compress-Archive -Path "$stagingDir\*" -DestinationPath "$distDir\${plugin}-${version}-Windows.zip" } + - name: Create portable ZIP + shell: pwsh + run: | + $plugin = "${{ inputs.plugin_name || 'ModularRandomizer' }}" + $version = "${{ inputs.version || '1.0.0' }}" + $distDir = "plugins/$plugin/dist" + $zipDir = "$distDir/${plugin}-${version}-Windows-Portable" + + New-Item -ItemType Directory -Path "$zipDir/VST3" -Force | Out-Null + + # Copy VST3 bundle + $vst3Dir = (Get-ChildItem -Path build -Recurse -Filter "*.vst3" -Directory | Select-Object -First 1).FullName + if ($vst3Dir) { Copy-Item -Path $vst3Dir -Destination "$zipDir/VST3/" -Recurse } + + # Copy Standalone + $exePath = (Get-ChildItem -Path build -Recurse -Filter "${plugin}.exe" | Select-Object -First 1).FullName + if ($exePath) { Copy-Item $exePath "$zipDir/" } + + # Copy LICENSE + if (Test-Path "plugins/$plugin/installer/LICENSE.txt") { + Copy-Item "plugins/$plugin/installer/LICENSE.txt" "$zipDir/" + } + + # Create README + @" + $plugin v${version} - Portable Install + ============================================ + + WINDOWS INSTALL INSTRUCTIONS + ---------------------------- + + VST3 Plugin: + Copy the entire "VST3\${plugin}.vst3" folder to: + C:\Program Files\Common Files\VST3\ + + Standalone App: + Run ${plugin}.exe from anywhere. + + After copying, restart your DAW and rescan plugins. + + NOTE: Windows SmartScreen may show a warning because the + software is not code-signed. Click "More info" then + "Run anyway" - this is safe. + "@ | Out-File -FilePath "$zipDir/README.txt" -Encoding utf8 + + Compress-Archive -Path "$zipDir/*" -DestinationPath "$distDir/${plugin}-${version}-Windows-Portable.zip" + Write-Host "Portable ZIP created" -ForegroundColor Green + - name: Upload Windows Artifacts uses: actions/upload-artifact@v5 with: @@ -217,17 +265,17 @@ jobs: - + - + - + - PLUGIN_NAME-VST3.pkg - PLUGIN_NAME-AU.pkg - PLUGIN_NAME-App.pkg + PLUGIN_NAME-VST3.pkg + PLUGIN_NAME-AU.pkg + PLUGIN_NAME-App.pkg DISTXML @@ -244,12 +292,67 @@ jobs: echo "macOS installer created: $DIST_DIR/${PLUGIN}-${VERSION}-macOS.pkg" ls -la "$DIST_DIR/" + - name: Create portable ZIP + run: | + PLUGIN="${{ inputs.plugin_name || 'ModularRandomizer' }}" + VERSION="${{ inputs.version || '1.0.0' }}" + DIST_DIR="plugins/$PLUGIN/dist" + ZIP_DIR="$DIST_DIR/${PLUGIN}-${VERSION}-macOS-Portable" + + mkdir -p "$ZIP_DIR/VST3" "$ZIP_DIR/AU" "$ZIP_DIR/App" + + # Find and copy artifacts + VST3_PATH=$(find build -name "*.vst3" -type d | head -1) + AU_PATH=$(find build -name "*.component" -type d | head -1) + APP_PATH=$(find build -name "*.app" -type d | head -1) + + [ -d "$VST3_PATH" ] && cp -R "$VST3_PATH" "$ZIP_DIR/VST3/" + [ -d "$AU_PATH" ] && cp -R "$AU_PATH" "$ZIP_DIR/AU/" + [ -d "$APP_PATH" ] && cp -R "$APP_PATH" "$ZIP_DIR/App/" + + # Copy license + [ -f "plugins/$PLUGIN/installer/LICENSE.txt" ] && \ + cp "plugins/$PLUGIN/installer/LICENSE.txt" "$ZIP_DIR/" + + # Create README + cat > "$ZIP_DIR/README.txt" << EOF + $PLUGIN v${VERSION} - Portable Install + ============================================ + + MACOS INSTALL INSTRUCTIONS + -------------------------- + + VST3 Plugin: + Copy the .vst3 bundle from the VST3 folder to: + /Library/Audio/Plug-Ins/VST3/ + (or ~/Library/Audio/Plug-Ins/VST3/ for current user only) + + Audio Unit (AU): + Copy the .component bundle from the AU folder to: + /Library/Audio/Plug-Ins/Components/ + (or ~/Library/Audio/Plug-Ins/Components/ for current user only) + + Standalone App: + Drag the .app from the App folder to /Applications. + + After copying, restart your DAW and rescan plugins. + + NOTE: macOS Gatekeeper may block unsigned software. + Right-click the plugin/app -> Open, then click "Open" again. + Or run in Terminal: xattr -cr /path/to/plugin.vst3 + EOF + + # Create ZIP + cd "$DIST_DIR" + zip -r "${PLUGIN}-${VERSION}-macOS-Portable.zip" "${PLUGIN}-${VERSION}-macOS-Portable/" + echo "macOS portable ZIP created" + - name: Upload macOS Artifacts uses: actions/upload-artifact@v5 with: name: macos-${{ inputs.plugin_name || 'ModularRandomizer' }} path: | - plugins/${{ inputs.plugin_name || 'ModularRandomizer' }}/dist/*.pkg + plugins/${{ inputs.plugin_name || 'ModularRandomizer' }}/dist/* retention-days: 30 # ============================================ @@ -378,8 +481,8 @@ jobs: ## ${{ inputs.plugin_name || 'ModularRandomizer' }} ${{ github.ref_name }} ### Downloads - - **Windows**: Run the Setup installer (VST3 + Standalone) - - **macOS**: Open the .pkg installer (VST3 + AU + Standalone) + - **Windows**: Run the Setup installer, or use the Portable ZIP (extract and copy VST3 folder to `C:\Program Files\Common Files\VST3\`) + - **macOS**: Open the .pkg installer, or use the Portable ZIP (copy .vst3 to `/Library/Audio/Plug-Ins/VST3/`) - **Linux**: Extract tar.gz, copy VST3 to ~/.vst3/ ### System Requirements diff --git a/plugins/ModularRandomizer/Source/ui/public/css/header.css b/plugins/ModularRandomizer/Source/ui/public/css/header.css index aeb81e7..7d07f1e 100644 --- a/plugins/ModularRandomizer/Source/ui/public/css/header.css +++ b/plugins/ModularRandomizer/Source/ui/public/css/header.css @@ -75,7 +75,95 @@ .bypass.on { background: var(--accent); border-color: var(--accent); - color: var(--fire-text) + color: var(--fire-text); + box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 50%, transparent), + 0 0 20px color-mix(in srgb, var(--accent) 25%, transparent); + animation: bypass-pulse 1.5s ease-in-out infinite; +} + +@keyframes bypass-pulse { + + 0%, + 100% { + box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent); + } + + 50% { + box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 70%, transparent), 0 0 28px color-mix(in srgb, var(--accent) 30%, transparent); + } +} + +/* When bypass is on, dim the entire rack area */ +.app.bypassed .rack { + opacity: 0.35; + pointer-events: none; + filter: grayscale(40%); + transition: opacity .25s, filter .25s; +} + +.app.bypassed .status { + opacity: 0.5; +} + +/* Routing area in header */ +.routing-area { + display: flex; + align-items: center; + gap: 6px; +} + +.routing-label { + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .5px; + white-space: nowrap; +} + +.routing-select { + background: var(--bg-cell); + border: 1px solid var(--border); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 3px; + cursor: pointer; + outline: none; + transition: border-color .15s; +} + +.routing-select:hover { + border-color: var(--border-focus); +} + +.routing-select:focus { + border-color: var(--accent); +} + +.routing-select option { + background: var(--bg-panel); + color: var(--text-primary); +} + +.routing-select.weq-active { + border-color: var(--accent); + color: var(--accent); + animation: routing-glow 2.5s ease-in-out infinite; +} + +@keyframes routing-glow { + + 0%, + 100% { + box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent); + } + + 50% { + box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 55%, transparent), + 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); + } } .mix-area { diff --git a/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css b/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css index fde573c..8d07251 100644 --- a/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css +++ b/plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css @@ -2397,7 +2397,7 @@ select.lane-morph-knob { .lane-canvas-wrap { flex: 1; position: relative; - background: var(--bg-inset); + background: var(--canvas-bg, var(--bg-inset)); min-width: 0; overflow: hidden; } diff --git a/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css b/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css index f418e90..3bc6d8e 100644 --- a/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css +++ b/plugins/ModularRandomizer/Source/ui/public/css/wrongeq.css @@ -24,8 +24,8 @@ left: 50%; top: 50%; transform: translate(-50%, -50%); - background: var(--bg-panel); - border: 1px solid var(--border-strong); + background: var(--weq-panel-bg, var(--bg-panel)); + border: 1px solid var(--weq-panel-border, var(--border-strong)); border-radius: 10px; box-shadow: 0 16px 64px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.03); width: min(97vw, 1180px); @@ -62,18 +62,19 @@ font-weight: 700 !important; letter-spacing: 0.06em; border-color: var(--accent) !important; - animation: weqPulse 2s ease-in-out infinite; + animation: weqBtnPulse 2s ease-in-out infinite; } -@keyframes weqPulse { +@keyframes weqBtnPulse { 0%, 100% { - box-shadow: 0 0 0 0 var(--accent); + box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 30%, transparent); } 50% { - box-shadow: 0 0 8px 0 var(--accent); + box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 60%, transparent), + 0 0 28px color-mix(in srgb, var(--accent) 25%, transparent); } } @@ -83,7 +84,7 @@ align-items: center; justify-content: space-between; padding: 12px 16px; - background: var(--bg-inset); + background: var(--weq-side-bg, var(--bg-inset)); border-bottom: 1px solid var(--border); border-radius: 8px 8px 0 0; cursor: move; @@ -268,7 +269,7 @@ gap: 4px; padding: 4px 8px; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - background: var(--bg-panel); + background: var(--weq-panel-bg, var(--bg-panel)); flex-wrap: wrap; } @@ -335,7 +336,7 @@ position: relative; flex: 1; min-height: 320px; - background: var(--bg-inset); + background: var(--canvas-bg, var(--bg-inset)); overflow: hidden; cursor: crosshair; } @@ -404,7 +405,7 @@ width: 210px; flex-shrink: 0; border-left: 1px solid var(--border); - background: color-mix(in srgb, var(--bg-inset) 60%, #000); + background: color-mix(in srgb, var(--weq-side-bg, var(--bg-inset)) 60%, #000); overflow-y: auto; overflow-x: hidden; display: flex; @@ -423,7 +424,7 @@ } .weq-sp-section { - background: var(--bg-cell); + background: var(--weq-cell-bg, var(--bg-cell)); border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); border-radius: 6px; padding: 10px 10px 8px; @@ -452,7 +453,7 @@ font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); - background: var(--bg-inset); + background: var(--weq-side-bg, var(--bg-inset)); border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); border-radius: 4px; padding: 4px 8px; @@ -467,13 +468,13 @@ .weq-sp-knob:hover { color: var(--text-primary); border-color: var(--accent); - background: var(--bg-cell-hover); + background: var(--weq-cell-hover, var(--bg-cell-hover)); } .weq-sp-knob.weq-anim-on { color: var(--accent); border-color: var(--accent); - animation: weqPulse 1.5s ease-in-out infinite; + animation: weqKnobPulse 1.5s ease-in-out infinite; } .weq-sp-label { @@ -533,7 +534,7 @@ } .weq-sp-toggle.on.weq-anim-on { - animation: weqPulse 1.5s ease-in-out infinite; + animation: weqKnobPulse 1.5s ease-in-out infinite; } .weq-ft-group { @@ -570,7 +571,7 @@ font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); - background: var(--bg-cell); + background: var(--weq-cell-bg, var(--bg-cell)); border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); border-radius: 4px; padding: 3px 7px; @@ -583,25 +584,25 @@ .weq-ft-knob:hover { color: var(--text-primary); border-color: var(--accent); - background: var(--bg-cell-hover); + background: var(--weq-cell-hover, var(--bg-cell-hover)); } /* Animated knob indicator — glowing pulse */ .weq-ft-knob.weq-anim-on { color: var(--accent); border-color: var(--accent); - animation: weqPulse 1.5s ease-in-out infinite; + animation: weqKnobPulse 1.5s ease-in-out infinite; } -@keyframes weqPulse { +@keyframes weqKnobPulse { 0%, 100% { - box-shadow: 0 0 2px rgba(45, 107, 63, 0.3); + box-shadow: 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } 50% { - box-shadow: 0 0 8px rgba(45, 107, 63, 0.6); + box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent); } } @@ -615,7 +616,7 @@ font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); - background: var(--bg-cell); + background: var(--weq-cell-bg, var(--bg-cell)); border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); border-radius: 4px; padding: 3px 6px; diff --git a/plugins/ModularRandomizer/Source/ui/public/index.html b/plugins/ModularRandomizer/Source/ui/public/index.html index c047c75..75b79c9 100644 --- a/plugins/ModularRandomizer/Source/ui/public/index.html +++ b/plugins/ModularRandomizer/Source/ui/public/index.html @@ -35,6 +35,15 @@
+
+ + +
+
@@ -88,14 +97,6 @@ style="width:56px;padding:2px 4px;border:1px solid var(--border);border-radius:3px;background:var(--bg-inset);color:var(--text-primary);font-size:11px;text-align:center">
-
-
Plugin Routing
-
- - - -
-
VST3 Scan Paths
diff --git a/plugins/ModularRandomizer/Source/ui/public/js/controls.js b/plugins/ModularRandomizer/Source/ui/public/js/controls.js index 6a29ed3..6ef0ac6 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/controls.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/controls.js @@ -5,6 +5,8 @@ // Bypass button — connected to JUCE BYPASS toggle relay document.getElementById('bypassBtn').onclick = function () { this.classList.toggle('on'); + var isOn = this.classList.contains('on'); + document.querySelector('.app').classList.toggle('bypassed', isOn); // Send to JUCE backend if available if (window.__JUCE__ && window.__JUCE__.getToggleState) { try { @@ -61,23 +63,21 @@ document.getElementById('internalBpmInput').onchange = function () { syncBlocksToHost(); saveUiStateToHost(); }; -// Plugin Routing mode toggle -document.querySelectorAll('.routing-btn').forEach(function (btn) { - btn.onclick = function () { - var mode = parseInt(btn.dataset.rmode); - routingMode = mode; - document.querySelectorAll('.routing-btn').forEach(function (b) { b.classList.toggle('on', parseInt(b.dataset.rmode) === mode); }); - if (window.__JUCE__ && window.__JUCE__.backend) { - var fn = window.__juceGetNativeFunction('setRoutingMode'); - fn(mode); - } - // Show/hide WrongEQ button - if (typeof weqSetVisible === 'function') weqSetVisible(mode === 2); - // Sync EQ state to C++ when entering WrongEQ mode - if (mode === 2 && typeof weqSyncToHost === 'function') weqSyncToHost(); - renderAllPlugins(); saveUiStateToHost(); - }; -}); +// Plugin Routing mode toggle (dropdown) +document.getElementById('routingSelect').onchange = function () { + var mode = parseInt(this.value); + routingMode = mode; + this.classList.toggle('weq-active', mode === 2); + if (window.__JUCE__ && window.__JUCE__.backend) { + var fn = window.__juceGetNativeFunction('setRoutingMode'); + fn(mode); + } + // Show/hide WrongEQ button + if (typeof weqSetVisible === 'function') weqSetVisible(mode === 2); + // Sync EQ state to C++ when entering WrongEQ mode + if (mode === 2 && typeof weqSyncToHost === 'function') weqSyncToHost(); + renderAllPlugins(); saveUiStateToHost(); +}; document.getElementById('addRnd').onclick = function () { addBlock('randomize'); }; document.getElementById('addEnv').onclick = function () { addBlock('envelope'); }; document.getElementById('addSmp').onclick = function () { addBlock('sample'); }; diff --git a/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js b/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js index 403da84..f30dbba 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/help_panel.js @@ -469,12 +469,12 @@ 'Collapse plugins in the rack when not editing \u2014 saves polling CPU.' ]) + - heading('Parallel Routing') + + heading('Routing Modes') + + para('Use the Routing Mode dropdown in the header bar to switch between plugin routing modes:') + bullet([ - 'Switch to Parallel mode in Settings to route plugins into separate buses.', - 'Each bus has independent Volume, Mute, and Solo controls.', - 'Drag plugins between buses, or use the bus selector dropdown on each plugin card.', - 'Buses are mixed together at the output with unity gain.' + 'Sequential: all plugins process in series (one after another). Simple and familiar.', + 'Parallel: plugins are grouped into buses with independent volume, mute, and solo. Buses are mixed at the output.', + 'WrongEQ: multiband frequency-split routing \u2014 each EQ point acts as a crossover, splitting audio into independent bands with per-band plugin chains.' ]) + heading('DAW Integration') + diff --git a/plugins/ModularRandomizer/Source/ui/public/js/persistence.js b/plugins/ModularRandomizer/Source/ui/public/js/persistence.js index ecd6766..57a65f1 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/persistence.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/persistence.js @@ -332,9 +332,8 @@ function restoreFromHost() { // Restore routing mode if (saved && saved.routingMode !== undefined) { routingMode = saved.routingMode; - document.querySelectorAll('.routing-btn').forEach(function (b) { - b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); - }); + document.getElementById('routingSelect').value = routingMode; + document.getElementById('routingSelect').classList.toggle('weq-active', routingMode === 2); if (window.__JUCE__ && window.__JUCE__.backend) { var rmFn = window.__juceGetNativeFunction('setRoutingMode'); rmFn(routingMode); @@ -410,9 +409,8 @@ function restoreFromHost() { // Restore also from getFullState response if (result.routingMode !== undefined && !(saved && saved.routingMode !== undefined)) { routingMode = result.routingMode; - document.querySelectorAll('.routing-btn').forEach(function (b) { - b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); - }); + document.getElementById('routingSelect').value = routingMode; + document.getElementById('routingSelect').classList.toggle('weq-active', routingMode === 2); } // Restore bus assignments if (saved && saved.pluginBuses) { diff --git a/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js b/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js index 7c29cf8..6d73ae3 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/preset_system.js @@ -1430,10 +1430,9 @@ function applyGlobalPreset(data, presetName) { routingMode = data.routingMode; var setRoutFn = (window.__JUCE__ && window.__JUCE__.backend) ? window.__juceGetNativeFunction('setRoutingMode') : null; if (setRoutFn) setRoutFn(routingMode); - // Update routing toggle buttons - document.querySelectorAll('.routing-btn').forEach(function (b) { - b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); - }); + // Update routing dropdown + document.getElementById('routingSelect').value = routingMode; + document.getElementById('routingSelect').classList.toggle('weq-active', routingMode === 2); if (typeof weqSetVisible === 'function') weqSetVisible(routingMode === 2); } @@ -1561,10 +1560,9 @@ function applyGlobalPreset(data, presetName) { var setRmFn = window.__juceGetNativeFunction('setRoutingMode'); setRmFn(routingMode); } - // Update routing toggle buttons - document.querySelectorAll('.routing-btn').forEach(function (b) { - b.classList.toggle('on', parseInt(b.dataset.rmode) === routingMode); - }); + // Update routing dropdown + document.getElementById('routingSelect').value = routingMode; + document.getElementById('routingSelect').classList.toggle('weq-active', routingMode === 2); // Show/hide WrongEQ panel if (typeof weqSetVisible === 'function') weqSetVisible(routingMode === 2); // Sync EQ state when entering WrongEQ mode diff --git a/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js b/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js index 9dbe3d3..8fb12c5 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/theme_system.js @@ -438,11 +438,12 @@ var THEMES = { '--snap-ring-color': '#1F5FA6', '--snap-ring-opacity': '0.45', - '--lane-color': '#1A6E5A', - '--lane-grid': 'rgba(10,10,10,0.08)', - '--lane-grid-label': 'rgba(10,10,10,0.20)', - '--lane-playhead': 'rgba(10,10,10,0.60)', - '--lane-active': '#1F5FA6', + '--lane-color': '#4A8AB0', + // Lane grid overrides for dark canvas bg + '--lane-grid': 'rgba(255,255,255,0.07)', + '--lane-grid-label': 'rgba(255,255,255,0.20)', + '--lane-playhead': 'rgba(255,255,255,0.70)', + '--lane-active': '#4A9ED0', '--range-arc': '#E87040', @@ -459,7 +460,22 @@ var THEMES = { '--toast-info-bg': 'linear-gradient(135deg,#E0E8F4,#D4DCE8)', '--toast-info-border': '#1F5FA6', '--toast-text': '#0A0A0A', '--preset-flash-color': '#1F5FA6', '--preset-flash-glow': 'rgba(31,95,166,0.4)', - '--drag-highlight': '#1F5FA6' + '--drag-highlight': '#1F5FA6', + + // ── CANVAS OVERRIDE (keep canvases dark in light theme) ── + '--canvas-bg': '#1A1A20', + '--canvas-text': '#C0C0CC', + '--canvas-grid': 'rgba(255,255,255,0.08)', + '--canvas-grid-label': 'rgba(255,255,255,0.22)', + + // ── WRONGEQ OVERRIDES (dark panels in light theme) ── + '--weq-panel-bg': '#1E1E24', + '--weq-panel-border': '#3A3A48', + '--weq-panel-text': '#C8C8D8', + '--weq-panel-muted': '#808090', + '--weq-side-bg': '#141418', + '--weq-cell-bg': '#282830', + '--weq-cell-hover': '#32323C' }, bcolors: [ '#1F5FA6', // steel blue — rand @@ -1038,11 +1054,12 @@ var THEMES = { '--snap-ring-opacity': '0.60', // ── LANE / AUTOMATION ───────────────────────────────────── - '--lane-color': '#000080', - '--lane-grid': 'rgba(0,0,0,0.08)', - '--lane-grid-label': 'rgba(0,0,0,0.30)', - '--lane-playhead': 'rgba(0,0,0,0.80)', - '--lane-active': '#008000', + '--lane-color': '#4444FF', + // Lane grid overrides for dark canvas bg + '--lane-grid': 'rgba(255,255,255,0.07)', + '--lane-grid-label': 'rgba(255,255,255,0.22)', + '--lane-playhead': 'rgba(255,255,255,0.70)', + '--lane-active': '#00CC00', // ── RANGE ARC ───────────────────────────────────────────── '--range-arc': '#0000FF', @@ -1069,6 +1086,21 @@ var THEMES = { '--preset-flash-color': '#000080', '--preset-flash-glow': 'rgba(0,0,128,0.35)', + + // ── CANVAS OVERRIDE (keep canvases dark in light theme) ── + '--canvas-bg': '#1A1A2C', + '--canvas-text': '#B0B0CC', + '--canvas-grid': 'rgba(255,255,255,0.08)', + '--canvas-grid-label': 'rgba(255,255,255,0.22)', + + // ── WRONGEQ OVERRIDES (dark panels in light theme) ── + '--weq-panel-bg': '#1C1C2A', + '--weq-panel-border': '#3A3A58', + '--weq-panel-text': '#C0C0D8', + '--weq-panel-muted': '#808098', + '--weq-side-bg': '#12121E', + '--weq-cell-bg': '#282840', + '--weq-cell-hover': '#34344C' }, bcolors: [ diff --git a/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js index a5e9790..e31d2ac 100644 --- a/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js +++ b/plugins/ModularRandomizer/Source/ui/public/js/wrongeq_canvas.js @@ -1372,7 +1372,7 @@ function weqDrawCanvas() { ctx.clearRect(0, 0, W, H); try { // Background - ctx.fillStyle = weqCssVar('--bg-inset', '#1a1a20'); + ctx.fillStyle = weqCssVar('--canvas-bg', '') || weqCssVar('--bg-inset', '#1a1a20'); ctx.fillRect(0, 0, W, H); // Bypass overlay: dim the whole canvas From 6465c0f24d6a2f569eed004cf1589e6c1b51a022 Mon Sep 17 00:00:00 2001 From: petrov Date: Tue, 10 Mar 2026 22:28:26 +0100 Subject: [PATCH 13/14] fix: clean up staging folders in CI artifacts, update ship workflow remote --- .agent/workflows/ship.md | 6 +++--- .github/workflows/build-release.yml | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.agent/workflows/ship.md b/.agent/workflows/ship.md index 24595a8..64f5e95 100644 --- a/.agent/workflows/ship.md +++ b/.agent/workflows/ship.md @@ -13,7 +13,7 @@ Push the latest ModularRandomizer code to the CI repo and trigger a cross-platfo // turbo 2. Stage all ModularRandomizer changes in the parent repo: ```powershell -git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" add plugins/ModularRandomizer +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" add plugins/ModularRandomizer .github .agent ``` 3. Commit with the user's description: @@ -23,13 +23,13 @@ git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" commi 4. Push to the fork: ```powershell -git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push fork main +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push origin main ``` 5. Tag the release and push the tag (this auto-triggers the CI build): ```powershell git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" tag v{VERSION} -git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push fork v{VERSION} +git -C "c:\Users\dpetr\Desktop\Juce project\noizefield\audio-plugin-coder" push origin v{VERSION} ``` 6. Report success and provide the link: diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e5f6197..a8ce3a8 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -122,6 +122,7 @@ jobs: Copy-Item "plugins/$plugin/installer/LICENSE.txt" "$stagingDir\" } Compress-Archive -Path "$stagingDir\*" -DestinationPath "$distDir\${plugin}-${version}-Windows.zip" + Remove-Item -Path $stagingDir -Recurse -Force } - name: Create portable ZIP @@ -170,6 +171,7 @@ jobs: "@ | Out-File -FilePath "$zipDir/README.txt" -Encoding utf8 Compress-Archive -Path "$zipDir/*" -DestinationPath "$distDir/${plugin}-${version}-Windows-Portable.zip" + Remove-Item -Path $zipDir -Recurse -Force Write-Host "Portable ZIP created" -ForegroundColor Green - name: Upload Windows Artifacts @@ -290,6 +292,7 @@ jobs: "$DIST_DIR/${PLUGIN}-${VERSION}-macOS.pkg" echo "macOS installer created: $DIST_DIR/${PLUGIN}-${VERSION}-macOS.pkg" + rm -rf "$STAGING" ls -la "$DIST_DIR/" - name: Create portable ZIP @@ -345,6 +348,7 @@ jobs: # Create ZIP cd "$DIST_DIR" zip -r "${PLUGIN}-${VERSION}-macOS-Portable.zip" "${PLUGIN}-${VERSION}-macOS-Portable/" + rm -rf "${PLUGIN}-${VERSION}-macOS-Portable" echo "macOS portable ZIP created" - name: Upload macOS Artifacts @@ -429,6 +433,7 @@ jobs: # Create tar.gz cd "$DIST_DIR" tar -czf "${PLUGIN}-${VERSION}-Linux.tar.gz" "${PLUGIN}-${VERSION}-Linux/" + rm -rf "${PLUGIN}-${VERSION}-Linux" echo "Linux package created" ls -la From ca83ee11c56ef5eeca648a42b4c3a5acc8693448 Mon Sep 17 00:00:00 2001 From: petrov Date: Wed, 11 Mar 2026 09:35:19 +0100 Subject: [PATCH 14/14] new block workflow --- .agent/workflows/new-block.md | 308 ++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 .agent/workflows/new-block.md diff --git a/.agent/workflows/new-block.md b/.agent/workflows/new-block.md new file mode 100644 index 0000000..69b8cf6 --- /dev/null +++ b/.agent/workflows/new-block.md @@ -0,0 +1,308 @@ +--- +description: Add a new Logic Block type to the ModularRandomizer plugin (UI + DSP + wiring) +--- + +# New Logic Block Workflow + +This workflow adds a new Logic Block type to the ModularRandomizer plugin. Follow every step exactly. +Missing ANY step will cause silent failures (block won't automate, won't animate, won't persist correctly). + +## Prerequisites + +The user must provide: +- **Mode name** — the internal `mode` string (e.g. `"step_seq"`, `"gravity"`, `"macro"`) +- **Display label** — what the user sees in the UI (e.g. `"Step Sequencer"`, `"Gravity"`, `"Macro Knob"`) +- **Block type** — `continuous` (like Shapes/Envelope: outputs a stream) or `triggered` (like Randomize: fires on events) +- **Brief description** — what it does musically + +--- + +## PART 1: JavaScript UI (7 files to touch) + +### Step 1: Add the "+" button in index.html + +**File:** `plugins/ModularRandomizer/Source/ui/public/index.html` +**Location:** Inside `
` (lines 135–151) + +Add a new button entry before the closing `
`: + +```html + +``` + +### Step 2: Wire the "+" button + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/controls.js` +**Location:** After line 87 (existing `addBlock` handlers at lines 81–87) + +```javascript +document.getElementById('add{PascalName}').onclick = function () { addBlock('{mode_name}'); }; +``` + +**Also check line 285:** There's a keyboard shortcut guard `if (b.mode === 'lane') return;` — decide if your block needs a similar guard for the R key (randomize all targets). + +### Step 3: Add default block state + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js` +**Location:** Inside `addBlock()` function (lines 120–133) + +If your block has custom fields, add them as defaults. If it only uses existing fields (trigger, polarity, speed, etc.), skip this — they already exist. + +### Step 4: Write the render function + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js` +**Location:** After the last render function (~line 935, after `renderShapesRangeBody`) + +Create `render{PascalName}Body(b)` — must accept `(b)` and return HTML string. + +**Available helpers:** +- `buildBlockKnob(val, min, max, size, mode, field, blockId, label, unit)` — SVG arc knob +- `buildKnobRow(html)` — horizontal knob layout +- `renderBeatDivSelect(blockId, field, currentVal)` — tempo division dropdown +- `buildDetectionBandSection(b, mode)` — LP/HP/BP audio selector +- `buildShapeOptions(field, b)` — shape type selector (15 shapes) +- Segmented buttons: `
...
` +- Toggles: `
` + +### Step 5: Register in buildBlockCard() — 5 SUB-STEPS + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js` + +**5a. Mode CSS class** (line 150) — add to the ternary chain: +```javascript +// Current pattern ends with: (b.mode === 'lane' ? ' mode-lane' : ' mode-smp') +// Insert your mode BEFORE the final fallback ' mode-smp' +``` + +**5b. Active highlight class** (line 151) — add: +```javascript ++ (b.mode === '{mode_name}' && isAct ? ' {mode}-active' : '') +``` + +**5c. Summary text** (lines 153–158) — add an `else if`: +```javascript +else if (b.mode === '{mode_name}') { sum = '{Label} / ' + someInfo; } +``` + +**5d. Mode button** (line 188) — add your button to the `
`: +```html + +``` + +**5e. Body dispatch** (line 190) — add to the if/else chain: +```javascript +else if (b.mode === '{mode_name}') bH += render{PascalName}Body(b); +``` + +### Step 6: Register modulation arc (continuous blocks ONLY) + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/plugin_rack.js` +**Location:** Inside `MOD_ARC_REGISTRY` (lines 484–600) + +This gives animated modulation arcs on param knobs for FREE: + +```javascript +{mode_name}: { + getDepth: function(b, pid) { return b.myDepth / 100; }, + getPolarity: function(b) { return b.polarity || 'bipolar'; }, + getOutput: function(b, pid) { return b.myReadbackValue || 0; }, + outputType: 'bipolar' // 'bipolar' (-1..1), 'unipolar' (0..1), or 'absolute' (0..1) +}, +``` + +**Also check:** If your block needs per-param state when a target is assigned (like shapes_range does at line 1351), add initialization logic to the assign handler in `plugin_rack.js`: +```javascript +if (b.mode === '{mode_name}') { /* init per-param state */ } +``` + +**Also check line 727 `updateModBases()`:** If your block tracks per-param base values, add a branch here. + +### Step 7: Sync to host — mode-specific fields + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/logic_blocks.js` +**Location:** Inside `syncBlocksToHost()` (lines 2580–2634) + +**IMPORTANT:** This function uses conditional blocks per mode. Generic fields (trigger, rMax, etc.) are already sent. But mode-specific fields MUST be wrapped in a conditional: + +```javascript +if (b.mode === '{mode_name}') { + obj.myCustomField = b.myCustomField; + obj.anotherField = (b.anotherField || 50) / 100; +} +``` + +Look at the existing patterns: `morph_pad` (line 2580), `shapes` (line 2610), `lane` (line 2635). + +### Step 8: Register in BLOCK_EXPOSABLE_PARAMS ⚠️ EASY TO MISS + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/expose_system.js` +**Location:** Inside `BLOCK_EXPOSABLE_PARAMS` (lines 18–70) + +Add an entry defining which params DAW automation can control: + +```javascript +{mode_name}: [ + { key: 'mySpeed', label: 'Speed', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'myDepth', label: 'Depth', type: 'float', min: 0, max: 100, suffix: '%' }, + { key: 'enabled', label: 'Enabled', type: 'bool' } +], +``` + +**Without this, the block's params WON'T appear in the Expose to DAW dropdown and can't be automated.** + +### Step 9: Add realtime readback handler ⚠️ EASY TO MISS + +**File:** `plugins/ModularRandomizer/Source/ui/public/js/realtime.js` +**Location:** Inside `setupRtDataListener()` — look at existing mode-specific handlers: +- Line 214: Envelope reads `envLevels` +- Line 351: Morph pad reads `morphHeads` +- Line 413: Shapes reads `shapeHeads` +- Line 484: Lane reads `laneHeads` + +For continuous blocks, add a handler that reads the C++ readback data and writes it to the block object (e.g., `b.myModOutput = readbackValue`). This is what makes the modulation arcs animate and meters move. + +For triggered blocks, check line ~200 area where trigger flash events are consumed — your block already participates via `triggerFifo` without extra code. + +### Step 10: CSS styles + +**File:** `plugins/ModularRandomizer/Source/ui/public/css/variables.css` — add `--{mode}-color: #HEX;` + +**File:** `plugins/ModularRandomizer/Source/ui/public/css/logic_blocks.css` — add: + +```css +.lcard.mode-{mode} .lhead { border-left-color: var(--{mode}-color); } +.lcard.mode-{mode}.active .lhead { background: color-mix(in srgb, var(--{mode}-color) 15%, var(--bg-card)); } +.lcard.mode-{mode} .block-section-label { color: var(--{mode}-color); } +``` + +--- + +## PART 2: C++ Backend (3 files to touch) + +### Step 11: Add BlockMode enum + +**File:** `plugins/ModularRandomizer/Source/PluginProcessor.h` +**Location:** Line 916 + +```cpp +enum class BlockMode : uint8_t { Randomize, Envelope, Sample, MorphPad, Shapes, ShapesRange, Lane, {PascalName}, Unknown }; +``` + +### Step 12: Add mode parser + +**File:** `plugins/ModularRandomizer/Source/PluginProcessor.cpp` +**Location:** Inside `parseBlockMode()` (lines 20–28) + +```cpp +if (s == "{mode_name}") return BlockMode::{PascalName}; +``` + +### Step 13: Add runtime state to LogicBlock struct (if needed) + +**File:** `plugins/ModularRandomizer/Source/PluginProcessor.h` +**Location:** Inside `struct LogicBlock` (~line 963) + +```cpp +// ── {Display Label} ── +float myPhase = 0.0f; +float mySmoothedValue = 0.0f; +``` + +### Step 14: Parse custom fields in updateLogicBlocks() + +**File:** `plugins/ModularRandomizer/Source/PluginProcessor.cpp` +**Location:** Inside `updateLogicBlocks()`, after the Shapes Block fields section (~line 577) + +```cpp +// ── {Display Label} Block fields ── +lb.myField = (float)(double) obj->getProperty("myField"); +``` + +### Step 15: Implement audio processing + +**File:** `plugins/ModularRandomizer/Source/ProcessBlock.cpp` +**Location:** Inside the main block loop (~line 458), after the last mode case + +```cpp +// ===== {DISPLAY_LABEL} MODE ===== +else if (lb.modeE == BlockMode::{PascalName}) +{ + // YOUR DSP LOGIC HERE +} +``` + +**Available audio thread helpers:** +| Function | Purpose | +|---|---| +| `checkTrigger(lb)` | MIDI/tempo/audio trigger detection | +| `getFilteredAudioLevel(lb)` | Band-filtered RMS | +| `computeShapeXY(shape, t, R)` | 2D shape geometry (15 shapes) | +| `addModOffset(pluginId, paramIndex, offset)` | Continuous modulation (summed in modbus) | +| `setParamDirect(pluginId, paramIndex, value)` | Immediate param set (triggered blocks) | +| `updateParamBase(pluginId, paramIndex, value)` | Update base value | +| `glidePool[]` / `numActiveGlides` | Smooth glide transitions | +| `envReadback[idx]` | Write readback for UI meters | +| `triggerFifo.write(1)` | Notify UI of trigger flash | + +**Audio thread rules:** ZERO heap allocations. No `new`, no `std::string`, no `push_back`. Use pre-allocated arrays. All float math. + +### Step 16: Write C++ readback data (continuous blocks) + +**File:** `plugins/ModularRandomizer/Source/ProcessBlock.cpp` (inside your case) + +```cpp +if (envIdx < maxEnvReadback) { + envReadback[envIdx].blockId.store(lb.id); + envReadback[envIdx].level.store(outputLevel); + envIdx++; +} +``` + +OR define a new readback channel if the existing ones (envReadback, morphReadback, shapeReadback, laneReadback) don't fit your data shape. + +--- + +## PART 3: Verification Checklist + +After implementing all steps, verify: + +- [ ] Click "+" button — block appears +- [ ] Mode buttons in card — switching works, body renders correctly +- [ ] Assign params — targets appear in target list +- [ ] Modulation arcs on param knobs animate (continuous blocks) +- [ ] Expose dropdown shows block params under Logic Blocks section +- [ ] DAW automation of exposed block params works (two-way) +- [ ] Save/reload project — block state preserved +- [ ] Save/load preset — block included +- [ ] Undo/redo (Ctrl+Z/Y) — block changes roll back correctly +- [ ] Duplicate block (right-click header) — deep copy works +- [ ] Delete block — clean removal + +--- + +## What IS Automatic (No Changes Needed) + +| System | Why | +|---|---| +| **Core persistence** | `saveUiStateToHost()` serializes all block fields via JSON | +| **Basic preset save/load** | Saves full `blocks` array (but check for mode-specific branches) | +| **Undo/Redo** | `pushUndoSnapshot()` captures full block state | +| **Target assignment** | Drag-drop and Assign mode work generically | +| **Color system** | Auto-assigns from `LANE_COLORS` palette | +| **Block duplication** | Deep clone via context menu copies any block | +| **Block deletion** | Generic handler removes any block | +| **Trigger flash** | Uses shared `triggerFifo` — already wired for all modes | + +## What is NOT Automatic (Mode-Specific Branches Exist) + +| System | File | What to check | +|---|---|---| +| **Expose to DAW** | `expose_system.js` | Must add `BLOCK_EXPOSABLE_PARAMS` entry | +| **Realtime readback** | `realtime.js` | Must add handler for mode's data channel | +| **Target assignment init** | `plugin_rack.js:1351` | May need per-param state init | +| **Mod base tracking** | `plugin_rack.js:727` | May need `updateModBases` branch | +| **Sync to host** | `logic_blocks.js:2580` | Mode-specific fields need conditional block | +| **Post-restore hooks** | `persistence.js:545` | May need canvas/init call after restore | +| **Preset mode branches** | `preset_system.js:1020,1579` | May need mode-specific save/load logic | +| **Keyboard guards** | `controls.js:285` | May need mode guard for R key |