Skip to content

Control surfaces

Building a layout from a list of parameters. This is what the py2tosc build subcommand calls, and what tests/demos/control_surface.py demonstrates.

read

read(payload: Any) -> list[Parameter]

Read a parameter list, in either of the shapes worth accepting.

A list of names is the short form. A list of objects is the long one, and only name is required:

["Threshold", "Ratio"]
[{"name": "Threshold", "cc": 20, "channel": 1}, {"name": "Ratio"}]

A plugin host exports an index alongside each name. It is deliberately ignored: an index identifies the parameter to the host and is not a controller number, and a real one runs well past the 127 a CC allows. Say cc if you mean a controller number.

Parameters:

Name Type Description Default
payload Any

The parsed JSON.

required

Returns:

Type Description
list[Parameter]

The parameters, in order.

Raises:

Type Description
TypeError

If the payload is not a list, or an entry is neither a string nor an object.

ValueError

If an entry is an object with no name.

Source code in src/py2tosc/surface.py
def read(payload: Any) -> list[Parameter]:
    """Read a parameter list, in either of the shapes worth accepting.

    A list of names is the short form. A list of objects is the long one, and
    only `name` is required:

        ["Threshold", "Ratio"]
        [{"name": "Threshold", "cc": 20, "channel": 1}, {"name": "Ratio"}]

    A plugin host exports an `index` alongside each name. It is deliberately
    ignored: an index identifies the parameter to the host and is not a
    controller number, and a real one runs well past the 127 a CC allows. Say
    `cc` if you mean a controller number.

    Args:
        payload: The parsed JSON.

    Returns:
        The parameters, in order.

    Raises:
        TypeError: If the payload is not a list, or an entry is neither a
            string nor an object.
        ValueError: If an entry is an object with no name.
    """
    if not isinstance(payload, list):
        raise TypeError(
            f"expected a list of parameters, found {type(payload).__name__}"
        )

    parameters = []
    for position, entry in enumerate(payload):
        if isinstance(entry, str):
            parameters.append(Parameter(entry))
            continue
        if not isinstance(entry, dict):
            raise TypeError(
                f"parameter {position} is a {type(entry).__name__}; "
                f"expected a name or an object with one"
            )
        name = entry.get("name")
        if not isinstance(name, str) or not name:
            raise ValueError(f"parameter {position} has no name")
        parameters.append(
            Parameter(
                name=name,
                cc=entry.get("cc"),
                channel=int(entry.get("channel", 0)),
            )
        )
    return parameters

Parameter dataclass

One thing to put on the surface.

Attributes:

Name Type Description
name str

What it is called. Shown as the caption, and slugged for the control's name, which is what the OSC address is built from.

cc int | None

The MIDI control change number. None takes it from the parameter's position in the list, which is almost always what you want -- see read.

channel int

The MIDI channel, 0-15.

Source code in src/py2tosc/surface.py
@dataclass
class Parameter:
    """One thing to put on the surface.

    Attributes:
        name: What it is called. Shown as the caption, and slugged for the
            control's name, which is what the OSC address is built from.
        cc: The MIDI control change number. `None` takes it from the
            parameter's position in the list, which is almost always what you
            want -- see `read`.
        channel: The MIDI channel, 0-15.
    """

    name: str
    cc: int | None = None
    channel: int = 0

build

build(
    parameters: Sequence[Parameter],
    *,
    prefix: str = "surface",
    midi: bool = True,
    osc: bool = True,
    columns: int = COLUMNS,
    rows: int = ROWS,
    frame: tuple[int, int, int, int] = (0, 0, *SIZE),
) -> Document

Lay parameters out across as many pages as they need.

Parameters:

Name Type Description Default
parameters Sequence[Parameter]

What to put on it, in order.

required
prefix str

The OSC namespace every address hangs off.

'surface'
midi bool

Whether to bind each control to a MIDI CC.

True
osc bool

Whether to give each control an OSC address.

True
columns int

Controls across each page.

COLUMNS
rows int

Controls down each page.

ROWS
frame tuple[int, int, int, int]

The design canvas, as (x, y, width, height). Defaults to SIZE; TouchOSC scales whatever you give it to the screen, so what matters is the aspect ratio and the room the controls get.

(0, 0, *SIZE)

Returns:

Type Description
Document

The document, resolved and ready to save.

Raises:

Type Description
ValueError

If there are no parameters, or neither binding is wanted.

Source code in src/py2tosc/surface.py
def build(
    parameters: Sequence[Parameter],
    *,
    prefix: str = "surface",
    midi: bool = True,
    osc: bool = True,
    columns: int = COLUMNS,
    rows: int = ROWS,
    frame: tuple[int, int, int, int] = (0, 0, *SIZE),
) -> Document:
    """Lay parameters out across as many pages as they need.

    Args:
        parameters: What to put on it, in order.
        prefix: The OSC namespace every address hangs off.
        midi: Whether to bind each control to a MIDI CC.
        osc: Whether to give each control an OSC address.
        columns: Controls across each page.
        rows: Controls down each page.
        frame: The design canvas, as `(x, y, width, height)`. Defaults to
            `SIZE`; TouchOSC scales whatever you give it to the screen, so
            what matters is the aspect ratio and the room the controls get.

    Returns:
        The document, resolved and ready to save.

    Raises:
        ValueError: If there are no parameters, or neither binding is wanted.
    """
    if not parameters:
        raise ValueError("a surface needs at least one parameter")
    if not midi and not osc:
        raise ValueError("a surface with neither MIDI nor OSC would do nothing")

    address = namespace(prefix) if osc else ""
    names = unique([slug(p.name) for p in parameters])
    title = address.rsplit("/", 1)[-1] if address else slug(prefix)

    pager = ui.pager(
        *_pages(parameters, names, address, midi, columns, rows), name=title
    )
    # The pager cannot be the root: TouchOSC treats the root node as the canvas
    # and gives it none of its type's behaviour, so a PAGER there would draw a
    # tab bar and then stack every page instead of paging between them.
    doc = Document(root=ui.stack(pager, name=title, frame=frame)).resolve()
    _fit_text(doc)
    return doc

slug

slug(text: str) -> str

An OSC-safe name, since an address cannot contain a space.

OSC also reserves #, *, ,, ?, [, ], { and }, so anything that is not alphanumeric is dropped rather than substituted.

Source code in src/py2tosc/surface.py
def slug(text: str) -> str:
    """An OSC-safe name, since an address cannot contain a space.

    OSC also reserves `#`, `*`, `,`, `?`, `[`, `]`, `{` and `}`, so anything
    that is not alphanumeric is dropped rather than substituted.
    """
    words: list[str] = re.findall(r"[A-Za-z0-9]+", text)
    if not words:
        return "parameter"
    return words[0].lower() + "".join(word.capitalize() for word in words[1:])

namespace

namespace(text: str) -> str

An OSC-safe address prefix, which may be more than one segment deep.

Each segment is slugged on its own, so Synth/Bank 1 survives as synth/bank1 rather than collapsing into a single name.

Source code in src/py2tosc/surface.py
def namespace(text: str) -> str:
    """An OSC-safe address prefix, which may be more than one segment deep.

    Each segment is slugged on its own, so `Synth/Bank 1` survives as
    `synth/bank1` rather than collapsing into a single name.
    """
    return "/".join(slug(part) for part in text.split("/") if part.strip())