Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2af7fcd
Merge branch 'fix/pillow-compat' into feat/v3-emulator
sastraxi Apr 22, 2026
bf8dd4b
Broken initial implementation
sastraxi Apr 22, 2026
5d09a1e
Add fonts
sastraxi Apr 22, 2026
583e03e
Mostly working emulator
sastraxi Apr 22, 2026
d921d38
Nice!
sastraxi Apr 22, 2026
5658b79
Remove branch for non-lilv
sastraxi Apr 22, 2026
ab1ca73
Bring back comments
sastraxi Apr 22, 2026
44b89c1
Type fixes and adjustments
sastraxi Apr 22, 2026
10c3ad3
Generic base + v2 emulator
sastraxi Apr 22, 2026
9ef458a
Wire up and get v1 working
sastraxi Apr 22, 2026
9775e70
Better v1 emulator labels
sastraxi Apr 22, 2026
d5b61ef
Move fonts into fonts/
sastraxi Apr 23, 2026
43c323c
Merge branch 'feat/v3-emulator' into feat/v1v2-emulator
sastraxi Apr 23, 2026
6072c8e
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi Apr 24, 2026
9772fa9
Emulate LCD speed correctly
sastraxi Apr 24, 2026
e6a0230
Backport emulator LCD sleep fix from feat/new-tuner
sastraxi Apr 25, 2026
1e55f22
chore: migrate dev deps to dependency-groups
sastraxi May 3, 2026
55b8e9f
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 3, 2026
59bc49f
Accidentally inverted
sastraxi May 3, 2026
6788bb2
fix: type Modhandler.audiocard as Audiocard; VirtualAudiocard inherit…
sastraxi May 4, 2026
3c6f821
fix: no-op set_output_muted on VirtualAudiocard
sastraxi May 4, 2026
a97d363
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 5, 2026
480fb87
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 5, 2026
d5cb29e
More responsive emulator
sastraxi May 6, 2026
f26598f
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 9, 2026
13a6db6
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 9, 2026
bf23a9a
Merge branch 'chore/pyright-no-black' into feat/v1v2-emulator
sastraxi May 10, 2026
0abe79e
Move emulator stuff out of modalapistomp
sastraxi May 11, 2026
d4281aa
Fix has_system_splash
sastraxi May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ curl -s http://localhost:80/pedalboard/list | python3 -m json.tool

## Hardware Versions

- **v1/v2**: Uses `modalapi/mod.py`
- **v3**: Uses `modalapi/modhandler.py` (current device)
- **v1**: Uses `modalapi/mod.py` (legacy handler)
- **v2/v3**: Uses `modalapi/modhandler.py` (current device)

## Python Environment

Expand Down Expand Up @@ -228,6 +228,11 @@ Shortpress accepts string (callback name) or object with `callback` and `args` (

### Hardware Version Selection

**Version float** comes from `hardware.version` in the active YAML config, selected from templates in `setup/config_templates/`:
- `default_config_pistomp.yml` → `1.0`
- `default_config_pistompcore.yml` → `2.0`
- `default_config_pistomptre.yml` → `3.0`

**Factory Pattern** routes version-specific implementations:

```python
Expand All @@ -247,6 +252,8 @@ Shortpress accepts string (callback name) or object with `callback` and `args` (
- SPI/ADC communication
- Controller dictionary: `{channel:CC}` → controller object

**LCD wiring**: each hardware subclass creates the LCD in `init_lcd()` and injects it into the handler via `handler.add_lcd(Lcd(...))`. The LCD is owned by the handler (`handler._lcd`), not the hardware. For v2/v3, `lcd320x240.Lcd` receives a back-reference to the handler for UI action callbacks (pedalboard/preset change, plugin bypass, parameter edits, system menu, etc.).

### Configuration System

**Two-Layer Config Overlay**:
Expand Down
17 changes: 17 additions & 0 deletions emulator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import os
from pathlib import Path
from PIL import ImageFont

_FONTS_DIR = Path(__file__).parent.parent / "fonts"
_orig_truetype = ImageFont.truetype


def _resolve_truetype(font=None, size=10, **kwargs):
if isinstance(font, str) and not Path(font).is_absolute() and not Path(font).exists():
candidate = _FONTS_DIR / font
if candidate.exists():
font = str(candidate)
return _orig_truetype(font, size, **kwargs)


ImageFont.truetype = _resolve_truetype
86 changes: 86 additions & 0 deletions emulator/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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 <https://www.gnu.org/licenses/>.

import logging
import os
from typing import Literal

from rtmidi.midiutil import open_midioutput

import pistomp.config as config
import pistomp.settings as Settings_module

EmulatorVersion = Literal["emulator_v1", "emulator_v2", "emulator_v3"]

_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
_CONFIG_TEMPLATES = {
"emulator_v1": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistomp.yml"),
"emulator_v2": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistompcore.yml"),
"emulator_v3": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistomptre.yml"),
}


def bootstrap_emulator(version: EmulatorVersion, cwd: str):
"""Initialize pygame, build the emulator handler/hardware, and return (handler, midiout)."""
import pygame
import pygame._freetype as _freetype
from emulator.window import EmulatorWindow

match version:
case "emulator_v1":
from emulator.hardware_v1 import EmulatorHardwareV1 as EmuHW
from emulator.mod import EmulatorMod as EmuHandler
case "emulator_v2":
from emulator.hardware_v2 import EmulatorHardwareV2 as EmuHW
from emulator.modhandler import EmulatorModhandler as EmuHandler
case "emulator_v3":
from emulator.hardware_v3 import EmulatorHardwareV3 as EmuHW
from emulator.modhandler import EmulatorModhandler as EmuHandler

pygame.init()
_freetype.init()

try:
midiout, _port_name = open_midioutput(0)
except Exception:
logging.warning("MIDI output unavailable in emulator mode - continuing without MIDI")
midiout = None

cfg = config.load_cfg_from_file(_CONFIG_TEMPLATES[version])

if version != "emulator_v1":
emu_cfg_dir = os.path.join(os.path.expanduser("~"), ".pistomp_emulator", "config")
os.makedirs(emu_cfg_dir, exist_ok=True)
Settings_module.DATA_DIR = emu_cfg_dir

handler = EmuHandler(cwd)
hw = EmuHW(cfg, handler, midiout, refresh_callback=handler.update_lcd_fs)
handler.add_hardware(hw)

window = EmulatorWindow(hw)
handler.set_window(window)

handler.load_banks()
handler.load_pedalboards()

current_bundle = handler.get_current_pedalboard_bundle_path()
if current_bundle and current_bundle in handler.pedalboards:
handler.set_current_pedalboard(handler.pedalboards[current_bundle])
else:
handler.pedalboard_change()

handler.system_info_load()

return handler, midiout
144 changes: 144 additions & 0 deletions emulator/controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# 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 <https://www.gnu.org/licenses/>.

"""Software-only stand-ins for the physical controls on pi-Stomp.

Each mock control subclasses the corresponding real hardware class so that
type checkers are satisfied, but bypasses any GPIO / SPI / ADC init.
"""

import pistomp.analogcontrol as analogcontrol
import pistomp.encoder as encoder
import pistomp.encodermidicontrol as encodermidicontrol
import pistomp.footswitch as footswitch

try:
from rtmidi.midiconstants import CONTROL_CHANGE
_rtmidi_available = True
except ImportError:
_rtmidi_available = False
CONTROL_CHANGE = 0xB0


class MockEncoder(encoder.Encoder):
"""Nav encoder (no GPIO). Driven externally via step() / press()."""

def __init__(self, callback, type=None, id=None):
super().__init__(d_pin=None, clk_pin=None, callback=callback, type=type, id=id)
self.press_callback = None
self.label: str | None = None

def read_rotary(self):
pass

def step(self, direction):
if direction != 0 and self.callback:
self.callback(direction)

def press(self, value):
if self.press_callback:
self.press_callback(value)


class MockEncoderMidi(encodermidicontrol.EncoderMidiControl):
"""Tweak encoder with MIDI CC. Driven externally via step() / press()."""

def __init__(self, handler, callback, midi_channel, midi_CC, midiout,
type=None, id=None, cfg=None):
super().__init__(handler=handler, d_pin=None, clk_pin=None, callback=callback,
midi_CC=midi_CC, midi_channel=midi_channel, midiout=midiout,
type=type, id=id)
self.cfg = cfg or {'type': type, 'id': id}
self.midi_value = 64
self.press_callback = None

def read_rotary(self):
pass

def set_value(self, value):
self.midi_value = int(value)

def step(self, direction):
self.midi_value = max(0, min(127, self.midi_value + direction))
if self.midiout and self.midi_CC is not None and _rtmidi_available:
self.midiout.send_message(
[CONTROL_CHANGE | (self.midi_channel & 0x0F),
self.midi_CC, self.midi_value])
if self.callback:
self.callback(direction)

def press(self, value):
if self.press_callback:
self.press_callback(value)


class MockFootswitch(footswitch.Footswitch):
"""Footswitch with no GPIO/ADC. Driven externally via press()."""

@classmethod
def check_longpress_events(cls):
pass

def __init__(self, id, midi_CC, midi_channel, midiout, refresh_callback):
# led_pin=None, pixel=None, gpio_input=None, adc_input=None — no GPIO paths taken
super().__init__(id, None, None, midi_CC, midi_channel, midiout, refresh_callback)
self.type = None
self.cfg = {}

def poll(self):
pass

def press(self):
self.enabled = not self.enabled
if self.midiout and self.midi_CC is not None and _rtmidi_available:
self.midiout.send_message(
[CONTROL_CHANGE | (self.midi_channel & 0x0F),
self.midi_CC, 127 if self.enabled else 0])
self.refresh_callback(footswitch=self)


class MockAnalogControl(analogcontrol.AnalogControl):
"""Expression pedal / knob with no SPI/ADC. Value set externally."""

def __init__(self, midi_CC, midi_channel, midiout, control_type=None,
id=None, cfg=None):
# AnalogControl.__init__ only stores spi/channel/tolerance — safe with None
super().__init__(spi=None, adc_channel=None, tolerance=0)
self.midi_CC = midi_CC
self.midi_channel = midi_channel
self.midiout = midiout
self.type = control_type
self.id = id
self.cfg = cfg or {'type': control_type, 'id': id}
self.value = 64
self.parameter = None

def refresh(self):
pass

def initialize(self):
pass

def set_midi_channel(self, ch):
self.midi_channel = ch

def set_value(self, value):
self.value = int(value)

def send_midi(self, value_0_127):
if self.midiout and self.midi_CC is not None and _rtmidi_available:
self.midiout.send_message(
[CONTROL_CHANGE | (self.midi_channel & 0x0F),
self.midi_CC, int(value_0_127)])
62 changes: 62 additions & 0 deletions emulator/gfxhat_adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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 <https://www.gnu.org/licenses/>.

"""Software stubs for the three gfxhat modules (lcd, backlight, touch).

GfxLcd renders to a LcdPygame backend via set_pixel/show.
GfxBacklight and GfxTouch are no-ops.
"""

from PIL import Image


class GfxLcd:
def __init__(self, lcd_pygame, width=128, height=64):
self._pygame = lcd_pygame
self._w = width
self._h = height
self._buf = Image.new('L', (width, height))

def dimensions(self):
return (self._w, self._h)

def set_pixel(self, x, y, val):
if 0 <= x < self._w and 0 <= y < self._h:
self._buf.putpixel((x, y), 255 if val else 0)

def show(self):
# Production code stores pixels with both axes inverted (hardware orientation).
# Rotate 180° to recover the correct image for pygame display.
img = self._buf.transpose(Image.ROTATE_180)
self._pygame.update(img.convert('RGB'))

def clear(self):
self._buf.paste(0, (0, 0, self._w, self._h))


class GfxBacklight:
def set_pixel(self, x, r, g, b):
pass

def set_all(self, r, g, b):
pass

def show(self):
pass


class GfxTouch:
def set_led(self, i, val):
pass
Loading