Skip to content

Edit MIDI Clips ​

A MIDI clip is a symbolic event list on a project timeline. This page covers the clip editing path: construct channel-voice events, replace a clip, check note pairs, route captured input, and choose between a non-destructive MIDI-FX chain and a baked result. For full-resolution MIDI 2.0 UMP builders and live multi-word messages, see MIDI 2.0, UMP, and Clip Files.

MIDI events are not audio

setMidiEvents changes the stored note and controller events. It does not render or alter PCM. Bind an instrument and bounce the project when the clip should become sound; see Project MIDI and Bouncing Projects.

Edit a MIDI clip
Captured /generated eventsValidate or routeProject MIDI clipMIDI-FX(optional bake)Instrument render
A clip stores symbolic events; routing and MIDI-FX happen before an instrument turns them into audio.

MIDI content ​

A MIDI clip holds a flat event list. Build events with the Project.midi* static packers (which produce the canonical MIDI 1.0 words) and replace the clip's list with setMidiEvents.

Numbers start at 0

Every packer takes (ppq, group, channel, …), and the numbers are the raw values on the wire, counted from 0:

ArgumentRangeWhat a MIDI device or GM chart calls it
group0–15The UMP group, a bank of 16 channels in MIDI 2.0 packets. Use 0 unless you route several groups
channel0–15MIDI channels 1–16; channel 9 is the GM drum channel 10
program0–127Programs 1–128; 4 is GM program 5, Electric Piano 1
note0–12760 is middle C (C4)

setMidiEvents discards the clip's SysEx

A clip's SysEx payloads (a GS Reset, a GS setup block made of manufacturer SysEx "Data Set" messages) live beside its event list, reached through a handle that ProjectMidiEvent does not carry. importSmf() keeps them, exportSmf() writes them back byte for byte, toJson() carries them, and an offline bounce realizes them: a GS insertion-effect type-select embedded in an imported SMF changes the rendered audio. setMidiEvents() replaces the list and leaves nothing referring to the payloads, so one call drops every frame, and there is no read-back entry point to save them through first. A caller that must preserve a GS setup block edits the exported file rather than the clip's event list.

typescript
import { Project, init } from '@libraz/libsonare';

await init();
const project = new Project();
project.setSampleRate(48000);
const { clipId: midiClip } = project.addMidiClip(0, 4);
project.setMidiEvents(midiClip, [
  Project.midiNoteOn(0, 0, 0, 60, 100),  // (ppq, group, channel, note, velocity)
  Project.midiNoteOff(2, 0, 0, 60),
  Project.midiNoteOn(2, 0, 0, 64, 100),
  Project.midiNoteOff(3.75, 0, 0, 64),
]);
project.setProgram(midiClip, 4);          // GM program (e.g. 4 = electric piano)
// Keep `project` for the following edit steps; call project.delete() when done.
python
import libsonare as sonare

project = sonare.Project()
project.set_sample_rate(48000)
_track_id, midi_clip = project.add_midi_clip(0.0, 4.0)
project.set_midi_events(midi_clip, [
    sonare.Project.midi_note_on(0.0, 0, 0, 60, 100),  # (ppq, group, channel, note, velocity)
    sonare.Project.midi_note_off(2.0, 0, 0, 60),
    sonare.Project.midi_note_on(2.0, 0, 0, 64, 100),
    sonare.Project.midi_note_off(3.75, 0, 0, 64),
])
project.set_program(midi_clip, 4)          # GM program (e.g. 4 = electric piano)
# Keep `project` for the following edit steps; call project.close() when done.

A clip like this, drawn as a piano roll and played:

MIDI · PIANO ROLLIDLE
A MIDI passage — same notes, any instrument

A three-voice phrase — melody, chords, and bass — drawn as a piano roll. The notes never change; switch the instrument and the engine bounces the exact same MIDI through a different built-in voice. Drag the tempo and the whole sequence speeds up or slows down. Press play to hear the passage; the playhead tracks the audio.

Instrument
Tempo
100 BPM

In Python the static packers are Project.midi_note_on(...) / Project.midi_note_off(...), each returning a (ppq, data0, data1) tuple, and the events list is any sequence of those tuples.

setProgram takes an optional third bank argument: setProgram(clipId, program, bank?). Omit it and no Bank Select is emitted; pass a value >= 0 to emit a Bank Select ahead of the program change. A negative bank is refused (RangeError on Node and WASM, SonareValueError in Python); -1 is not a way to say "none". To change the program on a specific UMP (Universal MIDI Packet) group and channel rather than the clip default, use setProgramOnChannel(clipId, group, channel, program, bank?). Both take the same optional bank across the WASM, Node, and Python bindings (set_program(clip_id, program, bank=None), set_program_on_channel(clip_id, group, channel, program, bank=None)).

ppq is in quarter notes, not ticks

The ppq argument is a position in quarter notes (a float), not a MIDI tick count. Project.midiNoteOn(1, …) is one quarter note in; Project.midiNoteOn(0.5, …) is an eighth note in. Despite the name, it is not 480-ticks-per-quarter: Project.midiNoteOn(480, …) schedules the note 480 quarter notes (120 bars) away, almost always far past your render window, so it silently never sounds. If you are converting from a tick-based source (an SMF at 480 PPQ, say), divide by the source's ticks-per-quarter first; see Timing: ticks and PPQ. The same unit applies to addMidiClip(startPpq, lengthPpq) and every clip/automation position on this page.

Every shipped static packer returns one or more MIDI 1.0 UMP words ready to drop into a setMidiEvents list:

PackerSignatureEvent
Note onProject.midiNoteOn(ppq, group, channel, note, velocity)Note-on
Note offProject.midiNoteOff(ppq, group, channel, note, velocity?=0)Note-off
Control changeProject.midiCc(ppq, group, channel, controller, value)CC
Program changeProject.midiProgram(ppq, group, channel, program)Program-change
Bank + programProject.midiBankProgram(ppq, group, channel, bankMsb, bankLsb, program)Bank-select + program-change (returns multiple events)
Poly pressureProject.midiPolyPressure(ppq, group, channel, note, pressure)Per-note aftertouch
Channel pressureProject.midiChannelPressure(ppq, group, channel, pressure)Channel aftertouch
Pitch bendProject.midiPitchBend(ppq, group, channel, bend)Pitch-bend; bend is unsigned 14-bit (0..16383, center 8192); out-of-range throws RangeError

The event-level Project.midiProgram(...) packer places a program-change word inside a clip's event list; it is distinct from the clip-level project.setProgram(midiClip, program) convenience shown above, which sets the clip's default program directly.

validateMidiNotes ​

Before bouncing, check a MIDI clip for hanging notes: a note-on with no matching note-off (or vice versa) plays a stuck note. validateMidiNotes pairs note-ons and note-offs FIFO per channel + note within the clip's playback window and reports the result. A note cut by the clip end or a loop boundary is closed with a note-off there, so a note that runs past the clip still validates as a matched pair. A note-on that never had a note-off is still reported.

typescript
const check = project.validateMidiNotes(midiClip);
// { ok: true, unmatchedNoteOns: 0, unmatchedNoteOffs: 0 }
if (!check.ok) {
  console.warn(`hanging notes: ${check.unmatchedNoteOns} on / ${check.unmatchedNoteOffs} off`);
}
python
check = project.validate_midi_notes(midi_clip)
# check.ok, check.unmatched_note_ons, check.unmatched_note_offs
if not check.ok:
    print(f"hanging notes: {check.unmatched_note_ons} on / {check.unmatched_note_offs} off")

To make a MIDI arrangement audible you bind an instrument at render time; see Rendering audio, the native synth, and the SoundFont player. For driving a project live from a controller, see MIDI input.

Route a captured MIDI stream ​

Project.midiRouteEvents(events, config?) is a static helper that runs a captured ProjectMidiEvent stream through the native MidiRouter (filter / remap / channel-thru), the same router the live runtime uses, and returns a ProjectMidiRouteResult. Use it to pre-filter or remap recorded input offline before building clips.

typescript
const routed = Project.midiRouteEvents(capturedEvents, {
  filterGroup: 0,        // keep group 0 only (omit / null = any)
  filterChannel: 9,      // keep channel 9 (the drum channel)
  remapChannel: 0,       // rewrite surviving events onto channel 0
  thru: true,            // pass matching events through
});
// routed.events       -> ProjectMidiEvent[]
// routed.overflowed   -> true if the router buffer dropped events
// routed.overflowCount-> number of dropped events
project.setMidiEvents(midiClip, routed.events);

Config fields are all optional and camelCase in JS/WASM (filterGroup, filterChannel, remapChannel, thru); a null or omitted filter field means "any", and an omitted remapChannel leaves the channel unchanged. Python uses snake_case (filter_group, filter_channel, remap_channel, thru). The helper ships across WASM, Node, and Python. Pair it with the offline CC learn flow below.

Learn a CC and turn it into automation ​

For an offline "wiggle a knob, capture which CC moved" flow, Project.midiCcLearn(events, paramId, options?) scans a captured event stream, picks the controller that moved, and returns a binding descriptor for the parameter paramId (or null when nothing qualifies). midiCcToBreakpoint(bindings, event) then turns a CC event into an automation breakpoint, and midiParamToCc(bindings, paramId, unitValue, group, ppq?) turns a parameter value back into a CC event. All three work on captured ProjectMidiEvent data, not on the live engine; for live CC-to-parameter binding see MIDI input.

typescript
const cc = (ppq: number, value: number) => Project.midiCc(ppq, 0, 0, 1, value); // CC1 on channel 0
const events = [cc(0, 10), cc(0.5, 100)];

const binding = Project.midiCcLearn(events, /* paramId */ 42, { minMovement: 20 });
// { ccNumber: 1, channel: 0, kind: 0, paramId: 42, minValue: 0, maxValue: 1, ... }

const point = Project.midiCcToBreakpoint([binding!], events[1]);
// { ppq: 0.5, value: 0.787, curveToNext: 0 }

const back = Project.midiParamToCc([binding!], 42, 0.5, /* group */ 0, /* ppq */ 1);
// a CC event at ppq 1
python
cc = lambda ppq, value: sonare.Project.midi_cc(ppq, 0, 0, 1, value)  # CC1 on channel 0
events = [cc(0.0, 10), cc(0.5, 100)]

binding = sonare.Project.midi_cc_learn(events, 42, min_movement=20)
point = sonare.Project.midi_cc_to_breakpoint([binding], events[1])   # (ppq, value, curve_to_next)
back = sonare.Project.midi_param_to_cc([binding], 42, 0.5, 0, 1.0)   # (ppq, data0, data1)

minMovement (Python min_movement) is an integer from 0 through 127 (default 0), measured in 7-bit CC units (a 14-bit pair is a low-numbered CC 0..31 plus the CC 32 numbers above it, explained in MIDI Basics; RPN/NRPN sequences are the parameter-number writes described there too); a value outside that range throws. With 0, the first observed standalone controller value binds immediately; a low-numbered CC (0..31) is held until the next message so it can still form a 14-bit pair. With a positive threshold, the controller must move by at least that many 7-bit units from its first value on the same UMP group and channel. MIDI 2.0 32-bit CC values are downscaled to those 7-bit units. The threshold applies only to standalone 7-bit controllers; 14-bit pairs and RPN/NRPN sequences are assembled without this gate.

Bake a MIDI-FX chain into a clip ​

A MIDI-FX chain (transpose, velocity curve, humanize, and so on) normally sits as a non-destructive layer over a clip's events. bakeMidiFx does the opposite: it runs the chain once and rewrites the clip's stored MIDI events with the result, so the transformed notes become the clip's real content. Bake when you want to freeze an effect into the arrangement; keep it non-destructive when you still want to tweak it.

typescript
const configJson = JSON.stringify({ transpose_semitones: 12 }); // up one octave
project.bakeMidiFx(midiClip, configJson);                        // events are now transposed in place

When an editor needs to preserve a selection or annotation through the rewrite, use the request form. sourceIndex has one entry per transformed event in canonical order: it always names the input event the output derives from, with an index in 0..inputCount-1. A chord or arpeggiator can produce several events with the same source index.

typescript
const count = project.previewMidiFxCount({ clipId: midiClip, configJson });
const { sourceIndex } = project.bakeMidiFx({
  clipId: midiClip,
  configJson,
  withSourceIndex: true,
});

previewMidiFxCount(...) runs the same deterministic transform without changing the project, so its result is the exact number of events the following bake produces. The positional bakeMidiFx(clipId, configJson) form remains available and returns no provenance. In Python, project.preview_midi_fx_count(clip_id, config_json) returns the count and project.bake_midi_fx(clip_id, config_json, with_source_index=True) returns the source indexes (without the flag it returns None).

The config is a JSON object whose stages are keyed by their parameters: include a stage's keys to enable it, omit them to skip it. Unknown keys are ignored, so a typo silently does nothing:

StageKeys
Transposetranspose_semitones
Velocity curvevelocity_scale, velocity_offset, velocity_gamma (>0); see below
Quantizequantize_ppq (>0), quantize_strength (0–1, default 1)
Humanizehumanize_ppq (>=0), humanize_velocity (0–127), seed (non-negative integer); deterministic timing/velocity jitter
Chordchord_intervals (array of semitone offsets, 1-8 entries)
Arpeggiatorarpeggiator_intervals (array of semitone offsets, 1-16 entries), arpeggiator_step_ppq (>0), arpeggiator_gate_ppq (defaults to the step length, capped to it)

The velocity curve applies to note-ons only, in this order: normalize the velocity to 0..1, raise it to velocity_gamma, multiply by velocity_scale, add velocity_offset (in 7-bit velocity units), then round and clamp to 1..127 (a note-on never falls to 0). As a formula, v' = clamp(127 · (v/127)^gamma · scale + offset). A gamma above 1 pulls soft notes down and leaves loud ones, and a gamma below 1 lifts soft notes. MIDI 2.0 note-ons are shaped in their full 16-bit range rather than rounded to 7 bits first.

Quantize moves events toward a grid of quantize_ppq quarter notes (0.25 is a sixteenth-note grid), counted from the clip's own start. Each note-on moves toward its nearest grid line by quantize_strength times the distance (1 lands on the line, 0.5 goes halfway), and its note-off moves by the same amount, so note lengths are kept. Controllers and other events keep their position. With a sixteenth grid at full strength, a note from 0.3 to 0.9 becomes 0.25 to 0.85.

chord_intervals is capped at 8 entries and arpeggiator_intervals at 16; an empty array, or one past either limit, makes bakeMidiFx throw a RangeError (SonareValueError in Python) rather than silently truncating.

typescript
// Turn each held note into a three-step up-arpeggio, one sixteenth per step.
project.bakeMidiFx(midiClip, JSON.stringify({
  arpeggiator_intervals: [0, 4, 7],
  arpeggiator_step_ppq: 0.25,
  arpeggiator_gate_ppq: 0.2,
}));

Because the rewrite is destructive, it is an undoable edit like any other, and undo() restores the original events.