Threading Model¶
nanosynth uses multiple threads internally. This page documents which threads exist, what they do, and what is safe to call from where.
Thread overview¶
| Thread | Created by | Purpose | Lifetime |
|---|---|---|---|
| Main thread | User | SynthDef construction, Server API calls, interactive use | Entire process |
| Engine thread | EmbeddedProcessProtocol.boot() |
Runs the embedded scsynth/supernova audio engine | Boot to quit (daemon) |
| Reply callback | Engine internals | Dispatches OSC replies from the engine back to Python | While engine is running |
| Clock thread | Clock.start() |
Drives pattern scheduling via time.monotonic() |
Start to stop (daemon) |
| Timer threads | Player._tick() |
One-shot threads that release synth gates after sustain duration | Short-lived (daemon) |
| MIDI thread | MidiIn() |
RtMidi's internal polling thread for MIDI input | While port is open |
All background threads are daemon threads -- they do not prevent process exit.
Engine lifecycle¶
EmbeddedProcessProtocol manages the audio engine on a background thread. The lifecycle is a state machine:
OFFLINE --> BOOTING --> ONLINE --> QUITTING --> OFFLINE
Only one engine instance (World) can exist per process. This is enforced by a class-level threading.Lock (_active_world_lock) that is held for the entire lifetime of the engine:
from nanosynth import Server, Options
# boot() acquires the lock, starts the engine, launches the daemon thread
with Server(Options(verbosity=0)) as server:
# engine is ONLINE, daemon thread is blocked in world_wait_for_quit()
server.synth("sine", frequency=440.0)
# quit() signals shutdown, joins daemon thread, releases the lock
The daemon thread blocks in a C++ call (world_wait_for_quit) until the engine shuts down. When shutdown occurs -- either from quit() or an engine panic -- the thread cleans up, transitions to OFFLINE, and signals an exit_future that callers can await.
EmbeddedSupernovaProtocol follows the same pattern but runs supernova's event loop on its daemon thread instead.
Reply dispatch¶
When the engine sends an OSC response (e.g., /done, /n_go, /b_info), it invokes a reply callback from its internal UDP processing thread. Server._dispatch_reply() handles this:
-
Decodes the raw OSC bytes into an
OscMessage -
Acquires
_reply_lockto snapshot the handler and waiter lists -
Releases the lock
-
Invokes all persistent handlers registered via
server.on(address, callback) -
Signals all one-shot waiters registered via
server.wait_for_reply(address)
Handler exceptions are caught and logged individually -- one failing handler does not prevent others from running.
# Persistent handler -- runs on every /n_go message
def on_node_created(msg):
print(f"Node {msg.contents[0]} created")
server.on("/n_go", on_node_created)
# One-shot waiter -- blocks until a specific reply arrives
reply = server.send_msg_sync("/b_query", 0, reply_address="/b_info")
Handlers run on the reply callback thread, not the main thread. Keep them fast. If a handler needs to interact with shared state, use explicit locking.
Thread-local SynthDef builders¶
SynthDefBuilder uses a threading.local() stack to isolate concurrent graph construction. Each thread maintains its own stack of active builders:
import threading
from nanosynth import SynthDefBuilder, SinOsc, Out
def build_in_thread(freq):
with SynthDefBuilder(frequency=freq) as b:
# This builder is only visible to this thread.
# Other threads have their own independent stacks.
Out.ar(bus=0, source=SinOsc.ar(frequency=b["frequency"]))
return b.build(name=f"sine_{freq}")
threads = []
for freq in [220, 440, 880]:
t = threading.Thread(target=build_in_thread, args=(freq,))
threads.append(t)
t.start()
for t in threads:
t.join()
Each UGen is stamped with its builder's UUID at creation time. If a UGen from one builder is passed as input to a UGen in a different builder, a SynthDefError is raised. This prevents accidental cross-contamination between concurrent builds.
Clock and Player scheduling¶
The Clock runs a daemon thread that drives pattern playback. Its main loop:
-
Acquires
_lock, copies the player list -
For each player whose scheduled time has arrived, calls
player._tick(now) -
Sleeps until the next scheduled event (adaptive wake time via
_stop_event.wait(timeout=...))
Player._tick() pulls the next event from its pattern, creates a synth via server.synth(), and -- for gated envelopes -- spawns a threading.Timer daemon thread to send gate=0.0 after the sustain duration:
import time
from nanosynth import Server, Clock, Player, Pbind, Pseq
with Server() as server:
synthdef.send(server)
time.sleep(0.1)
clock = Clock(bpm=120)
# Player._tick() runs on the clock thread
# Gate release runs on short-lived Timer threads
player = Player(server, Pbind(name="sine", dur=Pseq([0.25, 0.5], repeats=4)))
clock.add(player)
clock.start()
time.sleep(4.0)
clock.stop()
The Clock._lock protects mutation of the player list (add/remove). Player state itself (_stopped, _next_time) uses simple attribute writes that are atomic under CPython's GIL. Player.stop() can be called from the main thread while the clock thread is ticking -- the _stopped flag is checked at the top of each tick.
What is thread-safe¶
| Operation | Safe from any thread? | Notes |
|---|---|---|
server.synth(), server.free(), server.set() |
Yes | Delegates to send_packet(), a synchronous C call |
server.send_msg(), server.send_msg_sync() |
Yes | Same as above; send_msg_sync blocks the calling thread |
server.on(), server.off() |
Yes | Protected by _reply_lock |
server.wait_for_reply() |
Yes | Uses threading.Event, blocks calling thread |
SynthDefBuilder context manager |
Yes | Thread-local stacks, UUID scope checking |
clock.add(), clock.remove() |
Yes | Protected by Clock._lock |
player.stop() |
Yes | Atomic flag write |
node.set(), node.free() |
Yes | Delegates to server.set() / server.free() |
NodeProxy source swap |
No | No internal locking; use from one thread |
Ndef registry access |
No | Global dict, no locking |
| MIDI handler registration | No | Not locked; register before starting playback |