Skip to content

feat: VST3 sprint — GUI editor, MIDI routing, PluginEngine, categories#893

Merged
ChuxiJ merged 36 commits intomainfrom
claude/modest-germain
Mar 25, 2026
Merged

feat: VST3 sprint — GUI editor, MIDI routing, PluginEngine, categories#893
ChuxiJ merged 36 commits intomainfrom
claude/modest-germain

Conversation

@ChuxiJ
Copy link

@ChuxiJ ChuxiJ commented Mar 25, 2026

Summary

Consolidation PR for the VST3 sprint second wave:

  • GUI Editor: GuiManager + CocoaBackend for native VST3 plugin windows via IPlugView
  • MIDI Routing: Piano roll → pluginEngine.noteOn/noteOff for VST3 instruments
  • PluginEngine Integration: loadPlugin creates VST3PluginAdapter and wires audio graph
  • Category Display: Split "Fx|EQ" into category + subcategory
  • Protocol fixes: Optional reqId, instanceCreated rename, HMR-safe state sync

Test plan

  • 137 Rust tests, 2665 browser tests pass
  • 0 type errors
  • E2E verified

Refs #888

🤖 Generated with Claude Code

ChuxiJ and others added 30 commits March 24, 2026 23:07
Restore full implementations from parallel work units (lost during
merge conflict resolution), fix all type mismatches between units,
update tests to match consolidated APIs, and add COOP/COEP headers
to vite.config.ts for SharedArrayBuffer support.

- Restore canonical W1 protocol types (426 lines)
- Restore canonical W2 bridge client (446 lines)
- Restore canonical W3 ring buffer (203 lines)
- Restore canonical W4 audio worklet (169 lines)
- Add public store methods hooks expect
- Fix type name mismatches across all modules
- Add COOP/COEP headers to vite dev server
- All 2622 tests pass, 0 type errors, build succeeds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mock WebSocket companion server for testing (tests/support/)
- 9 Playwright E2E tests covering:
  - VST3 effect/instrument plugin CRUD on tracks
  - Parameter updates via store
  - Plugin chain with multiple VST3 plugins
  - State persistence (vst3Uid, vst3State fields)
  - Backward compatibility with non-VST3 plugins
  - SharedArrayBuffer availability (COOP/COEP)
  - Audio ring buffer allocation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change releaseTimeoutId type from `number` to `ReturnType<typeof setTimeout>`
to fix TS2322 in environments where setTimeout returns Timeout object.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore W14/W15 canonical implementations lost during merge:
- main.rs with tokio async runtime and clap CLI
- ws_server.rs handling full protocol (hello, scan, instantiate, etc.)
- protocol.rs with serde structs for all message types
- plugin_host.rs, plugin_scanner.rs, error.rs
- audio_thread.rs with crossbeam lock-free MIDI queue
- sidechain_router.rs for internal audio routing
- Add crossbeam dependency to Cargo.toml
- Add /companion/target/ to .gitignore

Verified: cargo build ✅, cargo test 58/58 ✅, WS handshake + scan ✅

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add useVST3Connection() and useVST3Sync() to EditorShell
- Add CompanionStatus indicator to Toolbar (right of harmony strip)
- Follows existing hook pattern (useAudioEngine, useEffectsSync)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace stub VST3BridgeClient with real WebSocket implementation:
- Connects to ws://127.0.0.1:9851 (companion app)
- Hello handshake protocol
- Auto-reconnect with exponential backoff (1s→30s)
- Plugin scan, instance lifecycle, parameter forwarding
- Auto-scan on connect
- localStorage-based auto-connect preference
- Hook overrides store connect/disconnect with real bridge calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of relying on store action override via useEffect (fragile with
HMR), CompanionStatus now imports _getBridgeClient() and calls
connect/disconnect directly on the singleton.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests now mock _getBridgeClient() instead of asserting store actions,
matching the updated component that calls the singleton directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Change serde rename_all to camelCase for variant tags
- Add per-field serde rename for camelCase fields (sampleRate, blockSize,
  instanceId, reqId, paramId, etc.)
- Add alias for backward compat with snake_case fields
- Update test assertions for new JSON format

Integration tested: browser ↔ companion handshake + plugin scan
working end-to-end (found 5 VST3 plugins on local system).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add VST3SidePanel component (right-side slide panel)
- Wire CompanionStatus click to toggle plugin browser when connected
- Add showVST3Panel state to uiStore with mutual exclusion
- Align all protocol types from snake_case to camelCase (browser + companion)
- Fix handshake to use fire-and-forget hello + helloAck listener
- End-to-end verified: companion → auto-connect → scan 5 plugins → browse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The companion returns plugins with `uid` field but the store expects `id`.
Map uid→id, category→subcategory in the old VST3BridgeClient's scanComplete
handler. Revert useVST3Connection to simple passthrough since the client
handles mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add vst3 + libloading crates for native VST3 COM interface bindings
- New vst3_loader.rs: loads .dylib from bundle, queries IPluginFactory,
  creates IComponent, extracts IAudioProcessor + IEditController,
  enumerates real parameters and latency
- Update plugin_host.rs: instantiate() now loads real VST3 binaries
  (falls back to stub when no path provided)
- Update ws_server.rs: looks up plugin path from scanner before instantiate
- Validated with real ACE Bridge VST3 plugin on macOS
- 76 Rust tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioThread now accepts a real Vst3PluginInstance via set_plugin()
- process() calls IAudioProcessor::process() with proper ProcessData,
  AudioBusBuffers, de-interleaved channel buffers
- start() calls setupProcessing() + setActive(true) + setProcessing(true)
- stop() calls setProcessing(false) + setActive(false)
- Falls back to stub behavior when no plugin attached
- Validated with real ACE Bridge VST3 plugin
- 77 Rust tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tream

- AceComponentHandler: captures parameter changes from plugin GUI via
  lock-free queue (for WebSocket forwarding)
- AceHostApplication: provides host name "ACE-Step DAW" to plugins
- MemoryStream (IBStream): in-memory byte buffer for state save/load
  with seek/tell support
- Wire IHostApplication into vst3_loader::load_plugin() so plugins get
  proper host context on initialize()
- 82 Rust tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- VST3PluginAdapter now uses VST3AudioWorkletNode for real-time audio
  via SharedArrayBuffer ring buffers + AudioWorklet
- Subscribe to bridgeClient.onAudioFrame, filter by instanceIdHash
  (FNV-1a), write decoded per-channel samples into outputRingBuffer
  via writeDeinterleaved — worklet reads on audio thread
- Send startAudioStream/stopAudioStream lifecycle messages to companion
- Sync createAudioNode creates placeholder GainNodes immediately,
  async createAudioNodeAsync wires real worklet + reconnects graph
- Input pump for effects: reads from input ring buffer, sends to
  companion via sendAudioFrame
- Add offAudioFrame to VST3BridgeClient for explicit unsubscribe
- 4 new tests for audio receiving pipeline, 27 total adapter tests
- 2658 tests pass, 0 type errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…iation

- Make Instantiate.req_id optional in companion protocol (browser sends
  without reqId, companion handles gracefully)
- Rename companion Instantiated response to "instanceCreated" via serde
  rename to match browser event handler expectations
- Fix vst3Store.loadPlugin to handle new message format: onCreated
  receives full msg object, extracts instanceId and parameters
- Wire store.connect/disconnect to call _getBridgeClient() directly
- Sync bridge client state to store on mount (handles HMR/singleton
  already connected before effect registers statusChange handler)
- 2658 tests pass, 120 companion tests pass

Verified end-to-end: companion start → auto-connect → scan 5 plugins →
Load ACE Bridge → instanceCreated → Active Plugins shows loaded plugin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a piano roll track has an active, online VST3 instrument instance,
notes are routed through the bridge client (sendMidi with noteOn/noteOff)
instead of the built-in synth/sampler engines. Separate noteOff events
are scheduled at note end time for proper release behavior.

Priority order: VST3 instrument > sampler > synth (fallback)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChuxiJ and others added 5 commits March 25, 2026 23:14
Companion (Rust):
- GuiManager with NativeBackend trait (CocoaBackend for macOS, MockBackend
  for tests) — creates NSWindow + NSView, attaches IPlugView
- PlugViewHandle wraps IPlugView lifecycle (create, attach, resize, remove)
- Wire OpenEditor/CloseEditor WebSocket handlers to real GuiManager calls
- Add get_controller() to PluginHost for IEditController access
- macOS deps: cocoa, objc, core-graphics (cfg-gated)

Browser:
- Wire openEditor store action to bridge client send
- Open Editor button in VST3PluginPanel (already existed, now functional)

126 Rust tests pass, 2658 browser tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- On loadPlugin success, create VST3PluginAdapter and register with
  pluginEngine.addPlugin() so plugins connect to the track audio graph
- useTransport now routes MIDI through pluginEngine.noteOn/noteOff
  (cleaner than direct bridge client calls, works through audio graph)
- Add SharedArrayBuffer availability check in VST3AudioWorkletNode.create
  with actionable error message about COOP/COEP headers
- Closes #887 (COOP/COEP already configured, runtime check added)

Refs #888

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Companion: split "Fx|EQ" from moduleinfo.json into separate category
  ("Fx") and subcategory ("EQ") fields in PluginInfo
- Browser: map subcategory from companion, improve instrument detection
  (matches "instrument", "synth", "generator" keywords)
- Plugin browser now shows real subcategories instead of "Unknown"
- 137 Rust tests + 2658 browser tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 25, 2026 15:57
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Consolidation of the VST3 “second wave” work: native GUI editor window support in the companion app, MIDI routing from piano roll to VST3 instruments, initial PluginEngine integration for VST3 instances, and improved plugin category/subcategory handling across the protocol.

Changes:

  • Add companion-side GuiManager with a macOS Cocoa backend to open/close VST3 editors via IPlugView.
  • Route piano-roll note playback to VST3 instances via pluginEngine.noteOn/noteOff, and create a VST3PluginAdapter on plugin load.
  • Split moduleinfo category strings (e.g. Fx|EQ) into category + subcategory, and propagate the new field through protocol + browser mapping.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/store/vst3Store.ts Creates a VST3PluginAdapter on load and registers it with pluginEngine; updates openEditor messaging.
src/services/vst3bridge/VST3AudioWorklet.ts Adds a runtime guard/error when SharedArrayBuffer is unavailable (COOP/COEP requirement).
src/hooks/useVST3Connection.ts Updates scan result mapping to support subcategory and improved instrument detection heuristics.
src/hooks/useTransport.ts Routes piano-roll MIDI playback to VST3 via pluginEngine when a VST3 instance is present on the track.
companion/src/ws_server.rs Wires OpenEditor/CloseEditor to the new GuiManager and stores it in shared app state.
companion/src/protocol.rs Adds subcategory to PluginInfo and updates tests accordingly.
companion/src/plugin_scanner.rs Splits moduleinfo.json category strings into category and subcategory (and updates tests).
companion/src/plugin_host.rs Exposes get_controller() to retrieve IEditController for GUI creation.
companion/src/gui_manager.rs Implements native editor window management with a macOS Cocoa backend and a stub backend for tests.
companion/Cargo.toml Adds macOS-only dependencies for Cocoa GUI support.
companion/Cargo.lock Locks new macOS dependency graph.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +154 to +168
{
instanceId,
parameters: parameters.map((p) => ({
id: p.id,
name: p.name,
title: p.name,
default: p.defaultValue,
defaultValue: p.defaultValue,
min: p.minValue,
max: p.maxValue,
stepCount: 0,
unit: '',
})),
latencySamples: 0,
},
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adapter is built with a synthetic instantiateResponse where latencySamples is hard-coded to 0 and presets/tail latency info are dropped. The companion’s instanceCreated message includes latencySamples/tailSamples/presets; use those fields so VST3PluginAdapter.latencySamples and any latency compensation / preset UI stay accurate.

Copilot uses AI. Check for mistakes.
Comment on lines +344 to +348
// Check for VST3 instrument on this track
const vst3Instances = Object.values(useVST3Store.getState().instances);
const vst3Instrument = vst3Instances.find(
(inst) => inst.trackId === track.id && inst.enabled && inst.online,
);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vst3Instrument is inferred from “any enabled+online VST3 instance on this track”, but instances don’t distinguish instrument vs effect. If an effect VST3 is loaded on a piano-roll track, this will disable the sampler/synth and route noteOn/noteOff to the effect (via pluginEngine), which is incorrect. Suggest looking up the plugin’s category (e.g. via useVST3Store.getState().plugins by pluginId) and only treating category === 'instrument' as a MIDI target.

Suggested change
// Check for VST3 instrument on this track
const vst3Instances = Object.values(useVST3Store.getState().instances);
const vst3Instrument = vst3Instances.find(
(inst) => inst.trackId === track.id && inst.enabled && inst.online,
);
// Check for VST3 *instrument* on this track
const { instances, plugins } = useVST3Store.getState();
const vst3Instrument = Object.values(instances).find((inst) => {
if (!(inst.trackId === track.id && inst.enabled && inst.online)) {
return false;
}
const plugin = plugins[inst.pluginId];
return plugin?.category === 'instrument';
});

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +420
const midiVelocity = Math.max(1, Math.round(velocity * 127));
const trackId = track.id;
engine.scheduleMidiEvent(scheduledStart, () => {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

midiVelocity is clamped with Math.max(1, …), which turns zero-velocity notes into audible note-ons at velocity 1. If zero velocity is used (e.g. muted notes or imported MIDI), this changes playback. Consider allowing 0 (or skipping note-on when velocity is 0) and relying on the scheduled note-off for silence.

Copilot uses AI. Check for mistakes.
Comment on lines 293 to 323
IncomingMessage::OpenEditor { instance_id } => {
info!(instance_id, "OpenEditor (stub)");
OutgoingMessage::EditorOpened {
instance_id,
width: 800,
height: 600,
// Get the IEditController from the live VST3 instance
match state.host.get_controller(&instance_id) {
Some(controller) => {
let mut gui = state.gui_manager.lock().unwrap();
match gui.open_editor_with_controller(&instance_id, &controller) {
Ok((width, height)) => {
info!(instance_id, width, height, "Editor opened");
OutgoingMessage::EditorOpened {
instance_id,
width,
height,
}
}
Err(e) => instance_error(instance_id, "open_editor_error", e),
}
}
None => instance_error(instance_id, "open_editor_error", "Plugin instance not found or not live".into()),
}
}

IncomingMessage::CloseEditor { instance_id } => {
info!(instance_id, "CloseEditor (stub)");
OutgoingMessage::EditorClosed { instance_id }
let mut gui = state.gui_manager.lock().unwrap();
match gui.close_editor(&instance_id) {
Ok(()) => {
info!(instance_id, "Editor closed");
OutgoingMessage::EditorClosed { instance_id }
}
Err(e) => instance_error(instance_id, "close_editor_error", e),
}
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EditorOpened / EditorClosed responses aren’t correlated to a request (reqId isn’t captured from OpenEditor and isn’t included in the outgoing messages). The browser client’s VST3BridgeClient.openEditor() uses reqId-based request() semantics, so it will time out against this server. Consider adding optional req_id to IncomingMessage::OpenEditor/CloseEditor and echoing it back on OutgoingMessage::EditorOpened/EditorClosed (similar to Instantiate).

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +316
let mut gui = state.gui_manager.lock().unwrap();
match gui.open_editor_with_controller(&instance_id, &controller) {
Ok((width, height)) => {
info!(instance_id, width, height, "Editor opened");
OutgoingMessage::EditorOpened {
instance_id,
width,
height,
}
}
Err(e) => instance_error(instance_id, "open_editor_error", e),
}
}
None => instance_error(instance_id, "open_editor_error", "Plugin instance not found or not live".into()),
}
}

IncomingMessage::CloseEditor { instance_id } => {
info!(instance_id, "CloseEditor (stub)");
OutgoingMessage::EditorClosed { instance_id }
let mut gui = state.gui_manager.lock().unwrap();
match gui.close_editor(&instance_id) {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using state.gui_manager.lock().unwrap() will panic the whole server if the mutex is poisoned (e.g. a previous GUI operation panicked while holding the lock). Prefer handling the LockResult and returning an OutgoingMessage::Error so the server stays up.

Copilot uses AI. Check for mistakes.
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
objc = "0.2"
core-graphics = "0.24"
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core-graphics is added as a macOS-only dependency but doesn’t appear to be used anywhere in companion/src yet. If it’s not needed for the current implementation, consider removing it for now to reduce compile time and dependency surface (can be re-added when required).

Suggested change
core-graphics = "0.24"

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +172
// Create the audio adapter and register with the plugin engine
// so audio routing (effects) and MIDI (instruments) work through the graph
try {
const { getContext } = await import('tone');
const ctx = getContext().rawContext as AudioContext;
const adapter = new VST3PluginAdapter(
instanceId,
{ ...pluginInfo, uid: pluginInfo.id },
{
instanceId,
parameters: parameters.map((p) => ({
id: p.id,
name: p.name,
title: p.name,
default: p.defaultValue,
defaultValue: p.defaultValue,
min: p.minValue,
max: p.maxValue,
stepCount: 0,
unit: '',
})),
latencySamples: 0,
},
client,
);
pluginEngine.addPlugin(trackId, instanceId, adapter, ctx);
} catch {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPlugin registers a VST3PluginAdapter with pluginEngine, but PluginEngine's nodes are never spliced into the track audio graph (no pluginEngine.getInputNode/getOutputNode usage anywhere, and TrackNode only supports spliceEffects). As-is, adding the adapter won’t route audio through the plugin (and instruments won’t be audible either). Consider wiring the returned PluginAudioNode into the track node (similar to TrackNode.spliceEffects) or adding a dedicated splice point for plugin chains.

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +173
} catch {
// Audio engine not ready — adapter will be created on next rebuild
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty catch {} around adapter creation silently leaves the store thinking the instance is online/enabled while pluginEngine never gets the plugin, so MIDI/audio routing can fail with no feedback. At minimum, log/report the error (or mark the instance offline / retry once the AudioContext is available) so failures aren’t silent.

Suggested change
} catch {
// Audio engine not ready — adapter will be created on next rebuild
} catch (error) {
// Audio engine not ready or adapter creation failed — log and mark instance offline
console.error('Failed to create VST3 audio adapter', {
pluginId,
instanceId,
error,
});
const { instances } = get();
const current = instances[instanceId];
if (current) {
set({
instances: {
...instances,
[instanceId]: { ...current, online: false },
},
});
}
toastError(`Audio engine is not ready. "${pluginInfo.name}" is loaded but currently offline.`);

Copilot uses AI. Check for mistakes.
if (vst3Instrument) {
// Route to VST3 instrument via plugin engine (which calls the adapter's noteOn/noteOff)
const midiVelocity = Math.max(1, Math.round(velocity * 127));
const trackId = track.id;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: trackId is declared twice (const trackId = track.id; above and again inside the vst3Instrument branch). This shadowing is unnecessary and makes the scheduling block harder to read—reuse the existing trackId constant.

Suggested change
const trackId = track.id;

Copilot uses AI. Check for mistakes.
Comment on lines +416 to +427
if (vst3Instrument) {
// Route to VST3 instrument via plugin engine (which calls the adapter's noteOn/noteOff)
const midiVelocity = Math.max(1, Math.round(velocity * 127));
const trackId = track.id;
engine.scheduleMidiEvent(scheduledStart, () => {
pluginEngine.noteOn(trackId, note.pitch, midiVelocity);
});
// Schedule note-off
engine.scheduleMidiEvent(scheduledStart + scheduledDuration, () => {
pluginEngine.noteOff(trackId, note.pitch);
});
} else if (useSampler) {
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change adds a new MIDI routing branch to pluginEngine.noteOn/noteOff for piano-roll playback, but there doesn’t appear to be test coverage validating that (a) VST3 instances on a track are detected correctly and (b) noteOn/noteOff are scheduled with the right timing/velocity. Consider adding a unit test around the scheduling logic (similar to other hook tests under src/hooks/__tests__).

Copilot uses AI. Check for mistakes.
@ChuxiJ ChuxiJ merged commit 52f15b9 into main Mar 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants