Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
8c68f3c
Add external MIDI device synchronization
sastraxi Apr 19, 2026
7428266
feat: route controller MIDI to external devices via midi_port config
sastraxi Apr 19, 2026
c170511
Merge branch 'refactor/common-parameter' into feat/external-midi
sastraxi Apr 24, 2026
8bf2557
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Apr 24, 2026
3f43531
Merge branch 'refactor/common-parameter' into feat/external-midi
sastraxi May 3, 2026
6962c1f
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 3, 2026
6b15526
Merge branch 'refactor/common-parameter' into feat/external-midi
sastraxi May 9, 2026
564a99c
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 9, 2026
6f38936
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi May 10, 2026
42e87e1
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 10, 2026
c18fea3
Remove unnecessary universal_encoder_sw
sastraxi May 10, 2026
6096815
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 10, 2026
a7cb4af
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi May 12, 2026
5467720
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 12, 2026
93f700a
Import EXTERNAL_INSTANCE_ID directly in modhandler
sastraxi May 12, 2026
da5f259
MIDI tests
sastraxi May 12, 2026
d4250e6
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 12, 2026
81d3251
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi May 21, 2026
e560a4d
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 21, 2026
a6909d1
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi May 22, 2026
0e8c68a
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 22, 2026
dcbe031
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi May 23, 2026
7a4be0e
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi May 23, 2026
ebdc908
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 3, 2026
6e2a946
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Jun 3, 2026
1efa624
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 5, 2026
1836e92
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Jun 5, 2026
2f2d98e
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 5, 2026
9f16a6e
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Jun 5, 2026
2fd85ac
Fix: update last_read before value_change_callback in AnalogMidiControl
sastraxi Jun 5, 2026
57b8004
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 6, 2026
ac7b884
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Jun 6, 2026
d05d6c5
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 6, 2026
6d1d5d2
Merge branch 'feat/external-midi' into feat/controller-routing
sastraxi Jun 6, 2026
5a4858e
Bugfixes + refators
sastraxi Jun 6, 2026
9602ecf
Remove dead code, noisy logging, over-verbosity
sastraxi Jun 6, 2026
56c844f
A bit more type safety
sastraxi Jun 6, 2026
5850be4
Add v1 system fixture + integration test
sastraxi Jun 6, 2026
35b56b9
external-midi: fix _init_port KeyError, double-close, midi_port log l…
sastraxi Jun 6, 2026
e017b10
external-midi: type-safe volume guard, align mod.py with modhandler
sastraxi Jun 6, 2026
fe3e700
external-midi: single-pass MIDI routing for all controls (C1/C3)
sastraxi Jun 6, 2026
e6571d7
external-midi: show external controllers on v1 (mod.py) (4D)
sastraxi Jun 7, 2026
c628bf1
4B reorder
sastraxi Jun 7, 2026
421af88
Real loopback test
sastraxi Jun 7, 2026
212ba8f
Removed dead code; added more loopback tests
sastraxi Jun 7, 2026
d578bd4
midi_port sends to ext midi "instead of" virtual
sastraxi Jun 7, 2026
d51d8e8
Config schema test + drop dead shortpress docs
sastraxi Jun 7, 2026
60255d4
Strip plan-marker references from comments
sastraxi Jun 7, 2026
70be97c
Characterizaiton tests for ext midi
sastraxi Jun 7, 2026
10d8d49
Extract current and controller management out of mod/modhandler
sastraxi Jun 7, 2026
79d9235
Drop unnecessary ID stamp controller manager config
sastraxi Jun 8, 2026
f5110fd
Greatly simplified device targeting
sastraxi Jun 8, 2026
c68ba53
external_midi is not nullable
sastraxi Jun 9, 2026
e06d38a
Move routing ownership from Controller to Hardware registry
sastraxi Jun 9, 2026
f1840ce
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 10, 2026
6e01f4f
Merge branch 'pistomp-v3' into feat/external-midi
sastraxi Jun 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
23 changes: 8 additions & 15 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,33 +132,26 @@ JACK (bridges via `-X seq`)
- ✅ Expression Pedal (CC 75) - sends to virtual port
- ✅ Footswitches (CC 60-63) - send to virtual port when pressed
- ✅ Rotary Encoder Rotation (Tweak1=CC70, Tweak2=CC71) - send to virtual port
- Encoder Button Presses - Can optionally send MIDI via `shortpress` config (see below)
- Encoder Button Presses - UI navigation / handler actions only; do not send MIDI

### Encoder Button Configuration (v3 only)
### Encoder Button Behavior (v3)

Encoder buttons support configurable `shortpress` callbacks:
Encoder button presses are wired to handler callbacks, not MIDI. A short press
invokes the built-in click handler (`universal_encoder_sw`, UI navigation); a
long press invokes the named `longpress` callback from config.

```yaml
encoders:
- id: 1
midi_CC: 70 # Rotation
midi_CC: 70 # rotation sends CC 70
longpress: previous_snapshot
shortpress: universal_encoder_sw # Default if omitted

- id: 2
midi_CC: 71
shortpress:
callback: send_midi_cc
args: {cc: 72} # Button sends CC 72 to virtual port
```

Shortpress accepts string (callback name) or object with `callback` and `args` (expanded as kwargs).

#### Implementation Details

| Control | Shortpress | Longpress |
|---------|------------|-----------|
| Encoder | String or `{callback, args}` via `encoderconfig.parse_shortpress_config()` | String only (no args) |
| Encoder | Built-in click handler (UI nav) - no config | String (callback name) - no args |
| Footswitch | Hardcoded (toggle/MIDI) - no config | String or list (group names) - no args |
| `GpioSwitch` | `callback_arg` (dict→kwargs, value→arg, None) | `longpress_callback_arg` (dict→kwargs, value→arg, None) |
| `AnalogSwitch` | Single `callback(state)` - no separate longpress | Same callback, state=LONGPRESSED |
Expand Down Expand Up @@ -403,7 +396,7 @@ Outbound behaviour depends on the initiator:
**Encoders** (`pistomp/encoder.py`, `pistomp/encodermidicontrol.py`):
- **Base**: Quadrature decoding, GPIO interrupts, debounce
- **MIDI Control**: Sends CC on rotation (v3 tweak encoders)
- **Buttons**: Configurable shortpress (callback + args) and longpress
- **Buttons**: Built-in shortpress (UI nav) and configurable longpress (callback name)
- **State Machines** (v1/v2 only): `TopEncoderMode`, `BotEncoderMode`, `UniversalEncoderMode`

**Analog Controls** (`pistomp/analogmidicontrol.py`):
Expand Down
250 changes: 250 additions & 0 deletions modalapi/external_midi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# 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/>.

from __future__ import annotations

import logging
import time
from typing import TypedDict

import rtmidi
from rtmidi import MidiOut as RtMidiOut

MidiMessage = list[int]

EXTERNAL_INSTANCE_ID = "External"

PORT_RETRY_BACKOFF_S = 5.0 # don't re-enumerate a failed port more often than this


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, fallback_midiout: RtMidiOut):
self.external_midi = external_midi_manager
self.port_name = port_name
self.fallback = fallback_midiout

def send_message(self, message: MidiMessage) -> None:
try:
success = self.external_midi.send_raw(self.port_name, message)
except Exception:
logging.warning(f"External MIDI send failed for port {self.port_name}, falling back to virtual")
self.fallback.send_message(message)
return
if not success:
self.fallback.send_message(message)


class ExternalMidiConfig(TypedDict, total=False):
enabled: bool
send_delay_ms: int
messages: dict[str, list[MidiMessage]]


def _client_name_of(port: str) -> str:
"""Extract the ALSA client name from an rtmidi port string like 'Client Name:Port Name N:M'."""
return port.split(":")[0].strip()


class ExternalMidiManager:
"""
Manages external MIDI device synchronization.
Sends MIDI messages to external devices when pedalboards are loaded.

Config keys in `messages` and `midi_port` are ALSA client names exactly as
reported by the device (e.g. 'Source Audio C4 Synth'). Matching is
case-insensitive against the client-name prefix of each rtmidi port string.
"""

def __init__(self):
self.midi_ports: dict[str, rtmidi.MidiOut] = {}
self.messages: dict[str, list[MidiMessage]] = {}
self.enabled: bool = False
self.send_delay_ms: int = 10
self._open_failures: dict[str, float] = {}

def update_config(self, cfg: ExternalMidiConfig | None) -> None:
"""Update configuration incrementally; only fields present are updated."""
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 "messages" in cfg:
# Merge messages at port level (replace entire message list per port)
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_index(self, device_name: str) -> int | None:
"""Return the index of the first port whose ALSA client name matches device_name (case-insensitive)."""
available = self._get_available_ports()
for idx, port in enumerate(available):
if _client_name_of(port).lower() == device_name.lower():
logging.info(f"Found MIDI port: {port} (index {idx})")
return idx
logging.warning(f"No MIDI port found with device name: {device_name!r}")
return None

def open_port(self, port_name: str) -> bool:
"""Eagerly open a port at routing time so the first poll-loop send doesn't enumerate."""
return self._init_port(port_name) is not None

def _init_port(self, port_name: str) -> rtmidi.MidiOut | None:
if port_name in self.midi_ports:
return self.midi_ports[port_name]

last_fail = self._open_failures.get(port_name)
if last_fail is not None and (time.monotonic() - last_fail) < PORT_RETRY_BACKOFF_S:
return None

port_idx = self._find_port_index(port_name)
if port_idx is None:
self._open_failures[port_name] = time.monotonic()
return None

try:
midi_out = rtmidi.MidiOut()
midi_out.open_port(port_idx)
self.midi_ports[port_name] = midi_out
self._open_failures.pop(port_name, None)
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._open_failures[port_name] = time.monotonic()
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]
self._open_failures[port_name] = time.monotonic() # back off before re-enumerating

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):
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_raw(self, port_name: str, message: MidiMessage) -> bool:
"""Send one raw message; True on success, False if the port is unavailable (caller falls back)."""
if not self.enabled:
return False

midi_out = self._init_port(port_name)
if midi_out is None:
return False

try:
midi_out.send_message(message)
return True
except Exception as e:
logging.error(f"Failed to send MIDI message to {port_name}: {e}")
self._invalidate_port(port_name)
return False

def send_messages_for_pedalboard(self) -> bool:
"""Send the current pedalboard's external messages (config set earlier via update_config)."""
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")
Loading