Live coding#

When libYSE is built with YSE_ENABLE_PYTHON=ON it embeds a CPython interpreter and exposes a small Python module, yse, for live coding — driving the running engine from scripts you can re-evaluate while sound is playing. A composer reasons in terms of names on a bus and callbacks tied to those names; sounds, channels, patchers, and freeform script-to-script signals all share that one model.

This page is a worked tour of the surface. The authoritative specification — address grammar, value types, threading and hot-reload semantics — is docs/design/live_coding_dsl.md.

Running a script#

Scripts are submitted from the host through the C API and executed asynchronously on a dedicated script thread. Errors arrive on a callback you install once:

#include "yse_c/yse_python.h"

void on_error(const char *traceback, void *user) {
    fprintf(stderr, "%s\n", traceback);
}

yse_python_set_script_error_callback(on_error, NULL);
yse_python_run_script("yse.send('patcher.lead.cutoff', 800)\n");

The yse module is already bound into the script’s namespace — scripts call yse.send(...) directly, no import needed.

The primitives#

yse.send(name, value)

Publish value to address name on the global bus. Accepts int, float, str, or a list of numbers; anything else (including bool) raises TypeError, and an empty name raises ValueError. Publishing is fire-and-forget.

yse.send("sound.kick.volume", 0.7)
yse.send("patcher.lead.cutoff", 800)
yse.send("position", [0.0, 1.5, -2.0])
yse.on(name, callback)

Subscribe callback to name; returns an integer handle. The callback fires on the script thread, once per tick, with the published value.

def on_trigger(v):
    yse.send("kick.gate", v)

h = yse.on("section_a.trigger", on_trigger)
yse.unsubscribe(handle)

Drop a subscription. Idempotent — an already-released or unknown handle is a no-op.

yse.latch(name)

Sugar over on that caches the most recent value. latch.value is None until the first publish; the subscription is released when the latch is garbage-collected or you call latch.unsubscribe().

cutoff = yse.latch("patcher.lead.cutoff")
# ... a tick or two after a publish ...
if cutoff.value is not None and cutoff.value > 1000:
    yse.send("alarm", 1)
yse.schedule(ticks, fn, *args, **kwargs)

Run fn ticks engine update ticks from now, on the script thread. ticks=0 fires on the next update (never the current one). Returns a handle; cancellation is via yse.cancel_all().

def kick():
    yse.send("kick.trigger", 1)

yse.schedule(4, kick)   # 4 ticks from now
yse.schedule(8, kick)   # 8 ticks from now
yse.tick

A read-only attribute: the monotonic engine tick counter, incremented once per system::update() and reset to 0 on System::init.

print(f"now at tick {yse.tick}")
yse.cancel_all()

Deregister every subscription and pending schedule from a strictly older evaluation than the current one. Registrations made in the same script that calls cancel_all survive, so the editor idiom is to prefix each re-evaluation with it:

yse.cancel_all()   # wipe the previous evaluation
# ... the rest of the script ...
yse.fresh_scope()

A context manager that calls yse.cancel_all() on entry — sugar for composers who prefer to scope a fresh start to a block rather than prefix the whole script. Registrations made inside the with block belong to the current evaluation and survive; everything from older evaluations is torn down before the body runs.

with yse.fresh_scope():
    yse.on("trigger", on_trigger)
    yse.schedule(4, kick)

This is exactly equivalent to opening the block’s body with a bare yse.cancel_all().

Hot-reload pattern#

Because libYSE never auto-clears state between evaluations, layering is the default: re-evaluating a script adds its subscriptions and schedules on top of what earlier evaluations installed. To get replacement behaviour instead — the usual expectation when iterating on a patch — start each evaluation from a clean slate. Either prefix the script:

yse.cancel_all()
# ... the patch ...

or wrap it in yse.fresh_scope(). The Phi editor’s “Evaluate” button can be configured to inject the yse.cancel_all() prefix automatically; composers who want to layer can omit it per evaluation.

Addressing#

Four prefixes are owned by the engine and let scripts reach engine objects by the name assigned through the C++/C API:

Address

Produced / consumed by

sound.<name>.<prop>

YSE::sound properties

channel.<name>.<prop>

YSE::channel properties

patcher.<name>.<slot>

patcher .s / .r slots

synth.<name>.<event>

YSE::synth note / controller events

Every other name is freeform and exists purely for script-to-script communication. A dotted path such as "section_a.gate" is just a human-readable convention — the bus treats the whole string as opaque. A freeform "lead.cutoff" does not reach a patcher named lead; that is "patcher.lead.cutoff".

Patcher names#

A patcher’s name, set with patcher::name() or yse_patcher_set_name, scopes everything name-addressed inside it. A .s cutoff / .r cutoff in a patcher named lead speaks on patcher.lead.cutoff, and the send messages of .forward, .bag and .table publish into the same scope. The patcher’s shared stores (.array, .dict, .coll, .value) are keyed the same way, although they are not bus traffic a script can subscribe to. Renaming a patcher moves all of it to the new name.

  • Same name, shared scope. Two patchers may share a name on purpose, and that is how patchers talk to each other directly: a .s in one reaches a .r in the other, and their same-named stores are one store. Patchers with different names stay isolated; a script can bridge them with yse.on("patcher.a.x", lambda v: yse.send("patcher.b.x", v)).

  • Unnamed patchers are auto-named patcher_<N>, so two anonymous patchers never share a scope by accident. The number comes from a process-wide counter and changes between runs, and it is never saved into a patch, so it is not an address to script against. Ask the host to name any patcher a script needs to reach. Setting an empty name restores the auto-name.

  • Length limits. A patcher name is at most 55 characters and a slot name at most 63. Longer ones are logged and refused.

A worked example#

A self-rescheduling sweep that reads a latched value back and nudges it each tick:

yse.cancel_all()

cutoff = yse.latch("patcher.lead.cutoff")

def sweep():
    if cutoff.value is None:
        yse.schedule(1, sweep)   # not ready yet, try next tick
        return
    yse.send("patcher.lead.cutoff", cutoff.value * 1.01)
    yse.schedule(1, sweep)

sweep()

Threading in one sentence#

Every callback runs on the script thread, in batch, once per system::update() tick — a value published on the audio thread at tick t is delivered to a Python handler at tick t+1. Scripts never run on the audio thread, and the surface offers no way to make them.