@codex-music/plugin-shell-kit

Plugin workbench, source packaging, and manifest-driven native shell codegen.

One generic native shell, N plugins. Everything per-plugin — the parameter table, the VST3 class ids, which kernel archive to link, which UI bundle to embed — is generated from a plugin.config.json manifest and the app's ParamSchema. This package is that generator, and unlike most of the SDK it is fully landed: the shell it produces builds and passes the VST3 validator.

The manifest

{
  "id": "music.codex.my-pedal",
  "name": "My Pedal",
  "vendor": "Codex Music",
  "version": "0.1.0",
  "category": "Fx",
  "kernel": {
    "package": "@codex-music/audio-kernels",
    "profile": "profiles/tone-pedal.profile.json",
    "abiPrefix": "sctone_"
  },
  "ui": {
    "dist": "dist-plugin"
  }
}

PluginManifest requires id (reverse-DNS), name, vendor, version, category ("Instrument" or "Fx" — nothing else validates), a kernel block of package / profile / abiPrefix, and ui.dist, the Vite outDir of the plugin-flavored UI build that gets bundled into plugin resources.

An optional schema block of { module?, export? } overrides where the ParamSchema lives. The default is src/schema.ts with the export auto-detected — the single export shaped like a ParamSchema whose kernelId matches kernel.abiPrefix. Ambiguity is an error naming the candidates, not a guess.

Several names derive from id alone: manifestSlug takes the last dotted segment (music.codex.fx-minifx-mini), manifestTargetName pascal-cases it into a CMake target (CodexFxMini), and kernelProfileStem reduces the profile path to an archive basename (profiles/fx1-chain.profile.jsonfx1-chain).

The CLI

codex-plugin generate [app-dir] [--cwd <dir>] [--project <spec>] [--out <dir>]
codex-plugin dev [app-dir] [--cwd <dir>] [--project <spec>] [--port 5173] [--host] [--open]
codex-plugin package [app-dir] [--cwd <dir>] [--project <spec>] [--out <file>]

plugin-shell-kit is a compatibility alias. --cwd defaults to the current directory and --project defaults to plugin.config.json.

It reads <app-dir>/plugin.config.json, loads the app's ParamSchema through the tsx loader, and hard-fails if schema.kernelId disagrees with manifest.kernel.abiPrefix — the two names must be one name. Default output is <repo>/native/plugin-shell/generated/<slug>/, gitignored and regenerable at any time because the output is deterministic and carries no timestamps.

Three files land there: codex_plugin_params.h, the C param table plus plugin identity and the normalize math; plugin.cmake, a fragment setting the per-plugin variables and calling codex_add_vst3_plugin(), the function native/plugin-shell/CMakeLists.txt defines once; and manifest.json, a resolved snapshot recording the slug, target name, UI dist path, param count, and both candidate kernel archive paths.

dev starts the plugin developer workbench plus the app's own Vite server. It injects a host-compatible development bridge, reads the schema into a live parameter inspector, serves configured audio fixtures, relays plugin and build logs, and shows browser-input scope, spectrum, and level diagnostics. React, Vue, and other Vite UIs keep their normal HMR behavior inside the webview.

The workbench imports the website's @codex-music/site-ui theme and shadcn Base UI components directly. Its audio and MIDI toolbar is the same AudioSessionControls component used by the website module player. Both bind to AudioSessionController; only their runtime adapter differs.

package writes a deterministic codex-plugin-package@1 JSON object containing the manifest, schema, source files, and hashes. It excludes dependencies, generated output, environment files, signing material, and VCS metadata. This is the object the future website submission endpoint will accept.

The developer surface

The optional dev block in plugin.config.json controls which workbench features appear:

{
  "dev": {
    "inputs": ["sample", "microphone"],
    "outputs": ["speakers", "file"],
    "diagnostics": ["scope", "spectrum", "eq", "levels"],
    "exports": ["web", "vst3", "clap", "standalone", "package"],
    "fixtures": {
      "audio": [{ "name": "Guitar take", "file": "dev/fixtures/guitar.mp3" }],
      "chords": [{ "name": "C maj7", "notes": [60, 64, 67, 71] }]
    }
  }
}

Omitted lists use category-aware defaults. Empty lists hide a surface. Effect defaults focus on audio files and microphone input; instrument defaults expose computer keyboard, Web MIDI, and chord pads.

The runner's bridge is live, but its analyser currently monitors browser input rather than processed plugin output. The workbench displays that boundary until the SDK's shared-WASM backend is connected.

Parameters that survive version bumps

generateParamTable walks bridgeParams(schema) and emits one codex_param_desc row per descriptor: the stable index as the automation id, path, label, group, type, range, step, engine and normalized defaults, units, and any enum option table.

The header also contains C translations of denormalize and normalize, and the comment above them is a specification: the math mirrors @codex-music/audio-contracts/src/params.ts bit-for-bit, codex_js_round is defined as floor(x + 0.5) because that is JavaScript's rounding rule and not C's, and any translation unit including the header must compile with -ffp-contract=off, because JS never fuses a + b * c into an FMA and neither may the C side. Those are the two places a naive port would silently diverge.

Deterministic class ids

VST3 identifies a plugin by a 16-byte TUID, and a session saved against one build must reopen against the next. deriveTuidWords(pluginId, role) derives four u32 words by FNV-1a hashing "<id> <role> <index>" for indices 0 through 3, with separate "processor" and "controller" roles; tuidWordsToHex formats them for INLINE_UID. Same plugin id, same TUID, forever — no registry and no generated GUID to check in and never lose.

State chunks

import {
  encodeStateChunk,
  decodeStateChunk,
} from "@codex-music/plugin-shell-kit";

const bytes = encodeStateChunk({ "tone.cutoff": 2500, "master.level": 0.9 });
const state = decodeStateChunk(bytes);

Preset JSON, UTF-8 encoded. That is the whole codec, and the plainness is the feature: a DAW's getState/setState chunk is byte-identical to what a web preset export produces, so a patch made in a browser opens in a session and back again.

The codexHost protocol

The shell injects window.codexHost before the UI bundle loads; the UI posts with postMessage and subscribes with onMessage. Messages are plain JSON discriminated on type, and parameter values are always engine values — the denormalized domain — never 0..1.

UI → host (CodexUiToHostMessage): codex.ready, codex.setParam, codex.beginEdit and codex.endEdit (gesture brackets around a drag, so host undo and automation recording see one edit rather than four hundred), codex.noteOn, codex.noteOff, codex.getState, codex.setState.

Host → UI (CodexHostToUiMessage): codex.hello, the reply to codex.ready, carrying plugin identity plus a full snapshot of every parameter as { path, value, normalized }; codex.paramChanged when something moved host-side via automation, a preset load, or another editor instance; and codex.state, a full restore or the reply to codex.getState.

These TypeScript types are the single source of truth. The native side implements the same shapes by hand in native/plugin-shell/src/bridge.h, with PROTOCOL.md as the human-readable specification.

What it produces

The generated shell is a thin VST3 entry plus a platform webview — no JUCE, no third-party UI bindings. The audio path runs the ScriptC-compiled kernel natively and never touches the webview or a JS engine; host MIDI and automation are translated into the same AudioEvent ABI the browser uses, which is the parity precondition. V1 scope is VST3 and standalone on macOS. pnpm verify:plugin-vs-offline loads the built .vst3 through the VST3 hosting API, replays a golden fixture with a preset chunk and an automation point, and byte-compares the PCM against the offline reference render.