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/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 000000000..e79e63686 Binary files /dev/null and b/tests/fixtures/adc_slow.npy differ 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 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