Source code for subsequence.held_notes

"""Held-note tracking for live MIDI note input.

``HeldNotes`` maintains the set of MIDI notes a player is currently holding
on a ``note_input`` keyboard, so an arpeggiator (or any pattern) can read the
live pitch set each cycle via ``p.held_notes()``.

It is a tiny, dependency-free state machine.  All of its state lives on the
sequencer loop thread: the mido callback thread only appends raw note events
to a deque, which the loop drains and feeds here.  Because it is never touched
from two threads, it needs no locking — and because it takes the current time
as an argument (rather than reading the clock itself), it is fully
deterministic and trivial to unit-test.

Two smoothing behaviours guard against the arp dropping to silence:

* **``release_ms`` debounce** — a just-released note lingers in the held set
  for a short window, so the momentary all-keys-up gap during a hand-position
  changeover does not register as "nothing held".
* **``latch``** — the held set persists after release until a *new* chord is
  started (the first key pressed after every key is up replaces it), like a
  hardware arp's latch / a sustain pedal.  Under ``latch`` the ``release_ms``
  window is unused — latch dominates.
"""

import typing


[docs] class HeldNotes: """The live set of notes held on a ``note_input`` keyboard.""" def __init__ (self, release_ms: float = 0.0, latch: bool = False) -> None: """Create a held-note tracker. Parameters: release_ms: How long (milliseconds) a released note keeps counting as held, smoothing the gap during hand-position changes. 0 removes a note the instant its note-off arrives. Ignored when ``latch`` is True. latch: When True, the held set persists after release until the next chord is started. """ self._release_s: float = max(0.0, release_ms) / 1000.0 self._latch: bool = latch # pitch -> velocity for notes physically down right now. self._on: typing.Dict[int, int] = {} # pitch -> perf_counter deadline for just-released notes (debounce window). self._releasing: typing.Dict[int, float] = {} # pitch -> velocity for the latched chord (latch mode only). self._latched: typing.Dict[int, int] = {}
[docs] def note_on (self, pitch: int, velocity: int, now: float) -> None: """Register a note-on (``now`` = a perf_counter timestamp).""" self._releasing.pop(pitch, None) if self._latch and not self._on: # First key after every key was up — start a fresh latched chord. self._latched.clear() self._on[pitch] = velocity
[docs] def note_off (self, pitch: int, now: float) -> None: """Register a note-off (``now`` = a perf_counter timestamp).""" velocity = self._on.pop(pitch, None) if velocity is None: return if self._latch: self._latched[pitch] = velocity elif self._release_s > 0.0: self._releasing[pitch] = now + self._release_s
[docs] def snapshot (self, now: float) -> typing.List[int]: """Return the currently held MIDI notes, sorted ascending. Combines notes physically down, any still within the ``release_ms`` debounce window at ``now``, and the latched chord. Expired release-window notes are pruned as a side effect. """ held: typing.Set[int] = set(self._on) held.update(self._latched) if self._releasing: for pitch in [p for p, deadline in self._releasing.items() if deadline <= now]: del self._releasing[pitch] held.update(self._releasing) return sorted(held)