From 8c68f3c2f03bf5eeee9c888a73c47fdea758a2f0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Apr 2026 15:18:12 -0400 Subject: [PATCH 1/2] Add external MIDI device synchronization Introduces ExternalMidiManager and ExternalMidiOut for sending MIDI messages to external devices (e.g. Source Audio C4, HX Stomp) on pedalboard load. Config is per-device with glob-based port auto-detection and per-pedalboard message override via config.yml. Co-Authored-By: Claude Sonnet 4.6 --- modalapi/external_midi.py | 362 ++++++++++++++++++ modalapi/mod.py | 24 +- modalapi/modhandler.py | 28 +- pistomp/hardware.py | 16 + .../default_config_pistomptre.yml | 43 +++ 5 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 modalapi/external_midi.py diff --git a/modalapi/external_midi.py b/modalapi/external_midi.py new file mode 100644 index 00000000..720536e9 --- /dev/null +++ b/modalapi/external_midi.py @@ -0,0 +1,362 @@ +# 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 . + +from __future__ import annotations + +import fnmatch +import logging +import time +from typing import TypedDict + +import rtmidi +from rtmidi import MidiOut as RtMidiOut + +MidiMessage = list[int] + +# Constant for identifying external parameters +EXTERNAL_INSTANCE_ID = "External" + + +class ExternalMidiOut: + """ + Wrapper around external MIDI port that implements the same interface as + the virtual port's midiout. Allows controls to send MIDI to external devices + transparently, with automatic fallback to virtual port if device unavailable. + """ + + def __init__( + self, external_midi_manager: ExternalMidiManager, port_name: str, midi_channel: int, fallback_midiout: RtMidiOut + ): + """ + Initialize external MIDI output wrapper. + + Args: + external_midi_manager: Reference to ExternalMidiManager. + port_name: Name of external MIDI port from config. + midi_channel: MIDI channel (0-15). + fallback_midiout: Virtual port midiout to use if external unavailable. + """ + self.external_midi = external_midi_manager + self.port_name = port_name + self.channel = midi_channel + self.fallback = fallback_midiout + + def send_message(self, message: MidiMessage) -> None: + """ + Send MIDI message to external port, falling back to virtual port if unavailable. + + Args: + message: MIDI message as [status_byte, data1, data2, ...] + """ + if len(message) < 3: + # Not a CC message we can route, just use fallback + self.fallback.send_message(message) + return + + # Extract CC and value from message + # Status byte format: 0xBn where n is channel (already included) + cc = message[1] + value = message[2] + + # Try to send to external port + success = self.external_midi.send_cc(self.port_name, self.channel, cc, value) + + if not success: + # External port unavailable, fallback to virtual port + logging.debug(f"External port {self.port_name} unavailable, using virtual port") + self.fallback.send_message(message) + + +class PortConfig(TypedDict, total=False): + auto_detect: list[str] + port_index: int + + +class ExternalMidiConfig(TypedDict, total=False): + enabled: bool + send_delay_ms: int + ports: dict[str, PortConfig] # configured devices + messages: dict[str, list[MidiMessage]] # port_name -> list of MIDI messages + + +class ExternalMidiManager: + """ + Manages external MIDI device synchronization. + Sends MIDI messages to external devices when pedalboards are loaded. + """ + + def __init__(self): + self.midi_ports: dict[str, rtmidi.MidiOut] = {} + self.port_configs: dict[str, PortConfig] = {} + self.messages: dict[str, list[MidiMessage]] = {} + self.enabled: bool = False + self.send_delay_ms: int = 10 + + def update_config(self, cfg: ExternalMidiConfig | None) -> None: + """ + Update configuration incrementally (can be called multiple times). + Only updates fields that are present. + """ + if cfg is None: + return + + if "enabled" in cfg: + self.enabled = cfg["enabled"] + if self.enabled: + logging.debug("External MIDI enabled") + else: + logging.debug("External MIDI disabled") + + if "send_delay_ms" in cfg: + self.send_delay_ms = cfg["send_delay_ms"] + + if "ports" in cfg: + # Merge ports (port-level granularity) + self.port_configs.update(cfg["ports"]) + + if "messages" in cfg: + # Merge messages at port level + # This allows pedalboard config to override specific ports while keeping others default + self.messages.update(cfg["messages"]) + + def _get_available_ports(self) -> list[str]: + try: + temp_out = rtmidi.MidiOut() + ports = temp_out.get_ports() + del temp_out + return ports + except Exception as e: + logging.error(f"Failed to enumerate MIDI ports: {e}") + return [] + + def _find_port_by_name(self, port_config: PortConfig) -> int | None: + """ + Find MIDI port index matching a given config, returning its index if found. + """ + if "port_index" in port_config: + return port_config["port_index"] + + # Auto-detect by name patterns + auto_detect = port_config.get("auto_detect", []) + if not auto_detect: + return None + + available_ports = self._get_available_ports() + if not available_ports: + return None + + # Search for matching ports using glob patterns + matched_ports = [] + for pattern in auto_detect: + for idx, port_name in enumerate(available_ports): + # Case-insensitive glob matching + if fnmatch.fnmatch(port_name.lower(), pattern.lower()): + matched_ports.append((idx, port_name)) + + if not matched_ports: + logging.warning(f"No MIDI ports matched patterns: {auto_detect}") + return None + + # Warn if multiple matches + if len(matched_ports) > 1: + port_names = [name for _, name in matched_ports] + logging.warning( + f"Multiple MIDI ports matched {auto_detect}: {port_names}. Using first match: {matched_ports[0][1]}" + ) + + selected_idx, selected_name = matched_ports[0] + logging.info(f"Auto-detected MIDI port: {selected_name} (index {selected_idx})") + return selected_idx + + def _init_port(self, port_name: str) -> rtmidi.MidiOut | None: + if port_name in self.midi_ports: + return self.midi_ports[port_name] + + port_config = self.port_configs.get(port_name) + if not port_config: + logging.warning(f"No configuration found for MIDI port: {port_name}") + return None + + port_idx = self._find_port_by_name(port_config) + if port_idx is None: + logging.warning(f"Could not find MIDI port for: {port_name}") + return None + + try: + midi_out = rtmidi.MidiOut() + midi_out.open_port(port_idx) + self.midi_ports[port_name] = midi_out + logging.info(f"Opened MIDI port: {port_name}") + return midi_out + except Exception as e: + logging.error(f"Failed to open MIDI port {port_name} (index {port_idx}): {e}") + self.midi_ports[port_name] = None + return None + + def _invalidate_port(self, port_name: str) -> None: + """ + Invalidate a port that has failed, closing it and removing from cache. + This forces re-discovery/re-opening on next use. + """ + if port_name in self.midi_ports: + midi_out = self.midi_ports[port_name] + try: + midi_out.close_port() + except Exception as e: + logging.debug(f"Error closing invalidated port {port_name}: {e}") + del self.midi_ports[port_name] + + def _validate_midi_message(self, message: MidiMessage) -> bool: + if not isinstance(message, list) or len(message) < 2: + logging.warning(f"Invalid MIDI message format (must be list with 2+ bytes): {message}") + return False + + status = message[0] + if not (0x80 <= status <= 0xFF): + logging.warning(f"Invalid MIDI status byte (must be 0x80-0xFF): 0x{status:02X}") + return False + + for i, byte in enumerate(message[1:], start=1): + if not (0x00 <= byte <= 0x7F): + logging.warning(f"Invalid MIDI data byte at position {i} (must be 0x00-0x7F): 0x{byte:02X}") + return False + + return True + + def _send_messages(self, port_name: str, messages: list[MidiMessage], delay_ms: int = 10): + """ + Send MIDI messages to a port. + + Args: + port_name: Name of port configuration. + messages: List of MIDI messages to send. + delay_ms: Delay between messages in milliseconds. + """ + midi_out = self._init_port(port_name) + if midi_out is None: + logging.warning(f"Skipping messages for unavailable port: {port_name}") + return + + for i, message in enumerate(messages): + if not self._validate_midi_message(message): + logging.warning(f"Skipping invalid MIDI message {i + 1}/{len(messages)}: {message}") + continue + + try: + midi_out.send_message(message) + logging.debug(f"Sent MIDI message to {port_name}: {[f'0x{b:02X}' for b in message]}") + + # Delay between messages (except after last one) + if i < len(messages) - 1 and delay_ms > 0: + time.sleep(delay_ms / 1000.0) + + except Exception as e: + logging.error(f"Failed to send MIDI message to {port_name}: {e}") + self._invalidate_port(port_name) + break # Stop sending remaining messages to this broken port + + def send_cc(self, port_name: str, channel: int, cc: int, value: int) -> bool: + """ + Send a single MIDI CC message to an external port. + Used by controls (footswitches, encoders, expression pedals) when + configured to route to external MIDI devices. + + Args: + port_name: Name of port configuration from config file. + channel: MIDI channel (0-15). + cc: MIDI CC number (0-127). + value: MIDI CC value (0-127). + + Returns: + True if message was sent successfully, False if port unavailable (caller should fallback). + + Raises: + RuntimeError: If port_name not in config (config validation failed at startup). + """ + if not self.enabled: + logging.debug(f"External MIDI disabled, falling back to virtual port") + return False + + # Port name must exist in config (should have been validated at startup) + if port_name not in self.port_configs: + raise RuntimeError( + f"Port '{port_name}' not found in external_midi config. " + f"Available ports: {list(self.port_configs.keys())}. " + f"This should have been caught during config validation." + ) + + # Validate MIDI values (programming errors) + if not (0 <= channel <= 15): + raise ValueError(f"Invalid MIDI channel {channel} (must be 0-15)") + if not (0 <= cc <= 127): + raise ValueError(f"Invalid MIDI CC {cc} (must be 0-127)") + if not (0 <= value <= 127): + raise ValueError(f"Invalid MIDI value {value} (must be 0-127)") + + # Lazy initialization of port (may fail if device not connected) + midi_out = self._init_port(port_name) + if midi_out is None: + # Port not available - caller should fallback to virtual port + logging.debug(f"Port {port_name} not available, caller will fallback to virtual port") + return False + + # Construct and send MIDI message + # Status byte: 0xB0 (CC) | channel + status = 0xB0 | channel + message = [status, cc, value] + + try: + midi_out.send_message(message) + logging.debug(f"Sent CC to {port_name}: channel={channel} cc={cc} value={value}") + return True + except Exception as e: + logging.error(f"Failed to send CC to {port_name}: {e}") + self._invalidate_port(port_name) + return False + + def send_messages_for_pedalboard(self) -> bool: + """ + Send external MIDI messages for current pedalboard configuration. + Configuration should have been set via update_config() before calling this. + + Returns: + True if messages were sent successfully, False otherwise. + """ + if not self.enabled: + return False + + if not self.messages: + return False + + for port_name, messages in self.messages.items(): + if not messages: + continue + + logging.debug(f"Sending MIDI message(s) to {port_name}: {messages}") + self._send_messages(port_name, messages, self.send_delay_ms) + + return True + + def close(self): + """Close ports and clean up.""" + for port_name, midi_out in self.midi_ports.items(): + try: + midi_out.close_port() + logging.debug(f"Closed MIDI port: {port_name}") + except Exception as e: + logging.warning(f"Error closing MIDI port {port_name}: {e}") + + self.midi_ports.clear() + logging.info("External MIDI manager closed") diff --git a/modalapi/mod.py b/modalapi/mod.py index bf7bedf4..8dad5819 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -27,6 +27,7 @@ import modalapi.pedalboard as Pedalboard import common.parameter as Parameter import modalapi.wifi as Wifi +import modalapi.external_midi as ExternalMidi from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.footswitch import Footswitch @@ -136,12 +137,20 @@ def __init__(self, audiocard, homedir): self.current_menu = MenuType.MENU_NONE # This file is modified when the pedalboard is changed via MOD UI - self.pedalboard_modification_file = "/home/pistomp/data/last.json" + self.data_dir = "/home/pistomp/data" + self.pedalboard_modification_file = os.path.join(self.data_dir, "last.json") self.pedalboard_change_timestamp = os.path.getmtime(self.pedalboard_modification_file)\ if Path(self.pedalboard_modification_file).exists() else 0 self.wifi_manager = Wifi.WifiManager() + # External MIDI device synchronization + self.external_midi = None + try: + self.external_midi = ExternalMidi.ExternalMidiManager() + except Exception as e: + logging.warning(f"Failed to initialize external MIDI manager: {e}") + # Callback function map. Key is the user specified name, value is function from this handler # Used for calling handler callbacks pointed to by names which may be user set in the config file self.callbacks = {"set_mod_tap_tempo": self.set_mod_tap_tempo, @@ -153,10 +162,14 @@ def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager + if self.external_midi is not None: + self.external_midi.close() def cleanup(self): if self.lcd is not None: self.lcd.cleanup() + if self.external_midi is not None: + self.external_midi.close() # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets @@ -183,6 +196,7 @@ def __init__(self, plugin): def add_hardware(self, hardware): self.hardware = hardware + hardware.external_midi = self.external_midi def add_lcd(self, lcd): self.lcd = lcd @@ -537,6 +551,14 @@ def set_current_pedalboard(self, pedalboard): self.load_current_presets() self.update_lcd() + # Send external MIDI messages for this pedalboard + # Config was already updated by hardware.reinit(cfg) above + if self.external_midi is not None: + try: + self.external_midi.send_messages_for_pedalboard() + except Exception as e: + logging.warning(f"Failed to send external MIDI messages: {e}") + # Selection info self.selectable_items.clear() self.selectable_items.append((SelectedType.PEDALBOARD, None)) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 2eb0429c..a662fde3 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -27,6 +27,8 @@ import common.util as util import modalapi.pedalboard as Pedalboard import modalapi.wifi as Wifi +import modalapi.external_midi as ExternalMidi +from modalapi.external_midi import EXTERNAL_INSTANCE_ID import pistomp.settings as Settings from pistomp.analogmidicontrol import AnalogMidiControl @@ -97,18 +99,31 @@ def __init__(self, audiocard, homedir): "next_snapshot": self.preset_incr_and_change, "previous_snapshot": self.preset_decr_and_change, "toggle_bypass": self.system_toggle_bypass, - "toggle_tap_tempo_enable": self.toggle_tap_tempo_enable + "toggle_tap_tempo_enable": self.toggle_tap_tempo_enable, + "universal_encoder_sw": self.universal_encoder_sw } + # External MIDI device synchronization + self.external_midi = None + try: + self.external_midi = ExternalMidi.ExternalMidiManager() + except Exception as e: + logging.warning(f"Failed to initialize external MIDI manager: {e}") + def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager + if self.external_midi is not None: + self.external_midi.close() + def cleanup(self): if self.lcd is not None: self.lcd.cleanup() if self.hardware is not None: self.hardware.cleanup() + if self.external_midi is not None: + self.external_midi.close() # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets @@ -123,6 +138,7 @@ def __init__(self, pedalboard): def add_hardware(self, hardware): self.hardware = hardware + hardware.external_midi = self.external_midi def add_lcd(self, lcd): self.lcd = lcd @@ -229,7 +245,7 @@ def poll_modui_changes(self): self.pedalboard_change_timestamp = ts self.lcd.draw_info_message("Loading...") mod_bundle = self.get_pedalboard_bundle_from_mod() - if mod_bundle: + if mod_bundle and self.current: logging.info("Pedalboard changed via MOD from: %s to: %s" % (self.current.pedalboard.bundle, mod_bundle)) pb = self.reload_pedalboard(mod_bundle) @@ -354,6 +370,14 @@ def set_current_pedalboard(self, pedalboard): self.lcd.link_data(self.pedalboard_list, self.current, self.hardware.footswitches) self.lcd.draw_main_panel() + # Send external MIDI messages for this pedalboard + # Config was already updated by hardware.reinit(cfg) above + if self.external_midi is not None: + try: + self.external_midi.send_messages_for_pedalboard() + except Exception as e: + logging.warning(f"Failed to send external MIDI messages: {e}") + def bind_current_pedalboard(self): # "current" being the pedalboard mod-host says is current # The pedalboard data has already been loaded, but this will overlay diff --git a/pistomp/hardware.py b/pistomp/hardware.py index 4fb2e527..ed3dc6df 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -26,6 +26,7 @@ import pistomp.taptempo as taptempo from abc import abstractmethod +from modalapi.external_midi import ExternalMidiManager class Hardware: @@ -56,6 +57,7 @@ def __init__(self, default_config, handler, midiout, refresh_callback): self.debounce_map = None self.ledstrip = None self.taptempo = taptempo.TapTempo(None) + self.external_midi: ExternalMidiManager | None = None def toggle_tap_tempo_enable(self, bpm=0): if self.taptempo: @@ -113,6 +115,9 @@ def reinit(self, cfg): # Footswitch configuration self.__init_footswitches(self.cfg) + # External MIDI configuration + self.__init_external_midi(self.cfg) + # Analog control configuration for ac in self.analog_controls: try: @@ -124,6 +129,7 @@ def reinit(self, cfg): if cfg is not None: self.__init_midi(cfg) self.__init_footswitches(cfg) + self.__init_external_midi(cfg) @abstractmethod def init_analog_controls(self): @@ -320,6 +326,16 @@ def __init_midi(self, cfg): if isinstance(ac, AnalogMidiControl.AnalogMidiControl): ac.set_midi_channel(self.midi_channel) + def __init_external_midi(self, cfg): + """Initialize/update external MIDI config (called for both default and pedalboard config).""" + if self.external_midi is None: + return + if cfg is None or Token.HARDWARE not in cfg: + return + ext_cfg = cfg[Token.HARDWARE].get("external_midi") + if ext_cfg: + self.external_midi.update_config(ext_cfg) + def __init_footswitches_default(self): for fs in self.footswitches: fs.clear_relays() diff --git a/setup/config_templates/default_config_pistomptre.yml b/setup/config_templates/default_config_pistomptre.yml index e0e836ef..c2be313a 100644 --- a/setup/config_templates/default_config_pistomptre.yml +++ b/setup/config_templates/default_config_pistomptre.yml @@ -81,3 +81,46 @@ hardware: - id: 3 type: VOLUME + # external_midi: + # External MIDI device synchronization - sends messages when pedalboards load + # Can be overridden per-pedalboard by creating .pedalboard/config.yml + # + # enabled: Enable/disable external MIDI (default: false) + # send_delay_ms: Delay between consecutive messages in milliseconds (default: 10) + # + # ports: Define external MIDI devices + # : + # auto_detect: [] List of glob patterns to match port names (case-insensitive) + # port_index: Manual port index (overrides auto_detect) + # + # messages: MIDI messages to send for each port on pedalboard load + # : + # - [0xSS, 0xDD, ...] List of MIDI messages (hex bytes) + # + # Example configuration: + # + # external_midi: + # enabled: true + # send_delay_ms: 10 + # ports: + # c4: + # auto_detect: ["*C4*", "*Source Audio*"] + # hx_stomp: + # auto_detect: ["*HX Stomp*"] + # messages: + # c4: + # - [0xB0, 0x66, 0x00] # CC 102 = 0 (bypass) + # hx_stomp: + # - [0xC0, 0x00] # Program Change 0 + # + # MIDI message formats: + # Program Change: [0xCn, program] where n = channel (0-F) + # Control Change: [0xBn, cc, value] where n = channel (0-F) + # + # Per-pedalboard override example (.pedalboard/config.yml): + # hardware: + # external_midi: + # messages: + # c4: + # - [0xC0, 0x05] # Program Change 5 for this pedalboard only + From c18fea3315d56fc84e329c9921bae98774e36810 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 10 May 2026 16:08:34 -0400 Subject: [PATCH 2/2] Remove unnecessary universal_encoder_sw --- GUIDE.md | 2 +- modalapi/modhandler.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 4ea52a3b..6d6f22b2 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -110,7 +110,7 @@ encoders: - id: 1 midi_CC: 70 # Rotation longpress: previous_snapshot - shortpress: universal_encoder_sw # Default if omitted + # shortpress omitted — defaults to the built-in encoder click handler - id: 2 midi_CC: 71 diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 3ea753dd..715c8c40 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -100,8 +100,7 @@ def __init__(self, audiocard, homedir): "next_snapshot": self.preset_incr_and_change, "previous_snapshot": self.preset_decr_and_change, "toggle_bypass": self.system_toggle_bypass, - "toggle_tap_tempo_enable": self.toggle_tap_tempo_enable, - "universal_encoder_sw": self.universal_encoder_sw + "toggle_tap_tempo_enable": self.toggle_tap_tempo_enable } # External MIDI device synchronization