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.
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:
| Argument | Range | What a MIDI device or GM chart calls it |
|---|---|---|
group | 0–15 | The UMP group, a bank of 16 channels in MIDI 2.0 packets. Use 0 unless you route several groups |
channel | 0–15 | MIDI channels 1–16; channel 9 is the GM drum channel 10 |
program | 0–127 | Programs 1–128; 4 is GM program 5, Electric Piano 1 |
note | 0–127 | 60 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.
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.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:
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:
| Packer | Signature | Event |
|---|---|---|
| Note on | Project.midiNoteOn(ppq, group, channel, note, velocity) | Note-on |
| Note off | Project.midiNoteOff(ppq, group, channel, note, velocity?=0) | Note-off |
| Control change | Project.midiCc(ppq, group, channel, controller, value) | CC |
| Program change | Project.midiProgram(ppq, group, channel, program) | Program-change |
| Bank + program | Project.midiBankProgram(ppq, group, channel, bankMsb, bankLsb, program) | Bank-select + program-change (returns multiple events) |
| Poly pressure | Project.midiPolyPressure(ppq, group, channel, note, pressure) | Per-note aftertouch |
| Channel pressure | Project.midiChannelPressure(ppq, group, channel, pressure) | Channel aftertouch |
| Pitch bend | Project.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.
const check = project.validateMidiNotes(midiClip);
// { ok: true, unmatchedNoteOns: 0, unmatchedNoteOffs: 0 }
if (!check.ok) {
console.warn(`hanging notes: ${check.unmatchedNoteOns} on / ${check.unmatchedNoteOffs} off`);
}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.
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.
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 1cc = 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.
const configJson = JSON.stringify({ transpose_semitones: 12 }); // up one octave
project.bakeMidiFx(midiClip, configJson); // events are now transposed in placeWhen 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.
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:
| Stage | Keys |
|---|---|
| Transpose | transpose_semitones |
| Velocity curve | velocity_scale, velocity_offset, velocity_gamma (>0); see below |
| Quantize | quantize_ppq (>0), quantize_strength (0–1, default 1) |
| Humanize | humanize_ppq (>=0), humanize_velocity (0–127), seed (non-negative integer); deterministic timing/velocity jitter |
| Chord | chord_intervals (array of semitone offsets, 1-8 entries) |
| Arpeggiator | arpeggiator_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.
// 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.
Related
- Audio to MIDI — turn a mono recording into project MIDI events
- MIDI 2.0, UMP, and Clip Files — full-resolution builders, live UMP, and lossless interchange
- MIDI Input — route live controller input to an instrument