Design decisions & findings

The non-obvious choices behind Clausters, and the upstream bugs we had to work around — the "why it is this way" that the code alone cannot explain. Each entry is written in ADR spirit: context → decision → consequence.

This is a curated record, not a changelog. What shipped and when lives in the git history; the full historical build journal (kept for reference, frozen) is history/build-log.md. When a milestone makes a choice with non-obvious context, add a short entry here — not a per-milestone diary.

Threading and the RT-safe boundary

The OSC network thread may allocate, lock and do I/O freely; the audio thread (the cpal callback, or render() in NRT) may do none of those. Everything the audio thread needs arrives fully pre-built over lock-free FIFOs, and freed memory leaves through a garbage FIFO to be dropped on the network thread.

  • Defs and their control-name tables live only on the network thread. The HashMap<String, Arc<SynthDef>> and a mirror node_id → Arc<SynthDef> (kept from /s_new, cleaned on Garbage::Freed) stay off the audio thread, so Cmd::SetControl is plain-old-data and the audio thread never compares strings. /d_free only removes the map entry — live synths keep their Arc (exact scsynth semantics).
  • UGen wiring allocates nothing. UGenSynth::process builds each UGen's inputs in a fixed stack array (MAX_UGEN_INPUTS) via split_at_mut over the wires; the topological order guarantees inputs only read earlier wires. Guarded by assert_no_alloc.
  • Asynchronous command semantics are deliberate. A /status immediately after a command may report the old count: commands apply at the start of the next block. That is scsynth's model, not a race.

SynthNode: one trait, symmetric def families

The node tree and FIFOs handle Box<dyn SynthNode>, never a concrete synth type. This was introduced before it was strictly needed so that a second def family (Faust) could join the tree without touching the engine or the tree. Consequence: the synth (SynthDef/UGen) and faust (FaustDef) families are independent Cargo features that combine freely — new work feature-gates against dyn SynthNode and stays symmetric.

Buses, execution order, and the network-thread mirror

  • Control buses bypass the command FIFO. /c_set//c_get operate directly on shared atomics from the network thread; a synth sees the change on its next block — the same effect as routing through the FIFO, without the traffic.
  • Execution order is audible and testable. Out sums, ReplaceOut overwrites; the order tests use a ReplaceOut(0.0) "silencer" that wins or loses a bus depending on whether it runs after or before the source.
  • The mirror can run ahead of the engine. The auto-order mirror reflects commands when sent, so a scheduled future bundle is already mirrored; queryTree may briefly show that future state and a re-sort converges on the next change. Correctness never depends on the mirror (see parallel groups).

Parallel groups are bit-identical to sequential

The safety of parallelism must not depend on the network mirror (which can run ahead). So BusUsage masks travel to the engine inside Cmd::AddSynth (and are re-sent by Cmd::SetUsage when a /n_set touches a control used as a bus index), and stage partitioning happens on the audio thread with its own data — pure bitops, no allocation. A greedy per-block rule in child order closes a stage on the first bus conflict, so writers to the same bus serialize in order.

Consequence: a stage's members touch pairwise-disjoint buses and never read what the stage writes, so results are independent of interleaving and the stages preserve order — --workers changes only wall-clock time, never the samples. Golden and RT/NRT-identity tests guard this.

Denormal protection (three independent pieces)

Subnormals appear in recursive states decaying to zero (filter tails, envelopes, Faust recursions) and resolve 10–100× slower in microcode on many CPUs — exactly as a sound fades out. Three guards, kept in lockstep:

  1. dsp::denormals::flush_to_zero() puts the calling thread in flush-to-zero mode (MXCSR FTZ+DAZ on x86-64, FPCR.FZ on aarch64, via inline asm — the _mm_setcsr intrinsics are deprecated). Re-armed in every cpal callback and armed at the start of render(): FTZ changes results, so NRT must arm it too to stay sample-identical to live.
  2. -ftz 2 in every Faust factory flushes recursive variables below the normal range, independent of architecture and thread FPU mode.
  3. The one exception: the Faust compiler path runs inside dsp::denormals::normal_precision — libfaust's interval typing aborts under FTZ/DAZ, and the guard restores the armed mode on exit.

Guarded by tests/denormals.rs and the Faust-tail test in tests/golden.rs.

Faust embedding: decisions and upstream bugs

  • Ubuntu's libfaust is unusable for embedding — built without the LLVM backend and shipped without headers. libfaust must be built from source with the LLVM backend; the reproducible recipe lives in BUILD.md.
  • A hand-written FFI, not a crate. faust-build/faust-types do Faust→Rust codegen at build time (they need the faust compiler and static DSP source) and do not embed the JIT; there is no maintained libfaust binding. We bind ~30 functions by hand against the real headers, avoiding a libclang build dependency. Because dynamic linking is lazy, a mistyped FFI symbol only fails when called — so a "kitchen sink" test exercises every schema op once.
  • libfaust does not tolerate concurrent compilation. Two compiler threads in one process SIGSEGV — Faust's global compiler state is not thread-safe, even for createCDSPFactoryFromString. Fix: a process-global compiler::ffi_lock() around every compilation call. A server has a single compiler thread, but the test harness (and any multi-server embedder) needs the lock. With every FFI compilation path behind it, the faust suites now run in the ordinary parallel harness; the historical --test-threads=1 rule is retired (a SIGSEGV there would mean a path skipped the lock).
  • Instantiation does not take the lock. Creating instances from an already-compiled factory is independent of the compiler's global state (JIT code + malloc; FaustLive does it concurrently with compilations). The lock stays only for compiling. (Open caveat: deleteDSPFactory may touch the global factory table and can flake under parallel test load — non-blocking for a real single-compiler server.)
  • Upstream bug — boxFmod() returns abs. boxFmod() in compiler/box_signal_api.cpp returns the abs primitive (a copy-paste bug, present through master-dev). Workaround: build fmod from a CDSPToBoxes("process = fmod;") fragment instead of the binding.
  • Upstream bug — the cos box returned abs likewise; fixed the same way.

Faust is a default feature, and the wheel ships it

faust used to be an opt-in Cargo feature, purely to keep a development build free of the libfaust dependency. The cost of that convenience landed on the user: the packaged artifacts were built with the default features, so an installed wheel could not compile a FaustDef at all — /d_faust replied /fail, and one of the two def families was, in practice, unavailable. Two families we document as peers cannot have one of them missing from the product.

So faust joins the default set, and the packaging bundles it:

  • A default build needs libfaust (built with the LLVM backend, a one-time from-source install; BUILD.md). Building without it stays supported and tested — --no-default-features --features synth,realtime,… is a SynthDef-only server with no libfaust on the machine — but it is now the explicit choice, not the default one.
  • The wheel carries libfaust and libLLVM in clausters/_libs/, staged by clients/python/build_native.py off the built artifacts (keyed by the exact soname the loader asks for). libLLVM is ~130 MB, which takes the wheel from a few MB to ~50 MB packed. That is not accidental weight: the Faust JIT is LLVM, and the alternative (static LLVM inside libfaust) is no lighter — the server binary and the embed cdylib would each embed their own copy, where the bundled shared library is loaded once by both.
  • DT_RPATH, not DT_RUNPATH. build.rs emits -Wl,--disable-new-dtags with an rpath of $ORIGIN, $ORIGIN/../_libs and the build prefix. The $ORIGIN entries make the artifacts relocatable (the wheel's _bin/clausters finds ../_libs/libfaust.so.2), and DT_RPATH is required because only it is inherited by transitive dependencies: libfaust itself carries no rpath, so its libLLVM is resolved through ours. With RUNPATH the loader would fall back to the system libLLVM — or find none.

CI with a default faust: one shared libfaust, and a baseline JIT CPU

Making faust default turned every job that links the default build (clippy, the default-set tests, the Python client, the release wheel/server) into a libfaust consumer, where before only one dedicated job needed it. Two choices keep that cheap and green:

  • One libfaust job builds it; everyone else restores a cache. A single job runs the from-source build and, crucially, stages the libLLVM it JITs with into <prefix>/lib beside libfaust.so. Downstream jobs needs: it and restore the cache through the .github/actions/libfaust composite; the same DT_RPATH that makes the wheel self-contained (<prefix>/lib, inherited transitively) then resolves both libfaust and its libLLVM, so a consumer needs no LLVM runtime installed — only the restore. A warm cache makes every downstream job free. release.yml uses the same composite, so the wheel build no longer sets up libfaust by hand.
  • The build is vendored and pinned, so the bundle is reproducible. The Faust source is too heavy to commit (it stays git-ignored under third_party/faust), so reproducibility lives in two committed files: third_party/faust.pin (the exact commit — the unpatched base, keeping the boxcos/boxfmod canaries valid — and the LLVM version) and third_party/build-faust.sh (the one recipe). The composite, release.yml and a developer all run that script and read that pin — the CI cache key included — so the bundled libfaust/libLLVM are the same everywhere, not whatever the build host defaulted to. LLVM is pinned to 18: it is Ubuntu 24.04's default (CI installs it from the distro repos, no apt.llvm.org), and the distro build targets a baseline x86-64, which keeps the wheel's ~130 MB libLLVM portable — it runs on any x86-64 CPU. A newer upstream build (e.g. apt.llvm.org's 21) is built for a higher baseline and can itself hit an illegal instruction on older machines, so it is the wrong thing to ship; a developer who happens to have another LLVM builds locally with LLVM_CONFIG=llvm-config-NN (the FFI surface is version-independent).
  • CI pins a baseline JIT target (CLAUSTERS_FAUST_TARGET). The Faust factory JITs for "" = the host machine, and LLVM tunes the code to the detected CPU — correct and fast on a real machine, which is why production leaves it unset. But virtualized CI runners can report CPU features the hypervisor then traps, so host-tuned JIT code hits an illegal instruction at run time on some Azure SKUs (seen intermittently, VM-dependent). The override forces a baseline x86-64 target. It must be a full triple (x86_64-unknown-linux-gnu:x86-64): faust sets mcpu from the string, but with an empty triple newer LLVM still host-detects the CPU features and re-introduces the crash — only a concrete triple pins it down. The override is a plain env var read by faust::compiler::host_target, so only CI opts in.

Def persistence: transparent JSON + a non-authoritative bitcode cache

Two layers, decided with the user:

  • B — JSON is the transparent source of truth. synthdefs/<name>.json is the SynthDefSpec verbatim; faustdefs/<name>.json is a FaustRecord (source/JSON + libfaust version + payload sha256). Reload = recompile from there, by the same path as a fresh /d_recv//d_faust. The FaustDef itself is never serialized — its factory is opaque LLVM JIT state.
  • A — the LLVM bitcode cache is non-authoritative. faustdefs/<name>.<sha16>.bc is restored only if the libfaust version matches and the file reads well; any miss recompiles from source and rewrites it. A libfaust upgrade invalidates every .bc automatically, and a corrupt cache never serves a wrong def (named by payload sha, so a stale .bc never pairs with a newer record).

MIDI: standard channel-voice, byte-identical to OSC, in a shared crate

  • A MIDI voice realizes the same OSC commands an OSC client would send. CmdTranslator::translate_midi maps note-on → /s_new (with freq/amp from named conversions), note-off → /n_free or /n_set gate 0, aftertouch/CC/bend → /n_set on live voices. Reusing the OSC path makes a MIDI voice byte-identical to its OSC equivalent, and the reserved voice-ID range (MIDI_NODE_ID_BASE) stays disjoint from client and /s_new -1 IDs.
  • MIDI lives in a reusable native crate, not the Python client. The original plan (MIDI 1.0 in a Python library) was scrapped: MIDI belongs in crates/clausters-midi (a versioned C ABI, shared by client and server), with the message layer in MIDI 2.0/UMP for high resolution (16-bit velocity, 32-bit controllers, no 7-bit loss), persistence to a MIDI 2.0 clip file and to .mid (SMF) for interop.

One global transport; the server broadcasts control, not audio

Decided with the user: a single global transport, and a conductor's play/stop/locate drives every client's playhead in lockstep. The server broadcasts transport control (/transport_play|stop|locate, pushed to /notify clients) and never schedules audio — each client rolls its own playhead on the shared grid. The /transport.reply grew playing and position fields, kept backward-compatible (older clients read the first three).

One seeded random context per script

Per-pattern seeds were a design error (corrected with the user): a piece is only reproducible end-to-end if everything random shares one seedable context — the sclang model. main.seed(n) seeds the root; every Routine/Stream derives its own generator at creation from the creating context; a draw uses the running routine's generator (thread-local), falling back to the root outside a routine. One root seed reproduces a whole script in creation order, and concurrent routines stay reproducible per routine regardless of wake interleaving. The generator itself is the core's seeded Rng over the FFI, so a seeded script replays identically in every client language.

Real-time scheduling: opt-in, then restored as default

A two-step decision worth recording because it reversed:

  • First made opt-in (M24b): the default rtprio promotion tripped RTKit's RLIMIT_RTTIME watchdog under sustained overload and the kernel killed the process with SIGXCPU — a silent death that hung clients. Overload must break the audio, never the process.
  • Then restored as default (M24c): with a SIGXCPU guard in place (sustained overload demotes the audio thread to SCHED_OTHER — audio degrades, server survives), only the performance cost of not scheduling RT remained. Measured on one machine: ~300 stable 1-sine nodes as SCHED_OTHER (124% peak at 36% avg) vs ~500+ as SCHED_RR (92% peak at 34% avg) — the average misleads, the callback must fit its worst block. An RT-scheduled callback is the standard operating mode of every production Linux audio client, so the default came back, kept safe by the guard.

Editor y-axis navigation: gestures on the ruler strip, props in display units

The editor-grade views (waveform, spectrogram) zoom and pan vertically. Two choices were on the table and both are worth recording:

  • Gesture surface: the y-ruler strip, not a modifier over the body. The wheel over the strip zooms the vertical axis (anchored at the cursor's height within its lane) and dragging the strip pans it; the wheel over the body stays horizontal zoom and plain/Shift drag keep selecting/panning time. Spatial separation needs no modifier chord, leaves every existing body gesture untouched, and matches the audio-editor convention (Audacity, most DAWs pan/zoom an axis by grabbing its ruler). The strip only exists when the ruler is on (ruler_y != "off"), which is also the only time the axis has visual feedback to navigate against.
  • Prop shape: y_start/y_len in normalized display units (0 = axis bottom, 1 = top; 0, 1 = no zoom; a non-positive y_len resets), on the shared EditorProps — not amplitude/frequency values. Display coordinates make the anchor math linear and unit-independent: the spectrogram's window survives switching freq_scale between linear/log/mel/bark without moving what is on screen (the nonlinearity lives entirely in the shader's display→bin mapping, as with the shader uniforms), and the waveform's amplitude window composes with AMP_MARGIN without leaking it into the protocol. Living in the widget tree means /gui_set drives it and the browser renders it through the same shared frame path with zero extra wiring (display + /gui_set parity; drag/wheel gestures stay native for now). Changes emit /gui_event id "view_y" y_start y_len, the "view" posture.

The y state is deliberately per widget while the horizontal view is slated to move into shared navigation groups (linked views): two lanes of one file scroll in lockstep in time, but each keeps its own vertical slice.

Linked views: explicit groups in the host core, shaped for the multitrack view

The linked editor views (a waveform lane and a spectrogram lane navigating as one item) settled four decisions:

  • Grouping is explicit — a link (int) prop — not implicit by shared source. An editor item legitimately wants unlinked views of one file (compare two zoom levels), and a link legitimately spans sources (aligned takes), so the source is the wrong grouping key on both sides. A negative link unlinks live, and the widget carries its current view along (unlink-to-diverge is the workflow); joining an existing group adopts it.
  • Group events carry the interacted member's id, not a group id. Every /gui_event names a widget id; a group id is not a widget and would fork the event shape. A gesture emits "view"/"selection" once — linked members repaint but do not re-emit — and a script that cares which lane was touched gets that for free.
  • Shared chrome is plain composition, not an automatic strip. A stack of linked lanes that wants one time ruler sets ruler:"off" on all but one — existing containers plus existing props, no new container kind, no protocol change. An automatic shared strip would need cross-widget layout coupling for something a prop already expresses.
  • The group state lives in the host core and navigates in session-timeline units. The state (horizontal view + selection + playhead anchor, in host/timeline.rs) is keyed by group, not by window: membership spans windows and fronts, the group's length is the max over its members' registered data extents (a shorter take just ends earlier), and both fronts drive it through the same Host ops — the /gui_set interception lives in the core dispatch, so the browser gets group semantics with no front code. This shape is deliberately the seat of the future multitrack (DAW-style) view: a clip item there is a member with a placement (a start offset) on the same shared timeline — the one designed extension point; today every member sits at offset 0. Selection/playhead are mirrored group→members' EditorProps so everything that reads the widget tree (renderer, playhead animation, readouts) keeps working; the group is the single writer, so the mirrors cannot drift.

Edit-back-to-data: flat event payloads, the binding path generalized to lists

The bpf envelope editor is the first widget that writes data back, and the pattern it establishes is meant to be reused verbatim by the later drawn-buffer and automation-lane cases. Four decisions:

  • Edited data flows to the script as new event payloads, never new addresses. An edit emits /gui_event <id> <tag> <flat values...> — for the envelope, "points" followed by t v shape curve per breakpoint, ints kept int and floats float. The /gui_* address family does not grow per widget; bulk edits (a drawn buffer region) would ride one blob in the existing samples_to_blob little-endian f32 layout. The flat-primitives boundary rule holds on the way back exactly as on the way in.
  • The server-bound direction is the widget-value binding generalized to a list. A bound editor forwards its whole edited structure straight to the audio server — Binding::message_args/Host::forward_args send addr prefix… values…, the multi-argument form of the bound knob's addr prefix… value — bypassing the script exactly as /gui_bind promises. No new binding vocabulary: the same destination, prefix and prune rules.
  • The host's mapped resources stay read-only. Edits mutate the host's typed tree and flow out over the two channels above; the mmap bulk path is never written through. Anything that must land in a shared file or a server buffer goes through the server (or the script), so ownership of bulk resources stays single-writer.
  • The envelope shape math lives once, in the core. The editor draws segments through clausters_core::envshape::shape_value — relocated from the server, whose EnvGen now delegates to it (the same move the forward FFT made) — so what the editor draws is what the server plays by construction. Its FFI export is deferred until a client actually evaluates envelopes client-side (the tap-reader precedent): the Python leg only maps breakpoint lists to/from Env, which needs no curve evaluation. The breakpoint model, hit-testing and drag clamps are display logic and stay gui-side (host/bpf.rs).

The widget model itself is deliberately the future automation-lane shape: values in any [min, max] (bipolar, unipolar, on/off lanes via the hold shape — SC's step jumps to the target level at segment start, so a step segment shows the next point's value), every standard EnvGen transition curve, an optional exponential display scale for frequency-like ranges, and times over an explicit [0, duration] domain — so a multitrack automation view later composes this model instead of designing a new one.

The multitrack editor: beats in the data, samples on the axis, and one converter

The arrangement places elements in beats; the multitrack view places clips in timeline samples. Two units, and the temptation is to pick one. Both are load-bearing:

  • The view must be in samples. A clip's body is audio data, and the placement work established that a member's data sample 0 sits at its offset. A take placed at its own frame count then lands 1:1 on the axis, and its waveform draws where it sounds. In beats, every body would need a scale factor that only the client knows.
  • The arrangement must be in beats. Its elements are musical, tempo-relative, and they render onto a beat clock. Baking a sample rate into them would tie a composition to one engine.

Decision: keep both, and make the editor driver the only converter — one beat is sample_rate / tempo timeline units. It is also where a musical quant becomes the lane's pixel-drag grid, so the grid a clip is dropped on is the grid the arrangement re-schedules on. The arithmetic itself is the core's (beats_to_secssecs_to_samples), never a second implementation, so a port inherits it.

Consequence: clausters.form stays pure and transport-agnostic (it is the piece a future TypeScript client factors into clausters-core), the host stays unit-free (it only knows "timeline units"), and the whole conversion is a handful of lines in one client module. Its dependency arrow is one-way — the editor imports the arrangement, never the reverse — the same call that moved the envelope point helpers out of the GUI submodule so seq would not depend on it.

A buffer sounds through an instrument, not by itself

Rendering a Buffer element was deferred with a note that it "needs an instrument". The temptation on picking it back up was to give the arrangement a built-in sampler def.

Decision: a buffer is data, and it sounds through the def named to play itBuffer(buf, instrument="take"), whose event carries the buffer number in a buf control. A buffer with no instrument is still a perfectly good element: it draws in the editor and contributes its extent, but emits no event.

Consequence: clausters.form ships no DSP and no def of its own (it stays the client-side structure it claims to be), the instrument stays the user's — any def that reads a buffer works, at any quality — and the "data vs. process" split the layer is built on holds at its most tempting exception. The one concession is practical: such an event sets legato = 1 so a take sounds its whole length, where a note's default would cut it short.

The patcher shows a connection, not a direction

The logical side of a composition — members wired to each other through buses — is drawn by the graph widget. The obvious design is a directed patch: outputs on one side, inputs on the other, arrows between them. It cannot be built honestly.

Context: a GraphDef says only that a member's control names a bus ({"out": "mix"}, {"in": "mix"}). Which end of that bus writes and which reads is not in the data — it is the server's analysis, which sorts the graph topologically before it runs. A view could guess the direction from a control's name ("out" writes, "in" reads), but that is a convention, not a contract: a def is free to name its controls anything, and the renderer would then draw arrows that are simply wrong.

Decision: the patch is bipartite — member boxes on one side, bus nodes on the other, and a wire per (member, control) ↔ bus pair. It shows the connection, which is what the data knows, and leaves the direction to the engine, which is what decides it.

Consequence: the view can never lie about signal flow, and the edit stays well-defined: rewiring means pointing a control at another bus (or at none), one wire per control, which is exactly the mutation the group and the GraphDef both accept. A directed rendering could be layered on later — but only from information the server surfaces, not from a name.

The arrangement model: five primitives, one recursive group

A sequencing layer (a timeline of items, a playhead) is enough to play music and not enough to compose it. A composition is an element inside an element — a phrase inside a section, a take against a melody, a generator that has not been evaluated yet — and the client had no name for that. The question was what to add.

Context. The tempting answer is a bag of editor types: an audio clip, a MIDI clip, an automation lane, a folder track, each with its own fields, its own view and its own rendering path. That is how a DAW grows, and it is also why a DAW's granularity stops where its type list stops: you can edit a clip, but not "the inside of that clip at the level you happen to care about".

Decision. A closed algebra instead of a type list. An element is anything bounded that produces a unit of meaning — in one of two modes, and this is the axis the layer turns on: generated (the rendered thing, editable data) or a generator (the algorithm that renders it). Evaluating the second into the first is the change of state — and it is a compositional act, not an optimization: what separates the two modes is not data versus process but what can be done with them. A generated element is random-access (an audio file plays backwards, slices, edits in place); a generator is forward-only (it evaluates, in order, and that is all). Bouncing is what turns something you can only produce into something you can manipulate. An element carries only two temporal properties — an onset and a duration, either of which may be absent (which gives it a temporal character: a segment, a punctual event, a relative segment, or a pure abstract context). There are exactly five kinds, and each is a thin adornment over something the client already had, never a reimplementation of it: an Event (parameters in one action), a List (strict order, no concrete time), a Buffer (a list at constant time), a Set (mixed placement — a track), and a Function (a process: server DSP, or a generator of sequences). The one genuinely new structure is the Group: a recursive placement of elements by offset, in two kinds — concrete (a relation in time) and logical (a relation of processing, wired by buses). A group's temporal relation (successive / simultaneous / mixed) is derived from where its members sit, never declared.

Two consequences of the shape are load-bearing:

  • Rendering is a change of state, not a second engine. Rendering flattens the tree — accumulating nested offsets into absolute beats — into the flat timeline the client already plays, bouncing any contained generator in the same pass. RT and NRT differ only in the destination, so the offline render stays sample-identical to what was heard.
  • The base level is the view, not the data. How coarse or fine a group is shown (a summary rectangle, or its members resolved into lanes) is a property of the look, so the same structure serves the whole scale from a section to a note to a parameter — which is the granularity a type list cannot buy.

Consequence. The layer is pure and transport-agnostic: no DSP, no protocol, no GUI — the piece a future client factors into the shared core. It carries the temptations too: a Buffer is data and sounds only through the instrument named to play it, and a logical group emits the bus-wired configuration the server already expresses (a GraphDef) rather than a wiring language of its own. Both exceptions were resolved in the algebra's favour, and both are recorded above.

The piano-roll: OSC events get their own lane, and the notes live once

The editor-grade pianoroll draws the two message families a sequence carries — MIDI notes and OSC events — and it had to place them without lying about what each is.

Context. A note has a pitch, so it maps naturally onto the grid's vertical axis (pitch × time). An OSC event does not: a /trig or a /cue is a moment, not a pitch. Forcing it into the grid means inventing a vertical position for something that has none — the same kind of lie the patcher refused when it declined to guess signal direction from a control's name.

Decision. MIDI notes draw in the pitch × time grid; OSC events draw as flags in a separate lane below it, on the shared time axis but with no pitch pretence. Velocity gets its own lane too (the DAW convention), so the grid stays one clean plane of notes.

Reuse. The note primitives — the notes, the pitch/time mapping, the drawing, the hit-test, the drag clamps — live once in host::pianoroll, shared by the dedicated pianoroll widget and the multitrack clip's roll body (which now delegates its drawing there). This is the bpf::place_point move again (G22h): a mapping-free core both the widget-local and the clip-placed editors call, so the two can never disagree on where a note is drawn or grabbed. The Note carries velocity/channel (a real MIDI note), and the wire form is the flat quintuple start dur pitch velocity channel — a length that is a multiple of five is read as quintuples, anything else as a legacy start dur pitch triple list, so old data still parses.

Placement (the G7b rule). The one piece of general musical knowledge — the MIDI-note ↔ name / black-key spelling on the keyboard — went to clausters_core::scale (note_name/pitch_class/is_black_key), beside the perceptual frequency scales; its FFI export is deferred until a client evaluates it client-side (the envshape/tap-reader precedent). Everything else is display-only and stays gui-side. The edit-back stays flat payloads, not new addresses: "notes" (start dur pitch velocity channel …) and "osc" (time label …) — the fourth use of the one edit-back pattern.

Multi-note selection: the marquee is the time selection, restricted in pitch. The piano-roll needed a multi-note selection without stealing the grid's one free gesture — a plain drag on empty grid is the heavy views' time selection (the linked group follows it), and a DAW marquee wants that same drag. The resolution is that there is no second gesture: the marquee is the shared time selection, restricted in pitch. Dragging the empty grid keeps driving the linked views' time span exactly as before, and the notes inside the time × pitch rectangle become the selected set (Alt+click toggles one note in or out; Delete/Backspace removes the set; dragging a selected note moves the block rigidly, its clamp computed as one so an edge stops the block instead of folding it; the velocity lane nudges the set relatively, each note saturating on its own). The set itself is native view state, not a wire prop: it lives in the widget state, clears when the script replaces notes (the indices would dangle), and every block edit reaches the script as the same flat "notes" payload — the wire did not grow, so every client consumes block edits with the code it already had.

Edit-back to the data (the Editor's dedicated view). When the Editor opens an element as a dedicated piano-roll, a per-note edit writes back only to a generated element: a Track's editable Timeline is rebuilt from the "notes" payload (times converted to beats, the OSC/MIDI events sharing the timeline preserved). A generator (Pbind/Routine) is forward-only — there is no second note to rewrite until it evaluates again — so its bounced notes are shown read-only, and bouncing it to a Track is what makes them editable. This is not a new rule: it is the generated/generator distinction of the arrangement model (above) doing its job at the granularity of a single note. OSC events stay display-only in this view too: the (time, label) flag is a lossy projection of the message, so writing it back would silently drop the arguments.

Transport roles: TCP as the default command plane, UDP as the probe

Decision. The server accepts TCP on the OSC port by default (--no-tcp opts out), and clients connect their command interface over TCP by default while keeping the UDP boot-or-attach probe. Each transport has one role: UDP is discovery and small real-time control (scsynth compatibility included), TCP is the command plane, shared memory is the data plane (taps, control buses), WebSocket is the browser's TCP, and the in-process link is the packaged standalone's (no sockets at all).

Why. A UDP datagram cannot exceed ~64 KB (the OS rejects the send), and off loopback anything over one MTU fragments at the IP layer, where a single lost fragment silently drops the whole packet. That bounds exactly the payloads a complex application needs to move — whole defs, GuiDef trees, buffer chunks, query replies — while the deployments this server targets (loopback as the common case, controlled networks for sound installations) get nothing back from staying datagram-only. TCP's framing makes size a configuration, not a protocol property: the length-prefix ceiling exists only so an untrusted prefix cannot drive an allocation, so it is a boot option (--max-frame, default 16 MiB, advertised in /server_info.reply for clients to size bulk requests from) rather than a constant — no limit is hard-wired, per the rule that the project must stay usable as a desktop/mobile application without arbitrary ceilings. Timing is unaffected by the switch: it rides on bundle timetags and /sched, never on arrival time. Replies became transport-aware in the same move (a /tap_stream window may fill a whole frame for a stream client; UDP keeps the datagram-safe clamp), and the IPC command rings deliberately stayed at 64 KiB — large payloads ride TCP even locally, and growing the ring would bump the versioned segment layout for no demonstrated need.

Package SemVer is decoupled from the binary ABI counters

Compatibility is tracked by two monotonic integer countersABI_VERSION (the shm segment layout + embed C ABI) and CORE_ABI_VERSION (the core FFI surface) — not by the package's SemVer. A release bumps a counter only when that boundary changes incompatibly, and both are advertised by runtime calls (clausters_abi_version(), clausters_core_abi_version()) that a peer checks on attach/load, refusing to connect on a mismatch.

Why. A binary boundary needs a check that a already-compiled peer can make at runtime, before it trusts a single byte of layout — SemVer strings cannot do that job, and the two boundaries evolve on independent cadences (the core FFI is already at v9 while the embed/IPC ABI is at v3). A monotonic integer per boundary is exactly the scsynth plugin-ABI lesson: every binary seam is versioned and verified where it is crossed. SemVer is left to govern the package — what cargo/pip resolves — where it belongs.

The one linkage rule keeps them from drifting into contradiction: a release that bumps either counter must also bump SemVer's breaking tier (the minor while the major is 0, per standard pre-1.0 SemVer where the minor acts as the major; the major once at 1.0). The reverse is not required — a minor can ship purely additive source-API work without touching either counter. The mechanical release rules live in CLAUDE.md ("Versioning"); this entry is the why.