Updated:
Tested on: MIDIFabric development build (macOS); Lua 5.5; all modes, patterns and scales covered by automated tests, playing on real instruments not yet checked

We rewrote MIDIFabric’s arpeggiator, previously built into the Rust core, as a bundled Lua effect.

The arpeggiator consists of about 400 lines of Lua, including comments, and a control-panel definition. You can read the code in the app and create an editable effect from the bundled template. The Rust core handles scheduling, cancellation and MIDI output; the Lua code determines which notes to play and when.

The control panel

Select the arpeggiator on the routing canvas and its control panel opens.

Arpeggiator1 selected on the routing canvas, with its control panel open: Time base, Note value, Gate, Octaves, Mode, Accent, Pattern and Hold

The arpeggiator processes MIDI 1.0 notes. MIDI 2.0 messages pass through unchanged, so the MIDI 2.0 output connected in this screenshot will not produce an arpeggio.

  • Time base: set the step interval in milliseconds, or follow MIDI clock. Following clock, it keeps up with tempo changes.
  • Note value / Time: the length of one step, as a note value such as 1/8 with clock, or as a time in milliseconds.
  • Gate: how much of each step the note sounds. 0.6 means 60 percent.
  • Octaves: how many octaves the held notes are spread over.
  • Mode: Held plays the notes you hold, in turn. Degree matches the last note you pressed to the chosen scale and plays scale degrees such as 1-3-5. Choosing Degree reveals the Root, Scale and degree controls.
  • Accent: a pattern of step levels. A step with 0 is a rest.
  • Pattern: Up, Down, Up / Down or Random.
  • Hold: keeps playing after you let go of the keys.

The panel’s controls and layout are also written in the definition (Params and Layout), not in code. Changing how the panel looks does not require rebuilding the app.

How the code is put together

The Program tab of the effect editor shows the code.

The Program tab of the effect editor showing the arpeggiator's Lua code, with functions for adding time, releasing a note and stopping a sequence

There are two entry points: on_midi, called when MIDI arrives, and on_scheduled, called at a time the effect reserved.

  • on_midi records keys pressed and released. On the first Note On it plays the first note right away.
  • One step is handled in tick. It builds the candidate notes, uses the accent pattern to decide whether this step is a rest, plays one note and reserves the next step.
local function tick(ctx, stream, id, due)
  local p = ctx.params
  local notes = candidates(stream, p)
  if #notes == 0 then
    stop_stream(ctx, stream, id)
    return
  end

  local now, interval, step_due = step_timing(ctx, stream, due)
  -- A zero accent is a rest: advance the accent step without consuming a pitch.
  local accent = accents[p.accent]
  local gain = accent[stream.step % #accent + 1]
  stream.step = stream.step + 1
  if gain > 0 then
    local index = next_pitch_index(stream, #notes, p.pattern)
    play_note(ctx, stream, notes[index], gain, now, interval)
  end
  schedule_next(ctx, stream, id, now, interval, step_due)
end

Tracking which keys are down is left to note_state, a shared Lua module that comes with the app and that other effects can use too.

Scheduling notes

The Lua code uses ctx:schedule to request a callback, ctx:schedule_emit to schedule Note Offs and ctx:cancel to cancel pending events. The app manages the event queue and MIDI output timing.

  • Following clock, time is counted in beats and never mixed with times in milliseconds.
  • If a call comes late, past steps are not played all at once to catch up. Only the next step is reserved again, from now.
  • If no clock has arrived yet, the arpeggio does not start and the notes you play pass through as they are.

Details we decided

We compared the earlier Rust implementation with the effect definition and resolved differences in their behavior during the port.

  • A separate sequence for each input. Input from a different port, protocol, group or channel is never merged into one arpeggio.
  • Up / Down does not repeat the end notes. With three notes it goes up and back down, and the top and bottom notes are never played twice in a row.
  • Setting changes apply from the next step. The length of the note already sounding and the time of the next reserved step stay as they are.
  • Reproducible Random sequences. Random uses a fixed number sequence of its own for each placed effect, so playing the same way right after placing it again gives the same order.
  • Clear held notes when routing stops. After the routing is stopped and started again, notes that were held before do not start playing. All Notes Off (CC 123) and All Sound Off (CC 120) stop the sequence for that input.
  • The previous note stops before a new one. A previously reserved Note Off can never cut off a newer note.

What we checked

Automated tests cover every mode, pattern, root, scale, degree set and accent, plus the ends of the note range, note number 0, short notes, re-pressed keys, Note Offs after a setting change, missing and interrupted clock, separation of inputs, and All Notes Off. We also checked on the real routing path that held notes do not come back after the routing is stopped and started again.

Why we wrote it in Lua

A bundled Lua effect can serve as an editable template. Users can inspect the arpeggiator, copy it and change its behavior without rebuilding MIDIFabric. An AI can also read the bundled code as an example when writing another effect (see Connecting MIDIFabric to AI through MCP).

Device definitions hold instrument-specific MIDI information; Lua programs define effect behavior. Both can be edited by users. The device-definition format is described in How CC 102 became “OP1 Level”.

← All developer notes