Build a MIDI tool
No audio path: take note input, emit chords, and route the result to another tool or your DAW.
Scope note: the
midi-tooltemplate and the MIDI-only export target are part of the proposed CLI surface. The event ABI it is built on is real and shipped — everything below aboutAudioEventdescribes types you can use today.
A MIDI tool has no io.inputs to read and writes no samples. It consumes note
events and emits note events. Arpeggiators, chord generators, scale quantizers,
velocity mappers, humanizers — all the same shape.
Start from the MIDI template
npx codex create my-chords --template midi-tool
cd my-chords
npm run dev
The dev UI gives you an on-screen keyboard and a device picker, and prints the event stream going in and coming out. That printout is the whole debugging story for a tool like this.
The event vocabulary is the same one
There is no separate MIDI type. A MIDI tool reads and writes the same
AudioEvent union every kernel speaks:
type AudioEvent =
| { kind: "param"; frameOffset: number; path: string; value: number }
| { kind: "noteOn"; frameOffset: number; note: number; velocity: number }
| { kind: "noteOff"; frameOffset: number; note: number };
That is deliberate. Because the vocabulary is shared, the output of a MIDI tool
plugs into an instrument kernel with no adapter, and the DAW's MIDI stream plugs
into your tool the same way. note is a MIDI note number, velocity is 0–1,
and frameOffset is an integer frame inside the current block.
Emit a triad
Chord generation is the "hello world" here: for every incoming noteOn, emit
three, at the root and two scale intervals above it.
process(state, _io, params, events): void {
const quality = params["chord.quality"]; // 0 = major, 1 = minor
const third = quality < 0.5 ? 4 : 3;
const fifth = 7;
for (let e = 0; e < events.length; e = e + 1) {
const ev = events[e];
if (ev.kind === "noteOn") {
emitNoteOn(state, ev.frameOffset, ev.note, ev.velocity);
emitNoteOn(state, ev.frameOffset, ev.note + third, ev.velocity);
emitNoteOn(state, ev.frameOffset, ev.note + fifth, ev.velocity);
} else if (ev.kind === "noteOff") {
emitNoteOff(state, ev.frameOffset, ev.note);
emitNoteOff(state, ev.frameOffset, ev.note + third);
emitNoteOff(state, ev.frameOffset, ev.note + fifth);
}
}
}
Preserve frameOffset exactly. A chord whose notes land on different frames is
a strummed chord, and if you did not mean to write a strummer you have written a
bug that only shows up on tight material.
The output buffer is preallocated too
The kernel rules do not relax because you stopped touching audio. Your emitted
events go into a fixed-size buffer allocated in init(), with a write cursor —
not into an array you push onto.
init(_sampleRate: number): ChordState {
const capacity = 256;
return {
outKind: new Int32Array(capacity),
outFrame: new Int32Array(capacity),
outNote: new Int32Array(capacity),
outVelocity: new Float32Array(capacity),
outCount: 0,
};
}
Pick a capacity and drop events past it rather than growing. A dropped note is a bad afternoon; an allocation on the audio thread is a click in someone's mix.
Track what you sent
noteOff is where MIDI tools get interesting. If a parameter changes between a
note's start and its end — the user switches major to minor mid-note — the
intervals you would compute at noteOff no longer match the ones you emitted at
noteOn, and you leave a hanging note.
The fix is to record the emitted note numbers per incoming note in state and
release exactly those, rather than recomputing. Latch parameters at noteOn and
use the latched values for the matching noteOff.
Routing and export
In the browser, chain a MIDI tool's output into any instrument's engine — same vocabulary, no glue. In a DAW, the export produces a MIDI-effect plugin that sits ahead of an instrument on the same track, so your DAW's own instruments work with it too.
Read How it works for where events sit in the pipeline, and Build a synth if you want something of your own to feed.