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
3 changes: 3 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Don't correct me on things that aren't germane, especially when you're only gues
the current selection. No panel or binding may consume a raw NAV event, and no
`declare_bindings()` row may name `cls=NAV` — it's the one axiom the precedence
resolver doesn't apply to. Enforced by the base `Panel`, not convention.
- **Immutable at domain boundaries.** Data that crosses from config to application is a frozen dataclass. Do not mutate it.
- **Lifecycle symmetry.** The code that creates an association must close it. Do not depend on a downstream sweep to clean up.
- **Domain separation.** Config (`pistomp/config/`), hardware, and application are three separate domains. Hardware objects must not cache config state. Config types must not reference hardware objects.

### Writing code here

Expand Down
27 changes: 3 additions & 24 deletions blend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,8 @@
# 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/>.

"""
Blend mode package - Analog input-driven snapshot interpolation.
"""Blend mode: an analog input interpolates between snapshots.

This package provides functionality for smoothly interpolating between snapshots
based on analog input position (expression pedals or tweak encoders).
Import from the submodules. This package must not re-export them, or
`pistomp.config` cannot use `blend.types` without a cycle.
"""

from blend.easing import EASING_FUNCTIONS, EasingFunc
from blend.input_controller import InputController
from blend.manager import BlendMode
from blend.parameter_setter import ParameterSetter
from blend.snapshot import SnapshotManager
from blend.stop import BlendStop
from blend.types import BlendSnapshotConfig, NormalizedStops

__all__ = [
"BlendMode",
"BlendStop",
"InputController",
"SnapshotManager",
"ParameterSetter",
"BlendSnapshotConfig",
"NormalizedStops",
"EASING_FUNCTIONS",
"EasingFunc",
]
4 changes: 2 additions & 2 deletions blend/input_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import logging
from bisect import bisect_right

import common.token as Token
from pistomp.controller import ControlType
from blend.easing import EasingFunc
from blend.parameter_setter import ParameterSetter
from blend.stop import BlendStop
Expand Down Expand Up @@ -60,7 +60,7 @@ def __init__(

def attach_to_input(self, control: BlendInputProtocol) -> None:
"""Store reference to the blend input controller."""
if getattr(control, "type", None) == Token.VOLUME:
if control.type == ControlType.VOLUME:
raise ValueError(f"Input {control.id} is a VOLUME controller and cannot be used for blend mode")
self.controlled_input = control
logging.info(f"Attached blend mode to {type(control).__name__} {control.id}")
Expand Down
3 changes: 2 additions & 1 deletion blend/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import json
import logging
import pistomp.httpclient as req
from collections.abc import Sequence
from pathlib import Path

from blend.types import (
Expand Down Expand Up @@ -89,7 +90,7 @@ def parse_snapshot_data(snapshots_json: SnapshotsJson, snapshot_index: int) -> S
@staticmethod
def sync_blend_snapshots(
bundle_path: Path,
blend_configs: list[BlendSnapshotConfig] | None,
blend_configs: Sequence[BlendSnapshotConfig] | None,
root_uri: str,
) -> dict[str, int]:
"""Ensure each configured blend snapshot exists as an empty entry.
Expand Down
1 change: 1 addition & 0 deletions blend/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class BlendInputProtocol(Protocol):
"""Protocol for blend mode input sources (expression pedal or encoder)."""

id: int
type: str | None

def get_normalized_value(self) -> float: ...

Expand Down
36 changes: 10 additions & 26 deletions common/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,9 @@
# 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/>.

"""What a control does is declared data, not per-panel `if` chains: a
BindingDecl names a ControlRef + EventKind, a closed Effect union to fire,
and the ContextRef (PANEL/BLEND/PEDALBOARD/SYSTEM) that owns it. NAV is the
one axiom excluded from this entirely — see uilib/panel.py's Panel.handle.

ContextStack.resolve walks a fixed per-ControlClass chain (_CHAINS below,
highest precedence first) and returns the winning row for a (control,
event_kind) pair, tagging every row it passed over ACTIVE/SHADOWED/ORPHANED
(ShadowState) so a shadowed binding is visible rather than silently dead —
this same resolved answer is what on-screen badges render from (never a
widget's own guess). Consumers: pistomp/input/dispatch.py (per-panel
resolve_local), pistomp/controller_manager.py (the PEDALBOARD layer),
modalapi/modhandler.py (the BLEND layer). See pistomp/input/README.md for
how the pieces fit together end to end."""

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Callable, TypedDict, Union
from typing import Callable, Union

from common.param_roles import ParamRole
from common.parameter import Symbol
Expand Down Expand Up @@ -141,16 +126,6 @@ class RawMidiCcEffect(Effect):
cc: int


class LongpressActionConfig(TypedDict, total=False):
"""Mapping-form `longpress:` config, exactly one key (enforced by the schema
in pistomp/config.py). Stays plain data — controller_manager builds the
Effect at bind time, when the footswitch's channel is resolved."""

midi_CC: int
preset: int | str # "UP" | "DOWN" | <index>
pedalboard: str # "UP" | "DOWN"


@dataclass(frozen=True)
class TapTempoEffect(Effect):
pass
Expand Down Expand Up @@ -193,6 +168,8 @@ class BindingDecl:

@dataclass
class ContextLayer:
"""Mappings of what physical controls do in a given context."""

ref: ContextRef
rows: dict[tuple[ControlClass, EventKind], list[BindingDecl]] = field(default_factory=dict)

Expand Down Expand Up @@ -226,6 +203,13 @@ def add(self, decl: BindingDecl) -> None:

@dataclass
class ContextStack:
"""
A stack of ContextLayers, bottom (PEDALBOARD) to top, representing
modals, panels, and other transient contexts. The stack is mutable; the layers
themselves are frozen once built. The topmost layer that has a row for a given
control is the one that wins.
"""

layers: list[ContextLayer] # bottom (PEDALBOARD) -> top

def layers_for(self, kind: ContextKind) -> list[ContextLayer]:
Expand Down
29 changes: 0 additions & 29 deletions common/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,50 +15,21 @@
# 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/>.

ACTION = 'action'
ADC_INPUT = 'adc_input'
ANALOG_CONTROLLERS = 'analog_controllers'
AUTOSYNC = 'autosync'
BANK = 'bank'
BUNDLE = 'bundle'
BYPASS = 'bypass'
CATEGORY = 'category'
CHANNEL = 'channel'
COLOR = 'color'
DEBOUNCE_INPUT = 'debounce_input'
DISABLE = 'disable'
DOWN = 'DOWN'
ENCODERS = 'encoders'
EXPRESSION = 'EXPRESSION'
FOOTSWITCHES = 'footswitches'
GPIO_INPUT = 'gpio_input'
GPIO_OUTPUT = 'gpio_output'
HARDWARE = 'hardware'
ID = 'id'
KNOB = 'KNOB'
LEDSTRIP_POSITION = 'ledstrip_position'
LEFT = 'LEFT'
LEFT_RIGHT = 'LEFT_RIGHT'
LONGPRESS = 'longpress'
MIDI = 'midi'
MIDI_CC = 'midi_CC'
MIDI_CHANNEL = 'midi_channel'
MIDI_PORT = 'midi_port'
NAME = 'name'
NAM_CAPTURE_GAIN = 'nam.capture_gain'
NAM_OUTPUT_VOL = 'nam.output_vol'
NAV = 'nav'
NONE = 'None'
PARAMETER = 'parameter'
PRESET = 'preset'
RIGHT = 'RIGHT'
TAP_TEMPO = 'tap_tempo'
TUNER_INPUT = 'tuner_input'
TUNER_MUTE = 'tuner_mute'
THRESHOLD = 'threshold'
TITLE = 'title'
TYPE = 'type'
UP = 'UP'
VERSION = 'version'
VOLUME = 'VOLUME'
WELCOME_SEEN = 'welcome_seen'
35 changes: 27 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,32 @@ Two config layers merge at pedalboard load time:

```
Default config (setup/config_templates/)
↓ loaded at startup, creates hardware
Per-pedalboard config ({bundle}/config.yml)
↓ overlaid by hardware.reinit(cfg)
↓ pistomp.config.resolve(default_cfg, bundle)
→ merge() overlays the two YAML documents
→ adapt() applies built-in defaults and converts to frozen model types
```

`reinit()` starts from a copy of the default config, applies defaults (footswitches,
encoders, MIDI), then overlays any pedalboard-specific overrides. Unspecified fields
keep their defaults. Analog controls call `initialize()` after the overlay — if
`autosync: true`, they read the ADC and emit their current position as a MIDI CC to
prevent state mismatch.
`Hardware.reinit(PedalboardConfig)` applies the resolved config: MIDI routing,
footswitch profiles, encoder type/longpress, external MIDI. It does not merge layers.
A control that the resolved config does not name falls back to its base-config
binding, so no state of the pedalboard before it can survive.
`ControllerManager.bind(current)` builds the controller–parameter subscriptions onto
the `Current`, which owns them; `current.close()` releases them.

### Config package layers

`pistomp/config/` has three layers. Each is a pure function from one type to another.

| Layer | File | Responsibility |
|---|---|---|
| File format | `schema_v1.py` | Parses and validates YAML. Knows only YAML structure. |
| Adapter | `adapt_v1.py` | Converts file-format types to model types. Applies built-in defaults. |
| Model | `model.py` | Frozen types that the application reads. Knows nothing about YAML. |

Only `adapt_v1.py` changes when the file format changes. The model is stable.

**Three-valued field semantics**: msgspec distinguishes `UNSET` (key absent from YAML) from `None` (explicit `null` in YAML) from a value. In `_merged_entries`, `None` means the pedalboard explicitly clears that section and the function returns `()`. `UNSET` means the pedalboard does not address that section and the function returns the base entries unchanged.

Config files:
- `/home/pistomp/data/config/default_config.yml` (written by firstboot from templates)
Expand Down Expand Up @@ -397,7 +413,10 @@ reads the ADC and sends current position on pedalboard load.
- `modalapi/plugin.py` — Plugin representation

**Config & State**
- `pistomp/config.py` — Config loading/validation
- `pistomp/config/schema_v1.py` — Parse and validate YAML; defines the file-format struct types
- `pistomp/config/adapt_v1.py` — Convert file-format types to model types; apply built-in defaults
- `pistomp/config/model.py` — Frozen domain types the application reads (`LongpressAction`, `FootswitchBinding`, etc.)
- `pistomp/config/__init__.py` — `resolve()` entry point; `load_cfg_from_file()`
- `pistomp/settings.py` — Persistent YAML key-value store

**Display**
Expand Down
5 changes: 2 additions & 3 deletions emulator/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ def __init__(self, id, midi_CC, midi_channel, refresh_callback):
# led_pin=None, pixel=None, gpio_input=None, adc_input=None — no GPIO paths taken
super().__init__(id, None, None, midi_CC, midi_channel, refresh_callback)
self.type = None
self.cfg = {}

def poll(self):
pass
Expand All @@ -111,10 +110,10 @@ def press(self):
class MockAnalogControl(analogmidicontrol.AnalogMidiControl):
"""Expression pedal / knob with no SPI/ADC. Value set externally."""

def __init__(self, midi_CC, midi_channel, control_type=None, id=None, cfg=None, midiout=None):
def __init__(self, midi_CC, midi_channel, control_type=None, id=None, midiout=None):
super().__init__(spi=None, adc_channel=None, tolerance=0,
midi_CC=midi_CC, midi_channel=midi_channel,
type=control_type, id=id, cfg=cfg)
type=control_type, id=id)
self.midiout = midiout
self.value = 64

Expand Down
41 changes: 8 additions & 33 deletions emulator/hardware_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
"""

import pistomp.hardware as hardware
import common.token as Token
import common.util as Util

from emulator.controls import MockFootswitch, MockAnalogControl, MockEncoder
from emulator.lcd_pygame import LcdPygame
Expand Down Expand Up @@ -60,43 +58,20 @@ def init_lcd(self):
)

def init_footswitches(self):
cfg = self.default_cfg.copy()
cfg_fs = cfg.get(Token.HARDWARE, {}).get(Token.FOOTSWITCHES)
if not cfg_fs:
return

midi_channel = self.get_real_midi_channel(cfg)
for f in cfg_fs:
if Util.DICT_GET(f, Token.DISABLE):
for b in self.config.footswitches:
if b.disable:
continue
id_ = Util.DICT_GET(f, Token.ID)
midi_cc = Util.DICT_GET(f, Token.MIDI_CC)
fs = MockFootswitch(id_, midi_cc, midi_channel, self.refresh_callback)
fs = MockFootswitch(b.id, b.midi_CC, b.midi_channel, self.refresh_callback)
self.footswitches.append(fs)
if midi_cc is not None:
key = "%d:%d" % (midi_channel, midi_cc)
self.controllers[key] = fs
self.register_controller(fs)

def init_analog_controls(self):
cfg = self.default_cfg.copy()
hw_cfg = cfg.get(Token.HARDWARE, {}) if cfg else {}
cfg_c = hw_cfg.get(Token.ANALOG_CONTROLLERS)
if not cfg_c:
return

midi_channel = self.get_real_midi_channel(cfg)
for c in cfg_c:
if Util.DICT_GET(c, Token.DISABLE):
for b in self.config.analog_controls:
if b.disable or b.midi_CC is None:
continue
id_ = Util.DICT_GET(c, Token.ID)
midi_cc = Util.DICT_GET(c, Token.MIDI_CC)
control_type = Util.DICT_GET(c, Token.TYPE)
if midi_cc is None:
continue
ctrl = MockAnalogControl(midi_cc, midi_channel, control_type, id_, c)
ctrl = MockAnalogControl(b.midi_CC, b.midi_channel, b.type, b.id)
self.analog_controls.append(ctrl)
key = "%d:%d" % (midi_channel, midi_cc)
self.controllers[key] = ctrl
self.register_controller(ctrl)

def init_relays(self):
self.relay = StubRelay()
Expand Down
4 changes: 2 additions & 2 deletions emulator/hardware_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
footswitches, one pot, one expression pedal, no relay interaction.
"""

import common.token as Token
from pistomp.controller import ControlType
from emulator.hardware_base import EmulatorHardwareBase
from emulator.controls import MockEncoder

Expand All @@ -39,7 +39,7 @@ def __init__(self, cfg, handler, midiout, refresh_callback):
self.init_analog_controls()

def init_encoders(self):
nav = MockEncoder(type=Token.NAV, id=0)
nav = MockEncoder(type=ControlType.NAV, id=0)
self.encoders.append(nav)
self.nav_encoder = nav
# tweak_encoders and volume_encoder stay None/[] — v2 has no extras
Expand Down
11 changes: 5 additions & 6 deletions emulator/hardware_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
one volume encoder, four footswitches, expression pedal.
"""

import common.token as Token
from pistomp.controller import ControlType

from emulator.hardware_base import EmulatorHardwareBase
from emulator.controls import MockEncoder, MockEncoderMidi
Expand All @@ -41,23 +41,22 @@ def __init__(self, cfg, handler, midiout, refresh_callback):
self.init_analog_controls()

def init_encoders(self):
nav = MockEncoder(type=Token.NAV, id=0)
nav = MockEncoder(type=ControlType.NAV, id=0)
self.encoders.append(nav)
self.nav_encoder = nav

cfg = self.default_cfg.copy()
self.create_encoders(cfg)
self.create_encoders(self.config)

def add_encoder(self, id, type, longpress_callback, midi_channel, midi_cc):
"""Called by Hardware.create_encoders() for each encoder in config."""
if type == Token.VOLUME:
if type == ControlType.VOLUME:
enc = MockEncoder(type=type, id=id)
self.volume_encoder = enc
else:
enc = MockEncoderMidi(
midi_channel=midi_channel,
midi_CC=midi_cc,
type=Token.KNOB,
type=ControlType.KNOB,
id=id)
if longpress_callback:
enc.set_longpress(longpress_callback)
Expand Down
Loading
Loading