Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A typed PCFG over patch terms

The genome is a term in quiver’s combinator algebra, generated by a probabilistic context-free grammar whose non-terminals are signal kinds.

The representation decision

The genome is a tree: a term in quiver’s Layer-1 combinator algebra. It is not a raw patch graph and not a parameter vector, and the patch graph is the compilation target.

Everything follows from this. One representation covers what usually needs three:

Level of evolutionIn the term grammar
Node settingsLeaf parameter sites: an f64 or usize draw at each module node
ConnectivityInterior structure: chains, parallel branches, modulation attachments
Node setWhich productions fire: the module choice sites

A parameter-vector genome cannot change topology. A raw-graph genome can, but most of its mutations produce invalid graphs, so it needs a repair step, which is a second, undocumented grammar. A typed term needs neither: every sampled term compiles to a valid, sound-making patch, because the type system constrains which productions can fire where.

Sample a few. Green is the Audio sort, amber is Modulation, and they are different Rust types. The dashed edges are modulation slots, and no amount of mutation can put one sort where the other belongs, because such a term cannot be constructed. Press it repeatedly and watch small terms dominate: that is the prior’s parsimony, with nothing penalizing size. (A faithful miniature of the production weights, not the shipped grammar.)

The sorts are quiver’s signal kinds: Audio, V/Oct, Gate, CV. Auracle’s PatchTree splits into AudioNode and ModNode, and the split is enforced by Rust’s own type system: an ill-sorted term is not rejected at runtime; it cannot be constructed.

The grammar as a probabilistic program

PatchGrammarPrior is a fugue program. Every node at tree path pp emits real probabilistic choices at path-keyed addresses:

SiteAddressDistribution
source-vs-processor<p>#leafBernoulli(source_prob)\mathrm{Bernoulli}(\text{source_prob}), forced at max depth
source kind<p>#srcCategorical(wsrc)\mathrm{Categorical}(w_{\text{src}}), 8 kinds
processor kind<p>#opCategorical(wop)\mathrm{Categorical}(w_{\text{op}}), 20 kinds
modulation kind<p>/m#modCategorical(wmod)\mathrm{Categorical}(w_{\text{mod}}), 9 kinds
CV-processor kind<p>/m#modopUniform over ModOp::ALL
CV-combiner kind<p>/m#pairopUniform over PairOp::ALL
oscillator octave<p>#octCategorical(woct)\mathrm{Categorical}(w_{\text{oct}}) over −2…+2 (see Discrete sites)
discrete params<p>#wave, #color, #fkind, #table, #dmode, #channelUniform categoricals
input slot<p>#inputPlayerInput: draws slot 0, scores every slot alike (see AUDIO IN)
continuous params<p>#cut, #res, #det, …Uniform(0,1)\mathrm{Uniform}(0,1)

The amplitude envelope is fixed at amp#attack … amp#release.

The three categorical orders (8 sources, 20 processors, 9 modulation kinds) are the persisted wire format, because the codec writes the chosen index into the trace. They are append-only.

Parsimony is the prior, not a penalty

Deeper terms pay more prior mass by construction: each additional level of recursion multiplies in another #leaf Bernoulli that came out “processor”, and each processor node draws its own parameters. There is no size penalty term anywhere.

Ad-hoc parsimony penalties are the norm in genetic programming and a persistent source of trouble: they need tuning, they interact badly with fitness scaling, and they leave the target distribution unwritten. Here the target is written down: πβ∝pgrammar⋅eβu\pi_\beta \propto p_{\text{grammar}} \cdot e^{\beta u}, and pgrammarp_{\text{grammar}} is exactly the parsimony pressure.

Modulation is a recursive sort

A modulation input does not take “an LFO”. It takes a modulation term, which can itself be built from modulation terms:

  • Six leaves: LFO, envelope, random (sample-and-hold), envelope follower, Euclidean, step sequencer.
  • Op wraps one modulation term: quantize, slew, rectify, hold.
  • Pair combines two.

The #mod order is None, Lfo, Env, Rand, Follow, Euclid, Op, Pair, Steps. The step sequencer is a leaf that sits after the two branches, because the order is append-only wire format and it arrived last. So “is this kind a leaf” is a predicate (mod_kind_is_leaf), not an index range: the range it replaced (kind < 6) would have switched the new leaf off at the depth bound along with the branches, and a term forced to bottom out could never have drawn it.

The step sequencer’s values are latent

Steps carries eleven continuous sites: #srate, #slen, #sslew, and one per step, #s0 … #s7. Each step value uiu_i is its own Uniform(0,1)\mathrm{Uniform}(0,1) draw and plays as (2ui−1)⋅5 V(2u_i - 1)\cdot 5\text{ V}; #slen decides how many of the eight play (2 to 8, seven equal bins of the knob). This is the Mutable Instruments Marbles design, turned into a genome:

  • One site, one step. An MH proposal that moves #s3 re-voices step four and nothing else, so evolution edits a pattern the way a hand does.
  • Hidden, not deleted. The steps past #slen stay in the trace. A proposal that shortens the pattern and a later one that lengthens it give back the steps that were hidden instead of inventing new ones.

The module behind it is Auracle’s own (auracle_grammar::steps::StepsCv) rather than quiver’s StepSequencer, whose values are internal state with no ports: every one of the eleven sites is a port driven by a live knob, so a bar drag in the rack is an atomic write, not a recompile. Its clock is free-running (#srate is 0.5⋅25x0.5\cdot 2^{5x} steps per second), and every audition hears it that way; tempo sync is a live-instrument concern and is not in the genome. With the dock’s sync on, the live engine snaps each sequencer’s rate to the nearest division of the tempo in octaves (straight, triplet, or dotted; a quarter-step per beat up to eight) and drives every voice’s clock from one transport through a sync port the term never sees (<key>#~sync). The transport restarts on the first key down or on MIDI start, and it counts steps, not bars, so a five-step pattern keeps its polymeter against a four-beat arp.

So s&h rand → quantize → slew is a legal modulation term, and the rack draws the whole chain. Subterms live at <p>/m/0 and <p>/m/1, the same child convention the audio tree uses; it is unambiguous because every modulation key sits below a /m.

Its parsimony pressure is max_mod_depth, and the renormalizations that enforce it live in mod_weights_at: at maximum depth only leaves remain available, and below a processor the “no modulation” option is removed so a slot that must be filled is filled.

A modulation slot hangs off every module with somewhere to send it. The exceptions are the ones without: Noise, whose only site is a color switch, AudioIn, whose only continuous knob is a level, Mix / RingMod, whose two inputs are both audio and whose single knob is the blend, and the two player kinds, Track and Capture, whose knobs are how they listen and play. Having two audio children is not itself an exception: the four sidechained productions (comp, duck, gate, and vocoder) take two subterms and carry a slot as well.

The modules

Forty-six modules: 8 sources, 22 processors (20 the prior draws and 2 player kinds), 16 modulators.

sources     Vco  Supersaw  NoiseGenerator  Wavetable  KarplusStrong  FormantOsc
            Silence  AudioInput
processors  Mix  Filter  Fold  Delay  Chorus  Reverb  Distortion  Bitcrush
            Phaser  RingMod  Flanger  Tremolo  Vibrato  Eq  Granular  Shift
            Comp  Duck  Gate  Vocoder
            PitchTracker (Track)  Capture        (player kinds)
modulators  Lfo  Adsr  SampleAndHold  SlewLimiter  EnvelopeFollower  …
            StepsCv (Auracle's own: a step sequencer whose values are ports)

Seven processors are binary:

ProductionSecond input
Mix, RingModAudioMerges two chains into one
Comp, Duck, Gate, VocoderControlReal sidechaining, in a typed tree
TrackControlThe signal that plays the first branch

A compressor’s sidechain is not an audio input, and the type system makes wiring it as one impossible.

AUDIO IN: the player’s input

AudioIn is a source whose signal comes from outside the patch: one of the host’s inputs, read through quiver’s AudioInput (ADR-015). It is source index 7, with three knobs:

KnobSiteRange
input#inputA slot, 0..INPUT_SLOTS (8), shown as 1 to 8. Which device a slot means is the host’s to say
gain#gainUniform(0,1)\mathrm{Uniform}(0,1), mapped to −24 to +12 dB (INPUT_GAIN_DB_MIN, INPUT_GAIN_DB_MAX), unity at 2/3
channel#channelleft, right, or both summed

What it is for comes from where it is patched: into a filter or a delay it is processed, into a ducker’s key or a vocoder’s modulator it shapes another sound. It is measured with the session’s audition clip, never with the live input.

The input belongs to the player. #input is a real trace site, so the rack, a knob edit, a lock and the lineage diff all address it, but the grammar never chooses it. Its distribution, PlayerInput, samples slot 0 and gives every slot the same (unnormalized) mass, so a node the prior draws reads the first input, and a player’s choice of slot 3 is exactly as probable, and as evolvable, as slot 0. A refinement walk locks every #input its seed holds (locks): it may change the gain, the channel and everything the signal goes through, and never the input or the node itself.

AUDIO IN is a player kind, like TRACK and CAPTURE below: the prior never draws one, and scores a player’s. Its source weight is 0 (AUDIO_IN_WEIGHT), with no taste tilt, so no fill, walk or offer deals an input and the source table draws exactly what it drew before the term: whether a patch listens is a choice a player makes by patching one in. The #src site’s distribution, SourceKind, samples that table unchanged and scores AUDIO IN at PLAYER_SOURCE_MASS (0.005) where the table gives it none, so a listening patch is inside the support and ⚡ and generations walk it, holding the node by its #input.

TRACK: an input plays the patch

Track lets a voice or an instrument play the patch the way the keys do (RFC-008’s play the patch). It compiles to quiver’s PitchTracker, which reads pitch by YIN, a gate and a level from its /1 branch (usually an AUDIO IN), and it is #op 20.

KnobSiteRange
band#bandlow (40–500 Hz), mid (70–1000 Hz) or high (140–2000 Hz): the band the tracker searches
sensitivity#tsensUniform(0,1)\mathrm{Uniform}(0,1), the gate threshold on the tracker’s level scale, geometric from TRACK_GATE_LOUD (2.5 V) down to TRACK_GATE_QUIET (0.025 V); quiver’s own 0.25 V at the knob’s center. Up opens on a quieter input
dynamics#tdynUniform(0,1)\mathrm{Uniform}(0,1): how far the input’s level shapes the played branch, from not at all to fully (full level at TRACK_FULL_LEVEL, 5 V, an input 6 dB under a full-scale sine)

The input is the keyboard for /0. While the compiler builds the played branch, the tracker’s pitch and gate stand where the keys’ stand everywhere else: a VCO there sings the tracked note (its octave knob still transposes it), a pluck is plucked and a mod envelope fires on every tracked onset. The tracker’s gate is also summed into the voice’s amp envelope with the keys’ (either opens it), so a note sung into an AUDIO IN sounds the way a key does, and a key let go while the input still sounds holds the voice open until the input stops. Outside /0 the keys play the patch as before, so a mix can hold a tracked side and a keyed side.

Three consequences worth knowing:

  • A chord collapses. Every voice of a chord plays the one tracked note inside /0: there is one input, and one pitch in it. In the audition phrase the dyad’s second voice is a follower: it plays the note the main voice’s tracker hears from its first frame, and its amp keeps its own key’s gate, so it stops with the dyad.
  • Live, one voice tracks. The instrument (LivePoly) builds a patch with a TRACK with one tracked voice, its open voice, and every key’s voice is a follower of it, as the phrase’s chord voices are: so a key let go stops with its key instead of staying open while the input sounds. The tracked voice is not held like a key; its tracker’s gate opens it, so while the input is monitored it sounds when the input does. It renders its quantum first and copies its tracked signals frame by frame (CompiledVoice::read_tracks) for the followers to read before each of their frames.
  • Before the first tracked note, /0 plays C4 (the tracker’s 0 V), and between notes it holds the last note it tracked.
  • The tracker hears what is there. Where one note still rings under the next, YIN can read the pair (see below).

Rendered on the audition clip, a TRACK plays the reference figure’s notes: thirteen of its fourteen within 1.4 cents, and the D4 that sounds over a still-ringing E4 17 cents sharp, because YIN hears the pair.

CAPTURE: a recorded take as a source

Capture records what is patched into it and plays the take back (RFC-008’s resample). It compiles to quiver’s Capture and is #op 21.

KnobSiteRange
play#playonce (each note plays the take to its end), hold (while the note is held) or loop (round and round while held)

Its output is its take, not its input. /0 is what it records; it is built and running, and never heard. So inserting a CAPTURE turns the chain below it into what is recorded, and the patch plays the take instead. Each note plays the take at the keys’ pitch, C4 at the speed it was recorded; an empty take plays silence, as an unplugged socket does.

The take is the sound’s content, saved in the term:

"take": {"format": "f32le-base64", "sample_rate": 48000.0, "length": 96000, "data": "…"}

It is quiver’s own Capture state, lossless, so a reloaded take plays back bit for bit. A take is bounded the way an audition clip is: the format tag, a rate no higher than MAX_TAKE_RATE (192 kHz), a length no longer than TAKE_SECONDS (4 s, quiver’s default Capture buffer) at that rate, and the data’s length checked before it is decoded. A take that breaks a rule never costs the sound: the term loads with the take empty, and a restore counts the sound as repaired, so the app says so. When that take was the sound’s only source it no longer renders, and the restore keeps it aside (the engine’s held list) instead of dropping it: out of the pool, so nothing deals, fits or breeds it, reported apart from the repairs (“One sound’s take couldn’t be read. It’s kept safe until you record it again.”, once for each set of such sounds), and written back by every save with its take JSON-equal to what was loaded, until a readable take brings it back (readmit_held), measured as a new sound. A fragment sent with a take that couldn’t be read (SetTake, ReplaceTree, InsertTree) is refused. A take is not a trace site, so no walk can propose a new one, and an edit that changes only a take logs a lineage event whose diff is empty.

A recording stops at TAKE_SECONDS at the voice’s rate (the record gate goes through a window that closes then), even into a buffer a longer, higher-rate take has enlarged, so what a capture records always reads back as a take.

Recording is the host’s. A compiled voice has a record gate per capture and reads the take back from quiver’s state, and StructOp::SetTake puts it in the term. Nothing in the engine raises the gate, so a measurement render never records and a capture measures the same every time. In the app, RECORD on the CAPTURE’s plate has the worklet copy the input the CAPTURE listens to while it is lit, and on STOP (or at TAKE_SECONDS) the engine worker plays that copy through one voice of the patch, its key held at C4 and the gate raised (render_take, with LivePoly::set_record and LivePoly::take_json): the take a voice recording live would have made, without compiling, rendering or encoding on the audio thread. The take goes out as set_take, an edit like any other. A sound kept safe is recorded the same way from its saved term, and the take goes to readmit_held.

Player kinds: scored and never drawn

Track and Capture are player kinds: a sound has one because a player put it there. The prior must never draw them (a drawn tree that tracks or captures would move every random-tree statistic, and a capture with nothing recorded is silence), but it must not score them at zero either: a tree the prior gives log⁡p=−∞\log p = -\infty cannot start a walk, so a player’s tracked sound could never be evolved.

The #op site’s distribution, OpKind, does both, as PlayerInput does for #input. Its sample is the categorical over the twenty drawn kinds, exactly as before, so it returns the same index from the same stream and every draw is unchanged. Its log_prob scores a drawn kind as before and a player kind at PLAYER_OP_MASS (0.005, the weight of the grammar’s other two kinds a player puts in place). The extra mass leaves the scorer unnormalized, which is harmless: a walk holds every player kind’s #op, so the term is the same constant in every ratio it takes. N_OPS, which the refinement budget scales with, still counts the twenty drawn kinds.

A TRACK or CAPTURE whose input chain holds an AUDIO IN scores finite too: SourceKind does for that AUDIO IN what OpKind does for the TRACK.

What is not in the grammar

Feedback. Terms are acyclic: there are no feedback combinator productions. Modules with internal feedback (delay, chorus, reverb) are fine, and there are plenty of them.

This is a v1 constraint rather than a principle. A tamed feedback production, with a mandatory attenuator and limiter in the loop path, is the intended v2 grammar extension. Until then cycles are unrepresentable, which is why cable dragging in the UI does not offer them.

Strict validation as an oracle

Grammar output is compiled with quiver’s ValidationMode::Strict in the test suite. Because the grammar is typed, a SignalMismatch is by construction a bug in the grammar, so Strict doubles as a property-test oracle: sample NN terms, compile all of them, and any error fails the test with quiver’s actionable message.

Patches are wired in Warn mode, though, with an allowlist test pinning the warning classes. Strict rejects warning-class pairings the compiler uses on purpose, the clearest being a constant bipolar Offset feeding a unipolar knob. The allowlist test (every_prior_sample_compiles) names each class with its reason, which keeps a known exception from quietly becoming a habit of ignoring warnings.

Where this comes from

The design mirrors fugue-evo’s ArithmeticGrammarPrior, with quiver signal sorts in place of arithmetic types. That is deliberate: fugue-evo’s subtree mutation and reversible-jump MH apply to this genome unchanged, because they operate on traces and this genome’s trace encoding is faithful. Auracle uses that MH. It does not use crossover, since every child grows from one seed (walk.rs), and it does not use fugue-evo’s tempered SMC.