Spaces:
Runtime error
Runtime error
| # T-005: nih-plug VST3/CLAP Scaffold β Audio Passthrough, Shared Core Crate, Bundler | |
| **Type:** Task | |
| **Phase:** 0 β Foundation | |
| **Autonomy:** `agent:human-led` (A3) β Agent implements the full scaffold. Human must confirm the three identity constants marked `[HUMAN DECISION]` before the PR is merged. Everything else is fully specified. | |
| **Stack:** `stack:rust` | |
| **Version:** v0.1 | |
| **Iteration:** iter-1 | |
| **Effort:** M (1β2 days) | |
| --- | |
| > β οΈ **Agent Scope:** Create the `plugin/` crate with nih-plug, implement stereo audio passthrough, set up the `xtask` bundler, and verify the plugin loads in Reaper. Do **not** add any model inference, IPC calls, or generation logic β those arrive in T-032 (Perf RNN VST), T-065, T-089 (Magenta RT VST). Parameters beyond a single placeholder `gain` are also out of scope. | |
| --- | |
| ## Context | |
| Synesthesia ships as both a standalone Tauri desktop app and a VST3/CLAP plugin. Both compile from the same `synesthesia-core` library crate. The plugin uses `nih-plug` β MIT-licensed, Rust-native, produces VST3 + CLAP from the same source, no JUCE commercial license required. | |
| **Why not JUCE:** JUCE's GPL licence requires open-sourcing your plugin or purchasing a commercial licence. nih-plug is MIT, ships as a single Rust crate, and the community has validated it against Reaper, Ableton, Bitwig, and FL Studio. | |
| **Dual-target build model:** | |
| ``` | |
| synesthesia-core (library crate) | |
| βββ kansas/ β Tauri app depends on this | |
| βββ plugin/ β nih-plug plugin depends on this | |
| ``` | |
| `synesthesia-core` is the only place shared business logic lives. Neither `kansas/` nor `plugin/` duplicates code. T-006 scaffolds `synesthesia-core`'s content; T-005 just adds `plugin/` as a consumer of it. | |
| --- | |
| ## Human Decisions Required Before Merge | |
| The following three constants in `plugin/src/lib.rs` must be reviewed and confirmed by the human before the PR is approved. The values below are **agent defaults** β the human changes them if desired: | |
| ```rust | |
| // [HUMAN DECISION 1] β VST3 Class ID | |
| // Must be exactly 16 bytes and globally unique per plugin. | |
| // This ID is baked into every DAW project that uses the plugin. | |
| // Changing it after release breaks saved sessions. | |
| // Default (agent sets this): ASCII-padded plugin name | |
| const VST3_CLASS_ID: [u8; 16] = *b"Synesthesia_Plug"; | |
| // [HUMAN DECISION 2] β Plugin metadata shown in DAW | |
| const VENDOR: &str = "Ash"; | |
| const URL: &str = "https://github.com/ashiedu/synesthesia"; | |
| // [HUMAN DECISION 3] β CLAP feature tags (affects DAW browser categorization) | |
| const CLAP_FEATURES: &[ClapFeature] = &[ | |
| ClapFeature::Instrument, | |
| ClapFeature::Synthesizer, | |
| ClapFeature::Stereo, | |
| ]; | |
| ``` | |
| Open a PR comment with `[HUMAN DECISION]` in the title containing the proposed values. Do not merge until the human replies with explicit confirmation or updated values. | |
| --- | |
| ## Prerequisites | |
| - [ ] T-001 merged β workspace `Cargo.toml` with `edition = "2024"`, `crates/synesthesia-core/` exists as empty lib | |
| - [ ] `cargo check --workspace` passes before starting | |
| > Note: T-004 (CPAL audio) and T-006 (`synesthesia-core` scaffold) do not need to be merged first. T-005 only requires the workspace to exist. | |
| --- | |
| ## New Dependencies | |
| nih-plug is **not on crates.io** β it is a git dependency. The wildcard version policy does not apply to git deps; use the git URL directly. | |
| Add to root `Cargo.toml` `[workspace.dependencies]`: | |
| ```toml | |
| # nih-plug β git only, not on crates.io | |
| nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", features = ["vst3", "clap"] } | |
| nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git" } | |
| ``` | |
| > `Cargo.lock` will pin these to the HEAD commit at first `cargo build`. Commit `Cargo.lock` so the pinned revision is reproducible. | |
| --- | |
| ## Workspace Changes (`Cargo.toml`) | |
| ```toml | |
| [workspace] | |
| members = [ | |
| "kansas", | |
| "crates/*", | |
| "plugin", | |
| "xtask", # cargo xtask bundler | |
| ] | |
| resolver = "2" | |
| ``` | |
| --- | |
| ## File Structure After This Task | |
| ``` | |
| synesthesia/ | |
| βββ Cargo.toml β updated: adds plugin, xtask members | |
| βββ plugin/ | |
| β βββ Cargo.toml | |
| β βββ src/ | |
| β βββ lib.rs β Plugin impl (passthrough + params stub) | |
| β βββ params.rs β SynesthesiaParams (single gain knob) | |
| βββ xtask/ | |
| βββ Cargo.toml | |
| βββ src/ | |
| βββ main.rs β delegates to nih_plug_xtask::main() | |
| ``` | |
| --- | |
| ## Implementation | |
| ### `plugin/Cargo.toml` | |
| ```toml | |
| [package] | |
| name = "synesthesia-plugin" | |
| version = "0.1.0" | |
| edition.workspace = true | |
| # REQUIRED: cdylib produces the .dll/.so/.dylib the DAW loads | |
| [lib] | |
| name = "synesthesia_plugin" | |
| crate-type = ["cdylib"] | |
| [dependencies] | |
| nih_plug = { workspace = true } | |
| synesthesia-core = { path = "../crates/synesthesia-core" } | |
| ``` | |
| ### `plugin/src/params.rs` | |
| ```rust | |
| use nih_plug::prelude::*; | |
| /// Plugin parameters. | |
| /// T-005: single placeholder gain knob β proves the parameter system compiles. | |
| /// T-032 (Perf RNN) adds: Temperature, Primer, BPM Sync | |
| /// T-065 (Perf RNN standalone) adds: channel routing | |
| /// T-089 (Magenta RT) adds: Style Blend 1β6 | |
| #[derive(Params)] | |
| pub struct SynesthesiaParams { | |
| /// Output gain in dB. Range β96 to +6. Default 0 dB. | |
| /// This is a smoke-test parameter; it is not used in T-005's passthrough. | |
| #[id = "gain"] | |
| pub gain: FloatParam, | |
| } | |
| impl Default for SynesthesiaParams { | |
| fn default() -> Self { | |
| Self { | |
| gain: FloatParam::new( | |
| "Gain", | |
| util::db_to_gain(0.0), | |
| FloatRange::Skewed { | |
| min: util::db_to_gain(-96.0), | |
| max: util::db_to_gain(6.0), | |
| factor: FloatRange::gain_skew_factor(-96.0, 6.0), | |
| }, | |
| ) | |
| .with_smoother(SmoothingStyle::Logarithmic(50.0)) | |
| .with_unit(" dB") | |
| .with_value_to_string(formatters::v2s_f32_gain_to_db(2)) | |
| .with_string_to_value(formatters::s2v_f32_gain_to_db()), | |
| } | |
| } | |
| } | |
| ``` | |
| ### `plugin/src/lib.rs` | |
| ```rust | |
| use std::sync::Arc; | |
| use nih_plug::prelude::*; | |
| mod params; | |
| use params::SynesthesiaParams; | |
| // Unused in T-005; imported to verify synesthesia-core links correctly. | |
| // T-006 populates this crate; for now it's just a link-time check. | |
| use synesthesia_core as _core; | |
| // βββ Plugin struct ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| pub struct SynesthesiaPlugin { | |
| params: Arc<SynesthesiaParams>, | |
| sample_rate: f32, | |
| } | |
| impl Default for SynesthesiaPlugin { | |
| fn default() -> Self { | |
| Self { | |
| params: Arc::new(SynesthesiaParams::default()), | |
| sample_rate: 44_100.0, | |
| } | |
| } | |
| } | |
| // βββ nih-plug Plugin trait ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| impl Plugin for SynesthesiaPlugin { | |
| // ββ Identity ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const NAME: &'static str = "Synesthesia"; | |
| const VENDOR: &'static str = "Ash"; // [HUMAN DECISION 2] | |
| const URL: &'static str = "https://github.com/ashiedu/synesthesia"; // [HUMAN DECISION 2] | |
| const EMAIL: &'static str = ""; | |
| const VERSION: &'static str = env!("CARGO_PKG_VERSION"); | |
| // ββ Audio I/O βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Stereo in, stereo out. The DAW may also call us with no inputs (instrument mode). | |
| const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[ | |
| AudioIOLayout { | |
| main_input_channels: NonZeroU32::new(2), | |
| main_output_channels: NonZeroU32::new(2), | |
| aux_input_ports: &[], | |
| aux_output_ports: &[], | |
| names: PortNames::const_default(), | |
| }, | |
| // Instrument mode β no audio input, stereo output | |
| AudioIOLayout { | |
| main_input_channels: None, | |
| main_output_channels: NonZeroU32::new(2), | |
| aux_input_ports: &[], | |
| aux_output_ports: &[], | |
| names: PortNames::const_default(), | |
| }, | |
| ]; | |
| // ββ MIDI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // T-068 (MIDI output) activates BasicMidi output. | |
| // T-005: accept MIDI so DAWs route notes to us; output is off until T-068. | |
| const MIDI_INPUT: MidiConfig = MidiConfig::Basic; | |
| const MIDI_OUTPUT: MidiConfig = MidiConfig::None; | |
| type SysExMessage = (); | |
| type BackgroundTask = (); | |
| // ββ Parameters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fn params(&self) -> Arc<dyn Params> { | |
| self.params.clone() | |
| } | |
| // ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fn initialize( | |
| &mut self, | |
| _audio_io_layout: &AudioIOLayout, | |
| buffer_config: &BufferConfig, | |
| _context: &mut impl InitContext<Self>, | |
| ) -> bool { | |
| self.sample_rate = buffer_config.sample_rate; | |
| // T-046+: initialize ONNX runtime here when model inference is added | |
| true | |
| } | |
| fn reset(&mut self) { | |
| // T-062+: reset generator state (e.g., Perf RNN LSTM hidden state) here | |
| } | |
| // ββ Audio processing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fn process( | |
| &mut self, | |
| buffer: &mut Buffer, | |
| _aux: &mut AuxiliaryBuffers, | |
| _context: &mut impl ProcessContext<Self>, | |
| ) -> ProcessStatus { | |
| // T-005: passthrough with gain applied. | |
| // T-032 replaces this with Perf RNN MIDI generation. | |
| // T-065 adds Magenta RT audio generation. | |
| // T-095 adds DDSP timbre transfer. | |
| let gain = self.params.gain.smoothed.next(); | |
| for channel_samples in buffer.iter_samples() { | |
| for sample in channel_samples { | |
| *sample *= gain; | |
| } | |
| } | |
| ProcessStatus::Normal | |
| } | |
| } | |
| // βββ CLAP identity ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| impl ClapPlugin for SynesthesiaPlugin { | |
| const CLAP_ID: &'static str = "com.synesthesia.plugin"; | |
| const CLAP_DESCRIPTION: Option<&'static str> = Some("Synesthesia ML Music Plugin"); | |
| const CLAP_MANUAL_URL: Option<&'static str> = None; | |
| const CLAP_SUPPORT_URL: Option<&'static str> = None; | |
| // [HUMAN DECISION 3] β affects DAW browser categorization | |
| const CLAP_FEATURES: &'static [ClapFeature] = &[ | |
| ClapFeature::Instrument, | |
| ClapFeature::Synthesizer, | |
| ClapFeature::Stereo, | |
| ]; | |
| } | |
| // βββ VST3 identity ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| impl Vst3Plugin for SynesthesiaPlugin { | |
| // [HUMAN DECISION 1] β 16 bytes, globally unique, NEVER change after first DAW session saved | |
| const VST3_CLASS_ID: [u8; 16] = *b"Synesthesia_Plug"; | |
| const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[ | |
| Vst3SubCategory::Instrument, | |
| Vst3SubCategory::Synth, | |
| Vst3SubCategory::Stereo, | |
| ]; | |
| } | |
| // βββ Export macros ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // These generate the C ABI entry points the DAW calls. | |
| nih_export_clap!(SynesthesiaPlugin); | |
| nih_export_vst3!(SynesthesiaPlugin); | |
| ``` | |
| ### `xtask/Cargo.toml` | |
| ```toml | |
| [package] | |
| name = "xtask" | |
| version = "0.1.0" | |
| edition.workspace = true | |
| [[bin]] | |
| name = "xtask" | |
| path = "src/main.rs" | |
| [dependencies] | |
| nih_plug_xtask = { workspace = true } | |
| ``` | |
| ### `xtask/src/main.rs` | |
| ```rust | |
| fn main() { | |
| nih_plug_xtask::main() | |
| } | |
| ``` | |
| --- | |
| ## Building and Installing the Plugin | |
| ### Build the plugin bundle | |
| ```bash | |
| # From workspace root β bundles both VST3 and CLAP | |
| cargo xtask bundle synesthesia-plugin --release | |
| ``` | |
| Output locations: | |
| ``` | |
| target/bundled/ | |
| βββ Synesthesia.vst3/ | |
| β βββ Contents/ | |
| β βββ x86_64-win/ | |
| β βββ Synesthesia.vst3 β the DLL (despite the .vst3 extension) | |
| βββ Synesthesia.clap β single file for CLAP hosts | |
| ``` | |
| ### Install for testing in Reaper | |
| ```bash | |
| # VST3 β copy entire .vst3 folder (not just the DLL inside) | |
| cp -r target/bundled/Synesthesia.vst3 "C:/Program Files/Common Files/VST3/" | |
| # CLAP | |
| cp target/bundled/Synesthesia.clap "C:/Program Files/Common Files/CLAP/" | |
| ``` | |
| Restart Reaper and run **Options β Preferences β Plug-ins β VST β Re-scan**. The plugin should appear under `Synesthesia / Ash`. | |
| ### Add to Taskfile | |
| Add these targets to `Taskfile.yml` (T-007 owns the Taskfile, but register these targets there): | |
| ```yaml | |
| tasks: | |
| build:plugin: | |
| desc: "Build VST3 + CLAP plugin bundles" | |
| cmd: cargo xtask bundle synesthesia-plugin --release | |
| install:plugin: | |
| desc: "Install plugin bundles to system VST3/CLAP directories" | |
| cmds: | |
| - cp -r target/bundled/Synesthesia.vst3 "C:/Program Files/Common Files/VST3/" | |
| - cp target/bundled/Synesthesia.clap "C:/Program Files/Common Files/CLAP/" | |
| ``` | |
| --- | |
| ## Implementation Notes | |
| ### Why `cdylib` and not `rlib` | |
| `cdylib` produces a C-ABI dynamic library (`.dll` on Windows). The DAW loads this with `LoadLibrary` and calls the VST3/CLAP entry points that `nih_export_vst3!` and `nih_export_clap!` generate. `rlib` is a Rust-only library format β the DAW cannot load it. | |
| ### `synesthesia-core` link check | |
| `use synesthesia_core as _core;` in `lib.rs` compiles but does nothing. Its purpose is to fail the build if `synesthesia-core` becomes unreachable or changes its public API in a breaking way. This is intentional dead-code usage β suppress with `#[allow(unused_imports)]` if clippy warns. | |
| ### Buffer size in DAW context | |
| Unlike T-004's fixed 512-sample buffer, the DAW calls `process()` with whatever buffer size the user configured β commonly 256, 512, or 1024. nih-plug's `Buffer` abstraction handles this; T-005's passthrough loop works correctly for any size. When T-032 adds Perf RNN generation, it will need to handle variable buffer sizes β that's T-032's concern. | |
| ### VST3_CLASS_ID collision risk | |
| Steinberg maintains no central registry for VST3 class IDs. The risk of collision with another plugin is real but low β there are tens of thousands of VST3 plugins. Using `*b"Synesthesia_Plug"` (human-readable ASCII) is less likely to collide than a random GUID would be with a generic name. If you want a guaranteed unique ID, use a UUID generator and convert to `[u8; 16]`. That is [HUMAN DECISION 1]. | |
| ### `MIDI_INPUT: MidiConfig::Basic` | |
| Setting `Basic` means the DAW routes note events to this plugin even though T-005 doesn't process them. This prevents DAWs from graying out MIDI routing to this plugin in their mixer. Setting it to `None` here and changing to `Basic` in T-032 would require DAW rescan/session reload. | |
| --- | |
| ## Acceptance Criteria | |
| - [ ] `cargo check --workspace` β zero errors including `plugin/` and `xtask/` | |
| - [ ] `cargo clippy --workspace -- -D warnings` β zero warnings | |
| - [ ] `cargo xtask bundle synesthesia-plugin --release` completes without error | |
| - [ ] `target/bundled/Synesthesia.vst3/` directory exists with DLL inside | |
| - [ ] `target/bundled/Synesthesia.clap` file exists | |
| - [ ] Plugin loads in Reaper without crash (no red error in plugin scanner) | |
| - [ ] Plugin appears in Reaper as `Synesthesia` under vendor `Ash` (or updated [HUMAN DECISION 2] value) | |
| - [ ] Audio passthrough works: audio routed through the plugin passes without silence, clipping, or crash | |
| - [ ] Gain knob visible in Reaper plugin GUI (nih-plug provides generic UI for free) | |
| - [ ] PR comment with `[HUMAN DECISION]` section posted before marking ready for review | |
| - [ ] Human has replied confirming or updating the three identity constants | |
| --- | |
| ## Testing | |
| ### Automated | |
| - [ ] `cargo check --workspace` β zero errors | |
| - [ ] `cargo clippy --workspace -- -D warnings` β zero warnings | |
| ### Manual | |
| **1. Build succeeds** | |
| ```bash | |
| cargo xtask bundle synesthesia-plugin --release | |
| ls target/bundled/ | |
| # Expected: Synesthesia.vst3/ and Synesthesia.clap | |
| ``` | |
| **2. Reaper loads plugin** | |
| - Copy `target/bundled/Synesthesia.vst3/` to VST3 system folder | |
| - Reaper: Options β Preferences β VST β Re-scan | |
| - Add an FX track, search "Synesthesia" β should appear | |
| - Open plugin: generic nih-plug UI shows "Gain" knob at 0.0 dB | |
| **3. Audio passthrough** | |
| - Route an audio track through the plugin | |
| - Play audio β output should be identical to input at 0 dB gain | |
| - Move gain knob to ββ β silence | |
| - Move gain knob to +6 dB β louder (clip if input is loud) | |
| **4. No xrun on buffer-size mismatch** | |
| - Set Reaper buffer size to 256, play audio through plugin β no crackling | |
| - Set buffer size to 1024 β still no crackling | |
| **5. Standalone + plugin both build** | |
| ```bash | |
| cargo build --package kansas --release | |
| cargo xtask bundle synesthesia-plugin --release | |
| # Both should succeed without conflict | |
| ``` | |
| --- | |
| ## Iteration Protocol | |
| ```markdown | |
| ## Blocker or Human Decision β [date] | |
| Decision/criterion: [verbatim] | |
| Current value: [what was set] | |
| Issue: [what's wrong or needs confirmation] | |
| Recommendation: [agent's suggestion] | |
| ``` | |
| Set `status:roadmap` if blocked. Post the `[HUMAN DECISION]` PR comment before requesting review. | |
| --- | |
| ## GitHub CLI | |
| ```bash | |
| gh issue create \ | |
| --title "T-005: nih-plug VST3/CLAP scaffold β audio passthrough, shared core, bundler" \ | |
| --label "type:task,stack:rust,agent:human-led,priority:critical,status:ready,day:1" \ | |
| --body-file T-005.md | |
| ``` | |
| --- | |
| **Parent:** GENESIS | |
| **Blocks:** T-032 (Perf RNN VST parameters), T-065 (Perf RNN standalone), T-069 (Perf RNN MIDI output VST), T-089 (Magenta RT VST audio), T-103 (DDSP + Steam Audio in VST), T-143 (pluginval CI) | |
| **Blocked By:** T-001 | |
| **Version:** v0.1 Β· **Iteration:** iter-1 Β· **Effort:** M (1β2 days) | |