feat: VST3 sprint — GUI editor, MIDI routing, PluginEngine, categories#893
feat: VST3 sprint — GUI editor, MIDI routing, PluginEngine, categories#893
Conversation
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>
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>
There was a problem hiding this comment.
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
GuiManagerwith a macOS Cocoa backend to open/close VST3 editors viaIPlugView. - Route piano-roll note playback to VST3 instances via
pluginEngine.noteOn/noteOff, and create aVST3PluginAdapteron plugin load. - Split moduleinfo category strings (e.g.
Fx|EQ) intocategory+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.
| { | ||
| 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, | ||
| }, |
There was a problem hiding this comment.
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.
| // 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, | ||
| ); |
There was a problem hiding this comment.
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.
| // 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'; | |
| }); |
| const midiVelocity = Math.max(1, Math.round(velocity * 127)); | ||
| const trackId = track.id; | ||
| engine.scheduleMidiEvent(scheduledStart, () => { |
There was a problem hiding this comment.
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.
| 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), | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| 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) { |
There was a problem hiding this comment.
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.
| [target.'cfg(target_os = "macos")'.dependencies] | ||
| cocoa = "0.26" | ||
| objc = "0.2" | ||
| core-graphics = "0.24" |
There was a problem hiding this comment.
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).
| core-graphics = "0.24" |
| // 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 { |
There was a problem hiding this comment.
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.
| } catch { | ||
| // Audio engine not ready — adapter will be created on next rebuild |
There was a problem hiding this comment.
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.
| } 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.`); |
| 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; |
There was a problem hiding this comment.
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.
| const trackId = track.id; |
| 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) { |
There was a problem hiding this comment.
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__).
Summary
Consolidation PR for the VST3 sprint second wave:
Test plan
Refs #888
🤖 Generated with Claude Code