Skip to content
Merged
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: 1 addition & 1 deletion common/param_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
The role drives ``Selectable.symbol_for(role)`` — e.g. an EQ band selection
returns a different symbol per role (gain/freq/Q), a compressor arc returns
the same symbol regardless. Step math is unified through ``ParameterSteps``
(``common/parameter_steps.py``); roles no longer carry their own step sizes.
(``common/parameter_editing.py``); roles no longer carry their own step sizes.
"""

from enum import auto, Enum
Expand Down
20 changes: 8 additions & 12 deletions common/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ def __init__(
elif TTL_TOGGLED in properties:
self.type = Type.TOGGLED

@property
def declared_extents(self) -> tuple[float, float]:
return self.declared_minimum, self.declared_maximum

@property
def value(self) -> float:
return self._value
Expand Down Expand Up @@ -229,23 +233,15 @@ def _notify_committed(self) -> None:
observe(self)

def set_binding_range(self, binding_range: tuple[float, float]) -> None:
"""Set the effective extents from a MIDI-CC (sub-)range."""
"""Set the physical-control extents without changing the port value."""
if (self.minimum, self.maximum) != binding_range:
self._reclamp(binding_range)
self.minimum, self.maximum = binding_range

def clear_binding_range(self) -> None:
"""Restore effective extents to the plugin's declared LV2 range."""
"""Restore physical-control extents to the declared LV2 range."""
declared = (self.declared_minimum, self.declared_maximum)
if (self.minimum, self.maximum) != declared:
self._reclamp(declared)

def _reclamp(self, extents: tuple[float, float]) -> None:
self.minimum, self.maximum = extents
self._value = max(self.minimum, min(self._value, self.maximum))
self._confirmed = max(self.minimum, min(self._confirmed, self.maximum))
for observe in self._observers:
observe(self)
self._notify_committed()
self.minimum, self.maximum = declared

def subscribe(self, cb: Callable[[Parameter], None]) -> Callable[[], None]:
"""Register *cb* to fire on every changed-value write. Returns its own
Expand Down
71 changes: 43 additions & 28 deletions common/parameter_steps.py → common/parameter_editing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,16 @@
# 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/>.

"""Quantized step grid for encoder-driven parameter edits.

Shared by EncoderController (v3 tweak encoders) and Parameterdialog (the nav
encoder, which is the only encoder on v2), so that one detent moves a parameter
by the same amount whichever control you turn.
"""
"""Shared models and step math for encoder-driven parameter edits."""

import bisect
from collections.abc import Callable
from dataclasses import dataclass
from typing import List

import common.util as util
from common.parameter import Parameter, Type
from common.parameter import Parameter, Symbol, Type


# Steps for a continuous parameter. Matches the 0-127 MIDI CC range, so a full
# sweep of a CC-bound encoder emits every distinct MIDI value.
Expand All @@ -40,24 +38,40 @@
# notch of a stepped range stays reachable.
FULL_SWEEP_DETENTS = 32
# The raw multiplier at which a spin counts as "full speed" — the historic
# MAX_MULTIPLIER. At or above this, the per-parameter cap binds; below it,
# the multiplier is interpolated linearly between 1 step/detent and the cap.
# MAX_MULTIPLIER. At or above this, the per-parameter cap binds; below it, the
# multiplier is interpolated linearly between 1 step/detent and the cap.
REFERENCE_FAST_MULTIPLIER = 4.0


def resolution(parameter: Parameter | None) -> int:
"""Detents needed to cross the parameter's range.
EditCommit = Callable[[Parameter, float], None]

An unbound encoder (parameter is None) is a free-running CC: give it the
full 128-value sweep. A bound discrete parameter gets one detent per
distinct value — extra steps would emit no additional MIDI values, since
the CC is derived from the parameter.
"""

@dataclass(frozen=True)
class EditContext:
parameter: Parameter
commit: EditCommit
grid_range: tuple[float, float] | None = None

@property
def cache_key(self) -> tuple[str | None, Symbol]:
return self.parameter.instance_id, self.parameter.symbol

@property
def extents(self) -> tuple[float, float]:
if self.grid_range is not None:
return self.grid_range
return self.parameter.declared_extents


def resolution(parameter: Parameter | None, minimum: float | None = None, maximum: float | None = None) -> int:
"""Detents needed to cross the selected parameter range."""
if parameter is None:
return CONTINUOUS_STEPS
lo = parameter.minimum if minimum is None else minimum
hi = parameter.maximum if maximum is None else maximum
match parameter.type:
case Type.INTEGER:
return int(parameter.maximum - parameter.minimum) + 1
return int(hi - lo) + 1
case Type.ENUMERATION:
return len(parameter.get_enum_value_list())
case Type.TOGGLED:
Expand All @@ -66,18 +80,18 @@ def resolution(parameter: Parameter | None) -> int:
return CONTINUOUS_STEPS


def effective_multiplier(multiplier: float, parameter: Parameter | None) -> float:
"""The multiplier actually applied to a parameter edit.

def effective_multiplier(
multiplier: float,
parameter: Parameter | None,
minimum: float | None = None,
maximum: float | None = None,
) -> float:
"""
Maps the encoder's raw speed multiplier onto the parameter's step range
so a full-speed spin covers the same fraction of any grid in roughly the
same number of detents. At ``multiplier == 1`` (slow) every detent moves
one step — every notch of a stepped range is reachable. At
``multiplier >= REFERENCE_FAST_MULTIPLIER`` (full speed) each detent moves
``resolution / FULL_SWEEP_DETENTS`` steps, so the whole range sweeps in
~32 detents regardless of grid size.
same number of detents.
"""
res = resolution(parameter)
res = resolution(parameter, minimum, maximum)
cap = res / FULL_SWEEP_DETENTS
if cap <= 1.0:
return multiplier # small grid: precision floor, no scaling
Expand Down Expand Up @@ -106,8 +120,9 @@ def __init__(self, minimum: float, maximum: float, logarithmic: bool, num_steps:
]

@classmethod
def for_parameter(cls, parameter: Parameter) -> "ParameterSteps":
steps = cls(parameter.minimum, parameter.maximum, parameter.is_logarithmic, resolution(parameter))
def for_parameter(cls, parameter: Parameter, extents: tuple[float, float] | None = None) -> "ParameterSteps":
minimum, maximum = extents or (parameter.minimum, parameter.maximum)
steps = cls(minimum, maximum, parameter.is_logarithmic, resolution(parameter, minimum, maximum))
steps.set_value(parameter.value)
return steps

Expand Down
35 changes: 27 additions & 8 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
)
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol
from common.param_source import ParamSink
from common.parameter_steps import ParameterSteps, effective_multiplier
from common.parameter_editing import EditContext, ParameterSteps, effective_multiplier
from modalapi.plugin import Plugin
from blend.input_controller import InputController
import modalapi.pedalboard as Pedalboard
Expand Down Expand Up @@ -395,7 +395,8 @@ def _handle_encoder(self, event: EncoderEvent) -> bool:
if c.type == ControlType.VOLUME and c.parameter is not None:
new_value = ParameterSteps.for_parameter(c.parameter).move(delta)
c.parameter.commit(new_value, self._sink_for(c.parameter))
d = self.lcd.draw_audio_parameter_dialog(c.parameter, self.audio_parameter_commit)
context = EditContext(c.parameter, lambda p, value: self.audio_parameter_commit(p.symbol, value))
d = self.lcd.open_audio_parameter_dialog(context)
if d is not None:
d.update_value(c.parameter.value)
return True
Expand All @@ -412,7 +413,12 @@ def _handle_encoder(self, event: EncoderEvent) -> bool:
# encoder, the WebSocket for :bpm) owns the send.
new_value = ParameterSteps.for_parameter(c.parameter).move(delta)
c.parameter.commit(new_value, self._sink_for(c.parameter))
self.lcd.display_parameter_value(c.parameter, c.parameter.value)
context = EditContext(
c.parameter,
self.parameter_value_commit,
grid_range=(c.parameter.minimum, c.parameter.maximum),
)
self.lcd.display_parameter_value(context, c.parameter.value)
return True

# Unbound: no sink, no row. This fallback CC is the only way mod-ui sees
Expand Down Expand Up @@ -585,13 +591,15 @@ def lcd(self):
return self._lcd

def open_parameter_dialog(self, parameter: Parameter) -> None:
self.lcd.draw_parameter_dialog(parameter)
context = EditContext(parameter, self.parameter_ui_value_commit)
self.lcd.open_parameter_editor(context)

def open_parameter_submenu(self, plugin: Plugin, rows: tuple[tuple[str, Symbol], ...], title: str) -> None:
self.lcd.draw_symbol_menu(plugin, rows, title)

def open_audio_parameter_dialog(self, parameter: Parameter, commit_callback: Callable[[str, float], None]) -> None:
self.lcd.draw_audio_parameter_dialog(parameter, commit_callback)
context = EditContext(parameter, lambda p, value: commit_callback(p.symbol, value))
self.lcd.open_audio_parameter_dialog(context)

def poll_controls(self):
if self.hardware:
Expand Down Expand Up @@ -1262,6 +1270,15 @@ def _sink_for(self, param: Parameter) -> ParamSink | None:
return functools.partial(self._publish_switch_cc, control)
return self._publish_plugin_param

def _ui_sink_for(self, param: Parameter) -> ParamSink | None:
if param.instance_id == Pedalboard.TRANSPORT_INSTANCE_ID:
return self._sink_for(param)
if param.instance_id is None:
return self._publish_audio
if param.instance_id == ExternalMidi.EXTERNAL_INSTANCE_ID:
return self._sink_for(param)
return self._publish_plugin_param

def _publish_bpm(self, param: Parameter) -> bool:
"""Publish the BPM to the transport."""
return self.set_mod_tap_tempo(param.value)
Expand Down Expand Up @@ -1469,9 +1486,10 @@ def effective_table(self) -> ContextStack:
#
# Parameter Stuff
#
def parameter_ui_value_commit(self, param: Parameter, value: float) -> None:
param.commit(value, self._ui_sink_for(param))

def parameter_value_commit(self, param: Parameter, value: float) -> None:
# The sink owns the route (WebSocket param_set, transport-bpm, external
# CC, local ALSA write); commit repaints, publishes through it, settles.
param.commit(value, self._sink_for(param))

@property
Expand Down Expand Up @@ -1834,7 +1852,8 @@ def _create_audio_parameter(self, name, symbol, min_val, max_val):
def audio_parameter_change(self, direction: int | None, parameter, commit_callback):
if parameter is None:
return
d = self.lcd.draw_audio_parameter_dialog(parameter, commit_callback)
context = EditContext(parameter, lambda p, value: commit_callback(p.symbol, value))
d = self.lcd.open_audio_parameter_dialog(context)
if d is None or direction is None:
return
step = (parameter.maximum - parameter.minimum) / 127.0
Expand Down
6 changes: 4 additions & 2 deletions pistomp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ def parameter_value_commit(self, param: "Parameter", value: float) -> None:
Reverts on screen if the send never left."""
raise NotImplementedError()

def parameter_ui_value_commit(self, param: "Parameter", value: float) -> None:
"""Commit a plugin-panel or NAV edit through its UI transport."""
raise NotImplementedError()

def toggle_plugin_bypass(self, plugin: "Plugin") -> None:
"""Flip a plugin's bypass the one way the whole UI flips it: through the
footswitch press path when the plugin has one (so mod-host's echo
Expand Down Expand Up @@ -265,5 +269,3 @@ def _rebind_pedalboard(self) -> None:
"""Build the board's associations and rows again. A handler that owns an
activation must override this."""
raise NotImplementedError()


6 changes: 3 additions & 3 deletions pistomp/input/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ open for the same symbol(s):
symbols at once) opens a `Menu` submenu over `menu_rows()`; a plain
`Selectable` opens a single `Parameterdialog` for
`symbol_for(ParamRole.GENERIC)`. Both go through
`Handler.open_parameter_dialog`/`open_parameter_submenu`
(`pistomp/handler.py` → `Lcd320x240.draw_parameter_dialog`/
`draw_symbol_menu`).
``Handler.open_parameter_dialog``/``open_parameter_submenu``
(``pistomp/handler.py`` → ``Lcd320x240.open_parameter_editor``/
``draw_symbol_menu``).
* Both take an `on_change` callback wired to `self.apply_state(self.
snapshot_state())` — the same resync call the mod-ui `ParamSetMessage` echo
handler uses. Needed because the generic dialog commits straight to
Expand Down
Loading