Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Expression pedal can be turned on from the LCD. It no longer needs an SSH session and an edit to `default_config.yml`.
- The analog row is now a nav stop. Click it to see every input with its resolved MIDI binding, and to turn one on or off. The choice is kept across pedalboards.
### Changed
- Pedalboard/snapshot titles now auto-scroll only while selected with the NAV encoder (at most one thing scrolls at a time, and they sit at their leftmost position otherwise) — LCD updates over SPI are audible on the DAC at high gain, so the screen stays quiet while you play
### Fixed
- The parameter dialog on the LCD sometimes did not change values due to a race condition with respect to MOD-UI's `last.json`. pi-Stomp then did not send parameter changes to MOD-UI until you selected a different pedalboard. Parameters on a knob or an encoder continued to work, because they send MIDI CC.
- The LCD showed a bypass that MOD-UI did not receive, if you tapped it while a pedalboard loaded. The LCD now keeps the last value that MOD-UI confirmed.
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ full-range input despite ADC noise.
Types: `KNOB` and `EXPRESSION` (config-driven). When `autosync: true`, `initialize()`
reads the ADC and sends current position on pedalboard load.

**On/off is a device fact, not a config fact** (`pistomp/input_enable.py`). An empty
jack reads noise, so an `EXPRESSION` input is off until the user turns it on from the
analog row on the LCD; every other control is on until turned off. The choice lives in
`settings.yml`, which the software owns, and it masks `disabled` at both create time and
`reinit`. `disable: true` in a config file is a different statement: that control is
never created, and the menu cannot offer it.

### LCD System

- **v1**: `pistomp/lcdgfx.py` — monochrome 128×64 display via gfxhat library. Direct
Expand Down
1 change: 1 addition & 0 deletions emulator/hardware_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def init_analog_controls(self):
if b.disable or b.midi_CC is None:
continue
ctrl = MockAnalogControl(b.midi_CC, b.midi_channel, b.type, b.id)
ctrl.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.analog_controls.append(ctrl)
self.register_controller(ctrl)

Expand Down
2 changes: 2 additions & 0 deletions pistomp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
from modalapi.plugin import Plugin
from modalapi.websocket_bridge import AsyncWebSocketBridge
from pistomp.hardware import Hardware
from pistomp.settings import Settings
from pistomp.tuner.source import TunerSourceFactory


class Handler(InputSink):
_ws_bridge: "AsyncWebSocketBridge | None" = None
settings: "Settings"

@property
def ws_bridge(self) -> "AsyncWebSocketBridge":
Expand Down
30 changes: 28 additions & 2 deletions pistomp/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
PresetStep,
)
from pistomp.config.schema_v1 import ConfigDocument
from pistomp.input_enable import InputEnable
import pistomp.relay as Relay

_Binding = TypeVar("_Binding", FootswitchBinding, EncoderBinding, AnalogBinding)
Expand All @@ -56,6 +57,7 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
self.test_pass = False
self.test_sentinel = None

self._input_enable: InputEnable | None = None
self.default_cfg: ConfigDocument = default_config
self.config = config.resolve(default_config)
self.base_config = self.config
Expand All @@ -76,6 +78,14 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
# reinit (mutated in place).
self.external_routing: dict[Controller, RoutingInfo] = {}

@property
def input_enable(self) -> InputEnable:
"""The user's on/off choice per input. Built late: the handler owns the
settings file and is not complete when the hardware is constructed."""
if self._input_enable is None:
self._input_enable = InputEnable(self.handler.settings)
return self._input_enable

@property
def version(self) -> float:
return self.config.version
Expand Down Expand Up @@ -132,6 +142,20 @@ def sync_analog_controls(self):
except Exception as e:
logging.warning(f"Failed to sync analog control {control.midi_CC}: {e}")

def set_input_enabled(self, control: Controller, enabled: bool) -> None:
"""Turn one analog input or encoder on or off, and remember the choice
across pedalboards. A control that the config disables never gets here:
it is not created at all."""
if control.id is None:
return
self.input_enable.set_enabled(control.id, enabled)
control.disabled = not enabled
if enabled and isinstance(control, AnalogMidiControl.AnalogMidiControl) and control.autosync:
try:
control.send_current_value()
except Exception:
logging.warning("Failed to sync analog control %s on enable", control.midi_CC)

def longpress_action(self, fs: Footswitch.Footswitch) -> LongpressAction | None:
"""The mapping form of longpress, which has no home on the footswitch."""
binding = self.config.footswitch(fs.id) if fs.id is not None else None
Expand Down Expand Up @@ -295,6 +319,7 @@ def create_analog_controls(self, config: PedalboardConfig) -> None:
control = AnalogMidiControl.AnalogMidiControl(
self.spi, b.adc_input, b.threshold, b.midi_CC, b.midi_channel, b.type, b.id, b.autosync
)
control.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.analog_controls.append(control)
self.register_controller(control)
logging.debug(
Expand All @@ -320,6 +345,7 @@ def create_encoders(self, config: PedalboardConfig) -> None:
# FIXME: add_encoder returns None for emulator v1/v2 stubs that don't
# implement config-driven encoders, forcing the return type to be optional.
if control is not None:
control.disabled = not self.input_enable.is_enabled(b.id, b.type)
self.encoders.append(control)
self.register_controller(control)
logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %s", b.id, b.midi_channel, b.midi_CC)
Expand Down Expand Up @@ -387,7 +413,7 @@ def __apply_footswitch(self, fs: Footswitch.Footswitch, binding: FootswitchBindi

def __apply_encoder(self, enc: Controller, binding: EncoderBinding) -> None:
enc.type = binding.type
enc.disabled = binding.disable
enc.disabled = binding.disable or not self.input_enable.is_enabled(binding.id, binding.type)
enc.midi_channel = binding.midi_channel
enc.midi_CC = binding.midi_CC
if isinstance(enc, EncoderController.EncoderController):
Expand All @@ -396,7 +422,7 @@ def __apply_encoder(self, enc: Controller, binding: EncoderBinding) -> None:
self.__route(enc, binding.midi_port)

def __apply_analog_control(self, control: Controller, binding: AnalogBinding) -> None:
control.disabled = binding.disable
control.disabled = binding.disable or not self.input_enable.is_enabled(binding.id, binding.type)
control.midi_channel = binding.midi_channel
control.midi_CC = binding.midi_CC
if isinstance(control, AnalogMidiControl.AnalogMidiControl):
Expand Down
55 changes: 55 additions & 0 deletions pistomp/input_enable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# 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 Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

"""Which hardware inputs the user turned on, kept across pedalboards.

This is a device fact, not a config fact: it says what is plugged in, so it
lives in settings.yml and not in a config file that the user owns. An
EXPRESSION input is off until the user turns it on, because an empty jack
reads ADC noise. Every other control is on until the user turns it off.

The id space is the screen position that `draw_analog_assignments` paints, so
one analog control and one encoder never share an id.
"""

from __future__ import annotations

from pistomp.controller import ControlType
from pistomp.settings import Settings

SETTING = "input_enabled"


class InputEnable:
def __init__(self, settings: Settings) -> None:
self._settings = settings
stored = settings.get_setting(SETTING)
self._choices: dict[int, bool] = (
{int(k): bool(v) for k, v in stored.items()} if isinstance(stored, dict) else {}
)

def is_enabled(self, control_id: int | None, control_type: ControlType) -> bool:
if control_id is None:
return True
chosen = self._choices.get(control_id)
if chosen is None:
return control_type is not ControlType.EXPRESSION
return chosen

def set_enabled(self, control_id: int, enabled: bool) -> None:
self._choices[control_id] = enabled
self._settings.set_setting(SETTING, dict(self._choices))
41 changes: 38 additions & 3 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type
from modalapi.plugin import Plugin
from ui.analog_menu import AnalogMenu
from ui.ethernet_menu import EthernetMenu
from ui.footswitch_menu import FootswitchMenu
from ui.wifi_menu import WifiMenu
Expand All @@ -39,6 +40,7 @@
import pygame

from uilib import (
AnalogBarPanel,
Box,
Config,
ContainerWidget,
Expand Down Expand Up @@ -76,6 +78,12 @@
# Parameter dialog auto-dismiss timeout (seconds)
PARAMETER_DIALOG_TIMEOUT = 1.0

# The analog row sits in its own panel so it is one nav stop. The pad leaves
# room for the selection border above and below the icons.
ANALOG_ROW_TOP = 54
ANALOG_ROW_PAD = 2
ANALOG_OFF_COLOR = (90, 90, 90)

# Subtitle auto-hide after no nav encoder movement (seconds)
SUBTITLE_TIMEOUT = 1.3

Expand Down Expand Up @@ -220,6 +228,11 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
no_dim=True,
accepts_input=False,
)
self.analog_menu: AnalogMenu = AnalogMenu(self)
# Built on the first draw, not here: the row overlaps the pedalboard
# and snapshot names, and a child paints in attach order, so it has to
# attach after them to own that band.
self.analog_panel: AnalogBarPanel | None = None

self.pedalboards = {}

Expand Down Expand Up @@ -1197,12 +1210,20 @@ def draw_analog_assignments(self, controllers):
height_per_control = 19
text_per_control = TILE_W - 16 # minus height of control icon

if self.analog_panel is None:
self.analog_panel = AnalogBarPanel(
box=Box.xywh(0, ANALOG_ROW_TOP, self.display_width, 2 * ANALOG_ROW_PAD + 19),
on_press=self.analog_menu.open,
subtitle="Analog Inputs",
parent=self.main_panel,
)

# clean up previous control widgets
for w in self.w_controls:
w.destroy()
self.w_controls = []

y = 56 # vertical position on screen
y = ANALOG_ROW_PAD # vertical position inside the analog panel
for i in range(0, num):
x = i * pitch
k = None
Expand Down Expand Up @@ -1278,6 +1299,12 @@ def draw_analog_assignments(self, controllers):
else:
text_color = color

if analog_control is not None and analog_control.disabled:
name = "off"
subtitle = f"{subtitle} (off)"
color = ANALOG_OFF_COLOR
text_color = ANALOG_OFF_COLOR

blend_initial_progress = None
if isinstance(icon_object, BlendMode):
text_color = TILE_DEFAULT_COLOR
Expand All @@ -1301,7 +1328,7 @@ def draw_analog_assignments(self, controllers):
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
text_color=text_color,
parent=self.main_panel,
parent=self.analog_panel,
outline=0,
object=icon_object,
subtitle=subtitle,
Expand All @@ -1316,7 +1343,7 @@ def draw_analog_assignments(self, controllers):
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
text_color=text_color,
parent=self.main_panel,
parent=self.analog_panel,
outline=0,
object=icon_object,
subtitle=subtitle,
Expand All @@ -1332,12 +1359,20 @@ def draw_analog_assignments(self, controllers):
if control_label_fn is not None and control_param is not None and w is not None:
w.bind_label(control_param, control_label_fn)

self.main_panel.add_sel_widget(self.analog_panel)

# Rebuild path: widget create/destroy above marks regions dirty, but
# the LCD push only fires on a refresh. Called standalone from
# _rebind_pedalboard (midi-learn of an encoder), where there's no
# enclosing draw_main_panel to refresh for us.
self.analog_panel.refresh()
self.main_panel.refresh()

def refresh_analog_row(self) -> None:
"""Repaint the analog row after an input is turned on or off."""
if self.current is not None:
self.draw_analog_assignments(self.current.analog_controllers)

def draw_info_message(self, text, refresh=False):
if self.w_info_msg is None:
self.w_info_msg = TextWidget(
Expand Down
16 changes: 10 additions & 6 deletions setup/config_templates/default_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ hardware:
# Falls back to the virtual port only if the device is unavailable; must be the device name (e.g. 'Source Audio C4 Synth')
# midi_channel: <integer> Override MIDI channel for this control (0-15); required when midi_port is set
# autosync: <true | false> Whether to send current value on pedalboard load (optional, default: false)
# disable: <true | false> Never create this control at all (optional, default: false)
#
#analog_controllers:
# - adc_input: 5
# id: 0
# type: EXPRESSION
# midi_CC: 75
# autosync: true
# An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
# it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
- adc_input: 5
id: 0
type: EXPRESSION
midi_CC: 75
autosync: true

# encoders:
# Each encoder definition is a list which starts with the id
Expand Down
14 changes: 9 additions & 5 deletions setup/config_templates/default_config_3fs_2knob.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ hardware:
# type: <KNOB | EXPRESSION> The control type, used to represent the control on the screen (optional)
# midi_CC: <integer> The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: <true | false> Whether to send current value on pedalboard load (optional, default: false)
# disable: <true | false> Never create this control at all (optional, default: false)
#
# An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
# it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
#- adc_input: 7
# id: 0
# midi_CC: 77
# type: EXPRESSION
# autosync: true
- adc_input: 7
id: 0
midi_CC: 77
type: EXPRESSION
autosync: true
- adc_input: 0
id: 1
midi_CC: 70
Expand Down
4 changes: 4 additions & 0 deletions setup/config_templates/default_config_3fs_2knob_exp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ hardware:
# type: <KNOB | EXPRESSION> The control type, used to represent the control on the screen (optional)
# midi_CC: <integer> The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: <true | false> Whether to send current value on pedalboard load (optional, default: false)
# disable: <true | false> Never create this control at all (optional, default: false)
#
# An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
# it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
- adc_input: 7
Expand Down
16 changes: 10 additions & 6 deletions setup/config_templates/default_config_pistompcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ hardware:
# type: <KNOB | EXPRESSION> The control type, used to represent the control on the screen (optional)
# midi_CC: <integer> The MIDI CC message to be sent when the control is adjusted (optional)
# autosync: <true | false> Whether to send current value on pedalboard load (optional, default: false)
# disable: <true | false> Never create this control at all (optional, default: false)
#
# analog_controllers:
# - adc_input: 7
# id: 0
# midi_CC: 77
# type: EXPRESSION
# autosync: true
# An EXPRESSION control starts off, because an empty jack reads ADC noise. Turn
# it on from the analog row on the LCD; the choice is kept in settings.yml.
#
analog_controllers:
- adc_input: 7
id: 0
midi_CC: 77
type: EXPRESSION
autosync: true
# - adc_input: 0
# id: 1
# midi_CC: 70
Expand Down
Loading
Loading