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 evolution | In the term grammar |
|---|---|
| Node settings | Leaf parameter sites: an f64 or usize draw at each module node |
| Connectivity | Interior structure: chains, parallel branches, modulation attachments |
| Node set | Which 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.
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 emits real
probabilistic choices at path-keyed addresses:
| Site | Address | Distribution |
|---|---|---|
| source-vs-processor | <p>#leaf | , forced at max depth |
| source kind | <p>#src | , 8 kinds |
| processor kind | <p>#op | , 20 kinds |
| modulation kind | <p>/m#mod | , 9 kinds |
| CV-processor kind | <p>/m#modop | Uniform over ModOp::ALL |
| CV-combiner kind | <p>/m#pairop | Uniform over PairOp::ALL |
| oscillator octave | <p>#oct | over −2…+2 (see Discrete sites) |
| discrete params | <p>#wave, #color, #fkind, #table, #dmode, #channel | Uniform categoricals |
| input slot | <p>#input | PlayerInput: draws slot 0, scores every slot alike (see AUDIO IN) |
| continuous params | <p>#cut, #res, #det, … |
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: , and 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.
Opwraps one modulation term: quantize, slew, rectify, hold.Paircombines 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 is its own
draw and plays as ;
#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
#s3re-voices step four and nothing else, so evolution edits a pattern the way a hand does. - Hidden, not deleted. The steps past
#slenstay 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 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:
| Production | Second input | |
|---|---|---|
Mix, RingMod | Audio | Merges two chains into one |
Comp, Duck, Gate, Vocoder | Control | Real sidechaining, in a typed tree |
Track | Control | The 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:
| Knob | Site | Range |
|---|---|---|
| input | #input | A slot, 0..INPUT_SLOTS (8), shown as 1 to 8. Which device a slot means is the host’s to say |
| gain | #gain | , mapped to −24 to +12 dB (INPUT_GAIN_DB_MIN, INPUT_GAIN_DB_MAX), unity at 2/3 |
| channel | #channel | left, 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.
| Knob | Site | Range |
|---|---|---|
| band | #band | low (40–500 Hz), mid (70–1000 Hz) or high (140–2000 Hz): the band the tracker searches |
| sensitivity | #tsens | , 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 | #tdyn | : 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,
/0plays 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.
| Knob | Site | Range |
|---|---|---|
| play | #play | once (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
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
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.