Skip to content

Add 10 utility abstractions: debounce, throttle, notepriority, cache, listdiff, ringbuf, dict.defaults, state.latch, notecluster, schmitt.edge~#27

Merged
user1303836 merged 25 commits intomainfrom
feat/util-debounce
Feb 25, 2026
Merged

Add 10 utility abstractions: debounce, throttle, notepriority, cache, listdiff, ringbuf, dict.defaults, state.latch, notecluster, schmitt.edge~#27
user1303836 merged 25 commits intomainfrom
feat/util-debounce

Conversation

@user1303836
Copy link
Owner

@user1303836 user1303836 commented Feb 24, 2026

Summary

Implements all 10 native Max abstractions from the utility objects roadmap:

Message/Data Utilities

  • util.debounce - Message debouncer with leading/trailing edge modes, flush/cancel/bypass/reset
  • util.throttle - Rate-limiter with drop/latest/queue overflow policies, flush/clear/bypass/reset
  • util.cache.ttl - Key/value cache with time-to-live expiration and lazy/eager expiry
  • util.listdiff - Duplicate-aware list comparison with added/removed/common outputs and sorted mode
  • util.ringbuf - Bounded circular message history buffer with random access and dump
  • util.dict.defaults - Dict merge/defaults with required-key validation and force mode
  • util.state.latch - Schmitt-style binary latch with dwell time for jitter/chatter suppression

MIDI Utilities

  • midi.notepriority - Monophonic note-priority filter with low/high/last modes and automatic switchover
  • midi.notecluster - MIDI chord/cluster detection within configurable time window

Signal Utilities

  • sig.schmitt.edge~ - Signal-rate hysteresis threshold detector with rising/falling edge bangs

Each utility includes abstraction .maxpat, help/demo .maxpat, and Cycling '74-style README.md.

Design

  • All internal state uses #0-scoped names for instance isolation
  • Deterministic message ordering via trigger objects throughout
  • bypass on all utilities clears pending state
  • Lazy expiry in cache silently removes entries without leaking status messages
  • sig.schmitt.edge~ uses gen~ codebox for per-sample Schmitt trigger logic

Review Fixes Applied

Round 1

  • P1 midi.notepriority bypass stuck notes: Added untracked note-off detection — note-offs for pitches not in held-note table are emitted directly
  • P2 FindLast init: Fixed best-sequence init from 0 to -1 so first note in last mode is not ignored
  • P2 util.cache.ttl lazy expiry: Silent invalidation path prevents status leakage on lazy expiry
  • util.dict.defaults double-validation: Fixed premature validation running before merge completed
  • util.state.latch trigger ordering: Fixed obj-82 trigger ordering in request handler

Round 2

  • P1 util.throttle stale queue payloads: Fixed pack hot/cold inlet ordering so payload reaches cold inlet before hot inlet triggers
  • P1 util.throttle capacity off-by-one: Added capacity check gate before enqueue; full queue now emits overflow status
  • P1 util.throttle flush ordering: Swapped trigger outlets so dump fires before clear
  • P1 midi.notecluster ClusterEmit wiring: Fixed zl len outlet routing so cluster list (not length) flows to downstream operators
  • P2 midi.notecluster bypass outlet contamination: Disconnected bypass control integers from note-off passthrough outlet
  • P2 sig.schmitt.edge~ reset thresholds: Reset now replays patcherarg-derived values instead of hardcoded 0.4/0.6
  • P2 sig.schmitt.edge~ reset bypass selector: Reset now sends selector update to match cleared bypass state
  • P3 util.ringbuf repeated full status: Added wasFull transition guard so full emits only on not-full→full transition

Test plan

  • Open each help patch in Max 9 and verify all demo sections work
  • util.debounce: rapid burst → single trailing output; leading-only; both-edges; flush/cancel/bypass
  • util.throttle: drop (burst → first only); latest (first + trailing); queue (FIFO drain at interval); queue full → overflow status; flush emits then clears
  • midi.notepriority: low/high/last modes; switchover; panic; bypass on → play notes → bypass off → release → no stuck notes
  • util.cache.ttl: set/get hit before expiry; get after TTL → miss; invalidate; sweep; clear
  • util.listdiff: first list → all added; same list → unchanged; add/remove; sorted mode; prime
  • util.ringbuf: append under capacity; wrap-around; get/dump/clear; full status fires once on transition only
  • util.dict.defaults: missing keys filled; force 0 preserves input; force 1 overwrites; required key validation
  • util.state.latch: hysteresis band; dwell timer; pending cancel on band return; reset; bypass
  • midi.notecluster: notes within window cluster; beyond window → separate; flush emits cluster; bypass doesn't pollute outlet 3
  • sig.schmitt.edge~: rising/falling edge bangs; hysteresis stability; reset restores creation-arg thresholds; reset clears bypass
  • Multiple instances of each utility don't interfere

Implements a message debouncer that delays output until input is quiet
for N ms. Supports leading/trailing edge modes, flush, cancel, bypass,
and reset. Uses #0-scoped internals for instance isolation. Argument
sets default debounce time (default 50ms).
Documents inlets, outlets, arguments, all control messages, mode
combinations, default values, usage examples, and related objects.
Max trigger objects fire right-to-left. Several triggers had
incorrect outlet ordering causing:

- obj-29, obj-37: gate data arriving before gate control was set
  (t b l -> t l b, connections swapped)
- obj-34: timer reset setting pending=1 before leading edge check
  could see pending=0 (t l l b -> t l b l, connections swapped)
- obj-43: new delay starting before old one stopped
  (outlets 1<->2 swapped)
- obj-58: pending cleared before checked, breaking flush
  (outlets 0<->2 swapped)
- obj-61: status sent before message output on flush
  (outlets 0<->2 swapped)
- obj-31: numinlets corrected to 2 for + object
The trigger object obj-34 was t l b l which caused the timer reset
(setting pending=1) to fire before the leading edge check could read
pending state. Changed to t b l l so R-to-L execution is:
  1) outlet 2 (list): store msg in buffers
  2) outlet 1 (list): check leading edge (while pending still has old value)
  3) outlet 0 (bang): reset timer and set pending=1 (fires last)

This fixes the bug where leading edge output never fired because
pending was always 1 by the time the leading check ran.
When bypass is enabled, any pending trailing-edge timer is now
canceled via s #0_cancel, preventing stale buffered messages from
leaking through during bypass mode. Added t i i -> sel 1 chain
on the bypass control path. Updated README to document this behavior.
Implements a MIDI note-priority abstraction that filters polyphonic
input into monophonic output based on low/high/last priority modes.
Supports switchover (re-triggers next priority note when active note
is released), panic/reset, and bypass. Includes help patch and docs.
Key-value cache abstraction using dict objects for storage and cpuclock
timestamps for expiry. Commands: set/get/has/invalidate/clear. Lazy
expiry on get/has, eager expiry via sweep. All internal state #0-scoped
for instance isolation. Includes help patch and README.
midi.notepriority:
- Default mode corrected from "last" to "low" per spec (README)

util.cache.ttl:
- Fix 3 reversed trigger orderings in SetCmd subpatcher where cold
  inlets fired after hot inlets (obj-3, obj-10, obj-15 connections)
- Default TTL corrected from 5000 to 1000 per spec (patch + README)
P1: midi.notepriority last mode ignores first note after init/reset.
FindLast initialized bestseq to 0, so first note (seq=0) failed
the strict > comparison. Changed bestseq init to -1.

P2: midi.notepriority bypass leaves stale held-note state.
Added t i i -> sel 1 -> s #0_panic chain on bypass control path
so enabling bypass clears all internal state via panic.

P2: util.cache.ttl leaks "invalidated" status on lazy expiry.
Lazy expiry in get/has/sweep now uses s #0_lazy_invalidate routed
to a silent p SilentInvalidate subpatcher (dict remove only, no
status output). Explicit invalidate command still outputs status.
Removed orphaned r #0_invalidate.

P3: midi.notepriority README still claimed "last" as default in
one example line. Fixed to match spec (low is default).
@user1303836 user1303836 changed the title Add util.debounce abstraction Add util.debounce, midi.notepriority, and util.cache.ttl abstractions Feb 24, 2026
Implements a message throttle abstraction that passes at most one
message per configurable interval. Three policies handle messages
arriving during cooldown: drop (discard), latest (keep newest),
and queue (FIFO buffer with configurable capacity).

Includes help patch demonstrating all policies, flush, bypass,
and dynamic interval adjustment.
…/common

Implements a list differ abstraction using v8.codebox for correct
duplicate-aware bag comparison. Outputs added, removed, and common
items with support for sorted (order-independent) mode, bypass, and
reset. Each occurrence is tracked independently.
Circular message history buffer using v8.codebox for ring buffer logic.
Supports get (by recency index), dump (oldest to newest), clear, capacity,
size, reset, and bypass. Configurable capacity via creation argument
(default 16). Includes help patch and README.
When bypass was enabled, notes bypassed the state-tracking path
(RecordNoteOn/ClearNoteOff). If bypass was then disabled while
those bypass-started notes were still held, their subsequent
note-offs entered the filtered path but were dropped because the
held-note table had no record of them.

Added an untracked note-off check in the note-off path: before
entering ClearNoteOff + findwinner priority logic, read
table #0_held for the incoming pitch. If held[pitch] == 0
(untracked), emit pitch 0 directly to the output, bypassing
the priority logic. If held[pitch] > 0 (tracked), proceed
with the normal priority evaluation path.
@user1303836 user1303836 changed the title Add util.debounce, midi.notepriority, and util.cache.ttl abstractions Add utility abstractions: debounce, throttle, notepriority, cache, listdiff, ringbuf Feb 24, 2026
New abstraction that merges default key/value pairs into incoming
dictionaries and validates required keys. Supports force mode
(defaults overwrite vs gap-fill), required key management, and
per-merge diagnostics (applied/preserved counts). Includes help
patch and documentation.
Control-rate hysteresis latch that suppresses jitter/chatter in noisy
control streams. State transitions require input to remain in threshold
region for configurable dwell period before committing.

Features:
- Binary state (0/1) with high/low thresholds and neutral band
- Configurable dwell timer for transition confirmation
- Pending transitions cancel if input returns to neutral band
- Bypass mode for simple thresholding without dwell
- Duplicate state suppression
- Creation args for low/high thresholds (defaults 0.4/0.6)
- #0-scoped internals for instance isolation
- Deterministic ordering via trigger objects throughout
Per-sample hysteresis implemented in a gen~ codebox: state=1 when
input > high threshold, state=0 when input < low threshold, else
hold previous state. edge~ converts state transitions to rising and
falling bangs.

Features:
- 4 inlets: signal in, low threshold, high threshold, control messages
- 4 outlets: state signal (0./1.), rising bang, falling bang, status
- Creation args set initial thresholds (defaults 0.4 / 0.6)
- Control messages: low, high, init, reset, bypass
- Bypass mode substitutes simple >0.5 comparator via selector~
- Threshold clamping in gen~ ensures correct behavior if low > high
- #0-scoped internals for instance isolation
- Includes help patch and Cycling '74-style README
Remove spurious connection from t s s (obj-28) to ValidateRequired
that caused validation to run before merge, producing an incorrect
status message from stale dict data. Validation now only fires once,
after MergeDefaults completes via t l l l (obj-29). Also corrects
README output order to match actual trigger firing sequence.
…ection

MIDI note clustering abstraction that accumulates note-on events within
a configurable window (default 5ms) and emits cluster list with metadata.

Features:
- Cluster window via delay timer (restarts on each note-on)
- Optional sort and unique filtering (both default on)
- Metadata output: count, lowest, highest, single, done
- Note-off passthrough on separate outlet
- Bypass, flush, and reset control messages
- #0-scoped internals for instance isolation

Subpatchers: ClusterAccumulate (zl group), ClusterTimer (delay),
ClusterShape (zl sort/unique), ClusterEmit (metadata computation).

Includes help patch and README.
obj-82 used t i b i which fired the request value to != hot inlet
before fetching current state into cold inlet, causing stale state
comparisons. Changed to t i i b so bang fetches state first, then
value triggers comparison -- matching the correct pattern in obj-106.
doStore() was emitting 'full' on every append once count === cap because
count stays clamped at cap after the buffer first fills. Add a wasFull
guard to only emit on the not-full to full transition.
Bug 1: ClusterEmit's zl len was only sending the length (outlet 0) to
the trigger, while the pass-through list (outlet 1) was disconnected.
Downstream list operators received an integer length instead of the
actual cluster list. Fixed by connecting zl len outlet 1 directly to
the gate data inlet and changing the trigger from t i l i to t i i.

Bug 2: Bypass control path sent !- 1 output (0/1 integers) directly
to outlet 3, polluting the note-off passthrough with stray control
values. Disconnected the bypass inversion from the outlet since it is
only needed for gate control.
…elector

Bug 1: Reset now reads initial thresholds from v #0_init_low / v #0_init_high
(populated at loadbang from patcherargs) instead of hardcoded 0.4/0.6 messages.

Bug 2: Reset now sends selector control value 1 to s #0_sel, ensuring the
selector~ switches back to the gen~ output (non-bypass) when bypass is cleared.
Bug 1: Reorder trigger outlets in enqueue path so pack's cold
inlet (payload) is set before hot inlet (qidx) fires output.
Changed obj-72 from 't l b b' to 't b l b' and rewired.

Bug 2: Add capacity check gate before enqueue. Compare qidx >= cap
and route to overflow status when queue is full, preventing
unbounded growth and off-by-one.

Bug 3: Swap flush trigger outlets so coll dump fires before clear,
preventing data loss on flush.
@user1303836 user1303836 changed the title Add utility abstractions: debounce, throttle, notepriority, cache, listdiff, ringbuf Add 10 utility abstractions: debounce, throttle, notepriority, cache, listdiff, ringbuf, dict.defaults, state.latch, notecluster, schmitt.edge~ Feb 24, 2026
…ring

Fix three bugs found during review:

1. Bypass gate (root): gate 2 control received raw bypass (0/1) instead
   of bypass+1 (1/2). With bypass=0 the gate was closed, blocking all
   normal processing. Added +1 before gate control, removed dead !-1 path.

2. ClusterEmit list loss: zl len outlet 1 (list passthrough) arrived at
   the gate data inlet before the gate was opened by the count path,
   causing the list to be discarded. Inserted t l l splitter so the count
   path (outlet 1, fires first) opens the gate before the list (outlet 0,
   fires second) arrives.

3. Metadata ordering: done message fired first (outlet 4, R-to-L) instead
   of last. Swapped cluster and done trigger outlets so done fires last
   among metadata. Updated README to match corrected sequence.
@user1303836 user1303836 merged commit 6fbca21 into main Feb 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant