Skip to content

midiports: arrival times for every incoming message - #211

Merged
bdbarnett merged 3 commits into
mainfrom
alignment/midi-timestamps
Oct 16, 2026
Merged

bdbarnett merged 3 commits into
mainfrom
alignment/midi-timestamps

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Every midiports input port now remembers when each message arrived, so a looper, a recorder or a quantiser can place notes where they were played rather than where your loop got round to them. Playing stays as it was: read a message and act on it at once.

msg = midi.receive()
if msg is not None:
    when = midi_in.take_timestamp()   # microseconds, or None

This builds on #208 and uses the same take_timestamp() it added for Web MIDI, so there's one contract. Until #208 merges, its commit shows here too; the change for review is the last commit.

The contract

Documented once, in the package docstring:

  • Times are whole microseconds on midiports.ticks_us(). That's time.ticks_us() on MicroPython, which wraps, so compare with midiports.ticks_diff(). It's time.monotonic_ns() // 1000 on CPython, and the same on CircuitPython except that it wraps at 2**30 on a board. In Pyodide it's performance.now(), the clock Web MIDI stamps events with.
  • You get one stamp per message, in the order the messages come out. Every System Real-Time byte counts, including one that arrived inside another message. Running status is expanded and each message is stamped at its first data byte. A SysEx gets one stamp, for its 0xF0. A message split across reads is stamped when its first byte arrived. Bytes the port drops get no stamp.
  • take_timestamp() returns None when no stamp is waiting.
  • To pair stamps with adafruit_midi, take one for every object receive() returns, of any type. Leave in_channel at its default, because a message that adafruit_midi filters out is consumed without being returned.
  • Stamps wait in a fixed ring made once, so stamping allocates nothing. If you never take them, the oldest are forgotten.

Where each port stamps

Port Stamped As fine as
usb_device in on_midi_event, as the USB stack hands the event over one USB frame (1 ms full speed), plus the scheduled callback's wait
webmidi by the browser (its timeStamp) the browser's event time
uart, linux (via WholeMessages) at the read that finds the message's first byte how often you poll
windows when a read drains winmm's window queue how often you poll (the queue's own post time ticks in ~16 ms steps)
usbif_shim at the read that finds the bytes how often you poll

Web MIDI stamps change from float milliseconds to integer microseconds, to match the rest. A relayed message without a timeStamp is now stamped when it reaches the worker, where before it got None.

Real-time bytes (clock, start, stop and the rest) that arrive inside a SysEx, as the MIDI spec allows, now come out just after the SysEx ends. Each one is its own message with its own arrival time, in order. WholeMessages (UART, Linux) and usb_device hold up to 8 of them, and the usbif shim's input now goes through WholeMessages too. Before, they went out in place, and adafruit_midi dropped both the clock and the SysEx. A tempo-synced instrument would have drifted during a SysEx dump. A SysEx that another message cuts short is ended with an 0xF7 first. Web MIDI already delivers these as separate events. Windows input carries no SysEx.

Tests

tests/midiports_contract.py gains a stamp check for every port: WholeMessages, uart, usb_device, usbif_shim, windows and webmidi. A fake clock moves on at each read, so each message's stamp names the read that found it. The checks cover running status, a clock in the middle of a note, a SysEx (and a clock inside one), a message split across two reads, and a full USB ring dropping messages, and clock and start bytes inside a SysEx on every port that can carry one. On MicroPython the checks also confirm that the ring and a burst through WholeMessages allocate nothing.

  • CPython: 33 passed. Unix MicroPython 1.29 (bin/micropython) and a current unix build: 33 passed each. tests/test_midiports.py: 23 passed.
  • The planted fault --plant=skip-clock-stamp leaves a clock byte unstamped. It fails the stamp checks on all six ports, the SysEx cases included, under both interpreters, and test_midiports.py asserts that it does.

Not covered: adafruit_midi itself drops a SysEx when it only sees part of it in one buffer, so a long SysEx that arrives over several reads is lost whatever the port does.

… port

take_timestamp() on every input port pops when the next message arrived:
whole microseconds on midiports.ticks_us() (time.ticks_us() on MicroPython,
time.monotonic_ns() // 1000 on CPython, performance.now() in Pyodide), one
per message in order, realtime bytes included. Running status stamps each
message at its first data byte, a SysEx at its 0xF0, a split message at its
first byte. The stamps live in a fixed ring, so stamping allocates nothing.

USB device input is stamped in the event callback; UART, Linux, Windows and
the usbif shim at the read that finds the bytes; Web MIDI keeps the
browser's own times, now in microseconds like the rest.

WholeMessages now holds a realtime byte that arrives inside a SysEx until the
SysEx ends, since adafruit_midi loses both otherwise.

The contract test checks the pairing on every port across running status, a
clock mid-message, SysEx and a split read, and a planted fault (a clock left
unstamped) fails it on every port.
…ges over USB and usbif

The MIDI spec lets clock, start, stop and the other real-time bytes land
inside a SysEx. usb_device now holds each one, with its arrival time, until
the SysEx ends, as WholeMessages does for byte streams; a SysEx another
message cuts short is ended with an 0xF7 first. The usbif shim's input goes
through WholeMessages for the same reason.

The contract test covers it on usb_device, WholeMessages, the usbif shim and
Web MIDI, and the planted unstamped clock fails each case.
Base automatically changed from alignment/midi to main October 16, 2026 06:18
@bdbarnett
bdbarnett merged commit 33c439b into main Oct 16, 2026
2 checks passed
@bdbarnett
bdbarnett deleted the alignment/midi-timestamps branch October 16, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant