Ashiedu's picture
Sync unified workbench
0490201 verified

A newer version of the Gradio SDK is available: 6.14.0

Upgrade

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:

// [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]:

# 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)

[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

[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

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

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

[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

fn main() {
    nih_plug_xtask::main()
}

Building and Installing the Plugin

Build the plugin bundle

# 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

# 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):

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

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

cargo build --package kansas --release
cargo xtask bundle synesthesia-plugin --release
# Both should succeed without conflict

Iteration Protocol

## 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

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)