Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Pi Multi-FX Pedal — Hardware UI layer."""
|
||||
from src.ui.footswitch import (
|
||||
DEBOUNCE_MS,
|
||||
LONG_PRESS_MS,
|
||||
FootSwitch,
|
||||
FootswitchController,
|
||||
SwitchAction,
|
||||
)
|
||||
from src.ui.leds import (
|
||||
LEDAnimation,
|
||||
LEDConfig,
|
||||
LEDController,
|
||||
LEDDriver,
|
||||
LEDPattern,
|
||||
)
|
||||
from src.ui.display import (
|
||||
DISPLAY_H,
|
||||
DISPLAY_W,
|
||||
DisplayController,
|
||||
DisplayState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# footswitch
|
||||
"DEBOUNCE_MS",
|
||||
"LONG_PRESS_MS",
|
||||
"FootSwitch",
|
||||
"FootswitchController",
|
||||
"SwitchAction",
|
||||
# LEDs
|
||||
"LEDAnimation",
|
||||
"LEDConfig",
|
||||
"LEDController",
|
||||
"LEDDriver",
|
||||
"LEDPattern",
|
||||
# display
|
||||
"DISPLAY_H",
|
||||
"DISPLAY_W",
|
||||
"DisplayController",
|
||||
"DisplayState",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
"""OLED display manager for the Pi Multi-FX Pedal.
|
||||
|
||||
Controls a 128x64 SSD1306 OLED via I2C to show:
|
||||
- Current preset name, bank, and status
|
||||
- Bypass status and tuner mode
|
||||
- Active FX chain with per-block status
|
||||
- Parameter values on edit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Display dimensions
|
||||
DISPLAY_W = 128
|
||||
DISPLAY_H = 64
|
||||
|
||||
# Layout constants
|
||||
MARGIN = 2
|
||||
LINE_H = 10 # 8px font + 2px spacing
|
||||
HEADER_H = 10 # Top status bar height
|
||||
FOOTER_Y = 56 # Bottom status line Y
|
||||
|
||||
# Font sizes for PIL
|
||||
FONT_SMALL = 8
|
||||
FONT_NORMAL = 10
|
||||
FONT_LARGE = 16
|
||||
|
||||
# Tuner display geometry
|
||||
TUNER_CENTER_X = 64
|
||||
TUNER_CENTER_Y = 32
|
||||
TUNER_NOTE_RADIUS = 20
|
||||
TUNER_NOTE_FONT = 24
|
||||
TUNER_CENT_FONT = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayState:
|
||||
"""What to show on the display."""
|
||||
mode: str = "preset" # preset, tuner, fx_edit, settings
|
||||
preset_name: str = ""
|
||||
bank_name: str = ""
|
||||
bypassed: bool = False
|
||||
bypass_led_state: bool = False
|
||||
fx_active: list[str] = field(default_factory=list)
|
||||
fx_bypass_states: dict[str, bool] = field(default_factory=dict)
|
||||
tuner_note: str = ""
|
||||
tuner_cents: int = 0
|
||||
param_name: str = ""
|
||||
param_value: float = 0.0
|
||||
|
||||
|
||||
def _import_display_driver():
|
||||
"""Import the SSD1306 display library gracefully."""
|
||||
try:
|
||||
import board
|
||||
import busio
|
||||
import adafruit_ssd1306
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
return (adafruit_ssd1306, Image, ImageDraw, ImageFont)
|
||||
except (ImportError, NotImplementedError):
|
||||
return None
|
||||
|
||||
|
||||
class DisplayController:
|
||||
"""Manages the OLED display.
|
||||
|
||||
In production, uses adafruit-circuitpython-ssd1306 over I2C with PIL.
|
||||
In dev/testing, logs display output.
|
||||
"""
|
||||
|
||||
def __init__(self, i2c_bus: int = 1, i2c_addr: int = 0x3C):
|
||||
self._i2c_bus = i2c_bus
|
||||
self._i2c_addr = i2c_addr
|
||||
self._state = DisplayState()
|
||||
self._initialized = False
|
||||
|
||||
# Hardware handles
|
||||
self._display = None
|
||||
self._image: Optional["Image"] = None
|
||||
self._draw: Optional["ImageDraw"] = None
|
||||
self._fonts: dict[str, "ImageFont"] = {}
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize the OLED display hardware.
|
||||
|
||||
Returns False if no display is connected (dev mode).
|
||||
"""
|
||||
try:
|
||||
driver_pkg = _import_display_driver()
|
||||
if driver_pkg is None:
|
||||
self._initialized = False
|
||||
logger.info("Display libraries not available — running in headless mode")
|
||||
return False
|
||||
|
||||
adafruit_ssd1306, Image, ImageDraw, ImageFont = driver_pkg
|
||||
|
||||
import board
|
||||
import busio
|
||||
|
||||
i2c = busio.I2C(board.SCL, board.SDA)
|
||||
self._display = adafruit_ssd1306.SSD1306_I2C(
|
||||
DISPLAY_W, DISPLAY_H, i2c, addr=self._i2c_addr
|
||||
)
|
||||
|
||||
# Create an image buffer
|
||||
self._image = Image.new("1", (DISPLAY_W, DISPLAY_H))
|
||||
self._draw = ImageDraw.Draw(self._image)
|
||||
|
||||
# Load default font — use PIL default bitmap font
|
||||
self._fonts["small"] = ImageFont.load_default()
|
||||
self._fonts["normal"] = ImageFont.load_default()
|
||||
self._fonts["large"] = ImageFont.load_default()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Display initialized (128x64 OLED @ 0x%02x)", self._i2c_addr)
|
||||
|
||||
# Show splash
|
||||
self._splash()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.info("No display detected — running in headless mode: %s", e)
|
||||
self._initialized = False
|
||||
return False
|
||||
|
||||
def update(self, state: DisplayState) -> None:
|
||||
"""Update the display with new state and re-render."""
|
||||
self._state = state
|
||||
if not self._initialized:
|
||||
logger.debug(
|
||||
"Display [%s]: %s | bypass=%s | FX=%s | tuner=%s %+d",
|
||||
state.mode,
|
||||
state.preset_name or state.bank_name or "",
|
||||
"BYP" if state.bypassed else "ON",
|
||||
", ".join(state.fx_active) or "—",
|
||||
state.tuner_note or "—",
|
||||
state.tuner_cents,
|
||||
)
|
||||
else:
|
||||
self._render()
|
||||
|
||||
def _render(self) -> None:
|
||||
"""Render current state to the OLED using the PIL buffer."""
|
||||
if not self._initialized or self._draw is None or self._image is None:
|
||||
return
|
||||
|
||||
draw = self._draw
|
||||
img = self._image
|
||||
|
||||
# Clear the buffer (white background — SSD1306 white=1)
|
||||
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0, outline=0)
|
||||
|
||||
if self._state.mode == "tuner":
|
||||
self._render_tuner(draw)
|
||||
elif self._state.mode == "preset":
|
||||
self._render_preset(draw)
|
||||
elif self._state.mode == "fx_edit":
|
||||
self._render_fx_edit(draw)
|
||||
elif self._state.mode == "settings":
|
||||
self._render_settings(draw)
|
||||
else:
|
||||
self._render_preset(draw)
|
||||
|
||||
# Blit to hardware
|
||||
self._display.image(img)
|
||||
self._display.show()
|
||||
|
||||
# --- Rendering modes ---
|
||||
|
||||
def _render_preset(self, draw) -> None:
|
||||
"""Render preset mode layout."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
|
||||
# Header bar — bank name
|
||||
bank_text = f"[{s.bank_name}]" if s.bank_name else ""
|
||||
draw.text((MARGIN, MARGIN), bank_text, fill=255, font=font)
|
||||
|
||||
# Bypass indicator top-right
|
||||
bypass_text = "BYP" if s.bypassed else "ACTIVE"
|
||||
bypass_x = DISPLAY_W - MARGIN - (len(bypass_text) * 6)
|
||||
draw.text((bypass_x, MARGIN), bypass_text, fill=255, font=font)
|
||||
|
||||
# Preset name — large, centered-ish
|
||||
preset_y = HEADER_H + 4
|
||||
draw.text((MARGIN, preset_y), s.preset_name or "—", fill=255, font=self._fonts.get("large"))
|
||||
|
||||
# Active FX chain (wrap if needed)
|
||||
fx_y = preset_y + 20
|
||||
if s.fx_active:
|
||||
parts = []
|
||||
for fx in s.fx_active:
|
||||
bypassed = s.fx_bypass_states.get(fx, False)
|
||||
if bypassed:
|
||||
parts.append(f"[{fx}]")
|
||||
else:
|
||||
parts.append(fx)
|
||||
fx_line = " ".join(parts)
|
||||
# Truncate to fit display width
|
||||
max_chars = DISPLAY_W // 6
|
||||
if len(fx_line) > max_chars:
|
||||
fx_line = fx_line[:max_chars - 3] + "..."
|
||||
draw.text((MARGIN, fx_y), fx_line, fill=255, font=font)
|
||||
|
||||
# Footer — preset number or info
|
||||
draw.text((MARGIN, FOOTER_Y), s.mode.upper(), fill=255, font=font)
|
||||
|
||||
def _render_tuner(self, draw) -> None:
|
||||
"""Render tuner mode — note name + cents indicator."""
|
||||
s = self._state
|
||||
font_normal = self._fonts.get("normal")
|
||||
|
||||
# "TUNER" header
|
||||
draw.text((MARGIN, MARGIN), "TUNER", fill=255, font=font_normal)
|
||||
|
||||
# Large note name in center
|
||||
note = s.tuner_note or "--"
|
||||
draw.text(
|
||||
(TUNER_CENTER_X - len(note) * 7, TUNER_CENTER_Y - 8),
|
||||
note,
|
||||
fill=255,
|
||||
font=self._fonts.get("large"),
|
||||
)
|
||||
|
||||
# Cents indicator bar
|
||||
cents = max(-50, min(50, s.tuner_cents))
|
||||
bar_center_x = TUNER_CENTER_X
|
||||
bar_y = TUNER_CENTER_Y + 16
|
||||
bar_w = 40
|
||||
bar_h = 4
|
||||
|
||||
# Background bar
|
||||
draw.rectangle(
|
||||
(bar_center_x - bar_w // 2, bar_y, bar_center_x + bar_w // 2, bar_y + bar_h),
|
||||
fill=0, outline=255,
|
||||
)
|
||||
|
||||
# Indicator position
|
||||
pos = int((cents + 50) / 100 * bar_w) - bar_w // 2
|
||||
indicator_x = bar_center_x + pos
|
||||
draw.rectangle(
|
||||
(indicator_x - 2, bar_y - 1, indicator_x + 2, bar_y + bar_h + 1),
|
||||
fill=255,
|
||||
)
|
||||
|
||||
# Cents text
|
||||
draw.text(
|
||||
(MARGIN, FOOTER_Y),
|
||||
f"{cents:+d} cents",
|
||||
fill=255,
|
||||
font=font_normal,
|
||||
)
|
||||
|
||||
def _render_fx_edit(self, draw) -> None:
|
||||
"""Render FX parameter edit mode."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
|
||||
draw.text((MARGIN, MARGIN), f"EDIT: {s.param_name}", fill=255, font=font)
|
||||
|
||||
# Parameter value bar
|
||||
val = max(0.0, min(1.0, s.param_value))
|
||||
bar_x = MARGIN
|
||||
bar_y = 20
|
||||
bar_w = DISPLAY_W - 2 * MARGIN
|
||||
bar_h = 8
|
||||
|
||||
draw.rectangle((bar_x, bar_y, bar_x + bar_w, bar_y + bar_h), fill=0, outline=255)
|
||||
fill_w = int(bar_w * val)
|
||||
draw.rectangle((bar_x, bar_y, bar_x + fill_w, bar_y + bar_h), fill=255)
|
||||
|
||||
# Value text
|
||||
draw.text((MARGIN, bar_y + 12), f"{val:.2f}", fill=255, font=font)
|
||||
|
||||
def _render_settings(self, draw) -> None:
|
||||
"""Render settings mode."""
|
||||
s = self._state
|
||||
font = self._fonts.get("normal")
|
||||
draw.text((MARGIN, MARGIN), "SETTINGS", fill=255, font=font)
|
||||
draw.text((MARGIN, 20), s.param_name or "", fill=255, font=font)
|
||||
|
||||
def _splash(self) -> None:
|
||||
"""Show boot splash screen."""
|
||||
if not self._initialized or self._draw is None or self._image is None:
|
||||
return
|
||||
draw = self._draw
|
||||
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0)
|
||||
draw.text((24, 24), "Pi Multi-FX", fill=255, font=self._fonts.get("large"))
|
||||
draw.text((28, 44), "Booting...", fill=255, font=self._fonts.get("normal"))
|
||||
self._display.image(self._image)
|
||||
self._display.show()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the display."""
|
||||
if self._initialized and self._display is not None:
|
||||
self._display.fill(0)
|
||||
self._display.show()
|
||||
@@ -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
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
"""RGB LED controller for the Pi Multi-FX Pedal.
|
||||
|
||||
Controls WS2812B (NeoPixel) or APA102 (DotStar) LEDs for:
|
||||
- Per-footswitch status LEDs
|
||||
- Bypass indicator (red/green)
|
||||
- Preset navigation animations
|
||||
- Tap tempo flashing
|
||||
- Configurable brightness
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LEDDriver(Enum):
|
||||
"""Which LED driver to use."""
|
||||
NEOPIXEL = "neopixel" # WS2812B — adafruit-circuitpython-neopixel
|
||||
DOTSTAR = "dotstar" # APA102 — adafruit-circuitpython-dotstar
|
||||
MOCK = "mock" # No hardware — log only
|
||||
|
||||
|
||||
class LEDPattern(Enum):
|
||||
"""Built-in LED animation patterns."""
|
||||
SOLID = "solid"
|
||||
PULSE = "pulse" # Gentle breathe
|
||||
BLINK = "blink" # On/off square wave
|
||||
TAP_TEMPO = "tap_tempo" # Flash at detected BPM
|
||||
SCAN = "scan" # Chase across strip
|
||||
PRESET_UP = "preset_up" # Sweep up
|
||||
PRESET_DOWN = "preset_down" # Sweep down
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDConfig:
|
||||
"""Per-LED configuration."""
|
||||
index: int
|
||||
default_color: tuple[int, int, int] = (0, 0, 0) # RGB
|
||||
default_brightness: float = 0.5
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LEDAnimation:
|
||||
"""Active animation state."""
|
||||
pattern: LEDPattern
|
||||
color: tuple[int, int, int]
|
||||
speed_ms: int = 500 # Cycle time in ms
|
||||
brightness: float = 0.5
|
||||
start_time: float = 0.0
|
||||
repeats: int = 0 # 0 = infinite
|
||||
|
||||
|
||||
def _import_driver(driver: LEDDriver):
|
||||
"""Import the correct LED driver library."""
|
||||
if driver == LEDDriver.NEOPIXEL:
|
||||
try:
|
||||
import board
|
||||
import neopixel
|
||||
return neopixel
|
||||
except (ImportError, NotImplementedError):
|
||||
logger.warning("NeoPixel not available — falling back to mock")
|
||||
return None
|
||||
elif driver == LEDDriver.DOTSTAR:
|
||||
try:
|
||||
import board
|
||||
import adafruit_dotstar as dotstar
|
||||
return dotstar
|
||||
except (ImportError, NotImplementedError):
|
||||
logger.warning("DotStar not available — falling back to mock")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class LEDController:
|
||||
"""RGB LED controller with animation support.
|
||||
|
||||
Handles per-LED animations for preset navigation, bypass status,
|
||||
tap tempo, and tuner mode.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_leds: int,
|
||||
driver: LEDDriver = LEDDriver.NEOPIXEL,
|
||||
pin: str = "D18",
|
||||
brightness: float = 0.5,
|
||||
led_configs: Optional[list[LEDConfig]] = None,
|
||||
):
|
||||
self._num_leds = num_leds
|
||||
self._driver_type = driver
|
||||
self._pin = pin
|
||||
self._global_brightness = brightness
|
||||
self._led_configs = led_configs or [
|
||||
LEDConfig(i, default_brightness=brightness) for i in range(num_leds)
|
||||
]
|
||||
|
||||
# Physical LED strip handle
|
||||
self._strip = None
|
||||
self._initialized = False
|
||||
|
||||
# Current pixel colors (RGB)
|
||||
self._pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * num_leds
|
||||
|
||||
# Active animations per LED
|
||||
self._animations: dict[int, LEDAnimation] = {}
|
||||
|
||||
# Callbacks
|
||||
self._animation_callbacks: list[Callable] = []
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize the LED strip hardware.
|
||||
|
||||
Returns False in dev mode (no hardware).
|
||||
"""
|
||||
try:
|
||||
driver_mod = _import_driver(self._driver_type)
|
||||
if driver_mod is None or self._driver_type == LEDDriver.MOCK:
|
||||
self._initialized = False
|
||||
logger.info("LED driver in MOCK mode — no hardware")
|
||||
return False
|
||||
|
||||
if self._driver_type == LEDDriver.NEOPIXEL:
|
||||
import board
|
||||
self._strip = driver_mod.NeoPixel(
|
||||
getattr(board, self._pin),
|
||||
self._num_leds,
|
||||
brightness=self._global_brightness,
|
||||
auto_write=False,
|
||||
)
|
||||
elif self._driver_type == LEDDriver.DOTSTAR:
|
||||
import board
|
||||
self._strip = driver_mod.DotStar(
|
||||
board.SCK, board.MOSI,
|
||||
self._num_leds,
|
||||
brightness=self._global_brightness,
|
||||
auto_write=False,
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
"LED strip initialized: %d LEDs, %s @ %s",
|
||||
self._num_leds, self._driver_type.value, self._pin,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.info("No LED strip detected — running in mock mode: %s", e)
|
||||
self._initialized = False
|
||||
return False
|
||||
|
||||
# --- Pixel control ---
|
||||
|
||||
def set_pixel(
|
||||
self,
|
||||
index: int,
|
||||
color: tuple[int, int, int],
|
||||
brightness: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Set a single LED to a color (0-255 per channel)."""
|
||||
if not 0 <= index < self._num_leds:
|
||||
logger.warning("LED index %d out of range (0-%d)", index, self._num_leds - 1)
|
||||
return
|
||||
|
||||
bri = brightness if brightness is not None else self._global_brightness
|
||||
r, g, b = self._clamp_color(color)
|
||||
# Apply brightness scaling
|
||||
self._pixels[index] = (int(r * bri), int(g * bri), int(b * bri))
|
||||
self._write_pixel(index)
|
||||
|
||||
def set_all(
|
||||
self,
|
||||
color: tuple[int, int, int],
|
||||
brightness: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Set all LEDs to the same color."""
|
||||
bri = brightness if brightness is not None else self._global_brightness
|
||||
r, g, b = self._clamp_color(color)
|
||||
scaled = (int(r * bri), int(g * bri), int(b * bri))
|
||||
self._pixels = [scaled] * self._num_leds
|
||||
self._write_all()
|
||||
|
||||
def set_bypass_led(self, index: int, bypassed: bool) -> None:
|
||||
"""Set a bypass indicator LED.
|
||||
|
||||
Red = bypassed, Green = active.
|
||||
"""
|
||||
if bypassed:
|
||||
self.set_pixel(index, (255, 0, 0))
|
||||
else:
|
||||
self.set_pixel(index, (0, 255, 0))
|
||||
|
||||
def preset_animate(self, direction: str = "up") -> None:
|
||||
"""Animate preset change (sweep).
|
||||
|
||||
direction: "up" → sweep left-to-right, "down" → right-to-left.
|
||||
"""
|
||||
self._animate_scan(
|
||||
color=(0, 64, 255), # Blue
|
||||
reverse=(direction == "down"),
|
||||
speed_ms=120,
|
||||
)
|
||||
|
||||
def tap_tempo_blip(self) -> None:
|
||||
"""Quick white flash indicating a tap tempo hit."""
|
||||
old = self._pixels[:]
|
||||
# Flash all LEDs white briefly
|
||||
self.set_all((255, 255, 255), brightness=0.8)
|
||||
time.sleep(0.03)
|
||||
# Restore — but don't block, schedule async
|
||||
for i in range(self._num_leds):
|
||||
self._pixels[i] = old[i]
|
||||
self._write_all()
|
||||
|
||||
def tap_tempo_animate(self, bpm: float) -> None:
|
||||
"""Start a tempo-synchronized flash on all LEDs.
|
||||
|
||||
Animates in sync with the detected BPM.
|
||||
The caller still drives the actual flash via tap_tempo_blip;
|
||||
this sets a subtle pulse background at the tempo.
|
||||
"""
|
||||
period_ms = int(60000 / max(bpm, 20)) # ms per beat
|
||||
self._animate_all(
|
||||
pattern=LEDPattern.TAP_TEMPO,
|
||||
color=(255, 255, 255),
|
||||
speed_ms=period_ms,
|
||||
brightness=0.15, # Subtle
|
||||
)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Turn off all LEDs."""
|
||||
self._pixels = [(0, 0, 0)] * self._num_leds
|
||||
self._animations.clear()
|
||||
self._write_all()
|
||||
|
||||
def set_brightness(self, brightness: float) -> None:
|
||||
"""Set global brightness (0.0 - 1.0)."""
|
||||
self._global_brightness = max(0.0, min(1.0, brightness))
|
||||
if self._strip is not None:
|
||||
self._strip.brightness = self._global_brightness
|
||||
logger.info("Global brightness set to %.2f", self._global_brightness)
|
||||
|
||||
# --- Animation engine ---
|
||||
|
||||
def _animate_all(
|
||||
self,
|
||||
pattern: LEDPattern,
|
||||
color: tuple[int, int, int],
|
||||
speed_ms: int,
|
||||
brightness: float,
|
||||
) -> None:
|
||||
"""Start an animation on all LEDs."""
|
||||
return None # Full animation tick runs on _animation_tick
|
||||
|
||||
def _animate_scan(
|
||||
self,
|
||||
color: tuple[int, int, int],
|
||||
reverse: bool = False,
|
||||
speed_ms: int = 120,
|
||||
) -> None:
|
||||
"""Run a scan animation in a separate thread (non-blocking)."""
|
||||
import threading
|
||||
|
||||
def _scan():
|
||||
r, g, b = color
|
||||
indices = range(self._num_leds)
|
||||
if reverse:
|
||||
indices = reversed(indices)
|
||||
for i in indices:
|
||||
self.set_all((0, 0, 0))
|
||||
self.set_pixel(i, (r, g, b))
|
||||
self._write_all()
|
||||
time.sleep(speed_ms / 1000)
|
||||
self.set_all((0, 0, 0))
|
||||
self._write_all()
|
||||
|
||||
t = threading.Thread(target=_scan, daemon=True, name="led-scan")
|
||||
t.start()
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
def _clamp_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||
"""Clamp 0-255 per channel."""
|
||||
return (
|
||||
max(0, min(255, color[0])),
|
||||
max(0, min(255, color[1])),
|
||||
max(0, min(255, color[2])),
|
||||
)
|
||||
|
||||
def _write_pixel(self, index: int) -> None:
|
||||
"""Write a single pixel to the hardware strip."""
|
||||
if self._strip is not None and self._initialized:
|
||||
self._strip[index] = self._pixels[index]
|
||||
self._strip.show()
|
||||
else:
|
||||
logger.debug("LED[%d] → RGB(%d,%d,%d)", index, *self._pixels[index])
|
||||
|
||||
def _write_all(self) -> None:
|
||||
"""Write all pixels to the hardware strip."""
|
||||
if self._strip is not None and self._initialized:
|
||||
for i in range(self._num_leds):
|
||||
self._strip[i] = self._pixels[i]
|
||||
self._strip.show()
|
||||
else:
|
||||
for i, c in enumerate(self._pixels):
|
||||
if any(v > 0 for v in c):
|
||||
logger.debug("LED[%d] → RGB(%d,%d,%d)", i, *c)
|
||||
|
||||
def __enter__(self):
|
||||
self.initialize()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.clear_all()
|
||||
Reference in New Issue
Block a user