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]¶
[0.3.1]¶
Fixed¶
-
OSC blob length integer overflow -> heap over-read (
_osc.cpp):decode_blobrounded the wire-supplied 32-bit blob length up to a 4-byte boundary before the bounds check, so a length nearUINT32_MAX(e.g.0xFFFFFFFF) wrapped to a tiny padded size and slipped past the guard, after which the ~4 GB blob was scanned/copied past the end of the datagram. Reachable from any incoming packet (engine replies are decoded through the same path). The length is now validated against the remaining bytes before the rounding, matching the overflow-safe form already used for bundle elements -
audioSignal * 0andaudioSignal ** 0crashed the engine (synthdef.py): the compiler foldedx * 0 -> 0andx ** 0 -> 1to a scalar constant unconditionally, even when the surviving operand was an audio/control-rate UGen. Feeding that scalar wire into a signal-rate input (e.g.Out.ar(bus, SinOsc.ar() * 0)) made scsynth's SIMDOut_next_a_nova_64read a single 4-byte scalar as an aligned 64-sample block -- an out-of-bounds, often misaligned load that segfaults (and, depending on heap layout, can read garbage instead of crashing). The two rate-downgrading folds now apply only when the surviving operand is itself scalar-rate; otherwise theBinaryOpUGenis kept so the result is a proper rate-matched silence/ones signal. This was the true cause of the real-boot smoke-test crash previously misattributed to the headless audio environment -
Unbounded C++ recursion in OSC encoding (
_osc.cpp):encode_valuerecursed into nested lists/tuples with no depth limit (unlike the decoder), so a deeply nested or self-referential Python sequence overflowed the C++ stack and crashed the process uncatchably. Encoding now bounds nesting depth and raisesValueErrorpast the limit -
Quit-timeout force-cleanup re-introduced a double-free (
scsynth.py,supernova.py): when the engine failed to acknowledge shutdown within 5s, the timeout path freed the nativeWorld/nova_servereven though the wait thread was still insideWorld_WaitForQuit/supernova_runon it -- the exact teardown race the normal path guards against. Shutdown now joins the thread first and, only if it has provably exited, deletes the native object; if the thread is wedged it deliberately leaks rather than free-under-a-live-thread (memory-safe).supernova_cleanupwas split intosupernova_stop(deactivate audio) andsupernova_deleteso teardown can order stop -> join -> delete -
Synchronous requests hung when issued inside an
at()block (server.py):send_msg_sync(and everything built on it --sync,status,version,query_tree,enable_notifications, thesend_synthdefconfirmation) registered a reply waiter and then sent the request, but insidewith server.at(...)the request was captured into the pending bundle instead of being sent, so no reply could ever arrive and the call blocked for the full timeout before reporting a spurious failure on a healthy engine. These now raise a clearEngineErrortelling the caller to move the synchronous call outside theat()block -
Comparison operators did not fold and could crash on cross-scope use (
synthdef.py):>,<,.equal(), and.not_equal()(unlike>=/<=) passed no float operator, so two-constant comparisons emitted a stray scalar UGen instead of folding, and one computed outside a builder then used inside raisedSynthDefError: UGen input in different scope. All six comparisons now fold consistently to1.0/0.0 -
Dwhiteupper bound defaulted to 0.0 (ugens/demand.py): a copy-paste madeDwhite'smaximumdefault0.0instead of1.0, soDwhite()with defaults degenerated to a constant-zero stream. Corrected to1.0(itsDbrown/Diwhitesiblings were already correct) and the language-neutral spec regenerated -
Clock.stop()left heldPmonovoices ringing (patterns.py): stopping a clock marked its players stopped but, unlikePlayer.stop(), never gated off their heldPmonosynths, so a mono voice rang until the server quit.Clock.stop()now releases every player's held voices -
Score.render()/to_binary()did not sort by timestamp (score.py): the NRT command stream is consumed sequentially, so an out-of-order entry (e.g.score.add(2.0, ...)beforescore.add(1.0, ...)) was rendered at the wrong time with no error. Both now serialize a stable-sorted copy, and the teardown guard bundle is appended after the sort so it still runs last -
Option-string use-after-free on abnormal teardown (
_scsynth.cpp): the capsule held theWorld's option strings (mPassword,mRestrictedPath, ...) which scsynth dereferences on every packet/command, but freed them in its destructor without regard to whether theWorldhad been cleaned up. If the capsule was garbage-collected while aWorldwas still live (an abandoned boot with a port open), the running engine read freed memory. The strings are now freed only after an explicitworld_cleanup/world_wait_for_quit; otherwise they are leaked deliberately rather than freed under a live engine -
enable_notificationsfirst-use race (server.py): two threads enabling node notifications concurrently could both install the dispatch handlers and both send/notify 1, so every/n_go//n_end/... fired user callbacks twice. The enable/install transition is now serialized by a dedicated lock -
send_packethandle race on panic (scsynth.py,supernova.py): the status check and the native-handle dereference readself._world/self._serverseparately, so the wait/run thread nulling it on a panic in between could pass a null handle to the native call. The handle is now snapshotted once and re-checked -
Explicit buffer-id reservation could corrupt the allocator (
server.py):_BlockAllocator.reservedid not bounds- or overlap-check the requested id, so reserving an out-of-range or already-allocated id populated the size table without matching a free interval, and a laterfreethen injected a bad interval thatallocatecould hand out.reservenow validates the range and rejects overlap with anEngineError -
Concurrent
boot()on one instance could clobber its state (scsynth.py,supernova.py): the OFFLINE->BOOTING check-and-set was unsynchronized, so two threads booting the same instance could both proceed and leaveis_runningreporting the wrong state. A per-instance lock now serializes that transition -
MIDI handler lists were mutated across threads without synchronization (
midi.py): the RtMidi callback thread iterated the handler lists whileon_*/off_*mutated them from the user thread, riskingRuntimeError: list changed size during iterationor missed/double dispatch during live coding. Dispatch now iterates over a snapshot -
midi_note_mapleaked voices on retrigger and cleanup (midi.py): a second Note-On for a still-held key overwrote the entry and dropped the previous synth un-gated (a stuck voice), andcleanup()left held voices ringing. The map now gates the previous voice off before retriggering and releases all held voices on cleanup -
Constant folding aborted the whole build on math-domain inputs (
synthdef.py): foldingConstantProxy(-1.0).sqrt_(),log(0),(-8) ** (1/3), etc. raisedValueError/TypeErrorout of graph construction, where scsynth would produce NaN/inf. Such folds now defer to a runtime UGen instead of failing the build -
Malformed envelope
curvescorrupted or emptied the envelope (envelopes.py): acurveslist longer than the segment count inflated the compiled segment count (cycling amplitudes/durations), andcurves=None/[]produced a zero-segment envelope that silently dropped every breakpoint. Curves are now normalized to exactlylen(durations)-- cycled if short, truncated if long, defaulting to LINEAR when empty -
Failed supernova boot leaked the process engine claim (
_supernova.cpp,_scsynth.cpp): a boot that failed before the SuperCollider core globals were initialized left the cross-engine coordination env var claimed, wrongly rejecting a later boot of the other engine kind. A scope guard now releases the claim on such pre-init failures (and keeps it once the core exists, since the process is then genuinely tainted), and the supernova RT memory pool is guarded against re-initialization on retry -
A self-freeing
LFGausswas silently optimized away (synthdef.py):LFGaussis marked pure, so anLFGauss(done_action=FREE_SYNTH)whose signal output is unused (used purely to self-free the synth) was dead-code-eliminated by graph optimization, dropping theDoneAction-- the synth would never free. Dead-code elimination now keeps any pure UGen that carries an active (nonzero)DoneAction; aDoneAction.NOTHINGUGen is still eliminated, so the optimization is otherwise unchanged -
Invalid SynthDef/parameter names raised opaque low-level errors (
compiler.py): a name longer than 255 bytes raisedstruct.errorand a non-ASCII name raisedUnicodeEncodeErrordeep in the binary encoder. SCgf string encoding now validates ASCII and the 255-byte limit and raises a clearSynthDefError -
Pfinover-consumed its source, andPtpardropped a leading offset (patterns.py):Pfin(count, p)pulled one extra element from the source before stopping (it now consumes exactlycount, which matters for a shared/side-effecting iterator), andPpar/Ptpardiscarded a nonzero minimum start offset so an all-delayed parallel group started early (it now emits a leading rest for that offset) -
alloc_buffer_from_arraywrote engine memory after a failed sync (server.py): thesync()return was ignored, so on a sync timeout the direct buffer write proceeded against a possibly-unallocated buffer. It now raisesEngineErroron a failed sync instead of writing blindly
Changed¶
-
Server.send_synthdefconfirmation timeout is now a parameter (server.py):/d_recvcompletion is asynchronous and a large SynthDef or a busy engine can take well over the previous hard-coded 100 ms, after which the def was marked loaded anyway and a following/s_newcould fail with "SynthDef not found". The timeout now defaults to 5s (matching the other synchronous calls) and is exposed as atimeoutargument; the wait still returns the instant/donearrives, so a healthy engine is not slowed -
Stochastic patterns accept a
seed(patterns.py):Prand,Pwhite, andPchoosetake an optionalseedand use a per-instance RNG instead of the module-globalrandom. A seeded pattern replays identically on every iteration (reproducible NRT renders), and even unseeded patterns are now independent of unrelatedrandomuse elsewhere in the process -
Ndefregistry keyed on the server object (proxy.py): the named-proxy registry was keyed onid(server)(an int that can be reused after a server is collected) and is now aWeakKeyDictionarykeyed on the server itself, so a collected server's entry is dropped automatically and identity can never be aliased -
In-code documentation of two accepted limitations: the direct in-process buffer transfer (
_scsynth.cppworld_buffer_get/set) now states its safety contract explicitly -- a get/set racing a/b_freeor/b_allocon the same buffer id is a use-after-free, not a benign glitch, so callers must not run buffer commands for that id during the transfer -- and the cross-engine guard (_scsynth.cpp,_supernova.cpp) documents that its coordination env var is inherited acrossfork()and is not atomic against concurrent claims (the two extension modules share no symbols, so a process-local static cannot replace it)
Added¶
-
Real-boot CI job (
.github/workflows/build.yml): atest-realtimejob provisions a virtual ALSA sound card and runstests/test_realtime_smoke.pyagainst a genuinely booted scsynthWorld(the live/syncround-trip, reclaiming allocators, node-free notifications, reboot, and the send/reply deadlock regression), closing the gap where the largest module was previously exercised only by opt-in local runs. Supernova real-boot is intentionally excluded pending a separate teardown/reboot fix -
Golden-fixture render test (
test_integration.py): a new NRT integration test loads each committed.scsyndefgolden fixture verbatim and asserts it renders non-silent audio -- the correctness link the golden-byte test's docstring claimed but did not previously exercise (the byte test only checkscompile() == fixture) -
Independent UGen-default oracle (
test_ugen_spec.py): a hand-verified test of well-known UGen argument defaults (the demand-random UGens plus anchor shapes), transcribed from the SuperCollider class library rather than generated from the same Python classes the spec introspects, so a wrong default like theDwhiteregression is caught instead of being mirrored into the committed spec
[0.3.0]¶
Added¶
-
Timestamped OSC bundle scheduling (
server.py):Server.send_bundle(contents, timestamp)sends an explicit bundle, andServer.at(timestamp)is a context manager that captures every message sent inside the block into a single timestamped bundle. Because it hookssend_msg-- which all OSC-sendingServermethods already route through -- the ordinary high-level API works unchanged inside the block (with server.at(t): server.synth(...)), rather than needing a bundle-aware variant of each method. Node ids are allocated eagerly, so returned proxies are usable before the bundle is sent. Timestamps are Unix epoch seconds; a past timestamp executes on arrival. The capture stack is thread-local (a block open on one thread never captures another's sends), blocks nest into nested OSC bundles, an empty block sends nothing, and a block that raises sends nothing rather than emitting a half-built bundle -
Pattern latency scheduling (
patterns.py):Clock(bpm, latency=...)andPlayer(..., latency=...)(defaulting to the clock's value, so players sharing a clock stay phase-aligned) schedule each event as a bundle stampedtarget + latencyinstead of an immediate/s_newat Python wake time.DEFAULT_LATENCYis 0.1s. Onset accuracy is now set by the engine rather than by when the clock thread woke, so playback holds steady under Python-side load. Verified against a live engine via/n_gonotifications: a bundle stamped 750ms ahead created its node 734ms later, versus 11ms for the unbundled path -
Pattern quantization (
patterns.py):Clocknow maintains a beat grid, andplay(quant=...)starts playback on the nextquant-beat boundary instead of immediately, so parts launched by hand at arbitrary moments still begin together.offsetstarts a given number of beats past the boundary (for backbeats and swung entries).Clock.elapsed_beats,Clock.next_boundary(quant, offset), andClock.reset_grid()expose the grid. All players on a clock share one grid, so quantized starts are mutually aligned -
Pitch and event derivation chain (
patterns.py):Pbindnow implements the sclang derivation chain --degree->note->midinote->freq, plusdb->ampanddur/legato/stretch->sustain/delta-- so patterns can be written in scale degrees rather than precomputed frequencies.degreeindexes ascale(a name from the newSCALEStable or an explicit semitone list), wrapping across octaves, with fractional degrees interpolating between steps;roottransposes andoctave(default 5) places note 0 at MIDI 60. Every step is skipped when the key is bound explicitly, so bindingfreqbypasses the chain entirely. Derivation inputs are treated as metadata and never sent to the synth as controls -
Ppar/Ptpar(patterns.py): run event patterns in parallel, merged into one time-ordered stream. Each voice keeps its ownsustain; onlydeltais rewritten to the gap in the merged stream. Simultaneous events receive a delta of 0 and therefore land in the same bundle timestamp -- verified against a live engine, where coincident events started 0.04ms apart.Ptparadds a per-pattern beat offset -
Pmono(patterns.py): holds a single synth for the lifetime of the pattern and sends/n_setper event rather than creating a node per note, releasing it when the pattern ends or the player stops. This is what produces a monophonic gliding line (portamento, continuous filter sweeps) that a stream of separate synths cannot. Verified live: five events created one node and ended one -
Pdef(patterns.py): a named, hot-swappable event pattern registry -- the pattern analogue ofNdef.Pdef(name, pattern)registers or replaces,Pdef(name)looks up, and a running player picks up a replacement at the next event boundary, so a part can be rewritten without stopping playback -
Pkey(patterns.py): references another key of the event being built, with an optional transform callable. APkeybound to a derivation-chain input (degree,dur,db, ...) resolves before the chain runs so it can drive it; bound to any other key it resolves after, so it can read derived values such asfreq. Patterns do not overload arithmetic operators, so the transform callable replaces sclang'sPkey(\freq) * 2 -
Pfin/Pfindur(patterns.py): bound a pattern by event count or by total duration.Pfindurclips the finaldeltaso the total is exact and the pattern lands on a bar line -
EventPatternbase class (patterns.py):play()moved here fromPbind, so every event pattern (Ppar,Pmono,Pdef,Pfindur) is directly playable.Pbindremains aPatternsubclass, so existing type checks are unaffected -
Demos for the scheduling and pattern features (
demos/scsynth/, run viamake demos):22_bundle_scheduling.pyplays the same 16-click rhythm twice under deliberate GIL load -- once as immediate messages (audibly uneven) and once asserver.at()bundles (rigid) -- then usessend_bundle()for an atomic chord change and a scheduled synchronised release.23_pitch_chain.pyplays one degree sequence through four scales, then variesroot/octave/db/legato/stretch, and drives filter cutoff and pan from the derivedfreqviaPkey.24_parallel_quant.pylayers bass/lead/hats throughPpar, staggers entries withPtpar, and launches parts at deliberately awkward mid-bar moments to showquantsnapping them to the downbeat.25_pmono_pdef.pyplays a line asPbindthenPmonoso the portamento difference is audible, hot-swaps a runningPdeftwice without a gap, and bounds patterns withPfin/Pfindur -
Patterns guide (
docs/patterns.md): documents the event model, the pitch chain, latency and quantization, and the composite patterns, including an explicit list of what is not implemented (array-valued chord keys, pattern arithmetic operators,Score.from_pattern) -
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-styleServerand SuperCollider-style pattern sequencing over the pure-Rustplyphonengine (native andwasm32), 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-benchmarkbenchmarks for the compile hot path (SynthDefBuilder.build()andSynthDef.compile()on a representative subtractive-synth reference graph) and OSC message/bundle encode/decode. Kept out oftests/so the coverage-gatedmake testrun does not time them.make benchruns them,make bench-baselineregenerates the committedbenchmarks/baseline.json, andmake bench-checkfails if any benchmark regresses more than 25% (median) versus the baseline on the same machine. A manual (workflow_dispatch-only)benchmarksCI 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
Fixed¶
- GIL/mutex lock-order inversion deadlocked the process (
_scsynth.cpp,_supernova.cpp,_midi.cpp): the engine's print, reply and MIDI callbacks took their C++ mutex and then acquired the GIL, while the Python-side entry points that share those mutexes (set_print_func,set_reply_func,set_callback, the MIDI capsule destructor, and criticallyworld_send_packet) are entered with the GIL already held and then take the mutex. Any interleaving of an outgoing packet with an incoming reply could therefore deadlock: the reply thread held the mutex waiting for the GIL while a Python thread held the GIL waiting for the mutex, and every remaining thread then blocked behind the GIL with the process hung at zero CPU. All five callbacks now acquire the GIL first, copy the callable under the mutex, release it, and invoke the handler outside the lock -- one consistent GIL-then-mutex order. Dispatching outside the lock also means a slow reply or MIDI handler no longer blocks every concurrentsend_packet, and the MIDI callback now holds its own reference so a handler cleared mid-dispatch cannot be torn down under it.SetPrintFuncwas additionally moved out of the print-mutex scope, where a log line emitted during registration would have re-entered the same non-recursive mutex
Found when a full make demos run hung; diagnosed from a sample(1) trace showing a Python thread blocked in std::mutex::lock() inside _scsynth while an scsynth thread sat in PyGILState_Ensure. A new opt-in regression test (test_realtime_smoke.py::test_concurrent_send_and_reply_does_not_deadlock, gated by NANOSYNTH_TEST_REALTIME=scsynth) drives six sender threads against four reply threads with node notifications enabled; it deadlocks the pre-fix build within seconds and passes on the fix. Verified by rebuilding both ways: pre-fix wedged 4/4 runs, post-fix survived 5/5 at ~140k reply events per 30s run. The test runs the stress in a subprocess so a future regression surfaces as a timeout rather than wedging the pytest session
Changed¶
-
Pattern.play()moved to the base class (patterns.py):play()was initially added to the newEventPatternbase, which left the generic wrappers unplayable --Pn(Pbind(...)), the idiomatic way to loop a part, raisedAttributeError, as didPseq([...]),Pfin(...)andp1 | p2over events. Since those are generic in their element type they cannot subclassEventPattern, soplay()now lives onPatternitself.EventPatternremains as the marker for the event layer. Found while writing the demos -
Player advances by
delta, notdur(patterns.py): the time advance after an event now comes from itsdeltakey (which honoursstretch, and whichPparrewrites to the gap in the merged stream), falling back todurwhen absent. For a plainPbindwith nostretchthe two are identical, so existing patterns are unaffected -
Gate release is now engine-scheduled (
patterns.py):Playerpreviously spawned athreading.Timerper note to sendgate=0.0after the sustain duration. The release is now sent during the same tick as the note itself, stampedonset + sustain, so the engine holds it. This leaks no thread at high event density, cannot drift independently of the clock, and can never fire from Python against an already-quit server (a queued release simply dies with the engine) -
Clock isolates failing players (
patterns.py): an exception inPlayer._tick()previously propagated into the clock's run loop and killed the clock thread, silencing every other player sharing it. A failing player is now logged and dropped while the clock keeps ticking; aPlayerwhose server has quit (EngineError/OSError) ends its own playback cleanly instead of raising on every subsequent tick -
UGen spec now records output arity: each entry in
spec/nanosynth-ugens.jsongains anoutputsfield --{"kind": "fixed", "count": N}for most UGens (0 forOut, 2 forPan2, ...) or{"kind": "variable", "default": N}for multichannel UGens. The@ugendecorator 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.jsonno longer carries agenerator_versionfield, so a version bump no longer trips thetests/test_ugen_spec.pydrift guard. The spec now regenerates only when the UGen metadata or enum tables actually change
[0.2.1]¶
Added¶
-
nanosynth compileCLI command: compiles Python-defined SynthDefs to.scsyndefbinaries for use with a standalonescsynth/supernova,/d_load, or deploy-time precompilation. Loads a.pyfile, discovers every module-levelSynthDef(both@synthdef-decorated functions andSynthDefBuilder.build()results), and writes one<name>.scsyndefper def (-o DIR) or a single bundled SCgf file holding all defs (-b FILE).-n/--nameselects specific defs (repeatable);--anonymousemits MD5-hash names. Reports actionable errors (missing/non-.pyinput, 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 ** 1return the original signal;sig * 0andsig ** 0fold 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 theinfoandcompilesubcommands, 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.scsyndefoffline withnanosynth compile, then load them at runtime via/d_load//d_loadDir//d_recvfrom 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, andunexpanded/pure/output/width-first flags), the operator/rate/done-action enum tables, and anoperator_ugensblock namingBinaryOpUGen/UnaryOpUGenand the enum table theirspecial_indexselects, so a non-Python implementation of the SynthDef frontend shares the drift-prone metadata rather than transcribing it by hand.tests/test_ugen_spec.pyfails if the committed spec drifts from the code (regenerate withpython 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-sourcejob runspytestagainst the source tree (not built wheels) across CPython 3.10--3.14 on Linux, building the C extension from source viauv sync. Closes the gap wheremake testwas 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_moveare parsed intoNodeEventobjects.on_node(callback)receives every event;wait_for_node_free(node_id, timeout)blocks until a node ends (including a self-freeingDoneActionsynth, which the client otherwise has no way to observe); andSynth.wait_free()/Synth.on_free(callback)are per-node conveniences (the latter is one-shot and self-unregisters).enable_notifications()waits for the/done /notifyconfirmation so registration is in effect before any node is created -- otherwise the first node's/n_gocan be missed.NodeEventis exported from the top-level package -
Server introspection & control (
server.py):Server.status()returns aServerStatus(CPU load, sample rate, and ugen/synth/group/synthdef counts from/status.reply);version()returns aServerVersion(/version.reply);query_tree(group, controls=...)returns the live node graph as a nestedNodeInfotree (groups, synths, and their current control values, parsed from/g_queryTree.reply);dump_tree()prints it to the engine log; andreset()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/NodeInfoare exported from the top-level package -
Direct numpy buffer data exchange (
server.py,_scsynth.cpp):Server.get_buffer_data(buffer_id)andset_buffer_data(buffer_id, array)copy samples directly between a numpy array and a buffer's in-process float storage viamemcpy-- no OSC round-trip and none of the/b_getn//b_setndatagram-size limits, which is the core advantage of the embedded engine.getreturns a(frames, channels)float32 array (owning a copy, so it stays valid across buffer reallocation);setaccepts 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), andalloc_buffer_from_array(array)allocates a correctly-sized buffer and fills it in one call (handy for loading wavetables/samples). scsynth-only (raisesEngineErrorfor 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/syncwith a unique id and blocks until the matching/syncedreply, the canonical scsynth flush primitive. ReturnsTrueonce synced orFalseon timeout. Concurrentsync()calls are matched by id -
Reply matchers:
Server.wait_for_reply()andsend_msg_sync()accept an optionalmatchpredicate so a waiter accepts only a reply satisfying it (e.g. a/donewhose 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 raisesEngineErroron exhaustion. Node ids use a wrapping allocator (_NodeIdAllocator) bounded within scsynth's id space (not reclaimed on free, since self-freeingDoneActionsynths 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 raisesServerCannotBootwith 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 viapython 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 byNANOSYNTH_TEST_REALTIME=scsynth|supernova) that boot a real engine and exercisesync(), 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 raiseOscErroron malformed input (the C++ path via a nanobind exception translator scoped to a dedicatedOscDecodeErrorC++ type, so it does not affectstd::runtime_errors from the_scsynth/_supernovaextensions -- nanobind translators are process-global; the Python path by wrappingstruct.error/IndexError/ValueError), andOscErrornow also subclassesValueErrorsoexcept ValueErrorkeeps 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-outwait_for_reply/send_msg_syncwaiter 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 onboot()and is removed onquit(), 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 signedint32_tand bounds-checked it withoffset + (size_t)length > len. A malformed datagram with a negative length cast tosize_tcould wrap that addition and pass the check, then hand a negative/huge size tonb::bytes, causing an out-of-bounds read or oversized allocation. Lengths are now read asuint32_tand validated with the overflow-safeelement_len > len - offset(offset <= lenis guaranteed at that point) -
Engine boot/quit thread coordination (
scsynth.py,supernova.py):_shutdown()previously did a blindthread.join(timeout=5)followed by an unconditional forcedworld_cleanup/supernova_cleanup, which could tear the engine down concurrently with a wait/run thread still insideWorld_WaitForQuit/supernova_run(a double-free / use-after-free race on the engine handle). It now gates on the existingexit_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/_activeflag was left set, permanently wedging every subsequent boot withServerCannotBoot. 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 andboot_futureresolved last, so rollback never races a running loop -
Pattern clock timing drift (
patterns.py):Player._tickadvanced 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.ymlhad itspush: tagsandworkflow_dispatchinputs commented out while the publish steps gated on the resulting always-undefinedinputs.targetand a never-truegithub.event_name == 'push', so no PyPI publish could ever run. Triggers are restored and the PyPI gate now keys onstartsWith(github.ref, 'refs/tags/v').docs.ymlranuv sync --group docsagainst a non-existent dependency group (mkdocs deps live indev); it now runsuv syncand itspush: branches: [main]trigger is restored
Changed¶
-
Synchronous SynthDef load matches the sub-command (
server.py):send_synthdef()now waits for a/donewhose first argument is/d_recv, so a/donefrom 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 oftime.sleep(server.py):record()andstop_recording()replaced their fixedtime.sleep(0.1)pauses withself.sync()barriers, so the disk buffer is provably open beforeDiskOutstarts and final samples are flushed before the file is closed -
Single-sourced package version: the version was hard-coded in both
pyproject.tomlandsrc/nanosynth/__init__.pyand could drift.pyproject.tomlnow declaresdynamic = ["version"]and reads__version__fromsrc/nanosynth/__init__.pyvia scikit-build-core's regex metadata provider, making__init__.pythe single source of truth
[0.1.6]¶
Added¶
-
nanosynth infoCLI 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/-lflag lists all available UGen classes alphabetically (341 UGens). Entry point registered via[project.scripts]inpyproject.toml. Usesargparsesubparsers 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-localSynthDefBuilderstacks 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,@synthdefdecorator, 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 coveringMulAdd,Sum3,Sum4, andMix-- 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 ofbasic.pyraised 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 viafrom nanosynth.exceptions import ....NanosynthErrorbase class withOscError(OSC encode/decode),EngineError(engine lifecycle),ServerCannotBoot(boot failures, subclass ofEngineError),MidiError(MIDI port/callback), andSynthDefError(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_recvreply before returning (0.1s timeout), ensuring the SynthDef is ready for immediate use. Previously the fire-and-forget/d_recvcaused race conditions on supernova where/s_newcould 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 (noDoneAction.FREE_SYNTHor explicit/n_free) containing delay UGens (DelayN, CombC, etc.) reading from private buses viaIn.arwere 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:ServerCannotBootmoved fromscsynth.pyandSynthDefErrormoved fromsynthdef.pyinto the newexceptions.pymodule. Both are re-exported from their original modules for internal use but the canonical import path is nownanosynth.exceptions -
Narrowed broad
except Exceptioncatches:server.py:_dispatch_replyOSC decode catch narrowed to specific decode-failure types (ValueError,IndexError,struct.error,OscError,RuntimeError).patterns.py:_release_synthnarrowed to(EngineError, OSError). Handler callback catch retained asexcept Exceptionwith explicit annotation for intentional user-callback isolation -
Replaced generic
RuntimeErrorwith typed exceptions:scsynth.pyandsupernova.pysend_packetraiseEngineErrorinstead ofRuntimeErrorwhen the server is not running.server.pyraisesEngineErrorfor bus/recording state errors.proxy.pyraisesEngineErrorfor missing source synth.midi.pyraisesMidiErrorfor port-not-found.osc.pyraisesOscErrorfor unparseable type tags
[0.1.5]¶
Added¶
-
Embedded supernova:
EmbeddedSupernovaProtocolruns SuperCollider's parallel DSP engine (supernova) in-process via nanobind (_supernova.cpp), as a drop-in replacement forEmbeddedProcessProtocol. SameServerAPI -- just passprotocol=EmbeddedSupernovaProtocol(). Supernova schedules independent nodes across CPU cores when placed inParGroups, providing parallel DSP execution that scsynth cannot. C++ wrapper (_supernova.cpp, ~400 lines) wraps supernova's C++ classes directly (no C API exists), synthesizingserver_argumentsfrom Python kwargs, implementing a custom reply endpoint for in-process OSC responses, and managing the event loop on a daemon thread. Supernova plugins (24_supernovavariants) are compiled withSUPERNOVAdefined and bundled alongside scsynth plugins. Build gated byNANOSYNTH_EMBED_SUPERNOVA=ONCMake option -
ParGroup support:
ParGroupproxy class (subclass ofGroup) andServer.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.scsyndeffile (with optionaluse_anonymous_nameflag).Server.load_synthdef(path)loads a.scsyndeffile into the engine via/d_load, resolving to an absolute path for the engine -
Demo scripts reorganized into
demos/scsynth/(21 demos) anddemos/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).Pbindbinds keys to patterns/scalars to produceEventdicts, stops on shortest pattern, merges with configurable defaults, auto-derivessustainfromdurandfreqfrommidinote.Clock(background daemon thread withtime.monotonic()scheduling, settablebpm) andPlayer(pulls events, creates synths, schedules gate release viathreading.Timer).Restsentinel 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),MidiInclass 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) andmidi_cc_map()(CC values scaled to parameter range). Build integration:NANOSYNTH_EMBED_MIDICMake option, rtmidi built as static library viaadd_subdirectory -
NodeProxy / Ndef:
NodeProxyowns 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 inSynthDefBuilderwith envelope) or aSynthDef. 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.Ndefis a global named proxy registry:Ndef(server, "name", source)creates or retrieves aNodeProxy,Ndef.clear_all(server)frees all proxies for a server. Registry keyed by(id(server), name)to support multiple servers -
Bus allocation:
Busproxy class withServer.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.Bussupportsint()conversion,set()for control buses, equality/hashing, andfree().Server.optionsproperty exposes theOptionsconfiguration -
Server recording:
Server.record(path)captures real-time audio output to WAV/AIFF via DiskOut.stop_recording()finalizes the file.is_recordingproperty for state inspection. Configurable channel count, bus, header/sample format. Recorder SynthDef built and cached on first use per channel count.write_buffer()gains aleave_openparameter for disk-streaming buffers -
NRT (non-real-time) rendering:
Scoreclass 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++ bindingworld_nrt_render()wrapsWorld_NonRealTimeSynthesiswith configurable sample rate, format, channels, and engine options -
SynthDef graph introspection:
SynthDef.graph()returns aSynthDefGraphNamedTuple containingUGenNodeandUGenInputstructures 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, theunexpandedflag, parameter rates, the builder scope, graph optimization (constant folding and dead code elimination), width-first ordering, and theDefaultsentinel. Each section includes code examples and practical implications
Changed¶
-
Demos 01--11 converted from low-level
_scsynthAPI to high-levelServerAPI (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 withAddAction, 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()usedtempfile.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) inWorld_New. Replaced withtempfile.mkstemp()so the file is closed beforeworld_nrt_renderreads it, with manual cleanup in afinallyblock
[0.1.3]¶
Added¶
-
Synth/Groupproxy objects:Server.synth()andServer.group()now return lightweightSynthandGroupproxy 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()andmanaged_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 toParameter(value=440.0, rate=ParameterRate.AUDIO). Accepts string rate tokens ("ar","kr","ir","tr") orParameterRateenum values -
Tuple syntax for
SynthDefBuilder: parameters can be specified as tuples --SynthDefBuilder(freq=("ar", 440.0))for(rate, value)orSynthDefBuilder(amp=("kr", 0.5, 0.1))for(rate, value, lag). Works alongsidefloat,Parameter, andcontrol()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 viafrom nanosynth.ugens import *or qualified imports -
Extended operators:
BinaryOperatorexpanded from 7 to 43 entries,UnaryOperatorfrom 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 foldfloat op floatat compile time -
POWER optimizations in
BinaryOpUGen._new_single:x ** 0folds to1,x ** 1folds tox -
Buffer management on
Server:alloc_buffer(),read_buffer(),write_buffer(),free_buffer(),zero_buffer(),close_buffer(),next_buffer_id(), plusmanaged_buffer()andmanaged_read_buffer()context managers. Buffer IDs are auto-allocated (monotonically from 0) or explicitly specified; allocated buffers tracked in_allocated_buffersset -
Reply handling: C++ reply callback (
set_reply_funcin_scsynth.cpp) routes OSC responses from the engine back to Python;EmbeddedProcessProtocol.set_reply_callback()wires it at boot;Servergains_dispatch_reply()router,on()/off()for persistent handlers,wait_for_reply()for blocking one-shot waits, andsend_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 viamake docs-deploy. Newdocsdependency group inpyproject.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 andfrom_expr()methodssynthdef.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:Envelopeclass with full Args section, all 5 factory methods (adsr,asr,linen,percussive,triangle),EnvGenwith parameter descriptionsosc.py:OscMessage,OscBundlewith public method docstrings (to_datagram,from_datagram,to_list),find_free_port()scsynth.py:Optionswith commonly adjusted fields,BootStatus,ServerCannotBoot,EmbeddedProcessProtocol.boot()and.quit()-
compiler.py:compile_synthdefs()with Args/Returns documenting the SCgf binary format -
AddActionenum (enums.py):ADD_TO_HEAD,ADD_TO_TAIL,ADD_BEFORE,ADD_AFTER,REPLACE-- replaces opaque raw ints inServer.synth(),Server.group(), and their managed variants. Raw int values still accepted for backwards compatibility -
ServerProtocolstructural type (synthdef.py):SynthDef.send()andSynthDef.play()now accept any object satisfyingServerProtocolinstead ofAny, 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 singlefrequencywhere SC expectsmin_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++ -- singlefrequency, no min/max). All three now match theirZIN0()indices exactly -
Klank audio rate (
ffsinosc.py):Klank.ar()passedcalculation_rate=Noneto_new_expanded, which resolved toCalculationRate.SCALAR-- the filter bank was computed once at init instead of processing audio samples per block. Changed toCalculationRate.AUDIO -
ParameterRate.from_expr("tr"): the"tr"token for trigger rate was missing from the string-to-enum mapping, causingKeyErrorwhen usingcontrol(rate="tr"). Added alongside"ar","kr","ir" -
help(nanosynth)crash: dynamically generated rate methods (.ar,.kr,.ir) created viaexecin_create_fnhad__module__ = None, causingpydocto raiseTypeError: unsupported operand type(s) for +: 'NoneType' and 'str'when rendering help text. Now sets__module__from the owning class before applying decorators. -
__bool__trap onUGenOperable:UGenOperable.__bool__now raisesTypeErrorinstead of silently returningTrue. Catches the common footgun whereif sig > 0:always takes the truthy branch because comparison operators returnUGenOperableobjects, not booleans -
Server.quit()decoupled from protocol internals:Server.quit()now delegates toEmbeddedProcessProtocol.quit()instead of reaching into the private_shutdown()method, properly setting theQUITTINGstate for clean shutdown callbacks -
Thread-local builder guard centralized: replaced three inconsistent
hasattr(_local, "_active_builders")guard patterns inSynthDefBuilder.__init__,__enter__, andUGen.__init__with a single_get_active_builders()function -
Topological sort descendant ordering:
_initiate_topological_sorthadkey=lambda x: ugens.index(ugen)which captured the loop variable, making the sort a no-op. Fixed tokey=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 34PV_*analysis/resynthesis UGens, plusRunningSum - 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) -
Defaultsentinel class insynthdef.pyfor parameters whose defaults are computed from other parameters at construction time (used byFFT,Gendy1-3,ScopeOut2) -
PseudoUGenbase class for virtual UGens that compose other UGens (Mix,Changed,CompanderD,LinLin,Silence,Splay) -
_postprocess_kwargshook onUGen.__init__for transforming parameters at construction time (dynamic channel counts, Default resolution, rate forcing) -
GREATER_THANandLESS_THANbinary operators with corresponding__gt__/__lt__onUGenOperable -
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¶
LocalBufcrash:SynthDefBuilder.build()now runs a_cleanup_local_bufspass that automatically inserts aMaxLocalBufsUGen whenLocalBufinstances are present in the graph (e.g. fromFFT's auto-allocated buffer). Without this, scsynth would crash withLocalBuf tried to allocate too many local buffers
[0.1.1]¶
Added¶
-
Serverclass (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()andServer.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)andSynthDef.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'sSynthDef.dumpUGens), showing UGen types, rates, input wiring, operator names, and multi-output counts -
Envelope.compile()-- dedicated serialization path producingtuple[float, ...]directly, bypassing UGenVector/ConstantProxy; raisesTypeErroron UGen inputs.serialize()retained for UGen graph wiring -
Demo scripts
12_server_sine.py(sine wave via Server API) and13_server_pad.py(gated pad chord progression withmanaged_synth,managed_group, andserver.set()) -
EmbeddedProcessProtocol.send_packet()andsend_msg()convenience methods for sending OSC to the engine without importing_scsynthdirectly -
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)
-
EmbeddedProcessProtocolstate machine tests: initial state, quit no-op, send errors when offline, callback storage, boot-when-active guard -
Test coverage for
SynthDefBuildercross-scope errors, graph optimization (_optimize/_eliminate),Envelope.linen/.triangle/.asrfactory methods, multi-channel UGens (In,PanAz,DecodeB2),compile_synthdefswith multiple SynthDefs, demand-rate UGens (Dseq,Drand,Duty, etc.), and@synthdefdecorator (trigger/audio/lag rates, complex graphs) -- 54 new tests (322 -> 376) -
qaCI 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_dispatchfor 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
atexitguard in_scsynth.cpp(afterWorld_New) that calls_exit(0)before CoreAudio's static destructors run; removedos._exit(0)from all 11 demo scripts -
WorldStringsmemory 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.cppcapsule constructor withWorldHandlestruct and non-capturing cleanup function -
Removed 11
type: ignore[arg-type]suppressions fromEnvGenby wideningUGen.__init__,_new_single, and_new_expandedkwargs toUGenRecursiveInput | None -
OSC decoder unbounded recursion:
_osc.cppblob/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_cleanpre-validates that the payload has enough bytes for all type tags before entering the decode loop -
world_send_packetconst_castremoval: OSC packet data is now copied into astd::vector<char>before passing toWorld_SendPacket, eliminating undefined behavior from casting away const on Python bytes -
scsynth_print_funcbuffer 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_worldwiththreading.Lockto prevent race conditions on concurrentboot()calls -
Cleaned up
Envelope.serialize()andUGenSerializable.serialize()signatures: removed unused**kwargsparameter, added docstring documenting the wire format -
Extracted 6 enum classes (
CalculationRate,ParameterRate,BinaryOperator,UnaryOperator,DoneAction,EnvelopeShape) fromsynthdef.pyintoenums.py -
Extracted SCgf binary compiler (
_compile_*,_encode_*,compile_synthdefs) fromsynthdef.pyintocompiler.py -
Pinned
nanobind>=2.11,<3in both[build-system]and[dependency-groups] -
All
ValueErrorraises insynthdef.py(10) andenvelopes.py(2) now include descriptive messages -
Narrowed bare
except Exceptioninosc.pytoexcept (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 --
SynthDefBuildercontext manager and@synthdefdecorator 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 --
Envelopeclass withadsr,asr,linen,percussive, andtrianglefactory methods, plus theEnvGenUGen -
OSC codec --
OscMessageandOscBundleencode/decode with pure-Python implementation and C++ accelerated path via nanobind (_osc.cpp) -
Embedded libscsynth -- in-process SuperCollider engine via nanobind (
_scsynth.cpp), withEmbeddedProcessProtocolfor lifecycle management andOptionsfrozen 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 builduses--wheel --no-build-isolationwith persistent cmake build cache inbuild/; incremental rebuilds in ~3s -
Wheel repair -- platform-conditional wheel repair via delocate (macOS), auditwheel (Linux), delvewheel (Windows); SC's macOS
POST_BUILDbundle-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 --strictcompliance 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, andresettargets