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
valueto addressnameon the global bus. Acceptsint,float,str, or alistof numbers; anything else (includingbool) raisesTypeError, and an empty name raisesValueError. 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
callbacktoname; 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
onthat caches the most recent value.latch.valueisNoneuntil the first publish; the subscription is released when the latch is garbage-collected or you calllatch.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
fnticksengine update ticks from now, on the script thread.ticks=0fires on the next update (never the current one). Returns a handle; cancellation is viayse.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.tickA read-only attribute: the monotonic engine tick counter, incremented once per
system::update()and reset to 0 onSystem::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_allsurvive, 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 thewithblock 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 |
|---|---|
|
|
|
|
|
patcher |
|
|
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
.sin one reaches a.rin the other, and their same-named stores are one store. Patchers with different names stay isolated; a script can bridge them withyse.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.