Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • Native Rust port (synthdef): a companion project reimplements the SynthDef compiler in Rust, driven by this package's language-neutral UGen spec (spec/nanosynth-ugens.json) and validated byte-for-byte against nanosynth's compiler via shared golden fixtures, so the two cannot silently diverge. On top of the compiler it adds a nanosynth-style Server and SuperCollider-style pattern sequencing over the pure-Rust plyphon engine (native and wasm32), targeting environments a Python runtime cannot reach -- other languages via a C ABI, the browser via WASM, or a DAW plugin. Consuming the shared spec is the reason it was decoupled from the release version (below)

  • Performance benchmark suite (benchmarks/): pytest-benchmark benchmarks for the compile hot path (SynthDefBuilder.build() and SynthDef.compile() on a representative subtractive-synth reference graph) and OSC message/bundle encode/decode. Kept out of tests/ so the coverage-gated make test run does not time them. make bench runs them, make bench-baseline regenerates the committed benchmarks/baseline.json, and make bench-check fails if any benchmark regresses more than 25% (median) versus the baseline on the same machine. A manual (workflow_dispatch-only) benchmarks CI job gates gross regressions by benchmarking HEAD and the previous commit on one runner (same-machine, apples-to-apples) at a looser 50% threshold that tolerates shared-runner noise

Changed

  • UGen spec now records output arity: each entry in spec/nanosynth-ugens.json gains an outputs field -- {"kind": "fixed", "count": N} for most UGens (0 for Out, 2 for Pan2, ...) or {"kind": "variable", "default": N} for multichannel UGens. The @ugen decorator stores the declaration on the class (_declared_channel_count / _declared_is_multichannel) for introspection without affecting the runtime channel count. Needed by non-Python frontends to emit multi-output UGens.

  • Decoupled the UGen spec from the release version: spec/nanosynth-ugens.json no longer carries a generator_version field, so a version bump no longer trips the tests/test_ugen_spec.py drift guard. The spec now regenerates only when the UGen metadata or enum tables actually change

[0.2.1]

Added

  • nanosynth compile CLI command: compiles Python-defined SynthDefs to .scsyndef binaries for use with a standalone scsynth/supernova, /d_load, or deploy-time precompilation. Loads a .py file, discovers every module-level SynthDef (both @synthdef-decorated functions and SynthDefBuilder.build() results), and writes one <name>.scsyndef per def (-o DIR) or a single bundled SCgf file holding all defs (-b FILE). -n/--name selects specific defs (repeatable); --anonymous emits MD5-hash names. Reports actionable errors (missing/non-.py input, import failure, no SynthDefs found, unknown name, missing output dir, duplicate names that would overwrite) on stderr with a non-zero exit

  • Property-based tests (test_properties.py, hypothesis): randomized invariants over the graph frontend -- algebraic identity laws (sig + 0/0 + sig/sig - 0/sig * 1/1 * sig/sig / 1/sig ** 1 return the original signal; sig * 0 and sig ** 0 fold to the constants 0 and 1), constant folding equivalence with Python float arithmetic, multichannel expansion arity (a width-n input expands to n channels; combining widths m and n yields max(m, n)), and compilation determinism (byte-identical SCgf and stable topological order across rebuilds, independent of the declared name). Recipes generate graphs up to 14 UGens deep

  • CLI documentation (docs/cli.md): documents both the info and compile subcommands, how SynthDefs are discovered, the option matrix, per-def vs bundled output, and exit-status behavior

  • Deployment guide (docs/deployment.md): the precompiled-artifact workflow for non-Python consumers -- compile SynthDefs to .scsyndef offline with nanosynth compile, then load them at runtime via /d_load / /d_loadDir / /d_recv from nanosynth, sclang, or any OSC client. Clarifies that the runtime identifier is the SynthDef name embedded in the binary, not the filename

  • Language-neutral UGen spec (spec/nanosynth-ugens.json, scripts/generate_ugen_spec.py): a versioned, machine-readable table of all 341 UGens (names, calculation rates, parameter defaults, and unexpanded/pure/output/width-first flags), the operator/rate/done-action enum tables, and an operator_ugens block naming BinaryOpUGen/UnaryOpUGen and the enum table their special_index selects, so a non-Python implementation of the SynthDef frontend shares the drift-prone metadata rather than transcribing it by hand. tests/test_ugen_spec.py fails if the committed spec drifts from the code (regenerate with python scripts/generate_ugen_spec.py); included in sdists

  • SCgf format specification (docs/scgf-format.md): documents the SCgf version-2 binary byte layout (header, constant pool, parameter tables, UGen records, input specs) and the UGen spec schema, for writing loaders, validators, or independent compilers in other languages

  • Source-tree CI test matrix: a test-source job runs pytest against the source tree (not built wheels) across CPython 3.10--3.14 on Linux, building the C extension from source via uv sync. Closes the gap where make test was previously only exercised against cibuildwheel-built wheels

[0.2.0]

Added

  • Node lifecycle notifications (server.py): Server.enable_notifications() registers for the engine's node events (/notify); once on, /n_go//n_end//n_off//n_on//n_move are parsed into NodeEvent objects. on_node(callback) receives every event; wait_for_node_free(node_id, timeout) blocks until a node ends (including a self-freeing DoneAction synth, which the client otherwise has no way to observe); and Synth.wait_free() / Synth.on_free(callback) are per-node conveniences (the latter is one-shot and self-unregisters). enable_notifications() waits for the /done /notify confirmation so registration is in effect before any node is created -- otherwise the first node's /n_go can be missed. NodeEvent is exported from the top-level package

  • Server introspection & control (server.py): Server.status() returns a ServerStatus (CPU load, sample rate, and ugen/synth/group/synthdef counts from /status.reply); version() returns a ServerVersion (/version.reply); query_tree(group, controls=...) returns the live node graph as a nested NodeInfo tree (groups, synths, and their current control values, parsed from /g_queryTree.reply); dump_tree() prints it to the engine log; and reset() is the "panic" equivalent -- frees all nodes (/g_freeAll), clears the scheduler (/clearSched), recreates the default group, and resets the node-id allocator (leaving loaded SynthDefs, buffers, and buses intact). ServerStatus/ServerVersion/NodeInfo are exported from the top-level package

  • Direct numpy buffer data exchange (server.py, _scsynth.cpp): Server.get_buffer_data(buffer_id) and set_buffer_data(buffer_id, array) copy samples directly between a numpy array and a buffer's in-process float storage via memcpy -- no OSC round-trip and none of the /b_getn//b_setn datagram-size limits, which is the core advantage of the embedded engine. get returns a (frames, channels) float32 array (owning a copy, so it stays valid across buffer reallocation); set accepts 1-D (mono) or 2-D input, coerced to contiguous float32 and shape-checked against the buffer. buffer_info(buffer_id) returns (frames, channels, sample_rate), and alloc_buffer_from_array(array) allocates a correctly-sized buffer and fills it in one call (handy for loading wavetables/samples). scsynth-only (raises EngineError for supernova); numpy is an optional dependency (pip install nanosynth[numpy]), imported lazily with a clear error if missing. The C++ accessors (world_buffer_get/set/info) read the live buffer, so concurrent synth access during a transfer may tear/glitch (documented, never crashes)

  • Server.sync() round-trip barrier: sends /sync with a unique id and blocks until the matching /synced reply, the canonical scsynth flush primitive. Returns True once synced or False on timeout. Concurrent sync() calls are matched by id

  • Reply matchers: Server.wait_for_reply() and send_msg_sync() accept an optional match predicate so a waiter accepts only a reply satisfying it (e.g. a /done whose first argument is the originating command). Non-matching replies leave the waiter registered instead of resolving it

  • Reclaiming id allocators (server.py): node ids, buffers, audio buses, and control buses are now managed by allocators that reclaim freed ids. Buffers/buses use a coalescing free-list block allocator (_BlockAllocator) that hands out the lowest available contiguous block, reuses freed blocks, supports explicit-id reservation, and raises EngineError on exhaustion. Node ids use a wrapping allocator (_NodeIdAllocator) bounded within scsynth's id space (not reclaimed on free, since self-freeing DoneAction synths are not reported to the client)

  • Cross-engine process guard (_scsynth.cpp, _supernova.cpp): scsynth and supernova each statically embed the full SuperCollider core and share process-global state (the dlopen'd UGen plugin registry, FFT init), so creating one kind after the other has run in the same process segfaults -- in either order, even after a clean quit, and even via NRT (which also creates a scsynth World). A guard in each extension's engine-creation entry point (coordinated through an environment variable, since the two modules share no symbols) now raises ServerCannotBoot with a clear message instead of letting the process crash. Same-kind reuse (scsynth reboot, repeated NRT renders) is unaffected

  • Test hardening: golden SCgf byte fixtures (tests/fixtures/scgf/, regenerable via python tests/test_golden_scgf.py) that freeze proven-correct compiler output and catch silent format regressions; unit tests for the allocators (reclaim, coalescing, capacity, reservation, thread-safety), sync(), and reply matchers; forced cross-path OSC parity tests (test_osc_parity.py) asserting byte-identical encode, identical decode, and identical exception types across the native and pure-Python codecs; and opt-in realtime smoke tests (test_realtime_smoke.py, gated by NANOSYNTH_TEST_REALTIME=scsynth|supernova) that boot a real engine and exercise sync(), buffer reclaim, and clean quit. A --cov-fail-under-style coverage floor (90%) is enforced via [tool.coverage.report]

Fixed

  • OSC codec C++/Python parity (_osc.cpp, osc.py, exceptions.py): the two codecs violated their "must stay compatible" contract. They now agree on (a) exception type -- both raise OscError on malformed input (the C++ path via a nanobind exception translator scoped to a dedicated OscDecodeError C++ type, so it does not affect std::runtime_errors from the _scsynth/_supernova extensions -- nanobind translators are process-global; the Python path by wrapping struct.error/IndexError/ValueError), and OscError now also subclasses ValueError so except ValueError keeps working; (b) string encoding -- both use UTF-8 (Python previously used ASCII and raised on non-ASCII); and (c) unterminated strings -- the C++ decoder now rejects a string with no null terminator instead of silently accepting it, matching Python

  • Reply waiter leak on timeout (server.py): a timed-out wait_for_reply/send_msg_sync waiter was never removed from _pending_replies, so it lingered and could be spuriously resolved by a later unrelated reply. Timed-out waiters are now unregistered

  • atexit cleanup leak (scsynth.py, supernova.py): atexit.register(self.quit) ran in __init__ and was never unregistered, so every protocol object (booted or not) leaked an atexit callback holding a strong reference. Registration now happens on boot() and is removed on quit(), so never-booted objects accumulate nothing

  • OSC bundle decoder out-of-bounds read (_osc.cpp): the native bundle decoders (decode_bundle_from_raw, decode_bundle_bytes) read each element's length as a signed int32_t and bounds-checked it with offset + (size_t)length > len. A malformed datagram with a negative length cast to size_t could wrap that addition and pass the check, then hand a negative/huge size to nb::bytes, causing an out-of-bounds read or oversized allocation. Lengths are now read as uint32_t and validated with the overflow-safe element_len > len - offset (offset <= len is guaranteed at that point)

  • Engine boot/quit thread coordination (scsynth.py, supernova.py): _shutdown() previously did a blind thread.join(timeout=5) followed by an unconditional forced world_cleanup/supernova_cleanup, which could tear the engine down concurrently with a wait/run thread still inside World_WaitForQuit/supernova_run (a double-free / use-after-free race on the engine handle). It now gates on the existing exit_future -- resolved by the wait/run thread only after the engine has been torn down internally -- while that thread is alive, with forced cleanup retained only as a logged last resort on a 5s timeout

  • Boot is now exception-safe (scsynth.py, supernova.py): if the on-boot callback or the wait/run-thread start raised, the engine handle was created and the process-global _active_world/_active flag was left set, permanently wedging every subsequent boot with ServerCannotBoot. The go-online sequence is now wrapped; on failure a new _abort_partial_boot() tears down the engine and clears the global flag. The thread is started and boot_future resolved last, so rollback never races a running loop

  • Pattern clock timing drift (patterns.py): Player._tick advanced the next event deadline from the wall-clock wake time (now + dur * beat_dur), baking every late wake-up into the following event and accumulating drift over a session. It now advances from the previous scheduled target (self._next_time + dur * beat_dur); if the clock falls behind, successive events fire back-to-back until it catches up, with no accumulated error

  • Release and docs CI workflows (.github/workflows/): release.yml had its push: tags and workflow_dispatch inputs commented out while the publish steps gated on the resulting always-undefined inputs.target and a never-true github.event_name == 'push', so no PyPI publish could ever run. Triggers are restored and the PyPI gate now keys on startsWith(github.ref, 'refs/tags/v'). docs.yml ran uv sync --group docs against a non-existent dependency group (mkdocs deps live in dev); it now runs uv sync and its push: branches: [main] trigger is restored

Changed

  • Synchronous SynthDef load matches the sub-command (server.py): send_synthdef() now waits for a /done whose first argument is /d_recv, so a /done from an unrelated async command cannot resolve the wait early (still falls back to fire-and-forget on timeout for mock servers)

  • Recording uses sync() instead of time.sleep (server.py): record() and stop_recording() replaced their fixed time.sleep(0.1) pauses with self.sync() barriers, so the disk buffer is provably open before DiskOut starts and final samples are flushed before the file is closed

  • Single-sourced package version: the version was hard-coded in both pyproject.toml and src/nanosynth/__init__.py and could drift. pyproject.toml now declares dynamic = ["version"] and reads __version__ from src/nanosynth/__init__.py via scikit-build-core's regex metadata provider, making __init__.py the single source of truth

[0.1.6]

Added

  • nanosynth info CLI command: shows version, Python version/platform/architecture, audio backend (CoreAudio/PortAudio), UGen plugin path and count, and whether the scsynth and supernova C extensions loaded successfully (with error reason if not). --list / -l flag lists all available UGen classes alphabetically (341 UGens). Entry point registered via [project.scripts] in pyproject.toml. Uses argparse subparsers for future extensibility

  • MIDI usage guide (docs/midi.md): covers port opening (by index, name substring, or virtual port), MIDI message types (NoteOn, NoteOff, ControlChange, PitchBend), handler registration and removal, midi_note_map() for note-to-synth mapping with frequency/velocity conversion, midi_cc_map() for CC-to-parameter mapping with linear scaling, and thread safety considerations for callbacks running on RtMidi's polling thread

  • Threading model documentation (docs/threading.md): documents all background threads (engine daemon, reply callback, Clock, Timer, MIDI), the engine lifecycle state machine (OFFLINE -> BOOTING -> ONLINE -> QUITTING), OSC reply dispatch with locking, thread-local SynthDefBuilder stacks with UUID scope checking, Clock/Player scheduling with adaptive sleep, and a reference table of thread-safe vs thread-unsafe operations

  • Integration tests (test_integration.py): 19 tests verifying the full compilation-to-audio pipeline (SynthDefBuilder -> SynthDef -> SCgf binary -> engine load -> audio synthesis -> WAV output) via NRT rendering. Covers sine wave synthesis (non-silence, amplitude scaling, frequency differentiation), parameter control, diverse UGens (WhiteNoise, Saw, LPF, RLPF, Pan2), envelopes (percussive decay verification), Mix, stereo panning, @synthdef decorator, complex graphs (subtractive, additive, multi-SynthDef scores), and compilation roundtrip (determinism, anonymous names, optimization equivalence). No audio hardware required

  • Basic UGen tests (test_basic_ugens.py): 37 tests covering MulAdd, Sum3, Sum4, and Mix -- all algebraic simplification paths, rate computation and validation, zero-elision, input swapping for rate validity, multichannel expansion, recursive Mix grouping (Sum3/Sum4 tree), and SCgf compilation. Coverage of basic.py raised from 26% to 86%

  • Adversarial compiler tests (test_adversarial.py): 32 tests for edge cases -- deep UGen chains (100-deep filter cascades, 200-deep arithmetic chains), large graphs (500 parallel UGens, 100 parameters, 200+ constants), name encoding boundaries (empty, 1-char, 255-char, 256-char overflow, non-ASCII rejection), scope isolation (nested/sequential/cross-scope errors), degenerate graphs (constant-only, parameter-only, all-dead-code), topological sort edge cases (diamond dependencies, wide fan-out/fan-in, disconnected subgraphs), compilation determinism, and custom UGen type name encoding

  • OSC edge case tests (test_osc_edge_cases.py): 48+ tests (parametrized across native C++ and pure-Python backends) covering NTP timestamp edge cases (zero, fractional, large, immediate, non-realtime), deeply nested bundles (3-level, 5-level, mixed message/bundle), special characters in addresses (underscores, digits, dots, long, minimal, OSC wildcards), equality edge cases, format_datagram/str/repr, to_list/to_osc, find_free_port, unsupported type encoding, and empty/no-arg messages

  • Concurrency stress tests (test_concurrency.py): 10 tests covering SynthDefBuilder thread isolation (50 concurrent builds with unique frequencies, barrier-synchronized stack checks, nested builder isolation, cross-thread UGen rejection, 30 concurrent complex graphs with Mix/LPF/params, deterministic output under concurrency) and Server reply dispatch (20 concurrent waiters resolved via raw datagram dispatch, concurrent handler registration/unregistration, 100 concurrent dispatches, waiter timeout)

  • Low-coverage UGen module tests (test_ugen_coverage.py): 30 tests covering LocalIn (single/multi-channel, default cycling, scalar defaults, feedback loops with LocalOut, control rate), BiPanB2 (audio/control rate, 3-channel output), DecodeB2 (4/8 channel counts), Splay (single/multiple sources, normalize/no-normalize, spread/center, control rate), Klank (basic, explicit amplitudes, decay times, default fill, empty frequencies rejection, frequency scale/offset/decay scale), LinLin (ar/kr mapping, identity), and Silence (mono/stereo/8-channel)

  • Typed exception hierarchy (exceptions.py): All exceptions defined in a single module importable via from nanosynth.exceptions import .... NanosynthError base class with OscError (OSC encode/decode), EngineError (engine lifecycle), ServerCannotBoot (boot failures, subclass of EngineError), MidiError (MIDI port/callback), and SynthDefError (graph construction) subclasses. All six exception classes exported from the top-level package

  • Supernova demos (demos/supernova/): 5 new demos -- demand-rate sequencing with parallel voices (07_demand_sequencer.py), pattern sequencing via Pbind/Clock (08_patterns.py), FFT spectral processing (09_spectral.py), managed ParGroup context manager with chord progression (10_managed_pargroup.py), and NodeProxy/Ndef live coding (11_nodeproxy.py)

Fixed

  • Synchronous SynthDef loading (server.py): send_synthdef() now waits for the engine's /done /d_recv reply before returning (0.1s timeout), ensuring the SynthDef is ready for immediate use. Previously the fire-and-forget /d_recv caused race conditions on supernova where /s_new could arrive before the SynthDef was loaded (e.g. NodeProxy source swaps failing with "Cannot create synth"). Falls back gracefully to fire-and-forget on timeout, so mock servers in tests are unaffected

  • NRT render crash with persistent delay synths (score.py): Score.render() would segfault during World cleanup when persistent synths (no DoneAction.FREE_SYNTH or explicit /n_free) containing delay UGens (DelayN, CombC, etc.) reading from private buses via In.ar were still running at end-of-score. The engine freed delay line buffers while they were still referenced. Fixed by sending /g_freeAll 0 (free all nodes in the default group) before the end-of-score marker, ensuring clean synth teardown before World cleanup

Changed

  • Consolidated exceptions into nanosynth.exceptions: ServerCannotBoot moved from scsynth.py and SynthDefError moved from synthdef.py into the new exceptions.py module. Both are re-exported from their original modules for internal use but the canonical import path is now nanosynth.exceptions

  • Narrowed broad except Exception catches: server.py:_dispatch_reply OSC decode catch narrowed to specific decode-failure types (ValueError, IndexError, struct.error, OscError, RuntimeError). patterns.py:_release_synth narrowed to (EngineError, OSError). Handler callback catch retained as except Exception with explicit annotation for intentional user-callback isolation

  • Replaced generic RuntimeError with typed exceptions: scsynth.py and supernova.py send_packet raise EngineError instead of RuntimeError when the server is not running. server.py raises EngineError for bus/recording state errors. proxy.py raises EngineError for missing source synth. midi.py raises MidiError for port-not-found. osc.py raises OscError for unparseable type tags

[0.1.5]

Added

  • Embedded supernova: EmbeddedSupernovaProtocol runs SuperCollider's parallel DSP engine (supernova) in-process via nanobind (_supernova.cpp), as a drop-in replacement for EmbeddedProcessProtocol. Same Server API -- just pass protocol=EmbeddedSupernovaProtocol(). Supernova schedules independent nodes across CPU cores when placed in ParGroups, providing parallel DSP execution that scsynth cannot. C++ wrapper (_supernova.cpp, ~400 lines) wraps supernova's C++ classes directly (no C API exists), synthesizing server_arguments from Python kwargs, implementing a custom reply endpoint for in-process OSC responses, and managing the event loop on a daemon thread. Supernova plugins (24 _supernova variants) are compiled with SUPERNOVA defined and bundled alongside scsynth plugins. Build gated by NANOSYNTH_EMBED_SUPERNOVA=ON CMake option

  • ParGroup support: ParGroup proxy class (subclass of Group) and Server.par_group() / Server.managed_par_group() for creating parallel groups via /p_new. ParGroups evaluate their child nodes in parallel across CPU cores (supernova only; scsynth treats them as regular groups)

  • SynthDef disk I/O: SynthDef.save(path) writes compiled SCgf bytes to a .scsyndef file (with optional use_anonymous_name flag). Server.load_synthdef(path) loads a .scsyndef file into the engine via /d_load, resolving to an absolute path for the engine

  • Demo scripts reorganized into demos/scsynth/ (21 demos) and demos/supernova/ (6 demos). Supernova demos: 01_sine.py (basic sine wave), 02_parallel_voices.py (24 voices in ParGroup), 03_fx_chain.py (delay + reverb effect chain), 04_parallel_fx.py (three independent effect chains on private buses in a ParGroup), 05_nested_pargroups.py (nested ParGroups with left/right voice banks and sequential fx group), 06_dense_polyphony.py (64 simultaneous voices stress test)

[0.1.4]

Added

  • Patterns / sequencing: Pattern[T] abstract base class with __iter__, take(n), and | (chain) operator. Eight value patterns: Pseq (sequential with nested pattern flattening), Prand (random selection), Pwhite (uniform random float), Pseries (arithmetic series), Pgeom (geometric series), Pchoose (weighted random), Pn (repeat a pattern N times), Pconst (yield until sum reaches total). Pbind binds keys to patterns/scalars to produce Event dicts, stops on shortest pattern, merges with configurable defaults, auto-derives sustain from dur and freq from midinote. Clock (background daemon thread with time.monotonic() scheduling, settable bpm) and Player (pulls events, creates synths, schedules gate release via threading.Timer). Rest sentinel class for silent beats

  • MIDI input: C++ nanobind extension (_midi.cpp) wrapping RtMidiIn from vendored rtmidi 6.0.0 (CoreMIDI on macOS, ALSA on Linux, WinMM on Windows; JACK disabled). Python layer (midi.py): frozen dataclass message types (NoteOn, NoteOff, ControlChange, PitchBend), MidiIn class with handler registration (on_note_on/off_note_on, on_cc/off_cc, etc.), pure-Python _parse() for raw MIDI bytes (velocity-0 note-on treated as note-off), context manager support, port opening by index/name/virtual. High-level helpers: midi_note_map() (note-on creates synth with freq/amp, note-off sends gate=0) and midi_cc_map() (CC values scaled to parameter range). Build integration: NANOSYNTH_EMBED_MIDI CMake option, rtmidi built as static library via add_subdirectory

  • NodeProxy / Ndef: NodeProxy owns a private audio bus, a source synth (with ASR envelope for clean crossfade on swap), and a monitor synth (reads from private bus, writes to hardware output). Source can be a callable (auto-wrapped in SynthDefBuilder with envelope) or a SynthDef. Hot-swap sends gate=0 to old source (10ms release) and creates new source (10ms attack) for overlap crossfade. play() / stop() control monitoring, clear() frees all resources, set() updates source synth params. Ndef is a global named proxy registry: Ndef(server, "name", source) creates or retrieves a NodeProxy, Ndef.clear_all(server) frees all proxies for a server. Registry keyed by (id(server), name) to support multiple servers

  • Bus allocation: Bus proxy class with Server.audio_bus(), Server.control_bus(), free_bus(), managed_audio_bus(), managed_control_bus(). Audio buses allocate from the private range (after hardware I/O); control buses from 0. Bus supports int() conversion, set() for control buses, equality/hashing, and free(). Server.options property exposes the Options configuration

  • Server recording: Server.record(path) captures real-time audio output to WAV/AIFF via DiskOut. stop_recording() finalizes the file. is_recording property for state inspection. Configurable channel count, bus, header/sample format. Recorder SynthDef built and cached on first use per channel count. write_buffer() gains a leave_open parameter for disk-streaming buffers

  • NRT (non-real-time) rendering: Score class for offline audio rendering without real-time audio hardware. Score.add(), add_synthdef(), add_synth() build a timestamped sequence of OSC commands; to_binary() serializes to SC's binary command file format; render() invokes the embedded scsynth NRT engine to produce WAV/AIFF files. C++ binding world_nrt_render() wraps World_NonRealTimeSynthesis with configurable sample rate, format, channels, and engine options

  • SynthDef graph introspection: SynthDef.graph() returns a SynthDefGraph NamedTuple containing UGenNode and UGenInput structures for programmatic DAG walking. SynthDef.to_dot() exports the graph as a Graphviz DOT string for visualization. Handles BinaryOpUGen/UnaryOpUGen operator names, Control parameter names, multi-output UGens, and constant inputs

  • Demo script 19_nrt_render.py: offline rendering of a C major arpeggio to WAV and AIFF files

  • Demo script 20_patterns.py: pattern-based sequencing with Pseq, Prand, Pwhite, Pbind, Clock, and Rest

  • Demo script 21_nodeproxy.py: NodeProxy hot-swapping (sine/saw/noise) and Ndef named proxy registry

  • Concepts documentation page (docs/concepts.md): explains 8 non-obvious internal concepts -- calculation rates, multichannel expansion, the unexpanded flag, parameter rates, the builder scope, graph optimization (constant folding and dead code elimination), width-first ordering, and the Default sentinel. Each section includes code examples and practical implications

Changed

  • Demos 01--11 converted from low-level _scsynth API to high-level Server API (server.synth(), server.group(), server.free(), node.set(), context manager lifecycle)

  • README restructured: Quick Start now leads with the Server-based workflow (define, boot, play), followed by managed nodes, effect chains with AddAction, and NRT rendering. Synthesis technique examples moved to a dedicated section. SynthDef compilation without engine, graph introspection, and OSC codec moved to Advanced Features

Fixed

  • Windows NRT render crash (score.py): Score.render() used tempfile.NamedTemporaryFile(delete=True) for the binary command file, which on Windows holds an exclusive file lock preventing the C++ engine from opening it -- causing an access violation (0xC0000005) in World_New. Replaced with tempfile.mkstemp() so the file is closed before world_nrt_render reads it, with manual cleanup in a finally block

[0.1.3]

Added

  • Synth / Group proxy objects: Server.synth() and Server.group() now return lightweight Synth and Group proxy objects instead of raw ints. Proxies support .set(**params), .free(), context manager usage (with server.synth(...) as node:), and are fully int-compatible via __int__(), __index__(), __eq__(), and __hash__(). managed_synth() and managed_group() also yield proxies. Existing code comparing against ints continues to work unchanged

  • control() function: convenience constructor for SynthDef parameters with rate and lag metadata -- control(440.0, rate="ar") is equivalent to Parameter(value=440.0, rate=ParameterRate.AUDIO). Accepts string rate tokens ("ar", "kr", "ir", "tr") or ParameterRate enum values

  • Tuple syntax for SynthDefBuilder: parameters can be specified as tuples -- SynthDefBuilder(freq=("ar", 440.0)) for (rate, value) or SynthDefBuilder(amp=("kr", 0.5, 0.1)) for (rate, value, lag). Works alongside float, Parameter, and control() styles

  • Trimmed __all__ exports: from nanosynth import * now exports ~60 names (core API + 29 common UGens) instead of 340+. The full UGen set remains available via from nanosynth.ugens import * or qualified imports

  • Extended operators: BinaryOperator expanded from 7 to 43 entries, UnaryOperator from 2 to 34, covering SC's full practical operator set (power, integer division, comparisons, bitwise ops, trig, pitch conversion, clipping, ring modulation, etc.)

  • Operator methods on UGenOperable: 16 new dunder methods (__pow__, __floordiv__, __le__, __ge__, __and__, __or__, __xor__, __lshift__, __rshift__ and their reverse variants), equal()/not_equal() explicit comparison methods, 25 named binary methods (min_, max_, clip2, fold2, wrap2, ring1--ring4, atan2, hypot, etc.), and 32 named unary methods (midicps, cpsmidi, dbamp, ampdb, tanh_, softclip, distort, squared, sqrt_, exp_, log_, sin_, cos_, etc.)

  • Constant folding for new operators: POWER, INTEGER_DIVISION, MINIMUM, MAXIMUM, comparison ops, and all math-stdlib unary ops fold float op float at compile time

  • POWER optimizations in BinaryOpUGen._new_single: x ** 0 folds to 1, x ** 1 folds to x

  • Buffer management on Server: alloc_buffer(), read_buffer(), write_buffer(), free_buffer(), zero_buffer(), close_buffer(), next_buffer_id(), plus managed_buffer() and managed_read_buffer() context managers. Buffer IDs are auto-allocated (monotonically from 0) or explicitly specified; allocated buffers tracked in _allocated_buffers set

  • Reply handling: C++ reply callback (set_reply_func in _scsynth.cpp) routes OSC responses from the engine back to Python; EmbeddedProcessProtocol.set_reply_callback() wires it at boot; Server gains _dispatch_reply() router, on()/off() for persistent handlers, wait_for_reply() for blocking one-shot waits, and send_msg_sync() for send-and-wait patterns -- all thread-safe

  • Demo script 18_operators_buffers.py: extended operators (midicps, tanh_, clip2, dbamp, softclip, distort), managed buffer allocation, and synchronous reply handling (send_msg_sync)

  • Documentation site (mkdocs-material + mkdocstrings): auto-generated API reference from docstrings, organized by core modules and 28 UGen categories, with Getting Started guide and changelog. Served locally via make docs-serve, deployed to GitHub Pages via make docs-deploy. New docs dependency group in pyproject.toml, GitHub Actions workflow (.github/workflows/docs.yml) for auto-deploy on push to main

  • Comprehensive docstrings across all core modules:

  • enums.py: all 6 enum classes (CalculationRate, ParameterRate, BinaryOperator, UnaryOperator, DoneAction, EnvelopeShape) with member descriptions and from_expr() methods
  • synthdef.py: SynthDef, SynthDefBuilder, UGen, UGenOperable, UnaryOpUGen, BinaryOpUGen, Parameter, Control, SynthDefError, UGenSerializable; 25 named binary methods with formulas (ring1--ring4, clip2, fold2, wrap2, difsqr, sumsqr, etc.); 8 pitch/amplitude conversion methods with examples (midicps, cpsmidi, dbamp, ampdb); waveshaping methods (distort, softclip)
  • envelopes.py: Envelope class with full Args section, all 5 factory methods (adsr, asr, linen, percussive, triangle), EnvGen with parameter descriptions
  • osc.py: OscMessage, OscBundle with public method docstrings (to_datagram, from_datagram, to_list), find_free_port()
  • scsynth.py: Options with commonly adjusted fields, BootStatus, ServerCannotBoot, EmbeddedProcessProtocol.boot() and .quit()
  • compiler.py: compile_synthdefs() with Args/Returns documenting the SCgf binary format

  • AddAction enum (enums.py): ADD_TO_HEAD, ADD_TO_TAIL, ADD_BEFORE, ADD_AFTER, REPLACE -- replaces opaque raw ints in Server.synth(), Server.group(), and their managed variants. Raw int values still accepted for backwards compatibility

  • ServerProtocol structural type (synthdef.py): SynthDef.send() and SynthDef.play() now accept any object satisfying ServerProtocol instead of Any, restoring type safety without circular imports

Fixed

  • Gendy1/2/3 parameter wire order (gendyn.py): all three Gendy UGens had incorrect parameter ordering and counts vs the C++ plugin (GendynUGens.cpp). Gendy1/2 had a single frequency where SC expects min_frequency/max_frequency, and had four distribution parameters (amplitude_parameter_one/two, duration_parameter_one/two) where SC expects two (amplitude_parameter, duration_parameter). Wire positions 2--10 were all wrong, causing silence or garbage output. Gendy3's parameter order was also incorrect (it has a different layout from Gendy1/2 in the C++ -- single frequency, no min/max). All three now match their ZIN0() indices exactly

  • Klank audio rate (ffsinosc.py): Klank.ar() passed calculation_rate=None to _new_expanded, which resolved to CalculationRate.SCALAR -- the filter bank was computed once at init instead of processing audio samples per block. Changed to CalculationRate.AUDIO

  • ParameterRate.from_expr("tr"): the "tr" token for trigger rate was missing from the string-to-enum mapping, causing KeyError when using control(rate="tr"). Added alongside "ar", "kr", "ir"

  • help(nanosynth) crash: dynamically generated rate methods (.ar, .kr, .ir) created via exec in _create_fn had __module__ = None, causing pydoc to raise TypeError: unsupported operand type(s) for +: 'NoneType' and 'str' when rendering help text. Now sets __module__ from the owning class before applying decorators.

  • __bool__ trap on UGenOperable: UGenOperable.__bool__ now raises TypeError instead of silently returning True. Catches the common footgun where if sig > 0: always takes the truthy branch because comparison operators return UGenOperable objects, not booleans

  • Server.quit() decoupled from protocol internals: Server.quit() now delegates to EmbeddedProcessProtocol.quit() instead of reaching into the private _shutdown() method, properly setting the QUITTING state for clean shutdown callbacks

  • Thread-local builder guard centralized: replaced three inconsistent hasattr(_local, "_active_builders") guard patterns in SynthDefBuilder.__init__, __enter__, and UGen.__init__ with a single _get_active_builders() function

  • Topological sort descendant ordering: _initiate_topological_sort had key=lambda x: ugens.index(ugen) which captured the loop variable, making the sort a no-op. Fixed to key=lambda x: ugens.index(x) to sort descendants by their position in the UGen list

[0.1.2]

Added

  • ~80 new UGen classes (290 -> 346 total), achieving full parity with supriya's UGen surface:
  • Phase vocoder (pv.py): FFT, IFFT, PV_ChainUGen, and 34 PV_* analysis/resynthesis UGens, plus RunningSum
  • Machine listening (ml.py): BeatTrack, BeatTrack2, KeyTrack, Loudness, MFCC, Onsets, Pitch, SpecCentroid, SpecFlatness, SpecPcile
  • Stochastic synthesis (gendyn.py): Gendy1, Gendy2, Gendy3
  • Hilbert transforms (hilbert.py): FreqShift, Hilbert, HilbertFIR
  • Mouse/keyboard (mac.py): KeyState, MouseButton, MouseX, MouseY
  • Disk I/O (diskio.py): DiskIn, DiskOut, VDiskIn
  • Utility (basic.py): MulAdd, Sum3, Sum4, Mix (signal mixer with Sum3/Sum4 tree optimization)
  • Additions to existing modules: LocalBuf, ScopeOut2 (bufio); Demand, Dwrand (demand); Poll, SendReply, SendPeakRMS (triggers); LocalIn (inout); Klank (ffsinosc); LinLin, Silence (lines); Changed (filters); CompanderD (dynamics); Splay (panning)

  • Default sentinel class in synthdef.py for parameters whose defaults are computed from other parameters at construction time (used by FFT, Gendy1-3, ScopeOut2)

  • PseudoUGen base class for virtual UGens that compose other UGens (Mix, Changed, CompanderD, LinLin, Silence, Splay)

  • _postprocess_kwargs hook on UGen.__init__ for transforming parameters at construction time (dynamic channel counts, Default resolution, rate forcing)

  • GREATER_THAN and LESS_THAN binary operators with corresponding __gt__/__lt__ on UGenOperable

  • Demo scripts: 14_spectral.py (FFT/PV spectral processing), 15_gendy.py (stochastic synthesis), 16_klank_splay.py (resonant filter banks), 17_freqshift.py (Bode frequency shifting)

Fixed

  • LocalBuf crash: SynthDefBuilder.build() now runs a _cleanup_local_bufs pass that automatically inserts a MaxLocalBufs UGen when LocalBuf instances are present in the graph (e.g. from FFT's auto-allocated buffer). Without this, scsynth would crash with LocalBuf tried to allocate too many local buffers

[0.1.1]

Added

  • Server class (nanosynth.server) -- high-level wrapper around the embedded scsynth engine with boot/quit lifecycle, node ID allocation, SynthDef dispatch, and convenience methods (synth, group, free, set). Supports context manager usage

  • Server.managed_synth() and Server.managed_group() context managers -- create a synth or group and automatically free it on context exit (including on exceptions); guard against freeing if the server has already stopped

  • SynthDef.send(server) and SynthDef.play(server, **params) convenience methods for sending SynthDefs and creating synths in one call

  • SynthDef.dump_ugens() pretty-printer -- returns a human-readable UGen graph representation (modeled on SuperCollider's SynthDef.dumpUGens), showing UGen types, rates, input wiring, operator names, and multi-output counts

  • Envelope.compile() -- dedicated serialization path producing tuple[float, ...] directly, bypassing UGenVector/ConstantProxy; raises TypeError on UGen inputs. serialize() retained for UGen graph wiring

  • Demo scripts 12_server_sine.py (sine wave via Server API) and 13_server_pad.py (gated pad chord progression with managed_synth, managed_group, and server.set())

  • EmbeddedProcessProtocol.send_packet() and send_msg() convenience methods for sending OSC to the engine without importing _scsynth directly

  • Auto-generated docstrings for all @ugen-decorated classes (e.g. SinOsc -- ar, kr\n\nParameters:\n frequency (default: 440.0))

  • __slots__ on core graph classes (UGen, OutputProxy, ConstantProxy, UGenVector, UGenOperable, UGenScalar, UGenSerializable) for lower memory usage

  • OSC test suite now runs all 24 tests against both the C++ and pure-Python backends (48 total)

  • EmbeddedProcessProtocol state machine tests: initial state, quit no-op, send errors when offline, callback storage, boot-when-active guard

  • Test coverage for SynthDefBuilder cross-scope errors, graph optimization (_optimize/_eliminate), Envelope.linen/.triangle/.asr factory methods, multi-channel UGens (In, PanAz, DecodeB2), compile_synthdefs with multiple SynthDefs, demand-rate UGens (Dseq, Drand, Duty, etc.), and @synthdef decorator (trigger/audio/lag rates, complex graphs) -- 54 new tests (322 -> 376)

  • qa CI job: runs ruff lint, ruff format check, mypy --strict, and pytest against the source tree on every push/PR

  • Release workflow (release.yml): tag-triggered publish to PyPI via trusted publisher, workflow_dispatch for TestPyPI, auto-generated GitHub Release

  • Docstrings on SynthDefBuilder.build(), .add_parameter(), and .__getitem__()

  • Plugin loading validation: _options_to_world_kwargs() logs a warning when no UGen plugins path is found

Fixed

  • macOS CoreAudio teardown crash: registered a C-level atexit guard in _scsynth.cpp (after World_New) that calls _exit(0) before CoreAudio's static destructors run; removed os._exit(0) from all 11 demo scripts

  • WorldStrings memory leak in _scsynth.cpp: capsule destructor now frees the heap-allocated strings object

  • Windows CI build failure caused by Strawberry Perl's incompatible ccache crashing MSVC (STATUS_ENTRYPOINT_NOT_FOUND); disabled SC's ccache integration on Windows

  • nanobind 2.11 compatibility: replaced capturing lambda in _scsynth.cpp capsule constructor with WorldHandle struct and non-capturing cleanup function

  • Removed 11 type: ignore[arg-type] suppressions from EnvGen by widening UGen.__init__, _new_single, and _new_expanded kwargs to UGenRecursiveInput | None

  • OSC decoder unbounded recursion: _osc.cpp blob/bundle recursive parsing now enforces a maximum nesting depth of 16 levels; beyond the limit, blobs are returned as raw bytes

  • OSC decoder aggregate bounds checking: decode_message_clean pre-validates that the payload has enough bytes for all type tags before entering the decode loop

  • world_send_packet const_cast removal: OSC packet data is now copied into a std::vector<char> before passing to World_SendPacket, eliminating undefined behavior from casting away const on Python bytes

  • scsynth_print_func buffer overflow: replaced fixed 4096-byte stack buffer with a two-pass approach that dynamically allocates when the formatted message exceeds the stack buffer

Changed

  • Protected EmbeddedProcessProtocol._active_world with threading.Lock to prevent race conditions on concurrent boot() calls

  • Cleaned up Envelope.serialize() and UGenSerializable.serialize() signatures: removed unused **kwargs parameter, added docstring documenting the wire format

  • Extracted 6 enum classes (CalculationRate, ParameterRate, BinaryOperator, UnaryOperator, DoneAction, EnvelopeShape) from synthdef.py into enums.py

  • Extracted SCgf binary compiler (_compile_*, _encode_*, compile_synthdefs) from synthdef.py into compiler.py

  • Pinned nanobind>=2.11,<3 in both [build-system] and [dependency-groups]

  • All ValueError raises in synthdef.py (10) and envelopes.py (2) now include descriptive messages

  • Narrowed bare except Exception in osc.py to except (ValueError, IndexError, struct.error)

  • Refactored all 11 demo scripts to use _options_to_world_kwargs() instead of duplicated 25-line _options_kwargs() functions

[0.1.0]

Initial release.

Added

  • SynthDef compiler -- SynthDefBuilder context manager and @synthdef decorator for defining UGen graphs in Python, compiled to SuperCollider's SCgf binary format

  • 290+ UGen definitions across 18 categories: oscillators, filters, BEQ filters, noise, delays, envelopes, panning, demand, dynamics, chaos, granular, buffer I/O, physical modeling, reverb, convolution, I/O, lines, and triggers

  • Envelope system -- Envelope class with adsr, asr, linen, percussive, and triangle factory methods, plus the EnvGen UGen

  • OSC codec -- OscMessage and OscBundle encode/decode with pure-Python implementation and C++ accelerated path via nanobind (_osc.cpp)

  • Embedded libscsynth -- in-process SuperCollider engine via nanobind (_scsynth.cpp), with EmbeddedProcessProtocol for lifecycle management and Options frozen dataclass for server configuration

  • Vendored dependencies -- SuperCollider 3.14.1, libsndfile, and PortAudio built from source via add_subdirectory; SC trimmed to 27MB (from 132MB) with only libscsynth, UGen plugins, and required boost headers; libsndfile tailored for WAV/AIFF only (no external codec deps). Audio backend: CoreAudio on macOS, vendored PortAudio on Linux/Windows

  • Incremental builds -- make build uses --wheel --no-build-isolation with persistent cmake build cache in build/; incremental rebuilds in ~3s

  • Wheel repair -- platform-conditional wheel repair via delocate (macOS), auditwheel (Linux), delvewheel (Windows); SC's macOS POST_BUILD bundle-copy commands disabled to prevent duplicate plugins leaking into the wheel

  • Demos -- three example scripts: sine wave (01_sine.py), subtractive synthesis (02_subtractive.py), and FM synthesis with melody (03_fm.py)

  • Test suite -- 291 tests covering OSC round-trip encoding, SynthDef compilation, UGen instantiation and calculation rates, and server options/lifecycle

  • Full mypy --strict compliance with complete type annotations

  • CI workflow -- GitHub Actions with cibuildwheel building wheels for CPython 3.10--3.13 across macOS ARM64, Linux x86_64, and Windows x86_64; sdist built separately; all artifacts aggregated into a single download

  • Development tooling -- Makefile with dev, build, sdist, test, lint, format, typecheck, qa, clean, and reset targets