Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""Footswitch controller — debounced GPIO input for stomp switches.
|
||||
|
||||
Handles multiple momentary footswitches with debouncing,
|
||||
long-press detection, and mode switching.
|
||||
|
||||
Typical layout:
|
||||
[FS1] Preset Up / Tap Tempo [FS2] Preset Down / Hold for Tuner
|
||||
[FS3] Bypass [FS4] Bank Up
|
||||
[FS5] FX Select [FS6] Tap Tempo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEBOUNCE_MS = 20 # Debounce window
|
||||
LONG_PRESS_MS = 500 # Long press threshold
|
||||
POLL_INTERVAL_S = 0.005 # 5ms poll for responsiveness
|
||||
|
||||
|
||||
class SwitchAction(Enum):
|
||||
"""Actions triggered by footswitch events."""
|
||||
PRESET_UP = "preset_up"
|
||||
PRESET_DOWN = "preset_down"
|
||||
BANK_UP = "bank_up"
|
||||
BANK_DOWN = "bank_down"
|
||||
BYPASS = "bypass"
|
||||
TAP_TEMPO = "tap_tempo"
|
||||
TUNER = "tuner"
|
||||
FX_PREV = "fx_prev"
|
||||
FX_NEXT = "fx_next"
|
||||
EXPRESSION_TOGGLE = "expression_toggle"
|
||||
MIDI_LEARN = "midi_learn"
|
||||
SNAPSHOT_SAVE = "snapshot_save"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FootSwitch:
|
||||
"""State of a single footswitch."""
|
||||
gpio_pin: int
|
||||
action_default: SwitchAction
|
||||
action_long_press: Optional[SwitchAction] = None
|
||||
active_low: bool = True
|
||||
|
||||
|
||||
def _import_gpio():
|
||||
"""Import RPi.GPIO gracefully — returns None on non-RPi platforms."""
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
return GPIO
|
||||
except (ImportError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
class FootswitchController:
|
||||
"""Debounced footswitch input monitor.
|
||||
|
||||
In production, this reads RPi.GPIO. In testing/dev,
|
||||
it uses a virtual pin store that can be driven by simulate_press.
|
||||
"""
|
||||
|
||||
def __init__(self, switches: list[FootSwitch] | None = None):
|
||||
self._switches = switches or self._default_layout()
|
||||
self._callbacks: dict[SwitchAction, list[Callable]] = {}
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._gpio = _import_gpio()
|
||||
|
||||
# Per-pin state tracking — maps gpio_pin -> pin state
|
||||
self._pin_tracker: dict[int, _PinState] = {}
|
||||
|
||||
for sw in self._switches:
|
||||
self._pin_tracker[sw.gpio_pin] = _PinState()
|
||||
|
||||
def _default_layout(self) -> list[FootSwitch]:
|
||||
"""4-switch default layout."""
|
||||
return [
|
||||
FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO),
|
||||
FootSwitch(27, SwitchAction.PRESET_DOWN, SwitchAction.TUNER),
|
||||
FootSwitch(22, SwitchAction.BYPASS, SwitchAction.SNAPSHOT_SAVE),
|
||||
FootSwitch(23, SwitchAction.BANK_UP, SwitchAction.BANK_DOWN),
|
||||
]
|
||||
|
||||
def register_callback(self, action: SwitchAction, callback: Callable) -> None:
|
||||
"""Register a callback for a switch action."""
|
||||
self._callbacks.setdefault(action, []).append(callback)
|
||||
|
||||
def _trigger(self, action: SwitchAction) -> None:
|
||||
"""Fire callbacks for an action."""
|
||||
for cb in self._callbacks.get(action, []):
|
||||
try:
|
||||
cb()
|
||||
except Exception as e:
|
||||
logger.error("Switch callback error for %s: %s", action.value, e)
|
||||
|
||||
# --- GPIO abstraction layer ---
|
||||
|
||||
def _read_pin(self, pin: int) -> bool:
|
||||
"""Read a GPIO pin.
|
||||
|
||||
Returns True = pressed, False = released.
|
||||
If RPi.GPIO is unavailable, reads from a virtual store (for testing).
|
||||
"""
|
||||
if self._gpio:
|
||||
raw = self._gpio.input(pin)
|
||||
# Find which switch maps to this pin so we can invert if active_low
|
||||
for sw in self._switches:
|
||||
if sw.gpio_pin == pin:
|
||||
return not raw if sw.active_low else bool(raw)
|
||||
return bool(raw)
|
||||
else:
|
||||
return self._pin_tracker[pin].virtual_level
|
||||
|
||||
def _setup_pins(self) -> None:
|
||||
"""Configure GPIO pins as inputs with pull-up/down."""
|
||||
if not self._gpio:
|
||||
logger.info("No RPi.GPIO — running in virtual (test) mode")
|
||||
return
|
||||
|
||||
self._gpio.setmode(self._gpio.BCM)
|
||||
for sw in self._switches:
|
||||
pull = self._gpio.PUD_UP if sw.active_low else self._gpio.PUD_DOWN
|
||||
self._gpio.setup(sw.gpio_pin, self._gpio.IN, pull_up_down=pull)
|
||||
|
||||
def _cleanup_pins(self) -> None:
|
||||
"""Release GPIO pin configuration."""
|
||||
if self._gpio:
|
||||
self._gpio.cleanup()
|
||||
|
||||
# --- Debounce engine ---
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Background thread: poll pins with debounce and long-press detection."""
|
||||
last_logged_state: dict[int, bool] = {}
|
||||
|
||||
for sw in self._switches:
|
||||
last_logged_state[sw.gpio_pin] = False
|
||||
|
||||
while self._running:
|
||||
now_ms = time.monotonic() * 1000
|
||||
|
||||
for sw in self._switches:
|
||||
pin = sw.gpio_pin
|
||||
tracker = self._pin_tracker[pin]
|
||||
raw = self._read_pin(pin)
|
||||
|
||||
# --- Debounce ---
|
||||
if raw != tracker.unstable_level:
|
||||
tracker.unstable_level = raw
|
||||
tracker.last_change_ms = now_ms
|
||||
continue # Changed — wait for debounce window
|
||||
|
||||
# Stable across debounce window?
|
||||
elapsed = now_ms - tracker.last_change_ms
|
||||
if elapsed < DEBOUNCE_MS:
|
||||
continue # Still within debounce window
|
||||
|
||||
# Stable and beyond debounce window — commit stable state
|
||||
if raw != tracker.stable_level:
|
||||
tracker.stable_level = raw
|
||||
logger.debug("Pin %d debounced: %s", pin, "PRESSED" if raw else "released")
|
||||
|
||||
if raw: # Just pressed
|
||||
tracker.press_start_ms = now_ms
|
||||
else: # Just released — check for short vs long press
|
||||
press_duration = now_ms - tracker.press_start_ms
|
||||
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
|
||||
logger.debug("Pin %d LONG press (%dms) → %s", pin, int(press_duration), sw.action_long_press.value)
|
||||
self._trigger(sw.action_long_press)
|
||||
tracker.long_press_handled = True
|
||||
elif press_duration >= LONG_PRESS_MS and not sw.action_long_press:
|
||||
# No long-press action mapped — fall through to default
|
||||
logger.debug("Pin %d long press, no action mapped — triggering default", pin)
|
||||
self._trigger(sw.action_default)
|
||||
else:
|
||||
logger.debug("Pin %d short press (%dms) → %s", pin, int(press_duration), sw.action_default.value)
|
||||
self._trigger(sw.action_default)
|
||||
tracker.long_press_handled = False
|
||||
|
||||
# If still pressed and past long-press threshold but no release yet
|
||||
# — don't repeatedly fire, just mark it
|
||||
if raw and tracker.stable_level and not tracker.long_press_handled:
|
||||
press_duration = now_ms - tracker.press_start_ms
|
||||
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
|
||||
logger.debug("Pin %d LONG press triggered (no release needed)", pin)
|
||||
self._trigger(sw.action_long_press)
|
||||
tracker.long_press_handled = True
|
||||
|
||||
# Reset long_press_handled when released
|
||||
if not raw:
|
||||
tracker.long_press_handled = False
|
||||
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
|
||||
# --- Lifecycle ---
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start monitoring footswitches.
|
||||
|
||||
In production, starts GPIO monitoring thread.
|
||||
"""
|
||||
self._setup_pins()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name="footswitch-poll")
|
||||
self._thread.start()
|
||||
logger.info("Footswitch controller started (%d switches)", len(self._switches))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop monitoring."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._cleanup_pins()
|
||||
logger.info("Footswitch controller stopped")
|
||||
|
||||
# --- Testing hooks ---
|
||||
|
||||
def simulate_press(self, action: SwitchAction) -> None:
|
||||
"""Simulate a footswitch press (for testing).
|
||||
|
||||
Directly triggers the action without going through GPIO.
|
||||
"""
|
||||
logger.debug("SIMULATED press: %s", action.value)
|
||||
self._trigger(action)
|
||||
|
||||
def simulate_gpio_change(self, pin: int, pressed: bool) -> None:
|
||||
"""Set a virtual GPIO pin level for testing the debounce engine."""
|
||||
tracker = self._pin_tracker.get(pin)
|
||||
if tracker:
|
||||
tracker.virtual_level = pressed
|
||||
|
||||
|
||||
# --- Internal state holder ---
|
||||
|
||||
@dataclass
|
||||
class _PinState:
|
||||
"""Per-pin debounce state."""
|
||||
last_change_ms: float = 0.0
|
||||
press_start_ms: float = 0.0
|
||||
unstable_level: bool = False
|
||||
stable_level: bool = False
|
||||
long_press_handled: bool = False
|
||||
virtual_level: bool = False # For testing without RPi.GPIO
|
||||
Reference in New Issue
Block a user