diff --git a/modalapi/external_midi.py b/modalapi/external_midi.py new file mode 100644 index 00000000..45d67a6a --- /dev/null +++ b/modalapi/external_midi.py @@ -0,0 +1,254 @@ +# 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 + + +MidiMessage = list[int] + + +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 | None] = {} + 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}") + self.midi_ports[port_name] = None + 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}") + self.midi_ports[port_name] = None + 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 _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 + + # Check status byte (must be 0x80-0xFF) + status = message[0] + if not (0x80 <= status <= 0xFF): + logging.warning( + f"Invalid MIDI status byte (must be 0x80-0xFF): 0x{status:02X}" + ) + return False + + # Check data bytes (must be 0x00-0x7F) + 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}") + + 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.join(', ')}") + 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(): + if midi_out is not None: + 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 8b3364c2..7b93998f 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -21,17 +21,20 @@ import sys import yaml +from enum import Enum +from rtmidi.midiconstants import CONTROL_CHANGE + import common.token as Token import common.util as util import pistomp.switchstate as switchstate import modalapi.pedalboard as Pedalboard import modalapi.parameter as Parameter import modalapi.wifi as Wifi +import modalapi.external_midi as ExternalMidi from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.footswitch import Footswitch from pistomp.handler import Handler -from enum import Enum from pathlib import Path #sys.path.append('/usr/lib/python3.5/site-packages') # TODO possibly /usr/local/modep/mod-ui @@ -136,12 +139,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 +164,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 +198,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 @@ -534,6 +550,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..14172f9a 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -23,17 +23,19 @@ import sys import yaml +from pathlib import Path +from rtmidi.midiconstants import CONTROL_CHANGE + import common.token as Token import common.util as util import modalapi.pedalboard as Pedalboard import modalapi.wifi as Wifi +import modalapi.external_midi as ExternalMidi import pistomp.settings as Settings from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.encodermidicontrol import EncoderMidiControl from pistomp.footswitch import Footswitch -from pistomp.handler import Handler -from pathlib import Path class Modhandler(Handler): @@ -91,24 +93,37 @@ def __init__(self, audiocard, homedir): 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, "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 } 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 @@ -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 27cb73eb..035e970a 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -112,10 +112,14 @@ def reinit(self, cfg): # Footswitch configuration self.__init_footswitches(self.cfg) + # External MIDI configuration + self.__init_external_midi(self.cfg) + # Pedalboard specific config if cfg is not None: self.__init_midi(cfg) self.__init_footswitches(cfg) + self.__init_external_midi(cfg) @abstractmethod def init_analog_controls(self): @@ -309,6 +313,18 @@ 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).""" + if not hasattr(self, 'external_midi') or 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 6f12f2d8..1ac0953d 100644 --- a/setup/config_templates/default_config_pistomptre.yml +++ b/setup/config_templates/default_config_pistomptre.yml @@ -79,3 +79,45 @@ 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 + # : + # - [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