From 55ceada390fa3e14d0b049a0680aa30ef2ce1114 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:36:19 -0500 Subject: [PATCH 1/2] midiports: an arrival time for every incoming message, on every input 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. --- midiports/README.md | 52 +++-- midiports/midiports/__init__.py | 235 ++++++++++++++++++++- midiports/midiports/linux.py | 6 + midiports/midiports/uart.py | 6 + midiports/midiports/usb_device.py | 22 +- midiports/midiports/usbif_shim.py | 18 +- midiports/midiports/webmidi.py | 62 +++--- midiports/midiports/windows.py | 14 +- tests/midiports_contract.py | 325 +++++++++++++++++++++++++++++- tests/test_midiports.py | 49 +++-- 10 files changed, 710 insertions(+), 79 deletions(-) diff --git a/midiports/README.md b/midiports/README.md index a64511d..e8ea604 100644 --- a/midiports/README.md +++ b/midiports/README.md @@ -79,6 +79,41 @@ A port that polls beside audio should call `readinto` with a buffer of its own. `read` makes a new `bytes` for every message that arrives, though an empty poll allocates nothing. +## When each message arrived + +Every input port keeps the time each message arrived, so a looper or a +recorder can place notes where they were played, not where your loop got +round to them. Call `midi_in.take_timestamp()` once for each message +`receive()` gives you, whatever its type, clocks included: + +```python +import midiports + +msg = midi.receive() +if msg is not None: + when = midi_in.take_timestamp() # microseconds, or None + played_ago = midiports.ticks_diff(midiports.ticks_us(), when) +``` + +Times are whole microseconds: `time.ticks_us()` on MicroPython, which wraps, +so subtract with `midiports.ticks_diff`; `time.monotonic_ns() // 1000` on +CPython and CircuitPython; Web MIDI's own `performance.now()` clock in a +browser. Leave `MIDI`'s `in_channel` at its default and filter on +`msg.channel` yourself, because a message `adafruit_midi` filters out never +reaches you but its time would. + +How close a time is depends on the transport: + +| Port | Stamped | As fine as | +|---|---|---| +| `usb_device` | in the USB event callback | one USB frame (1 ms at full speed), plus the wait for the scheduled callback | +| Web MIDI | by the browser | the browser's event time | +| `uart`, `linux`, `windows`, `usbif_shim` | by the read that finds the bytes | how often you poll | + +The [package docstring](midiports/__init__.py) has the rules: one time per +message in order, how running status, SysEx and a message split across reads +are stamped, and what happens if you don't take them. + ## In a browser Python running in Pyodide reaches MIDI devices through Web MIDI. The browser @@ -110,19 +145,10 @@ messages are plain objects: | page to worker | `{data: Uint8Array, timeStamp: number}`: one whole MIDI message per post, `timeStamp` from its `MIDIMessageEvent` | | worker to page | `{data: Uint8Array}`: whole messages to `send()` to the page's output | -Every Web MIDI input keeps the time each message arrived, in milliseconds on -the `performance.now()` clock. Call `midi_in.take_timestamp()` once for each -message `receive()` gives you and you get that message's time, or `None` when -there is none: - -```python -msg = midi.receive() -if msg is not None: - when = midi_in.take_timestamp() -``` - -That works because Web MIDI hands over whole messages, one timestamp each. -The [module docstring](midiports/webmidi.py) has the details. +Every Web MIDI input keeps the browser's time for each message; see +[When each message arrived](#when-each-message-arrived). The +[module docstring](midiports/webmidi.py) has the details, including which +clock a worker's relayed times are on. ## Limits diff --git a/midiports/midiports/__init__.py b/midiports/midiports/__init__.py index 451d3e4..510839d 100644 --- a/midiports/midiports/__init__.py +++ b/midiports/midiports/__init__.py @@ -53,11 +53,73 @@ A port that holds an OS handle (the desktop ones) also has ``close()`` and works as a context manager. The others accept ``close()`` and do nothing. +Arrival times +------------- +Every input port also has ``take_timestamp()``. It returns when the next +message arrived, oldest first, one per whole message the port hands out, so +something that records (a loop, a pattern, a file) can place notes where they +were played rather than where your loop got round to them:: + + msg = midi.receive() + if msg is not None: + when = midi_in.take_timestamp() + +The time is a whole number of microseconds on the clock :func:`ticks_us` +reads, which is: + +- MicroPython: ``time.ticks_us()``. It wraps (after about 18 minutes on a + board), so subtract two times with :func:`ticks_diff`, never ``-``. +- CPython: ``time.monotonic_ns() // 1000``. It doesn't wrap, and + :func:`ticks_diff` is a plain subtraction. +- CircuitPython: the same, but on a board it wraps at 2**30 as MicroPython's + does, so a stamp fits the board's 32-bit arrays. +- Pyodide: the page's ``performance.now()``, times 1000, the clock Web MIDI + stamps its events with. + +Use ``midiports.ticks_us()`` and ``midiports.ticks_diff()`` to compare a stamp +with the time now, and the same code runs everywhere. + +There is one stamp per message, in the order the messages come out: + +- Each System Real-Time byte (clock, start, continue, stop, active sensing, + reset) is a message and gets its own stamp, even one that arrived inside + another message. It comes out ahead of that message, and so does its stamp. +- Running status is expanded into whole messages, and each one is stamped + with the time its first data byte arrived. +- A SysEx gets one stamp, for its 0xF0. Its 0xF7 gets none, nor does the + 0xF7 a port supplies when another status cuts a SysEx short. +- A message split across two reads is stamped when its first byte arrived. +- Bytes a port drops (a full ring, a data byte with no status) get no stamp. + +``take_timestamp()`` returns ``None`` when no stamp is waiting: nothing has +arrived, or you've taken one for every message. + +Pairing with ``adafruit_midi`` works because ``receive()`` returns one object +for each message, of every type: ``MIDIUnknownEvent`` and ``MIDIBadEvent`` +count too. Take a stamp for each, even ones you ignore. Leave ``MIDI``'s +``in_channel`` at its default, all channels: a message on a channel it +filters out is consumed without being returned, and its stamp would be +handed to the next message. Filter by ``msg.channel`` yourself instead. + +The stamps wait in a fixed ring, made once, so stamping allocates nothing. If +you never take them, the oldest are forgotten and nothing else changes. Take +one for every message from the start, or none at all: a reader that starts +taking stamps partway through gets old ones first. + +How close the stamp is to the moment the byte reached the board or the +computer depends on the transport, and each module says. Where a transport +calls back when data arrives (USB on a board, Web MIDI), the stamp is taken +there. Where it doesn't (a UART, Windows, Linux, usbif), it's taken by the +read that finds the bytes, so it's only as fine as how often you poll. + :class:`PortIn` and :class:`PortOut` are bases a transport can build on: a subclass writes ``readinto`` or ``write`` and inherits the rest. """ +from array import array from collections import namedtuple +import sys +import time __version__ = "0.0.1" @@ -75,6 +137,130 @@ PortInfo = namedtuple("PortInfo", " ".join(PORT_FIELDS)) # noqa: PYI024 +# -- the clock stamps are taken on ---------------------------------------------------- + +if hasattr(time, "ticks_us"): # MicroPython + ticks_us = time.ticks_us + ticks_diff = time.ticks_diff + + def _typecode(): + # The narrowest array that holds every tick and reads back without + # allocating: "l" is 32 bits on a board, where ticks wrap at 2**30, + # and 64 on the unix port. "q" reads back as a new long int, so it's + # the last resort (the Windows port, whose "l" is 32 bits). + top = time.ticks_add(0, -1) + for tc in ("l", "q"): + try: + if array(tc, [top])[0] == top: + return tc + except (OverflowError, ValueError): + pass + return "q" + + _TICKS_TYPE = _typecode() +else: + if sys.platform == "emscripten": # Pyodide: Web MIDI's own clock + + from js import performance as _performance + + def ticks_us(): + """Microseconds now, on ``performance.now()``.""" + return int(_performance.now() * 1000) + + elif hasattr(time, "monotonic_ns"): + + def _now_us(): + return time.monotonic_ns() // 1000 + + else: # a CircuitPython build without long ints + + def _now_us(): + return int(time.monotonic() * 1000000) + + _TICKS_TYPE = "q" + try: + array("q") + except ValueError: # CircuitPython has no 64-bit arrays + _TICKS_TYPE = "l" + try: + _wide = array(_TICKS_TYPE, [1 << 40])[0] == 1 << 40 + except OverflowError: + _wide = False + + if _wide: + if sys.platform != "emscripten": + + def ticks_us(): + """Microseconds now, on ``time.monotonic_ns()``.""" + return _now_us() + + def ticks_diff(end, start): + """``end - start`` in microseconds; this clock doesn't wrap.""" + return end - start + + else: + # A board whose arrays are 32 bits wraps the clock as MicroPython + # does, at 2**30 us (about 18 minutes), so a stamp always fits. + _PERIOD = 1 << 30 + + def ticks_us(): + """Microseconds now, wrapping at 2**30.""" + return _now_us() & (_PERIOD - 1) + + def ticks_diff(end, start): + """``end - start`` in microseconds, across a wrap.""" + d = (end - start) & (_PERIOD - 1) + return d - _PERIOD if d >= _PERIOD >> 1 else d + + +class Stamps: + """Arrival times, one per message, oldest first, in a ring made once. + + One writer (where messages arrive) pushes and one reader (``take``) pops; + neither allocates. When the ring is full the oldest stamp is forgotten. + """ + + def __init__(self, size=64): + self._t = array(_TICKS_TYPE, [0] * (size + 1)) + self._head = 0 + self._tail = 0 + + def push(self, t): + t_ = self._t + head = self._head + t_[head] = t + head += 1 + if head == len(t_): + head = 0 + if head == self._tail: + # Full of stamps nobody took: forget the oldest. + tail = self._tail + 1 + self._tail = 0 if tail == len(t_) else tail + self._head = head + + def take(self): + tail = self._tail + if tail == self._head: + return None + stamp = self._t[tail] + tail += 1 + self._tail = 0 if tail == len(self._t) else tail + return stamp + + def __len__(self): + return (self._head - self._tail) % len(self._t) + + +def _stamp(stamps, status, t): + """Stamp ``t`` if ``status`` is the first byte of a message. + + It is unless it's a data byte or 0xF7, which ends a SysEx. Every port + decides through here, so the rule lives in one place. + """ + if status >= 0x80 and status != 0xF7: + stamps.push(t) + + def _count(buf, nbytes): n = len(buf) return n if nbytes is None or nbytes > n else nbytes @@ -91,10 +277,19 @@ class PortIn: def __init__(self, size=64): self._scratch = bytearray(size) self._view = memoryview(self._scratch) + self._stamps = None def readinto(self, buf, nbytes=None): raise NotImplementedError + def take_timestamp(self): + """When the next message arrived (see the contract), or ``None``. + + A subclass that stamps keeps a :class:`Stamps` in ``_stamps``. + """ + stamps = self._stamps + return None if stamps is None else stamps.take() + def read(self, nbytes=None): n = self.readinto(self._view, _count(self._scratch, nbytes)) if not n: @@ -161,23 +356,38 @@ class WholeMessages(PortIn): Running status is expanded, so ``90 3C 64 3E 50`` reads as two complete note-ons. A realtime byte that arrives inside a message comes out ahead - of it, and the message stays whole; inside a SysEx it stays where it - arrived, since a SysEx can be too long to hold back. A status byte that cuts a SysEx short + of it, and the message stays whole. One inside a SysEx comes out just + after the SysEx ends, since a SysEx can be too long to hold back and + ``adafruit_midi`` loses both when a clock sits inside one; its stamp still + says when it arrived. Up to 8 are held that way; beyond that they stay + where they arrived. A status byte that cuts a SysEx short ends it with an 0xF7, as the MIDI spec says it ends. A data byte with no status to belong to (a cable plugged in mid-message) is dropped and counted in :attr:`desync`. + Each message is stamped with the time the read that found its first byte + returned (see the contract's arrival times), so a stamp is as fine as how + often you poll. ``stamps`` sizes the ring; the default holds one for every + message this port and ``adafruit_midi`` can have waiting at once. + Nothing is allocated per byte or per poll: the state lives in buffers made once. """ - def __init__(self, port, size=32): + def __init__(self, port, size=32, stamps=None): PortIn.__init__(self, 2 * size + 4) + self._stamps = Stamps(2 * size + 36 if stamps is None else stamps) + self._now = 0 + self._t0 = 0 self.port = port self.desync = 0 self._raw = bytearray(size) self._rawv = memoryview(self._raw) - self._out = bytearray(2 * size + 4) + self._out = bytearray(2 * size + 12) + # Realtime bytes held back while a SysEx runs, with their times. + self._rt = bytearray(8) + self._rtt = array(_TICKS_TYPE, [0] * 8) + self._nrt = 0 self._start = 0 self._end = 0 self._msg = bytearray(3) @@ -192,13 +402,24 @@ def _emit(self, b): def _byte(self, b): if b >= 0xF8: + n = self._nrt + if self._sysex and n < 8: + self._rt[n] = b + self._rtt[n] = self._now + self._nrt = n + 1 + return self._emit(b) + _stamp(self._stamps, b, self._now) return if b >= 0x80: if self._sysex: # Any status ends a SysEx: its own 0xF7, or one we supply. self._sysex = False self._emit(0xF7) + for i in range(self._nrt): + self._emit(self._rt[i]) + _stamp(self._stamps, self._rt[i], self._rtt[i]) + self._nrt = 0 if b == 0xF7: return elif b == 0xF7: @@ -208,12 +429,14 @@ def _byte(self, b): self._sysex = True self._status = 0 self._emit(b) + _stamp(self._stamps, b, self._now) return if b >= 0xF0: self._status = 0 need = _COMMON_LEN.get(b, 0) if not need: self._emit(b) + _stamp(self._stamps, b, self._now) return else: self._status = b @@ -221,6 +444,7 @@ def _byte(self, b): self._msg[0] = b self._have = 1 self._need = 1 + need + self._t0 = self._now return if self._sysex: self._emit(b) @@ -232,12 +456,14 @@ def _byte(self, b): self._msg[0] = self._status self._have = 1 self._need = 1 + _VOICE_LEN.get(self._status >> 4, 0) + self._t0 = self._now # running status: the first data byte's time self._msg[self._have] = b self._have += 1 if self._have == self._need: for i in range(self._need): self._emit(self._msg[i]) self._have = 0 + _stamp(self._stamps, self._msg[0], self._t0) def any(self): if self._start == self._end: @@ -249,6 +475,7 @@ def any(self): n = self.port.readinto(self._rawv, len(raw)) if not n: return 0 + self._now = ticks_us() for i in range(n): self._byte(raw[i]) return self._end - self._start diff --git a/midiports/midiports/linux.py b/midiports/midiports/linux.py index fcc7dfe..db4fa82 100644 --- a/midiports/midiports/linux.py +++ b/midiports/midiports/linux.py @@ -25,6 +25,12 @@ Inputs open non-blocking, so a read on a silent instrument returns ``None`` instead of hanging your loop. + +The input ``ports()`` returns stamps each message with its arrival time +(``take_timestamp()``, the package's contract) through ``WholeMessages``, at +the read that finds its first byte: as fine as how often you poll. A port +from ``open_port()`` is the raw byte stream and has no stamps until you wrap +it in ``WholeMessages`` too. """ import os diff --git a/midiports/midiports/uart.py b/midiports/midiports/uart.py index d7eda0b..9d2a898 100644 --- a/midiports/midiports/uart.py +++ b/midiports/midiports/uart.py @@ -16,6 +16,12 @@ ``ports`` passes anything else (``rxbuf``, ``invert``, ...) on to ``machine.UART``. +Each message is stamped with its arrival time (``midi_in.take_timestamp()``, +the package's contract) by the read that finds its first byte. The UART's +driver buffers bytes without saying when each came, so a stamp is as fine as +how often you poll, never finer than one byte's 320 us on the wire, and +every message one read finds shares its time. + To use a UART you've already made, check its timeout is 0 and pass ``MIDI(midi_in=WholeMessages(u), midi_out=u)``. diff --git a/midiports/midiports/usb_device.py b/midiports/midiports/usb_device.py index e69eedf..e809a8d 100644 --- a/midiports/midiports/usb_device.py +++ b/midiports/midiports/usb_device.py @@ -25,6 +25,13 @@ (``mip.install("usb-device-midi")``), unless your firmware already has it frozen in. It needs a port with ``machine.USBDevice``. +Each message is stamped with its arrival time (``midi_in.take_timestamp()``, +the package's contract) in ``on_midi_event``, as the USB stack hands the +event over. That's within one USB frame of the host sending it (1 ms at full +speed, 125 us at high speed), plus however long the scheduled callback waits +for your code to reach a point where it can run. Every event in one USB +packet arrives together, so a chord or a burst shares nearly one time. + Received bytes wait in a ring (``ring`` bytes, 256 by default). If you don't read for long enough to fill it, whole messages are dropped and counted in ``MIDIPortsInterface.overflows``. Sending waits briefly for room when the host @@ -37,7 +44,7 @@ from usb.device.midi import MIDIInterface -from . import PortIn, PortOut, _count +from . import PortIn, PortOut, Stamps, _count, _stamp, ticks_us # Bytes carried by each USB-MIDI Code Index Number (USB MIDI 1.0, table 4-1). # 0 and 1 are reserved (cable events) and carry nothing we pass on. @@ -53,6 +60,7 @@ class _In(PortIn): def __init__(self, itf): PortIn.__init__(self) self._itf = itf + self._stamps = itf._stamps def any(self): itf = self._itf @@ -73,9 +81,12 @@ def write(self, buf, nbytes=None): class MIDIPortsInterface(MIDIInterface): """A USB MIDI interface whose ``ports`` are ``(midi_in, midi_out)``.""" - def __init__(self, ring=256, rxlen=64, txlen=128): + def __init__(self, ring=256, rxlen=64, txlen=128, stamps=None): super().__init__(rxlen=rxlen, txlen=txlen) self._ring = bytearray(ring) + # One stamp per message: room for a ring of one-byte clocks, plus + # the messages adafruit_midi has read ahead. + self._stamps = Stamps(ring + 32 if stamps is None else stamps) # One producer (the scheduled USB callback) moves _head, one consumer # (your read) moves _tail. Neither touches the other's index, so a # callback landing in the middle of a read can't tear the ring. @@ -103,6 +114,9 @@ def on_midi_event(self, cin, midi0, midi1, midi2): if (self._tail - head - 1) % size < n: self.overflows += 1 return + # A SysEx's continuation and end events start with a data byte (or + # 0xF7), so only its first event is stamped. + _stamp(self._stamps, midi0, ticks_us()) ring[head] = midi0 head = (head + 1) % size if n > 1: @@ -206,7 +220,7 @@ def _put(self, buf, nbytes): return nbytes -def ports(ring=256): +def ports(ring=256, stamps=None): """Start the USB device with one MIDI interface; ``(midi_in, midi_out)``. The built-in USB serial port stays, so the REPL survives. Calling this @@ -214,6 +228,6 @@ def ports(ring=256): """ import usb.device - itf = MIDIPortsInterface(ring=ring) + itf = MIDIPortsInterface(ring=ring, stamps=stamps) usb.device.get().init(itf, builtin_driver=True) return itf.ports diff --git a/midiports/midiports/usbif_shim.py b/midiports/midiports/usbif_shim.py index 963586e..7a3e9ea 100644 --- a/midiports/midiports/usbif_shim.py +++ b/midiports/midiports/usbif_shim.py @@ -16,9 +16,14 @@ usbif's ``read(buf)`` fills a buffer; the contract's ``read(n)`` returns one. The shim reads into a buffer of its own and hands bytes out of it, so a poll that finds nothing allocates nothing. + +Each message is stamped with its arrival time (``take_timestamp()``, the +package's contract) by the read that finds it. usbif's ``MidiPort`` keeps no +times of its own, so a stamp is as fine as how often you poll, and every +message one read finds shares that read's time. """ -from . import PortIn, PortOut, _count +from . import PortIn, PortOut, Stamps, _count, _stamp, ticks_us class _In(PortIn): @@ -28,11 +33,20 @@ def __init__(self, port, size): self._raw = bytearray(size) self._have = 0 self._pos = 0 + self._stamps = Stamps(size + 32) def any(self): if self._pos >= self._have: - self._have = self._port.read(self._raw) + self._have = n = self._port.read(self._raw) self._pos = 0 + if n: + # usbif delivers whole messages, so each status byte (but + # a SysEx's 0xF7) starts one. + now = ticks_us() + raw = self._raw + stamps = self._stamps + for i in range(n): + _stamp(stamps, raw[i], now) return self._have - self._pos def readinto(self, buf, nbytes=None): diff --git a/midiports/midiports/webmidi.py b/midiports/midiports/webmidi.py index e9691b8..984f747 100644 --- a/midiports/midiports/webmidi.py +++ b/midiports/midiports/webmidi.py @@ -30,16 +30,25 @@ Timestamps ---------- -Each input keeps the time every message arrived, in milliseconds on the -``performance.now()`` clock, as Web MIDI stamps it. ``take_timestamp()`` -pops the oldest one not yet taken, or returns ``None`` when there is none. -Call it once for each message ``adafruit_midi.MIDI.receive()`` hands you and -you get that message's time:: +Each input keeps the time every message arrived, as the package's contract +describes: whole microseconds, here on the page's ``performance.now()`` +clock (the time Web MIDI stamps each event with, times 1000), which is also +what ``midiports.ticks_us()`` reads in Pyodide. ``take_timestamp()`` pops the +oldest one not yet taken, or returns ``None`` when there is none. Call it +once for each message ``adafruit_midi.MIDI.receive()`` hands you and you get +that message's time:: msg = midi.receive() if msg is not None: when = midi_in.take_timestamp() +These are the browser's own times for each message, as fine as Web MIDI +makes them, so they don't depend on how often you poll. A relay message +without a ``timeStamp`` is stamped when it reaches the worker. A worker's +``performance.now()`` starts from the worker's own origin, not the page's, +so in a worker compare stamps with times the page sends, not with +``ticks_us()``. + That pairing holds because Web MIDI delivers whole messages, one timestamp each. A message dropped because the input was full takes its timestamp with it. If you never call ``take_timestamp()``, old timestamps are discarded and @@ -56,16 +65,11 @@ note. """ -from array import array -from math import isnan - -from . import PortIn, PortOut, _count, message_length +from . import PortIn, PortOut, Stamps, _count, _stamp, message_length, ticks_us #: Longest unfinished message a write holds back, waiting for its end. PENDING_LIMIT = 4096 -_NAN = float("nan") - # -- the browser's FFI, imported only when it's there ------------------------------ @@ -111,11 +115,12 @@ def _bytes(data): return to_bytes() if to_bytes is not None else bytes(data) -def _ms(stamp): +def _us(stamp): + """A Web MIDI ``timeStamp`` (ms) as whole microseconds; now if it has none.""" try: - return float(stamp) + return int(round(float(stamp) * 1000)) except (TypeError, ValueError): - return None + return ticks_us() def _matches(port, name): @@ -141,14 +146,12 @@ def __init__(self, ring=1024): # One timestamp per message, oldest first. Room for one per byte of # the ring, plus some for messages already read whose timestamp # hasn't been taken yet. - self._times = array("d", [0.0] * (ring + 64)) - self._thead = 0 - self._ttail = 0 + self._stamps = Stamps(ring + 64) self.overflows = 0 self._closed = False def push(self, data, timestamp=None): - """Queue one whole message that arrived at ``timestamp`` (ms).""" + """Queue one whole message that arrived at ``timestamp`` (us), or now.""" if self._closed: return n = len(data) @@ -164,24 +167,7 @@ def push(self, data, timestamp=None): ring[head] = data[i] head = (head + 1) % size self._head = head - times = self._times - tsize = len(times) - thead = self._thead - times[thead] = _NAN if timestamp is None else timestamp - thead = (thead + 1) % tsize - if thead == self._ttail: - # Full of timestamps nobody took: forget the oldest. - self._ttail = (self._ttail + 1) % tsize - self._thead = thead - - def take_timestamp(self): - """The arrival time (ms) of the next message, in order, or ``None``.""" - tail = self._ttail - if tail == self._thead: - return None - stamp = self._times[tail] - self._ttail = (tail + 1) % len(self._times) - return None if isnan(stamp) else stamp + _stamp(self._stamps, data[0], ticks_us() if timestamp is None else timestamp) def any(self): if self._closed: @@ -347,7 +333,7 @@ def attach(self, port): midi_in = self.midi_in def on_message(event): - midi_in.push(_bytes(event.data), _ms(event.timeStamp)) + midi_in.push(_bytes(event.data), _us(event.timeStamp)) handler = _proxy(on_message) self.inputs[port.id] = (port, handler) @@ -444,7 +430,7 @@ def _on_message(self, event): data = getattr(message, "data", None) if data is None: return - self.push(_bytes(data), _ms(getattr(message, "timeStamp", None))) + self.push(_bytes(data), _us(getattr(message, "timeStamp", None))) def close(self): if not self._closed: diff --git a/midiports/midiports/windows.py b/midiports/midiports/windows.py index 59c5cfb..1c6fd20 100644 --- a/midiports/midiports/windows.py +++ b/midiports/midiports/windows.py @@ -23,6 +23,13 @@ winmm callback would run on Windows' own thread, which isn't safe for MicroPython. +Each message is stamped with its arrival time (``take_timestamp()``, the +package's contract) when a read or ``any()`` drains it from the window's +queue, so a stamp is as fine as how often you poll, and every message one +drain finds shares its time. winmm's own time for a message reaches only a +callback, and the queue's posting time ticks in steps of about 16 ms, which is +coarser than a loop polling beside audio. + SysEx isn't supported: it needs ``midiOutLongMsg`` and its buffers, which this doesn't bind. Writing a 0xF0 raises ``NotImplementedError`` rather than dropping it, because a SysEx that vanishes looks like a device ignoring it. @@ -30,7 +37,7 @@ import uwin32 as win -from . import IN, OUT, PortIn, PortInfo, PortOut, _count, message_length +from . import IN, OUT, PortIn, PortInfo, PortOut, Stamps, _count, _stamp, message_length, ticks_us HWND_MESSAGE = -3 @@ -155,10 +162,12 @@ def __init__(self, info, index): self.info = info self._hwnd = _make_sink_window() self._pending = bytearray() + self._stamps = Stamps(128) self._handle = win.midiInOpen(index, self._hwnd) win.midiInStart(self._handle) def _drain_queue(self): + now = None while True: msg = win.PeekMessageW(self._hwnd) if msg is None: @@ -167,6 +176,9 @@ def _drain_queue(self): continue status, d1, d2 = win.midi_unpack(msg.lParam) want = message_length(status) + if now is None: + now = ticks_us() + _stamp(self._stamps, status, now) self._pending.append(status) if want >= 1: self._pending.append(d1) diff --git a/tests/midiports_contract.py b/tests/midiports_contract.py index e9f8ea7..97c308f 100644 --- a/tests/midiports_contract.py +++ b/tests/midiports_contract.py @@ -9,6 +9,7 @@ python tests/midiports_contract.py --adafruit=DIR micropython tests/midiports_contract.py --adafruit=DIR python tests/midiports_contract.py --adafruit=DIR --plant=drop-status # must FAIL + python tests/midiports_contract.py --adafruit=DIR --plant=skip-clock-stamp # must FAIL ``DIR`` holds ``adafruit_midi/`` as ``midiports/package.json`` fetches it; ``tests/test_midiports.py`` fetches it there and runs this every way. @@ -53,7 +54,19 @@ import midiports from midiports import PortIn, PortOut, WholeMessages -PLANTS = ("drop-status",) +PLANTS = ("drop-status", "skip-clock-stamp") + +if PLANT == "skip-clock-stamp": + # Every port decides what to stamp through midiports._stamp. Planted, a + # clock byte goes unstamped, so each later message takes the stamp of + # the one before it. Applied before any port module imports it. + _real_stamp = midiports._stamp + + def _skip_clock(stamps, status, t): + if status != 0xF8: + _real_stamp(stamps, status, t) + + midiports._stamp = _skip_clock _passed = 0 _failed = 0 @@ -609,6 +622,8 @@ def _install_fake_win(): setattr(mod, name, getattr(fake, name)) sys.modules["uwin32"] = mod sys.modules.pop("midiports.windows", None) + if "windows" in midiports.__dict__: + del midiports.__dict__["windows"] # or the import finds the old one from midiports import windows windows._make_sink_window = lambda: 1 @@ -802,7 +817,7 @@ def feed(): start = access.clock got = stamps_follow_receive(midi_in, feed) expect([type(m) for m, _ in got] == [NoteOn, TimingClock, ControlChange], "got %r" % (got,)) - expect([t for _, t in got] == [start + 0.5, start + 1.0, start + 1.5], + expect([t for _, t in got] == [int(1000 * (start + d)) for d in (0.5, 1.0, 1.5)], "timestamps %r" % ([t for _, t in got],)) expect(midi_in.take_timestamp() is None, "no more timestamps") midi_in.close() @@ -840,7 +855,7 @@ def feed(): got = stamps_follow_receive(midi_in, feed) expect(len(got) == 2 and isinstance(got[0][0], NoteOn) and isinstance(got[1][0], NoteOff), "got %r" % (got,)) - expect(got[0][1] == 12.5 and got[1][1] == 20.0, "timestamps %r" % (got,)) + expect(got[0][1] == 12500 and got[1][1] == 20000, "timestamps in us %r" % (got,)) midi_in.close() expect(port.listeners == [], "close removes the listener") @@ -928,6 +943,302 @@ def c_garbage_per_note(): print(" info: %d bytes allocated per note received (unix MicroPython)" % (used // max(got, 1))) +# -- arrival times: one stamp per message, in order ----------------------------------- +# +# A fake clock makes every read's time known: each read the port makes +# advances it by 1000 us. Then a message's stamp says which read found its +# first byte, and the checks below can name it exactly. + + +class FakeClock: + def __init__(self): + self.t = 0 + + def __call__(self): + return self.t + + +_clock = FakeClock() + + +def use_fake_clock(*modules): + for m in modules: + m.ticks_us = _clock + + +class TickingIn(FakeIn): + """FakeIn whose every read that returns bytes moves the clock 1000 us on.""" + + def readinto(self, buf, nbytes=None): + n = FakeIn.readinto(self, buf, nbytes) + if n: + _clock.t += 1000 + return n + + +# The four hard cases, as reads: running status, a clock inside a note, a +# SysEx, a note split across two reads. Each entry is one read's bytes. +STAMP_READS = ( + b"\x90\x3c\x64\x3e\x50", # read 1: note-on, then one by running status + b"\x90\x40\xf8\x64", # read 2: a clock cuts into a note-on + b"\xf0\x7d\x01\x02\xf7\xb0", # read 3: a SysEx, then a CC's status... + b"\x07\x7f", # read 4: ...whose data comes a read later + b"\xf8\x80\x3c\x00", # read 5: a clock, then a note-off +) + +# (message class, the read that found its first byte), in the order +# adafruit_midi returns them. The clock in read 2 comes out ahead of the +# note it cut into; the CC is stamped by read 3, where its status arrived. +STAMP_WANT = ( + (NoteOn, 1), (NoteOn, 1), + (TimingClock, 2), (NoteOn, 2), + (SystemExclusive, 3), (ControlChange, 3), + (TimingClock, 5), (NoteOff, 5), +) + + +def stamps_per_message(midi_in, pump=None): + """Drain ``midi_in`` through adafruit_midi; ``[(class, read), ...]``.""" + rx = adafruit_midi.MIDI(midi_in=midi_in) + got = [] + idle = 0 + while idle < 3: + if pump is not None: + pump() + msg = rx.receive() + if msg is None: + idle += 1 + continue + idle = 0 + t = midi_in.take_timestamp() + got.append((type(msg), None if t is None else t // 1000)) + expect(midi_in.take_timestamp() is None, "a stamp left over after the last message") + return got + + +def expect_stamps(got, want=STAMP_WANT): + names = [(c.__name__, r) for c, r in got] + wanted = [(c.__name__, r) for c, r in want] + expect(names == wanted, "stamps %r, want %r" % (names, wanted)) + + +def c_stamps_whole_messages(): + use_fake_clock(midiports) + _clock.t = 0 + port = WholeMessages(TickingIn(STAMP_READS), size=8) + expect(port.take_timestamp() is None, "a stamp before anything arrived") + expect_stamps(stamps_per_message(port)) + # Running status across reads takes the time of its first data byte. + _clock.t = 0 + port = WholeMessages(TickingIn([b"\x90\x3c\x64", b"\x3e", b"\x50\xf8"])) + expect_stamps(stamps_per_message(port), ((NoteOn, 1), (NoteOn, 2), (TimingClock, 3))) + # A status cutting a SysEx short: one stamp for the SysEx, none for the + # 0xF7 the port supplies. + _clock.t = 0 + port = WholeMessages(TickingIn([b"\xf0\x7d\x01\x90\x3c\x64", b"\xf8"])) + expect_stamps(stamps_per_message(port), + ((SystemExclusive, 1), (NoteOn, 1), (TimingClock, 2))) + # A clock inside a SysEx comes out after it, with its own arrival time. + _clock.t = 0 + port = WholeMessages(TickingIn([b"\xf0\x7d\x01\xf8\x02\xf7\x90\x3c\x64"])) + expect_stamps(stamps_per_message(port), + ((SystemExclusive, 1), (TimingClock, 1), (NoteOn, 1))) + + +def c_stamps_uart(): + sys.modules["machine"] = _FakeMachine + try: + from midiports import uart + + use_fake_clock(midiports) + midi_in, midi_out = uart.ports(1) + u = FakeUART.made[-1] + reads = list(STAMP_READS) + + def pump(): + # One chunk arrives on the wire before each poll. + if reads and not u.rx: + u.rx.extend(reads.pop(0)) + _clock.t += 1000 + + _clock.t = 0 + expect_stamps(stamps_per_message(midi_in, pump)) + finally: + del sys.modules["machine"] + + +def _whole_events(data): + """USB-MIDI events for a whole-message stream, as a host sends them.""" + events = [] + i = 0 + while i < len(data): + b = data[i] + if b == 0xF0: + end = data.index(b"\xf7", i) + 1 + body = data[i:end] + while len(body) > 3: + events.append((0x4, body[0], body[1], body[2])) + body = body[3:] + cin = (0x5, 0x6, 0x7)[len(body) - 1] + events.append((cin, body[0], body[1] if len(body) > 1 else 0, + body[2] if len(body) > 2 else 0)) + i = end + continue + n = 1 + midiports.message_length(b) + m = data[i:i + n] + b"\x00\x00" + cin = 0xF if b >= 0xF8 else b >> 4 + events.append((cin, m[0], m[1], m[2])) + i += n + return events + + +# What a whole-message transport (USB, usbif, Windows) delivers for +# STAMP_READS: running status expanded, the clock ahead of its note, the CC +# whole in the read where it completed. One entry per arrival. +WHOLE_ARRIVALS = ( + b"\x90\x3c\x64\x90\x3e\x50", + b"\xf8\x90\x40\x64", + b"\xf0\x7d\x01\x02\xf7", + b"\xb0\x07\x7f", + b"\xf8\x80\x3c\x00", +) +WHOLE_WANT = STAMP_WANT[:5] + ((ControlChange, 4),) + STAMP_WANT[6:] + + +def c_stamps_usb_device(): + usb_device = _install_fake_usb() + try: + use_fake_clock(usb_device) + itf = usb_device.MIDIPortsInterface() + _clock.t = 0 + for chunk in WHOLE_ARRIVALS: + _clock.t += 1000 + for ev in _whole_events(chunk): + itf.on_midi_event(*ev) + # SysEx longer than one event: stamped once, for its first event. + _clock.t += 1000 + for ev in _whole_events(b"\xf0\x7d\x01\x02\x03\x04\x05\xf7"): + itf.on_midi_event(*ev) + expect_stamps(stamps_per_message(itf.ports[0]), + WHOLE_WANT + ((SystemExclusive, 6),)) + # A message the full ring drops takes no stamp with it. + itf = usb_device.MIDIPortsInterface(ring=8) + for note in range(4): + _clock.t += 1000 + itf.on_midi_event(0x9, 0x90, note, 0x40) + expect(itf.overflows == 2, "overflows %d" % itf.overflows) + expect(len(itf._stamps) == 2, "%d stamps for 2 kept notes" % len(itf._stamps)) + finally: + _remove_fake_usb() + + +def c_stamps_usbif_shim(): + from midiports import usbif_shim + + use_fake_clock(usbif_shim) + port = FakeMidiPort() + midi_in, _ = usbif_shim.ports(port, size=64) + chunks = list(WHOLE_ARRIVALS) + + def pump(): + if chunks and not port.rx: + port.rx.extend(chunks.pop(0)) + _clock.t += 1000 + + _clock.t = 0 + expect_stamps(stamps_per_message(midi_in, pump), WHOLE_WANT) + + +def c_stamps_windows(): + fake, windows = _install_fake_win() + try: + use_fake_clock(windows) + midi_in, _ = windows.ports("loopmidi") + chunks = list(WHOLE_ARRIVALS[:2] + WHOLE_ARRIVALS[3:]) # winmm: no SysEx in + + def pump(): + if chunks and not fake.queue: + data = chunks.pop(0) + i = 0 + while i < len(data): + n = 1 + midiports.message_length(data[i]) + m = data[i:i + n] + b"\x00\x00" + fake.queue.append(_Msg(FakeWin.MM_MIM_DATA, m[0] | m[1] << 8 | m[2] << 16)) + i += n + _clock.t += 1000 + + _clock.t = 0 + want = STAMP_WANT[:4] + ((ControlChange, 3), (TimingClock, 4), (NoteOff, 4)) + expect_stamps(stamps_per_message(midi_in, pump), want) + midi_in.close() + finally: + sys.modules.pop("uwin32", None) + sys.modules.pop("midiports.windows", None) + + +def c_stamps_webmidi(): + from midiports import webmidi + + access = FakeMIDIAccess() + midi_in, _ = webmidi.ports(access, "loopback") + want = [] + for chunk in WHOLE_ARRIVALS: + for msg in split_messages(chunk): + access.fire(access.loop_in, msg) + # Web MIDI's own time for the event, in ms; stamps are whole us. + want.append(int(round(access.clock * 1000))) + rx = adafruit_midi.MIDI(midi_in=midi_in) + got = [] + while True: + msg = rx.receive() + if msg is None: + break + got.append((type(msg), midi_in.take_timestamp())) + expect([c for c, _ in got] == [c for c, _ in WHOLE_WANT], "classes %r" % (got,)) + expect([t for _, t in got] == want, "stamps %r, want %r" % ([t for _, t in got], want)) + expect(midi_in.take_timestamp() is None, "a stamp left over") + midi_in.close() + + +def c_stamps_ring_is_fixed(): + stamps = midiports.Stamps(4) + expect(stamps.take() is None, "empty ring") + for t in range(10): + stamps.push(t) + expect(len(stamps) == 4, "a full ring holds 4, has %d" % len(stamps)) + expect([stamps.take() for _ in range(5)] == [6, 7, 8, 9, None], "keeps the newest") + if MICROPYTHON: + now = midiports.ticks_us() + stamps.push(now) + stamps.take() + gc.collect() + before = gc.mem_alloc() + for _ in range(200): + stamps.push(now) + stamps.take() + used = gc.mem_alloc() - before + expect(used == 0, "200 pushes and takes allocated %d bytes" % used) + # And WholeMessages stamping a burst: clocks, notes, running status. + port = WholeMessages(FakeIn([b"\xf8\x90\x3c\x64\x3e\x50" * 5])) + buf = bytearray(64) + gc.collect() + before = gc.mem_alloc() + n = port.readinto(buf) + taken = 0 + while port.take_timestamp() is not None: + taken += 1 + used = gc.mem_alloc() - before + expect(n == 35 and taken == 15, "read %r, %d stamps" % (n, taken)) + expect(used == 0, "stamping 15 messages allocated %d bytes" % used) + + +def c_clock_helpers(): + a = midiports.ticks_us() + b = midiports.ticks_us() + expect(isinstance(a, int), "ticks_us is a whole number of microseconds") + expect(midiports.ticks_diff(b, a) >= 0, "the clock runs forward") + + CHECKS = ( ("adafruit_midi receives a note-on", c_note_on), ("running status gives two notes", c_running_status), @@ -951,6 +1262,14 @@ def c_garbage_per_note(): ("webmidi: a page MIDIAccess, 200 of 200, timestamps follow receive()", c_webmidi_page), ("webmidi: a worker relay, 200 of 200, whole messages posted", c_webmidi_relay), ("garbage per received note", c_garbage_per_note), + ("stamps: ticks_us and ticks_diff", c_clock_helpers), + ("stamps: a fixed ring that keeps the newest, allocating nothing", c_stamps_ring_is_fixed), + ("stamps: WholeMessages, one per message across running status, clock, sysex, split reads", c_stamps_whole_messages), + ("stamps: uart", c_stamps_uart), + ("stamps: usb_device, at the event callback", c_stamps_usb_device), + ("stamps: usbif_shim", c_stamps_usbif_shim), + ("stamps: windows", c_stamps_windows), + ("stamps: webmidi, one per message", c_stamps_webmidi), ) diff --git a/tests/test_midiports.py b/tests/test_midiports.py index 34d0570..4c7bcd0 100644 --- a/tests/test_midiports.py +++ b/tests/test_midiports.py @@ -135,6 +135,14 @@ def test_a_dropped_status_byte_fails_it(self): self.assertNotEqual(result.returncode, 0, "the plant went unnoticed:\n" + result.stdout) self.assertIn("FAIL", result.stdout) + def test_a_clock_byte_left_unstamped_fails_it(self): + result = run([sys.executable, CONTRACT, "--adafruit=" + adafruit_dir(self), + "--plant=skip-clock-stamp"]) + self.assertIn("PLANTED skip-clock-stamp", result.stdout) + self.assertNotEqual(result.returncode, 0, "the plant went unnoticed:\n" + result.stdout) + for port in ("WholeMessages", "uart", "usb_device", "usbif_shim", "windows", "webmidi"): + self.assertIn("FAIL stamps: " + port, result.stdout) + def _make_asound(root, entries): """A synthetic /proc/asound: ``entries`` maps (card, device) to the body @@ -205,8 +213,15 @@ def test_ports_reads_and_writes_whole_messages(self): midi_in, midi_out = linux.ports("loop", proc=self.proc, dev=self.dev) try: self.assertIsInstance(midi_in, WholeMessages) + before = midiports.ticks_us() self.assertEqual(midi_in.read(64), b"\x90\x3c\x64\x90\x3e\x50") + after = midiports.ticks_us() self.assertIsNone(midi_in.read(64)) + # One arrival time per note, taken by the read that found them. + stamps = [midi_in.take_timestamp() for _ in range(3)] + self.assertIsNone(stamps[2]) + self.assertEqual(stamps[0], stamps[1]) + self.assertTrue(before <= stamps[0] <= after) self.assertEqual(midi_out.write(b"\xb0\x07\x7f\x00", 3), 3) finally: midi_in.close() @@ -291,7 +306,7 @@ def test_the_ring_keeps_whole_messages_and_drops_whole_ones_when_full(self): self.assertIsNone(midi_in.read(16)) self.assertEqual(midi_in.any(), 0) for note in range(4): - midi_in.push(bytes((0x90, note, 0x40)), float(note)) + midi_in.push(bytes((0x90, note, 0x40)), note * 1000) self.assertEqual(midi_in.overflows, 2) # 7 bytes fit: two notes, never a part self.assertEqual(midi_in.any(), 6) self.assertEqual(midi_in.read(4), b"\x90\x00\x40\x90") @@ -299,7 +314,7 @@ def test_the_ring_keeps_whole_messages_and_drops_whole_ones_when_full(self): buf = bytearray(8) self.assertEqual(midi_in.readinto(buf, 8), 2) self.assertEqual(bytes(buf[:2]), b"\x01\x40") - self.assertEqual([midi_in.take_timestamp() for _ in range(3)], [0.0, 1.0, None]) + self.assertEqual([midi_in.take_timestamp() for _ in range(3)], [0, 1000, None]) for _ in range(5): # wraps round the ring midi_in.push(b"\xb0\x07\x7f") self.assertEqual(midi_in.read(16), b"\xb0\x07\x7f") @@ -309,21 +324,24 @@ def test_the_ring_keeps_whole_messages_and_drops_whole_ones_when_full(self): def test_timestamps_pair_with_messages_and_stale_ones_are_forgotten(self): midi_in = webmidi._RingIn(ring=16) - midi_in.push(b"\x90\x3c\x64", 5.25) - midi_in.push(b"\xf8") # no stamp - midi_in.push(b"\x80\x3c\x00", 9.0) - self.assertEqual(midi_in.take_timestamp(), 5.25) - self.assertIsNone(midi_in.take_timestamp()) - self.assertEqual(midi_in.take_timestamp(), 9.0) + midi_in.push(b"\x90\x3c\x64", 5250) + before = midiports.ticks_us() + midi_in.push(b"\xf8") # no time given: stamped on arrival + after = midiports.ticks_us() + midi_in.push(b"\x80\x3c\x00", 9000) + self.assertEqual(midi_in.take_timestamp(), 5250) + self.assertTrue(before <= midi_in.take_timestamp() <= after) + self.assertEqual(midi_in.take_timestamp(), 9000) self.assertIsNone(midi_in.take_timestamp()) self.assertEqual(midi_in.read(16), b"\x90\x3c\x64\xf8\x80\x3c\x00") # A reader that never takes timestamps loses nothing else. for i in range(500): - midi_in.push(b"\xf8", float(i)) + midi_in.push(b"\xf8", i) self.assertEqual(midi_in.read(4), b"\xf8") self.assertEqual(midi_in.overflows, 0) - oldest = midi_in.take_timestamp() - self.assertEqual(oldest, 500.0 - (len(midi_in._times) - 1)) + kept = len(midi_in._stamps) + self.assertEqual(kept, 16 + 64) # the ring stays its size + self.assertEqual(midi_in.take_timestamp(), 500 - kept) # the newest kept def test_ports_attach_by_name_and_merge_inputs(self): keys = _Port("i1", "Keystation 49", "input") @@ -360,7 +378,7 @@ def test_statechange_reattaches_a_port_that_comes_back(self): self.assertEqual(midi_in.names, ["Keys"]) keys.play(b"\x90\x3c\x64", 3.0) self.assertEqual(midi_in.read(16), b"\x90\x3c\x64") - self.assertEqual(midi_in.take_timestamp(), 3.0) + self.assertEqual(midi_in.take_timestamp(), 3000) midi_out.write(b"\xfa") self.assertEqual(synth.sent, [b"\xfa"]) late = _Port("i9", "Keys 2", "input") @@ -407,11 +425,14 @@ def test_relay_input_takes_page_messages_with_their_timestamps(self): port = _MessagePort() midi_in, _ = webmidi.relay_ports(port) port.deliver(_Obj(data=b"\x90\x3c\x64", timeStamp=41.5)) - port.deliver(_Obj(data=b"\xf8")) # no timeStamp + before = midiports.ticks_us() + port.deliver(_Obj(data=b"\xf8")) # no timeStamp: stamped on arrival + after = midiports.ticks_us() port.deliver(_Obj(other=1)) # not MIDI: ignored self.assertEqual(midi_in.any(), 4) self.assertEqual(midi_in.read(16), b"\x90\x3c\x64\xf8") - self.assertEqual(midi_in.take_timestamp(), 41.5) + self.assertEqual(midi_in.take_timestamp(), 41500) + self.assertTrue(before <= midi_in.take_timestamp() <= after) self.assertIsNone(midi_in.take_timestamp()) midi_in.close() self.assertEqual(port.listeners, []) From 1eb84ff404966861387d1cb1783b593d57c8473d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:41:25 -0500 Subject: [PATCH 2/2] midiports: real-time bytes inside a SysEx come out as their own messages 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. --- midiports/midiports/usb_device.py | 46 ++++++++++++- midiports/midiports/usbif_shim.py | 47 +++++-------- tests/midiports_contract.py | 107 +++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 35 deletions(-) diff --git a/midiports/midiports/usb_device.py b/midiports/midiports/usb_device.py index e809a8d..e7ce539 100644 --- a/midiports/midiports/usb_device.py +++ b/midiports/midiports/usb_device.py @@ -32,6 +32,11 @@ for your code to reach a point where it can run. Every event in one USB packet arrives together, so a chord or a burst shares nearly one time. +A real-time byte (clock, start, stop) a host sends in the middle of a SysEx +comes out just after the SysEx ends, as its own message with its own arrival +time: ``adafruit_midi`` loses both if it sits inside. Up to 8 are held that +way. A SysEx another message cuts short is ended with an 0xF7 first. + Received bytes wait in a ring (``ring`` bytes, 256 by default). If you don't read for long enough to fill it, whole messages are dropped and counted in ``MIDIPortsInterface.overflows``. Sending waits briefly for room when the host @@ -41,10 +46,11 @@ """ import time +from array import array from usb.device.midi import MIDIInterface -from . import PortIn, PortOut, Stamps, _count, _stamp, ticks_us +from . import _TICKS_TYPE, PortIn, PortOut, Stamps, _count, _stamp, ticks_us # Bytes carried by each USB-MIDI Code Index Number (USB MIDI 1.0, table 4-1). # 0 and 1 are reserved (cable events) and carry nothing we pass on. @@ -87,6 +93,11 @@ def __init__(self, ring=256, rxlen=64, txlen=128, stamps=None): # One stamp per message: room for a ring of one-byte clocks, plus # the messages adafruit_midi has read ahead. self._stamps = Stamps(ring + 32 if stamps is None else stamps) + # Receive state: inside a SysEx, and real-time bytes held until it ends. + self._rx_sysex = False + self._rt = bytearray(8) + self._rtt = array(_TICKS_TYPE, [0] * 8) + self._nrt = 0 # One producer (the scheduled USB callback) moves _head, one consumer # (your read) moves _tail. Neither touches the other's index, so a # callback landing in the middle of a read can't tear the ring. @@ -108,6 +119,37 @@ def on_midi_event(self, cin, midi0, midi1, midi2): n = _CIN_LEN[cin] if not n: return + now = ticks_us() + if self._rx_sysex: + if midi0 >= 0xF8: + # Real-time inside a SysEx, as the spec allows: hold it, with + # its time, until the SysEx ends, or adafruit_midi loses both. + k = self._nrt + if k < 8: + self._rt[k] = midi0 + self._rtt[k] = now + self._nrt = k + 1 + return + elif midi0 < 0x80 or midi0 == 0xF7: + self._rx_put(n, midi0, midi1, midi2, now) + if 0x5 <= cin <= 0x7 or midi0 == 0xF7: + self._end_sysex() + return + else: + # A status cuts the SysEx short: end it as the spec does. + self._rx_put(1, 0xF7, 0, 0, now) + self._end_sysex() + if midi0 == 0xF0 and cin == 0x4: + self._rx_sysex = True + self._rx_put(n, midi0, midi1, midi2, now) + + def _end_sysex(self): + self._rx_sysex = False + for i in range(self._nrt): + self._rx_put(1, self._rt[i], 0, 0, self._rtt[i]) + self._nrt = 0 + + def _rx_put(self, n, midi0, midi1, midi2, now): ring = self._ring size = len(ring) head = self._head @@ -116,7 +158,7 @@ def on_midi_event(self, cin, midi0, midi1, midi2): return # A SysEx's continuation and end events start with a data byte (or # 0xF7), so only its first event is stamped. - _stamp(self._stamps, midi0, ticks_us()) + _stamp(self._stamps, midi0, now) ring[head] = midi0 head = (head + 1) % size if n > 1: diff --git a/midiports/midiports/usbif_shim.py b/midiports/midiports/usbif_shim.py index 7a3e9ea..8d5248c 100644 --- a/midiports/midiports/usbif_shim.py +++ b/midiports/midiports/usbif_shim.py @@ -17,48 +17,33 @@ The shim reads into a buffer of its own and hands bytes out of it, so a poll that finds nothing allocates nothing. -Each message is stamped with its arrival time (``take_timestamp()``, the +The input is wrapped in ``midiports.WholeMessages``, so a real-time byte a +host sends inside a SysEx comes out after it, as its own message. Each +message is stamped with its arrival time (``take_timestamp()``, the package's contract) by the read that finds it. usbif's ``MidiPort`` keeps no times of its own, so a stamp is as fine as how often you poll, and every message one read finds shares that read's time. """ -from . import PortIn, PortOut, Stamps, _count, _stamp, ticks_us +from . import PortIn, PortOut, WholeMessages, _count -class _In(PortIn): +class _Raw(PortIn): + """usbif's ``read(buf)`` as ``readinto``; ``WholeMessages`` reads it.""" + def __init__(self, port, size): PortIn.__init__(self, size) self._port = port - self._raw = bytearray(size) - self._have = 0 - self._pos = 0 - self._stamps = Stamps(size + 32) - - def any(self): - if self._pos >= self._have: - self._have = n = self._port.read(self._raw) - self._pos = 0 - if n: - # usbif delivers whole messages, so each status byte (but - # a SysEx's 0xF7) starts one. - now = ticks_us() - raw = self._raw - stamps = self._stamps - for i in range(n): - _stamp(stamps, raw[i], now) - return self._have - self._pos def readinto(self, buf, nbytes=None): - if not self.any(): - return None - n = min(_count(buf, nbytes), self._have - self._pos) - raw = self._raw - pos = self._pos - for i in range(n): - buf[i] = raw[pos + i] - self._pos = pos + n - return n + n = _count(buf, nbytes) + if n == len(buf): + got = self._port.read(buf) + else: + got = self._port.read(self._view[:n]) + for i in range(got): + buf[i] = self._scratch[i] + return got or None def close(self): self._port.close() @@ -86,4 +71,4 @@ def ports(port="dev:midi", size=64): import usbif.auto port = usbif.auto.open_midi(port) - return _In(port, size), _Out(port) + return WholeMessages(_Raw(port, size), size), _Out(port) diff --git a/tests/midiports_contract.py b/tests/midiports_contract.py index 97c308f..7fa1ab3 100644 --- a/tests/midiports_contract.py +++ b/tests/midiports_contract.py @@ -49,6 +49,7 @@ from adafruit_midi.pitch_bend import PitchBend from adafruit_midi.program_change import ProgramChange from adafruit_midi.system_exclusive import SystemExclusive +from adafruit_midi.start import Start from adafruit_midi.timing_clock import TimingClock import midiports @@ -535,8 +536,8 @@ def c_usbif_shim(): expect(got == 200, "%d of 200 notes through the shim" % got) port.rx.extend(b"\x90\x3c\x64\x80\x3c\x00\xb0\x01\x02") # more than one raw read expect(midi_in.read(4) == b"\x90\x3c\x64\x80", "first 4") - expect(midi_in.read(30) == b"\x3c\x00\xb0\x01", "rest of the raw read") - expect(midi_in.read(30) == b"\x02", "the next raw read") + expect(midi_in.read(30) == b"\x3c\x00", "rest of the raw read's whole messages") + expect(midi_in.read(30) == b"\xb0\x01\x02", "the CC, whole once the next raw read ends it") expect(midi_in.read(30) is None, "empty") if MICROPYTHON: gc.collect() @@ -1105,6 +1106,105 @@ def _whole_events(data): WHOLE_WANT = STAMP_WANT[:5] + ((ControlChange, 4),) + STAMP_WANT[6:] +# Real-time bytes inside a SysEx, as the MIDI spec allows: each must come out +# as its own message with its own arrival time, in order, after the SysEx. +# Arrival 1 starts the SysEx, 2 is a clock, 3 is more SysEx, 4 a start +# byte, 5 ends the SysEx, 6 a note-on. +RT_IN_SYSEX = (b"\xf0\x7d\x01\x02", b"\xf8", b"\x03\x04", b"\xfa", b"\x05\xf7", + b"\x90\x3c\x64") +RT_IN_SYSEX_WANT = ((SystemExclusive, 1), (TimingClock, 2), (Start, 4), (NoteOn, 6)) + + +def c_stamps_realtime_in_sysex_usb_device(): + usb_device = _install_fake_usb() + try: + use_fake_clock(usb_device) + itf = usb_device.MIDIPortsInterface() + _clock.t = 0 + # The USB-MIDI events a host sends for RT_IN_SYSEX. + for ev in ((0x4, 0xF0, 0x7D, 0x01), (0xF, 0xF8, 0, 0), (0x4, 0x02, 0x03, 0x04), + (0xF, 0xFA, 0, 0), (0x6, 0x05, 0xF7, 0), (0x9, 0x90, 0x3C, 0x64)): + _clock.t += 1000 + itf.on_midi_event(*ev) + got = stamps_per_message(itf.ports[0]) + expect_stamps(got, RT_IN_SYSEX_WANT) + # A SysEx cut short by a note: the port ends it, then the held clock. + itf = usb_device.MIDIPortsInterface() + _clock.t = 0 + for ev in ((0x4, 0xF0, 0x7D, 0x01), (0xF, 0xF8, 0, 0), (0x9, 0x90, 0x3C, 0x64)): + _clock.t += 1000 + itf.on_midi_event(*ev) + expect_stamps(stamps_per_message(itf.ports[0]), + ((SystemExclusive, 1), (TimingClock, 2), (NoteOn, 3))) + finally: + _remove_fake_usb() + + +def stamps_after_drain(midi_in, pump=None): + """Read every byte the port hands out, then parse them in one go. + + adafruit_midi drops a SysEx it sees only part of in one buffer, whatever + the port does, so these cases give it the port's whole output at once. + The stamps still come from the port. + """ + data = bytearray() + idle = 0 + while idle < 3: + if pump is not None: + pump() + chunk = midi_in.read(64) + if chunk is None: + idle += 1 + else: + idle = 0 + data.extend(chunk) + + class _Stamped(FakeIn): + def take_timestamp(self): + return midi_in.take_timestamp() + + return stamps_per_message(_Stamped([data])) + + +def c_stamps_realtime_in_sysex_streams(): + # A byte stream (UART, Linux) through WholeMessages. + use_fake_clock(midiports) + _clock.t = 0 + port = WholeMessages(TickingIn(RT_IN_SYSEX)) + expect_stamps(stamps_after_drain(port), RT_IN_SYSEX_WANT) + # usbif's MidiPort. + from midiports import usbif_shim + + raw = FakeMidiPort() + midi_in, _ = usbif_shim.ports(raw, size=64) + chunks = list(RT_IN_SYSEX) + + def pump(): + if chunks and not raw.rx: + raw.rx.extend(chunks.pop(0)) + _clock.t += 1000 + + _clock.t = 0 + expect_stamps(stamps_after_drain(midi_in, pump), RT_IN_SYSEX_WANT) + + +def c_stamps_realtime_in_sysex_webmidi(): + # The browser hands a SysEx over whole and real-time bytes as their own + # events, each with its own time. + from midiports import webmidi + + access = FakeMIDIAccess() + midi_in, _ = webmidi.ports(access, "loopback") + access.clock = 0.0 + for msg, at in ((b"\xf8", 2.0), (b"\xfa", 4.0), (b"\xf0\x7d\x01\x02\x03\x04\x05\xf7", 1.0), + (b"\x90\x3c\x64", 6.0)): + access.clock = at - 0.5 + access.fire(access.loop_in, msg) + got = stamps_per_message(midi_in) + expect_stamps(got, ((TimingClock, 2), (Start, 4), (SystemExclusive, 1), (NoteOn, 6))) + midi_in.close() + + def c_stamps_usb_device(): usb_device = _install_fake_usb() try: @@ -1270,6 +1370,9 @@ def c_clock_helpers(): ("stamps: usbif_shim", c_stamps_usbif_shim), ("stamps: windows", c_stamps_windows), ("stamps: webmidi, one per message", c_stamps_webmidi), + ("stamps: usb_device, real-time bytes inside a SysEx", c_stamps_realtime_in_sysex_usb_device), + ("stamps: WholeMessages and usbif_shim, real-time bytes inside a SysEx", c_stamps_realtime_in_sysex_streams), + ("stamps: webmidi, real-time bytes beside a SysEx", c_stamps_realtime_in_sysex_webmidi), )