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 |
diff --git a/.agent/workflows/ship.md b/.agent/workflows/ship.md
index 1f37776..64f5e95 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 .github .agent
```
-**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 origin 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 origin 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)
```
-
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index e9ddb9f..a8ce3a8 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,20 +23,28 @@ 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
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
submodules: recursive
fetch-depth: 0
@@ -43,44 +52,152 @@ jobs:
- name: Setup MSVC
uses: ilammy/msvc-dev-cmd@v1
- - name: Configure CMake
+ - name: Install WebView2 SDK
+ shell: pwsh
run: |
- cmake -S . -B build -G "Visual Studio 17 2022" -A x64
+ # 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
- 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: |
- cmake --build build --config Release --target "${{ inputs.plugin_name }}_Standalone"
+ choco install innosetup -y --no-progress
+ Write-Host "Inno Setup installed" -ForegroundColor Green
+
+ - name: Build Windows Installer
+ shell: pwsh
+ run: |
+ $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"
+ Remove-Item -Path $stagingDir -Recurse -Force
+ }
+
+ - 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"
+ Remove-Item -Path $zipDir -Recurse -Force
+ Write-Host "Portable ZIP created" -ForegroundColor Green
- name: Upload Windows Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
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
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
submodules: recursive
fetch-depth: 0
- - name: Install Dependencies
- run: |
- brew install cmake || true
-
- name: Configure CMake
run: |
cmake -S . -B build -G Xcode \
@@ -88,36 +205,172 @@ 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"
+ rm -rf "$STAGING"
+ 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/"
+ rm -rf "${PLUGIN}-${VERSION}-macOS-Portable"
+ echo "macOS portable ZIP created"
- name: Upload macOS Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
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/*
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
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
submodules: recursive
fetch-depth: 0
@@ -137,119 +390,116 @@ jobs:
libxinerama-dev \
libxrandr-dev \
libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
libjack-jackd2-dev \
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/"
+ rm -rf "${PLUGIN}-${VERSION}-Linux"
+
+ echo "Linux package created"
+ ls -la
- name: Upload Linux Artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
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]
- 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
steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- name: Download All Artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
- path: artifacts
- pattern: "*-${{ inputs.plugin_name }}"
+ path: release-artifacts
+ merge-multiple: false
- - 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 ../..
+ if [ -d "release-artifacts" ]; then
+ find release-artifacts -type f
+ else
+ echo "No artifacts directory found"
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/
- name: Create GitHub Release
- if: github.event_name == 'push'
- uses: softprops/action-gh-release@v1
+ if: github.event_name == 'push' && hashFiles('release-artifacts/**/*') != ''
+ uses: softprops/action-gh-release@v2
with:
- files: release/*
+ files: release-artifacts/**/*
generate_release_notes: true
body: |
- ## ${{ inputs.plugin_name }} Release
+ ## ${{ inputs.plugin_name || 'ModularRandomizer' }} ${{ github.ref_name }}
- ### 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
-
- ### Supported Formats
- - VST3 (All platforms)
- - AU (macOS only)
- - LV2 (Linux only)
- - Standalone (All platforms)
+ ### Downloads
+ - **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
- - **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
+ uses: actions/upload-artifact@v5
with:
- name: release-${{ inputs.plugin_name }}
- path: release/*
+ name: release-all-platforms
+ path: release-artifacts/
retention-days: 30
+
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..3293af7
--- /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://dimitarp.com/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 "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 DmPt
+ PLUGIN_CODE MdRn
+ FORMATS ${PLUGIN_FORMATS}
+ PRODUCT_NAME "ModularRandomizer"
+ BUNDLE_ID "com.dimitarp.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 0000000..301aee4
Binary files /dev/null and b/plugins/ModularRandomizer/Design/04 Female Vocal A.vstpreset differ
diff --git a/plugins/ModularRandomizer/Design/ModularRandomizer_Audit_v2.md b/plugins/ModularRandomizer/Design/ModularRandomizer_Audit_v2.md
new file mode 100644
index 0000000..8981c3c
--- /dev/null
+++ b/plugins/ModularRandomizer/Design/ModularRandomizer_Audit_v2.md
@@ -0,0 +1,334 @@
+# ModularRandomizer — Codebase Audit (Current Source)
+
+---
+
+## First: What You Already Fixed Correctly
+
+Reading `ProcessBlock.cpp`, the most important line in your whole codebase is this comment at line 129:
+
+> *"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
+
+
+
+