@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-mini → fx-mini), manifestTargetName pascal-cases it
into a CMake target (CodexFxMini), and kernelProfileStem reduces the profile
path to an archive basename (profiles/fx1-chain.profile.json → fx1-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.