From 2cc2c1782ccc4f4f7f6112520b49f117bd515a7c Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Thu, 9 Jul 2026 23:58:17 -0400 Subject: [PATCH 1/2] ADC noise supression (sleep + wake) --- pistomp/analogmidicontrol.py | 14 +- pistomp/input/analog_connection.py | 193 ++++++++++++ tests/fixtures/adc_slow.npy | Bin 0 -> 192128 bytes tests/test_analog_connection_monitor.py | 399 ++++++++++++++++++++++++ tests/test_analog_midi_control.py | 6 + 5 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 pistomp/input/analog_connection.py create mode 100644 tests/fixtures/adc_slow.npy create mode 100644 tests/test_analog_connection_monitor.py diff --git a/pistomp/analogmidicontrol.py b/pistomp/analogmidicontrol.py index 04bd9264b..77f9fa99c 100755 --- a/pistomp/analogmidicontrol.py +++ b/pistomp/analogmidicontrol.py @@ -19,6 +19,7 @@ import pistomp.analogcontrol as analogcontrol import pistomp.controller as controller from pistomp.controller import AnalogDisplayInfo +from pistomp.input.analog_connection import AnalogConnectionMonitor from pistomp.input.event import AnalogEvent @@ -38,6 +39,7 @@ def __init__(self, spi, adc_channel, tolerance, midi_CC, midi_channel, type, id= self.last_read = 0 self.value = None self.cfg: dict[str, Any] = cfg or {} + self._connection = AnalogConnectionMonitor() def set_midi_channel(self, midi_channel): self.midi_channel = midi_channel @@ -65,12 +67,22 @@ def _send_value(self, value): )) def send_current_value(self): - """Force-send the current ADC value unconditionally. Used by sync_analog_controls().""" + """Force-send the current ADC value unconditionally. Used by sync_analog_controls(). + + Suppressed while the connection monitor has not yet classified the + channel or has classified it as floating (ASLEEP) — autosyncing a + disconnected pin would emit a spurious MIDI CC for the noise floor. + """ + if not self._connection.is_awake: + return value = self._clamp_endpoints(self.readChannel()) self._send_value(value) def refresh(self): value = self._clamp_endpoints(self.readChannel()) + self._connection.observe(value) + if not self._connection.is_awake: + return if abs(value - self.last_read) > self.tolerance: self._send_value(value) diff --git a/pistomp/input/analog_connection.py b/pistomp/input/analog_connection.py new file mode 100644 index 000000000..3ad8d7a64 --- /dev/null +++ b/pistomp/input/analog_connection.py @@ -0,0 +1,193 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +"""Analog input connection monitor — detects disconnected/floating ADC pins. + +A pure-Python two-state machine fed one raw 10-bit ADC reading per poll tick. +No hardware dependencies; fully unit-testable with synthetic streams. + +WHY + The MCP3008 has no fault-detection pin. An unconnected analog input + (e.g. an expression-pedal jack with nothing plugged in) floats, picking + up 60 Hz mains hum via capacitive coupling. At our 100 Hz poll rate the + 60 Hz aliases into a ~40 Hz beat, producing stddev of ~23–34 LSB and + excursions up to 142 LSB on a nominally "zero" input. This drives + spurious MIDI CCs and, worse, repaints the LCD control-progress bars + every tick. + + A connected passive potentiometer at rest produces Johnson + kTC noise + well below 1 LSB (measured σ ≈ 0.4 LSB), so the two states are separated + by ~50× in stddev. This module exploits that gap. + +STATES + DETERMINING First WINDOW samples after construction. Classify as ASLEEP + if the window shows the floating signature (high σ, high + excursion, low rail-avoidant mean); otherwise AWAKE. + Autosync is suppressed until a verdict is reached. + AWAKE Normal operation — the caller should emit MIDI and update + its cached reading. Every tick also feeds a rolling window + so we can detect an unplug at runtime. + ASLEEP The pin appears floating. The caller should silently drop + readings (no MIDI, no LCD progress update). The baseline + drifts via EMA to track slow floating-mean wander. A + reading that steps far enough from the baseline wakes + immediately (a pedal was plugged in). + +THRESHOLDS (derived from on-device measurement, see docs below) + WINDOW = 16 samples (160 ms at 10 ms poll) + STD_MIN = 3 LSB (3× above connected σ≈0.4, ~8× below floating σ≈23) + STD_MAX = 50 LSB (above floating σ≈34; a fast sweep >128 LSB has σ≈40+) + EXCURSION_MIN = 8 LSB (4× above connected E≈2, ~12× below floating E≈96) + EXCURSION_MAX = 150 LSB (above floating E≈142; a fast sweep >128 LSB has E≈128+) + WAKE_EXCURSION = 48 LSB (1.2% false-wake on pure floating noise; catches all plug-ins ≥ 100 LSB) + EMA_ALPHA = 0.01 (baseline tracks slow floating drift over minutes) + +The classifier uses std AND excursion, each bounded in a [min, max) range. +A connected pot at rest has σ ≈ 0.4 LSB and E ≈ 2 LSB — far below both +gates regardless of where the wiper is parked. A floating pin has +σ ≈ 23–34 LSB and E up to 142 LSB — within the gates. A fast pedal sweep +(>128 LSB over 160 ms) has σ > 40 and E > 128, which exceeds the upper +bounds and is correctly AWAKE. A slow sweep near heel overlaps the +floating band and may be ASLEEP at startup, but the wake mechanism +(48 LSB step from baseline) recovers it within 1 frame. + +The upper bounds make the classifier conservative: when in doubt, AWAKE. +This matches the musical-instrument priority — let a few noise samples +through rather than block a real pedal. + +All thresholds are over the 10-bit ADC range [0, 1023]. +""" + +from __future__ import annotations + +from collections import deque +from enum import Enum + + +class AnalogConnectionState(Enum): + DETERMINING = "determining" + AWAKE = "awake" + ASLEEP = "asleep" + + +class AnalogConnectionMonitor: + """Two-state connection monitor for a single ADC channel. + + Feed one raw 10-bit reading per poll tick via observe(); the return + tells the caller whether to emit the reading (pass it downstream) or + drop it silently. + """ + + # --- Tunables (see module docstring for justification) --- + WINDOW: int = 16 + STD_MIN: float = 3.0 + STD_MAX: float = 50.0 + EXCURSION_MIN: float = 8.0 + EXCURSION_MAX: float = 150.0 + WAKE_EXCURSION: float = 48.0 + EMA_ALPHA: float = 0.01 + + def __init__(self) -> None: + self._state: AnalogConnectionState = AnalogConnectionState.DETERMINING + self._window: deque[int] = deque(maxlen=self.WINDOW) + self._baseline: float = 0.0 + self._startup_samples: int = 0 + + # --- Public API ----------------------------------------------------- + + @property + def state(self) -> AnalogConnectionState: + return self._state + + @property + def is_awake(self) -> bool: + return self._state is AnalogConnectionState.AWAKE + + @property + def baseline(self) -> float: + """Current baseline value (floating mean when ASLEEP, last mean when AWAKE).""" + return self._baseline + + def observe(self, raw: int) -> AnalogConnectionState: + """Process one raw ADC reading. Returns the resulting state. + + The caller checks ``is_awake`` (or compares the returned state to + its previous state) to decide whether to emit the reading. + """ + self._window.append(raw) + if self._state is AnalogConnectionState.DETERMINING: + self._startup_samples += 1 + if self._startup_samples >= self.WINDOW: + self._classify_startup() + # Still determining: don't emit (autosync suppressed). + return self._state + + if self._state is AnalogConnectionState.AWAKE: + self._check_runtime_sleep(raw) + return self._state + + # ASLEEP + self._check_runtime_wake(raw) + return self._state + + # --- Internal ------------------------------------------------------- + + def _window_stats(self) -> tuple[float, float, float]: + """Return (mean, stddev, excursion) of the current window.""" + n = len(self._window) + if n == 0: + return 0.0, 0.0, 0.0 + s = float(sum(self._window)) + mean = s / n + if n == 1: + return mean, 0.0, 0.0 + var = sum((v - mean) ** 2 for v in self._window) / n # population + std = var ** 0.5 + excursion = float(max(self._window)) - float(min(self._window)) + return mean, std, excursion + + def _has_floating_signature(self) -> bool: + """True if the current window looks like a floating pin.""" + if len(self._window) < self.WINDOW: + return False + _, std, exc = self._window_stats() + return ( + self.STD_MIN <= std < self.STD_MAX + and self.EXCURSION_MIN <= exc < self.EXCURSION_MAX + ) + + def _classify_startup(self) -> None: + mean, _, _ = self._window_stats() + self._baseline = mean + if self._has_floating_signature(): + self._state = AnalogConnectionState.ASLEEP + else: + self._state = AnalogConnectionState.AWAKE + + def _check_runtime_sleep(self, raw: int) -> None: + """While AWAKE, watch for the floating signature (unplug event).""" + if self._has_floating_signature(): + mean, _, _ = self._window_stats() + self._state = AnalogConnectionState.ASLEEP + self._baseline = mean + + def _check_runtime_wake(self, raw: int) -> None: + """While ASLEEP, watch for a real signal stepping away from baseline.""" + # EMA-update the baseline so slow floating drift over minutes does + # not accumulate into a false wake. A genuinely floating reading + # stays near the drifting baseline; a plugged-in pedal jumps. + self._baseline = self._baseline + self.EMA_ALPHA * (raw - self._baseline) + if abs(raw - self._baseline) >= self.WAKE_EXCURSION: + self._state = AnalogConnectionState.AWAKE \ No newline at end of file diff --git a/tests/fixtures/adc_slow.npy b/tests/fixtures/adc_slow.npy new file mode 100644 index 0000000000000000000000000000000000000000..e79e636860ae83179c7173acdd98871c6b36d97b GIT binary patch literal 192128 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 NlE3KkF!z}|001z>6ny{y literal 0 HcmV?d00001 diff --git a/tests/test_analog_connection_monitor.py b/tests/test_analog_connection_monitor.py new file mode 100644 index 000000000..761cec4cc --- /dev/null +++ b/tests/test_analog_connection_monitor.py @@ -0,0 +1,399 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +"""Monte Carlo tests for AnalogConnectionMonitor. + +The physical stream generators below model the actual electrical +signatures measured on a pi-Stomp v2 core (MCP3008, 3.3 V, 100 Hz poll): + + FLOATING — 60 Hz mains hum cap-coupled onto a high-impedance + pin. At 100 Hz sample rate the 60 Hz aliases to a + ~40 Hz beat. Measured: sigma ≈ 23 LSB, E up to 96 LSB, + mean wanders ±45 LSB over 60 s. Modeled as a + Gaussian-modulated sinusoid. + CONNECTED_REST — passive pot at rest. Johnson + kTC noise < 1 LSB. + Measured sigma ≈ 0.4 LSB, E ≈ 2 LSB. Modeled as + value + Gaussian(0, 0.4), rounded, clipped. + CONNECTED_MOVING— wiper in motion. Clean monotonic sweep + sub-LSB + noise. Modeled as linspace + Gaussian(0, 0.4). + PLUGIN — floating stream, then step to CONNECTED_REST at + a given value at a given index. + UNPLUG — CONNECTED_REST, then transition to FLOATING. + +All trials use sequential fixed seeds for reproducibility. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from pistomp.input.analog_connection import AnalogConnectionMonitor, AnalogConnectionState + +ADC_MAX = 1023 + + +# ─── Physical stream generators ────────────────────────────────────── + + +def floating_stream( + n: int, + rng: np.random.Generator, + *, + mean: float = 16.0, + hum_amp: float = 23.0, + beat_freq: float = 0.07, + noise_std: float = 6.0, +) -> np.ndarray: + """Model a floating ADC pin: 60 Hz mains hum aliased at 100 Hz poll. + + The aliased 60 Hz appears as a slow beat (freq ≈ 0.07 Hz/sample for + a ~14 s period, matching the measured wander). A Gaussian envelope + adds sample-to-sample jitter. + """ + t = np.arange(n, dtype=np.float64) + beat = mean + hum_amp * np.sin(2 * np.pi * beat_freq * t / n * 8) + noise = rng.normal(0, noise_std, n) + return np.clip(np.round(beat + noise), 0, ADC_MAX).astype(int) + + +def connected_at_rest(n: int, value: float, rng: np.random.Generator, *, sigma: float = 0.4) -> np.ndarray: + """Model a connected passive pot, stationary, at `value`.""" + return np.clip(np.round(value + rng.normal(0, sigma, n)), 0, ADC_MAX).astype(int) + + +def connected_moving(n: int, v0: float, v1: float, rng: np.random.Generator, *, sigma: float = 0.4) -> np.ndarray: + """Model a connected pot swept from v0 to v1 over n samples.""" + sweep = np.linspace(v0, v1, n) + return np.clip(np.round(sweep + rng.normal(0, sigma, n)), 0, ADC_MAX).astype(int) + + +def plugin_event(n_float: int, plug_value: float, rng: np.random.Generator, *, n_post: int = 400) -> np.ndarray: + """Floating for n_float samples, then connected-at-rest at plug_value.""" + pre = floating_stream(n_float, rng) + post = connected_at_rest(n_post, plug_value, rng) + return np.concatenate([pre, post]) + + +def unplug_event(conn_value: float, n_conn: int, rng: np.random.Generator, *, n_float: int = 400) -> np.ndarray: + """Connected-at-rest for n_conn samples, then floating.""" + pre = connected_at_rest(n_conn, conn_value, rng) + post = floating_stream(n_float, rng) + return np.concatenate([pre, post]) + + +# ─── Helpers ───────────────────────────────────────────────────────── + + +def _run_stream(stream: np.ndarray) -> AnalogConnectionMonitor: + """Feed an entire stream into a fresh monitor, return the monitor.""" + mon = AnalogConnectionMonitor() + for raw in stream: + mon.observe(int(raw)) + return mon + + +def _run_stream_track_emits(stream: np.ndarray) -> tuple[AnalogConnectionMonitor, list[bool]]: + """Run a stream, recording (state != DETERMINING and is_awake) per tick.""" + mon = AnalogConnectionMonitor() + emits: list[bool] = [] + for raw in stream: + mon.observe(int(raw)) + emits.append(mon.is_awake) + return mon, emits + + +# ─── Startup classification ────────────────────────────────────────── + + +class TestStartupClassification: + """The first WINDOW samples classify the channel as ASLEEP or AWAKE.""" + + def test_floating_classified_asleep_high_rate(self): + """≥95% of 16-sample floating windows → ASLEEP (measured: 96.1%).""" + trials = 1000 + asleep_count = 0 + for i in range(trials): + r = np.random.default_rng(1000 + i) + stream = floating_stream(AnalogConnectionMonitor.WINDOW, r) + mon = _run_stream(stream) + if mon.state is AnalogConnectionState.ASLEEP: + asleep_count += 1 + rate = asleep_count / trials + assert rate >= 0.95, f"floating→ASLEEP rate {rate:.1%} < 95%" + + def test_connected_at_rest_never_asleep(self): + """A connected pot at any parked position must NEVER be ASLEEP. + + This is the musical-instrument invariant: never silence a real + pedal at startup. Tested across the full range of parked values + including heel-down (0) and toe-down (1023). + """ + trials = 1000 + for val in [0, 30, 100, 512, 1023]: + asleep = 0 + for i in range(trials): + r = np.random.default_rng(2000 + i + val) + stream = connected_at_rest(AnalogConnectionMonitor.WINDOW, val, r) + mon = _run_stream(stream) + if mon.state is AnalogConnectionState.ASLEEP: + asleep += 1 + assert asleep == 0, f"connected@{val}: {asleep}/{trials} false ASLEEP" + + def test_connected_moving_fast_sweep_stays_awake(self): + """A fast pedal sweep (>128 LSB over 160 ms) at startup must be AWAKE. + + std≈78, E≈256 — exceeds the upper bounds (50, 150), so correctly + classified AWAKE. A slow sweep near heel (0→64, std≈20, E≈64) + overlaps the floating band and may be ASLEEP, but the wake + mechanism recovers it (tested in TestRuntimeWake). + """ + trials = 1000 + for v0, v1 in [(0, 256), (256, 0), (0, 1023), (512, 800)]: + asleep = 0 + for i in range(trials): + r = np.random.default_rng(3000 + i) + stream = connected_moving(AnalogConnectionMonitor.WINDOW, v0, v1, r) + mon = _run_stream(stream) + if mon.state is AnalogConnectionState.ASLEEP: + asleep += 1 + assert asleep == 0, f"moving {v0}->{v1}: {asleep}/{trials} false ASLEEP" + + def test_startup_takes_exactly_window_samples(self): + """Classification happens after exactly WINDOW samples, not before.""" + mon = AnalogConnectionMonitor() + for i in range(AnalogConnectionMonitor.WINDOW - 1): + mon.observe(500) + assert mon.state is AnalogConnectionState.DETERMINING + mon.observe(500) + assert mon.state is AnalogConnectionState.AWAKE + + +# ─── Runtime wake (plug-in) ────────────────────────────────────────── + + +class TestRuntimeWake: + """An ASLEEP channel must wake promptly when a pedal is plugged in.""" + + @pytest.mark.parametrize("plug_value", [100, 200, 400, 700]) + def test_wake_within_few_frames(self, plug_value): + """Plug-in to a value ≥ 100 wakes within 2 frames (20 ms).""" + rng = np.random.default_rng(7) + stream = plugin_event(200, plug_value, rng) + mon = AnalogConnectionMonitor() + woke_at = None + for i, raw in enumerate(stream): + mon.observe(int(raw)) + if i >= 200 and mon.state is AnalogConnectionState.AWAKE: + woke_at = i - 200 + break + assert woke_at is not None, f"plug@{plug_value}: never woke" + assert woke_at <= 2, f"plug@{plug_value}: woke after {woke_at} frames (>{2})" + + def test_wake_emits_immediately(self): + """On wake, the current reading passes through (caller emits it).""" + rng = np.random.default_rng(11) + stream = plugin_event(100, 400, rng) + mon, emits = _run_stream_track_emits(stream) + # At the plug frame (idx 100), the monitor should be awake. + assert emits[100] is True + assert mon.state is AnalogConnectionState.AWAKE + + +# ─── Runtime sleep (unplug) ────────────────────────────────────────── + + +class TestRuntimeSleep: + """An AWAKE channel must go ASLEEP when the pedal is unplugged.""" + + @pytest.mark.parametrize("conn_value", [200, 512, 800]) + def test_sleep_within_window_after_unplug(self, conn_value): + """After unplug, channel sleeps within WINDOW+16 frames (320 ms).""" + rng = np.random.default_rng(11) + stream = unplug_event(conn_value, 400, rng, n_float=500) + mon = AnalogConnectionMonitor() + slept_at = None + for i, raw in enumerate(stream): + mon.observe(int(raw)) + if i >= 400 and mon.state is AnalogConnectionState.ASLEEP: + slept_at = i - 400 + break + assert slept_at is not None, f"unplug@{conn_value}: never slept" + limit = AnalogConnectionMonitor.WINDOW + 16 + assert slept_at <= limit, f"unplug@{conn_value}: slept after {slept_at} (>{limit})" + + def test_connected_at_rest_never_sleeps_at_runtime(self): + """A connected, stationary pedal must not be put to sleep at runtime.""" + for val in [0, 30, 100, 512, 1023]: + rng = np.random.default_rng(99 + val) + stream = connected_at_rest(6000, val, rng) + mon, emits = _run_stream_track_emits(stream) + # After startup, should be AWAKE and stay AWAKE. + assert mon.state is AnalogConnectionState.AWAKE, f"connected@{val} slept at runtime" + # Every tick after startup should be awake. + false_sleeps = sum(1 for e in emits[AnalogConnectionMonitor.WINDOW :] if not e) + assert false_sleeps == 0, f"connected@{val}: {false_sleeps} false-sleep ticks" + + +# ─── Noise leakage while ASLEEP ────────────────────────────────────── + + +class TestNoiseLeakage: + """A floating channel may briefly wake on noise — bounded leakage.""" + + def test_floating_leakage_rate_bounded(self): + """While floating for 60 s, ≤ 5% of ticks emit (false wakes accepted). + + The measured false-wake rate at threshold 48 LSB is ~1.2%; we + allow 5% headroom for handling transitions the user accepts. + """ + rng = np.random.default_rng(42) + stream = floating_stream(6000, rng) + mon = AnalogConnectionMonitor() + awake_ticks = 0 + total_ticks = 0 + for raw in stream: + mon.observe(int(raw)) + total_ticks += 1 + if mon.is_awake: + awake_ticks += 1 + # Only count post-startup ticks. + post = total_ticks - AnalogConnectionMonitor.WINDOW + awake_post = awake_ticks - sum(1 for _ in range(AnalogConnectionMonitor.WINDOW)) + leak_rate = awake_post / post + assert leak_rate <= 0.05, f"floating leakage {leak_rate:.1%} > 5%" + + +# ─── Baseline drift tracking ───────────────────────────────────────── + + +class TestBaselineDrift: + """The EMA baseline must track slow floating drift to avoid false wakes.""" + + def test_baseline_drifts_with_floating_mean(self): + """Over a long floating stream, baseline follows the wandering mean.""" + rng = np.random.default_rng(55) + stream = floating_stream(6000, rng) + mon = AnalogConnectionMonitor() + for raw in stream: + mon.observe(int(raw)) + # After 6000 samples, baseline should be within the stream's + # recent mean range (not stuck at the startup value). + recent_mean = float(stream[-100:].mean()) + assert abs(mon.baseline - recent_mean) < 30, ( + f"baseline {mon.baseline:.1f} not tracking recent mean {recent_mean:.1f}" + ) + + +# ─── State machine properties ──────────────────────────────────────── + + +class TestStateMachineProperties: + """General invariants of the state machine.""" + + def test_state_transitions_are_valid(self): + """State only follows DETERMINING → {AWAKE, ASLEEP} → {AWAKE ⇄ ASLEEP}.""" + rng = np.random.default_rng(77) + stream = plugin_event(200, 400, rng, n_post=200) + stream = np.concatenate([stream, floating_stream(200, np.random.default_rng(78))]) + mon = AnalogConnectionMonitor() + prev = AnalogConnectionState.DETERMINING + for raw in stream: + mon.observe(int(raw)) + cur = mon.state + if prev is AnalogConnectionState.DETERMINING: + assert cur in ( + AnalogConnectionState.AWAKE, + AnalogConnectionState.ASLEEP, + AnalogConnectionState.DETERMINING, + ) + else: + assert cur in (AnalogConnectionState.AWAKE, AnalogConnectionState.ASLEEP) + prev = cur + + def test_observe_never_raises_on_any_valid_adc(self): + """Every value in [0, 1023] is accepted without error.""" + mon = AnalogConnectionMonitor() + for v in [0, 1, 512, 1022, 1023]: + mon.observe(v) + assert mon.state is AnalogConnectionState.DETERMINING # not enough samples yet + + +# ─── Measured-data validation (the real captured streams) ──────────── + + +class TestMeasuredDataValidation: + """Validate against actual ADC captures from pistomp@pistomp.local. + + These tests use the measured floating + connected streams captured + on 2026-07-09 (60 Hz environment, v2 core). They are skipped if the + .npy files are not present (e.g. CI without the captures). + """ + + SLOW_PATH = str(Path(__file__).parent / "fixtures" / "adc_slow.npy") + + @pytest.fixture + def slow_data(self): + pytest.importorskip("numpy") + if not Path(self.SLOW_PATH).exists(): + pytest.skip(f"measured capture {self.SLOW_PATH} not available") + return np.load(self.SLOW_PATH) + + def test_measured_connected_channel_stays_awake(self, slow_data): + """CH0 (connected, σ≈0.4) must classify AWAKE and stay AWAKE.""" + ch0 = slow_data[:, 1].astype(int) # CH0 + mon, emits = _run_stream_track_emits(ch0) + assert mon.state is AnalogConnectionState.AWAKE + # Never sleeps at runtime. + false_sleeps = sum(1 for e in emits[AnalogConnectionMonitor.WINDOW :] if not e) + assert false_sleeps == 0 + + def test_measured_floating_channel_classifies_asleep(self, slow_data): + """CH1 (floating, σ≈23) must classify ASLEEP at startup.""" + ch1 = slow_data[:, 2].astype(int) # CH1 + mon = AnalogConnectionMonitor() + for raw in ch1[: AnalogConnectionMonitor.WINDOW]: + mon.observe(int(raw)) + assert mon.state is AnalogConnectionState.ASLEEP + + def test_measured_floating_leakage_bounded(self, slow_data): + """CH1 floating for 60 s: ≤ 5% leakage (accepted false wakes).""" + ch1 = slow_data[:, 2].astype(int) + mon = AnalogConnectionMonitor() + awake_post = 0 + for i, raw in enumerate(ch1): + mon.observe(int(raw)) + if i >= AnalogConnectionMonitor.WINDOW and mon.is_awake: + awake_post += 1 + post = len(ch1) - AnalogConnectionMonitor.WINDOW + leak = awake_post / post + assert leak <= 0.05, f"measured floating leakage {leak:.1%} > 5%" + + def test_measured_ch7_floating_classifies_asleep(self, slow_data): + """CH7 (floating, sigma≈34 — worst channel) must reach ASLEEP. + + The first 16 samples may land at a beat peak (std > 50, exceeding + the upper bound → AWAKE), but the rolling window recovers within + ~3 s as the aliased beat subsides. + """ + ch7 = slow_data[:, 3].astype(int) # CH7 + mon = AnalogConnectionMonitor() + for raw in ch7[:500]: + mon.observe(int(raw)) + if mon.state is AnalogConnectionState.ASLEEP: + return + assert False, "CH7 never reached ASLEEP in 500 ticks (5 s)" diff --git a/tests/test_analog_midi_control.py b/tests/test_analog_midi_control.py index 3956b6fbe..bdc6c7ddd 100644 --- a/tests/test_analog_midi_control.py +++ b/tests/test_analog_midi_control.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from pistomp.analogmidicontrol import AnalogMidiControl +from pistomp.input.analog_connection import AnalogConnectionMonitor, AnalogConnectionState from pistomp.input.event import AnalogEvent @@ -25,6 +26,11 @@ def _make_control(spi, *, midi_CC=75, midi_channel=14, last_read=0): sink = MagicMock() control.sink = sink control.last_read = last_read + # Prime the connection monitor to AWAKE by feeding it 16 stable + # connected-at-rest readings. This simulates a plugged-in pedal. + for _ in range(AnalogConnectionMonitor.WINDOW): + control._connection.observe(last_read) + assert control._connection.state is AnalogConnectionState.AWAKE return control, sink From 8928ed94ff476b9bd370e05843615fc160d0e694 Mon Sep 17 00:00:00 2001 From: Cameron Gorrie Date: Fri, 17 Jul 2026 18:50:46 -0400 Subject: [PATCH 2/2] Reduce thread wakeups by using proper async (#196) * v2 SPI tweaks * Do not wake up websocket unnecessarily * Put dpkg-verify on a background thread * Seems to be fine skipping reset now?? * Back out modify version changes * Faster blitting and better Pi3/Pi5 SPI constants and tools --- modalapi/websocket_bridge.py | 34 +++++++++++++- tests/test_websocket_bridge.py | 81 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 9902f7bb1..a464dedda 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -55,6 +55,7 @@ def __init__( self.ws = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._stop_event: asyncio.Event = asyncio.Event() + self._wakeup: asyncio.Event = asyncio.Event() # Metrics self.messages_sent = 0 @@ -80,10 +81,21 @@ def signal_stop(self): if self._loop is None: return self._loop.call_soon_threadsafe(self._stop_event.set) + self._loop.call_soon_threadsafe(self._wakeup.set) ws = self.ws if ws is not None: asyncio.run_coroutine_threadsafe(ws.close(), self._loop) + def notify(self): + """Thread-safe: wake the send loop after a message is enqueued.""" + loop = self._loop + if loop is None: + return # worker not started; connect-time flush covers these + try: + loop.call_soon_threadsafe(self._wakeup.set) + except RuntimeError: + pass # loop closed during shutdown + async def _interruptible_sleep(self, delay: float) -> bool: """Sleep for delay seconds; returns True if stop was signaled before the delay elapsed.""" try: @@ -124,7 +136,19 @@ async def _async_worker(self): if flushed: logging.info(f"Flushed {flushed} stale messages from queue after reconnect") - await asyncio.gather(self._process_queue(ws), self._receive_messages(ws)) + # FIRST_COMPLETED, not gather: the send loop parks on _wakeup and + # cannot notice a closed socket on its own. Whichever loop exits + # first cancels the other so we fall through to reconnect. + tasks = { + asyncio.create_task(self._process_queue(ws)), + asyncio.create_task(self._receive_messages(ws)), + } + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + for task in done: + task.result() # re-raise so the reconnect handler sees it except (websockets.exceptions.WebSocketException, OSError, ConnectionRefusedError) as e: logging.error(f"WebSocket connection error: {e}") @@ -156,7 +180,11 @@ async def _process_queue(self, ws): try: msg = self.command_queue.get_nowait() except queue.Empty: - await asyncio.sleep(0.001) # 1ms yield + # Clear before re-checking: a producer that enqueues between the + # failed get and the clear would otherwise have its wakeup erased. + self._wakeup.clear() + if self.command_queue.empty(): + await self._wakeup.wait() continue await ws.send(msg) @@ -263,6 +291,7 @@ def send_bpm(self, bpm: float) -> bool: if self._worker.backpressure_active: return False self.command_queue.put_nowait(f"transport-bpm {bpm}") + self._worker.notify() return True def send_parameter(self, instance_id: str, symbol: str, value: float) -> bool: @@ -271,6 +300,7 @@ def send_parameter(self, instance_id: str, symbol: str, value: float) -> bool: if self._worker.backpressure_active: return False self.command_queue.put_nowait(f"param_set /graph/{instance_id}/{symbol} {value}") + self._worker.notify() return True def get_received_messages(self) -> list: diff --git a/tests/test_websocket_bridge.py b/tests/test_websocket_bridge.py index ff10395fa..5ea828ab6 100644 --- a/tests/test_websocket_bridge.py +++ b/tests/test_websocket_bridge.py @@ -3,6 +3,8 @@ import asyncio import queue +import pytest + from modalapi.websocket_bridge import AsyncWebSocketBridge, WebSocketWorker @@ -231,3 +233,82 @@ def test_multiple_sends_preserve_order(): "transport-bpm 60", "param_set /graph/b/y 2.0", ] + + +# --------------------------------------------------------------------------- +# _process_queue: parks on _wakeup rather than polling +# --------------------------------------------------------------------------- + + +class _SendWs: + """WebSocket stand-in that records sends. No transport => buffer size reads as 0.""" + + def __init__(self): + self.sent: list[str] = [] + + async def send(self, msg: str) -> None: + self.sent.append(msg) + + +async def _started_worker() -> tuple[WebSocketWorker, _SendWs, asyncio.Task]: + worker = _make_worker() + worker.running = True + worker._loop = asyncio.get_running_loop() + ws = _SendWs() + task = asyncio.create_task(worker._process_queue(ws)) + await asyncio.sleep(0.02) # let it reach the park + return worker, ws, task + + +def test_idle_send_loop_parks_instead_of_polling(): + async def go(): + worker, ws, task = await _started_worker() + assert not task.done() + assert ws.sent == [] + # Parked: the event was consumed, so the loop is suspended, not spinning. + assert not worker._wakeup.is_set() + + worker.running = False + worker.signal_stop() + await asyncio.wait_for(task, timeout=1.0) + + asyncio.run(go()) + + +def test_notify_wakes_parked_send_loop(): + async def go(): + worker, ws, task = await _started_worker() + + worker.command_queue.put_nowait("param_set /graph/a/x 1.0") + worker.notify() + await asyncio.sleep(0.02) + assert ws.sent == ["param_set /graph/a/x 1.0"] + + worker.running = False + worker.signal_stop() + await asyncio.wait_for(task, timeout=1.0) + + asyncio.run(go()) + + +def test_parked_send_loop_is_cancellable(): + """_async_worker cancels the sender when the receive loop sees the socket close. + + Without this, a disconnect while idle would hang the bridge forever: the sender + never touches the socket, so it cannot notice the close on its own. + """ + + async def go(): + _worker, _ws, task = await _started_worker() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(go()) + + +def test_notify_before_worker_starts_is_a_noop(): + # No event loop yet; notify() must not raise. Queued messages are flushed on connect. + bridge = _make_bridge() + bridge.send_parameter("a", "x", 1.0) + assert bridge.get_queue_depth() == 1