Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user