Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions common/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

ACTION = 'action'
ADC = 'adc'
ADC_INPUT = 'adc_input'
ALSA = 'alsa'
ANALOG_CONTROLLERS = 'analog_controllers'
AUTOSYNC = 'autosync'
BANK = 'bank'
Expand Down
6 changes: 6 additions & 0 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from plugins.customization import lookup as plugin_lookup
import modalapi.external_midi as ExternalMidi
from modalapi.external_midi import EXTERNAL_INSTANCE_ID
from pistomp.midi_input_manager import MidiInputManager
from modalapi.ethernet import EthernetManager
from modalapi.jack_mute import JackMute
from pistomp.lcd320x240 import Lcd
Expand Down Expand Up @@ -181,6 +182,9 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
# External MIDI device synchronization
self.external_midi = ExternalMidi.ExternalMidiManager()

# MIDI input from wireless/USB expression pedals (v2/v3 only)
self.midi_input_manager = MidiInputManager()

# Blend mode manager - multiple blend snapshots per pedalboard
self.blend_modes: dict[str, Any] = {} # {snapshot_name: BlendMode}
self.active_blend_mode: Any | None = None # Currently active blend mode
Expand All @@ -197,6 +201,7 @@ def cleanup(self):
if self._hardware is not None:
self._hardware.cleanup()
self.external_midi.close()
self.midi_input_manager.close()
self.ws_bridge.stop()
logging.info("WebSocket bridge stopped")
self.ethernet_manager.shutdown()
Expand All @@ -218,6 +223,7 @@ def _rest_post(self, url: str, *, json=None, data=None) -> Response | None:
def add_hardware(self, hardware):
self._hardware = hardware
hardware.external_midi = self.external_midi
hardware.midi_input_manager = self.midi_input_manager
self._controller_manager = ControllerManager(hardware)
self.bind_volume_encoder()
hardware.register_sink(self)
Expand Down
32 changes: 30 additions & 2 deletions pistomp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,32 @@
"type": "object",
"properties": {
"adc_input": {
"type": "integer"
"type": "integer",
"description": "Legacy top-level ADC channel; equivalent to input.adc"
},
"input": {
"type": "object",
"description": "Input source: a physical ADC channel (adc) or a MIDI device (alsa)",
"properties": {
"adc": {
"type": "integer",
"description": "ADC channel of a physical control"
},
"alsa": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}, "minItems": 1}
],
"description": "ALSA client name(s) of a MIDI device (USB or Bluetooth make no difference here; "
"list multiple names to try candidates in order, e.g. BLE name then USB name for "
"the same physical pedal)"
}
},
"anyOf": [
{"required": ["adc"]},
{"required": ["alsa"]}
],
"additionalProperties": False
},
"id": {
"type": "integer"
Expand All @@ -148,8 +173,11 @@
}
},
"required": [
"adc_input",
"midi_CC"
],
"anyOf": [
{"required": ["adc_input"]},
{"required": ["input"]}
]
}
},
Expand Down
64 changes: 51 additions & 13 deletions pistomp/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import pistomp.encoder_controller as EncoderController
import pistomp.footswitch as Footswitch
import pistomp.taptempo as taptempo
from pistomp.midi_input_control import MidiInputControl
from pistomp.midi_input_manager import MidiInputManager

from abc import ABC, abstractmethod
from rtmidi import MidiOut
Expand Down Expand Up @@ -52,7 +54,7 @@ def __init__(self, default_config, handler, midiout, refresh_callback):

# Standard hardware objects (not required to exist)
self.relay: Relay.Relay | None = None
self.analog_controls: list[AnalogMidiControl.AnalogMidiControl] = []
self.analog_controls: list[AnalogMidiControl.AnalogMidiControl | MidiInputControl] = []
self.encoders = []
self.controllers: dict[str, Controller] = {}
self.footswitches: list[Footswitch.Footswitch] = []
Expand All @@ -61,6 +63,9 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
self.ledstrip = None
self.taptempo = taptempo.TapTempo(None)
self.external_midi: ExternalMidiManager | None = None
# Owns rtmidi input ports for wireless/USB expression pedals. Assigned by the
# handler (v2/v3 only) in add_hardware, mirroring external_midi; None on v1.
self.midi_input_manager: MidiInputManager | None = None
# control → destination; absent means internal (virtual/mod-host).
# Rebuilt every reinit in __apply_midi_routing. Identity-keyed; controllers
# are stable across reinit (mutated in place).
Expand Down Expand Up @@ -160,6 +165,15 @@ def reinit(self, cfg):
for fs in self.footswitches:
self.handler.chord_helper.register(fs.longpress_groups)

# Wire wireless/USB MIDI inputs (channels/CCs are now finalized for this cfg).
self.__sync_midi_inputs()

def __sync_midi_inputs(self) -> None:
if self.midi_input_manager is None:
return
controls = [c for c in self.analog_controls if isinstance(c, MidiInputControl)]
self.midi_input_manager.rebuild(controls)

@abstractmethod
def init_analog_controls(self):
...
Expand Down Expand Up @@ -280,30 +294,54 @@ def create_analog_controls(self, cfg):
continue

id = Util.DICT_GET(c, Token.ID)
adc_input = Util.DICT_GET(c, Token.ADC_INPUT)
midi_cc = Util.DICT_GET(c, Token.MIDI_CC)
threshold = Util.DICT_GET(c, Token.THRESHOLD)
control_type = Util.DICT_GET(c, Token.TYPE)
autosync = Util.DICT_GET(c, Token.AUTOSYNC)

# Input source: an `input:` block (adc / alsa) or the legacy top-level
# `adc_input`. ADC drives an AnalogMidiControl; alsa drives a
# MidiInputControl fed by MidiInputManager.
input_cfg = Util.DICT_GET(c, Token.INPUT) or {}
adc_input = Util.DICT_GET(input_cfg, Token.ADC)
if adc_input is None:
logging.error("Config file error. Analog control specified without %s" % Token.ADC_INPUT)
continue
adc_input = Util.DICT_GET(c, Token.ADC_INPUT)
# alsa may be a single client name or an ordered list of candidates
# (e.g. try a BLE name before falling back to a USB name)
alsa = Util.DICT_GET(input_cfg, Token.ALSA)
if alsa is None:
device_candidates = []
elif isinstance(alsa, str):
device_candidates = [alsa]
else:
device_candidates = list(alsa)

if midi_cc is None:
logging.error("Config file error. Analog control specified without %s" % Token.MIDI_CC)
continue
if threshold is None:
threshold = 16 # Default, 1024 is full scale
if autosync is None:
autosync = False # Default to False
if adc_input is None and not device_candidates:
logging.error("Config file error. Analog control specified without %s or an %s source"
% (Token.ADC_INPUT, Token.INPUT))
continue

control: AnalogMidiControl.AnalogMidiControl | MidiInputControl
if adc_input is not None:
if threshold is None:
threshold = 16 # Default, 1024 is full scale
if autosync is None:
autosync = False # Default to False
control = AnalogMidiControl.AnalogMidiControl(self.spi, adc_input, threshold, midi_cc, midi_channel,
control_type, id, c, autosync)
logging.debug("Created AnalogMidiControl Input: %d, Midi Chan: %d, CC: %d" %
(adc_input, midi_channel, midi_cc))
else:
control = MidiInputControl(midi_channel, midi_cc, control_type, id, device_candidates, c)
logging.debug("Created MidiInputControl Devices: %s, Midi Chan: %d, CC: %d" %
(device_candidates, midi_channel, midi_cc))

control = AnalogMidiControl.AnalogMidiControl(self.spi, adc_input, threshold, midi_cc, midi_channel,
control_type, id, c, autosync)
self.analog_controls.append(control)
key = format("%d:%d" % (midi_channel, midi_cc))
self.controllers[key] = control
logging.debug("Created AnalogMidiControl Input: %d, Midi Chan: %d, CC: %d" %
(adc_input, midi_channel, midi_cc))

@abstractmethod
def add_encoder(self, id, type, callback, longpress_callback, midi_channel, midi_cc) -> EncoderController.EncoderController | None:
Expand Down Expand Up @@ -428,7 +466,7 @@ def __init_midi(self, cfg):
self.midi_channel = self.get_real_midi_channel(cfg)
# TODO could iterate thru all objects here instead of handling in __init_footswitches
for ac in self.analog_controls:
if isinstance(ac, AnalogMidiControl.AnalogMidiControl):
if isinstance(ac, (AnalogMidiControl.AnalogMidiControl, MidiInputControl)):
ac.set_midi_channel(self.midi_channel)

def __init_external_midi(self, cfg):
Expand Down
70 changes: 70 additions & 0 deletions pistomp/midi_input_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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 typing import Any

import pistomp.controller as controller
from pistomp.controller import AnalogDisplayInfo
from pistomp.input.event import AnalogEvent


class MidiInputControl(controller.Controller):
"""An expression pedal (or knob) whose value arrives as MIDI CC from an
external device (USB or BLE) rather than a physical ADC channel.

MidiInputManager receives the CC on the rtmidi callback thread and calls
feed_midi(), which only stores the value. The value is emitted as an
AnalogEvent on the poll thread in refresh() (called from
Hardware.poll_controls, same as AnalogMidiControl), keeping all dispatch —
and thus every LCD/blend touch — on the 10ms critical path."""

def __init__(self, midi_channel: int, midi_CC: int | None, type: str | None,
id: int | None = None, device_candidates: list[str] | None = None,
cfg: dict[str, Any] | None = None):
controller.Controller.__init__(self, midi_channel, midi_CC)
self.type = type
self.id = id
# ordered device names to try (bluetooth first, then usb); first match wins
self.device_candidates: list[str] = device_candidates or []
self.cfg: dict[str, Any] = cfg or {}
self.last_read: int = 0
self._pending: int | None = None # written by rtmidi callback thread, read by poll thread

def set_midi_channel(self, midi_channel: int) -> None:
self.midi_channel = midi_channel

def feed_midi(self, cc_value: int) -> None:
"""Store the latest CC (rtmidi callback thread). A single int assignment
is atomic under the GIL; refresh() drains it on the poll thread."""
self._pending = cc_value

def refresh(self) -> None:
"""Poll thread: emit an AnalogEvent if a new value has arrived.
Latest-wins, matching AnalogMidiControl's per-tick ADC read."""
pending = self._pending
if pending is None or pending == self.last_read:
return
self.last_read = pending
self.midi_value = pending
self.sink.handle(AnalogEvent(controller=self, raw_value=pending, midi_value=pending))

def send_current_value(self) -> None:
"""No-op: a MIDI input has no queryable position, so autosync doesn't apply."""

def get_normalized_value(self) -> float:
return self.last_read / 127.0

def get_display_info(self) -> AnalogDisplayInfo:
return {'type': self.type, 'id': self.id, 'category': None}
Loading
Loading