Skip to content

Server Control

Beyond creating synths and groups, the Server exposes runtime facilities that take advantage of the engine running in-process: a synchronization barrier, direct numpy buffer exchange, introspection of the live node graph, and node lifecycle notifications. See the Server API reference for the full signatures.

Synchronization

Most commands (creating synths, allocating buffers, loading SynthDefs) are asynchronous -- the call returns before the engine has processed them. sync() sends /sync and blocks until the engine acknowledges with /synced, guaranteeing every prior command has been applied:

from nanosynth import Server

with Server() as server:
    synthdef.send(server)
    buffer_id = server.alloc_buffer(44100)
    server.sync()                 # buffer + synthdef are now ready to use
    node = server.synth("player", buffer=buffer_id)

sync() returns True once synced, or False on timeout. It is the canonical way to order work that depends on a prior asynchronous command having completed.

Timestamped Bundles

A plain server.synth(...) executes when the engine receives it, so its onset carries whatever jitter the sending thread had. A bundle carries a timestamp instead: the engine holds it and applies every message it contains on the same control block, at the requested time. This is what makes timing accurate to the sample rather than to the scheduler.

at() captures everything sent inside the block into one bundle. Ordinary Server methods work unchanged inside it:

import time
from nanosynth import Server

with Server() as server:
    synthdef.send(server)
    server.sync()

    onset = time.time() + 0.2          # Unix epoch seconds
    with server.at(onset):
        note = server.synth("sine", freq=440.0)
    with server.at(onset + 1.0):
        server.set(note, gate=0.0)     # release, queued in advance

Both messages are sent immediately; the engine fires them at the stamped times. Node IDs are allocated eagerly, so the returned proxy is usable straight away -- before the bundle has even been sent.

Points worth knowing:

  • Timestamps are Unix epoch seconds (the time.time() domain). A timestamp in the past executes on arrival, so falling behind degrades to immediate playback rather than dropping the event.

  • An empty block sends nothing, and a block that raises sends nothing -- you never emit a half-built bundle.

  • Blocks may nest, producing nested OSC bundles.

  • The capture stack is thread-local: a block open on one thread never captures another thread's messages.

send_bundle(contents, timestamp) is the lower-level form, taking explicit OscMessage objects when you are building packets directly.

The pattern engine is built on this. A Clock schedules events a configurable latency ahead (default 0.1s), which is why pattern playback stays steady under Python-side load. See the Patterns API.

Buffer Data Exchange (numpy)

Because the engine shares the process, sample data moves between a numpy array and a buffer's memory with a direct memcpy -- no OSC /b_setn//b_getn round-trip and no datagram-size limits. Install the optional dependency with pip install nanosynth[numpy].

import numpy as np
from nanosynth import Server

with Server() as server:
    # Load a numpy array straight into a new buffer (allocates + fills).
    wavetable = np.sin(np.linspace(0, 2 * np.pi, 1024, endpoint=False)).astype("float32")
    buffer_id = server.alloc_buffer_from_array(wavetable)

    # Inspect: (frames, channels, sample_rate)
    frames, channels, sample_rate = server.buffer_info(buffer_id)

    # Read samples back into numpy -- shape (frames, channels), float32.
    data = server.get_buffer_data(buffer_id)

    # Process in numpy and write back (shape must match the buffer).
    server.set_buffer_data(buffer_id, np.tanh(data * 2.0))

get_buffer_data always returns a 2-D (frames, channels) array (mono is (frames, 1)); set_buffer_data accepts 1-D (mono) or 2-D input and coerces it to contiguous float32. These read and write the live buffer, so for clean results the buffer should not be in active use by a synth during the transfer (a concurrent read/write may tear or glitch -- it never crashes). Direct buffer access is available on the embedded scsynth engine only (not supernova).

Introspection

Query the running engine and inspect or reset its node graph:

from nanosynth import Server

with Server() as server:
    status = server.status()      # CPU load, sample rate, node/synth/ugen counts
    print(status.num_synths, status.actual_sample_rate)

    version = server.version()    # program name and version

    # The live node tree as a nested NodeInfo (groups, synths, control values).
    tree = server.query_tree(controls=True)
    for child in tree.children:
        print(child.node_id, "group" if child.is_group else child.synthdef)

    # "Panic": free all nodes, clear the scheduler, recreate the default group.
    server.reset()

status(), version(), and query_tree() block for a reply and raise EngineError on timeout. reset() frees all nodes and resets node-id allocation but leaves loaded SynthDefs, buffers, and buses intact.

Node Lifecycle Notifications

Register for node events to observe nodes being created, freed, paused, resumed, or moved. This is the only way to know when a synth that frees itself (via a DoneAction envelope) has actually finished:

from nanosynth import Server

with Server() as server:
    synthdef.send(server)
    server.enable_notifications()

    # Observe every node event.
    server.on_node(lambda e: print(e.action, e.node_id))

    # ...or wait for one specific node to free itself.
    node = server.synth("ping", frequency=440.0)  # a self-freeing percussive synth
    if node.wait_free(timeout=3.0):
        print("the synth finished and freed itself")

Each callback receives a NodeEvent with action ("go", "end", "off", "on", "move"), node_id, parent_group_id, is_group, and (for groups) head_node_id/tail_node_id. Synth.on_free(callback) is a one-shot convenience that fires when that specific node ends.

Notifications are off by default. enable_notifications() waits for the engine to confirm registration so the first node's /n_go is not missed; callbacks run on the reply thread, so they must be non-blocking (see the Threading Model).