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
15 changes: 14 additions & 1 deletion common/param_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,17 @@ def is_bypassed(self) -> bool: ...

def set_bypass(self, bypass: bool) -> None: ...

pedalboard_snapshot: "dict[Symbol, float]"
pedalboard_snapshot: "dict[Symbol, float]"


@runtime_checkable
class ParamSink(Protocol):
"""The outbound dual of `ParamSource` — where a `Parameter.commit` goes next.

`reconcile` adopts the single writer's value and sends nothing; `commit`
publishes a finished local edit through this sink. `publish` returns whether
the send left — False lets commit roll the value back. A param with no sink
is display-only.
"""

def publish(self, param: "Parameter") -> bool: ...
77 changes: 67 additions & 10 deletions common/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@

from collections.abc import Callable
from enum import Enum
from typing import NewType, NotRequired, TypedDict
from typing import NewType, NotRequired, TypedDict, TYPE_CHECKING
import json
import common.util as util

if TYPE_CHECKING:
from common.param_source import ParamSink

# strings as they appear in TTL files
TTL_ENUMERATION = 'enumeration'
TTL_INTEGER = 'integer'
Expand Down Expand Up @@ -114,12 +117,18 @@ def __init__(self, plugin_info: PortInfo, value: float, binding: str | None, ins
# fallbacks only serve the params we synthesise (bypass, volume, VU).
self.default: float = float(ranges.get("default", self.minimum))

# Reactive value: a property setter that notifies observers. _observers
# must exist before the first assignment below, or the write fires into
# a missing list.
# Reactive value. Writes go through reconcile/preview/commit, never a raw
# setter — the verb names the provenance (see those methods). _confirmed
# is the last value the single writer (mod-ui) echoed back; the gap from
# _value is `pending`.
self._observers: list[Callable[[Parameter], None]] = []
self._value: float = 0.0
self.value = float(value)
self._settled_observers: list[Callable[[Parameter], None]] = []
self._value: float = float(value)
self._confirmed: float = float(value)
# Where local edits go upstream. None = display-only: reconciled from
# mod-ui, never sent back. Attached at bind time by whoever owns the
# remote channel.
self.sink: ParamSink | None = None
self.binding: str | None = binding
self.instance_id: str | None = instance_id.lstrip("/") if instance_id else instance_id
self.type = Type.DEFAULT
Expand Down Expand Up @@ -154,14 +163,49 @@ def __init__(self, plugin_info: PortInfo, value: float, binding: str | None, ins
def value(self) -> float:
return self._value

@value.setter
def value(self, v: float) -> None:
if v == self._value:
@property
def pending(self) -> bool:
"""A committed edit the single writer hasn't echoed back yet."""
return self._value != self._confirmed

def reconcile(self, value: float) -> None:
"""Adopt the single writer's value: repaint, mark confirmed, settle,
publish nothing. The settle fires even at an unchanged value — a mod-ui
echo confirming what we optimistically previewed still has to refresh a
keycap the preview left alone."""
self._confirmed = value
self._set(value)
self._notify_settled()

def preview(self, value: float) -> None:
"""An optimistic local move not yet committed — a knob mid-turn whose CC
emit is the real send, a footswitch keycap already toggled by the press.
Repaints live observers; does not settle; publishes nothing."""
self._set(value)

def commit(self, value: float) -> None:
"""A finished local edit: repaint, publish through the sink, then settle.
Rolls back to the last confirmed value (and does not settle) if the send
never leaves — otherwise the LCD would show a number mod-ui never took.
Publishing is unconditional; preview and reconcile share mechanics, so
there is nothing to diff against here."""
self._set(value)
if self.sink is not None and not self.sink.publish(self):
self._set(self._confirmed)
return
self._notify_settled()

def _set(self, value: float) -> None:
if value == self._value:
return
self._value = v
self._value = value
for observe in self._observers:
observe(self)

def _notify_settled(self) -> None:
for observe in self._settled_observers:
observe(self)

def subscribe(self, cb: Callable[[Parameter], None]) -> Callable[[], None]:
"""Register *cb* to fire on every changed-value write. Returns its own
unsubscriber. An unchanged write (v == current) does not notify."""
Expand All @@ -173,6 +217,19 @@ def _unsub() -> None:
pass
return _unsub

def subscribe_settled(self, cb: Callable[[Parameter], None]) -> Callable[[], None]:
"""Register *cb* for settled values only — a reconcile or a committed
edit, never a bare preview — fired unconditionally, even when the value
is unchanged. Stateful presentation (a footswitch keycap) uses this so it
tracks confirmed state, not a mid-scrub. Returns its own unsubscriber."""
self._settled_observers.append(cb)
def _unsub() -> None:
try:
self._settled_observers.remove(cb)
except ValueError:
pass
return _unsub

def get_enum_value_list(self) -> list[tuple[str, float]]:
return [(v["label"], v["value"]) for v in self.enum_values]

Expand Down
Loading
Loading