Skip to content

Control surface from parameter data

Generate a whole interface from a plugin's parameter list: a fader per parameter, chunked into pages, each bound to both an OSC address and a MIDI CC. Nothing is placed by hand -- the data decides how many pages there are and what is on them.

"""Generate a paged MIDI and OSC control surface from a plugin's parameters.

Given a JSON list of parameters -- the kind a DAW or a plugin host will export
-- this builds a fader per parameter, laid out in pages, with each fader bound
both to an OSC address and to a MIDI CC. Nothing about the layout is written by
hand: the parameter list decides how many pages there are and what is on them.

    python tests/demos/control_surface.py tests/data/pro_c_2_fabfilter.json
    python tests/demos/control_surface.py params.json --prefix synth/bank1

The work is `py2tosc.surface`, which lives in the package rather than here
because `py2tosc build` needs it too. What this file shows is the shape of
using it: read a file, hand over the parameters, save what comes back. The
same thing from the command line is one line:

    py2tosc build tests/data/pro_c_2_fabfilter.json

Two things about real parameter data drive that module's design, and are worth
knowing before pointing it at your own file. Names are meant for people, so
they contain spaces and repeat, while an OSC address can have neither -- each
control gets a slug for its name and keeps the original text on its caption.
And a plugin's parameter *index* is a host identifier rather than a controller
number: this file's indices run to 182, well past the 127 a CC allows, so the
CC comes from the parameter's position unless an entry names one.

Compare `from_json.py`, which is the smallest version of this idea: one row of
faders, OSC only, using the eager `py2tosc.layout` functions.
"""

import argparse
import json
from pathlib import Path

from py2tosc import surface


def main(parameters_path: Path, output_path: Path, prefix: str = "") -> None:
    parameters = surface.read(json.loads(parameters_path.read_text()))
    doc = surface.build(parameters, prefix=prefix or parameters_path.stem)

    for issue in doc.validate():
        print(f"  {issue}")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    doc.save(output_path)
    pages = len(doc.find(type="PAGER").children)
    print(
        f"{len(parameters)} parameters -> {pages} pages, "
        f"{len(list(doc.walk()))} controls -> {output_path}"
    )


def parse_args() -> argparse.Namespace:
    """Read the command line, so a missing path is a message and not a crash."""
    parser = argparse.ArgumentParser(
        description=__doc__.split("\n\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "parameters", type=Path, help="a JSON list of plugin parameters"
    )
    parser.add_argument(
        "--prefix",
        default="",
        help="the OSC namespace; defaults to the parameter file's name",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=Path("build") / f"{Path(__file__).stem}.tosc",
        help="where to write the layout (default: %(default)s)",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    main(args.parameters, args.output, args.prefix)
$ python tests/demos/control_surface.py tests/data/pro_c_2_fabfilter.json
54 parameters -> 5 pages, 168 controls -> build/control_surface.tosc

$ python tests/demos/control_surface.py params.json synth/bank1 -o surface.tosc

The optional third argument is the OSC namespace every address hangs off, and it may be more than one segment deep. It defaults to the file's name, which is convenient and fragile: renaming the data silently moves every address, so anything that has to stay put should say so.

This is the larger sibling of Faders from JSON, which is the same idea at its smallest: one row, OSC only, using the eager layout functions. Here the arrangement is described with the combinators in py2tosc.ui instead, so the pages nest inside the pager as ordinary composition and nothing is sized until resolve runs.

What real data forces

Two details account for most of the code, and both come from the parameter list rather than from TouchOSC.

Names are for people, addresses are not. Parameter names contain spaces, and OSC addresses cannot -- the specification also reserves #, *, ,, ?, [, ], { and }. Real lists repeat, too: this one has three parameters called Bypass and two called Internal, which would collide into one address. So each control takes a slug for its name, which is what the address is built from, and keeps the original text on its caption.

A parameter index is not a controller number. It identifies the parameter to the host, and this file's indices run to 182 -- past the 127 a MIDI CC allows. The CC comes from the parameter's position in the list instead. A plugin exposing more than 128 parameters still gets an OSC binding on every fader; the ones past the end simply go out over OSC alone.