How to read this
What Auracle computes, in enough detail to disagree with.
This book is the technical companion to the guide. The guide says what the instrument does; this book says how, with the math written out and pointers into the code that implements it.
It is organized as a pipeline, because that is what Auracle is. A term (the genome) compiles to a patch, the patch renders audio, the vetting gate passes or quarantines it, measures it as 44 numbers, and the utility scores it:
A loop closes over it: the player’s picks condition the taste parameters , and reshapes how the next term is proposed.
Three commitments
Every number is sourced. Thresholds, dimensions, defaults, and step counts are quoted from the code, with the constant named so a reader can check. Where a figure came out of a measurement, the measurement is named too.
Design and implementation are distinguished. Several things in Auracle are intended as one algorithm and currently implemented as a simpler one. The clearest case is refinement: the design is tempered sequential Monte Carlo, and what ships is a short local Metropolis–Hastings walk. Those pages say so in their first paragraph. See Refinement.
Known weaknesses are stated. Where a coefficient is unidentified, a variance inflation factor is uncomfortably high, or a memory spike is unfixable without forking a dependency, it is written down.
Four pages to start with
- A typed PCFG over patch terms is the representation decision everything else follows from. Because the genome is a typed term rather than a parameter vector or a raw graph, all three levels of evolution (settings, connectivity, module set) live in one object, and every sample is valid by construction.
- Trace addresses are the naming scheme shared by panel knobs, hand edits, locks, live parameter handles, and search proposals. Nothing else stays coherent without it.
- Utility as a max of experts explains why taste is a maximum over styles rather than a mixture, and what that buys.
- The vetting gate explains why randomly composed DSP graphs are safe to put in front of a person.
Conventions
- Code references name the crate and the item:
auracle_features::vet::VetConfig. The API documentation has the generated rustdoc for all of them. - Math follows Notation. is a patch term, its feature vector, the taste parameters, the latent utility.
- Measured claims cite the harness that produced them, usually an example
binary such as
auracle-session/examples/search_health.rs, runnable from a checkout.
What lives elsewhere
- The generated API documentation is the rustdoc.
- Playing the instrument is the guide.
- Working on Auracle (layout, the quality bar, the sharp edges, cutting a
release) is
CONTRIBUTING.md. - What changed when is
CHANGELOG.md.
Design decisions, rejected alternatives, the milestones, the open questions, and
the directions nobody has raised yet are in this
book, under Design. They used to be a
DESIGN.md at the repo root, which made the reasoning and the math it
justifies two documents that could disagree.
The two libraries underneath
Auracle is thin on top of two in-house libraries:
- fugue-evo does evolution as
Bayesian inference. Priors as probabilistic programs, typed
Metropolis–Hastings with automatic reversible jump, grammar-based genetic
programming, tempered SMC in trace space. Auracle’s grammar is a
GenomePrior, and its search is fugue-evo’s typed MH with a learned fitness plugged in. Auracle uses neither the tempered SMC nor any crossover. - quiver does modular synthesis. Arrow-style combinators, typed ports (Audio / V-Oct / Gate / CV), patch graphs, headless rendering, first-class WebAssembly. Auracle’s genome is a term in quiver’s combinator algebra; its “compiler” targets a quiver patch graph.
Where a guarantee comes from one of them, this book says so.
Notation
Fixed throughout. Where a symbol appears in the code under a different name, the code’s name is given.
Objects
| Symbol | Is | In the code |
|---|---|---|
| A patch term: a tree in the typed grammar | PatchTree | |
A tree path, e.g. node/0/1 | path keys | |
| A trace: the execution record of the grammar program | fugue::Trace | |
| The feature vector of | Features::phi() | |
| Perceptual descriptors of the render | AudioFeatures | |
| Structural descriptors of the term | StructFeatures | |
| A standardized feature vector, | phi_std |
is always the concatenation , in that order. It is written rather than
throughout; the code’s phi is this vector.
The taste model
| Symbol | Is | In the code |
|---|---|---|
| Number of styles, the experts of the taste model () | TasteConfig::k_styles | |
| Feature dimension () | TasteConfig::n_features | |
| Style ’s weight vector | TasteSample::theta[k] | |
| All of them, | TasteSample::theta | |
| Latent utility of | utility_mix | |
| Style ’s utility, | utility(phi, k) | |
| Session ’s keep/kill threshold | TasteSample::tau[s] | |
| Star cutpoint | TasteSample::cuts[j] | |
| Prior SD of one coordinate | TasteConfig::sigma_theta() | |
| Max-of--normals SD correction | MAX_NORMAL_SD | |
| Number of sessions in the log | FitSet::n_sessions() |
Search
| Symbol | Is | In the code |
|---|---|---|
| Prior probability of term | PatchGrammarPrior | |
| Boltzmann sharpness | SessionConfig::beta | |
| The target, | (none) | |
| Taste-tilt strength (a tilt of the prior; see Proposals) | SessionConfig::proposal_tilt | |
| The set of locked addresses | locked: HashSet<String> |
Conventions
- is the logistic function , never a standard deviation. Standard deviations are always subscripted () or written as .
- is natural. Log-losses are in nats.
- Indices are 0-based, matching the code, including cutpoint indices, which matters for reading the ordinal likelihood.
- Weights are always normalized unless stated: importance weights sum to one, recency weights are relative to the newest observation being .
- “Standardized” always means after the affine transform in Standardization. The taste model never sees raw ; the observation log never stores anything else.
KaTeX macros
Defined in www/reference/katex-macros.txt, which book.toml points to, so a
symbol cannot mean two things on two pages:
| Macro | Renders |
|---|---|
\R | |
\E | |
\phivec | |
\thetak | |
\sig |
The crates
Core-library-first. Every frontend is a thin shell over the same engine.
crates/
auracle-grammar the genome: typed PCFG over quiver combinator terms,
trace codec, term → Patch compiler, structural edit ops,
rack description, presets
auracle-features phrase render → vet → LUFS-normalize → φ extraction
auracle-taste max-of-experts utility, three likelihoods, MCMC posterior,
standardization, portable profiles
auracle-session the two-loop engine: pool, acquisition, refinement,
lineage, calibration, persistence, migration
auracle-wasm WasmEngine (worker-side brain) + LivePoly (worklet-side
instrument)
apps/web the instrument: vanilla JS, no build step
Dependencies run strictly downward: grammar knows nothing of features,
features nothing of taste, taste nothing of the engine. session is the
only crate that sees all of them, and wasm is a binding surface with no logic
of its own.
auracle-grammar
The representation, and the crate everything else is built on.
| Module | Owns |
|---|---|
term | PatchTree, AudioNode, ModNode: the genome type. The Audio/Mod sort split is enforced by Rust’s type system, so ill-sorted terms are unrepresentable |
prior | PatchGrammarPrior: the PCFG as a fugue program. Implements fugue-evo’s GenomePrior |
genome | The canonical trace codec. This is the addressing scheme, and a round-trip property test keeps it from drifting |
compile | Term → quiver Patch, with live parameter handles. Nearly 5,000 lines, the largest single file in the workspace |
mutate | Structural edit operations, and their validity gate |
edit | Single-site parameter writes by address |
diff | Human-readable diffs between two terms: what the lineage log prints |
describe | The rack description the panel draws from |
presets | The 62-patch hand-made library, in seven families |
genome’s codec is the grammar’s addressing. It is one scheme rather
than two kept in sync, which is what makes a knob turn, a lock, and an MH
proposal refer to the same thing.
auracle-features
The measurement crate. One render serves three purposes: the vet report, the feature vector, and the audition buffer the player hears.
| Module | Owns |
|---|---|
phrase | PhraseSpec: the standard stimulus |
render | Deterministic headless rendering through quiver |
vet | The quarantine gate |
loudness | ITU-R BS.1770 K-weighting, gated integrated loudness, and the peak ceiling |
audio | : 18 perceptual descriptors |
structural | : 26 structural descriptors |
pipeline | The composition, in the one order that is safe |
cache | Render memoization and the persistent-cache namespace; what makes the MH walk affordable |
pipeline::featurize is the whole crate in forty lines, and the order it
composes them in matters; see
The vetting gate.
auracle-taste
The model. No knowledge of patches at all: it consumes standardized feature vectors and feedback events.
| Module | Owns |
|---|---|
model | TasteModel as a fugue program; TastePosterior and its summaries |
observe | Feedback, ObservationLog, FitSet, and the by-name projection that migrates old logs |
standardize | The affine transform, with runaway-column detection |
synthetic | SyntheticUser: the non-negotiable validation gate |
synthetic is not a test helper that happens to live in src/. It validates
the taste model against a simulated user with known ground truth: assert the
posterior concentrates on , and that acquisition regret shrinks. That
makes the core falsifiable headlessly.
auracle-session
The engine every frontend drives.
| Module | Owns |
|---|---|
engine | Engine: pool, log, posterior, refinement, workbench, lineage. About 4,100 lines |
walk | A generation’s walks as data: the jobs, the context they share, and the walk as a pure function |
perform | PERFORM’s named controls, drift, and offers |
surrogate | The learned taste as a fugue-evo Fitness |
calib | Prequential forecast scoring and reliability diagrams |
map | The 2D projection behind the taste map |
naming | Generated patch and style names |
farm | Indexed draw seeding: what makes parallel filling reproducible |
migrate | Loading sessions written by older versions |
auracle-wasm
Two objects, on two threads, and the split matters:
WasmEngineis the wholeauracle-sessionengine, in a Web Worker. Pool filling, posterior fits, refinement, workbench edits. Nothing real-time.LivePolyis the instrument, in an AudioWorklet. It holds compiled copies of the current patch, via the samecompile()path evolution uses, limiter included.
So what the instrument plays is not a re-implementation of what was evolved; it is the same compiled artifact. See The web runtime.
apps/web
Vanilla JavaScript, no build step, no framework, no dependencies. Among its
scripts: main.js (UI and Web Audio), worker.js (the engine), farm.js (a
stateless render and walk worker), live-audio.js (worklet assembly),
perform.js (PERFORM), taste.js (TASTE and LEARNING), midi.js (MIDI
controllers) and booth.js (booth mode). Beside them are smaller modules,
most of them pure rules with unit tests of their own, such as words.js
(sentences built from engine facts), taste-geom.js (TASTE’s and LEARNING’s
geometry), deal.js (EVOLVE’s dealing) and toasts.js (the toast lane).
apps/web/AGENTS.md
lists every one and what it holds.
Its own architecture notes are in
apps/web/README.md;
the parts that constrain the engine are in The web runtime.
Foundations, from crates.io
quiver-dsp 0.5.1 | Modular DSP. Library name is quiver |
fugue-evo 0.4.0 | Evolution as inference. default-features = false, because checkpoint and parallel do not compile on wasm32 |
fugue-ppl 0.2.4 | The probabilistic programming layer. From 0.2.3 a seeded run draws the same in wasm32 as natively |
All three come from the registry. To hack on them alongside Auracle, add a
[patch.crates-io] block at the bottom of the workspace manifest.
Two build settings worth knowing, both in Cargo.toml:
- Release builds use
lto = "fat",codegen-units = 1,panic = "abort". Everything the player waits on is render-bound.panic = "abort"also drops unwinding tables from the wasm bundle. None of these can change float results; only--fast-math-style options could, and none is enabled. serde_jsonwithfloat_roundtrip. The observation log is the profile’s source of truth and must reload bit-identically; serde_json’s fast float parse can be off by one ULP.
The two loops
A machine-paced loop and a human-paced loop, sharing one observation stream.
┌─ patch loop (fast, silent, machine-paced) ─────────────────┐
│ grammar prior → vet → pool │
│ local MH toward π_β: subtree moves → struct-screen → │
│ render survivors → feature-score │
└──────────────┬─────────────────────────────────────────────┘
│ candidate pool (`pool_size`: 48 by default, 40 in the app)
▼
acquisition: choose what to play
(uniform by default; BALD selectable)
│ audition + feedback events
▼
┌─ taste loop (slow, human-paced, persistent) ───────────────┐
│ observe events → posterior over (θ, τ, cutpoints) │
│ persisted across sessions = the user model │
└──────────────┬─────────────────────────────────────────────┘
│ θ reshapes the prior's proposal weights
└──────────────► back into the patch loop
The two loops run at different speeds on purpose. The machine can evaluate thousands of candidates against a learned surrogate silently, and surface only a curated few. That addresses interactive evolution’s classic failure mode: the human bottleneck, where a user is asked to rate a whole population per generation and quits from fatigue.
The patch loop
Machine-paced. No human in it.
- Fill. Sample terms from the grammar prior, compile, render, vet,
featurize. The pool target is
SessionConfig::pool_sizevetted candidates (48 by default inengine.rs; the web app passes 40 inapps/web/main.js), with at mostmax_draws(400) draws attempted per fill, since vet failures burn attempts. - Refine. Once a posterior exists, take the top
refine_seedscandidates and runrefine_stepsMetropolis–Hastings steps from each. The defaults inengine.rsare 10 seeds × 40 steps, both scaled from the grammar’s processor count (N_OPS, 20, inprior.rs), so a change to the processor set does not silently change the search’s character. - Inject. Each child is admitted only if it beats the member it would
displace. When the generation ends, the lowest-utility members are replaced
to bring the pool back to size. Pinned (saved) candidates are exempt,
including ones pinned while the generation runs, and so is a sound kept as
new until its first pick (at most
pool_size / 4of them, persistence).
The 10 × 40 split is measured; moving in either direction is worse.
The taste loop
Human-paced, and persistent across sessions.
- Observe. Every duel (an EVOLVE pair, a heard PERFORM offer, or an edit claim), star, and cut appends to the observation log, as raw , never standardized. That is what lets the standardizer be re-fit later without invalidating history.
- Reweight, immediately. Each new observation folds into the existing posterior by importance sampling. It is exact, costs time linear in the number of posterior draws, and is what makes the next pair respond to the last pick.
- Refit, every sixth pick. Full MCMC over the log: 10,000 post-warmup
steps (
mcmc_samples) after 3,000 warmup steps (mcmc_warmup), thinned to at most 500 retained draws (KEEP, inauracle-taste’smodel.rs).
The app paces refits by count: every sixth pick refits (FIT_EVERY, 6, in
apps/web/main.js), and so do the end of the warm start and opening a taste
profile that holds picks. The engine also reports when the cheap path has run out of
road, that is, when the reweighted posterior’s effective sample size has
collapsed far enough to need resampling since the last fit
(Engine::needs_refit). The app does not wait for that signal. See
The posterior.
Where they meet
Acquisition picks the pair the player hears next. The taste tilt carries back into the grammar.
The tilt is the part that makes this more than a scored search. The fitted
structural coefficients reshape the categorical weights of the grammar the
search draws new modules from. Because the tilted grammar is installed as the
prior, they also reshape the target the search climbs (see
Proposals). Kind ’s weight is multiplied by
, where is the fitted coefficient for that kind and
is the tilt strength (proposal_tilt, 0.6, in engine.rs):
with each multiplier clamped to so no module kind is ever starved or monopolized. Details and the shrinkage applied to are in Proposals.
So the loop is closed: the player’s picks change what gets proposed and what the search counts as parsimonious, not only what scores well once proposed.
Why this is preferential Bayesian optimization
There is a latent objective (the player’s utility), an expensive oracle (the player), a cheap surrogate (the posterior), and a generator of candidates (the grammar prior plus MH). The acquisition step is where ’s posterior uncertainty would earn its keep: early sessions could ask informative questions (duels the model cannot rank), and a confident model could mostly serve sounds it predicts the player will pick.
Whether it is worth asking informative questions rather than random ones is an empirical question. See Acquisition.
The gate on all of it
auracle-session’s closed-loop test runs the engine against a SyntheticUser
with known ground-truth , end to end through the real grammar →
render → vet → feature pipeline, and asserts that the learned taste ranks
genuinely preferred patches on top.
It is slow, and it is the only test that can fail when the loop is broken while every component is individually correct.
Trace addresses
The spine. Six subsystems refer to the genome, and they all use this one naming scheme.
A trace address names one probabilistic choice site in the grammar program. Every site in a term has one, and it is derived from the site’s position in the tree rather than assigned:
| Address | Names |
|---|---|
node | The root audio node |
node/0, node/0/1 | Children, by index |
node/0/m | The modulation slot hanging off node/0 |
node/0/m/0 | A subterm of that modulation term |
node/0#cut | The cut parameter of the node at node/0 |
node/0/m#rate | The rate parameter of that modulation term |
amp#attack | The amplitude envelope’s attack |
The pattern is <path>#<param> for parameters and <path> for structure.
Paths are /-separated child indices from the root; a /m segment enters a
modulation slot, and because every modulation key sits below a /m, the child
convention is reused there without ambiguity.
What shares it
| Subsystem | Uses an address to |
|---|---|
| Panel knobs | Identify what a knob writes |
| Hand edits | Write one site: set_param(addr, value) |
| Locks | Name the frozen set |
| Live parameter handles | Map a knob to an atomic in the running voices |
| MH proposals | Name the site a move touches |
| The lineage diff | Print what changed (node/0#cut 0.31→0.78) |
Six subsystems, one vocabulary. The alternative is three schemes that drift: a UI parameter id, a genome index, and a DSP handle, mapped to each other. The drift surfaces as a knob that edits the wrong thing after a structural change.
Why it cannot drift
Because the canonical trace codec is the addressing scheme, not a
translation of it. auracle_grammar::genome encodes a PatchTree to a
fugue::Trace by walking the tree and emitting exactly the addresses the
grammar program samples at. The same walk decodes.
A round-trip property test pins it: encode a random term, decode it, and require the result be identical. If the codec and the grammar ever disagreed about what a site is called, that test fails.
Structure is encoded in its own choices
The reason a tree can live in a flat trace at all: the structure of an
execution is determined by the choices the execution makes. The value at
node#leaf decides whether node is a source or a processor, which decides
whether node/0 exists at all.
That is what lets fugue’s generic trace machinery work unchanged: subtree
regeneration and reversible-jump Metropolis–Hastings operate on traces without
knowing anything about synthesizers. Auracle contributes a grammar, not an
inference algorithm. It uses no crossover: every child grows from one seed
(walk.rs).
Live parameter handles
When a term is compiled, each continuous parameter site yields a ParamHandle,
an atomic the audio thread reads. Turning a knob does two things:
- Writes the atomic, so the running voices change without a recompile.
- Writes the genome at the same address, so the edit is real rather than cosmetic.
Both, always. Writing only the atomic gives a knob whose change disappears on the next patch swap; writing only the genome gives a knob that must be recompiled to be heard.
Not every address has a live handle. Structural sites do not, and a few
parameters feed compile-time decisions. window.__aur.nonLiveAddrs in the web
app is the set that requires a recompile.
Locks, precisely
is a set of exact address strings, typically snapshotted from the UI. A proposal is rejected if it changes, deletes or creates any address in .
All three, and the third is the one that is easy to omit. Scanning only the current trace lets a birth at a locked address through while rejecting the death that would undo it. That is an asymmetric constraint region: it breaks detailed balance, makes the exactness argument false, and lets the chain drift into locked structure it can never leave.
One limit: a structural move can grow a brand-new address inside a locked module, present in neither trace, so it cannot be in . That case is symmetric (unmatched in both directions), so it costs nothing in detailed balance. A lock is a guarantee about addresses, not about subtrees.
Persisted UI state must be JS-owned
A rule from the web app, and it belongs here because it is about this scheme. UI state that persists must be held in JavaScript and never scraped from the DOM at save time. A phantom DOM slider (present but not the live control) once reset a value and poisoned an autosave with it.
The address scheme makes the genome authoritative; the rule keeps the interface from quietly disagreeing with it.
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.
Parameter sites and their domains
Every continuous knob in the genome is a draw from . The musical meaning is the compiler’s job.
One domain, everywhere
pub const PARAM_DOMAIN: std::ops::Range<f64> = 0.0..1.0;
pub const PARAM_MAX: f64 = 1.0 - f64::EPSILON;
pub fn in_domain(v: f64) -> bool {
v.is_finite() && PARAM_DOMAIN.contains(&v)
}
Every continuous site is normalized to and the mapping to Hz, seconds,
dB, or cents happens in the compiler. Half-open, because that is what
is: fugue’s log_prob is at . The
domain used to be 0.0..=1.0, which made exactly 1.0 legal here and
impossible under the prior: a knob dragged to its stop, two shipped presets,
and the default vibrato insert all had , so init_from
refused them and ⚡ evolve silently did nothing. Every clamp in the crate now
lands on PARAM_MAX, never on 1.0, and it is one f64::EPSILON below rather
than the next float down so that a JSON round trip cannot put it back on the
boundary. No mapping in the compiler can hear the difference. Three things fall
out of the shared domain:
- The prior is trivially correct. at every site, with no per-parameter range table to get wrong.
- A proposal cannot leave the domain. MH moves are in normalized space.
- The panel can read in musical units (
840 Hz,24 ms,−6.0 dB,+12 ¢) while the genome stays uniform. The knob and the number under it are two representations of the same site.
Note that in_domain requires finite: NaN compares false against every
bound, and an infinity is exactly the runaway the gate exists to stop.
Bounded by the mapping
Because the mapping is the compiler’s, the musically dangerous regions are excluded by how is spent rather than by a downstream guard. Filter resonance maps to a range that stops short of self-oscillation; delay feedback stops short of 1; V/Oct maps into an audible band.
So the grammar cannot express the most degenerate settings at all, which leaves no pathological region for the search to keep sampling and be penalized for.
It is not a substitute for vetting, which catches pathology that arises from composition: a bounded resonant filter fed by a bounded distortion fed by a bounded fold can still scream.
Latent sites
Not every continuous site is heard. A step sequencer (ModNode::Steps, see
the grammar) always
carries eight step values #s0 … #s7, and #slen decides how many of them
play. The rest are latent: in the trace, in the domain, drawn from the
prior, and moved by MH like any other site, but inert until a longer #slen
reveals them.
That is deliberate, and it costs nothing the search has to pay for. A proposal
on a latent step changes neither the sound nor , so the tempered
target accepts it as a neutral move (drift, not selection: nothing about taste
has acted on it yet), and the value it leaves behind is what a later #slen
proposal reveals. Shrinking a pattern never throws a step away, and growing it
again brings back the step that was there.
Discrete sites
Categoricals, each with a named domain, and all uniform but the octave:
| Site | Domain | Drawn |
|---|---|---|
#wave | Waveform: saw, square, triangle, sine | uniformly |
#oct | Octave offset, −2 … +2 | 5%, 15%, 40%, 25%, 15% (OCTAVE_WEIGHTS, in prior.rs) |
#color | Noise color | uniformly |
#fkind | Filter kind | uniformly |
#table | Wavetable shape | uniformly |
#dmode | Drive mode: soft, hard, tube | uniformly |
The octave is weighted for the speakers a patch is heard on. A patch’s
register comes almost entirely from its lowest oscillator. Drawn uniformly,
30.5% of a fresh bank put most of its energy at C4 below 200 Hz, and 17.5%
put under a fifth of it in 200 Hz–5 kHz, the band a laptop’s speakers
reproduce: sounds a laptop barely plays. With the weights, the same five
fresh loads of the app’s pool (200 patches, measured by auracle-wasm’s
pool_loudness example) hold 21.5% and 8.5%. Most of what is still below
200 Hz has its lowest oscillator at −1: a patch with two oscillators has one
at −1 or lower more than a third of the time.
Octave 0 is the common draw and the lowest the rarest. The edges keep a weight above zero: at zero, a preset or a saved patch with an oscillator at −2 or +2 would score , and refinement would refuse to start from it.
Plus the structural categoricals (#src, #op, #mod, #modop, #pairop),
whose orders are the persisted wire format and therefore append-only.
Enumerating the sites
domain_violations() returns every out-of-domain continuous site as (address, value), in address order. It reads the trace, not the term:
self.to_trace().choices.iter().filter_map(|(a, c)| match c.value {
ChoiceValue::F64(v) if !in_domain(v) => Some((a.to_string(), v)),
_ => None,
})
The trace enumerates exactly the continuous sites, by construction, from the same walk the prior samples. A hand-written match over the productions would be a second table of “which fields are knobs”, and the first module somebody forgot to add to it would be the one the next bad value escaped through.
This is the address scheme paying for itself: there is one enumeration of the genome’s sites, and it is the one inference uses.
Repair, not refusal
clamp_domains() pulls every out-of-domain site back in and returns how many
it fixed. NaN goes to the domain’s midpoint; anything else is clamped, with
1.0 and above landing on PARAM_MAX. It runs on every session load, which is
what mends a save written by a build that still let a knob rest on the stop.
It is half of normalize_tree(), the normal form every tree is put in once, on
its way in: a session's bank on load, a held sound brought back, an opened
patch file, and a whole tree the panel posts. The other half folds a modulation
term the grammar would fold (ModNode::normalized): a shaper over nothing is
nothing, a pair with an empty side is its other side, and a one-parameter
shaper's unused second parameter is 0. Every structural edit ends in both,
though an edit that grafted a subtree once brought its modulation in unfolded,
which is how a saved session or a patch file came to hold such a term. A term
left unfolded was rewritten by the next edit instead, and on such a patch every
module the guess could add also took the folded one
away, so the guess said the patch was full. The compiler builds each such term
as its folded form, so the fold changes no sound: it takes off the rack a
module that did nothing. That is why a restore says so when it clamps a knob
and not when it folds a term.
The asymmetry with the size ceilings is deliberate:
| Violation | Response | Because |
|---|---|---|
| A knob outside | Repaired, exactly and locally | There is one right answer |
| A modulation term the grammar folds | Folded | It already plays as its folded form |
| A term over the module/depth ceilings | Refused | Fixing it means deciding which modules to delete |
Repair wins for parameters on product grounds: a saved session that already contains a bad value must not become an app the player cannot edit, load, or evolve their way out of. Corruption must not be load-bearing.
The sentinel incident
The gates above are not hypothetical. A shipped session contained amp.sustain = 1e30, an out-of-domain sentinel that had escaped into the genome and then
into the observation log.
What one bad cell did:
- The value rendered fine. The limiter bounds the output, so the audio was unremarkable and vetting passed it. The vet gate is a gate on the sound, not on the term.
- Its entered the observation log, with
amp_sustain. - The standardizer fit on that column produced a mean of and an SD of , which standardized every real patch in the pool to .
- The coordinate was dead. The model could never learn from it again, and the belief line still printed a contribution for it.
- The panel read
SUSTAIN 1200.0 dB, and the HELD tray printed1e+30.
The fixes are at three layers:
clamp_domainson load, which repairs the corruption that exists.FeaturizeError::OutOfDomain, which refuses to measure a term whose φ would be a lie, before spending the render. This is the gate that keeps the log clean; every row in the log came through it.- Runaway-column detection in the standardizer, so the next escape costs a coordinate’s precision rather than the coordinate.
Layers 1 and 2 should make layer 3 unnecessary. It exists anyway, because the value got through everything that was supposed to stop it.
Budgets
Separately from domains, the search is bounded in size:
Ceiling (in mutate.rs) | Default | Because |
|---|---|---|
Modules (MAX_SIZE) | 24 | the realtime voice |
Term depth (MAX_DEPTH) | 6 | the prior’s max_depth (5) + 1, the deepest term it can score |
Modulation depth (MAX_MOD_DEPTH) | 3 | the prior’s max_mod_depth (2) + 1 |
The app shows a ceiling once a patch is one step from it, in the form
23/24 modules, and all three (8/24 modules · 4/6 depth · 1/3 mod depth)
with Show measurements on. A hand-built
patch past a ceiling is refused, and one at a ceiling has no room to grow,
which is a common reason a generation reports that every walk came back
unchanged.
See the validity gate for why the two depth
ceilings are derived from the prior rather than set above it.
Structural edits
Hand edits and search proposals walk the same lattice, which is what makes the workbench trustworthy.
The vocabulary
Because the genome is a typed tree, rewiring is a small closed set of operations that are type-safe by construction: an LFO can never end up in an audio slot, and a filter always has exactly one audio input.
| Op | Does |
|---|---|
Replace { key, kind } | Swap the node’s kind. A processor keeps the old node’s primary input, so replacing a source with a processor wraps the source; a source takes the whole subtree’s place |
Insert { key, kind } | Insert a processor into the wire between this node and its parent; the old subtree becomes its primary input |
Delete { key } | Remove the node, splicing its primary input up to take its place |
SetMod { key, kind } | Set the modulation slot on an audio module. A source kind replaces the slot’s term; a shaper wraps it |
SwapMix { key } | Swap the two audio inputs of a binary node |
ReplaceTree { key, node } | Install an explicit fragment, discarding what was there |
InsertTree { key, node } | Graft an explicit fragment into the wire; the old subtree becomes its primary input |
SetModTree { key, m } | Install an explicit modulation term wholesale |
Nodes are addressed by trace key: node,
node/0, node/0/1, node/0/m.
The *Tree variants exist for the wiring gestures: “plug this staged chain in
here”. Callers park the displaced subtree client-side, which is what the SET
ASIDE tray is.
What a splice keeps
Insert and a processor Replace place the kind’s default module, and they
seat the chain with the same splice InsertTree uses. The chain becomes the new
module’s primary input, /0: the old subtree for Insert, the old node’s own
primary input for Replace (the old node itself, when it was a source). On the
two-input modules that is a mix’s or ring mod’s a, the in of a compressor,
ducker or gate, and a vocoder’s carrier, the one branch whose waveform
reaches the output. A Replace drops the old node itself and its /1, and
nothing else. The default module brings its own second branch:
| Kind | Its own /1 |
|---|---|
| mix | a triangle VCO |
| ring mod | a sine VCO an octave up |
| comp, duck, gate | a pluck as the key |
| vocoder | a formant oscillator as the voice |
So a vocoder placed on a chain makes that chain speak. The Vox Machina preset
is built the same way: a supersaw stack as the carrier, a formant voice as the
modulator. Until October 2026 Insert and Replace built the vocoder with a
supersaw carrier of its own and dropped the chain they landed on. The app was
never affected: it never sends Insert or Replace. Its structural edits are
insert_tree, replace_tree, set_mod_tree, set_mod, swap_mix and
delete, and its other wiring gestures post the whole rewired tree.
Wrap versus replace
The distinction shows up twice and is the same idea both times:
Replaceon a source with a processor kind wraps the source rather than deleting it, because a processor needs an input and the obvious one is what was already there.SetModwith a shaper kind wraps the existing modulation term rather than evicting it, which is what makess&h rand → quantize → slewa three-click build.
The socket in the UI says which of fill / replace / wrap it is about to do, so the choice is never implicit.
Hand edits and MH proposals share one lattice
The search does not call these operations. A walk’s structural move is
fugue’s single-site MH on the trace: it redraws one structural site (#leaf,
#src, #op or #mod) and draws from the prior whatever the new choice
needs, keeping every site whose address survives. What hand edits and the
search share is the state space and the
address scheme: both produce terms of one
grammar, under ceilings that end where the prior’s support does.
Consequences:
- Anything a player can build by hand, the search can score and walk from, because it is inside the prior’s support. Anything the search produces, a player can edit.
- A structural edit cannot produce a term the search would consider invalid, because validity is one predicate.
⚡ evolve from thison a hand-built patch is not a special case.
Parameter edits
Separately, edit::set_param(tree, addr, value) writes one continuous or
discrete site by address. This is what a knob drag is: a one-site write, then a
re-render and re-vet before the result can be auditioned.
The validity gate
validate_tree is the predicate every edit result must satisfy, and it is what
the
structural-edit gate test exercises.
Hard ceilings on hand-built patches:
pub const MAX_SIZE: usize = 24; // modules
pub const MAX_DEPTH: usize = PRIOR_MAX_DEPTH + 1; // audio tree depth: 6
pub const MAX_MOD_DEPTH: usize = PRIOR_MAX_MOD_DEPTH + 1; // modulation nesting: 3
MAX_SIZE protects the realtime voice and the feature pipeline. The two depth
ceilings are derived from the prior’s support, and that is a correction: they
used to be 9 and 4 against a prior whose max_depth is 5 and max_mod_depth is
2, on the reasoning that a person stacking modules by hand knows what they are
building and the ceiling only protects the voice. What that reasoning missed is
that the prior forces #leaf at max_depth and zeroes Op/Pair at
max_mod_depth, so the deepest term it can score has depth max_depth + 1.
A hand edit past that had , EvolutionChain::init_from
returned None, and ⚡ evolve on the patch did nothing and said nothing: the
very failure the grammar gives Silence non-zero weight to prevent. Now the
ceiling is the support, stated once in prior.rs and read from there.
A session saved under the old ceilings may hold a deeper tree. It still loads
and plays (no load path re-checks the ceilings, because corruption must not be
load-bearing), but refinement reports it as outside_support rather than
pretending to walk, and a structural edit that leaves it over the ceiling is
refused until one brings it under.
MAX_MOD_DEPTH stops well short of the audio ceiling for a concrete reason: a
Pair branches, so depth 3 is up to eight leaves on one cable, and each is
another level of the compiler’s by-value recursion stacked on top of the audio
tree’s. That is a stack-depth argument rather than an aesthetic one; see
the wasm stack note.
The gate test
The structural-edit suite is a gate rather than a set of unit assertions:
Apply every operation at every node of randomly generated trees, and require the result to stay compilable.
This catches the class of bug that unit tests miss: an operation that is
individually correct but produces an invalid term in combination with a
particular tree shape. The codebase leans on gates like this generally; the
preference is stated in
CONTRIBUTING.md:
prefer extending a gate over asserting implementation details.
Naming stability
NodeKind serializes as snake_case, and that string is also what
describe::RackModule::kind reports and what the frontend keys its module
list off.
RingMod is renamed by hand, because the derived spelling would be ring_mod
while the module is ringmod everywhere else, and one module with two
spellings is a defect waiting for a caller.
Silence is silence on both sides, and it is the one kind in the vocabulary
nobody shops for: Replace { kind: silence } is how an edit unplugs a socket.
The app’s unplug, extract, and cable-move gestures leave the same leaf (as
{"Silence":{}} in the tree they post), the rack describes it as kind
silence titled empty, and it renders nothing. It used to be unreachable by
hand, so the app stood a saw VCO in an unplugged socket and drew the EMPTY
plate over it; the plate said “nothing here” while the saw played, and φ
measured the saw. As a source kind it can be neither inserted into a wire nor
wrap anything.
Node identity
Nodes carry a Uid assigned on the way into the pool. This is what makes the
rack’s hand positions and locks survive a structural edit. Without them a node
is its position, so any structural change wipes the locks and destroys the
hand-build → pin → breed loop the editor exists to serve.
A node is a thing with an identity that has a position, not a position that has contents.
Compilation to a patch
Term → quiver `Patch`. One path, used by both the search and the live instrument.
auracle_grammar::compile is the largest single module in the workspace, and
its job is narrow: turn a PatchTree into a playable quiver patch graph, with
handles for every live parameter.
The mandatory output chain
Every compiled voice ends the same way, and none of it is optional:
Plus two external controls (pitch in V/Oct and gate in volts) fanned out to
every pitched source and every envelope.
No evolved patch can bypass the limiter or end up unplayable. That is safety layer 3, and it is enforced by the compiler emitting the chain, not by asking the grammar not to.
The tail is built once per channel, so a subtree that produces true stereo (reverb, chorus) keeps both tanks all the way to the output rather than having the right one discarded on the way to a mono sum.
Parameter mapping
The compiler owns the musical meaning of every normalized site, and the ranges are deliberately bounded away from pathology:
| Bound | |
|---|---|
| Filter resonance | max 0.85 |
| Delay feedback | max 0.7 |
So the grammar cannot express self-oscillating resonance or a runaway delay. This is the same argument as parameter domains, one layer down: excluding a region is better than generating it and rejecting it.
Two details worth knowing when reading the code:
- Some quiver inputs are gates, not amounts.
Adsr.shape,Vca.response, andLimiter.softare read at a 2.5 V threshold, so 5 V and 10 V do the same thing. The compiler uses named constantsGATE_TRUE = 5.0/GATE_FALSE = 0.0rather than bare numbers, because “5.0” at one of those ports does not mean what it looks like. - Filter keytracking is fixed at 0.5. quiver applies , so 0.5 moves the corner half an octave per octave played: enough that a patch still speaks two octaves above where it was dialed in, which is what the audition phrase’s C5 stab measures.
The DC blocker, and makes_dc
The output chain includes a DC blocker, and the compiler decides whether it is needed by walking the term:
fn makes_dc(node: &AudioNode) -> bool {
match node {
AudioNode::Filter { kind, input, .. } =>
matches!(kind, FilterKind::Ladder) || makes_dc(input),
AudioNode::Distortion { mode, input, .. } =>
matches!(mode, DriveMode::Tube) || makes_dc(input),
AudioNode::Mix { a, b, .. } | AudioNode::RingMod { a, b, .. } =>
makes_dc(a) || makes_dc(b),
// sources produce none; dynamics inherit from their audio input
…
}
}
Two productions generate a DC offset (the ladder filter and tube-mode
distortion), and it propagates up through anything downstream of them. An
AudioIn always pays for the blocker: a host input can carry an offset of its
own, and so does a Capture, whose take was recorded from one. A Track pays
for it only if the branch it plays does.
Without the blocker, a tube-drive patch measures 1–8% DC as a fraction of RMS. That is nowhere near the vet gate’s 0.6 limit, which is the point worth recording: the vet gate was never what protected the feature extractor from that offset. The blocker was.
Validation mode
Patches are wired under ValidationMode::Warn, not Strict.
quiver’s Strict rejects warning-class pairings, and this compiler uses
several of them on purpose, for example:
- a unipolar modulation envelope driving a bipolar FM input;
- a constant bipolar
Offsetfeeding a unipolar knob.
The type discipline Strict would enforce is already guaranteed by
construction: the term’s Audio/Mod sorts are Rust types, and the compiler
only emits known-good connection shapes.
Compile errors (invalid ports, cycles) remain hard failures. Accumulated
warnings are returned for inspection, and a property test
(every_prior_sample_compiles) asserts they stay within an allowlist that
names each class with its reason. That test keeps a known exception from
drifting into a habit of ignoring warnings.
Separately, the grammar’s output is compiled under Strict in the test
suite, where a SignalMismatch is by construction a bug in the grammar and
therefore a useful oracle. Two different modes for two different questions.
Live parameter handles
Compilation returns a ParamMap: address → ParamHandle, each wrapping an
AtomicF64 the audio thread reads.
This is what makes knob turns free. Turning a knob writes the atomic, so the running voices change on the next block with no recompile, and writes the genome at the same address. Both, always; see Trace addresses.
Structural changes do require a recompile, and so do the handful of parameters that feed compile-time decisions.
Taps, and the level on each cable
Compilation also records each term node's tap: the quiver port its audio
leaves by (CompiledVoice::taps, keyed by the node's trace key). A term is a
tree, so each module's output feeds exactly one parent, and its tap carries
exactly what the cable from that module carries. Two readers use them, and
both read in the same scale, dB re 1 V of the raw port value (quiver's
calculate_rms_db), so PATCH maps a level to light one way:
- The live meter. While notes sound,
LivePoly::set_metersubscribes to every tap, read on one voice, the newest one sounding, and each tap's RMS reaches the page as quiver's level windows fill. It needs no recompile and costs nothing when off. - The cable probe, for a patch at rest (
auracle_features::probe_cables,WasmEngine::edit_cable_levelsin wasm, the worker'scable_levelsmessage). It renders the audition phrase once and reads every audio cable after every tick of the main voice, through the routing slot its tap resolves to, keeping each cable's RMS and peak over the whole phrase, gaps included. A cable that carries nothing readsPROBE_FLOOR_DB(−120 dB).
The probe's cables are the rack's: one per audio wire of describe, in its
order, with the from and to module keys PATCH draws a cable by and both
modules' uids, which PATCH's cable identity is made of. Modulation cables are
not measured, because the compiler taps audio nodes only, and PATCH draws a
modulation cable by its rate. Chord voices are not measured either: the
levels are the voice that plays every note of the phrase, as the live meter's
are one voice.
The probe writes nothing: it reads each port's last value and leaves the
patch alone, so a render it watches is the plain render, bit for bit. All 62
presets render identically with and without it
(crates/auracle-features/examples/cable_probe.rs), and the test suite holds
every fourth preset to that. It is not φ, and nothing it reads reaches the
featurizer.
Its cost is one render of the phrase. Natively, a probed render took a median
0.99 times a plain one over the 62 presets (162 ms against 165 ms of CPU, on
a shared machine, so the reads are lost in the noise); in wasm under node it
took a median 160 to 206 ms over two runs, against 183 to 238 ms for a render with φ
(crates/auracle-wasm/examples/cable_cost.mjs). That is not a per-block
cost, so the probe runs once an edit has settled, in the worker's
later lane, and the live meter covers the patch while notes sound.
How PATCH uses it
PATCH (apps/web/patch.js) asks for the probe once the bench has settled
after an open or an edit (nothing in the edit lane, nothing opening, no knob
held, about half a second after an edit's reply and a little over a second
after an open or PATCH coming into view, so the next click is not kept
waiting behind a render), and only while PATCH is in
sight; a knob turned during a drag asks once, after the drag. A reply measured
on a tree the bench has since left is dropped and asked again. With the
levels in hand, each audio cable at rest:
- carries light by its level, mapped as the live meter maps one: −54 dB re 1 V
and below is unlit, 0 dB full, linear between (
paintWireLevel's stroke opacity, eased over--d-state); - wears a level mark on its middle that lights one bar per third of that range, with the level as its tooltip.
Until a structure has been measured, its cables are unlit and its marks hollow: the rack used to draw an estimate here (each cable's share of the output through the mixers above it, every source at unity), which is not a measurement and is not drawn on the workbench any more. After a knob turn the marks stay hollow until the change has been measured. Modulation cables carry no light and no mark. The pictures of other patches (EVOLVE's pair) still draw the estimate; they have no probe.
One compiler, two callers
- The search compiles a term to render and measure it.
LivePolycompiles the same term, through the same function, to play it: copies for voices, limiter included.
So what the player hears under their fingers is the patch that was evolved, vetted, and featurized. There is no separate “playback engine” that could disagree with the one the model learned from.
Cost
The compiler is recursive and builds by value: every level of
Compiler::build constructs quiver modules before moving them into the patch,
and some of those carry large inline buffers. A PitchShifter holds [f64; 4800], which is 38 KB, and a Granular holds more.
On a native main thread this is invisible. On wasm32, whose default stack is 1
MB, a dozen-module patch overflows it, and it does so as memory access out of bounds, nowhere near the flag that caused it. See
the stack size for the fix and why it lives in the
Makefile.
The standard phrase
Audio features are only comparable under an identical stimulus. This module owns that stimulus.
The spec
PhraseSpec::default() is four notes, 5.05 s in all, at 44,100 Hz, with the
random seed 0xE05_F00D:
| # | Note | Gate on | Gate off | Chord | Reveals |
|---|---|---|---|---|---|
| 1 | C4 | 1.80 s | 0.20 s | none | Slow attacks; sub-Hz modulation over a register-constant sustain |
| 2 | C5 | 0.30 s | 0.15 s | none | Whether the patch speaks at all an octave up |
| 3 | C4 | 0.50 s | 0.20 s | +E4 | Intermodulation and mud when voices stack |
| 4 | C3 | 0.80 s | 1.10 s | none | Bass register, and the release / delay / reverb tail |
Pitches are V/Oct offsets from C4. The seed is installed into quiver’s thread-local RNG before rendering, so noise and analog drift are bit-reproducible: a patch’s features are the same every time it is measured.
tail_ratio is
measured in, which is why the low note is last.Why each segment
The original phrase was three short notes (0.6 s stab, 0.25 s stab, 0.8 s low note), and it was the loop’s weakest link. It could not discriminate
- slow pads: a 2-second attack was silent for most of the stimulus,
- anything modulated below ~1 Hz: no register-constant segment long enough to hold a modulation cycle,
- anything above Eb4, its highest note,
- how a patch stacks polyphonically: it was strictly monophonic.
So the grammar could express patches the audition could never reveal, and the taste model was being asked to learn preferences over evidence that was not in . No amount of model improvement fixes that; it is a measurement problem.
That reasoning closed four holes and then stopped. It is still true of everything
the v2 phrase did not reach, velocity above all: NoteSpan has no velocity
field, and the live instrument responds to velocity. What the audition cannot
hear is the register of what is still outside
the stimulus, and this paragraph is the argument it is built on.
The v2 default covers each hole with the cheapest segment that reveals it:
- C4 held 1.8 s. The attack measurement window (onset → next onset) is now
2.0 s rather than 0.75 s, and the sustain is long enough that sub-Hz
modulation completes most of a cycle.
held_centroid_stdis measured here specifically, which is what makes it register-constant by construction. - C5 stab. One octave above the old ceiling. With the compiler’s fixed 0.5
keytracking, this is where dark patches reveal whether they speak up high
(
high_ratio). - C4+E4 dyad. A second compiled voice, gate-synced with the main voice,
reveals intermodulation (
chord_flatness_delta). A dyad rather than a triad because render cost is per voice-second and pairwise intermodulation is the first-order phenomenon. - C3 with a 1.1 s release window, kept last. Bass register, and its position matters: the tail measurement is the final 300 ms, so putting this note last is what makes the tail see release length and reverb rather than a truncated chord decay.
Cost: about 2× the v1 render, measured. The dyad’s second voice is the difference between wall seconds and rendered voice-seconds.
Chord voices
Note::chord carries additional simultaneous pitches, each rendered by its
own compiled voice, gate-synced with the main note.
Two behaviors worth knowing:
- Chord voices start cold at the note’s onset, exactly how live voice allocation behaves, so the measurement matches what a player would hear.
- After the shared gate closes they keep ticking until their own output parks on silence. A truncated release tail is a broadband click, and a click would poison every spectral feature in the frame it lands in.
max_voices() reports the largest simultaneous count (2 for the default spec),
and the vet gate’s peak ceiling scales with
it.
The :p2 stimulus tag
Every audio feature name carries a generation tag:
centroid_mean:p2 rms_std:p2 attack_s:p2 …
This is the migration mechanism, not a version comment.
A stimulus change changes what every audio value means, even when the formula
is untouched. A slow pad’s rms_mean under a phrase that never lets it open is
a different quantity from the same field under one that does. The observation
log stores raw by name, and FitSet::build projects old logs
onto the current names on the rule same name ⇒ same coordinate.
So tagging the name with the stimulus generation means votes recorded under the v1 phrase:
- keep their structural coordinates, which are stimulus-independent;
- have their old-stimulus audio coordinates imputed as “no evidence” rather than mixed into a standardizer they were never commensurable with.
Bump the tag whenever PhraseSpec::default() changes audibly. Failing to bump
it is worse than a wrong number: it is old evidence presented as current
evidence.
What the phrase still does not reveal
Stated because the model cannot learn what the stimulus does not show:
- Velocity response. The phrase plays at one velocity.
- Fast passages. No segment tests how the patch behaves in a run.
- Long-term behavior. Five seconds cannot reveal a 30-second evolving pad.
- Stereo width. The render is summed to mono for feature extraction, and there is no width coordinate in at all. The chorus module’s spec card says so outright in the app.
The intended direction is per-style audition phrases (a discovered bass
style picks a bassline, a pad style picks a chord swell), which would make the
stimulus adaptive rather than fixed. That is a design note, not shipped code,
and the :p2 tag is the mechanism that would let it happen without
invalidating history.
Audition clips
A live input is neither fixed nor repeatable, so it cannot be measured as it is. A patch with an AUDIO IN is measured with an audition clip instead, and the clip is part of the stimulus.
A patch that reads the player’s input (an
AudioIn anywhere in its
term, PatchTree::listens) would otherwise be rated on whatever the room
sounded like when it was rendered. Taste would chase the room. So every AUDIO
IN in a measurement render reads an audition clip: its input slot’s own,
a capture of what the player plays into that input, if it has one, else the
clip every slot hears, which is a built-in reference signal unless a session
saved before slots had clips of their own brought back its one capture
(RFC-008,
part 2). The features, the vet, the walks and the render farm all render with
it, so a patch that listens is measured, rated and bred like any other.
What a clip is
AuditionClip (auracle-features, clip.rs):
- one or two channels, at the phrase’s sample rate;
- at most
MAX_CLIP_SECONDS(8 s) long; - quantized to 16 bits when it is made, so the engine measures exactly the samples it saves and a reload measures the same ;
- named by a content id: FNV-1a 128 over its rate, channel count and samples.
A capture becomes a clip through AuditionClip::from_interleaved. It is
resampled linearly to the phrase’s rate (a browser captures at 48 kHz, the
phrase renders at 44.1 kHz) and cut to the phrase’s length, since nothing past
the phrase is ever read. Two captures are refused rather than repaired: one
holding a sample that is not a number, and one that is silent (below the vet’s
silence floor, an RMS of ), because every patch that listens would
fail the vet over it.
The clip rides inside the stimulus, as PhraseSpec::clip. None means the
reference. An input slot can have a clip of its own, in PhraseSpec::clips,
which an AUDIO IN on that slot reads in its place (PhraseSpec::slot_clip).
A spec without a clip serializes exactly as it did before clips existed, which
is what keeps every existing cache key and fingerprint where it was.
The reference signal
auracle_features::reference builds it from the phrase’s rate and length. It
is a plucked figure: fourteen notes over four and a half seconds, in A minor
pentatonic from A2 to E4, then a rest.
| Notes | Harmonic stacks of up to 32 partials, none above 45% of the sample rate, at (bright) or (dark), alternating |
| Envelope | A 2 ms attack, then an exponential decay: 0.07 to 0.08 s for a pluck, 0.35 s and 0.2 s for one longer note in each bar |
| Picks | 12 ms of white noise at every onset |
| Dynamics | An accent pattern across each bar |
| Level | Peaks at −6 dBFS (REFERENCE_PEAK), so an AUDIO IN at unity meets the patch at half of quiver’s 5 V full scale |
| Channels | Mono |
It has to exercise what a patch does to an input, and each property is there for a reason:
- Pitched. Every note is a harmonic stack, so a filter, a wavefolder, a pitch shifter or a vocoder has partials to work on.
- Transients. A pluck has fallen at least 27 dB by the next note, and a longer note 19 dB, so every onset is a rise a follower, a compressor, a gate or a ducker can react to, and a delay has onsets to repeat.
- Broadband. The picks are white noise across the whole band and the stacks reach up to 10 kHz, so a lowpass on the input moves the spectral centroid.
- Space around it. Every note has died away before the phrase’s last
300 ms, the window
tail_ratiois measured in.
It is deterministic: integer sample indices, libm’s sine and
exponential (pure Rust, so the native build and the wasm build compute the
same samples), a fixed xorshift for the picks, and the same 16-bit
quantization a capture gets. It is built once per rate and length and shared.
Being mono, it makes an AUDIO IN’s channel knob inaudible to the model. A
stereo capture is where that knob is heard.
examples/audio_in.rs measures eight patches built around an input through it:
a lowpass on the input lowers the centroid (0.63 to 0.38) and a highpass raises
it (to 0.74); hard drive lowers the crest factor (2.69 to 1.56); a reverb
smears the plucks (crest 2.04, centroid movement halved); the input keying a
ducker sets a supersaw pumping (motion_mid −2.54 to −0.90); and the input
as a vocoder’s modulator moves the supersaw’s spectrum. A reverb does not
lengthen the tail: every effect sits before the voice’s amp envelope, so the
tail ends with the amp’s release, for this patch as for any.
Rendering on the host’s clock
render_phrase gives a patch that listens one quiver AudioInputStream for
each input slot it reads, for the whole render, built with with_host_clock;
slots that hear the same clip share one stream. The whole clip is written once,
as one block, and the render calls advance() after each frame’s tick of every
voice, never tick_block. That is quiver’s rule for a frame-by-frame host, and
it is what keeps the chord voices in step: a chord
voice is compiled at its note’s onset, mid-render, and on the host’s clock its
AUDIO IN reads the frame every other voice is reading from its first tick. (On
a cursor-mode stream a voice built mid-block would be silent until the next
block, and a render has only one.) The test renders a dyad over a clip that is
silent until the dyad and a tone after it, and the dyad’s render is the mono
render doubled, bit for bit.
A patch that does not listen has no stream to keep in step, and its render
ticks each voice in blocks of 64 frames (tick_block): the same samples, bit
for bit, at about two thirds of the CPU. Its chord voice still ticks frame by
frame beside the main voice while it rings if it follows a TRACK, or if its
sources draw quiver’s shared random stream (noise, a plucked string), whose
draws come in tick order across the voices.
Frame of the render reads frame of the clip: a clip shorter than the phrase falls silent at its end.
A patch that does not listen gets no stream at all, so its render, and every row cached from it, is the same whatever clip the session holds.
The compiler binds the streams: compile_with_input (and compile_for_render,
the same voice with its knobs folded, which a measurement render uses) builds
each AUDIO IN in the patch on the stream it is given for that AUDIO IN’s slot
(InputStreams), a slot it is given none for on a stream nothing writes
(silent), and compile builds them all so, which is the same voice for any
patch that does not listen. The live instrument, LivePoly, binds its own
stream to every slot (InputStreams::every): quiver’s cursor
mode, built once in LivePoly::new, which the worklet calls in its port
handler and never in process(), and which every voice it rebuilds shares.
The worklet writes each quantum’s input through input_ptr and write_input,
with no allocation, and a quantum with no write is silent. It writes only
while the player monitors (MONITOR on the AUDIO IN module); otherwise the live
input is silent and only the module’s meter reads it.
Every patch ends in its amp envelope, gated by the keys, so a live patch that
listens would be silent with no key down. LivePoly builds such a patch one
voice longer (LivePoly::set_open): with monitoring on, that open voice is
held at C4 (0 V, OPEN_NOTE) at full velocity, outside the keys’ allocation,
so the input sounds through the whole patch, amp envelope included. Every
voice reads the one stream, so a key held over the open voice adds a second
copy of the input while it sounds, as a chord voice in the phrase does.
Cache keys
Two clips never share a cached row, and a new clip costs no row it does not have to.
render_keyis unchanged for a patch that does not listen. For one that does, when every slot it reads hears one clip, a0xfeseparator and the clip’s content id (the reference’s when the spec has none) follow the spec. When its slots hear different clips, each slot it reads follows instead, in order, as a0xfdseparator, the slot and its clip’s id. A clip on a slot the patch does not read moves nothing. The clip’s samples are never hashed per key: the id already is their hash.cache_namespacenever sees the clip. The farm’s store is stamped with its namespace and cleared when the stamp changes, so a clip there would throw away every cached render each time a player captured one.- The farm’s stored key,
"<namespace>/<render_key>", therefore carries the clip for exactly the patches that listen. - PERFORM’s wiring key (
wireKeyinperform.js) appends the clips to the key of a sound with an AUDIO IN, and to no other, named asrender_keynames them (clipKeyinclips.js): the one clip’s id when every input the sound reads hears it, which is the key such a sound had before an input could have a clip of its own, and otherwise each input it reads with its clip’s id, in order (0:<id>+1:<id>).
Walks and the farm
A walk renders with the clip because WalkContext.phrase carries it, and the
context goes with every walk job (farm_walk reuses its parsed copy while the
text matches). A farm worker’s fill and restore renders use the clip because
the phrase handshake carries it, parsed once per worker rather than once per
render: a phrase with a clip is about 600 KB of JSON.
The handshake is sent when the farm starts, which is before a staged restore installs the session’s saved clip. So after a capture, or a restore with a clip, the farm can be a phrase behind, and its render of a patch that listens carries the old clip’s key:
- A fill draw that listens is admitted only on the session’s own key.
pre_featurizedrefuses the stale result, andEngine::absorb_priormeasures the draw itself, on the session’s clips, as the serial fill would. It does the same when the farm sent no result for a listener, since the old clip may be why it failed the vet. A draw that does not listen keys no clip and is taken as sent. - A restore’s entry is refused by
bank_absorb, and the worker measures it withbank_render.
The pool is right either way, but each such patch costs a serial render. So
the engine worker sends the handshake again (farmResendPhrase in
worker.js): to the crew standing whenever a capture is taken, and in a
staged restore right after the import installs a captured clip on any input,
before the bank’s first render goes out. The phrase carries every input’s
clip, so one handshake brings the farm every one of them. A crew raised
later is handed the current phrase when it starts.
Capturing a clip
The web app captures a clip of each input the player plays into
(audio-in.js), and each becomes that input slot’s own:
- On an input’s first listen. When an input a sound on the bench reads,
or one lent to a recording (a CAPTURE’s RECORD, or RECORD AGAIN on a sound
kept safe,
lend), first carries a signal (over −50 dBFS RMS,CLIP_SIGNAL_DB) while no clip has been captured from that input (the engine hears it through the reference, or through the one clip of a session saved before inputs had their own, below), it recordsCLIP_SECONDS(6 s) of it, before the patch, at the browser’s rate. Each input waits for its own signal, so a patch reading two inputs has each captured when it is first played. The engine cuts a capture to the phrase (about 5.05 s), so the capture leaves room over it; a mono input is sent as one channel. - On NEW CLIP, whatever the input holds: a module’s NEW CLIP captures the input that module reads, whether the keys read it or its meter only.
- It is sent to the engine worker as
set_input_clipwith the input’s slot, which callsWasmEngine::set_input_clip. The reply carries the engine’s sentence, which the app shows, what it measured again (only the pool’s members that read that input), and which clip every input now hears (clips, frominput_clip), as the worker’sreadyand a restore carry it.
The app never sets the clip every input hears (set_audition_clip): a
capture of one input is that input’s own, not another’s. So in a new session
an input that has not been listened to is heard through the reference, and
the clip every input hears is the reference, unless a session saved before
inputs had clips of their own brought its one clip back. Such a session’s
inputs all hear that clip until each has its own. Since the app never sets
it, a captured clip that every input hears is always such a session’s, and
an input heard through it has no clip captured from it: its first listen
captures it (clips.js’s firstListen compares the input’s clip with the
one every input hears, clip on ready and a restore’s audition_clip).
Nothing tells which input the old clip came from, so that input is captured
again too, at the cost of one capture and of measuring again the sounds
that read it, in the background. An input with a clip of its own, captured
in this session or restored as its own, is left alone.
A silent capture is refused (see above) and changes nothing; the app shows the engine’s sentence and does not capture that input again by itself.
Stored with the session
The clip is saved in SessionState.audition_clip, in its saved form:
{"format":"s16le-base64","sample_rate":44100.0,"channels":1,
"frames":222703,"data":"…","source":"captured"}
Interleaved 16-bit little-endian samples in base64: about 600 KB of text for
five seconds of mono. The reference is never saved; no clip means the
reference. The slots’ own clips are saved beside it in
SessionState.audition_clips, an object from the slot ("0" to "7") to the
same form: one per slot at most, and a slot whose clip is the one every slot
hears is not written, so the file holds at most nine clips.
A saved clip is untrusted input, and AuditionClip::from_saved holds it to the
bound quiver’s Capture uses for its saved take:
- the format tag must be
s16le-base64; - the rate must be finite, positive and at most 192 kHz (
MAX_CLIP_RATE); - one or two channels;
- at most
MAX_CLIP_SECONDSat its own rate; - a frame count that agrees with the data, and the data’s length is checked before it is decoded, so nothing allocates beyond what the file’s own bytes occupy;
- and not silent.
The session holds the clip as raw JSON and checks it on the way in, so a clip
that cannot be read costs the session its clip and nothing else. It is
restored before the bank is measured. An unreadable one restores as the
reference, and the clip’s status (Engine::audition_clip_status, the wasm
binding audition_clip) says why, in unreadable. A slot’s own clip that
cannot be read costs that slot alone, which hears the one every slot hears,
and its status (Engine::slot_clip_status, the binding input_clip) says
why. A session saved before slots had clips of their own restores its one
clip on every slot.
Setting a clip (Engine::set_audition_clip) measures the pool’s members that
listen again, and only them. A member that no longer vets under the new clip
keeps the measurement it had and is reported as unmeasured: a clip change
never deletes a patch. Observations already in the log keep the
they were logged with. Setting it also takes each slot’s own clip away.
Setting one slot’s (Engine::set_slot_clip, the binding set_input_clip)
measures again only the members that read that slot.
Breeding a patch that listens
AUDIO IN is a player kind (grammar):
the prior never draws one, so no fill or walk deals an input, and it scores a
player’s, so a listening patch is walked like any other. A walk renders every
proposal with the session’s clips, because WalkContext.phrase carries them,
and the farm’s walk (farm_walk) is handed the same context: a ⚡ or a
generation from a listening seed lands the same child on the farm as on the
engine, with the seed’s inputs held and any CAPTURE’s take carried through
(crates/auracle-wasm’s
a_listening_seed_evolves_on_the_farm_with_its_clip_and_take).
What is open
- One live input.
LivePolybinds one input stream, so in the live voices every AUDIO IN reads the input of the patch’s first one; the app opens each other input for its module’s meter only, and says so. The model measures a patch that reads two inputs through each input’s own clip, so it rates the patch as it sounds with each input playing its own signal, which the live voices do not play yet.
Loudness normalization
Louder reliably wins A/B tests. Without normalization the model would learn a taste for loudness and present it as a preference about timbre.
Every render is normalized to −18 LUFS (TARGET_LUFS, in pipeline.rs)
before audition and before feature extraction. Unnormalized loudness would
poison , and it would do so in a way that looks like a real result.
Why LUFS and not RMS
Because the confound is perceived loudness. K-weighting approximates the ear’s sensitivity (a high-shelf boost above ~1.7 kHz plus a ~38 Hz highpass), and 400 ms gated blocks keep silence and release tails from dragging the measurement down. Plain RMS would under-measure a bright patch and over-measure a bass-heavy one, and then the “loudness” the model learned about would be a spectral preference in disguise.
The implementation follows ITU-R BS.1770 (auracle_features::loudness).
K-weighting
Two biquads in direct form 1, derived parametrically from the BS.1770 analog prototype by the RBJ bilinear transform, the same approach pyloudnorm takes, so any sample rate works and the coefficients match the spec’s published 48 kHz values at 48 kHz.
Stage 1, the high shelf:
Stage 2, the highpass:
With , , and , the shelf’s coefficients are
and the highpass is the standard RBJ form. Deriving rather than tabulating is what makes the measurement correct at 44,100 Hz, which is the rate the phrase renders at.
Block loudness and the two gates
Blocks are 400 ms with 75% overlap. Each block’s loudness is
where is the K-weighted signal. The dB offset is the spec’s calibration constant.
Then two gates, in order:
- Absolute gate. Discard blocks with LUFS. If none survive,
the signal is silent and the function returns
None. - Relative gate. Compute the mean energy of the surviving blocks, and discard blocks more than 10 LU below it:
The integrated loudness is the same expression over the twice-gated set:
Note that gating averages in the energy domain, not the dB domain, which is why the implementation exponentiates each retained block loudness back before averaging rather than taking a mean of decibels.
The relative gate is what makes this robust for the phrase specifically: the phrase ends with 1.1 seconds of release tail by design, and a plain average would let that tail pull the measurement down and then be compensated for by a boost.
Applying the gain
let wanted_db = (target_lufs - lufs).min(MAX_GAIN_DB); // MAX_GAIN_DB = 30.0
let headroom_db = 20.0 * (PEAK_CEILING / peak_before).log10(); // PEAK_CEILING = 1.0
let gain_db = wanted_db.min(headroom_db);
let gain = 10f64.powf(gain_db / 20.0);
The boost is capped at +30 dB. A patch needing more than that is a vetting problem, not something to amplify, and vetting runs first, so in practice the cap is a backstop.
Loudness is a target; the peak is a limit
Matching integrated loudness says nothing about the peak, and crest factor spans tens of dB across this grammar: a pad and a pluck at the same LUFS are nowhere near the same peak. A pure loudness match therefore sends percussive patches over full scale, and it did. Measured over 150 vetted prior draws:
| before | after | |
|---|---|---|
| peak p50 | 0.623 | 0.623 |
| peak p90 / p99 / max | 1.061 / 2.098 / 4.063 | 1.000 / 1.000 / 1.000 |
| over full scale | 22 (15%) | 0 |
over 1.25 (where the app’s master.gain = 0.8 clips) | 11 (8%) | 0 |
| gave up gain | none | 22 (15%), mean 3.0 dB, worst 12.2 dB |
The two 22s are the same twenty-two patches, and the unmoved median is the check that this is a fault stop rather than a re-leveling of the pool.
This is not a matter of audio polish. Preference data is elicited on this exact buffer, so a clipped audition collects a pick about clipping rather than about the patch. That is the confound loudness normalization exists to remove, one stage later and silent. The live voice was never exposed to it; its master limiter has always held a 0.98 ceiling. The offline path took the volt divisor and not the limiter.
A smaller gain, not a limiter. A scalar keeps render_playback
bit-identical by construction (the property its bit-identity test exists to
protect), and cannot change timbre at all. A limiter would reshape the waveform,
moving crest, flatness_mean, and flux_mean as well as the RMS pair, and
would need a second copy of itself inside the replay path forever.
What it costs is on the record rather than hidden: the ~15% that reach the
ceiling audition below target, so loudness matching degrades exactly where
crest is highest. Quieter is a smaller bias on a preference judgment than
clipped. Features::peak_reduction_db carries the amount, so a surface can say
“pulled down 3 dB so it would not clip” instead of presenting a peak-limited
patch as merely quiet.
make norm-peak reproduces the table.
rms_mean and rms_std are the only audio coordinates that are not
scale-invariant, so the change carries the standing
revalidation. Paired 16-seed make climb:
+1.877 ± 0.362 → +2.457 ± 0.298 mean gain, paired difference
+0.579 ± 0.350 (1 se), 95% CI [−0.121, +1.280]. That crosses zero, so no
improvement is claimed. What the run establishes is that the change costs the
search nothing. Every seed now climbs (16/16 against 15/16) and the generation
curve stopped turning over.
The report carries lufs_before, gain_db, and peak_reduction_db, all of
which survive into Features. They are diagnostics rather than model inputs:
they are not coordinates of , because a sound’s level before
normalization is exactly the information normalization exists to discard.
Where it sits in the pipeline
After vetting and before feature extraction:
Vetting inspects the raw render, deliberately: its thresholds are about the patch’s real output level, and measuring them post-normalization would make the peak ceiling meaningless. See the order is the design.
The normalized buffer is also what ▶ plays (raised to the target where a
ceiling stopped it short, in auracle-wasm’s level.rs). One buffer serves the
health check, the measurement, and the playback, which is what makes “no
audition is unvetted” true by construction rather than by discipline. (The
live keys play an edit before its check: see
Safety.)
Mono
The measurement is mono, and so is the buffer is computed from. A patch that produces true stereo keeps both channels through to the live output, because the compiler builds the tail per channel, but the measurement path sums.
So stereo width is invisible to the model. There is no width coordinate, so no number of picks can teach a preference for it. The app says so on the chorus module’s spec card, and this is why.
The vetting gate
No candidate is ever auditioned unvetted. This is what makes randomly composed DSP graphs safe to put in front of a person. The live keys play an edit before its check, and a failed check mutes them (Safety).
Evolution will generate pathological patches: screaming resonance, silent duds, NaN-poisoned state, astronomically high pitches. The gate is what makes that acceptable rather than dangerous.
What it measures
vet(samples, cfg) inspects the raw, pre-normalization render and returns
either a report or a quarantine reason.
pub struct VetReport {
pub peak: f64, // max |sample|
pub rms: f64, // whole-phrase RMS
pub dc_ratio: f64, // |mean| / rms
pub pinned_fraction: f64, // fraction within 2% of peak
}
Failures, checked in this order:
| Order | Failure | Condition |
|---|---|---|
| 1 | Silent | Empty buffer |
| 2 | NonFinite | Any sample is not finite |
| 3 | Silent | |
| 4 | Overlevel | |
| 5 | DcDominated |
pinned_fraction, the share of samples within 2% of the peak, is
informational only. It indicates heavy limiting, which is a character
rather than a fault; promoting it to a failure would quarantine an entire
timbre.
The thresholds
impl Default for VetConfig {
fn default() -> Self {
Self { rms_floor: 1e-4, peak_ceiling: 2.0, max_dc_ratio: 0.6 }
}
}
Deliberately lenient. The gate exists to catch pathology, not to encode taste; that is the model’s job, and a gate that quietly enforces a preference corrupts the data it protects.
The polyphony-scaled ceiling
VetConfig::for_spec scales the peak ceiling with the phrase’s polyphony:
where is max_voices(). The default 2.0 is one limiter-bounded voice (~1.5
peak in the ±1.0 float domain) plus overshoot headroom; gate-synced voices
legitimately sum toward × one voice.
Not scaling it would quarantine honest polyphony as runaway, and specifically the dyad segment, which exists to measure that summing. The measurement and the gate have to agree about what stacking is.
The thresholds were re-checked, and did not move
Worth recording, because a gate tuned before a whole family of modules existed is exactly the kind that starts quarantining a timbre.
When the drive modules arrived, the three thresholds were measured over the
full cross of {soft, hard, tube} × drive {0.3, 0.6, 0.85, 1.0} × {saw, square, supersaw}, plus a stacked fold → tube drive → resonant ladder chain:
| Measured | Against | Result |
|---|---|---|
| peak never exceeded 2.00 | ceiling 3.5 (at ) | Fine |
| never exceeded 0.0016 | limit 0.6 | Fine |
| rms stayed far above the floor | Fine: distortion raises level |
Peak is bounded by construction: quiver’s shapers all normalize into the ±1 domain and rescale, so a drive module is bounded at ±5 V however hard it is pushed. Drive buys harmonics, not level.
The DC result is 0.0016 only because
compile::makes_dc puts a
blocker in front of every tube-mode patch. Without it the same renders measure
1–8%, still nowhere near 0.6. So this gate was never what protected the
feature extractor from that offset. A threshold a defect passes comfortably
is not a defense against it.
The one threshold that would have had to move, had the shaper not been bounded,
is peak_ceiling.
The order is the design
pipeline::featurize composes the stages, and the order matters:
// 1. Domain check, BEFORE the render
if let Some((site, value)) = tree.domain_violations().into_iter().next() {
return Err(FeaturizeError::OutOfDomain { site, value });
}
// 2. Render
let mut render = render_phrase(tree, spec)?;
// 3. Vet the RAW render
let report = vet(&render.samples, &VetConfig::for_spec(spec))?;
// 4. Normalize
let norm = normalize_to(&mut render.samples, render.sample_rate, TARGET_LUFS)…;
// 5. Extract φ
let audio = audio_features(&render);
let structural = struct_features(tree);
// 6. Non-finite check on the VECTOR
for (name, value) in Features::phi_names().iter().zip(features.phi()) {
if !value.is_finite() { return Err(FeaturizeError::NonFiniteFeature { … }); }
}
Four things about that order:
The domain check is first, before the render. A term with a knob outside
its range is not a candidate that happens to sound bad: it is a term whose
would be a lie, and the ~600 ms render is wasted on it either way.
This is the gate that keeps the observation log clean: every row in the log
came through here. It is also the gate that was missing when the 1e30
sentinel got in, because vetting is a gate on the sound and amp.sustain = 1e30 renders perfectly well.
Vetting is on the raw render. Its thresholds are about the patch’s real output level; measuring peak after normalization would make the ceiling meaningless.
Normalization is before extraction. Otherwise loudness leaks into every amplitude-sensitive coordinate.
There is a second finiteness check, on the vector. It costs one pass over
forty-four doubles against a render that took most of a second, and it is the only
thing standing between a NaN out of a spectral descriptor and a posterior fit
that returns all-NaN . It is a different error from OutOfDomain and
names the coordinate rather than a genome site, because at that point the term
was legal and the measurement went wrong.
Quarantine is not just hiding
A failed candidate is never played and never shown, and it also scores
QUARANTINE_FITNESS = -50.0 (in auracle-session’s surrogate.rs) in the
search target.
That is safety layer 2: evolution learns to avoid the pathological region rather than repeatedly sampling it. Hiding alone would leave the search wasting its budget in a place it cannot see is bad.
The five layers, in one place
| Layer | Where | What |
|---|---|---|
| 0 | quiver | Denormals flushed at graph scatter; NaN-latch protection on stateful modules; soft-clipped filter state; cycle detection; non-finite module outputs zeroed at scatter so one module’s NaN cannot poison another’s state |
| 1 | auracle-features | This gate. Every ▶ plays a pre-rendered, vetted, normalized buffer. The live keys play an edit before its check, and a failed check mutes them |
| 2 | auracle-session | Quarantine → large negative fitness, so the search avoids the region |
| 3 | auracle-grammar | Mandatory … → Limiter → StereoOutput, and parameter ranges bounded away from pathology |
| 4 | tests | ValidationMode::Strict as a property-test oracle over grammar output |
Two upstream bugs
Both in quiver, both fixed there, and both worth knowing as the class of thing that lurks under randomly composed DSP:
- Q198. Oscillator phase accumulators latched NaN permanently on
non-finite pitch (
NaN − floor(NaN)), and thewhile phase >= 1.0wrap style used by Wavetable and FormantOsc spun the audio thread forever on an infinite increment (voct_to_hzoverflows at extreme V/Oct). An infinite loop on the audio thread is not a glitch; it is a dead tab. Fixed with a sharedwrap_phasethat recovers non-finite values. - Q199. Graph scatter now zeroes non-finite module outputs, so one module’s NaN/Inf can never poison another module’s recursive state through the routing buffers. Containment at the graph boundary; per-module input sanitization remains defense in depth.
Since quiver-dsp 0.3.0, voct_to_hz clamps pitch to ±32 octaves (Auracle pins
0.4.0), so the overflow Q198 recovers from can no longer be produced by pitch
CV. What remains at the clamp is finite aliasing, which meets this gate like
any other render.
φ_audio: perceptual descriptors
Eighteen dimensions, kept compact and put on axes a linear model can express a preference along.
Computed on Hann-windowed frames of the normalized mono render (2048 samples, 50% hop), plus a few time-domain and segment-local measurements. Every field is finite by construction, because vetting ran first.
The coordinates
| # | Name | Is |
|---|---|---|
| 0 | centroid_mean:p2 | Mean spectral centroid on the log axis: brightness |
| 1 | centroid_std:p2 | SD of that centroid over frames: timbral movement, in octaves |
| 2 | rolloff_mean:p2 | Mean 85% spectral rolloff, log axis |
| 3 | flatness_mean:p2 | Mean spectral flatness: 0 tonal … 1 noisy |
| 4 | flux_mean:p2 | Mean spectral flux: how fast the spectrum changes |
| 5 | zcr_mean:p2 | Zero-crossing rate as an equivalent frequency, log axis |
| 6 | rms_mean:p2 | Mean frame RMS |
| 7 | rms_std:p2 | SD of frame RMS: dynamics |
| 8 | crest:p2 | crest factor |
| 9 | attack_s:p2 | of the first note |
| 10 | tail_ratio:p2 | tail level relative to whole-phrase RMS |
| 11 | bass_fraction:p2 | Energy fraction below ~250 Hz |
| 12 | held_centroid_std:p2 | Centroid SD over the held note’s gate-on span only |
| 13 | high_ratio:p2 | RMS of the highest note’s span, relative to the held note’s |
| 14 | chord_flatness_delta:p2 | Flatness over the chord note’s span, minus the held note’s |
| 15 | motion_slow:p2 | Held-note motion energy, 0.5–2 Hz: sweeps and breathing |
| 16 | motion_mid:p2 | Held-note motion energy, 2–8 Hz: pulsing and tremolo |
| 17 | motion_fast:p2 | Held-note motion energy, 8–30 Hz: flutter |
The :p2 suffix is the stimulus generation
tag, and it is the migration
mechanism rather than a comment.
Why these axes and not the obvious ones
The model downstream is linear in , so the axis a feature lives on decides what preferences are expressible at all.
Frequency features are logarithmic, not linear in Hz
Brightness and pitch perception are octave-based. On a linear-Hz axis normalized by Nyquist, moving a patch from 200 Hz to 400 Hz (a full octave, an enormous audible change) shifts the coordinate by 0.009, while 8 kHz → 16 kHz shifts it by 0.36.
A linear model in that coordinate cannot represent a taste for basses a shade brighter: the entire usable range is swallowed by the bright tail of the pool. The preference is not hard to learn, it is inexpressible.
So log_axis puts centroid, rolloff, and ZCR on a shared octaves-above-20
Hz scale, normalized to at Nyquist:
20 Hz because below it frequency is not audible as pitch and the ratio scale stops meaning anything. Normalizing at Nyquist keeps the vector sample-rate agnostic.
Note that a zero-crossing rate is a frequency (two crossings per cycle), so it goes on the same axis:
where is the crossing fraction. Leaving it as a raw fraction would put a frequency-like quantity on a non-frequency axis beside three that are on one.
Heavy tails are logged
crest spans 1 to 40+; tail_ratio spans three orders of magnitude.
Standardizing either raw hands the model a coordinate whose z-score is
near-constant for most of the pool and for a handful of outliers: a
coordinate that separates nothing except the outliers.
The floor inside the tail log matters: a pluck fully decayed by the last 300 ms would otherwise send the log to , and “silent tail” and “very quiet tail” are the same judgment to a listener anyway.
The attack crossing is interpolated, not floored
Quantizing the 90%-of-peak crossing to the analysis-window index makes
attack_s exactly zero for every patch whose first window is already at
peak (most percussive patches), turning a continuous axis into a zero-inflated
spike.
So the envelope uses a fine grid (4 ms window, 1 ms hop) and interpolates linearly between the last sub-threshold hop and the first one over it:
The measurement window is onset → the second note’s onset (2.0 s under the v2 phrase), and the ms inside the log keeps the fast end resolved instead of compressing every percussive patch into the same value.
Spectral definitions
Per frame, with magnitudes over and :
Centroid. The magnitude-weighted mean frequency, then mapped by log_axis:
Rolloff. The lowest bin at which cumulative power reaches 85% of the total.
Flatness. Geometric over arithmetic mean of the power spectrum, clamped to 1:
Flux. Normalized by the combined magnitude sum of both frames:
Dividing by the current frame alone is the obvious choice and it explodes: a loud frame decaying into near-silence gives an enormous flux for a change that is barely audible. The combined denominator keeps it in roughly .
Frames whose power is below contribute to none of the spectral means: a silent frame has no centroid, and averaging in a zero would drag brightness down in proportion to how much silence the phrase happens to contain.
Segment-local coordinates
The last three are measured over one note’s gate-on span, and they exist because whole-phrase statistics conflate things a listener does not.
Roles are found by property, not position, which is what keeps them meaningful if the phrase changes:
- held: the first note.
- high: the highest note at least half an octave above the held one.
- chord: the first note with chord voices.
A phrase missing a role yields 0.0 for its features, which reads as “no evidence” rather than as a measurement.
held_centroid_std is the important one. centroid_std over the whole
phrase conflates note-to-note register jumps with genuine timbral motion: a
static patch played across two octaves has a large centroid_std. Restricted
to the held note’s span the coordinate is register-constant by
construction, so it is the axis on which “a filter sweeping at 0.4 Hz” and “a
static patch” are different patches at all. It needs at least 3 frames in the
span, or it reports 0.0.
high_ratio = of the high note’s span RMS over the held note’s.
Does the patch speak in the upper register, or does its filter choke it?
chord_flatness_delta = mean flatness over the chord span minus the held
span. Intermodulation and mud when voices stack.
Motion bands
held_centroid_std says how much a held note moves. It cannot say how
fast. Measured on one saw-into-ladder patch under a ladder of cutoff
modulations (cargo run -p auracle-features --example motion_probe --release),
a 0.55 Hz sweep and a 13 Hz flutter score 0.098 and 0.094, and stepped random
motion scores like a 6 Hz LFO. A linear model on those coordinates cannot hold
“slow breathing, not fast wobble”, which is the first thing anyone says about a
texture.
Hearing sorts fluctuation by modulation rate: a filterbank over the envelope, not just its variance (Dau, Kollmeier & Kohlrausch 1997), and the band-wise modulation power of a sound is much of what makes it recognizable as a texture at all (McDermott & Simoncelli 2011). The three coordinates are that filterbank, cut to three bands.
Motion is measured over the held span, starting once the note has arrived, so
the attack is not read as motion. That is 250 ms after onset (MOTION_SKIP_S,
in audio.rs), or later if the level, smoothed over ≈ 46 ms, has not yet
reached 97% of its peak (MOTION_ARRIVED). Two trajectories are taken at a
256-sample hop (MOTION_HOP, ≈ 172 frames/s); the spectral features’ own
43 frames/s would fold the fast band. One is brightness,
, in octaves. The other is
level, , where one
unit is 6 dB (one doubling, the same currency as an octave of brightness). A dip
reads at most 60 dB deep, so one frame of digital silence in a chopped sound
cannot outweigh every audible wobble.
The arrival rule matters for pads. A 0.9 s swell measured from the fixed 250 ms alone read 4.3 octaves over the floor in the slow band, because a ramp is curved in log level and detrending leaves most of it.
Each trajectory is linearly detrended (a ramp across the span is drift, which
held_centroid_std already carries), Hann-windowed, and transformed. With the detrended residual, its variance, and
its modulation power spectrum, band gets the variance share
The result is a log standard deviation in octaves. The floor, , is a hundredth of an octave: a static tone reads it exactly in all three bands, so “still” is one value and not numerical noise. A phrase whose held span is shorter than 0.75 s reads the floor too.
Measured on the probe ladder, the band that reads highest follows the rate: 0.55 Hz lands in slow, 2.7 Hz in mid, 13 Hz in fast, and stepped random motion spreads across slow and mid as its spectrum says it should.
What it cannot say
It does not say whether motion is regular. Separating a periodic sweep from a random walk needs several cycles in the window, and the held span holds fewer than three cycles of anything in the slow band. Both candidate measures tried (the normalized autocorrelation peak and the harmonic share of the modulation spectrum) separate periodic from random cleanly at 2.7 Hz and above, and not at all below 1.5 Hz, which is exactly where evolving textures live. A coordinate that guesses there would be taught to the model as a measurement, so regularity waits for a stimulus with a longer held span.
Deliberately compact
Eighteen dimensions is a choice. The model is a mixture of linear experts, and interpretable axes are the point: “bright”, “noisy”, “slow attack”, and “long tail” are things LEARNING’s weights can name and a person can recognize in their own taste.
A 128-dimensional MFCC bank would carry more information and would be unreadable, and would make the cold start dramatically worse: every dimension is posterior variance to pay down before the model says anything at all.
Known collinearity
Measured over 1,200 prior draws (cargo run -p auracle-features --example pipeline_stats --release -- 1200), the variance inflation factors are mostly
comfortable, with one cluster that is not:
| Coordinate | VIF |
|---|---|
rolloff_mean | ≈ 18.7 |
zcr_mean | ≈ 12.0 |
centroid_mean | ≈ 6.8 |
These are the values with the grammar’s octave weights (Discrete sites); drawing the octave uniformly, as it did before them, gave 19.7, 12.9, and 6.1. The zero-crossing rate’s DC removal, when it shipped, took them from 18.4, 10.4, and 5.9 to 16.9, 9.7, and 5.9 (Open questions has the history).
That is the brightness cluster: three genuine measurements of one
perceptual thing. It is left standing deliberately: dropping any of them
discards real signal rather than redundancy, since they disagree in informative
ways (a bright noisy patch and a bright tonal patch differ in
ZCR-versus-centroid). A shared (fused) prior over the cluster would be a
modeling change rather than a feature change. It is built and switched off
(TasteConfig::fused_rho, 0 by default): it improved θ recovery on the
closed-loop gate and made the climb worse, as
Open questions records.
For contrast, φ_struct had two exact linear
dependencies, which is a different and worse problem and was fixed by dropping
columns.
φ_struct: structural descriptors
Twenty-six dimensions, free to compute.
These cost nothing: no compile, no render, only a walk of the term. That is what makes a screening cascade possible: a structure-only surrogate could prune candidates before the expensive render path. No such cascade is built yet (Directions §12). They also capture taste axes audio features cannot fully separate (“likes supersaws”, “likes deep modulated chains”).
The coordinates
Nineteen family counts:
n_vco n_supersaw n_noise n_wavetable n_pluck n_formant n_silence
n_filter n_drive n_time n_mod_fx n_reverb n_dynamics
n_lfo n_env n_rand n_follow n_mod_shape n_mod_logic
Seven term-level numbers:
mod_density mod_depth_mean amp_attack amp_sustain amp_release
chain_balance frac_sidechained
Twenty-six in total, appended after the eighteen audio coordinates to give .
Families, not one column per module
StructFeatures keeps a raw counter per module kind internally (the Styles tab
and the auto-namer both want “two filters”, not “two subtractive stages”), but
NAMES and to_vec collapse forty-six productions into nineteen family
counts, and three of them, AudioIn, Track and Capture, into none (see below).
Two reasons.
Nothing meaningful distinguishes them. n_fold, n_distortion, and
n_bitcrush all answer “how much nonlinear color”. n_chorus, n_phaser,
n_flanger, n_tremolo, and n_vibrato all answer “how much periodic
movement”. A user who likes drive does not first decide which drive.
Per-kind columns arrive as near-indicator variables. The prior draws bitcrush at 2.5%, ring mod at 2%, and granular at 1.5%, so those columns are zero in ~19 of every 20 pool members. A coefficient fitted on a column that is almost always zero is estimated from a handful of rows, and the Styles tab would render it beside coefficients fitted on hundreds, at the same visual weight.
Measured over 1,200 draws, the extreme case: each of the four CV processors appears in under 4% of patches and each of the six combiners in under 1%. A column that is zero in 99 rows of every 100 is not a coefficient, it is a rounding error with a name in the UI.
Sixteen sparse columns would also cost sixteen dimensions of posterior variance for the cold start to pay down before the model says anything at all.
The step sequencer shows the rule applied to a newcomer. The prior draws it
into about 3% of slots, so a column of its own would be exactly the
near-indicator above. It joins n_rand, which has become the stepped CV
family (StructFeatures::n_stepped): to a listener an S&H and a step pattern
are the same gesture, a value that holds and jumps (or glides) on a clock, and
they differ only in whether the values were drawn once into the genome or
anew at every tick. It does not join the euclid in n_mod_logic, because a
euclid emits a gate and a step sequence emits a value. The column keeps the
name n_rand because stored observations carry φ names, and every row already
on disk predates the step sequencer, so its n_rand already is its
stepped-CV count. The panel labels the column “stepped mods”.
What is deliberately not in φ
size, an exact identity
Every audio node increments exactly one raw counter, so
Including it makes the design matrix rank-deficient. The Gaussian prior
keeps the posterior proper, so nothing crashes and no test fails, but there is
an unidentified ridge along which the MH chain random-walks forever. That
wrecks mixing, splits each coefficient arbitrarily between size and the
counts (so the per-feature weights the Styles tab renders mean nothing
individually), and poisons the
taste→grammar proposal tilt, which reads exactly those
coefficients.
size − depth would be no better: still an exact linear combination of
coordinates already present.
The field is kept for display and naming. It just never reaches the model.
A second, subtler identity
Dropping size alone was not enough, and a VIF sweep caught it: VIF
on every column involved, which is what an exact dependency
looks like numerically.
A tree is a forest of source leaves joined by productions that each take some number of audio children, so the leaf count exceeds the total branch count by exactly one:
Silence joins that sum as a source leaf, because that is what it is: it has
no children, so it ends a branch exactly as a Vco does. Joining keeps this
one equation with one dropped column, and stays the
column dropped. Leaving it outside instead would make the identity exact for a
tree with no holes and slack for one with them: near-exact almost always,
which is a worse thing to carry than an exact dependency: an exact one is
unmistakable in a VIF sweep, and a near-exact one is a large number that looks
like a judgment call.
Audio in: counted, and not a column
AudioIn is a source leaf too, and it has its raw counter, n_audio_in, so
size is still the sum of the counts and the identity above, written over the
raw counts, still holds. But it is not a column of : RFC-008 keeps
’s shape, and at its prior weight (0 until live capture works, 0.5%
after) the column would be zero in nearly every row. Leaving it out brings no dependency back, because the identity was
never reachable from the retained columns: it needs n_mix, which is dropped,
and each binary count separately, which the families hide. The shape
coordinates count it with no help (chain_balance and frac_sidechained read
arity from the tree, where it is a leaf), and what an input patch does to a
signal reaches the model through , measured on the
audition clip.
Track and Capture are counted the same way, with n_track and n_capture
and no column, and one more reason: the prior never draws either (they are
player kinds), so a
column would be zero in every drawn row. The shape coordinates read them from
the tree: a TRACK is a binary node, a CAPTURE a unary one over what it records.
The identity holds exactly, for every tree. It became a general statement only when the four sidechained productions (comp, duck, gate, and vocoder) arrived, each taking two audio subterms exactly as mix and ring mod do.
That is one equation, so exactly one column has to go, and dropping more would remove real dimensions rather than redundant ones. With both binary counts gone, could not tell a crossfade from a ring modulator at all, which are about as different as two nodes in this grammar get.
So n_mix leaves: it is the one determined by the others, and its proposal
tilt is recovered from the source coefficients in the engine’s biased_prior.
The other five stay, but never as columns of their own: ring mod lives
inside n_drive, the vocoder inside n_filter, and comp/duck/gate inside
n_dynamics.
Why n_dynamics is still safe
Worth checking rather than assuming, because n_dynamics is exactly
and it is a retained
column, the only family whose members are all on the wrong side of the
identity.
It is safe because the identity needs each binary count separately.
n_ringmod is only ever visible summed with folds, distortions, and
bitcrushers; n_vocoder only summed with filters and EQs. No linear
combination of the retained columns isolates either, so the equation cannot be
reconstructed. n_dynamics supplies three of the six binary terms and nothing
supplies the other three.
Confirmed empirically: on the 1,200-draw sweep every structural coordinate comes
back well under 10, with n_dynamics at 1.7 (1.9 when this was first
measured).
depth, a weaker but real argument
VIF . Not exact, so the posterior stays proper, but a coefficient that unstable is not individually meaningful, and the Styles tab renders these per-feature weights as though they were. Dropped.
Health of the retained set
Every family count comes back at 3.0 or under on the 1,200-draw sweep, the
highest being n_noise. That is the reason the families exist; forty separate
module columns would not have managed it. The term-level numbers run higher,
and still well under 10: amp_attack is the highest of all twenty-six at 6.3,
then mod_density 4.6, amp_release 4.3 and mod_depth_mean 4.1.
When the three most recent additions arrived they measured n_mod_shape 1.6,
n_mod_logic 1.3 and mod_depth_mean 3.8, with mod_density rising from 2.7
to 4.1 as the one visible cost of adding a second modulation-shape coordinate
beside it. The same sweep today reads 1.8, 1.2, 4.1 and 4.6.
Reproduce with:
cargo run -p auracle-features --example pipeline_stats --release -- 1200
The unit coordinates
Six of the twenty-six are UNIT_NAMES, a subset of NAMES rather than a
reordering. Each is either a normalized genome site read straight through
(amp_attack, amp_sustain, and amp_release) or a ratio of two counts,
already in (mod_density, chain_balance, and frac_sidechained).
mod_depth_mean is not one of them. It is a mean of modulation depths (1
for a bare modulator, 2 for one shaped once), so it is 2 or more for exactly
the shaped chains it exists to measure.
The distinction matters for display: these can be rendered as percentages honestly, whereas a family count cannot.
amp_sustain is also the coordinate the 1e30 sentinel killed; see
the sentinel.
Standardization
A Gaussian prior over only makes sense on a common scale. The fitting of that scale is a view of the data, not the data.
Raw scales vary wildly: counts 0–5, log-octave axes ~0–1, log crest 0–4. The standardizer is a per-dimension affine map
re-fit at every posterior fit, over the union of the observation log and the live pool.
It persists with the profile, always
is only meaningful relative to the standardization that produced it, so a taste profile carries both or neither. A log without its standardizer is a set of numbers whose units have been lost.
The log stores raw , which is what makes re-fitting safe: a re-fit standardizer simply re-expresses the same evidence on a scale that still matches where the pool actually is. Had the log stored z-scores, the scale would be frozen at whatever the pool looked like on the day each vote was cast.
The inverse map exists for exactly one reason: migrating logs written before raw-φ logging. A legacy log plus the standardizer it was written under is the raw data, just encoded.
Robustness: a fault detector, not a policy
Standardizer::fit is the plain moments unless a column is provably
runaway. On clean data it is bit-identical to the naive fit.
Per column:
- Drop non-finite cells (a column that is entirely non-finite falls back to , the reading of “no usable evidence on this axis”).
- Compute the plain moments .
- Compute winsorized moments with the extreme 2% of each tail pulled in.
- Use the winsorized pair only if .
The constants, in auracle-taste’s standardize.rs:
const WINSOR_TAIL: f64 = 0.02;
const WINSOR_MIN_ROWS: usize = 10;
const RUNAWAY_RATIO: f64 = 1e6;
Finally if , so a degenerate column standardizes everything to itself rather than dividing by nothing.
Why not just winsorize always
Because it was tried first and thrown out, and the measurement is why.
Clipping 2% of each tail unconditionally took a 16-seed search_health --climb
run from +1.877 ± 0.362 mean gain, climbing on 15 of 16 seeds, to +0.204
± 1.347 on 11 of 16, with one seed at −18.2.
Trimming a real tail is not free. A data-hygiene fix that costs the search a standard deviation is not a fix. So the clip became a fault detector: plain moments unless the column is provably broken.
Why the threshold is
The first guess was 8×, on the reasoning that clean columns differ “by a factor of order one”. The paired run said otherwise: 15 of 16 seeds came back bit-identical and the sixteenth went from +0.12 to −40.5.
So the threshold was measured.
cargo run -p auracle-features --example winsor_ratio --release -- 150
fits 150 clean 48-patch pools and reports the largest plain/winsorized
ratio per column. Over 6,000 column-fits the maximum is 14.6
(rms_std:p2), with chord_flatness_delta:p2 at 13.9, and still climbing
with the sample, because a log-scale audio descriptor over a pool that happens
to contain one near-silent patch genuinely has a tail.
Meanwhile a single in a column whose real values live in gives a ratio near .
sits five orders above anything clean has been observed to produce and twenty-three below the fault, which is about as far from both edges as this quantity allows.
The tail size
ceil, not floor. The first version used floor and was inert exactly
where it was needed. The reference population is a 48-patch pool, and , so nothing was clipped at the size the app
actually fits at. The pre/post measurement came back bit-identical and said so.
Below 10 rows nothing is winsorized at all: with a handful of values the min and max are the spread, and pulling them in throws away the only information about it.
There is also a hi > lo guard, which keeps a legitimately rare column intact:
when 96% of rows are the same value (a module that appears in two patches out
of forty-eight) the tail is the column’s only information, and clipping it
would flatten a real coordinate to nothing in the name of robustness.
Winsorizing rather than trimming
When it does fire, the extreme rows are pulled in, not dropped. The rows are not independent draws from a nuisance distribution: they are the patches the player actually met, and a real extreme patch is evidence about where the pool is. Winsorizing keeps its vote and takes away only its leverage on the units.
Two properties, both tested
A single escaped row cannot kill a column. Fifty values spread over plus one : unwinsorized, the outlier owns the mean and the scale, every real patch standardizes to the same place, and the column is dead. The model can never learn from an axis whose fifty honest values are separated by of a standard deviation. The test asserts moves by less than 0.05 and that the coordinate still separates two real patches by more than 3.
Clean columns come out bit-identical to the plain moments. The load-bearing
property, asserted with assert_eq! on floats, because “close enough” would
let the regression back in, over a heavy right tail, a near-constant column, a
bipolar one, a count with a legitimately extreme member, and a five-row column
below the floor entirely.
One implementation detail exists to protect that property: the column stays in row order and the quantiles come off a copy. Floating-point addition is not associative, so summing the sorted column would move the mean by a ULP on clean data, and the whole claim is that clean data comes out bit-identical.
Where this fits in the defense
The fault this detector exists for is fixed upstream of here: clamp_domains
on load, FeaturizeError::OutOfDomain before the render, the load-time repair.
This is the line that means the next escape costs a coordinate’s precision
rather than the coordinate.
Layers above should make this unnecessary. It exists anyway, because in the sentinel incident the value got through everything that was supposed to stop it.
Faces
A face is a picture of a render: its spectrum in bands and slices of time, compared with the bank. It is not a feature. Nothing in , the taste model or the vetting gate reads it.
The app draws every sound as its face (the guide’s
a sound’s face). The measurement is
auracle_features::face; the comparison with the bank is apps/web/faces.js,
and one renderer draws a face at every size, from a bank row’s icon to a full
screen, apps/web/vessel.js.
Where it is taken
Inside every featurization (featurize_memo), from the same normalized render
is measured on, and carried beside on the memo row
(CachedFeatures::face). Every render already passes through there (the
serial fill, the farm’s farm_render, a generation’s walks, an offer, an
edit), so a face costs no render of its own: 1.66 ms natively against a
146 ms featurization (1.1%, examples/face_cost.rs over the 62 presets), and
2.5 ms in wasm against 201 ms (auracle-wasm/examples/face_cost.mjs).
Samples are read as the f32 the audition buffer holds, so the face of the
featurization’s render and the face of the stored audition are the same bytes.
A row stored before faces existed has none and is still current
(the RENDER_EPOCH does not move for it); its face is completed by a render
when the app asks for it.
The measurement
Hann-windowed frames of 2048 samples, hop 1024, of the mono render .
Bands. FACE_BANDS = 40 bands with edges evenly spaced in log frequency
from FACE_LO_HZ = 35 Hz to FACE_HI_HZ = 14 kHz (about a fifth of an
octave each):
A band’s value is the mean power density over its edges. With the power in FFT bin (covering in bins) and the length of band ’s overlap with bin ,
A band narrower than one bin (every band below about 150 Hz at 44.1 kHz) reads the bin it sits in rather than nothing, and white noise reads the same in every band. (Prototype v2’s data summed whole bins, and its two narrowest bands read empty.)
Slices. The render is cut into FACE_SLICES = 12 equal spans of time, and
a frame belongs to the slice its center falls in. On the standard phrase
(5.05 s) a slice is 0.42 s: five slices of the held C4, one of the C5 stab,
two of the dyad, four of the low C3 and its release. Each slice’s spectrum is
the mean of its frames’ power; the long-term spectrum is the mean over every
frame.
Levels. Each spectrum is in dB relative to its own loudest band, so a face
shows shape and not level (the render is loudness-normalized already), floored
at FACE_FLOOR_DB = −60 dB. Each slice’s loudness is its mean power in dB
relative to the loudest slice, with the same floor. A slice under about
−170 dBFS is silence: all floor.
Encoding. FACE_LEN = 532 bytes, each a level in FACE_STEP_DB = 0.5 dB
steps above the floor: the long-term spectrum (40), the slices (12 × 40),
their loudness (12). It serializes as one base64 string, about 700 bytes
beside a memo row of about 1 KB.
Against the bank
The app knows which sounds the bank shows, so the comparison is made there, over the long-term spectra of the bank’s sounds (the pool, less what was cut):
The spread is one number pooled over every band of every sound, as
prototype v2 took it, floored at 3 dB (FACE_SPREAD_FLOOR_DB) so a bank of
near copies does not blow a fraction of a dB up to full width. A spectrum
is drawn as
Below FACE_MIN_BANK = 4 faces in the bank there is nothing to compare
with, and no face is drawn.
When it is taken again. The bank’s and are recomputed when
the set of the bank’s faces changes (a sound added, replaced or cut; at most
once a frame), and the faces are drawn against them only when they have moved
since the faces were last drawn by more than FACE_RESTAT_DB = 0.25 dB in a
band or FACE_RESTAT_SPREAD = 1% of . A smaller change moves a row's 24 px face by under a tenth of a pixel at the bank's usual spread (about 13 dB), and by about a third of one at the 3 dB floor; the 150 px card's by under half a pixel, and about two at the floor (a band’s
half-width moves by at the mean). The recomputation is ;
redrawing is the cost, so the bank’s rows in view are redrawn the next frame
and the rest when the page is idle (2–3 ms in the next frame for a pool
of 40, against 17–22 ms when all forty were redrawn at once).
The drawing
As prototype v2 draws it (A.face): the vessel is mirrored about its center,
low bands at the base, with half-width
for a box of half-width ( here the logistic function), so a band
at the bank’s mean is half the box wide. The 40 values are smoothed over 2 bands
either side (1 on a face 36 px tall or more) and joined with quadratic curves
through their midpoints. The outline is the long-term spectrum ();
under it each slice draws a faint layer with and
opacity , where
, and a
slice with draws none (FACE_LAYER_RANGE_DB).
Large faces (the share card, a full screen) add the specimen’s glow, a blur of 14 to 30 px on the outline, and the floor’s reflection: a line of light under the vessel and its outline mirrored in it, faint and fading over a third of its height. A slot shows its face drawn once per bank, at the screen’s density, as an image.
Keys and caching
A face is filed under its render namespace and render key,
"<cache_namespace>/<render_key>", as a farm row is
(persistence, the web runtime). The
engine worker copies a face out of the memo (a least-recently-used map a
generation’s walks churn) the first time it is asked for, and keeps it in
memory and in an IndexedDB store stamped with the namespace: a build whose
renders differ cannot read another’s faces. What the worker holds in memory,
what the memo holds and what a resident audition gives are answered at once;
the store is looked up in its background lane, and a face none of them has
is rendered in a lane of its own below that, after the bank has finished
arriving, behind a refit, a guess or a cable probe, and dropped if the row
asking for it has left the view. Such a render is not kept, so it pushes no
audition out of the cache. A face’s render already running (about half a
second) is the most it can hold anything up by.
The presets’ faces ship with the app. A preset’s render is fixed, so
every preset’s face is rendered ahead of time, natively, through the same
engine surface the worker uses, into apps/web/preset-faces.json
(make preset-faces), with the render namespace and the reference clip it
was rendered under and each preset’s render key. The page draws a preset’s
face from it, filed under the same key, without asking the engine, where the
namespace is the session’s and, for a preset with an AUDIO IN, while the
session hears the reference clip; anywhere else the engine renders it as
above. A test renders every preset again natively and fails while the file
differs by a byte (crates/auracle-wasm/tests/shipped_faces.rs), and
another renders every preset in the built wasm, as the worker renders a
preset’s face, and fails while one of them differs from the file’s, or is
filed under another key (tests/web/boot_agrees.spec.js): a face is
quantized to half a decibel, so a last-digit difference between the two
builds could move one preset’s band by a step.
The stage’s live outline (PERFORM’s stage mode) is measured as a face
is: the output’s latest 2048 samples, through the same Hann frame and FFT,
read as each band’s mean power density over the same fractional bins
(faces.js createLiveMeter, bandWeights), into buffers made once. Not
the analyzer node’s own spectrum, which is Blackman-windowed: on a C3
sawtooth it read a low band 16.5 dB away from the face. A test feeds the
meter the engine’s own fixture (one frame of that sawtooth and the face the
engine takes of it) and holds every band to the face within 1 dB (it agrees
to 0.24 dB); the same frame through Blackman fails it.
A sound of your own
A recording you bring is measured the way a patch is, on the part of φ a recording can measure. It takes a place among the sounds, its nearest are named, and a generation can be bred toward it.
This is the engine and the worker's side of Plan-005 task 11. The app does not offer it yet: the card that takes a dropped file is still to be built.
A recording is not the phrase
Every patch is measured on the standard phrase: a held C4, a C5 stab, a dyad and a low C3 with a release window, with rests between them. Each coordinate of φ is a statistic of that render. A recording is one sound of something else entirely, with no notes the engine knows of. So a coordinate means the same thing on a file only if it does not depend on what the phrase plays.
That was measured, not argued. examples/file_phi.rs (in auracle-features)
renders every preset on three stimuli that are not the phrase (a legato melody
with no rests, a drone, short stabs over two octaves), measures each render as
a file, and compares the result with the same preset's φ on the phrase. The
preset is the truth: a file of that patch should land where the patch does. A
coordinate counts as measured by a file when, on every stimulus, its
correlation with the phrase value across the presets is at least SURVIVES_R
and its root-mean-square difference is at most SURVIVES_RMSE σ
of the presets' spread on the phrase (two presets drawn at random differ by
about 1.4σ). Every σ in this table is that one: the presets' spread, not a
session's. Run it with cargo run -p auracle-features --example file_phi --release; it also prints each coordinate's bias.
| Coordinate | melody r / rmse | drone r / rmse | stabs r / rmse | Measured |
|---|---|---|---|---|
centroid_mean | 1.00 / 0.17 | 0.98 / 0.23 | 0.98 / 0.22 | yes |
rolloff_mean | 0.99 / 0.18 | 0.98 / 0.25 | 0.99 / 0.19 | yes |
flatness_mean | 1.00 / 0.03 | 1.00 / 0.04 | 1.00 / 0.05 | yes |
zcr_mean | 0.98 / 0.41 | 0.99 / 0.33 | 0.95 / 0.33 | yes |
bass_fraction | 0.98 / 0.20 | 0.96 / 0.43 | 0.93 / 0.39 | yes |
centroid_std | 0.94 / 0.43 | 0.84 / 0.93 | 0.90 / 0.49 | no |
flux_mean | 0.93 / 0.42 | 0.60 / 0.73 | 0.72 / 1.61 | no |
rms_mean | 0.88 / 0.93 | 0.79 / 0.93 | 0.81 / 0.58 | no |
rms_std | 0.13 / 1.82 | 0.31 / 2.08 | 0.67 / 1.78 | no |
crest | 0.90 / 0.92 | 0.83 / 0.96 | 0.84 / 0.64 | no |
attack_s | 0.64 / 0.95 | 0.99 / 0.14 | 0.47 / 1.21 | no |
tail_ratio | 0.11 / 2.99 | 0.19 / 2.98 | 0.12 / 3.34 | no |
62 presets on the phrase; 62, 56 and 62 measured as files (six presets fade below the trim before half a second of drone has passed).
The measured coordinates are not unbiased. A file reads a little brighter than
its preset does on the phrase: zcr_mean by +0.35σ on the melody and +0.30σ
on the drone, centroid_mean and rolloff_mean by +0.09 to +0.17σ. Within the
bar, so they are measured; but a recording sits a shade brighter among the
sounds than the patch that made it would, and its nearest sounds lean that way.
A one-shot shorter than the half second a file must hold does not measure them
all: with a single G4 of 0.25 s added to the stimuli, zcr_mean (0.55σ) and
bass_fraction (0.62σ) miss the bar, and they still miss at 0.5 s (0.54σ,
0.61σ). One short note at one pitch says too little about weight and
brightness, which is why FILE_MIN_SECONDS stays at 0.5 s.
What a file measures is its spectral balance: how bright it is
(centroid_mean, rolloff_mean, zcr_mean), how noisy (flatness_mean) and
how much weight it has below 250 Hz (bass_fraction). Those depend on what the
sound is. The rest depend as well on what was played:
- Level and dynamics (
rms_mean,rms_std,crest) carry the phrase's four rests and its release window, and a recording has neither. - Movement (
centroid_std,flux_mean) over the whole phrase is mostly its jumps between registers, and a file's is whatever it plays. - The attack is the phrase's held C4 arriving, and a file's first event is something else.
- The tail is the phrase's release window; a trimmed file's last 300 ms are wherever the sound stopped.
- The six role coordinates (
held_centroid_std,high_ratio,chord_flatness_deltaand the three motion bands) each read one note of the phrase by its role, and a file has no such note. - Every structural coordinate describes a patch, and a recording has none.
So a file is placed by 5 of φ's 44 coordinates. The other 39 are masked
(FILE_MASKED and the structural half; file_observed() is the whole mask).
A gate test re-measures half the presets on every build and fails when the
mask and the measurement disagree.
How a file is measured
featurize_file takes what the page decoded, mixed to mono, at its own rate,
and measures it as featurize measures a render:
- Bounds. A rate from 8 to 192 kHz, at most
FILE_INPUT_MAX_SECONDSs in, every sample finite. A refusal is a flag the app words (bad_rate,too_long,too_short,non_finite,silent), never text. - Trim. The sound runs from the first 10 ms window within
TRIM_DBdB of the loudest to the end of the last one. Silence inside the recording is kept; it is part of the sound. - Cut. At most
FILE_MAX_SECONDSs of sound, from where it starts, and at leastFILE_MIN_SECONDSs. - Resample to the phrase's 44.1 kHz with a windowed sinc (16 zero crossings, Blackman window, cut at 0.97 of the lower Nyquist). Every frequency coordinate is normalized at Nyquist, so this is what puts a 48 kHz file's brightness on the same axis as a render's. A file already at 44.1 kHz is not touched.
- Normalize to
TARGET_LUFSunder the same peak ceiling, as loudness normalization does for every render. - Measure with the same
audio_featuresa render goes through. Nothing in it changes for a file. Its first-note window is the phrase's own (onset to the second onset, 2.0 s), and its role coordinates find no note and read their "no evidence" values, which the mask then ignores.
Measuring a render's φ is unchanged by any of this: no coordinate, constant or
step of featurize moved.
What the page sends
The page decodes the file, mixes it to mono, cuts it to 120 s and resamples
anything above 48 kHz down to 48 kHz before it posts it. The engine measures at
most the first 30 s of sound, and the samples are copied into the engine's
memory, which wasm never gives back: two minutes grow it by about 42 MB at
48 kHz and 108 MB at 192 kHz. The analysis is one call the worker cannot
interrupt; for 30 s of sound it takes 0.16 s at 44.1 kHz, 0.31 s at 48, 0.52 s
at 96 and 0.94 s at 192 (auracle-wasm's examples/own_cost.mjs, wasm under
node on an M3 Max), most of it resampling.
What each refusal asks of the card:
too_long: more than 120 s reached the engine. Cut it and send it again.too_short: under half a second of sound once trimmed. Nothing to retry; the card says the sound is too short to place.silent,non_finite,bad_rate: nothing in it can be measured.- After any refusal the sound brought before is still the session's: the card
asks
own_soundto redraw it.
Missing coordinates
What a masked coordinate becomes depends on what reads the sound, and none of it claims to know the coordinate's value:
- Distances leave them out. The nearest sounds are ranked by Euclidean distance in standardized φ over the five measured coordinates. Filling a masked coordinate with the pool's mean would pull every distance toward the center of the pool and call the most ordinary patch the nearest.
- The tilt has no factor for them, which is the same as integrating them out (below).
- Anything linear reads them at the mean, where they add exactly nothing. The map's projection is one such reading (below). A utility would be another, and the observation log already puts a coordinate an old vote lacks at the standardizer's mean for the same reason: zero contribution, exactly "no evidence". Nothing reads the sound's utility today.
Its place among the sounds
The sound's is its raw φ standardized by the session's standardizer, the same scale every pool member's is on, over the five coordinates it has.
Its nearest sounds (Engine::own_nearest, Engine::own_nearest_presets)
are ranked by
where is the set of measured coordinates and the file's. TASTE's map
is a projection of this same Euclidean space (PCA over standardized φ), so near
means what the map draws as near, without the projection's loss. The app had no
nearest sounds before this, so there was no other metric to match. The presets
are ranked from the measurements the app already ships
(perform-wirings.json, regenerated by make perform-wirings), back to raw φ
and standardized by this session's scale: no preset is rendered.
Its place on the map (TasteMap::own) is the map's own projection of the
five measured coordinates, on the map's axes about its center :
which is every other point's projection with each masked coordinate at the map's mean. The sound is placed on the map; it is not one of the points the axes are fitted to, so bringing a sound does not move the map.
That was chosen by measurement, in examples/own_census.rs (in
auracle-session). Each target preset is put into a taught pool, so the map
draws it, and its recording is placed. Error is the distance to the preset's
own point, as a share of the map's spread (the RMS distance of its points from
the center), over 18 placements (3 sessions × 6 presets):
| Placed by | Error |
|---|---|
The projection above (Placement::Imputed, used) | 0.37 ± 0.05 |
Probabilistic PCA's posterior mean for a partly observed point (Placement::Fit) | 0.41 ± 0.04 |
| The centroid of its three nearest pool members (the prototype's way) | 0.53 ± 0.07 |
| The projection, from the preset's phrase φ on the same five coordinates | 0.36 ± 0.05 |
The posterior mean, with , the axes' variances and the variance they leave unexplained, shrinks a point measured on few coordinates toward the center; here that cost more than it saved. The last row says where the error comes from: a recording costs almost nothing over the phrase on the same coordinates, and the mask is what costs. Five coordinates put a sound within about a third of the map's spread of where its whole φ would, which is what the prototype's card means by "placed by its spectrum".
Breeding toward it
Engine::refine_toward_jobs opens a generation exactly as
EVOLVE POOL does, with two differences. Its parents
are the refine_seeds pool members nearest the sound, not the best ranked:
breeding toward a sound starts from what is already near it. And every walk
samples a target tilted toward the sound (TowardFitness):
where is the taste surrogate , ,
is OWN_GAMMA and is OWN_FLOOR .
Read as Bayes, this is the taste's own target times a Gaussian likelihood with : the patches you would like, weighted by how well each explains the recording as a measurement of its sound. A masked coordinate has no factor, which is that likelihood integrated over it. At , σ of the session's spread. That is the same size as the error a recording makes on the coordinates it measures (0.03 to 0.43σ in the table above), but those are σ of the presets' spread, a different scale, so it is a choice of size made with the census below, not a calibration of the file's error.
The floor is the likelihood of a contaminated measurement: the recording is the patch's sound with noise , or a sound this grammar does not make, and then distance says nothing more. It is also what keeps the walk out of patches that do not vet. A proposal that does not vet has no φ to measure, so it keeps its quarantine score () untilted; an unbounded pull would rank it above every patch more than σ from the target, and a first census without the floor saw strongly pulled walks end on one; with it, none did. Half the quarantine's depth keeps every listenable patch above quarantine for any taste above , and still pulls out to σ.
The walk stays exact Metropolis–Hastings, as an
aimed offer does: the
kernel is untouched and the tilt is a deterministic function of the patch, so
every acceptance is the ratio of this target, and the state a walk ends on is a
draw from it. The tilt costs no render: the taste
surrogate has just featurized the proposal through the same memo. A proposal
that does not vet keeps its quarantine score. The target rides in the
generation's context (WalkContext::toward), so the render farm walks the same
tilted target the engine would. A generation without a target walks exactly as
before; at the tilted walk is the untilted one, bit for bit.
Children are admitted by the bar every generation's are: a child must please the taste more than the member it would displace. So the engine breeds toward a sound only after the first fit, as EVOLVE POOL does; before it, Breed toward it adds the nearest presets (below).
Measured
examples/own_census.rs: three sessions (a pool of 48 and a taste taught by a
synthetic listener over 60 duels), six target presets recorded on the melody,
five walks of 40 steps a target, every arm from the same job seeds, so the arms
are paired walk by walk. cargo run -p auracle-session --example own_census --release reproduces these tables (its defaults are 3 6 1,2,4,8 5), and its
placement error lines the placement table above. Distances are in σ of the
session's spread over the five measured coordinates, mean ±
standard error over 90 walks: to the recording's at the walk's start and
end, and at the end to the preset itself (its phrase φ on the same
coordinates), which is the truth the recording stands in for. is
the child's expected utility minus its parent's.
From the parents nearest the sound, which is what a breed toward it does:
| Arm | Start | End | Change | Ends nearer | End to the preset | |
|---|---|---|---|---|---|---|
| untilted | 0.61 ± 0.04 | 2.55 ± 0.16 | +1.94 ± 0.16 | 7% | +0.86 ± 0.17 | 2.60 ± 0.16 |
| γ = 1 | 0.61 ± 0.04 | 0.88 ± 0.05 | +0.27 ± 0.05 | 33% | +0.55 ± 0.12 | 0.94 ± 0.05 |
| γ = 2 | 0.61 ± 0.04 | 0.67 ± 0.04 | +0.06 ± 0.04 | 48% | +0.32 ± 0.08 | 0.71 ± 0.03 |
| γ = 4 | 0.61 ± 0.04 | 0.52 ± 0.03 | −0.09 ± 0.03 | 59% | +0.36 ± 0.07 | 0.61 ± 0.03 |
| γ = 8 | 0.61 ± 0.04 | 0.43 ± 0.02 | −0.18 ± 0.03 | 71% | +0.25 ± 0.08 | 0.53 ± 0.02 |
From the taste's own parents (the best ranked, as EVOLVE POOL breeds from), which says whether the pull works from farther away:
| Arm | Start | End | Change | Ends nearer | End to the preset | |
|---|---|---|---|---|---|---|
| untilted | 2.18 ± 0.15 | 2.29 ± 0.14 | +0.12 ± 0.17 | 50% | +0.73 ± 0.41 | 2.38 ± 0.15 |
| γ = 1 | 2.18 ± 0.15 | 1.06 ± 0.07 | −1.12 ± 0.15 | 78% | +0.03 ± 0.17 | 1.16 ± 0.06 |
| γ = 2 | 2.18 ± 0.15 | 0.93 ± 0.06 | −1.25 ± 0.15 | 81% | −0.10 ± 0.17 | 1.01 ± 0.06 |
| γ = 4 | 2.18 ± 0.15 | 0.79 ± 0.05 | −1.38 ± 0.15 | 88% | −0.56 ± 0.10 | 0.84 ± 0.05 |
| γ = 8 | 2.18 ± 0.15 | 0.86 ± 0.09 | −1.32 ± 0.16 | 89% | −0.30 ± 0.25 | 0.94 ± 0.09 |
Tilted walks end nearer than untilted ones from the same jobs by 2.03 ± 0.16σ from the nearest parents and 1.50 ± 0.15σ from the taste's (paired by walk, γ = 4). Untilted, a walk from the nearest parents wanders off the sound as it climbs the taste; tilted, it stays near and still pleases the taste more than its parent. From farther away the pull costs taste (−0.56 at γ = 4), which is the trade the target states. γ = 4 is the smallest pull whose walks from the nearest parents end nearer than they began, and from the taste's parents it ends nearest both the recording and the preset: at 8 the floor leaves the farther walks unguided. Of the 90 γ = 4 children from the nearest parents, absorbed into their generations as the app would, 86 were admitted and 4 were not.
What is not done, and what the card will do
The card is the app's half, still to be built: the worker answers
own_sound_set, own_sound, own_sound_clear and refine with
toward: true, and nothing sends them yet. Where the prototype shows what the
engine does not do, the maintainer decided the card's behavior (Plan-005 task
11):
- In the bank, never in a pair. The prototype lists the recording among the presets and plays it from there, and so will the card: the page keeps the decoded audio in IndexedDB, so it plays after a reload too. In the engine the sound has no tree, so it is never dealt in a pair, held as a patch or bred from; the session keeps only its measured coordinates, by name, because an autosave holding half a minute of samples would be megabytes the engine never reads.
- Before the first fit, Breed toward it adds the nearest presets, as the
prototype does:
own_nearest_presetsnames them andload_presetadds them, with no taste needed. After the first fit it breeds the tilted generation above. - The children grow from their parents, the pool members nearest the sound, leaning toward the recording; the prototype's buds from the dropped face would show a fact the engine does not record (ADR-012).
- Hold it plays the recording through the named controls once audio in is live (Plan-007), the UI stream's work.
- Nearest, and the map, are of other sounds. The prototype ranks presets by its 40-band face and places the recording on a map of the 62 presets. The engine ranks pool members and presets, separately, over the five coordinates, and places it on TASTE's map of the pool and what you have heard. The face itself is the page's to draw from the decoded file, as for any render.
Not done in the engine:
- More of φ. Five coordinates are what a recording measures under today's
φ. A recording's movement, attack and tail would need coordinates defined
without the phrase's notes, which is a change to φ and owes a revalidation.
A change to φ also owes a re-measure of the mask: the gate test fails until
FILE_MASKEDagrees withfile_phiagain.
Utility as a max of experts
A candidate is as good as its best style thinks it is. Two more obvious designs cannot represent a cross-island comparison at all.
The form
A candidate has the feature vector , which the standardizer maps to with per-feature means and scales . The taste model holds styles, and style is a weight vector : a linear functional on . Utility is the maximum over styles, not a weighted mixture:
At this reduces exactly to Bayesian linear regression on , which is a useful property: the mixture is a strict generalization with no special-casing at the boundary.
One posterior draw’s utility (TasteSample::utility_mix):
pub fn utility_mix(&self, phi: &[f64]) -> f64 {
self.theta.iter()
.map(|t| dot(t, phi))
.fold(f64::NEG_INFINITY, f64::max)
}
Why a maximum
Taste is multi-modal. One person can love dark drones and bright plucks (“ambient-me” and “acid-me”), and those are not points on one axis. A single linear utility would average them into a preference for neither, and would then be confidently wrong about both.
The max form gives each island its own style, and every judgment, including a duel across two islands, compares candidates on the shared scale . A dark drone and a bright pluck are both scored, each by whichever style rates it highest, and the comparison is well-formed.
Two rejected designs, and why
A per-session style latent
“One mood per session: sample which style is active, then use it.”
Fails because it cannot represent several islands inside a session. A user who auditions a pad, then a bass, then a pad in one sitting is not switching moods; they have two tastes at once. Whenever the session’s latent is wrong for the current candidate, every observation in that session is scored by the wrong style.
A per-observation marginalized style
“Marginalize over which style judges each observation.”
Fails on a sharper point: it forces both duel items through the same style, so a cross-island comparison is unrepresentable. There is no style under which “the drone beats the pluck” is a sensible statement if the drone lives in style 1 and the pluck in style 2, and a duel between them is exactly the question the acquisition rule will ask.
This is not a theoretical objection. A synthetic bimodal user exposed it: the marginalized mixture failed to beat . Adding capacity made the model no better, which is the signature of capacity the likelihood cannot use.
What max-utility buys structurally
There are no discrete latent sites at all. No style assignment to sample,
no categorical variables, no label-switching during inference to fight. Every
site in the model is an f64, which means fugue’s generic adaptive single-site
MH applies unchanged, with no custom kernel and no Rao-Blackwellization.
Label permutation is resolved post hoc instead, by
TastePosterior::aligned.
is an upper bound, not a claim
by default (SessionConfig::k_styles, in engine.rs), and the fitted
number of live styles grows with evidence.
Nothing enforces that; it falls out. A style with no evidence to explain stays
near its prior, and style_share reports what fraction of the pool each style
actually claims as its best. A style claiming ≈0% is idle: the player’s
taste has fewer islands than , and the app dims it rather than inventing a
name for it.
So is capacity, and the data decides how much gets used.
The prior, and the correction forces
The factor is standard: with for a standardized vector, it makes the prior utility of a candidate roughly unit-variance, so likelihood scales stay sane at any feature count.
The factor is the correction the max form forces, and it is easy to miss.
Under the prior each is marginally , so is the maximum of iid standard normals, whose standard deviation
falls with (MAX_NORMAL_SD, in auracle-taste’s model.rs):
| 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| 1.000 | 0.826 | 0.748 | 0.701 | 0.669 |
The mean shift cancels in duels (both sides shift equally) and is absorbed by and the cutpoints elsewhere. The variance shrinkage does not cancel. Left uncorrected, drops from 2.0 at to 0.90 at , so growing mid-session would quietly make the model less able to express a strong preference.
That is the opposite of what adding capacity should do, and a player would meet it as a model that grows vaguer the longer they play.
Dividing by restores invariance: is the same at every .
What the interface reads off this
| Quantity | Is |
|---|---|
utility_mix(z) | of over posterior draws: the glow and size on the taste map |
utility(z, k) | Style ’s rating specifically |
best_style(z) | Which style claims this candidate: the hue on the map |
responsibilities(z) | Posterior probability that each style is the best one for this candidate |
slope(z, ê) | The slope of along a direction at , through the style that claims in each draw: PERFORM’s lean on a control |
style_share(pool) | Per-style share of the pool, averaged over candidates |
prob_prefers(a, b) | : the forecast the app shows for a pair |
responsibilities is a posterior distribution over which style applies, which
is strictly more informative than an argmax and is what lets a candidate sit
visibly between two islands.
One utility, three likelihoods
Every feedback mode conditions the same latent . They differ only in how an answer connects to it.
All three enter as a single factor carrying the total weighted
log-likelihood, so from fugue’s point of view the model has one observation
node regardless of how many kinds of feedback the log contains.
Pairwise duels: Bradley–Terry
Feedback::Duel { a, b, chose_a } => {
let d = s.utility_mix(a) - s.utility_mix(b);
log_sigmoid(if *chose_a { d } else { -d })
}
The primary signal: best statistical properties, lowest cognitive load. It identifies up to an additive constant, which is exactly the right amount of information: a preference relation does not have an origin, and pretending otherwise is what makes absolute ratings drift.
Note that here is the mixture utility, so a duel across two islands compares the drone’s best style’s rating with the pluck’s best style’s rating. That this is well-formed is the whole reason for the max form.
log_sigmoid is computed stably as with
. The naive
underflows for moderately confident predictions, which is
exactly where a fitted model spends its time.
Keep / kill: a thresholded Bernoulli
is a per-session latent, one per session in the log.
The app emits one side of it: a bank row’s cut calls
record_keep(id, false) once its 7 s undo window closes (UNDO_WINDOW_MS,
defined in toasts.js and used in main.js’s cutRow).
Nothing emits a keep; the triage surfaces that would are unbuilt.
“Feeling picky today” is therefore modeled rather than treated as noise. A session where the player cuts almost everything is read as a strict session (a high ) rather than as a change in their taste. Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days’ data would be degraded by the other’s.
One implementation subtlety: reweighting an old observation against a posterior fitted before that session existed finds no site, and contributes zero rather than guessing. No threshold site means no threshold evidence.
Star ratings: a cumulative logit
with 0-based cutpoints, (so the first term is 0) and (so the last is 1). Six categories by default, hence five cutpoints.
The code computes it in log space:
match (k == 0, k == n_cats - 1) {
(true, true) => 0.0,
(true, false) => log_sigmoid(a * (s.cuts[k] - u)),
(false, true) => log_sigmoid(-a * (s.cuts[k - 1] - u)),
(false, false) => {
log_sigmoid(a * (s.cuts[k] - u))
+ log_sigmoid(-a * (s.cuts[k - 1] - u))
+ log1mexp(a * (s.cuts[k] - s.cuts[k - 1]))
}
}
The factor is 1 unless some of the observation’s coordinates were imputed (below). A rating between the lowest and the highest rests on an identity: with above , , so
Three terms of one sign, none of them a difference of near-equal numbers, so
the log-probability is exact to a few rounding errors wherever sits.
log1mexp is the standard accurate
(Mächler 2012): ln_1p(-exp(-d)) above
and ln(-expm1(-d)) below it, so even a category squeezed nearly
shut scores its width. Far below the lower cutpoint the log-probability tends
to , and far above the upper
one to plus the same constant
(a_middle_rating_far_outside_its_cutpoints_scores_its_exact_tail in
auracle-taste pins both tails against a series, to , out to a
utility past the cutpoint).
Subtracting two near-equal sigmoids used to bottom out at a floor of . The first log-space form, , lifted the floor but not the tail: once passed about 37 the ratio rounded to 1 and the result was where the true value is finite, so a strong enough rating could move the weights less than a weaker one (#227; reweighting now).
This treats ★★★ as “between two cutpoints” rather than as the number 3, which is the point. A rating is an ordinal judgment, and modeling it as a real number asserts that the gap between 1 and 2 stars equals the gap between 4 and 5, which no rater believes.
Because the cutpoints are fitted, the model absorbs scale drift: a player who becomes harsher moves the cutpoints, not . Without that, a change in rating habit would be indistinguishable from a change in taste.
Enforcing the ordering
Cutpoints must be increasing. Rather than constrain the sampler, the model samples unconstrained normals and transforms:
The exponential increments are positive by construction, so ordering holds for every draw. No rejection, no constrained kernel, and the generic single-site MH applies unchanged.
The constants place the prior sensibly: near (so a 0-star rating means “well below average”), and increments with a median of so the five cutpoints span a few units of utility.
Imputed coordinates
An observation logged under an older feature set can lack some of the current coordinates, and persistence imputes them at the mean. A duel needs no correction: both sides carry the same absence, so the imputed terms cancel in . Keep/kill and stars compare one with a fixed threshold, so there the model scales the difference by
where is the variance the imputed coordinates add to under the draw: over the imputed , for the style that scores the candidate. It is the logistic analogue of marginalizing a Gaussian through a probit link. The observation still teaches, and stops claiming certainty about a comparison that is partly guessed. With nothing imputed, .
Edit-beats-original
Not a fourth likelihood, but a duel with a provenance tag. Keeping a
hand edit as new with pick the edit ticked records Duel { a: edited, b: original, chose_a: true }.
The tag is what makes the claim auditable. Provenance distinguishes:
Duel | A dealt pair the player listened to |
HeardEdit | An edit committed through a heard comparison |
SelfReport | An edit committed by ticking the box |
PerformOffer | A PERFORM offer heard against the sound being played, then taken (offer wins) or passed by asking for another (the played sound wins) |
These make the same claim in the log, and there is no reason to believe they are equally reliable. Calibration scores them separately, which is the only way to find out rather than assume.
Recency weighting
Every observation’s log-likelihood is scaled before summing:
so the newest observation has weight 1 and one back has weight .
The default is recency_half_life = Some(150.0) (in engine.rs); None
disables forgetting entirely.
Taste is allowed to change, and a model weighting a pick from three sessions ago equally with one from a minute ago would fight the player when it did. The cost is stated plainly: this is not a proper Bayesian posterior over a stationary parameter, it is a tempered/discounted likelihood, chosen because stationarity is the wrong assumption about a person.
Implicit signals are logged, not modeled
Play counts, opening a pair’s sound in PATCH, and the editor’s stream of edits
and reverts are logged as ImplicitEvents (Engine::log_event). None of
them enters the likelihood. Listen time and hover duration are not recorded
at all.
They are cheap to collect and easy to misread: a long listen can mean fascination or confusion, and the two have opposite signs; a revert can be curiosity. This version prefers less data that means what it says, and keeps the log so a later fit can be validated against the explicit picks.
Site count, and what it costs
The model has
sample sites. With and 6 star categories, that is : **49
- ** at and 225 + at . (A fused prior over correlated coordinates, built and off by default, would add sites per fused group.)
Single-site MH moves one site per step, over a fixed budget of steps, and each
step’s likelihood scores every observation through every lens. Two
consequences, both measured by auracle-taste/examples/fit_bench.rs:
- The fit is many times slower at the cap than at the first fit.
- The step budget is fixed, so a mature fit gets proportionally fewer sweeps per site than an early one. Growing makes the fit both slower and statistically thinner.
That is a real tension in the design. It used to be sharper: fugue’s chain
driver re-executed the whole program on every step, which first made the
address table worth hoisting out of the
step loop (building addresses inline cost a format!, a re-allocation, and a
SipHash per site per step, which measured as the bulk of a mature fit’s
wall time), and then made a kernel that never
rebuilds the program worth writing.
The posterior
MCMC when it can afford to, importance sampling when it cannot, and an honest signal for when the cheap path has run out.
The full fit
TasteModel::fit runs fugue’s adaptive single-site Metropolis–Hastings,
with these defaults (SessionConfig in engine.rs, and KEEP in
auracle-taste’s model.rs):
| Default | |
|---|---|
| Post-warmup steps | 10,000 (mcmc_samples) |
| Warmup steps | 3,000 (mcmc_warmup) |
| Retained draws | ≤ 500 (KEEP), by thinning |
Every site is an f64 (there are
no discrete latents), so
fugue’s generic chain applies, and no site is conditional on another’s value,
so the program’s structure never changes. The fit runs that chain
over an array of the sites’ values rather than through the
program. Adaptation tunes per-site proposal scales during warmup.
Each MH step moves one site, so a useful way to budget is . At , sites over 10,000 steps is at most about 44 sweeps per site. That is thin, and it is the tension noted in site count.
The result is uniformly weighted:
TastePosterior { cfg, samples, weights: vec![1.0 / n; n] }
The kernel
fugue’s chain driver runs the whole program on every MH step: it rebuilds
sample nodes and their closures, and fills a fresh trace of as many
BTreeMap entries, to move one number. That rebuild, not the likelihood, was
most of a refit’s time. Since the structure never changes, TasteModel::fit
runs a kernel of its own (model/kernel.rs). The state is an array of the
sites’ values in the program’s execution order, and a step proposes one value,
decodes the array into a draw, and scores it with the program’s own pieces:
the same priors, the same cutpoint transform and the same likelihood sum.
It is fugue’s kernel, not a new one. The start is a prior run of the program
itself. The site is drawn over the trace’s address order as fugue draws it, its
scale comes from fugue’s own DiminishingAdaptation, and the Gaussian step and
the accept test are fugue’s, reading the random stream in the same order. The
log-weight is summed in the trace’s order. So a seed deals the same draws, bit
for bit: the kernel’s tests run it against fugue’s driver over seeds and
configurations (fused groups, several sessions, stars, imputed coordinates, a
recency half-life, a stride longer than the chain), and fit_bench’s checksums
did not move.
µs per step, fit_bench 10000 3000 | first fit (, 6 rows, 50 sites) | mature fit (, 100 rows, 226 sites) |
|---|---|---|
| fugue’s driver | 22.7 | 207.1 |
| the kernel | 1.9 | 47.8 |
What was left of a mature step then was the likelihood: the same 226 sites over 6 rows took 6.1 µs a step.
The engine the app runs shows the same: WasmEngine::fit in Node over the
release build, on a 4-core Linux machine, took 479 ms before and 33 ms after
for a first fit (6 duels), and 3.9 s before and 0.95 s after for a mature one
(100 duels, ), each the least of five, the fit’s own alignment and style
shares included. Both builds ended on the same ranking, byte for byte.
A step recomputes only what its site reaches. Rescoring a proposal with the whole likelihood redid every row though one site moved: at over 100 duels, a thousand dot products of 44 to move one θ coordinate. So the chain keeps, for its current state, every candidate’s utility under every lens, every row’s weighted term and every site’s prior term, and a proposal recomputes only what its site moves:
| The site | What is recomputed |
|---|---|
| θk,i | its prior term; lens ’s utility of every candidate, then every row’s term from the kept utilities |
| μ (a fused group’s mean) | its prior term and those of the θ drawn about it; the likelihood is unchanged |
| τs | its prior term; the keep/kill rows of session |
| a cut | its prior term; every star row (the cuts decode cumulatively, so one raw moves every later cut) |
It stays bit-identical. A row’s term is one function whether its utilities
were just computed or kept from an earlier step, so the two cannot drift, and
the totals are the kept terms summed the way the whole computations sum them:
the rows in order by the same sum, the prior from 0 in execution order. (A
running total patched by each step’s difference would not be the same number.)
The kernel’s tests check the kept totals against the whole ones bit for bit,
after every move of every kind of site and every step of a chain, and
fit_bench’s checksums did not move.
µs per step, fit_bench 10000 3000, another 4-core Linux machine | first fit | mature fit | 226 sites over 6 rows |
|---|---|---|---|
| every row and site rescored | 0.9 | 22.8 | 3.0 |
| only what the site reaches | 0.6 | 7.1 | 0.7 |
WasmEngine::fit in Node on that machine, the least of each build’s ten or
more runs: a first fit
from 17.8 ms to 11.8 ms, a mature one from 436 ms to 202 ms, and both builds
ended on the same ranking, byte for byte.
The address table
SiteAddrs::new builds every site address once per fit, and the program
clones Address (an Arc refcount bump plus a cached hash) into each node.
The kernel keys its proposal scales by the same addresses.
When fugue’s driver rebuilt the program on every step, building addresses
inline (addr!(format!("theta{k}"), i)) cost a format! into a String, a
re-allocation into Arc<str>, and a SipHash of that string, per site per
step: roughly 3.7 M allocations per mature fit, and measurably the bulk of
the fit’s wall time then (examples/fit_bench.rs).
The addresses are a pure function of , none of
which move during a fit. And they are produced by the same addr!
invocations as before, so traces, serialized posteriors, and warm-start paths
see byte-identical addresses.
Thinning happens at the driver, not after it
95% of the post-warmup chain is discarded (19 draws in every 20), and it is discarded as it is produced.
That used to happen one line after the whole chain was built. adaptive_mcmc_chain
materialized every step, pushing (TasteSample, Trace) per iteration into a
Vec it returned by value, and only then did step_by(stride) keep every 20th.
At that is ~10,000 Trace clones of 225 + S BTreeMap entries each,
held live at once to retain 500: 303.1 MB peak RSS at the shipped budget,
scaling with n_samples, and a plausible mobile-Safari OOM on a 32-bit heap
rather than mere waste.
It could not be fixed here then. The retention was inside fugue’s chain driver,
and the pieces needed to reimplement that driver with identical RNG consumption
(single_site_mh_step, propose_and_score, SingleSiteProposalHandler) are
private or pub(crate); forking fugue’s inference core into this crate would
have traded a memory spike for a correctness hazard on every upgrade. (The
kernel has since done that reimplementation, for time rather
than memory, and its tests against fugue’s driver are what guard that hazard:
a fugue upgrade that changes the chain fails them. It keeps every
stride-th draw as it runs, as the driver does.)
So it was fixed upstream instead, as
fugue-ppl 0.2.2:
adaptive_mcmc_chain_thinned takes a stride and pushes only when
i % thin == 0.
| peak RSS | mature-fit checksum | |
|---|---|---|
| before | 303.1 MB | 07d204764b58c88b |
| after | 18.2 MB | 07d204764b58c88b |
16.7× less peak memory for bit-identical draws. The unchanged checksum is
the point of that table rather than a footnote to it. thin gates the push and
nothing else: every transition still runs, so the RNG is consumed in the same
order and quantity, and is exactly
what step_by kept. fit_bench’s per-fit checksum is the Auracle-side witness;
fugue’s thinning_retains_exactly_the_draws_step_by_would is the upstream one.
What stays resident is the 500 draws the posterior actually keeps, so the peak
no longer scales with mcmc_samples at all. The budget is now free to be
chosen on the recovery tables rather than against a memory ceiling.
Between fits: sequential importance sampling
A full fit costs under half a second on a fast laptop, about two on an older one, and cannot run after every pick. So each new observation is folded into the existing draws by reweighting:
let m = (0..n)
.filter(|&i| self.weight(i) > 0.0)
.map(|i| ll[i])
.fold(f64::NEG_INFINITY, f64::max);
let mut w: Vec<f64> = (0..n)
.map(|i| match self.weight(i) {
wi if wi > 0.0 => wi * (ll[i] - m).exp(),
_ => 0.0,
})
.collect();
This is exact (the weighted draws target the updated posterior), and its cost is linear in the number of draws. It is what makes each duel respond to the one before it; without it the acquisition rule reads a frozen posterior and re-asks the same question until the next full fit.
The likelihoods are shifted before they are exponentiated, by the best log-likelihood among the draws that still carry weight. A draw whose weight has already fallen to zero takes no part, in the shift or in the products.
However strong the contradiction
A vote can rule out every draw that still carries weight. Likelihoods of and are far under the smallest double (about ), and taken as they are, every product would round to zero. Shifted by the best weighted draw, that draw's product is its own weight, so the sum cannot underflow while that draw's log-likelihood is finite. The weight moves to the draw that contradicts the vote least, by the exact update. If it concentrates the weights far enough, ESS falls, the draws are resampled, and the refit trigger is armed.
The shift used to be taken over every draw, including those with no weight
left. When one of those scored best, its log-likelihood set the shift, and a
vote that every weighted draw ruled out firmly enough pushed every product
under the smallest double. The weighted draws still ranked the vote, but the
update was skipped: a slightly weaker version of the same vote could collapse
the weights and arm the trigger, while the stronger one left them where they
were (the_update_is_exact_however_strong_the_contradiction in
auracle-taste).
Every vote on a finite has a finite log-likelihood under every
draw: a duel, a keep or a cut, and a star rating wherever its utility sits
against the cutpoints
(computed in log space, by a
form exact in both tails). So a stronger contradiction never moves the
weights less than a weaker one
(every_vote_on_a_finite_phi_has_a_finite_likelihood in auracle-taste).
A star rating between the lowest and the highest used to be the exception.
Far below its lower cutpoint its log-likelihood lost precision, and about 37
under it underflowed to where the true value is finite: a draw it
underflowed for lost all its weight, and once every weighted draw's did, the
weights were kept. On three test draws weighted [0.7, 0.3, 0], a rating of 1
collapsed the weights to [0, 1, 0] at one strength and left them at
[0.7, 0.3, 0] at a stronger one. It now moves them by the exact update at
every strength, to about [0.0001, 0.9999, 0] at that stronger one (#227;
a_star_rating_far_below_its_cutpoint_updates_exactly_at_every_strength).
A vote with no likelihood keeps the weights
The denominator is now not a number only when the weighted draws give the
update nothing to go by: when one of their log-likelihoods is NaN, or all of
them are (the shift is then too, and
is NaN). Only a vote on a that is not
finite does that, since every vote on a finite one has a finite
log-likelihood (above). One that holds a
NaN (which vetting exists to prevent) makes every
log-likelihood NaN. Then the update keeps the previous weights. That is
a choice: a NaN leaves no update to make. What the votes since the last fit
taught the draws stays, and the observation waits in the log for the next
fit. ESS is unchanged, so such a vote never calls for a resample by
itself. The update used to reset the weights to uniform here,
which threw that evidence away and claimed a full ESS besides
(a_vote_with_no_finite_likelihood_keeps_the_previous_weights in
auracle-taste).
What the app is sent after each pick
The reweighted draws are the posterior the engine holds until the next fit,
so the app is told what they say after every pick, not only after a refit.
Each reply to a pick (a duel, a cut, stars, an answered offer in PERFORM)
carries the pool's ratings as they stand, a ratings field filled from
WasmEngine::belief():
{"ranked":[{"id":12,"mean":0.84,"std":0.31,"style":1}, …],
"seeds":[12,7,31, …],
"may_replace":[40,3, …]}
ranked: every pool member's posterior mean and std of the mixture utility, best first, and the lens most responsible for it. They are the ranked list's numbers and the map's glow, size and color for each pool dot, computed under the weights the pick left, exactly as a refit's views would compute them.seeds: the parents the next generation would take, andmay_replace: the members its end could retire (refinement).
One pass over the draws per member serves the mean, the std and the lens
(utility_mix_and_responsibilities). Measured in wasm under node on an
Apple M3 Max (examples/pick_belief.mjs in auracle-wasm, beside its
native twin pick_belief.rs), that is under a millisecond per pick for a
session of 30 picks (two lenses) and under 2 ms at five lenses. While a
generation is open with the pool over size, what its end would retire is
ranked as well, under the posterior it opened with: 1.8 ms at two lenses and
3.9 ms at five. The rest of the map, its projection and the ghosts of earlier
picks, costs 28 ms by 100 picks and grows with the history, so it still waits
for a refit.
Effective sample size
Equals the draw count for uniform weights, and collapses toward 1 as weights concentrate.
Importance weights degenerate, and ESS says so rather than letting the posterior quietly become one point wearing 500 hats. It is the engine’s signal that a real refit is due.
Systematic resampling
When weights have concentrated far enough (ESS below half the draws),
resampled() draws the weighted set back to a uniformly weighted one of the
same size.
The trade: resampling produces duplicate draws, so the sample is impoverished but still spans the posterior’s support, and ESS on the fresh uniform weights no longer claims more information than is there. Left unresampled, almost all the mass sits on one draw, and a “posterior” of one point tells the acquisition function it is certain when it is merely exhausted.
It is a stopgap between full refits, not a substitute for one.
Deterministic (systematic, offset ) rather than multinomial, because every other stochastic step in the engine is seeded and reproducible and this one has no reason not to be.
The refit trigger
pub fn needs_refit(&self) -> bool {
match &self.posterior {
Some(_) => self.resamples_since_fit > 0,
None => !self.log.is_empty(),
}
}
The engine’s condition is not a count of picks. It is “the weights have had to be resampled at least once since the last real fit”, that is, the cheap path has provably run out of road. With no posterior yet, it is true as soon as the log holds anything. The engine reports it in its status. A vote that rules out every weighted draw arms it like any other pick that concentrates the weights, however strong it is, unless it is a vote with no likelihood, one on a that holds a NaN. Such a vote leaves the weights as they were and does not arm it, but the picks after it reweight from those weights as usual.
The app does not wait for it. Every sixth pick refits (FIT_EVERY, 6, in
apps/web/main.js), and PERFORM’s answered offers count as picks. Two other
moments refit at once: the end of the warm start, and opening a taste profile
that holds picks. The app used
to require needs_refit as well, to save the time of a fit whose posterior
had not gone stale. Which picks those were depended on how surprising they had
been, so a run of agreeable picks ended with the teaching meter’s countdown and
no refit: the meter promised something it then did not do. A fit costs under
half a second on a fast laptop, about two on an older one, off the audio
thread, at most
once every six picks outside those two moments, and the pair stays audible
through it.
Label alignment
Mixture posteriors are permutation-symmetric in the style labels (label switching), so per-style summaries are meaningless on a raw posterior.
aligned() resolves it post hoc, in two passes:
- Relabel every sample to best match a reference (the last sample), maximizing total cosine similarity across styles.
- Recompute the mean of the pass-1 result and relabel against that.
Alignment is exhaustive over permutations, which is fine because and . No-op at .
Call it before theta_mean, theta_std, style_share, or anything else
per-style. Aggregate quantities (utility_mix, prob_prefers, and slope,
which names each draw's style by its rating) are permutation-invariant and do
not need it.
What the summaries are
All weighted by the importance weights:
theta_mean(k) | |
theta_std(k) | Per-dimension posterior SD: the whiskers on LEARNING’s weights |
utility_mix(z) | of : glow and size on the map |
responsibilities(z) | |
style_share(Z) | responsibilities averaged over candidates |
slope(z, ê) | of , each draw’s best style of : PERFORM’s lean |
prob_prefers(a,b) |
prob_prefers marginalizes and the weights and the
per-candidate style choice, which is why it is the right forecast to show for a
pair: it is a predictive probability, not a point estimate’s opinion.
Serialization
TastePosterior serializes to JSON, and weights carries #[serde(default)]
so older persisted posteriors, written before reweighting existed, deserialize
to empty and are read as uniform.
The observation log remains the source of truth. A posterior snapshot is a cache: it can always be recomputed from the log plus its standardizer, and that is exactly what a profile stores.
Calibration
Every duel is forecast before it is answered. This page is how those forecasts are scored, and why the obvious metric would have lied.
Prequential by construction
record_duel scores the posterior’s and only then
appends the observation. So every forecast is an out-of-sample, one-step-ahead
prediction: the model has never seen the answer it is being scored on.
Why not accuracy
A running count of outcomes is accuracy, and accuracy is not a proper scoring rule. Two failures, and the second is fatal here:
It cannot see sharpness. A model that says 0.51 every time and is right 51% of the time scores identically to one that says 0.99 and is right 51% of the time. The second is wildly overconfident, and accuracy cannot say so.
It is pinned near 50% by the acquisition rule. An information-seeking rule deliberately picks pairs near , because those are the questions worth asking. So the hit rate sits near chance by construction: a perfectly calibrated model looks like a coin flip, and the player concludes it is not learning.
The second point is what makes accuracy harmful rather than merely crude: it penalizes the search for doing its job.
hit_rate is still computed, only so a frontend could show how misleading
it is next to the skill score. The app does not show it.
Brier score and skill
where is the probability the model gave to the option the player actually picked. Lower is better; is what always saying 0.5 scores.
Reported as skill against that baseline:
| skill | Means |
|---|---|
| No better than a coin flip | |
| Perfect and certain | |
| Worse than a coin |
Brier is proper and bounded, and it moves as sharpness improves rather than only as accuracy does, which is the property accuracy lacked.
Log-loss, and what it may not be compared across
Baseline .
Comparable across time for one acquisition rule. Not comparable across acquisition rules: an information-seeking rule serves duels near , which carry the highest log-loss by construction. Comparing two rules on their own self-chosen question sets would score the willingness to ask hard questions as a failure.
check_log_loss is the version for that comparison. See below.
The selection-bias fix
The acquisition function chooses which duels get scored, which means overall skill is measured on a question set the model helped select. That is circular.
So one shown pair in ten (duel_check_every, 10, in engine.rs) is drawn
uniformly at random and flagged Forecast::random_check, and calibration
restricted to those is unbiased. Under a rule that chooses its pairs (BALD or
Thompson), the app marks such a pair ◇ unbiased probe. Under the default rule,
its deal line says every pair is dealt at random (◇ random pair).
| Field | Is |
|---|---|
check_n | Number of random-probe forecasts |
check_skill | Brier skill on them: the number without an asterisk |
check_log_loss | Log-loss on them: the only log-loss comparable across rules |
It costs a small share of the query budget and it is the only number here that means what it says unqualified.
The share is of the pairs the player is shown, not of the pairs dealt: the app deals the next pair ahead and sometimes throws a deal away unseen, and a probe thrown away does not use up its turn.
The shipped default acquisition is uniform random pairing, so every duel is already
an unbiased sample and check_skill equals overall skill. The probe machinery exists
for the BALD rule, where the distinction is real, and it is one of the reasons
uniform pairing was chosen. See Acquisition.
The reliability diagram
Five buckets over (N_BINS = 5, in calib.rs: the most a
small session can fill without every bucket being noise). Each bucket reports:
predicted | Mean forecast in the bucket: the model’s claim |
observed | Observed frequency of “A won”: the evidence |
n | How many forecasts landed here |
Plotted, the diagonal is the claim and the dots are the reality: a single number cannot distinguish “overconfident at the top end” from “underconfident in the middle”, and the shape of the failure is what says what to do about it.
The app does not plot it. LEARNING draws every forecast instead
(WasmEngine::forecasts, the list these scores are taken over), each as the
probability it gave the sound picked, with the hits out of all of them and
the mean confidence of its guesses (expected, the mean of
) beside the share it got right (was). Expected above was
is overconfidence, the diagram’s top-end failure, in two numbers. The buckets
stay in calibration() and in LEARNING’s copy as JSON.
By provenance
The same scores, split by how the answer was collected:
pub struct ProvenanceScore {
pub provenance: String, // "duel" | "heard_edit" | "self_report" | "perform_offer"
pub n: usize,
pub brier: f64,
pub log_loss: f64,
pub skill: f64,
}
The comparison this exists for: a hand edit kept as new through a heard duel and one kept by ticking pick the edit make the same claim in the log, and there is no reason to believe they are equally reliable. Scoring them against forecasts the model made before either answer arrived is the only way to find out which, and it costs one tag.
Empty streams are omitted, so a session whose every answer was a dealt pair carries exactly one row.
Interpreting it
| Shape | Reading |
|---|---|
| Skill ≈ 0, small | Too early. Correct and expected |
| Skill < 0 with real | Worse than chance: either overfitting a coincidental coordinate, or genuinely inconsistent answers |
| Dots below the diagonal on the right | Overconfident: when it says 80% it is right less often |
| Dots above on the left | Underconfident |
| Skill stuck near 0 with large | The preference is probably not in the feature space |
The user-facing version of this table is in Reading what it learned.
Why the number can look bad
Committing to a forecast before each answer and then reporting the error against a proper scoring rule means the model can publicly fail, and early on it does.
That is what makes the number worth reading later, and it is why the app shows “not beating a coin flip yet” rather than hiding the metric until it flatters.
The Boltzmann target
One distribution, two factors: the grammar supplies parsimony, the learned taste supplies direction, and is the single dial between them.
The target
This is fugue-evo’s EvolutionModel with the learned utility plugged in as
fitness, so the whole thing becomes an ordinary probabilistic program and
fugue-evo’s typed MH applies unchanged.
What each factor does
is the parsimony pressure. It is the prior probability of the term under the typed PCFG, not a penalty term. Deeper terms pay more prior mass by construction, because each extra level multiplies in another Bernoulli that came out “processor” plus that node’s own parameter draws.
Ad-hoc size penalties in genetic programming need tuning, interact badly with fitness scaling, and leave the target distribution unwritten. Here the target is written down, and the parsimony term is a probability rather than a hyperparameter.
is the direction. The expectation is over the posterior, so the search climbs the model’s mean belief and is not seduced by a single confident-looking draw.
SessionConfig::beta, default 2.0 (in engine.rs).
| Behavior | |
|---|---|
| Browse the prior. The taste model is ignored | |
| Shipped default | |
| large | Optimizer mode: the search climbs toward the model’s single best guess |
One dial for conservatism, which is the practical payoff of writing the target down: there is no explore/exploit schedule to tune, no diversity term, no niching parameter. Tempered SMC, which is not built, would temper this same target.
Fitness through the surrogate
SurrogateFitness is the bridge:
impl Fitness for SurrogateFitness {
fn evaluate(&self, genome: &PatchTree) -> f64 {
match featurize_memo(genome, &self.phrase, &self.memo, false) {
Ok((cf, _)) => {
let phi = self.standardizer.transform(&cf.features.phi());
self.posterior.utility_mix(&phi).0
}
Err(_) => QUARANTINE_FITNESS,
}
}
}
Three things in nine lines:
Quarantine is a fitness, not just a filter. QUARANTINE_FITNESS = -50.0
(in surrogate.rs), so a pathological candidate contributes a large negative factor to the target
and the search learns to avoid the region rather than repeatedly sampling
it. That is
safety layer 2; hiding alone would
leave the search wasting budget somewhere it cannot see is bad.
The standardizer must be the one the observations were made under. is meaningless against any other scaling; see Standardization.
want_audio: false. The surrogate only ever wants ; nothing in a
refinement generation is played. Asking for samples would undo the memo: a miss
would convert about 223,000 f64s (the 5.05 s phrase at 44,100 Hz) that it then
drops, and a hit would copy that buffer, about 0.9 MB as f32, out of the audio
tier. Once per MH step, 40 times per seed at 40 steps, that is tens of
megabytes of churn for a value discarded on the next line.
Why the render memo matters
It is what makes the walk affordable at all.
A walk step executes the model once, for the proposal; the current state is carried scored from the step before and is not run again. A walk still revisits trees (the seed, a state it returns to, the drift walks), and a render is the one expensive thing the model does, so a revisit that is recomputed is a number the walk already had.
A render of the audition phrase costs about a ninth of a second of CPU in
wasm on a 4-core Linux machine (quiver-dsp 0.5.1; 2.0 s for the 18 trees of
make bench-render, 3.1 s on 0.4.1 the same day) and several times that on
the reference MacBook Air, so every revisit the memo answers is a whole render
the generation does not wait for.
What is not sampled from this
What ships is not a sample from . Refinement runs a short adaptive single-site MH walk warm-started from each of the best pool members and keeps the final state: local hill-climbing on that target, which is what a candidate pool needs, rather than a draw from it.
The design’s tempered SMC is not built, and there is no crossover: every child
grows from one seed (walk.rs). Refinement draws the
distinction.
Proposals, and the taste tilt
The loop closes here: what the model learns reshapes the grammar the search walks. That changes what the search proposes and, because the tilted grammar is installed as the prior, what it scores against too.
Moves
Refinement uses fugue’s adaptive single-site MH over the trace, so the move set is whatever the trace machinery provides:
- Parameter moves perturb one continuous or discrete site.
- Structural moves regenerate a subtree, which changes the set of sites and is therefore a reversible-jump move. fugue handles the Jacobian bookkeeping; Auracle does not implement it.
The structural moves walk the same lattice as hand edits, by another route: they regenerate from the trace, and never call the edit operations.
The tilt
Once a posterior exists, the grammar’s categorical weights are reshaped by what it has learned:
then renormalized. SessionConfig::proposal_tilt is , default 0.6
(in engine.rs).
pub fn tilt_weights(base: &[f64], tilts: &[f64], eta: f64) -> Vec<f64> {
let mut out: Vec<f64> = base.iter().zip(tilts)
.map(|(w, t)| w * (eta * t).exp().clamp(0.25, 4.0))
.collect();
let sum: f64 = out.iter().sum();
if sum > 0.0 { for w in &mut out { *w /= sum; } }
out
}
The function is pure, which is why the taste→grammar mapping is testable without an MCMC fit. That matters for a mapping this easy to get subtly wrong.
The clamp
bounds every multiplier, so no module kind is ever starved or monopolized.
Without it a confidently-fitted coefficient could drive a kind’s proposal weight to effectively zero, and the search would stop being able to discover that it was wrong about that kind. A prior that has been argued out of considering an option cannot be argued back in by evidence it can no longer generate.
Where comes from
biased_prior builds the tilt vector from the posterior, in three steps.
1. Blend the styles by their pool share.
Share-weighted rather than uniform, so an idle style (one claiming ≈0% of the pool) contributes ≈nothing to how the search proposes. Uniform weighting would let a style with no evidence steer the search as hard as one with plenty.
2. Shrink each coefficient by its own uncertainty.
| Regime | Factor |
|---|---|
| \theta | |
| \theta | |
| \theta |
Same shape as a signal-to-noise weighting, and chosen over a hard significance cut for a specifically musical reason: a cut makes the proposal distribution jump discontinuously as evidence accumulates, and players hear that as the instrument changing its mind. A smooth ramp is a model getting more opinionated; a threshold crossing is a different instrument arriving mid-session.
3. Map coordinates to categorical slots. The source-kind tilts read
n_vco, n_supersaw, n_noise, n_wavetable, n_pluck, and n_formant
directly; processor and modulation tilts read their family coordinates.
Silence takes no tilt: how often a hole appears should come from a player
unplugging a socket, not from a fitted coefficient. Nor does AudioIn, for
the same reason: whether a patch listens is a player’s choice. The mixer’s
tilt stays the mean of the original seven, so adding the input moved no
session’s mixer weight.
The n_mix reconstruction
n_mix is not a column of :
it was dropped to break an exact linear dependency.
But the search still needs some tilt for the mix production, and
biased_prior recovers it from the source coefficients. That is legitimate
precisely because of the identity that forced the drop: n_mix is determined
by the other counts, so information about it is present in what remains. The
dependency that made the column unusable as a regressor is what makes it
recoverable as a tilt.
Why tilt rather than only score
A scored-only search is limited by what it happens to generate. If the prior draws bitcrush into 2.5% of terms, then no matter how much the model likes bitcrush, only 2.5% of proposals will contain one and the search has to wait for luck.
Tilting the grammar means the search looks where the model expects to find things. Combined with the clamp, it is a change of emphasis rather than a change of support: every kind stays reachable, and the ones the model believes in get proposed more often.
This page used to say that tilting changes the kernel, not the target, and that the
stationary distribution is unchanged. That was false, and the September 2026 audit
(AU-G3) caught it. biased_prior builds the tilted grammar and installs it as the
prior of the EvolutionModel; fugue-evo's target is prior.model() + factor(β·f),
and fugue's categorical proposal is a resample from that same prior, so the Hastings terms
cancel and the chain is a correct MH sampler for
which is a different target from . The seed is scored under the same tilted
prior, RefineKeep::Best ranks under it, and the parsimony mass the walk climbs is the
tilted one. Nothing about that is unsound (MH is exact for ), but what the search
climbs includes the tilt.
A true proposal tilt, one that leaves alone, would need a custom site proposal
carrying its own Hastings correction; fugue offers only PriorResample for usize
sites, so it is not available without an upstream hook. Because refinement
hill-climbs rather than samples, the practical effect is the one
intended: the climb finds the kinds the player picks sooner. The field keeps its name
(SessionConfig::proposal_tilt), since the app and the harness both set it. What
changed is the claim, not the code.
Structural taste, specifically
Note that the tilt reads the structural coefficients. That is a deliberate
asymmetry: coordinates map onto grammar productions
more or less directly (n_filter ↔ the filter production), whereas an audio
coefficient like centroid_mean has no single production to point at.
Brightness is a property of the composition, not of a module.
So the audio half of influences the search only through the fitness
factor, and the structural half through both the fitness factor and the tilted
prior. Turning
centroid_mean into a proposal tilt would require a model of which productions
raise brightness, which is a model nobody has fitted.
Locks as conditional refinement
Locking is exact rather than heuristic: Metropolis-within-Gibbs on the conditional posterior. The argument depends on one detail that is easy to omit.
The claim
Let be a set of trace addresses. Refinement with locked samples from
That is the target distribution conditioned on the locked sites holding their current values. Not “mostly avoids changing them”; conditioned on them.
That is exactly Metropolis-within-Gibbs: a valid MCMC scheme in which a subset of coordinates is held fixed and the remainder is updated by MH steps that respect the constraint.
The implementation
pub fn violates_locks(prev: &Trace, next: &Trace, locked: &HashSet<String>) -> bool
A proposal is rejected if it changes, deletes, or creates any address in . Rejection happens outside the kernel: the move is simply not taken.
Every walk adds one set of locks of its own: the #input of each
AUDIO IN in its seed. The
input a node reads is the player’s, so a walk conditions on it as on a
player’s lock, and the node stays where it was with the input it had. A node
the walk grows reads the first input (the grammar never chooses one).
It holds the #op of each
TRACK and CAPTURE
the same way. The prior never draws either, so a step that regrew one away
could never grow it back. Holding a node’s #op holds the node and every node
above it, since regrowing any subtree that contains it would redraw that site;
its knobs and everything beside it stay free. A capture’s take is not a site at
all, so no walk can propose a new one, and the walk carries it onto every term
it scores, and onto the term it returns.
Why all three, and why both directions
The third, creates, is the one that gets omitted, and omitting it breaks the proof.
Scanning only prev catches changes and deletions. But a birth at a locked
address would be allowed through, while the death that would undo it is
rejected. The constraint region is then asymmetric:
which violates detailed balance. Concretely, the chain drifts into locked structure it can never leave, so a player who locked a module would watch the search grow new sites inside it and then be unable to remove them.
Checking both traces makes the constraint region symmetric, and symmetry is what the Metropolis-within-Gibbs argument needs.
The honest limit
A lock is a set of exact address strings, typically snapshotted from the UI. Every address in it is frozen, in both directions, and that is exact.
It is not the same as freezing a module. A structural move can grow a brand-new address inside a locked module: one that was in neither trace when the set was taken, so it cannot be in the set. That case is not caught.
It costs nothing in correctness: the case is symmetric by construction (unmatched in both directions), so detailed balance holds. It just means “locked” is a guarantee about addresses, not about subtrees.
In practice the UI’s lock module (▢) control snapshots every address currently inside the module, which covers everything that exists at lock time. A subsequent structural move that adds a genuinely new site inside it is the uncovered case.
The granularity available
| Control | Locks |
|---|---|
| A knob’s lock dot | One parameter address |
| A module’s ▢ | Every address currently in that module |
| lock knobs | Every parameter address in the patch |
| lock wiring | Every structural address |
| clear locks | Nothing |
refine_from(seed_id, locked) takes the set explicitly, so a frontend can
construct any subset.
What this enables
The workflow the rack exists for:
Find a patch whose character is right but whose envelope is wrong. Lock every knob except the envelope. Evolve. The variations differ only where the locks allow.
Because the guarantee is exact rather than best-effort, that is a statement about what the search will do rather than what it will probably do. A heuristic version (penalize changes to locked sites, or revert them afterward) would be a search that mostly respects the player’s intent, and “mostly” is not a useful promise about the one thing the player explicitly protected.
Everything locked
If covers every address, the search has nothing to do and every proposal is rejected. Each walk ends where it started, and the app reports it the way it reports any generation whose walks found nothing, as every walk came back unchanged, which is honest but not very informative. It is listed in Troubleshooting as a thing to check.
Relation to the design’s exactness claim
The decisions log states this as:
Locks / partial evolution: freeze any set of trace addresses; MH proposals touching them are rejected outside the kernel, in both directions. Exactly Metropolis-within-Gibbs on the conditional posterior, so locking is exact rather than heuristic.
This page is that claim spelled out: why both directions are needed, and where the address-level guarantee stops.
Refinement: what ships
The design is tempered sequential Monte Carlo. What ships is a short local Metropolis–Hastings walk. This page is about the difference, because it is easy to overstate.
The design describes generation as sampling from
by tempered SMC.
That is an intention, not a description of the code. There is no crossover: every
child grows from one seed (walk.rs).
What runs is a 40-step adaptive single-site MH walk (refine_steps; a walk with
locked sites runs proportionally more steps, up to LOCK_SCALE_CAP (4×), in
walk.rs), warm-started from each of the best pool members and keeping the final
state: local hill-climbing on that target, rather than a draw from it.
What runs
A generation is jobs, then absorption. A walk reads the engine and changes nothing; only admitting its child changes the pool. So the engine hands the walks out as data and folds their results back in, in a fixed order:
pub fn refine<R: Rng>(&mut self, rng: &mut R) {
let Some((ctx, jobs)) = self.refine_jobs(rng) else { return };
for job in &jobs {
let result = run_walk(&ctx, job, &self.memo);
self.refine_absorb(result);
}
self.refine_finish();
}
refine_jobs advances the generation counter and makes one job for each of
the top refine_seeds candidates by posterior utility, best first. Every walk
shares one context: the grammar prior tilted by the posterior (computed
once, as the pool stands when the generation opens), the posterior, the
standardizer, the phrase, β, and the keep rule. Each job carries its seed tree,
its step budget, and its own RNG seed, derived from a single draw of the
caller’s refine stream. It returns nothing (and does not advance the
counter) when there is no posterior or no standardizer, because there is no
direction to climb in.
run_walk is the walk as a pure function of a context and a job: it walks
refine_steps MH steps from the seed and returns one state of that walk, or
the reason there is none. Because each walk owns its randomness and the
render memo only ever saves work, a walk returns the same child in the engine
or on a render-farm worker, early or late.
refine_absorb takes the results in job order, whatever order they
finished in, and does what the serial loop always did after its walk: it
drops a child the pool already holds, admits the rest if they earn a place,
and records the lineage. A result offered out of turn changes nothing.
refine_finish closes the generation, and it is where patches leave (see
who leaves, and when). Stopping a generation early is
the same call: it ends the generation with the children already absorbed,
which are then ranked with everyone else when the pool is trimmed.
Which state of the walk gets injected
A walk renders and featurizes ~40 candidates and injects one, and which
one is a free choice that had never been measured. SessionConfig::refine_keep
makes it selectable so the comparison stays runnable, the same rule the
acquisition enum follows:
RefineKeep::Last | the state the walk ended on (the default) |
RefineKeep::Best | the highest- state it occupied, seed included |
The archive is free. Every trace the kernel returns already carries its own
, so Best is one f64 compare per step and no extra render.
It is scored on the target, not on fitness alone, and that is the load-bearing choice: taking the argmax of would discard the parsimony half of the very distribution the walk is sampling, and would do it with a bias: a bigger term has more modules to score well with, so fitness-argmax systematically returns the largest tree the walk touched.
Under Best the seed is in the archive, so a walk that finds nothing better than
where it started injects nothing, rather than whatever it happened to be
standing on at step 40.
Best ships switched off. Argmax over a surrogate is the classic way to find
that surrogate's errors rather than the player's taste, and the always-on
gate has already caught this happening: over 16 seeds, two produced pools
−12.0 and −5.5 worse in the synthetic user's true utility after three
generations, because insert_candidate admits and evicts by the model. Turning
Best on without measuring it is the move most likely to make that worse.
refine_from(seed_id, locked) is the same thing from an explicit seed with
an explicit lock set, the ⚡ evolve from this path: a single job
over the same walk and the same absorption, with the seed never displaced by
its own child, nor by anything else while its walk is out: from
refine_from_job until refine_from_absorb (or refine_from_cancel, a stop)
the seed is exempt from eviction like a pinned patch.
Who leaves, and when
A child is admitted only if it beats the member it would displace: the
lowest-utility member not kept, counting the displacements the generation
already owes. Kept (Candidate::kept) is saved (pinned), or kept as new and
not yet in a pick
(persistence).
Nobody leaves while the generation runs. At its end the pool is
trimmed back to pool_size: its lowest-utility members not kept are replaced,
so the patches that leave are exactly the ones evicting one child at a time
would have removed, but a patch saved (pinned) at any point before the end is
never among them. Kept candidates are always exempt.
So are the sounds on the table: the app holds the pair EVOLVE shows and the
pair of a pick still in its undo window (set_on_table, at most
ON_TABLE_MAX, four; read at the end as pins are, and never saved), so the vote that pick becomes when
its window closes finds both its sides in the pool. One rule,
Engine::spared (kept, a ⚡ seed in flight, or on the table), is what every
eviction passes over: the bar a child must beat, the trim at the end, a
preset's or an import's insert, a ⚡ child's trim, and retiring() below. A
session that holds nothing evicts exactly as it did before the hold existed.
Both the bar a child must beat and the trim at the end are judged under the
posterior the generation opened with, the one in its context and the one
its walks climbed. A pick made while the generation runs reweights the
engine’s posterior at once, so the next pair responds to it, but not the
generation’s: which children are kept and which members leave do not depend
on when picks landed between absorptions (natively,
picks_during_a_generation_do_not_change_which_children_are_kept). The picks
count from the next refit, which waits for the generation to end.
Before a generation: its seeds, and what it may replace
Both are stated by the engine before EVOLVE POOL is pressed.
next_seeds()is the parentsrefine_jobswould take if it ran now: the toprefine_seedsof the ranked list, under the posterior as it stands, so they move with every pick between refits. A saved patch seeds like any other. Natively,refine_jobstakes them by the same rule (next_seeds_and_may_replace_are_what_a_generation_does).may_replace()is the members that generation could retire, lowest first. Each walk admits at most one child, and the trim takes only as many members as the children put the pool over size, lowest first. So with the pool at size, the trim would take nothing outside therefine_seedslowest members not kept nor on the table: those come first. But the trim reads the table as it finds it at the end, and the player keeps picking while the generation runs. A sound up at the press is put away long before the end, and up toON_TABLE_MAXof the sounds the trim would take can be up when it ends, each sending it one member further. So the list goes on with theON_TABLE_MAXnext lowest with the table put away, and nothing outside therefine_seeds+ON_TABLE_MAXlowest members not kept can leave, whatever is on the table, unless something else changes the pool meanwhile (a save, an edit, a preset) or a sound kept as new has its first pick, after which it competes. Natively,may_replace_bounds_a_generations_end_whatever_is_on_the_table_by_then. A patch the app has cut is in the pool and ranks low, so it is on this list too: the engine does not know about cuts.
The app is sent both with every pick, beside the ranked numbers
(the posterior),
and marks them in the bank while EVOLVE POOL is pointed at. Once a generation
is open both describe the next one, so the app marks the running
generation's own instead: its parents, which the worker posts with the
generation's progress (job i walks from seeds[i]), and retiring(), which
each absorbed walk's message carries with the job's parent_id, as does the
reply to a save, a preset, a kept edit or a change of what is on the table
while the generation is open: a saved or held member drops out of
eviction_order and the next lowest takes its place, and a new member can
move the lowest. The app says
will be replaced for retiring(), not may be: it is the lowest members
not kept, as many as the children put the pool over size, under the posterior
the generation opened with, so each child admitted adds one member and removes
none, and the end replaces them whether it is stopped or runs out, but for a
member saved meanwhile or on the table when it ends, whose place goes to the
next lowest. Each child still to come can add one more.
While ⚡ walks with no generation open, its child is admitted and the pool
trimmed at once (evict_to_size, the seed protected), lowest first by
eviction_order, which passes over a seed in flight. The first part of
may_replace() ranks the same way, the table passed over, so its first
pool + 1 − pool_size members (one with the pool at size) are what the child
would replace; the app marks those, and the seed.
The split is measured
Defaults (in engine.rs), both scaled from the grammar’s processor count
N_OPS = 20 (in prior.rs):
Riding N_OPS matters: a structural proposal picks a new operator from a
categorical that grew from six to twenty kinds, so a fixed budget would spend
the same number of proposals covering a far wider move set and land children in
a visibly thinner slice of it. The tuning survives a change to the processor
set.
The 40 × 10 split was an argument that could have been wrong in either
direction, so search_health --budget-ab was written to settle it. Over 8
seeds, 6 generations, graded against a synthetic user’s true utility, under
fugue-ppl 0.2.2 (“What has changed since”, below, says what is different now):
| steps | seeds | proposals | mean | max | |
|---|---|---|---|---|---|
| 40 | 10 | 400 | 1.714 | 8.154 | shipped |
| 40 | 3 | 120 | 1.241 | 6.178 | same depth, fewer seeds |
| 66 | 3 | 198 | 0.774 | 6.281 | same total, fewer seeds |
| 20 | 20 | 400 | 0.568 | 6.790 | half depth, double breadth |
The shipped split wins on both metrics, and moving off it in either direction is worse.
Two rows are worth more than the headline.
Depth from few seeds is harmful. 66 × 3 runs 65% more proposals than 40 × 3 and scores lower (0.774 against 1.241). A long chain from a bad starting point converges confidently on somewhere nobody wanted to be, and the extra steps are what get it there.
Breadth is not free either. 20 × 20 spends the full shipped budget and is
the worst row of the four. Under 0.2.2, twenty steps was not enough for a chain
to leave its seed, so the generation is twenty barely-moved copies of the
current top, which is also why it has the second-best max: it preserves the
frontier by never straying from it.
Re-run this before changing either number.
What has changed since. The four rows were measured before 2026-10-07, under
fugue-ppl 0.2.2 and with oscillator octaves drawn uniformly, and --budget-ab
has not been re-run. fugue-ppl 0.2.3 reflects a knob’s proposal into its bounds
(at 40 steps, knob moves are accepted 85% of the time against 53%, and are
about 1.8 times larger), and octaves are now drawn by weight.
Two measurements on 2026-10-07 (the share of locked ⚡ walks whose child beats
its parent, search_health --hits) found the shipped 40 steps not worse under
either change, measured one at a time: 66.7% to 70.3% on 48 seeds, paired, for
fugue-ppl 0.2.3, and 71.1% to 68.0% on 16 seeds, unpaired, for the weighted
octaves, which ran on fugue-ppl 0.2.2, before the branch that drew them met
0.2.3. The two together, which is the walk now, were never measured by this rate
at any length.
On 0.2.2, “twenty steps is not enough” read as 60.9% against 71.1% at 20 and 40 steps with octaves uniform, and did not show with them weighted (71.1% against 68.0%, inside the noise); what it reads on 0.2.3 with the weighted octaves nobody has measured. The 80-step row leans worse under each change, and the 160-step row under fugue-ppl 0.2.3, all inside the noise; longer walks are not shipped, and the weighted octaves’ 160-step row was never finished. Neither study was re-measured after the walk began to run its model once a step and the renders moved to quiver-dsp 0.5.1.
Why local climbing suits this anyway
The gap between design and implementation is real, but the implementation is not merely a shortcut.
A candidate pool is not a sample. The pool’s job is to hold a few dozen patches worth auditioning. A correct sample from would include low-utility regions in proportion to their (small but nonzero) probability mass, which is right for estimating an expectation and wrong for filling a shortlist a person will listen to.
Warm-starting from the best members is deliberate. It concentrates effort where the model already believes, which is what “propose toward me” means from the player’s side.
Diversity comes from elsewhere. The measured result below is that the pool does not concentrate over a session anyway, so the thing SMC would primarily buy (maintained diversity via a population kernel) is being supplied by frontier-biased injection plus worst-eviction.
What is lost: any claim about the distribution of the pool. That is the whole of it. The other thing this section used to claim was lost turns out not to be.
The islands are not separated by a valley
This page previously said that a player with two distant islands “may find that refinement from island A never discovers island B, and has to reach it by hand or by the prior”. That was an argument from the shape of a local walk, and it is false.
make islands teaches a bimodal synthetic user (two islands opposed on every
coordinate they share), runs real generations, and asks how often a child lands
on the island its parent was not on:
| refinement events that cross islands | 99 / 473 (20.9 %) |
| of those, decisive (both ends > 1.0 onto their island) | 64 (13.5 % of all events) |
| seeds whose pool ended on one island only | 0 / 8 |
The decisive column is the one that matters. A patch sitting on the decision boundary can flip island under an arbitrarily small change, and counting that as crossing a valley would be measuring nothing; the filter removes it and the answer survives. The pool share is reported beside it as a control, because both islands being occupied would say only that the prior scattered candidates over both.
Why the argument was wrong. It reasoned about a local walk in feature space. This is a reversible-jump walk over a tree grammar: one accepted structural move swaps a subtree, and that is a large jump in . The search does not have to travel through the low-utility space between the islands, so there is no valley for a tempering schedule to cross.
Tempered SMC may still be worth having for the distributional claim. It is no longer worth having for this.
The measured non-concentration
From the same harness, as a manipulation check that turned into a finding.
Final pool spread, measured as mean pairwise on the reference scale, was 7.7–7.9 evolving versus 7.2 static. Six generations over a 72-duel session did not concentrate the pool at all; it widened it slightly, because mutation pushes children into feature-space extremes faster than eviction trims them.
That has two consequences, and one of them decided a default:
- The diversity argument for SMC is weaker than expected at session horizon.
- The concentrated regime that BALD was hypothesized to win in never arises, so the measured tie between BALD and uniform pairing is not an artifact of a spread pool that only the static setup guaranteed. The product’s own dynamics keep the pool spread.
The screening cascade
is free (no compile, no render), so a structure-only
surrogate could prune candidates before the expensive path, and only the
survivors would be rendered and scored in full. No such cascade is built:
SurrogateFitness featurizes every candidate it is handed
(Directions §12).
This is designed into the feature split and is why is two-part rather than one vector. In the refinement path specifically, the affordability comes primarily from the render memo rather than from screening, because the walk re-scores its own current state on every step.
Lineage
Every injected child records a LineageEvent:
pub struct LineageEvent {
pub generation: usize, // the generation counter at the event
pub kind: String, // "refine" | "edit"
pub parent_id: u64,
pub child_id: u64,
pub diff: Vec<DiffEntry>, // what changed, in trace-address terms
pub parent_utility: f64, // posterior mean at event time
pub child_utility: f64,
}
tree_diff produces the address-level diff, which the app renders as attack 0.59→0.83, +noise, −distortion · liked +0.65, naming the parent and child
by their bank names. The bank shows each bred child's event under its name
(from Soft Pad · +delay, cutoff 1.2 kHz → 3.4 kHz). The diff is
positional, so a module inserted above others shifts their addresses and one
change to what the patch holds can read as several swaps, removals and
additions. The app counts the modules instead: every module site nets its old
name −1 and its new name +1, a swap is said only where it accounts for a lost
module and a gained one, and the rest is said as gained (+) or lost (−). A
module removed in one place and added in another nets to nothing. Compare
shows it whole:
both utilities, through the same logistic as the bank's percentages
(), and the diff. A refused child (NotAdmitted) records no
event; the app knows it only from the walk's reason and its job's
parent_id.
Utilities are recorded at event time: a later refit changes the model, and re-deriving these numbers afterward would rewrite history to look better-informed than it was.
Hand edits appear in the same log tagged "edit", because the lineage is a
record of everything that produced a patch and not only of what the machine
did.
Performance: named controls and the drift walk
A control called Bright is a fixed direction in standardized φ. Which knobs it turns is measured per patch, checked on real renders, and refused when the knobs cannot honestly produce it.
PERFORM, the instrument’s first view, needs two things from the machinery. It
needs controls whose names mean the same thing on every patch, which a knob
address cannot give: node/0#cut is a brightness control in one patch and a
wavefolder’s drive in another. And it needs motion that stays inside the
sound: a patch that changes on its own without changing what it is made of.
The first is a least-squares problem on the patch’s own Jacobian. The second is
the locked walk with every structural site locked. Both live in
auracle_session::perform, and reach the browser as perform_wire,
perform_apply, perform_drift, and perform_offer on WasmEngine. Under
the model view the instrument also draws which way your taste leans along
each control (below), perform_lean.
Symbols on this page
The notation fixes as the feature dimension and as a
term, so this page uses different letters for the two things the code calls
d and x.
| Symbol | Is | In the code |
|---|---|---|
| The patch’s continuous knob values, normalized | Jacobian::values | |
| The audio block of standardized at those values | audio_z | |
| A named control’s unit direction | direction(...), d | |
| at the current values | Jacobian::cols | |
| The knob move the wiring solves for | x | |
| The knobs a control is allowed to move, | support | |
| A control’s setting; is the sound as it is | c |
Distances along are in units: one unit is one standard deviation of the session’s own spread, from the standardizer. The instrument’s tooltips and the code’s comments write this unit as σ.
The constants on this page live in auracle-session’s perform.rs unless the
text names another file.
A named control is a direction
Six controls on the panel, each a fixed weighting of named φ_audio coordinates, normalized to unit length. They are the first six of the palette's eighteen, which the engine defines and measures the same way:
| Control | Low · high | Weights before normalizing |
|---|---|---|
| Bright | dark · bright | centroid_mean , rolloff_mean |
| Snap | bloom · snap | attack_s , crest |
| Motion | still · restless | held_centroid_std, motion_slow, motion_mid, motion_fast, each |
| Body | thin · full | bass_fraction |
| Grit | smooth · rough | flatness_mean |
| Space | close · far | tail_ratio |
So , and
Motion spreads over its four coordinates with weight each, the
motion bands plus the older
held_centroid_std. A unit test requires each of the eighteen directions to
have unit norm over φ’s real coordinate names, and every coordinate it weights
to be one of them, so a control that lost any coordinate to a rename fails the
build instead of quietly narrowing to the rest.
The direction is defined in , not raw , because only a standardized coordinate has a scale that means the same thing across axes. The patch’s current position on a control is ; the instrument draws it as a dot on the control’s ring, at of the way to the stop.
The standardizer is the session’s, refit at every posterior fit. A control’s
wiring and position are therefore relative to the patches this session has
seen, and can change after a refit. Before the pool has a standardizer at all,
perform_wire returns null and the view says there is nothing to measure
against yet.
The other two controls, Blend and Wander, are not directions in φ and live in the instrument, not in this module.
The Jacobian, by one-sided differences
For each continuous knob , one render nudged by :
JACOBIAN_STEP of the knob’s normalized range: large enough to move φ
past the numerical floor of a five-second render, small enough to stay local.
Stepping toward the interior keeps every nudge inside the knob’s domain,
and one-sided differences cost renders instead of the that central
differences would, all through the render memo. A knob whose nudge fails to vet
gets a zero column, and the wiring simply cannot use it.
The estimate is first order: its error is in the curvature of the response. That is not bounded here. It is what the verification below exists to catch.
Wiring: ridge, support, re-solve
The knob move that best produces is the ridge solution
Ridge rather than plain least squares because is badly conditioned by construction. Two knobs can do nearly the same thing to the sound, which makes columns nearly collinear, and many knobs are nearly inaudible, which makes columns nearly zero. Unregularized, the solve answers both with large, canceling moves.
The support is the four knobs with the largest effect
(MAX_KNOBS), and
the move is re-solved on it:
Truncating would throw away the other knobs’ contribution without letting the four that remain compensate; the re-solve is the best move on those four. Choosing the four by effect is a heuristic, not a best-subset search. It is effect and not because a knob that barely moves the sound needs a large coefficient to contribute anything. Ranked by coefficient, that knob headed the support, and the scale below then gave the knob doing the work a sliver of a turn. On Iron Bass, Bright wired to the drive () at , and the cutoff (, of centroid per unit) barely moved: of reach.
The control’s own knobs first. The solve runs twice at most. First it is
restricted to the knobs a musician would name for the control
(NamedControl::sites: cutoff, tone, … for Bright; mod depth and rate for
Motion). If that wiring clears the gate below, it is used. Only if it does not
does the solve range over every live knob, with those sites as a soft prior
(SEMANTIC_RIDGE). A player who turns Bright and watches the cutoff move has
learned something true about the patch. One who watches the amp release move,
which is what the unrestricted solve chose on Acid Line, has learned nothing.
Four, because a control is heard as one gesture, and because four is where most of a patch’s audible leverage already is: over the preset library, a patch’s four most audible knobs carry a median 68% of its φ movement (below).
The movement this predicts is , and three numbers follow from it.
Purity is the cosine between the predicted movement and the direction asked for:
is a pure move along the label. At the off-axis part of the movement is times the on-axis part.
Scale. A full turn may move no knob by more than MAX_TRAVEL of its
range. The knob with the largest effect gets all of it, and the rest follow in
proportion, clamped at the same limit:
Purity and reach below are computed from the clamped travel , so they describe the move the control actually makes.
The upper clip is KNOB_MAX. Every continuous site is
on the half-open interval, so a knob at exactly has log-prior and
would make the patch un-evolvable. A performance gesture must never do that.
Reach is the predicted movement along at a full turn, in units:
A control is a search control on this patch when the knobs cannot honestly produce it:
(PURITY_FLOOR, REACH_FLOOR), or when there is no support or the predicted
movement points the wrong way. The reach floor was first set at against
the preset library’s spread. The session pool is spread wider than a curated
library, which shrinks every distance, and hid controls that are
plainly audible on a live patch.
Several controls turned at once add: each contributes to the knobs it wires, and the sum is clipped. The composition is linear by assumption and is not verified.
Purity measures cross-talk, not correlates
Purity asks whether a move is this control. Measured against the whole of φ, it cannot tell a brightening that also raises the zero-crossing rate and the high band (which every real brightening does) from one that also slows the attack. Over a fresh session pool, Bright’s median cosine with its own axis was 0.26 for that reason. What a player hears as “this control does something else” is movement along another control’s axis, so purity is the cosine with inside the subspace the six named axes span:
with the other controls’ axes. Reach and position stay on .
And two controls whose predicted movements are within
of each other are one gesture with two names: the later one in the order they
were wired (for the panel, Bright, Snap, Motion, Body, Grit, Space) becomes a
search control (separate, COLLINEAR).
A palette control is not one of the six axes, so the same idea is written for
any direction (purity_basis). Take an orthonormal basis of
the span of and the six axes, with and the rest from
Gram–Schmidt over the six in order, and, for the predicted movement as
above,
For one of the six this is the formula above, bit for bit: the other five axes
share no coordinate with it, so they enter the basis unchanged (a unit test
holds the two equal). A control that leans on some of the six (Thump on Body,
and on Snap through crest) takes up what it shares with them, and the
remainder of each, the part orthogonal to , counts against it in full:
a move of plus an equal part of that remainder has
(the unit test checks this for every such pair). One with a
coordinate of its own (Air’s zcr_mean) widens the span by that much. A move
along the control’s own direction has .
Measured over the first 24 patches of a fresh session pool (reach_census,
seed 7), with verification on real renders:
| Reachable | Bright | Snap | Motion | Body | Grit | Space |
|---|---|---|---|---|---|---|
| Purity against the whole of φ | 25% | 58% | 58% | 17% | 4% | 38% |
| Purity against the named axes | 29% | 71% | 62% | 21% | 4% | 38% |
Patches on which no control reaches fell from 4 of 24 to 2. The gain is modest because purity was not Bright’s real limit: its median predicted reach is about 0.1σ, since many pool patches have no filter for a named control to turn. That is the honest reading, and a search control’s offer is the answer to it.
The same 24 patches, after the effect ranking, the control’s-own-knobs pass,
and a half-travel retry in verify (all three above and below). Both rows
are measured on the same build, since the pink-noise fix and the re-voiced
presets moved the pool standardizer:
| Reachable | Bright | Snap | Motion | Body | Grit | Space | mean per patch |
|---|---|---|---|---|---|---|---|
| Ranked by coefficient | 29% | 67% | 62% | 21% | 4% | 38% | 2.21 |
| Ranked by effect | 50% | 79% | 46% | 29% | 4% | 46% | 2.54 |
The median verified reach of a reachable control grew three- to four-fold (Bright 0.09σ → 0.33σ, Snap 0.54σ → 0.95σ). Motion lost: 62% → 46%. Motion leans on several small knobs together, and giving the strongest one its whole turn is where a Motion wiring most often turns back on itself. The half-travel retry did not win those patches back. Dropping the weak knobs was tried first and cost Motion more.
Tried and not shipped. Aiming the solve at each control’s population pattern (the correlation-weighted direction, Haufe et al. 2014) first looked like a large improvement, but only because purity was then measured against the pattern instead of the axis. Measured against the axis, with the pool correlation shrunk toward the identity (Schäfer & Strimmer 2005) and collinear controls separated, it reached fewer patches than the bare axis (Bright 21%, Motion 42%), and on 2 of 12 patches it put Bright and Body onto the same knobs with opposite signs. It was removed.
Verification on real renders
The Jacobian is a local, linear claim, and it fails exactly where a player would notice: at a boundary. On First Bass, which already sits at the floor of motion, the linear prediction said turning Motion down would make it stiller. Rendered, it made it very slightly more restless.
So verify renders every non-search control alone at four settings and
measures the movement along its own axis:
For each half, with sign , the measured reach is
The half must move the asked way at half travel, and further at full travel. A half that moves at the end but reverses on the way is closed. A half is open when , half the reach floor. A control with neither half open becomes a search control. The instrument draws a half-closed control’s ring solid on the side it can turn toward and dotted on the other, with a stop at the center, and says which way it turns: turns toward restless only, for Motion on First Bass.
A control that would close at full travel gets one retry at half: the same two-point test over (the renders are already in the memo). If a half opens, the wiring keeps half its travel rather than closing.
Total cost for a patch with knobs and reachable controls is
renders, plus two for each retry, all through the memo. In the browser that is
around 20–30 renders, and the page caches each measurement by tree, stamped
with the standardizer's revision and the render namespace (cache_namespace: the
featurizer's RENDER_EPOCH and the quiver version), so returning to a patch
costs none. A stamp that no longer matches is played at once and re-measured
in the background. The stamp is the standardizer's revision (the status's
std_rev), not the vote count: the scale moves when a fit runs over sounds
the session had not met, and a pick alone left every kept wiring stale. Every preset’s measurement also
ships with the app (apps/web/perform-wirings.json, made natively through the
same WasmEngine calls by make perform-wirings), so a preset is playable the
moment it lands; the page re-measures it in the background, because the file
was measured under a standardizer fitted to a standard pool, not the session’s.
The view re-wires after every patch change, and after a glide or a Keep that
carries the knobs out of the neighborhood the wiring was measured in (see
below), so the claims are always about the neighborhood the sound is in. A
background re-measurement that wires each control to the same knobs is taken
in place, without re-centering the controls.
The gate, and why it samples somewhere else
named_controls_move_the_sound_they_name wires and verifies four presets
(First Bass, Ceiling, Detune Dream, Long Way Down) and then checks every
open half at , a point verification never rendered:
It requires at least eight open halves, so a wiring change that closed most of them would fail the gate rather than pass it vacuously. The palette’s twelve are held to the same check on the same four presets, each wired alone, and must show at least 16 open halves over at least 8 of the twelve.
It samples somewhere else because no finite set of samples proves a response monotone. A smooth function can agree with any finite set of points and reverse between them. Checking an unsampled point is the measurable form of the promise, and a check at the points verification already used would be the same test twice.
The tolerance is stated rather than zero because a five-second render has numerical noise. units is two orders of magnitude below an audible difference and above that noise. A check with no tolerance failed on Detune Dream’s Snap, which reversed by : real, measurable, and far below anything a listener would hear. That is the case the tolerance exists for. Verification itself has no tolerance: a half that reverses at a sampled point is closed however small the reversal.
The measurements, and what reproduces them
Per patch, not per knob kind. The obvious alternative to measuring a Jacobian per patch is a table: what a cutoff knob, or an attack knob, usually does. Both were measured over 61 presets. The table was built leave-one-out, from per-site-kind averages over the other 60, so no preset was wired by a table that had seen it.
| Median purity | Bright | Snap | Motion |
|---|---|---|---|
| Wired from the patch’s own Jacobian | 0.61 | 0.77 | 0.75 |
| Wired from the best leave-one-out per-site table | 0.23 | 0.16 | 0.09 |
The same knob does different things in different patches. That is the reason the grammar exists, and it is the reason the table is never the last word.
It is the first one, though (#290). A first measurement is 8 to 71 renders, a
minute and more on a slow computer, and a sound nobody has measured plays its
controls at once on a wiring that costs none: one kept for a sound of the same
shape (the same modules and selectors, so the same knobs; predict::shape_of)
if one was measured, and otherwise one predicted from a table of how each
kind of module's knob moves φ (predict::KnobTable: the median measured
column per module kind, site, modulation target and third of the range,
backing off to coarser keys), run through the same solve. The measurement
then lands on it by a rebase, so nothing heard moves. Until it does, a control
playing on either says not measured yet: a borrowed wiring was measured, on
another sound, and is not gated; a predicted one is gated (below). Judged against each sound's own Jacobian,
held out (examples/wire_predict.rs, 240 pool sounds from six sessions):
| Table learned from | Wires | Turns the named way | Covers the measured reach |
|---|---|---|---|
| The presets | 767 | 70% | 53% |
| The presets and one session's pool | 688 | 76% | 56% |
| The presets and five sessions' pools | 579 | 79% | 50% |
The gate. It is right on some controls far more than others (Space 99%,
Bright and Snap about 78%, Motion 62%, Body 63%), so a control is wired from
the prediction only where the table has shown it can be. make perform-wirings
learns the shipped table from the presets' Jacobians and 240 pool sounds' (the
standard pool's and five more sessions'), and for each palette control counts,
on every pool sound held out of the table in turn, how often the prediction
wired it and how often that turned it the named way (KnobTable::agreement).
A control passes when the lower end of the Wilson interval at two standard
errors reaches 0.70 (PREDICT_GATE, GATE_Z): more than two turns in three
the named way, after paying for how few turns there were, so a perfect record
needs ten. One session's pool is too few to read it on: across six, one pool's
Bright read 65% and another's Motion 72%. The rest wait for the measurement and
read listening…, as do controls the prediction cannot reach (a guess does
not say a patch can't).
| Control | Wired | Named way | Bound | |
|---|---|---|---|---|
| Bright | 139 | 82% | 0.746 | passes |
| Snap | 231 | 83% | 0.776 | passes |
| Motion | 108 | 60% | 0.506 | |
| Body | 19 | 32% | 0.151 | |
| Grit | 5 | 100% | 0.556 | |
| Space | 73 | 99% | 0.924 | passes |
Of the palette's twelve, Warmth, Thump, Round, Distance, Haze and Bite pass;
Air, Heft, Punch, Throb, Sway and Lo-fi do not. shipped_wirings.rs pins the
list. By ear, rendering each gated control turned fully up and fully down on a
fresh session's 40 sounds (wire_predict --shipped), 81% moved the named way
turned up and 66% both ways (ungated, 66% and 55%), and the prediction wires
58% of the controls the measurement reaches. "The named way" is the sign of
the movement along the control's own direction, not how much else moves with
it, so a purity row like the one above would read lower.
Grit and Space measure a median purity of about zero by knobs alone on most presets, because most patches contain no drive or reverb to turn. They are the controls most often drawn as search controls.
Grafts. Turning a search control first tries to give it something to turn
(graft_for). Bright and Body get a flat EQ, placed below any stereo module
that ends the chain, because above one it folds the patch to mono (Ghost Bell
moved 0.27σ that way). Space, turned up, gets its amp release raised to 0.6
(≈250 ms). A reverb was tried first, and every effect here sits before the amp
envelope: on First Bass with a reverb grafted, ∂Space/∂mix measured −0.04 and
∂Space/∂release +3.65. Grit gets nothing (see the
open question). perform_inserts measures each
graft. Over the preset bank, the EQ is transparent (median |Δz| 0.000; the
unit test bounds it at 0.05σ). It opens Bright on 7 of the 10 presets where
Bright was a search control, and Body on 15 of 48. The release graft opens
Space on 41 of 46, at 1–2σ of reach. It is not transparent, and is not meant
to be: it is what “farther” asked for.
Leverage. Nudging every knob of every preset by and measuring how
far moves, in units of the library’s own
per-coordinate spread: over 61 presets and 821 knobs, a patch’s four most
audible knobs carry a median 68% of its total movement and its top eight
94%. 172 of the 821 knobs barely move it at all. That is what makes
MAX_KNOBS a real operation rather than a random pick with a story
attached.
| Example | Produces |
|---|---|
cargo run -p auracle-features --example jacobian_probe --release > jac.csv | for every preset: the raw material for both purity rows |
cargo run -p auracle-features --example leverage_probe --release > leverage.csv | Per-knob leverage for every preset |
cargo run -p auracle-session --example perform_wiring --release -- "First Bass" | The shipped wiring on named presets: knobs, purity, reach, position, search |
cargo run -p auracle-session --example offer_cost --release -- 12 3 20 | What an offer, an aimed offer and a drift cost, per preset and as a distribution, and how much of it is renders (offer_cost.mjs in auracle-wasm's examples is the wasm twin) |
cargo run -p auracle-session --example perform_inserts --release | For each preset’s search controls, whether PERFORM’s graft is transparent and whether it makes the control reachable |
cargo run -p auracle-session --example reach_census --release -- 24 7 | How many controls reach the patches of a fresh session pool, with verification, and how the gate would read with purity against the whole of φ |
cargo run -p auracle-wasm --example palette_census --release -- 3 --prototype | The palette: the eighteen directions, their cosines, how often each reaches the presets, and what measuring them costs natively |
node crates/auracle-wasm/examples/palette_cost.mjs 4 | The same measurement’s cost in wasm, the six against the eighteen (make wasm first) |
The two probes print CSV and the medians are computed from it. jacobian_probe
uses central differences on raw φ at the same , so it measures the
same response as the shipped code without being bit-identical to it.
perform_wiring runs the shipped jacobian and the bare-axis wire under a
preset-library standardizer, and does not run verification; reach_census
runs the shipped path (Engine::wire_controls) with verification.
The palette: eighteen directions
RFC-006
turns PERFORM’s controls into a palette of eighteen in six families, of which
the player places up to eight. The engine defines all eighteen (PALETTE in
perform.rs) and measures any of them exactly as it measures the six: one
Jacobian, the ridge solve onto at most four knobs, the purity and reach gate,
separation, and verification on real renders (Engine::wire_named; the
arithmetic alone, unverified and without a render, is wire_set). The first
six entries are CONTROLS, at the same indices, so an index names the same
control in either list. The app wires the controls the player has placed on
PERFORM’s panel, at most eight (PANEL_MAX in perform.js): for the six it
sends no controls, so the request is the one it always was, and for any
other panel it sends the panel’s set.
The set is asked for in palette order, whatever order the panel shows.
separate makes the later of two collinear controls the search control, so
the order asked is part of the answer; asking in palette order makes the
answer a function of the set alone, so reordering the panel costs no renders,
and the six keep the precedence the census above measured them with (“beside
the six”). The page keys its cache of wirings (wireKey) by the patch and the
set: the patch’s own key for the six, which is the key every kept and shipped
wiring already had, and that key with #controls= and the set for any other.
A set not measured on a patch yet borrows, control by control, from any set
that was (borrowWiring), and is measured behind it.
How a control is named across the boundary. perform_wire’s controls is
a JSON array of palette indices, read entry by entry: a non-negative whole
number is an index, and an index out of range, a repeat, or anything else in
the array (a negative, a fraction, a string) is dropped on its own. What is
not an array at all means the six. An array with nothing valid in it wires
nothing and renders only the patch itself. The reply’s wiring follows the
order asked, and each entry carries its palette index (Wiring::index)
beside its name. The page names a control back to the engine by that
index (perform_offer’s control, perform_graft’s k), never by the
entry’s position: asked for [16, 6], the first entry is Bite with index
16, and a position of 0 would aim along Bright. Only for the six asked in
order are position and index the same.
The directions
Each of the twelve is a direction of its own over φ’s coordinates, normalized like the six:
| Family | Control | Low · high | Weights before normalizing | What it means in φ |
|---|---|---|---|---|
| Tone | Bright | dark · bright | centroid_mean , rolloff_mean | The six, above |
| Tone | Warmth | cold · warm | bass_fraction , high_ratio | Weight low down and a soft upper register: the highest note comes through quieter than the held one, as under a low-pass that does not follow the keys |
| Tone | Air | closed · airy | zcr_mean , rolloff_mean , centroid_mean | The very top, above the notes: the highest partials and any breath, and the rolloff, measured against the centroid, so the top opens while the body of the sound stays |
| Weight | Body | thin · full | bass_fraction | The six |
| Weight | Thump | light · thumping | bass_fraction , crest | Weight low down that hits |
| Weight | Heft | slight · heavy | rms_mean , bass_fraction | Dense, held weight: frames full of level (which loudness normalization gives a sustained, low sound, since K-weighting counts the lows for less) and weight low down |
| Dynamics | Snap | bloom · snap | attack_s , crest | The six |
| Dynamics | Punch | gentle · punchy | crest , rms_std | The size of the hit against the rest: peaks, and a level that moves over the phrase |
| Dynamics | Round | hard · round | attack_s , rolloff_mean | A soft attack and few harmonics, the voice’s round |
| Movement | Motion | still · restless | held_centroid_std, motion_slow, motion_mid, motion_fast, each | The six |
| Movement | Throb | steady · throbbing | motion_mid | Pulsing and tremolo: the held note’s motion at 2–8 Hz alone |
| Movement | Sway | fixed · swaying | motion_slow | Sweeps and breathing: the held note’s motion at 0.5–2 Hz alone |
| Space | Space | close · far | tail_ratio | The six |
| Space | Distance | near · distant | tail_ratio , attack_s , crest , rolloff_mean | What distance does to a sound: a longer tail, a softer attack, smaller peaks and a duller top |
| Space | Haze | clear · hazy | tail_ratio , motion_fast , crest | A wash: a tail, a shimmer (the 8–30 Hz beating that detune and chorus give) and peaks smoothed away |
| Character | Grit | smooth · rough | flatness_mean | The six |
| Character | Bite | mild · biting | flux_mean , zcr_mean | An edge: the spectrum changing fast, and high partials (a filter that snaps open, a resonance that rings) |
| Character | Lo-fi | clean · worn | flatness_mean , rolloff_mean , motion_fast | Worn like tape: hiss, a dull top and flutter |
Three things φ lacks shaped these. It has no low-mid band, so Warmth is not
quite the voice’s warm, “weight in the low middle, a soft top”. It measures
weight low down (bass_fraction, below about 250 Hz) against a soft top of
the keyboard (−high_ratio: the highest note quieter than the held one).
That is close to the word, and not exactly it: a sound can gain low-middle
warmth that this does not see, and lose some that it does not count.
Defined with the spectrum’s top instead (rolloff_mean down with
bass_fraction up), it moved with Body across the sounds (), and
on 28 of the 40 presets where both it and Bright reached it was Bright’s
gesture, which is why it reads the keyboard’s top and not the spectrum’s.
φ has no bit-depth or bandwidth feature, so a crusher reaches Lo-fi only
through the flatness it adds. And its only space coordinate is tail_ratio,
which the amp release sets (a reverb’s tail is cut at note-off), so Distance
and Haze lean on what else distance and a wash do to a sound.
Two names for one control
The approved prototype (docs/notes/vision-2026-09/prototype/perform.js)
defined the twelve as blends of the six. Five pairs of those blends were one
direction, : Air and Bright (), Softness and
Snap (, Snap turned down), Wobble and Motion (), Distance and
Space (), and Warmth and Air (). Punch was to Snap. That is
why each of the twelve above is its own direction. Redundancy was then measured
three ways over the eighteen, on the presets and the shipped engine’s pool:
- The directions themselves, : no pair reaches . The largest are Space and Distance () and Body with each of Warmth, Thump and Heft ().
- The sounds’ positions along them, correlated over the 62 presets and the pool of 40: no pair reaches . The closest are Motion and Throb (), Snap and Distance (), Motion and Sway () and Throb and Sway (): a sound that moves tends to move in every band, and a distant one tends to have a slow attack.
- The wirings: over the presets where both reach alone, how often their
predicted movements are one gesture (, what
separatechecks). Only Space and Distance do on more than half (9 of 14): where a patch can reach Space at all, it is through the release, and Distance turns it too. A first Heft that also carriedcrestwas Thump turned down on two presets in three (both turned the amp envelope, opposite ways), and was changed to the definition above.
How often each reaches
Each control wired on each of the 62 presets, on the engine the shipped wirings are measured under. Alone is the control wired by itself: whether this patch has knobs that move it, purely and verifiably. Beside the six is the control wired after the six, so a control whose gesture on a patch is one of theirs () is a search control there: whether it adds a gesture the panel does not have. Both halves is both directions open after verification.
| Family | Control | Alone | Beside the six | Both halves | Median purity | Median reach (σ) |
|---|---|---|---|---|---|---|
| Tone | Bright | 79% | 79% | 74% | 0.81 | 1.64 |
| Tone | Warmth | 73% | 52% | 26% | 0.70 | 0.57 |
| Tone | Air | 66% | 48% | 35% | 0.60 | 0.47 |
| Weight | Body | 53% | 32% | 21% | 0.54 | 0.68 |
| Weight | Thump | 81% | 40% | 27% | 0.70 | 1.55 |
| Weight | Heft | 76% | 52% | 19% | 0.65 | 0.77 |
| Dynamics | Snap | 82% | 82% | 32% | 0.99 | 1.56 |
| Dynamics | Punch | 73% | 60% | 15% | 0.89 | 0.92 |
| Dynamics | Round | 82% | 44% | 34% | 0.74 | 1.18 |
| Movement | Motion | 77% | 74% | 26% | 0.98 | 1.91 |
| Movement | Throb | 47% | 29% | 6% | 0.75 | 1.80 |
| Movement | Sway | 55% | 34% | 5% | 0.94 | 2.67 |
| Space | Space | 23% | 23% | 3% | 0.98 | 2.18 |
| Space | Distance | 94% | 39% | 32% | 0.55 | 1.16 |
| Space | Haze | 90% | 63% | 35% | 0.69 | 1.49 |
| Character | Grit | 11% | 6% | 11% | 0.58 | 0.29 |
| Character | Bite | 81% | 58% | 37% | 0.73 | 0.72 |
| Character | Lo-fi | 56% | 34% | 23% | 0.56 | 0.91 |
Purity and reach are medians over the presets the control reaches alone. For the six, “beside the six” is the panel as it is today: Body and Grit lose patches to the gestures of the controls wired before them. Alone, a median preset is reached by 12 of the eighteen (5 at least, 16 at most), against 3 of the six.
Read the table with one caution. Purity measures cross-talk against the named axes, not whether a control moved every coordinate it names, so a direction is reached through whichever of its coordinates the knobs move. On a patch where nothing moves the flatness, Lo-fi is reached by a duller top and flutter, and Distance (94%) by a softer attack and a duller top, since few patches can move their tail (Space reaches 23%). Throb and Sway rarely open both halves: a sound that is still cannot be made stiller.
What it costs
A measurement is renders for the Jacobian, shared by every control, then four per reachable control and two more for each retry. Wiring eighteen instead of six multiplies only the arithmetic (the solve per control takes microseconds) and the verification renders.
| Per preset | Renders, median | Mean | Most | Native s, mean | wasm s, mean |
|---|---|---|---|---|---|
| The Jacobian alone | 12 | 12.1 | 23 | ||
| The six, today | 25 | 26.2 | 42 | 5.9 | 7.5 |
| All eighteen | 49 | 49.8 | 84 | 11.2 | 14.2 |
The render counts are over all 62 presets (palette_census). The native times
are from the same run, three presets at a time on an Apple M3 Max shared with
other work, at 224 ms a render (173 ms in a quieter run). The wasm times are
palette_cost.mjs under node, on one thread as the engine worker has, over
every fourth preset (16), at 265 ms a render. That run boots its own pool from
the same seed, and in wasm the pool comes out differently from the native one,
so its counts differ a little per preset (median 28 renders for the six, 60 for
the eighteen).
A control that reaches costs about five verification renders: 4.7 for the six, and 5.0 for the eighteen wired in palette order, where 7.5 controls reach a preset on average. So the eighteen double today’s measurement. The panel never needs all eighteen. To keep within today’s budget:
- Wire all eighteen by prediction from the one Jacobian the six already pay for. That is arithmetic, and enough for each palette entry to preview what it would do, drawn as a guess (ADR-012): verification closes some predicted reaches (Throb predicted on 71% of presets alone, verified on 47%).
- Verify only the placed controls, at most eight. A panel of the six and two of the twelve adds one or two controls that reach (the twelve reach a mean 46% of presets beside the six, 73% alone), so five to seven renders, a fifth to a quarter more than today’s measurement.
- Verify a control placed later from the memo: the Jacobian is already there, so it costs only its own four renders, and two more if it is retried at half travel.
The app does the second and the third: it measures the panel’s set and
nothing off the panel, on the sound in hand, when PERFORM is in sight
(measurePanel in perform.js), and the engine’s memo holds the Jacobian
from the six’s measurement. The first, a preview of every palette entry by
prediction, is not built (below).
The shipped preset wirings stay the six’s. With the twelve’s wirings beside
them, apps/web/perform-wirings.json would weigh 391,740 bytes instead of
173,171 (2.3 times), fetched by every visitor for controls the panel does not
show yet.
Names
Three of the prototype’s names are changed, and the maintainer approved them with Heft and the end words below on 2026-10-01 (voice):
- Round (hard · round) for Softness (hard · soft): the voice’s word table already defines round as “a soft attack, few harmonics”, which is this direction.
- Throb (steady · throbbing) for Wobble: Wobble Board is a preset, and a control of the same name reads as that sound. Throb is the tremolo module’s own tag. Pulse was the other candidate, and it is a wave shape.
- Sway (fixed · swaying) for Drift: drift is already WANDER’s zone and
the walk behind it (
Engine::drift). Breath would read as Air’s breathiness, and Sweep Machine is a preset.
Heft (slight · heavy), the twelfth, is new. The four have their rows in the voice’s word table.
Drift: a local walk on the live knobs
Engine::drift samples the same target
restricted to this patch’s shape: every structural and categorical address,
every continuous site without a live handle, and the player’s own locks are
held fixed; the walk moves only live_knobs. Holding sites fixed in a Metropolis–Hastings walk is
exact conditioning (Locks as conditional refinement), so the walk
targets over the knobs the voices can take
live. Structure cannot change under the player’s hands, and nothing the walk
moves needs a recompile to be heard.
The kernel is not refinement’s. Each step picks one free knob uniformly and proposes
accepted with probability . The
reflected Gaussian is symmetric, so there is no Hastings correction
(Engine::drift). Refinement’s kernel, fugue’s adaptive single-site MH,
starts every fresh chain at a step of scale 1 on each site and adapts it only
after ten proposals there, so on a unit-interval knob each step of a short
walk is a Gaussian as wide as the whole range, reflected back into it: close to
a fresh draw. An offer of 8 steps with every site but the live knobs locked
moved its farthest knob 0.36–0.68 of its range over the table’s 12 presets
(its last row). A drift should wander, and how far is the Wander dial’s to say.
| Wander | Steps | Farthest knob moved (12 presets) | |
|---|---|---|---|
| drift, at its start | 8 | 0.05 | 0.06–0.14 |
| drift, at its top | 18 | 0.08 | 0.15–0.33 |
| roam, at its top | 40 | 0.15 | 0.25–0.61 |
| refinement’s kernel, the live knobs alone | 8 | 1, at first | 0.36–0.68 |
(cargo run -p auracle-session --example drift_distance --release.) Within
each zone the pace rises linearly with the dial (wanderPace, in
perform.js); roam starts at 24 steps and . The walk
returns its end state, or nothing if it ends where it started. Like
refinement, a short walk’s end state is local movement on , not a
draw from it (What is not sampled).
Nothing enters the pool: a performance gesture is not a candidate until the
player keeps it.
The wiring survives a small drift. The named controls are a linear model
measured with 0.08 knob steps, so the instrument re-measures them only when
some knob has left a 0.12 neighborhood of where they were measured (TRUST,
in perform.js). A gentle
drift usually stays inside and costs nothing; before, every glide was followed
by a full re-measure of about 46 renders, which in drift kept the worker busy
much of the time and queued offers behind it.
The instrument then glides the knobs from where they are to the returned values along a smoothstep, , over 2 to 6 seconds. The intermediate states are interpolations, not states of the walk. They are all valid patches, since the structure is fixed and the knob box is convex. A touch stops the glide where it is.
Offers
Engine::offer is the same walk with only the player’s locks, so structural
moves are allowed: it may add, remove, or replace a module. It is also
non-inserting. The instrument asks for 20 steps (40 in roam) and plays the
result in the B slot, never as a jump. The Offer pad and Wander ask for this
walk. A search control released more than
from its center (ASK_AT, in perform.js) asks for an aimed one
(below), and springs back without moving a knob.
A search control’s offer is aimed
Engine::offer_toward walks the same kernel on the target tilted along the
control’s direction , turned up () or down ():
where is the taste surrogate , or
VetOnlyFitness before any fit, and is AIM_GAMMA . At
, a proposal one σ further the asked way gains as much log-weight as
one unit more of expected utility. TiltedFitness reads from the memo
entry the inner fitness has just written, so the tilt costs no render, and a
proposal that does not vet keeps its quarantine score untilted.
Twenty single-site steps rarely propose the one module that makes a patch
rough (a noise source, a crusher), and the tilt cannot make a proposal the
kernel never draws. So a walk that has not moved at least REACH_FLOOR the
asked way,
keeps walking from the state it stopped in, up to AIM_WALKS walks of
the asked steps. It is one longer chain that stops as soon as it has arrived,
not a filter: nothing grown is thrown away, which is what the rejected
alternative (grow several undirected offers and keep the one that moved most)
would have done.
The reply carries moved
(Engine::moved_along), and B’s strip prints moved in amber:
grittier by 1.8σ. Below half of REACH_FLOOR (AIM_SAID, in perform.js:
the line a verified half must clear to be said to turn that way), it says the
walk did not get there, not grittier: this walk found no way there, and
invites another turn.
The census. and the walk count were chosen by
make offer-census (offer_census): 16 presets (every third in the bank),
every control PERFORM draws as a search control on each (verified, as the app
wires them: Grit on 14, Body on 14, Space on 12, and a few of the rest), turned
both ways, two seeded offers of 20 steps a walk per arm, the same seeds in
every arm so the arms are paired and is the Offer pad’s walk
itself. The session is a filled pool (its standardizer) with a taste taught by
the synthetic listener search_health climbs toward, which dislikes noise; the
prior rows are the same session before any fit. A move counts when it is at
least REACH_FLOOR the asked way; σ is the median verified
reach of a control that does reach, on the same presets.
| walks | ≥ floor, taught | ≥ , taught | median , taught | ≥ floor, prior | Grit up ≥ floor, taught / prior |
|---|---|---|---|---|---|
| (Offer pad) | 22% | 13% | +0.92 | 23% | 7% / 14% |
| 26% | 16% | +0.84 | 31% | 14% / 35% | |
| 34% | 23% | +0.74 | 35% | 17% / 35% | |
| 39% | 22% | +0.63 | 36% | 25% / 32% | |
| 40% | 23% | +0.62 | 38% | 32% / 32% | |
| 32% | 18% | +1.72 | 41% | 17% / 32% | |
| (shipped) | 46% | 28% | +1.33 | 53% | 46% / 64% |
| 48% | 28% | +1.18 | 54% | 50% / 71% |
(192 offers per arm. is the offer’s expected utility less the patch it grew from; the pool’s spread of is 0.72.)
Reading it: the tilt buys most of what it can by . Past that, more aim buys a few points of the floor, none of , and a lower , so 1 is the smallest at which the curve has flattened. The walks do more than the tilt for the control that needs them most. Grit turned up went from 7% (Offer pad) to 17% with the tilt alone and 46% with three walks, after a taste that dislikes noise, and from 14% to 64% before any fit. Continuing without the tilt () does less than continuing with it (32% and 41% of all asks, against 46% and 53%). The taste cost is bounded: every arm’s median offer still climbs .
Some asks have nowhere to go, and no changes that: Grit down and
Space down moved in none of 28 and 24 trials, because a patch with no
noise sits at the floor of flatness and one with no tail at the floor of
tail_ratio. B says not smoother then, which is the truth. Space up
reaches 29–45%; the instrument asks for it only when the release graft did
not reach.
What it costs in time. The tilt costs no render; the extra walks do.
Timed natively on a shared four-core machine (offer_census --timed, each
offer from a fresh memo holding only the patch it grew from, as in the app),
one tilted walk took 5.1 s on average against 5.1 s for the untilted one, and
the shipped offer, which walks again only when it has not arrived, 15.6 s
against 6.0 s for the Offer pad’s. The instrument counts the seconds in B
while it grows. Growing it on the render farm, and starting both ways at
pointer-down, would take the wait off the player’s hands; that belongs to
the farm’s walk path and is not built here.
Whether it holds the engine. It does not. A walk is a chain of steps, and
a step costs at most one phrase render (about 0.15 s natively, 0.3 s in wasm
under node on the machine this was timed on, offer_cost), so the engine
worker runs an offer or a drift one step at a time and answers the player’s
picks, Keeps and plays between steps. An offer you did not ask for (the spare
grown while you play) gives way to one you did. Cutting a walk up does not
change what it finds: for a seed, the offer is the same tree whatever the step
sizes (the walk is the same code, run in pieces), and what you do between
steps does not move it, because it keeps the target it began on and draws
from a generator of its own.
Before any evidence: VetOnlyFitness
With no posterior there is no utility to climb, and the first version of PERFORM returned nothing for drift and offers until the player had made picks. But the posterior before any evidence is not undefined. It is the prior.
With a zero-mean prior on and a single style, the prior expectation of
utility is for every patch, so the target is
itself. VetOnlyFitness writes that down, with the vetting
gate kept:
At an unvetted term is down-weighted by , so is restricted to listenable patches, to any precision that matters. The proposal distribution is the plain grammar prior too, since the taste tilt needs a posterior.
Every drift and offer reply carries whether it was taste-directed
(Engine::has_taste), and the instrument says which: the line under Wander
reads drift · gliding · no taste yet while a grammar-drawn drift glides, and
drift · gliding once there is a taste to walk toward. Wiring the named
controls needs no taste at all, only a standardizer.
The B slot
An offer is heard through a second LivePoly in the same AudioWorklet. It
receives every note-on, note-off, bend, glide, unison, and arpeggiator message
the first one does, and notes held when it loads are replayed into it, so it
joins a chord already sounding. It renders continuously while loaded, so its
envelopes and tails are in step with A when the mix moves.
The mix is equal-power, with the mix position smoothed per sample:
The one-pole has a time constant of 500 samples, about 10 ms at 48 kHz, so a Peek is a gesture rather than a click. The gains satisfy , which holds the summed power constant when A and B are uncorrelated. When they are nearly identical the sum rises by up to 3 dB at , since . An equal-gain law would have the opposite error, flat for identical sources and 3 dB down for unrelated ones. An offer is a different patch, so the equal-power error is the one taken.
B is loudness-matched. Its makeup gain is the one
loudness normalization computes for the offer,
TARGET_LUFS minus the phrase’s measured lufs_before, so the offer’s standard
phrase plays at −18 LUFS. The gain is clamped only to the range the voice
accepts (MAKEUP_MIN_DB to
MAKEUP_MAX_DB, −24 to +60 dB, in auracle-wasm’s live.rs). It is the same
makeup every live patch gets (live_makeup, in level.rs). It used to be
clamped to ±12 dB, which left 43% of a fresh pool outside the clamp; a leveler
on the summed polyphony now catches a held note that grows louder than the
phrase predicted. Without the match a crossfade would mostly compare levels,
and the louder side reliably wins. The match is made on the standard phrase at each patch’s own
settings; a named control turned on A afterward changes A’s level without
re-normalizing it.
Which way your taste leans
Under the model view (hold ⌥, or MODEL), each control carries its lean: how the model’s rating of the sound in hand changes as the sound moves along the control’s direction. With the sound’s standardized and the control’s unit direction, extended to all coordinates with zeros on the structural block (turning a control moves the sound, never the patch’s module counts), the lean of one posterior draw is the derivative of its utility along :
The utility is a maximum of experts, piecewise linear, so its slope at a sound is the slope of the style that claims that sound, and is chosen per draw: a draw in which another style rates the sound highest answers with that style’s . It is not the mean of one style projected on , which would describe a style that may not be the one rating this sound. Far enough along another style can take the sound over, so a lean says what happens near the sound, not along the whole control. Which style a draw names does not depend on how the styles are labeled, so no alignment is needed.
The instrument is sent the importance-weighted mean and standard deviation of
over the draws (TastePosterior::slope, through Engine::lean; in
utility per unit of moved toward the control’s high word), and draws it on
the control’s dial: an arc from 12 o’clock toward the end it leans to, its
SD interval behind it, every control of the panel on one scale. A lean
whose interval crosses zero () is
drawn as a guess, the rule LEARNING’s weights and PATCH’s worth chips use.
Before the first fit there is no posterior and perform_lean answers null:
nothing is drawn. Opening a taste file takes the posterior away until it is
fitted again from the file (a file with nothing taught in it asks for no
fit), and the leans go with it.
The lean is taken at the tree PERFORM measures, with no overrides. That tree's render is in the engine's memo once PERFORM has measured it (the measurement renders it first) or the bench has opened it, and then the lean is a few dot products per draw; a tree not in the memo costs one render, as ▶ does. A control turned since is not part of it, and it is asked again when the view comes up, the sound or the panel changes (for a sound with an AUDIO IN, a new audition clip of an input it reads too, since it is measured through it), and the posterior moves (a refit, a pick’s reweighting, a taste file opened), never per frame.
What is not done
- An aimed offer is aimed at a direction, not at a sound. A search control’s offer tilts the target along (above); it does not aim at a reference sound. Breeding a generation toward a recording is a sound of your own, which the engine has and PERFORM's offers do not use. Nor is the aim itself tuned per patch or per player: repeated turns the same way do not ask harder, and Wander’s offers are not aimed at the last control turned (RFC-002’s open questions).
- The palette doesn’t preview. A palette entry not on the panel is not
measured, so the palette shows nothing of what it would do to this sound.
The preview by prediction from the Jacobian (the first of the budget’s three
points, above) needs the unverified wiring (
wire_set) exposed to the page, and is not built. The twelve have no grafts yet, so a search control among them asks for an aimed offer only. The shipped preset wirings are the six’s, so a preset opened with a palette control on the panel plays the six at once and measures the rest. - The directions are fixed, not personal. The eighteen are the same for every player. A control along a fitted style is a different and more interesting object, and not this one.
- Most of what the player does here is logged, not fitted. Keep, Back,
Take, each offer asked for, and each control turn are recorded as
ImplicitEvents (perform_keep,perform_back,perform_take,perform_offer,perform_turn), and none of those events enters the likelihood. A Keep can mean I love this or stop drifting for a moment, and implicit signals stay out of the model until a fit using them can be validated against the explicit ones. The exception is an offer’s answer: once B has been heard for a second (HEARD_MS, inperform.js), Take, or asking for another offer, is recorded as a duel with thePerformOfferprovenance, and it counts as a pick. - Combinations are not verified. Each control is verified alone, at .
- Motion hears one note. Its axis is built from the held note’s span, with the limits that implies.
Explaining a control: what a figure measures
A control's figure is the sound in hand rendered twice, the control at its center and turned, and each render measured the way φ measures it. The lesson on filters is the same sound rendered through a lowpass from the grammar, with that filter's response taken from its own impulse; while the cutoff moves, the page plays and draws a browser lowpass as a guess until that render lands.
PERFORM's controls are directions in φ, wired onto each patch's knobs by a measured Jacobian, so no control is a fixed effect: BRIGHT turns a filter's cutoff on one patch and a detune and an envelope on another. What a control does is therefore not something the app can draw from a recipe. It asks the engine. The figure (Plan-005 task 10, RFC-006 §9, ADR-012) draws what came back, and nothing else. This page is how.
The engine side lives in auracle_features::explain (the portrait),
auracle_grammar::lowpass_response (the lesson's filter) and
auracle_wasm's explain.rs (the bindings explain_render and
lesson_filter). The page side is apps/web/explain.js; perform.js's
explainOf says what to render, and words.js says what was measured.
What a figure asks for
When a player asks about the control at panel position (? with the
pointer over it or keyboard focus on it, its ? chip, or a long press on a
touch screen), PERFORM hands over the performed state twice, as knob overrides on
the tree it is playing (explainOf):
- made: every knob as it sounds now, but with control at
(
soundingOfwith that control's value zeroed); - turned: the same with control at , where the player has it when , else a full turn toward the end its wiring opens to (high, unless only the low half was confirmed on real renders). Absent for a search control and for one not measured yet: nothing turns them.
The worker answers explain in its soon lane, holding the floor, one
render per turn with the player's gestures served between (breathe). Each
render is WasmEngine::explain_render(tree, overrides, k): the performed tree
featurized exactly as the pool is (featurize: render, vet, loudness
normalization, φ), then its portrait, and where it measures on control
's unit direction in σ (along, the same dot product as a wiring's
position). A render that does not vet answers {error}.
Replies are cached by the page under the control, the tree, both override
sets and the wiring's words (measured or not, a search control or not, the
knobs it turns), so switching back to a control costs nothing; the page does
not read along, so the model's revision is not in the key. One request is
out at a time: a key asked for while one is out is not queued, and when the
reply lands the answer is built again and asks for whatever its control is
then, unless that control is still turning. An open figure follows its
control: a turn, once it has rested for 300 ms (FOLLOW_MS; a reply that
lands mid-turn and the page's 400 ms check both leave a turn in progress to
it, and a different control switched to asks at once), a new measurement or
a drift changes what explainOf returns, and the figure asks again. Measured
in the browser, a three-second drag with an answer open sends at most two
requests, plus one for each time the page itself stalled for longer than the
rest, since a loaded page sees those gaps as rests
(explain.spec.js). While they render, the last figure stays, dimmed, under
measuring….
The portrait
Everything is measured on the normalized render (the buffer an audition
plays), so two portraits compare as the model hears them: loudness matched,
shape against shape. The constants are in auracle-features's explain.rs.
| Field | What | Drawn by |
|---|---|---|
bands | The long-term spectrum: every sounding frame's power (2048-sample Hann frames, hop 1024, as φ's), in 40 bands log-spaced from 35 Hz to 14 kHz, each the mean power density over its edges, in dB re the loudest band, floored at −60 (BANDS, LO_HZ, HI_HZ, FLOOR_DB) | BRIGHT, BODY, WARMTH, AIR |
held | The held note's spectrum bin by bin up to 4 kHz (HELD_HI_HZ), C4's first fifteen harmonics and what lies between them, dB re its loudest bin | GRIT, BITE, LO-FI |
onset | The first note's level in φ's 4 ms attack windows, every 2 ms for 400 ms (ONSET_STEP_S, ONSET_S), re the note's peak | SNAP, ROUND |
level | The phrase's level every 50 ms (LEVEL_STEP_S), dB re its RMS, with each note's gate (notes) | SPACE, DISTANCE, HAZE, PUNCH, THUMP, HEFT |
bright, loud | The held note's spectral centroid (Hz, magnitude-weighted as φ's) and frame level (dB re its loudest frame), frame by frame at a 512-sample hop from its onset to its gate closing | MOTION, THROB, SWAY |
facts | φ's own coordinates of the render in their units (Facts) | the sentence |
bands is a face's band layout (BANDS, LO_HZ, HI_HZ and FLOOR_DB
are face.rs's FACE_*, one definition, and band_edges_hz is face.rs's),
so a figure's spectrum and the sound's face line up band for band: the
portrait carries the render's own face too (Portrait::face,
Face::of_f64), which a_sine_lands_in_its_band holds to the bands within
a face's 0.5 dB step. The page draws the face (vessel.js drawVessel,
whitened against the bank's faces) for BRIGHT, BODY, WARMTH and AIR and the
lesson's shape, the as-made one dashed by its outline; with under four faces
in the bank there is nothing to whiten against, and it draws the spectrum
itself, which the lesson then calls "its spectrum". response_bands keeps
its own band weighting, since an impulse response's transform is not a
face's frame length.
Facts reads φ's raw audio coordinates back into units a sentence can say:
centroid_mean, rolloff_mean and zcr_mean from φ's log axis to Hz;
attack_s to ms; crest and high_ratio to dB; tail_ratio to the last
300 ms against the phrase in dB (floored at −60, φ's own floor); rms_mean to
dBFS; bass_fraction to %; held_centroid_std from φ's log axis to
octaves (one unit of that axis is the span from 20 Hz to Nyquist, about
10.1 octaves at 44.1 kHz, so it is multiplied by
); and the three motion bands from φ's
of a variance to a standard deviation. That deviation is not in
octaves alone: each band sums the variance of the held note's brightness, in
octaves, and its level, in doublings (6 dB), floored at 0.01, so the sentence
gives it as a number with both named, and its floor as none.
facts_read_phi_in_its_units pins the inversions to Hz, ms, dB, %, octaves
and the bands. flux_mean stays the index φ keeps, 0 to 1, said as one.
What each figure draws
Each control draws the measurement its direction is made of first, made
dashed and turned lit (words.js FIGURE_OF), and its sentence says that
coordinate made then turned:
| Control | Figure | The sentence's measurement |
|---|---|---|
| BRIGHT | spectrum, and the difference band by band (±24 dB) | the center (centroid_mean) |
| BODY, WARMTH, THUMP | spectrum; THUMP the level | the share below 250 Hz (bass_fraction) |
| AIR | spectrum | where the top rolls off (rolloff_mean) |
| SNAP, ROUND | the first 400 ms, each 90% crossing ticked | the attack (attack_s) |
| PUNCH | the level | the peaks over the average (crest) |
| HEFT | the level | the frames' level (rms_mean) |
| SPACE, DISTANCE, HAZE | the level, the last 300 ms shaded | the tail (tail_ratio) |
| MOTION | the held note's brightness or level, whichever the turn moved more (an octave against 6 dB, φ's currency) | the brightness's wander (held_centroid_std), in octaves |
| THROB, SWAY | the same | the 2–8 Hz or 0.5–2 Hz band (motion_mid, motion_slow), brightness and level together |
| GRIT, LO-FI | the held note's harmonics | flatness, in dB (flatness_mean) |
| BITE | the held note's harmonics | the frame-to-frame change (flux_mean), an index from 0 to 1 |
The sentence's second half is the wiring's: the knobs it turns on this sound. For a search control it is the panel's own words (nothing here turns it, and turning it asks for an offer), with the made measurement alone.
What moves. A figure draws its turned measurement in along its own axis
and holds: band by band from the bottom over two --d-move, or along time at
the phrase's own pace. MOTION's runs the held note's measured track at its
real rate, looping, while it is open. Nothing is interpolated between the two
renders: no frame of any figure shows a setting the engine did not render.
Under reduced motion every figure is drawn whole at once.
The lesson's filter
The lesson renders the sound in hand with lesson_filter, one call in soon
per render: once as it is (no cutoff: the first step's shape and sound), and
once for each cutoff the player drags to, one request out at a time with the
latest cutoff waiting for it.
- The filter is the grammar's own: an
AudioNode::Filterof kindSvfLpat the knobcutoff, resonanceLESSON_RESONANCE(0.345, a damping of 1.41 on quiver's filter: Butterworth, flat below the cutoff and 3 dB down at it), inserted on the output below any stereo effect that ends the chain (insert_at_output, the walk the Bright and Body grafts already use). The patch itself is never changed. - A patch with no room for one more module (the grammar's size ceilings:
16 of 150 pool draws of the shipped seed, none of the 62 presets) gets the
same filter after the whole voice instead (
placement: "after"):lowpass_applyruns quiver'sSvfover the phrase's raw render, which is then vetted and normalized asfeaturizedoes every render. Nothing keytracks it there, so its corner is the held note's on every note, and the lesson says so.a_patch_with_no_room_gets_the_filter_after_itpins it. Measured byexplain_lessonon those draws at a cutoff of 0.5: 133 rendered inside, 16 after, 1 silent. - When a render fails, the reply's
errorstands for its portrait (no_tree,silent,vet). The page says why in a sentence (words.jslessonTrouble), draws no curve or readout for that cutoff and plays nothing under through it; a cutoff that failed may render at another, so a drag still asks. When the sound itself fails, or its tree cannot be read, the lesson asks nothing more. - Its cutoff is the knob's corner on the held note,
cutoff_hz: quiver's20 · 1000^xHz, which keytracking (KEYTRACK_AMT, half an octave per octave from C4) leaves unmoved on C4. PATCH prints a filter's cutoff knob in the same unit. - Its response is the impulse response of quiver's
Svfdriven as the compiler drives it (lowpass_response: the cutoff knob as is, the resonance knob through the compiler's mapping, keytracking at C4), 8192 samples, in the portrait's 40 bands (response_bands).lowpass_response_is_the_compiled_filterholds it to a compiled filter's gain on a sine at C4 within 0.25 dB, for four cutoffs and resonances. - Its sound is the filtered render's audition at the level every audition
plays at (
audition_pcm). Every render is normalized to −18 LUFS (TARGET_LUFS) as the pool's are, so a lower cutoff is heard darker, not quieter, and its spectrum is drawn against its own loudest band. - Before it lands, a guess. A render takes 0.2 to 0.5 s here and up to
2.4 s on the reference Air, one at a time, so on the filter's step the page
loops the plain render through a Web Audio
BiquadFilterNode(lowpass, Q −3.01 dB, which is Web Audio's unit for a Q of 1/√2, the ButterworthLESSON_RESONANCEsets), its frequency set in the move's own task fromhzOfKnob, the page's copy of the grammar's map (the reply'scutoff_hzis the engine's, and the spec holds the two equal). Its level is lifted by an estimate, capped at 12 dB: the plain portrait's band power, each band counted by its width and by BS.1770's K-weighting asloudness.rsapplies it, over what the browser's filter leaves of it. Measured at the output against the render on Reese and Glass Pad at six cutoffs from 100 Hz to 5.9 kHz, the two are within 0.8 dB. Its curve is the browser filter's own response (getFrequencyResponse), drawn dashed amber and marked not measured yet (ADR-012). When the render for the cutoff held lands, it fades in over the live path on a second looped source at the same place in the phrase, and its measured curve fades in over the guess (at once under reduced motion); the next move fades back. The browser's filter goes after the whole sound, with no keytracking, and the instrument's sits inside it, so the two can differ until the render lands. No guess is drawn at a cutoff that was refused, nor for a sound that never rendered.
What it costs
crates/auracle-wasm/examples/explain_cost.mjs times each binding on all 62
presets in the built package under node (V8, single-threaded, as the engine
worker is), in CPU ms, median and maximum, on an Apple M3 Max shared with
other jobs:
| Call | Median | Max |
|---|---|---|
explain_render | 207 | 462 |
lesson_filter | 233 | 475 |
a plain render and φ (farm_render) | 199 | 454 |
A portrait adds about 4% to the render it measures; a figure is two renders, about 0.4 s, and its reply is 5.4 KB of JSON (median). A cached figure opens at once.
Where the specimen and the engine differ
Prototype v2's explain.js drew each control as the effect it was in the
prototype. The app's controls are not effects, so the engine was followed:
- BRIGHT is not a filter. The specimen's figure is a lowpass response with its cutoff ("Here it cuts above 3.7 kHz"). The app draws the measured difference between two renders band by band, and says what moved and which knobs moved it: on Reese, a filter's cutoff; on another sound, something else.
- SNAP, MOTION, GRIT and SPACE were an envelope, an LFO, a waveshaper's curve and a reverb's tail. The app draws the onset, the held note's brightness, its harmonics, and the phrase's level and tail, as rendered.
- The patch with no room. The specimen's filter is a knob on its fixed chain; the app's goes after the voice on the one pool sound in ten that has no room for it, and says so.
- The lesson's filter is added, not BRIGHT. The specimen's lesson drags BRIGHT; the app's adds the grammar's lowpass to a copy, so "BRIGHT turns a filter like this one, on any sound" became a sentence from this sound's wiring, and "Done puts BRIGHT back" became "Done leaves it as it was".
- The shape is the face from four faces up. With fewer in the bank there is nothing to whiten against, and the spectrum stands in.
- The keys play the sound without the lesson's filter: the lesson's sound is the phrase, looped, through the browser's lowpass and then the filtered render; the live voices are not given the filter.
- A palette control's preview (the specimen's
macroAnswer, a recipe over the six) is not drawn: each of the eighteen is its own direction, measured when placed. - Only the controls ask for now. The specimen also asks about a bank row's liking, the pair, the map's directions and the sound's face; those wait for their own tasks, and ⌘K's entries for the shell (Plan-005 task 2).
The model's guess: the next module
For the patch in hand, the model guesses which module you would add next. It renders up to eight of the modules that could go at the output, in the structural design's order, scores each by the posterior's draws of what it adds, and leads with the one whose lower bound is highest.
PATCH starts from nothing and grows a module at a time. The guess is the
model's answer to "what next?": GUESS · FILTER at the output, with its reason
in the model's italic. It lives in auracle_session::guess and reaches the
browser as guess_plan, guess_rank, guess_skip and guess_take on
WasmEngine. The design was chosen by measurement against synthetic
listeners, in the design note docs/notes/suggest-2026-10/ in the repository;
this page is what was built from it.
The candidates
Every edit the grammar allows that adds a module and takes none away,
at three kinds of socket (guess_candidates):
- the output: a processor inserted into the wire before the amp
(
StructOp::Insertatnode), for each of the twenty processor kinds; - the root's modulation slot: a modulation source when the slot is empty,
a shaper or combiner when it is filled (
SetModatnode); - every empty socket: each of the six sources (
Replaceof aSilenceleaf).
Each must pass validate_tree (the size, depth and modulation-depth
ceilings), and no raw module count of φ_struct
may fall while their sum rises. A patch that does not sound takes sources
only: a processor over an empty socket is silent too. The seven presets
measured (three of them in the table below) have 20 to 30 such candidates,
and an empty patch has six. A deeper wire is ranked only when asked for by
its module's key (at); the note found that deeper sockets add
candidates the model rates highly by extrapolating, not ones a listener
likes more, so the guess PATCH shows unasked looks at the output, and a
deeper place is ranked only when the player asks what goes here? of a
module (its ⋯, or Q).
At the grammar's ceiling there is no candidate, and the guess says so
(full).
The score
Each candidate is rendered on the audition phrase and standardized, . The patch is , its own standardized φ, or , the pool's average sound, when it does not sound. Over the posterior's weighted draws , with the mixture utility , the gain is
and the candidates are ranked by the lower bound , best first, ties in the candidates' order. Each guess also carries the pick forecast , the same number the app shows beside every prediction with its sure word.
The top guess's lower bound can be negative. The list leads with the best bound, not with a module the model is sure will help: in the native run measured below, 12 of the 18 first guesses had a bound under zero, with forecasts from 49% to 76%. The words a guess is shown with have to allow for that.
The lower bound was the best criterion the note measured at both stages: after the warm start a listener would pick the patch with the guessed module over the patch 66% of the time, and the guess truly helps 71% of the time; after 78 picks, 70% and 78%. A random module is picked 51% of the time, and the grammar's own favorite 54%. The mean and the forecast trail the lower bound by 5 to 7 points at the warm start, where a few picks leave the posterior wide.
There is no trust region. A guess may sit far from every sound the player has heard: after a warm start that picked the three brightest presets, the first guess in an empty patch is noise, 4σ rougher than the pool's average, at 73%. The maintainer decided the lower bound is the guard for now.
Render order, and the floor
Rendering is the whole cost, so the candidates are rendered in the order the
structural design would rank them: by the posterior mean of the patch's audio
φ beside the candidate's structural φ, which needs no render (guess_plan).
A crew that stops early has rendered the likeliest.
GUESS_FLOOR (8) is how many the engine worker renders when there is no render
crew, one per turn with the player answered between them; GUESS_BUDGET_MS
(3 000) is how much rendering it spends before it ranks what it has. The
budget counts render time only, not the time the worker spends on the
player's requests between renders, and it is checked after each render, so it
can run over by one render. The note
measured the first eight in this order (its dout8) against rendering every
candidate: 66% against 66% picked at the warm start, 69% against 70% after 78
picks. On a crew the plan asks for all of them.
| Patch | Candidates | Renders, floor / all | CPU s native, floor / all | CPU s wasm, floor / all |
|---|---|---|---|---|
| An empty patch | 6 | 6 / 6 | 0.5 / 0.5 | 0.7 to 0.9 |
| Sub & Sparkle | 20 | 8 / 20 | 1.4 / 3.3 | 1.9 / 4.7 |
| Hornet | 30 | 8 / 30 | 1.4 / 5.2 | 1.9 / 6.6 |
| Ceiling, the heaviest | 30 | 8 / 30 | 3.3 / 12.2 | 4.0 / 14.1 |
One render's CPU time is a median 184 ms natively and 251 ms in wasm under
node. The plan and the ranking render nothing and cost 1 to 17 ms together.
Measured on 2026-10-01 on an Apple M3 Max that other jobs were sharing, by
crates/auracle-session/examples/guess_cost.rs and guess_cost.mjs in
auracle-wasm, after both warm starts the note used; the native run starts
each patch from a memo that holds only the patch. With no crew, the floor
fits the 3 s render budget in wasm on every preset measured but the two
heaviest
(Deadfall and Ceiling, 3.3 to 4.2 s), where the worker ranks the seven or so
it rendered in time. On a crew of six, every candidate of a preset would take
about five renders' time, under 2.5 s (derived, not measured).
Why
A guess's reason is the largest part of its gain under the style most
responsible for its sound, with and that style's
posterior mean (GuessWhy::part). The parts considered are:
- each of PERFORM's six named directions : the gain's projection on it, , named by the end word the guess moves toward ("it moves toward dark"). The projection is positive only when the move and the lean point the same way along , so the word names where the gain comes from. Summing over the direction's coordinates would not: for Bright, a centroid that rises while the rolloff falls further reads "toward dark", and a taste for the centroid alone gains from the rise;
- each structural coordinate the guess changes, ("more drive").
The largest positive part is the reason, with how far the guess moves along
it in σ; a move under 0.005σ, which would print as 0.00σ, is never a reason.
When no part is positive, no part leans the player's way, and the guess has
no reason (why is null). For a patch that does not sound, the gain
is over the pool's average, but the reason measures the move from the average
sound with the patch's own structure, so it can name only what the guess
changes, never the amp envelope an empty patch keeps.
A skip, and an undo
The ranking is a pure function of the tree, the posterior and the
standardizer, so what a player has turned down has to be remembered outside
it (GuessMemory):
- A skip keeps that module's family away from that socket, for this
patch. The model cannot tell a wavefolder from a bitcrusher: both are
one coordinate of φ,
n_drive. Skipping one and being offered the other would be the same guess again. A socket is named so that it outlives other edits:out, the output;mod:<uid>, a module's slot;in:<uid>, an empty socket;wire:<uid>, a deeper wire. - Undoing a taken guess counts as a skip. Taking a guess is an ordinary structural edit, so ⌘Z undoes it, and the tree it returns to would rank the same guess first again. When the patch in hand comes back to the tree a guess was taken on (compared by content, as patches always are), the guess is skipped. Opening a patch (another, or the same one again) or closing the bench forgets the guesses taken, so a later edit undone back to an old tree is not taken for an undo of a guess.
- Only a current guess is taken. A guess is checked against the patch as
it is when it is taken (
guess_is_current: the same edit, socket and family among its candidates now), and refused with a reason otherwise: a source ranked for an empty socket, sent after the player filled it, would wipe what they placed. The check renders nothing. - Per patch: skips are kept by the pool id the patch was opened from, for the session; an import starts with none. Keep as new carries them, and the guesses taken, to the new sound: it is the same patch the player is working on, and what is skipped after the commit is filed under the new sound.
Neither a take nor a skip is evidence. Both are logged in the implicit stream, as a revert is, and stay out of the likelihood: a skip is confounded with curiosity, and keep as new already records what the player thought of an edit.
When it is computed
- Nothing before the warm start. With no fitted posterior there is
nothing to guess from (
no_taste). - On a structural edit, a refit, or the player asking: never per knob
step. Every candidate contains the patch, so a turned knob changes every
render. A ranking carries
observations, the count the posterior holds, so a page can tell one from before a refit. - Deterministic. The plan and the ranking draw no randomness, and the memo only saves renders (a hit is bit-identical to a miss), so a seeded session guesses the same whichever order the renders land in (one random stream per consumer).
- Keyed by stimulus. A render's memo key is the content address under
the engine's phrase, and each job's persistent key begins with the render
namespace (
farm_key). A row measured under another stimulus or another build is never absorbed, so it costs a render, never a wrong φ.
The engine worker
The worker answers guess in its later lane, holding the floor (no other
long job starts while it runs; the player is answered between its steps), in
two phases:
- On a crew. It plans first: a refusal (
no_taste,full) or a guess the memo already holds raises no crew. Otherwise, where a walk crew can be had (raised on demand as a generation raises one: not while a generation or ⚡ walks; while boot's own crew is filling the pool, the guess waits for it to finish, as a generation does), it plans every candidate (limit0) and hands them out, onefarm_renderper idle worker, absorbing each result as it lands (memo_absorb, which refuses a row measured under another stimulus). It stops afterGUESS_BUDGET_MSof wall-clock time, counted from when the crew is up. It renders nothing on the engine worker. - The floor. Then it renders with
memo_renderwhat is still owed of the firstGUESS_FLOORin order, one per turn: nothing after a crew that rendered them, everything with no crew (at width 0,?farm=0or a machine with one core, the page answers the request for a crew with none).
It ranks over every candidate when a crew ran, over the first eight when none
did, and posts the ranking with the tree it ranked, or the error if the engine
failed. A taken guess is edit_structure with the guess attached
(guess_take), and a skip is guess_skip.
In PATCH
PATCH (apps/web/patch.js) asks for a guess once the bench has settled after
an open or a structural edit, after each refit, and after a skip, only while
PATCH is in sight, and never after a knob turn alone. A ranking made on a
structure the bench has since left is dropped and asked again. It shows the
first guess of the ranking:
- GUESS · REVERB over the rack, with the reason in the model's italic
(
why: it moves toward still, as your picks lean, or your picks lean toward more reverbs for a structural coordinate, or no part of it leans your way whenwhyis null), then the forecastpwith its sure word, over your pool's average whenagainstispool. When the lower bound is under zero the line ends it may not help. - The module at its socket, dashed amber: above the cable an
insertwould cut, over the empty socket areplacewould fill, under the module aset_modwould plug, with a dashed lead to that place. It is kept in the camera's view: with no room above, below the chain; with no room there, at the view's top. The catalog marks the guessed kind. - Add (a click, or Enter) sends the guess through the edit lane
as
edit_structurewithguess; a refusal (the patch changed after that guess, so it was not placed) is said as any refused edit is, at once. Skip (its ×, or Delete) sendsguess_skipand shows the next. - Refusals in words:
no_tasteandno_patchdraw nothing;fullsays no guess: nothing more fits, so a module has to come out first;unmeasuredsays no guess yet: it hasn't heard this patch; a ranking with nothing in it says every module that fits was skipped, or that none passed the safety check.
Keep as new files later skips and takes under the kept sound's id
(WasmEngine's guess_key, moved by the commit), not the id the bench was
opened from, which a later commit's comparison still plays against.
Acquisition
Which duel to ask next. The answer turned out to be “it does not matter much”.
The rules
Acquisition is selectable, because the choice is an empirical claim and
both alternatives are kept so the comparison stays runnable.
| Rule | Picks |
|---|---|
Random (default) | A pair uniformly at random from the pool |
Bald | The pair maximizing expected information gain about |
Thompson | Dueling Thompson sampling: a best-arm rule |
In the app, a pair is chosen from the sounds that have arrived while the pool
fills, at once. In a session opened with a seed in the address (?seed=)
whose pool fills at boot, it is chosen from the sounds the fill's schedule
names instead: the first four deals from the first 8, 16, 24 and 32 sounds of
the pool, every later one from all of it, so a seed deals the same pairs
while the pool fills at any machine speed. A session restored with its pool
full deals from all of it from the first
(the runtime).
The measurement below deals from the whole pool.
The measurement
cargo run -p auracle-session --example learn_synthetic --release -- --compare 20
20 seeds, 72 duels, refit every 12, against the synthetic user.
The methodology matters more than usual here, because the effect sizes are small:
- Common random numbers. Pool fill, the user’s coin flip at duel , the MCMC seed at round , and refinement seeds are all shared across arms, so only the acquisition draw differs. Without this the between-seed variance would swamp everything.
- One fixed held-out exam under a single reference scale, so arms that built different pools are still answering the same questions.
- is two standard errors of the paired difference.
Three metrics: cosine similarity to the true (↑), rank correlation on the exam (↑), and excess nats against the true model (↓).
Static pool: i.i.d. prior draws, refine_steps: 0
| cos θ* ↑ | rank r ↑ | excess nats ↓ | |
|---|---|---|---|
| random | 0.460 | 0.731 | 0.211 |
| thompson | 0.416 | 0.628 | 0.254 |
| bald | 0.484 | 0.762 | 0.199 |
| bald − thompson | +0.068 ± 0.062 | +0.134 ± 0.044 | −0.055 ± 0.014 |
| bald − random | +0.025 ± 0.058 | +0.031 ± 0.046 | −0.012 ± 0.013 |
Thompson is the one clear loser (). It is a best-arm rule: it converges on identifying the top patch, which is not what a duel is for here. Finding the single best patch in a pool and learning the shape of a taste are different objectives, and optimizing the first does not deliver the second.
BALD and uniform pairing are within two standard errors on every metric.
Evolving pool: refine_steps: 12, refinement between rounds
A static i.i.d. pool is a weak regime to conclude from on its own: prior draws are spread over feature space by construction, which is exactly where uniform pairs already achieve near-optimal coverage and an information-seeking rule has no redundancy to prune. The shipped pool is not that pool (refinement injects children near the current best, and insertion evicts the worst) so the comparison runs an evolving regime too.
| cos θ* ↑ | rank r ↑ | excess nats ↓ | |
|---|---|---|---|
| random | 0.479 | 0.694 | 0.232 |
| thompson | 0.459 | 0.583 | 0.276 |
| bald | 0.465 | 0.707 | 0.232 |
| bald − thompson | +0.006 ± 0.068 | +0.124 ± 0.066 | −0.044 ± 0.017 |
| bald − random | −0.015 ± 0.055 | +0.013 ± 0.048 | −0.000 ± 0.014 |
Same answer. Thompson loses; BALD and uniform pairing tie on every metric.
Why Random is the default
Measured in both the regime the product starts in and the regime it evolves into, uniform pairing is indistinguishable from BALD. A rule with four tuning constants that ties a rule with none should not ship on a tie.
Two supporting reasons survived checking, one did not:
- The
info_gainBALD reports had zero consumers in the frontend. - BALD’s repeat avoidance is real but barely needed over a pool this size that
uniform pairing already samples without repeating (gated by
duels_spread_over_candidates_not_just_pairs). Randommakes every duel an unbiased calibration sample rather than one in ten, a virtue that holds regardless of which rule learns faster.
A retraction worth recording
One earlier justification was withdrawn for a bad reason, and the record should say so.
The “pool grows and concentrates” argument was dismissed on the grounds that insertion caps the pool, but a capped size is not an unchanging spread, and evicting the worst member could in principle concentrate a pool. Dismissing the concentration argument because it was unmeasured, while treating a measurement from the other regime as decisive, had the burden of proof backward.
The evolving run above is that measurement. It happens to show the concentration never materializes, but the default rests on the measured tie, not on the dismissal.
What Bald is still for
It decisively beats the best-arm rule, so it is the right thing to reach
for if acquisition ever needs to do something uniform pairing cannot. Its
levers are SessionConfig fields, with defaults in engine.rs:
| Lever | Config | Default |
|---|---|---|
| Bias duels toward patches the player will enjoy auditioning | duel_utility_weight | 0.1 |
| Bound how often one patch reappears | duel_exposure_penalty | 0.25 |
| Avoid re-asking a pair | duel_repeat_penalty | 0.5 |
| Soften the selection | duel_temperature | 0.6 |
| Reserve unbiased probes | duel_check_every | 10 |
All measured, none currently worth the tie.
A correction worth recording
An earlier version of the BALD rule scored its enjoyment term on unnormalized utility and used an absolute softmax temperature of 0.05 nats.
Both are scale bets, and both lost. The enjoyment term grew without bound as the posterior sharpened, and ran to , so the “softmax” was an argmax. That version was measurably worse than random, and it is the version an independent replication measured.
It also produced the duel repetition observed in the running app: the same defect, seen from two directions. Fixed, BALD ties random, and the tables above are the fixed rule.
The general lesson: a temperature with units of nats is a bet about the scale of the quantity it divides, and a quantity that grows as a model sharpens will eventually break that bet.
Where uncertainty would earn its keep
The design’s argument for acquisition is that ’s posterior uncertainty lets early sessions ask informative questions (duels the model cannot rank) while a confident model mostly serves sounds the player will pick. That remains the right frame, and it is also the frame in which the measurement says the informative-question machinery is not currently paying for itself.
At session horizon (tens of duels, and a pool of 40 to 48 patches kept spread by its own dynamics), there is not enough redundancy in the question set for an information-seeking rule to exploit. A much larger pool, or a much longer session, is where the tie would be expected to break.
Safety
Evolution will generate pathological patches. Five layers make that acceptable rather than dangerous.
Randomly composed DSP graphs produce screaming resonance, silent duds, NaN-poisoned recursive state, and astronomically high pitches. None of that is hypothetical and none of it is rare. Safety is layered because no single check covers it.
The layers
| Layer | Where | What |
|---|---|---|
| 0 | quiver | Denormals flushed at graph scatter; NaN-latch protection on stateful modules; soft-clipped filter state; cycle detection with named paths; non-finite module outputs zeroed at scatter |
| 1 | auracle-features | The vetting gate. Every ▶ plays a pre-rendered, vetted, normalized buffer. The live keys play an edit before its check, and a failed check mutes them (below) |
| 2 | auracle-session | Quarantine → QUARANTINE_FITNESS = -50.0, so the search learns to avoid the region |
| 3 | auracle-grammar | Mandatory … → DC blocker → VCA → Limiter → StereoOutput; parameter ranges bounded away from pathology |
| 4 | tests | ValidationMode::Strict as a property-test oracle over grammar output |
Layer 0 is a dependency’s, and the one Auracle has least control over, which is why it was audited and why two bugs found there are recorded below.
Layer 0: quiver
Verified 2026-07-28 and hardened where needed. quiver was already substantially prepared for this use:
- Denormals flushed at graph scatter.
- NaN-latch protection on stateful modules: filters, limiter, and EQ sanitize inputs so non-finite samples cannot poison recursive state.
- Soft-clipped SVF state.
- Cycle detection with named-path errors.
- Actionable
PatchErrors (InvalidPortlists the available ports). ValidationMode::Strictfor typed connections.
Two gaps were found and fixed upstream:
Q198: permanently latched NaN, and an infinite loop on the audio thread.
Oscillator phase accumulators latched NaN forever on non-finite pitch, because
NaN − floor(NaN) is NaN. Worse, the while phase >= 1.0 wrap style used by
Wavetable and FormantOsc spun the audio thread forever on an infinite
increment, and voct_to_hz overflows at extreme V/Oct, which the grammar can
reach. An infinite loop on the audio thread is not a glitch, it is a dead tab
with no error message. Fixed with a shared wrap_phase that recovers
non-finite values.
Q199: cross-module poisoning. Graph scatter now zeroes non-finite module outputs, so one module’s NaN or Inf cannot poison another module’s recursive state through the routing buffers. Containment at the graph boundary; per-module input sanitization remains defense in depth.
voct_to_hz is clamped to ±32 octaves as of quiver-dsp 0.3.0 (Auracle pins
0.4.0), so the overflow Q198 recovers from can no longer be produced by pitch
CV at all; what remains at the clamp is finite aliasing garbage, which the vet
gate quarantines like any other.
Layer 1: the vetting gate
No candidate is ever auditioned unvetted. Every ▶ plays pre-rendered, LUFS-normalized buffers, and the standard-phrase render doubles as a health check.
Thresholds, the measurements that confirmed them, and the ordering that makes the whole thing work are in The vetting gate.
The structural point: one render serves the health check, the features, and the playback. That is what makes “no audition is unvetted” true by construction rather than by discipline: there is no second path that could skip the check, because there is no second render.
The live keys play an edit before its check
The gate stands between a patch and every ▶. The keys are another path: the
live voices (LivePoly, in the AudioWorklet) play the sound you're playing,
and they take an edit before the engine has rendered and checked it, so that
a turn is heard as it is made. What reaches them early, and what happens when
its check fails:
| Edit | Reaches the voices | A failed check |
|---|---|---|
| A knob, the octave, a wavetable position | At once, as a parameter written into the running voices | The voices are muted until a check passes, and the alarm says so |
| A structural edit (a module placed, removed or rewired) | As a new tree, as soon as the engine has made the edit (tree_json), at the previous tree’s makeup until the check’s reply corrects it | The same mute |
| An undo or a redo; PERFORM’s Take | As a tree before its render, at the makeup it was measured at (an undo or redo lands on a tree measured when it was made, edit_known_makeup; a Take on the offer, measured as it grew). An undo or redo of a tree no longer remembered waits for its render | The same mute |
| Any other selector (a VCO’s wave, a filter’s mode) | With the check’s reply, after its render, at the makeup the render measured | Never reaches them: the voices keep the tree before it |
So an unchecked edit can sound for as long as its render takes (a fraction
of a second, longer while the engine is busy) before a failed check mutes it.
Everything the voices play passes the master brickwall on the summed
polyphony and the leveler in front of it (live.rs), which bound the level
that reaches the output, not the sound. A selector waits because only its
render measures the level it plays at: sent early, the previous tree's
makeup was off by more than 3 dB on 46% of the presets' selector changes,
up to 27 dB hot (crates/auracle-wasm/examples/selector_makeup.rs).
Layer 2: fitness shaping
Quarantined patches do not just get hidden; they score in the search target.
Hiding alone would leave the search spending its budget in a region it cannot observe is bad, repeatedly rediscovering the same pathology. Shaping the fitness makes avoidance something the search learns.
Layer 3: the live path
The keys play an edit before its check (above), so what bounds the live path is the patch itself. The compiled output chain is mandatory:
The limiter is compiled in by auracle-grammar, not optional and not a
setting. It sits on top of quiver’s scatter sanitization.
And parameter priors are bounded (resonance max 0.85, delay feedback max 0.7, V/Oct into an audible band) so the grammar cannot express the most degenerate settings. That is categorically better than generating and rejecting them: there is no pathological region for the search to keep sampling.
Layer 4: Strict as an oracle
Grammar output is compiled with ValidationMode::Strict in the test suite.
Because the grammar is typed, a SignalMismatch is by construction a bug in
the grammar, so Strict is a property-test oracle: sample terms, compile
all, any error fails the test with quiver’s actionable message.
Patches are wired in Warn mode, with an allowlist test pinning the warning
classes the compiler uses on purpose. See
Validation mode: two different modes for
two different questions.
Non-audio safety
The gates above are about sound. Two others are worth listing here because they are the same kind of thinking applied elsewhere.
Escape everything a user or a file can name. renderBank once built rows
by interpolating r.name straight into innerHTML. Renaming a patch to <img src=x onerror=…> executed, persisted into the saved bank, and re-fired on
every reload. The same sink is fed by imported patch JSON, so opening a
shared patch was script execution in the recipient’s session. Every
interpolation of a name is now escaped, including the two that land in
attributes, and textContent is preferred wherever the node allows it.
Refuse to measure a term that cannot be interpreted.
FeaturizeError::OutOfDomain rejects a term with a knob outside its range
before the render, because its would be a lie and a row the model
cannot interpret must not enter the log. This is the gate the
1e30 sentinel got past
when it did not exist.
What is not defended against
- A malicious patch file can name things and set parameters. Names are escaped and parameters are domain-checked and repaired, so the blast radius is intended to be zero. It is still a parser handling untrusted input, and that is always a claim rather than a guarantee.
- Hearing damage is mitigated (limiters, LUFS normalization to a peak ceiling, vetted auditions, a mute when an edit's check fails), but the output level is ultimately the player’s. An edit at the keys plays until its check fails, which is a fraction of a second or longer (above). Nothing stops a limiter-bounded signal from being turned up.
- Denial of service via a huge patch is bounded by the module and depth ceilings, not by a time limit. A 24-module patch with granular and reverb is legitimately expensive.
- The audio thread can still be starved by the rest of the machine. That is a browser scheduling matter and outside what the engine can fix.
Persistence and migration
The observation log is the source of truth. Everything else is a cache, and saying so is what makes migration tractable.
What is stored
| Object | Contains |
|---|---|
SessionState | The whole session: pool, bank, names, log, posterior, generation, forecasts, and the audition clips (the session's, and each input slot's own) |
BankEntry | A patch’s tree (with any CAPTURE take in it), id, origin, name, pinned flag, and unjudged when it was kept as new and has not been in a pick yet (left out when false). Renders and features are re-derived on import. A sound kept aside because its only take couldn’t be read is a bank entry too, written back JSON-equal to what was loaded |
ObservationLog | Every Feedback with its session index and raw by name |
Profile | The log plus the standardizer: the portable unit |
TastePosterior | A snapshot, kept with the session (SessionState::fit: its draws, at most 500, and their weights, with how many observations it was fitted on), so a restore fits nothing. Recomputable from the log, and recomputed when the log or the standardizer comes back changed |
OwnSound | A sound of your own: its name and the raw of the coordinates its file measures, by name. Never the audio |
Two of these choices carry the design.
BankEntry stores the tree, not the features. Trees are the source of
truth; renders and are re-derived on import. That is what lets the
feature extractor change without invalidating a saved bank, and it is why
restoring a large session costs real work rather than being instant.
The log stores raw by name. Not standardized, and not by index. Both halves of that matter, below.
A profile is the log plus its standardizer
pub struct Profile {
pub log: ObservationLog,
pub standardizer: Option<Standardizer>,
}
is only meaningful relative to the standardization that produced it, so the two persist together or not at all. A log without its standardizer is a set of numbers whose units have been lost.
The posterior itself is not in a profile. It does not need to be: it is recomputable from these two, and shipping a fitted model would mean shipping something that could disagree with the evidence it was fitted from.
A saved session does keep it, as a cache: a refit costs seconds on a slow laptop, and a restore that refitted held the first pair's sounds behind it. It is installed only when the log and the standardizer came back exactly as they were saved: a migration, a repair or a merged session sets it aside, and the session is fitted again after it loads. So it never disagrees with the evidence it is restored beside. A session saved before it was kept loads the same way, with no posterior until that fit.
Names, not indices
FitSet::build projects a stored log onto the current feature names,
matching on the name. The rule is same name ⇒ same coordinate, and anything
unmatched is left at the new standardizer’s mean, which standardizes to zero
and means “this vote says nothing about that axis”.
That is the honest imputation, and it is why by-name storage is worth the
bytes. By index, a feature-set change would silently re-interpret every
historical vote: coordinate 12 was held_centroid_std yesterday and is
mod_density today, and every vote ever cast would now be a claim about a
different thing.
Three kinds of change, and the one that fails silently
Dropped coordinate. size and n_mix were removed to break exact linear
dependencies. Drop them from the migration too; nothing is lost that was ever
usable.
Changed units. These have to be converted, because a value silently carried across a unit change is worse than a dropped one: it is evidence pointing the wrong way. The conversions applied when the audio features moved to the log axis:
| Coordinate | Conversion |
|---|---|
centroid_mean, rolloff_mean, zcr_mean | Recover the frequency from the linear-Hz fraction, re-map onto the octave axis. Exact |
centroid_std | The spread of a linear quantity becoming the spread of a log one. No exact inverse for a spread, so the delta method: the local derivative of the axis map at that observation’s own centroid. First-order, and honest about it |
crest, tail_ratio, attack_s | Now logged. Exact |
Renamed coordinate, the silent failure. When n_delay became n_time,
by-name matching would have found no n_time in any historical row and imputed
it at the mean for every vote ever cast. That reads as “this player has no
opinion about delays” rather than as a rename, and nothing anywhere would have
reported a problem.
RENAMES carries the value across, and in this case it is exact rather than
a convenience: n_time counts delays and granulators, and no observation
predating that wave can contain a granulator, so the old n_delay count
is the new coordinate’s value for every row being migrated.
That reasoning is worth copying for the next rename. A rename table entry is only exact if the new coordinate’s extra contributors could not have been present in the old data.
Schema 1 → raw φ
The oldest logs stored standardized over a 30-coordinate feature set with no names.
Recoverable, because the profile persisted the standardizer alongside it:
inverts the transform exactly, and the schema-1 coordinate order is known
and fixed (SCHEMA1_NAMES). Then the unit conversions above apply.
This is the concrete payoff of persisting the standardizer with the log: a legacy log plus the standardizer it was written under is the raw data, just encoded. Without the standardizer those votes would be unrecoverable.
Forward compatibility in the small
Individual fields use #[serde(default)] where a default is honest:
| Field | Default | Reads as |
|---|---|---|
BankEntry::pinned | false | Sessions saved before pinning existed had no pins |
BankEntry::unjudged | false | Sessions saved before it existed held no sound protected until its first pick; every sound in them competes, as it did. Written only when true, so a session with no such sound saves byte for byte as before |
TastePosterior::weights | empty | Uniform: posteriors written before reweighting existed were uniform |
Forecast::provenance | Duel | Every forecast already on disk was a dealt duel, which is what Duel means |
TasteConfig::recency_half_life | None | No forgetting |
Each of those is a case where the default is correct history, not merely a value that parses. That is the bar for adding one: if the default would misrepresent what an old file meant, it needs a migration instead.
Where the browser keeps it
IndexedDB, under the page’s origin. No account, no server, nothing transmitted.
Consequences worth stating in a reference: the hosted build and a locally-served copy are different origins and do not share storage; clearing site data destroys the session; and there is no server-side copy to recover from. The only backup of the taste is a downloaded profile (⌘K’s Download your taste command).
Restore goes sound by sound
Restoring re-measures the saved bank, which is the single most expensive thing
the app does on load. It is one entry at a time in bank order
(import_session_deferred_v2 → bank_absorb per entry → restore_finish),
with fill_progress (“recalling 12 of 40 sounds…”) after each:
- On the farm while a farm worker is ready, as the initial fill runs. See
The web runtime. The first eight entries
(
FARM_AUDIO_AHEAD) are rendered with their audio whatever the cache holds; the rest are read from it where it has their rows. - In the engine worker otherwise (
?farm=0, a machine too small for a farm, or none ready yet), with a yield to the message queue between entries, so the bar moves and requests are answered while it runs. Each entry is read from the render cache when it has the entry's row, and rendered and written back otherwise. A farm worker that reports ready later takes the rest.
The cache has a row for what a farm worker rendered and for what an earlier restore rendered in the engine worker, and for nothing else (see what writes a row), so a sound that joined the bank in play is rendered again by the next restore.
In the engine worker it was one synchronous call (import_session_checked)
that re-rendered every entry, read nothing from the cache and posted nothing
until it returned: 13.6 s for 40 sounds on one unchanging line in Firefox on
an M-series laptop, and minutes on a CPU four or five times slower. That call is kept only for a binary without
the deferred surface. Whichever path runs, the bank comes back as
import_state builds it, in its order (deferred_restore_equals_import_state).
The persistent render cache
is a pure function of , which is the determinism contract, so a featurization stored once can be replayed instead of re-rendered. Without that, every reload re-renders the whole bank from nothing: the app’s 40 sounds at ~0.5 s each, for numbers the machine computed yesterday.
Farm workers consult an IndexedDB store (auracle-renders) before rendering and
write back every render they make. A restore the engine worker runs itself, with
no farm worker ready, reads and writes the same rows under the same keys
(bankPass in worker.js), so a session restored with no farm comes back from
the store the next time, and so does one whose farm wrote its rows. The engine
reports the hit rate per wave, and per restore it runs itself, into the app’s
own log.
What writes a row
Only those two: a farm worker's render (a fill's draw, a restore's entry, a
guess's candidate), and a render the engine worker's own restore makes. What
the engine worker measures anywhere else goes into its memo and no further: a
fill with no farm (fill_step), a generation's or ⚡'s children (walks
return their child, not a row), a kept edit, an opened patch file, a preset
taken in. Those are rendered again by the next restore, which writes their
rows then. A row for each would make every return a read; it is not written
today.
The engine worker opens the store once at boot, before any farm worker is handed the phrase: it creates the store on a first visit and stamps it with the namespace (below), so the farm workers’ own opens only read it. When each farm worker created and stamped it, their writes queued behind one another, and a first visit at six workers waited about 1.5 s longer for its first sounds. The farm waits two seconds at most for the stamp, which takes about 50 ms; past that, its workers open the store themselves, as they did before.
The key is not enough
render_key addresses , which is everything
depends on given a fixed featurizer. It hashes the inputs, and a
change to the normalizer or to a descriptor’s formula is a change to the
function: the same key would then name a different measurement.
RENDER_EPOCH (3, in auracle-features’ cache.rs) is that missing
coordinate for the featurizer. QUIVER_DSP_VERSION (0.4.0, beside it) is the
same coordinate for the DSP library every render calls into: a quiver release
can change a sample with no line of Auracle changing, as 0.4.0 did for Pluck and
Delay. cache_namespace combines the three: the epoch, the quiver version, and a
hash of the phrase spec without its audition clips. A clip is in the
render_key of a patch that listens instead (audition
clips), so a new clip never clears the store.
A namespace mismatch orphans every stored row at once, which is the only
correct granularity: a cache whose invalidation is anything less than total will
one day serve a number from a featurizer that no longer exists. Bump the epoch on
any change to a coordinate, to loudness normalization (including
PEAK_CEILING and TARGET_LUFS), to the vetting thresholds, or to the compiler’s
term → module mapping. Move QUIVER_DSP_VERSION with the quiver dependency: a
test reads Cargo.lock and fails while they disagree. When in doubt, bump: the
cost is one cold boot.
Every row is stored under a key that begins with its namespace (farm_key: the
namespace, then render_key), so a row written under another namespace is never
a hit. The store’s own stamp is not enough for that: it is checked only when the
store is opened, and a tab still running an older build goes on writing its rows
after a newer tab has cleared and re-stamped it.
A hit is checked rather than trusted: pre_featurized re-derives the content
address from the tree the engine holds at that index and drops the row if it
disagrees.
Two deliberate limits
Cached rows carry without samples, so a job that asked for audio
still renders. Serving it a row would move the saving onto the first patches the
player actually auditions, which is exactly where wantAudio exists to avoid it.
Its row is still written: a restore with no farm asks for no audio, and reads it.
Eviction is “clear everything” past a row cap, which is crude on purpose: an LRU needs an access-time write on every hit, turning the cheap path into a write, and what is being protected is a disk quota rather than a working set.
It lives in the farm worker rather than in the engine’s runFarm loop, whose
absorb cursor, re-issue watchdog, and speculative-work handling must not acquire
asynchrony. A cache hit is simply a job that returns fast. The engine worker’s
own restore reads its rows in one transaction before its first entry. It
waits for the stamp as a crew does (renderStoreReady, at most
RENDER_STAMP_MS), then for the open and the read, at most RENDER_STAMP_MS
again, so up to twice that in all. A store that opens after that is closed
unread, and every entry is rendered and none written back. One that opens in
time but whose rows come later costs the entries measured before they come:
those are rendered, and the rest are read.
Pins live engine-side
Candidate::pinned and BankEntry::pinned, not a UI-side set.
The engine is what evicts, so the engine must be what knows about exemptions. Holding pins in the UI beside the stars would rebuild exactly the split that made the stars-are-saves bug possible.
Capped at pool_size / 4 (Engine::pin_cap) so the pool can never be pinned
solid. That state has
no honest report, because it surfaces as insert_candidate returning None,
which the app reports as children that did not rate above the sounds they
would replace (3 were bred, but none rated above the sounds they would
replace).
Kept as new, protected until a pick
Candidate::unjudged and BankEntry::unjudged: the other engine-side
exemption, and not a pin. commit_edit (keep as new, and a shared patch
imported through it) sets it on the sound it admits, after recording the
comparison that kept it, which judges the original: the pick that keeps a
sound is not a judgment of it. It clears the first time the sound is one of
the two in a recorded pick: record_duel_as (every dealt pair, the warm
start's, and a later kept edit's comparison with it as the original) clears
both sides, and record_tree_duel (a PERFORM offer heard and answered)
clears any member whose tree is either side; the app's perform_record also
clears the sound in hand as it is in the bank, since PERFORM plays it with its
controls moved (Engine::mark_judged). A cut (record_keep(false)) is an
answer about the sound and clears it too; stars leave it set.
A PERFORM answer is recorded a window after it is given (eight seconds for a
Take, so DON'T COUNT IT can drop it), and in that window the player can take
the offer onto the bench and keep it as new. So the answer carries asOf,
the newest pool id the page had shown when it was given, and
record_tree_duel_as_of and mark_judged clear only members with an id at
most asOf (ids are issued in order). A sound kept after the answer was
given is not judged by it. The page's knowledge can only lag the engine's,
which errs toward keeping a protection a little longer.
The rule is Candidate::kept() (pinned || unjudged), and with what the
engine holds for the moment (a ⚡ seed in flight, the sounds on EVOLVE's table:
Engine::spared) it is the one insert_candidate and eviction_order_by both
apply, so retiring(), may_replace(), refine_finish, a ⚡ child's trim and
a preset's insert all pass over it alike. (may_replace() passes over the
table only for what a generation's end would take now, and lists the sounds
the table could send it to as well:
refinement.)
It is not charged to pin_cap and
the app shows no mark for it. The table is not saved: the app holds the pair
it shows and the pair of a pick in its undo window (set_on_table), and a
restore holds nothing.
Bounded at pool_size / 4 (Engine::unjudged_cap) on the flags of unsaved
members: a keep that would put one more past the cap clears the oldest (lowest
id; ids are issued in order), which stays in the pool as an ordinary member. A
saved member is protected by its pin and is not counted; unsaving it bounds
the flags again. With pins capped the same, at least half of a pool of four or
more is never kept (below four, the caps' floor of one each can protect more
than half). Every eviction also passes over any ⚡ seed in flight and the
sounds on the table (at most ON_TABLE_MAX, four), and an insert over its own
protect too (the sound a kept edit was made from). That still leaves
an insert a member to evict in any pool of thirteen or more. In the app's 40 it
leaves at least 14, more than the ten a generation's walks can put the pool
over, so there an insert always lands and a generation's end always brings
the pool back to size. A restore applies the cap
again, for a file written under a larger one.
The web runtime
Five thread kinds, one wasm binary, and a set of constraints that shaped the architecture more than any design preference did.
The threads
| Thread | Holds | Runs |
|---|---|---|
| Main | UI, Web Audio graph | main.js, never in the audio or render data path |
| Engine worker | WasmEngine (all of auracle-session) | worker.js: pool fill, fits, refinement, workbench |
| Render workers ×N | A wasm instance, nothing else | farm.js: stateless (term, phrase) → φ at boot, (context, job) → child for a generation’s walks and ⚡, task → posterior for a refit |
| Face painter | An OffscreenCanvas for each face of a sound being played, nothing else | live-paint.js: each frame of what is drawn over those faces, answered with a picture of each, where main's own paint of them is over budget |
| AudioWorklet | LivePoly | The instrument. Real-time |
Main compiles the wasm binary once, spawns the render workers, and
transfers one MessagePort per worker into the engine worker. After that
main is out of the data path, and no audition buffer ever touches the UI
thread.
No nested workers (Safari shipped those only in 16.4), no SharedArrayBuffer,
no COOP/COEP headers, no build step, and no server change. Those constraints are
why the topology is a star around the engine worker rather than a tree.
The face painter is main's own, outside that star, and holds no wasm, and it runs only where it pays. The face of a sound being played answers what is heard (the guide): each frame, main measures what is heard and decides what is drawn over each such face (each outline's points, color and glow, and the trail's fade). The glow is a blur, and a browser that draws a canvas as it is called makes it on the thread that asks: on the main thread, in Firefox at a Retina screen's scale on a fast laptop (an M3 Max), that was 7 ms of every frame, and more on a slower machine. Chromium only records the calls, and draws them after the frame's script. So main times its own canvas calls against the live faces' 2 ms a frame, first while the page is idle, before any sound: one frame of the largest face, painted on a canvas that is never shown. Over budget (Firefox), main starts the painter then, and from the first frame, for the session, the painter makes those calls and answers with a picture of each face, which main shows as it is, so a frame is shown one round trip after it is read. Inside it (Chromium), main makes them itself and a frame shows in the frame that read it, for as long as they measure inside as the faces are played (the median of the last ten frames); once they measure over, main starts the painter and hands it each face as it stands. One frame is with the painter at a time. What main times is the calls: where Chromium draws a canvas in software (on a machine whose GPU it doesn't use), that drawing after the script lands on the main thread too, and neither measurement sees it yet. Main paints the faces itself, with the same calls, where the painter cannot run.
The AudioWorklet’s hostile environment
An AudioWorklet has no fetch, no TextDecoder, no TextEncoder.
wasm-bindgen’s glue needs all three.
So the worklet is assembled as a blob with the glue inlined behind a polyfill, and raw wasm bytes are transferred into it for a synchronous in-worklet compile.
The bytes specifically, not the module: a transferred WebAssembly.Module
arrives as a silent messageerror in some engines. That is the kind of failure
that costs a day: no exception, no log, just a worklet that never initializes.
Also, no wall clock on the audio thread. LivePoly uses a deterministic
xorshift for the random arpeggiator pattern; anything Date.now()-shaped
belongs on the main thread.
LivePoly holds compiled copies of the patch, via
the same compile() path evolution uses
and with the limiter included, plus oldest-note stealing and silent-tail voice
parking. Every workbench edit re-patches the live instrument.
The stack size
wasm32’s default stack is 1 MB, and the patch compiler is recursive: every
level of Compiler::build constructs quiver modules by value before moving
them into the patch, and some carry large inline buffers. A PitchShifter
holds [f64; 4800] (38 KB), a Granular more.
A dozen-module patch overflows it, and it does so as memory access out of bounds, nowhere near the flag that caused it. It then poisons the engine:
the panic unwinds out of a &mut self binding, and every later call fails with
wasm-bindgen’s “recursive use of an object” instead of the real fault.
The fix is 8 MB, the same order as the native main-thread stack the test suite
runs on, which is why make check never saw this:
WASM_STACK := 8388608
WASM_RUSTFLAGS := RUSTFLAGS="-C link-arg=-zstack-size=$(WASM_STACK)"
It lives in the Makefile, and every build path goes through it: CI,
releases, the site build. Invoking wasm-pack directly ships a 1 MB stack and
reintroduces the bug, which is why the CI workflows build wasm via make wasm
rather than calling the tool.
Progressive boot
Boot costs ~40 renders. The bank is standardized and posted as playable at
8 patches (PLAYABLE_AT, in worker.js), which is when the first pair is
dealt. The remaining ~32 fill in
chunks that yield to the message queue between batches, so playing during
the fill is real rather than cosmetic.
Generated names are kept from the moment the bank is posted, and they don't
depend on when that was. The farm folds results in batches, so playable can
catch 8 sounds on one run and more on the next; each sound is named from the
bank as it stood when it arrived, and the first eight from each other
(NAME_FLOOR, in naming.rs, which must not exceed PLAYABLE_AT). A seed
names its pool the same at any handover and any farm width
(a_seed_names_its_pool_however_the_bank_was_handed_over).
Pairs are dealt at once, from however many sounds have arrived, so a pick
never waits for more to arrive. That depends on the machine's speed, so a
seed in the address (?seed=) deals them the way it names its pool. In a
seeded session whose pool fills at boot, the -th deal draws only from the
first sounds of the pool (8, 16, 24, 32), in the order the seed's
fill folds them in, whether or not the fill has finished by then, and every
later deal from the whole pool (DealSchedule, in auracle-session). A
deal whose sounds have not all arrived waits for them in the worker
(dealsWaiting), and every other request is served meanwhile. A deal used
to draw over however many sounds had arrived, so a seed dealt other pairs on
a faster machine; now it deals the same pairs at any speed and farm width.
An ordinary session has no schedule: the wait would cost a fast picker
seconds on a slow machine, and its pairs are shared with no one. A session
restored with its pool full has no fill and no schedule: every deal draws
from the whole pool. In every session a uniform pair is drawn over every
sound its schedule names (or every sound that has arrived), cut ones
included, and one holding a cut sound is drawn again as the next deal, so a
cut moves no pair but one holding the sound cut.
filled still fires, and everything downstream of it still runs.
fill_progress carries stage/stages, so a restore and a top-up fill each
own a labeled share of one bar.
The render farm
capped at 2 when deviceMemory ≤ 4, and a width of 1 is taken as 0:
below two workers the serial path is as fast (farmWidth, in main.js).
Override with ?farm=k or localStorage["auracle-renderers"], from 0 to 8;
0 is the serial path, which builds the same pool.
The pool is identical at every width, including 0
This is a structural guarantee, and two properties carry it:
Draws are indexed. Draw is the prior sampled under
StdRng::seed_from_u64(splitmix64(fill_seed, i)), so a term is a pure
function of . Not of arrival order, not of which
worker got it.
Results are absorbed in index order. The pool at index depends only on indices .
Together those mean a lost or timed-out job is re-issued by index with no retained state, and speculative work past the stop point is simply discarded.
Gated natively by farm_width_does_not_change_the_pool and
farm_absorption_reproduces_the_serial_pool, on (id, tree, raw φ).
Every degradation path falls back to the serial fill of the same draw
stream: a worker that never initializes, one killed mid-boot, a build-stamp
mismatch, and a browser that cannot structured-clone a WebAssembly.Module. So
parallelism costs time and never content.
Boot waits FARM_HANDSHAKE_MS (5 s, in worker.js) for a farm worker to
report ready. When none has, a crew with a worker still starting is kept: the
fill (or a restore) begins in
the engine worker, asks before each batch (each entry) whether a worker has
reported ready since, and hands it the rest from there. A slow machine
instantiating the engine in several workers at once can miss the window, and
before this its crew sat out the whole boot. The two paths fold the same
stream from the same cursor, so the pool is the same whichever took which part.
A farm run that ended because every ready worker died leaves the draws it was
handed and did not fold in issued (the engine's issue cursor past its fill
cursor); the engine worker's batches fold some, and the next farm run hands
the rest out again first, by index (draw_json), before it draws new ones.
A draw retired after two attempts (MAX_TRIES, in worker.js) is recorded,
not hidden, but in the app’s own log (window.__aurLog) rather than as a
console warning. It is a designed degradation, and the console gate holds a
clean boot to zero warnings.
Walks on the farm
A generation is refine_seeds walks, and each is a pure function of the
generation’s shared context and its own job
(refinement). So after boot the farm comes back for
them: the engine worker asks main for a crew when a generation or ⚡ evolve
from this starts, main spawns it, and the crew is reaped after a minute with
nothing to walk. Main compiles the wasm module once and keeps it, so a crew is
an instantiation per worker, not a compile: where boot had a farm (four cores
or more) the module was compiled then, and on a two- or three-core machine,
where boot fills serially, the first crew compiles it. A browser that cannot
hand a compiled module to a worker has each worker compile its own. A crew’s
width is boot’s rule with a floor of one worker wherever there are two cores
(walkWidth, in main.js), because even one worker takes the walk off the
engine worker, which then answers everything else.
The context (the tilted prior, the posterior’s draws, the standardizer, the
phrase: about 2.2 MB of JSON) goes to each worker once per generation, as one
string, and farm_walk keeps its parse keyed by that exact text. Results are
absorbed in job order, one per turn, whatever order they finished in, so
the pool is the serial path’s at every width; natively
a_generation_absorbed_in_any_completion_order_is_the_serial_one, and across
the farm’s wire farm_walks_are_the_engines_walks_absorbed_in_job_order. A
walk a worker cannot run, or a crew that never comes up, is walked in the
engine worker from the engine’s own copy of the same job.
⚡ evolve from this is one job over the same path. It draws its job from the
refine stream before it waits for a crew, so a generation asked for
during a cold crew’s handshake cannot draw first and change the child: a
seeded session breeds the same ⚡ child however warm the crew was. With no
crew the engine walks that very job (refine_from_walk). A generation and ⚡
take turns in the engine worker, and a refit waits for both, so a ⚡ child is never absorbed into a generation
at whatever job count its walk finished on. The engine keeps a ⚡ seed out of
every eviction until its walk is absorbed or stopped.
The refit on the farm
A refit is one MCMC call, about a second on a fast laptop and about four on
the reference machine for a mature log. The engine worker does only its two
ends (fit_export, then fit_install), and a render worker the MCMC
(farm_fit), handed out as a walk is. The export is the evidence as the fit
reads it, Engine::fit_job: the log standardized on the scale it is fitted
on (the standardizer refitted over the pool and the log, not yet adopted),
the model's configuration, the chain's length, the last fit's lenses, and
the seed of the generator the engine's own fit would use (the session seed
and the log's length). The render worker aligns the new lenses to those (a
search over every permutation, for every draw) and sends the draws back as
bytes. The install adopts the scale and folds in the observations recorded
since the export, as a pick between fits is.
With nothing between them the three are the engine's fit, draw for draw:
fit_posterior is the three in a row, and
a_fit_on_the_farm_installs_the_draws_fit_makes holds it across the wire.
Until the fit lands the engine answers with the posterior it has, so a deal
asked for meanwhile is dealt under it. One fit is out at a time, a
generation and ⚡ wait for it, and with no crew the engine fits the same
export itself, after the player's requests.
A saved session keeps the posterior's draws and weights (SessionState::fit),
so a restore installs them and fits nothing, when the log and the
standardizer come back exactly as saved.
Replacement waits for the end: children join the pool as they are absorbed,
and refine_finish retires the weakest members not kept (saved, or kept as
new and not yet in a pick) once, when the last
job lands or the player stops the generation. Weakest is judged under the
posterior the generation opened with, as admission is, so picks made while it
breeds (they reweight the posterior for the next pair) do not change which
children are kept.
Worker replies are load-bearing
Every workbench edit message must get a reply (bench or edit_rejected),
or the main thread’s in-flight queue deadlocks.
bench_missing is the sharpest case. The worker has always sent it when
edit_begin fails, and because nothing handled it, the optimistic “it’s on the
workbench” toast stayed on screen while the bench showed the previous patch. A
protocol whose failure message has no listener is a protocol with a silent
failure mode.
The general rule: a control that cannot act says so. The recurring bug is
silence: a ▶ with no handler, an if (x == null) return, a worker failure
nothing listened for. Prefer a disabled control with a reason in its title, or
a note; never a handler that returns.
Caching, in development
The dev server sends Cache-Control: no-store and the app version-stamps
its worker and wasm URLs. Both are needed: a browser’s heuristic cache ignores
late no-store on an already-cached module worker.
Getting this wrong gives a rebuild that appears to change nothing, or an engine and a UI from two different commits.
Verification beyond make check
UI changes are verified live in a browser (Playwright) with numeric audio
assertions (an AnalyserNode RMS, boundary-sample checks around patch swaps)
plus a zero-console-error requirement.
The engine worker's half of the messages has a level of its own: the
worker-protocol tests (make worker-test, tests/worker/) run worker.js
as it is, in Node over the built engine with no page, and hold what it answers
and in what order (its lanes, a long job giving way, what reaches the farm).
Debug hooks: window.__aur and window.__aurLog. (window.__ric is kept as
an alias for notes written before the rename.)
That combination is the only thing that can catch a class of bug make check
cannot see: the engine is correct, the UI is correct, and the message between
them is wrong.
Lineage
Auracle is the third attempt at the same idea. The first two are why this one is shaped the way it is.
| Iteration | Year | What it proved | What it lacked |
|---|---|---|---|
| neuralCompressor (C++/Arduino pedal) | 2020 | The interaction model: human-based GA, fit/unfit foot-switch, mutate/crossover knobs | The engine: EA and DSP were never implemented |
| evosynth v1 (Next.js/Tone.js + FastAPI/DEAP) | 2025 | A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage tracking | Preference persistence (ratings died each generation), topology evolution, principled inference |
| Auracle (this project) | 2026– | (open) | (open) |
v0 had the interaction but no engine. v1 had an engine, but a naive one with no memory of the user. Both of those gaps are load-bearing in the present design:
- The engine is real, and it is inference rather than a genetic algorithm. Search is Metropolis–Hastings in trace space against a Boltzmann target whose fitness is a fitted posterior, not a hand-written scoring function. There is no crossover: every child grows from one seed.
- Preferences persist. Every judgment enters an observation log that outlives the generation it was made in, the session it was made in, and, through a downloaded profile, the browser it was made in.
Platform
Web and WebAssembly first: both foundations ship first-class WASM, and the interaction design was the unsettled part, so the fastest iteration loop won. That is what exists.
A desktop plugin via nih-plug (VST3/CLAP) and then AUv3 via a Swift shell are
the intended next shells; neither is started. The constraint they inherit is
the one the web build already keeps: inference and rendering stay off the
audio thread, which only ever plays the current patch.
Decisions log
What was chosen, and what it was chosen over. A decision that only records the choice is not a record of anything.
Each row links, where there is one, to the page that works the choice out in full. Rejected alternatives are named in the rationale rather than kept in a separate list, because the reason a design was rejected is only legible next to the one that replaced it.
| Decision | Choice | Rationale |
|---|---|---|
| Genome representation | Typed combinator-term PCFG (not raw graph, not NEAT) | Types make every sample valid; reuses fugue-evo grammar machinery; all 3 evolution levels in one rep |
| Feedback signals | Pairwise duels + stars (ordinal) + keep/kill; implicit signals logged, not in the likelihood | One latent utility, three likelihoods; duels primary |
| Taste features | φ_audio (18) + φ_struct (26) = φ ∈ ℝ⁴⁴ | Transfer across topologies + free structural screening |
| Feature axes | Log-frequency, logged heavy tails, families not per-module columns | The model is linear in φ, so the axis decides what is expressible; sparse columns are coefficients fitted on a handful of rows |
| Utility form | Max of linear experts u = max_k θ_k·z, K = 5 | Multi-modal taste; handles cross-island duels (per-observation and per-session latent-z designs both fail there); no discrete sites; K=1 ≡ BLR |
| Preference sets | Discovered styles, aligned post hoc; nameable and persisted | A style claiming ≈0% of the pool is idle: K is an upper bound |
| Locks / partial evolution | Freeze any set of trace addresses; MH proposals touching them are rejected outside the kernel, in both directions | Exactly Metropolis-within-Gibbs on the conditional posterior, so locking is exact rather than heuristic |
| Hand edits | Knob turn = write at a trace address; keep as new inserts a new candidate; optional “edit beats original” duel, provenance-tagged | Panel and genome share one encoding, so edits, locks, and evolution cannot drift |
| Profile portability | A profile = observation log + standardizer; log stores raw φ by name | θ is only meaningful relative to its standardizer; by-name raw storage is what lets the feature set change without re-interpreting history |
| Modules | 46 productions: 8 sources, 22 processors (two of them player kinds the prior never draws), 16 modulators; categorical orders are append-only wire format | Enough texture axes to learn on; the codec writes indices into the trace |
| Feedback loops in grammar | Not yet (tree terms only; see open questions) | Stability; internal-feedback modules still allowed. Note the ceiling is tighter than “acyclic”: a tree also forbids sharing, so one output cannot feed two places |
| Audition | Standard 5.05 s phrase + free-play; per-style phrases later | Feature comparability requires fixed stimulus |
| Loudness | LUFS-normalize all renders to −18 | Loudness bias would poison the preference data |
| Acquisition | Uniform random pairing by default; BALD selectable, Thompson kept for contrast | Measured tie with BALD over 20 paired seeds in two pool regimes; uniform has no tuning constants and makes every duel an unbiased calibration sample |
| Calibration metric | Brier skill against the 0.5 baseline, plus random check duels | Accuracy is not proper and is pinned near chance by an information-seeking pairing rule |
| Recency | Discounted likelihood, half-life 150 observations | Taste is allowed to change; stationarity is the wrong assumption about a person |
| First frontend | Web / WASM | Both deps ship WASM; fastest UX iteration; shareable |
| Session UX | All three modes, built duels → grid → radio | Same observation stream; sequenced by signal quality |
| Safety | Vetting gate: audition = pre-rendered vetted buffers; the live keys play an edit before its check and are muted if it fails | One render serves health-check, features, and playback; quarantine + fitness shaping teach evolution to avoid pathology; an edit is heard as it is made |
| A generation’s walks | Jobs and one shared context, walked side by side on a render-farm crew raised on demand, absorbed in job order; admissions and replacements judged under the posterior the generation opened with | The seeds are independent, so the generation takes about its slowest walk instead of the sum of ten; job-order absorption makes the pool the serial path’s at every width and whatever order the walks finish in. Parallelism inside one walk was rejected (MH is sequential, and a farm round trip per step is slower than whole walks), and so were fewer or shorter walks (measurably worse search) |
| A search control’s offer | The taste walk tilted along the control’s direction (AIM_GAMMA = 1), continued up to AIM_WALKS = 3 walks until it has moved that way; the reply says how far it did | Growing undirected offers and keeping the one that moved most spends renders on offers thrown away, and yields nothing when no walk heads that way; a graft needs a transparent module, and Grit has none. γ and the walk count are the census’s (a flattened curve, bounded taste cost) |
What is not in this table
Two kinds of thing deliberately stay out of it, and three live on their own pages: open questions, which are decisions that have not been made rather than decisions that have; unraised directions, which are possibilities nobody has yet argued about either way (the distinction matters, because a reader cannot otherwise tell rejected from never considered); and what the audition cannot hear, which is the one premise underneath several rows of this table rather than a row of it.
Reversible details. Buffer sizes, the exact number of MCMC steps, which easing curve a knob uses. These live in the code and in the pages that quote them by name; a decisions log that tracks them stops being readable.
The pass-by-pass history. What changed when is
CHANGELOG.md.
This table is evergreen: it says what is true now and why, not what was true in
March.
Milestones and the gates that closed them
Each milestone had a demo that either worked or did not. None of them closed on “the code is written”.
| # | Deliverable | Demo / gate | Status |
|---|---|---|---|
| M0 | Workspace scaffold, CI | cargo check green | ✅ |
| M1 | auracle-grammar: PCFG, palette, term→Patch compiler | Play random grammar samples (already fun) | ✅ |
| M2 | auracle-features: phrase renderer, LUFS, feature vector | Feature vectors stable & reproducible for fixed seeds | ✅ |
| M3 | auracle-taste: mixture-BLR (K=1), 3 likelihoods, synthetic user | Posterior recovers ground-truth θ*; regret shrinks | ✅ |
| M4 | auracle-session: two-loop engine + acquisition | Headless closed loop vs synthetic user | ✅ |
| M5 | WASM + web app: duel mode + bench | A human can teach it their taste | ✅ |
| M6 | Grid & radio modes; K>1 style discovery; named profiles | Styles discovered & pinnable | ◑ |
Status (2026-08-05). M0–M5 complete. Of M6, K>1 style discovery has shipped (dynamic K, max-of-experts, post-hoc label alignment), styles are nameable and persist, and profiles download and open as a portable observation log plus its standardizer. Grid and radio modes remain open, and with them keep/kill’s triage surface: today it is emitted only by the bank’s cut, as a kill.
The pass-by-pass record is
CHANGELOG.md.
The synthetic user, and why M3 was a gate
Before any UI existed, the taste crate was validated against a simulated user: ground-truth θ* (and ground-truth styles for K>1), synthetic duels/stars/keep-kills with realistic noise, asserting
- posterior concentration on θ*, and
- shrinking regret of the acquisition loop.
That makes the core falsifiable headlessly, and it later doubled as a demo mode that learns a fake user in fast-forward.
It has since become the harness the engine’s own tuning is settled on.
search_health --budget-ab chose the 40 × 10 refinement
split, and learn_synthetic --compare produced the
acquisition measurement. The closed-loop test runs
the real grammar → render → vet → feature pipeline, and it is the only test in
the workspace that can fail when the loop is broken while every component is
individually correct.
Session UX: three modes, one observation stream
All modes are emitters into the same observation log. The build order was duels → grid → radio, sequenced by signal quality rather than by effort.
- Duel stream and workbench: shipped. The core loop is A/B pairs in EVOLVE; candidates land on the workbench in PATCH, where stars, free play, hand edits, locks, and downloads happen. TASTE and LEARNING are the model reporting on itself: the map, with each pick drawn as the direction it taught; the styles and their coefficients with credible intervals; and its forecasts, scored.
- Population grid: open. See a generation at once, rate/cull/breed; keeps evosynth v1’s “generations” mental model for users who want to steer. This is where keep/kill triage would get its surface.
- Radio mode: open. Lean-back continuous stream with keep/kill/skip; the payoff once generation quality is high.
The app grew a long way beyond the original duel-mode scope on the way there: four-voice AudioWorklet polyphony, MIDI, an arpeggiator, an interactive lockable rack with typed rewiring and a module list, a bank with three tabs, session persistence in IndexedDB, and a 62-patch preset library across seven families.
Open questions
Things this design has not settled. They are written down here rather than left out, because a reference that only describes what works is not a description of the system.
Every entry below is a question this design raised. Possibilities it never raised are a separate register (unraised directions), and an entry there graduates onto this page as soon as someone can state the measurement that would settle it.
-
Tempered SMC for generation. The Boltzmann target is written down but not sampled from, and there is no crossover: every child grows from one seed. Whether a population sampler is worth the complexity over local climbing is untested; the measured non-concentration of the pool (it widens slightly over a session) weakens the diversity argument for it.
-
Cross-island discovery.Closed by measurement, and it was wrong. This entry read: “local refinement from island A will not find island B; a tempering schedule would cross the valley.” Measured against a bimodal synthetic user (make islands), 20.9 % of refinement events cross islands (13.5 % of all events decisively, with both ends more than 1.0 onto their island rather than hovering at the boundary), and 0 of 8 seeds ended with a pool on one island only.The reasoning was wrong about the geometry. The walk is not local in feature space: it is a reversible-jump walk over a tree grammar, and a single accepted structural move swaps a subtree, which is a large jump in φ. There is no valley to cross, because the search does not have to travel through the space between the islands. See Refinement.
-
Fan-out and feedback in the grammar. Two separate ceilings, deferred together because they are the two things the term algebra cannot say.
This entry used to read “the grammar is DAG-only today”, which understated it in the direction that matters. The genome is a tree (
term.rssays so in its first line), and a tree forbids more than cycles. It forbids sharing: one output cannot feed two places, so there is no shared sub-patch. A DAG would already allow that. The distinction is the whole of the first half of this entry, and the docs were describing the looser of the two ceilings.Fan-out is the more valuable of the two and the more invasive. One oscillator into both a filter and a delay line, summed: an idiom so ordinary that the app already has to explain its absence. The connect offer does that well, volunteering the constraint as a fact (“A copy: one output cannot feed two places”), and the panel called it the best copy in the product. But it is still a ceiling being narrated rather than lifted.
The cost is not the grammar rule; it is everything keyed to the tree. Child indices (
node/0,node/1) are the trace addressing, so a shared node has no single path and the address scheme stops being a naming of the term. That scheme is load-bearing for panel knobs, locks, live parameter handles, MH proposals, and the persisted genome.CONTRIBUTING.mdlists it as a sharp edge for exactly this reason.children(),size(),depth(), andsite_count()all assume each node is visited once, andsizesets the module ceiling (MAX_SIZE). Every structural op inmutateassumes a unique parent. It is a genome-format change with a migration, not a production.Feedback needs a mandatory attenuator and limiter in the loop path, and a delay of at least one sample to be computable at all. quiver’s graph is evaluated per sample in dependency order, so a cycle needs an explicit unit-delay node to break it, which is a real design, not a relaxation of the acyclicity check.
Deferred, deliberately, and this is the record of it. Neither is blocked on evidence (no measurement would change the answer), so neither belongs in the “measure it” pile with the rest of this page. They are blocked on being worth a genome migration, and nothing in the loop currently says they are: the search is not starved for expressiveness (refinement crosses islands, and the pool widens rather than concentrates). Re-open this when a player wants something the tree cannot say, rather than when someone notices it cannot say it.
-
Per-style audition phrases: a discovered bass style picks a bassline, a pad style a chord swell. Still open, and worth stating precisely what stands in the way, because the migration mechanism is not it.
The
:p2stimulus tag does solve the history problem: a phrase change renames the audio coordinates, old votes keep their stimulus-independent structural coordinates, and their old-stimulus audio coordinates are imputed as “no evidence”, which the likelihood now handles honestly (imputed coordinates) rather than as a measurement. Two prerequisites are therefore already met, and one is not:- Comparability is fine, contrary to the obvious worry. The phrase is a
property of the session (
SessionConfig::phrase), not of a candidate, so everything in a pool is auditioned under one stimulus and duels stay apples-to-apples. Per-style phrases only make sense as per-context phrases for the same reason. - The tag would have to be derived rather than declared.
:p2is a hard-coded literal inAudioFeatures::NAMES, andFeatures::phi_namesis global. A phrase that varies needs the tag to be a function of thePhraseSpecactually rendered, or the names silently stop describing the numbers. - The real obstacle is circular, and it is a design problem rather than an engineering one. A style is discovered: it is an inference from φ. φ is measured under a phrase. If the phrase is chosen by the style, then the stimulus depends on an inference that depends on the stimulus. That loop can be broken (bootstrap from the standard phrase, switch only once a style’s share is confidently high, never re-audition history), but every way of breaking it is a decision about how much the instrument is allowed to change what it is measuring while it measures it.
Deferred until that loop has an answer worth defending, rather than until someone has time: the mechanism is ready and the question is not.
- Comparability is fine, contrary to the obvious worry. The phrase is a
property of the session (
-
Where acquisition would earn its keep.Measured, and the tie does not break. BALD ties uniform pairing at session horizon, and this entry named two regimes where that should stop being true: a much larger pool, or a much longer session. Both were run at 20 CRN-paired seeds,bald − random:regime cos θ* rank r excess nats baseline (pool 48, 6 rounds) +0.059 ± 0.046 (static) +0.045 ± 0.068 −0.013 ± 0.012 (static) pool 192 −0.002 ± 0.044 +0.015 ± 0.061 −0.001 ± 0.012 24 rounds (288 duels) +0.031 ± 0.042 +0.013 ± 0.013 −0.003 ± 0.006 At the baseline BALD has two marginal wins in the static regime (t = 2.6 and −2.2). Widening the pool fourfold removes them rather than growing them, and lengthening the session fourfold leaves everything inside noise with several signs flipped. The reasoning behind the entry (that a bigger pair space gives an information-seeking rule more redundancy to prune) does not survive being tried.
What is stable across all three regimes is that BALD beats dueling Thompson (t = 2.9 to 6.9), which was already known and is unchanged.
So uniform random pairing stands as the default on the same grounds it always had: it ties the information-seeking rule everywhere anyone has looked, has no tuning constants, and makes every duel an unbiased calibration sample. The session-length knob this needed (
--rounds) is now inlearn_syntheticbeside--pool, so the next person can ask a third regime without patching a constant. -
Fit cost at the K cap. A mature fit is both slower and statistically thinner than an early one: every step’s likelihood scores every lens, and 225 + S sites over a fixed 10,000 steps is ≈ 44 sweeps per site. The fit no longer rebuilds the program per step (the kernel) and the chain no longer holds itself in memory, so what is left is the likelihood and the statistical shape of the problem: the budget can now be chosen on the recovery tables rather than against a memory ceiling. The written-down option (cap at 3) is gated on
style_shareevidence from real sessions.That evidence is now collected. Every posterior fit records what fraction of the pool each style claimed, and the register persists across reloads (
Engine::style_shares). The question is still open (it wants sessions, which take time to accumulate, and synthetic runs cannot answer it), but it is now open for want of data rather than for want of an instrument. Rows wherek == k_stylesare the ones that bear on it:kgrows with the log, so an early row with two styles is not evidence that styles 3–5 are idle. -
Which state of a refinement walk to inject.Run, and it tied. A walk renders ~40 candidates and keeps one;RefineKeep::Besttakes the highest-log π_βstate the walk occupied, seed included, and ships switched off. Sixteen paired seeds:LastBestmean gain +1.927 ± 0.452 +1.774 ± 0.302 median gain +2.058 +1.819 10% trimmed +1.840 ± 0.383 +1.925 ± 0.190 climbed on 14/16 15/16 Paired difference (
Best−Last): mean −0.153 ± 0.384, median −0.185, trimmed −0.113 ± 0.318, sign test 8 better / 8 worse (p = 1.000). As exact a tie as sixteen seeds can produce, and it does not clear zero at 2 se on any statistic, so the default staysLast, kept re-checkable rather than deleted, asAcquisition::Thompsonis.Neither the feared failure nor the hoped-for win appeared. The worry was that argmax over a surrogate would deepen the catastrophic tail; across the pair the tails are a wash. What did show is that
Bestis the lower-variance rule rather than the better one: half the trimmed standard error (0.190 against 0.383). Injecting the walk’s argmax is more consistent than injecting where it stopped; it just does not aim anywhere better on average. That is the argument to re-run this on if the surrogate ever gets sharper. -
Interior signal taps in quiver.Closed, and it was wrong. This entry read: “a compiled patch exposes exactly one output … a quiver-side probe API would turn both into measurements. Not filed: it needs scoping first.” There was nothing to scope and nothing to file. quiver’sStateObserverhas takenLevel,Scope, andSpectrumsubscriptions on any node port for some time, in the release the lockfile already pinned. The gap was here, not upstream.The compiler now records where each term node’s audio leaves it, and the rack’s flow animation multiplies a measured RMS into its reach factor while notes sound. Two expectations about the work turned out not to hold either:
sync_output_keepaliveis unnecessary, because the genome is a tree and every module’s output already feeds a parent, so quiver is already computing every metered value; and the open design question, which of N voices to meter, has an answer: the most recently pressed one, since a sum across the bank averages notes at different envelope phases and is not the level on any wire. The port trace stays an offline render, now by choice: it wants the same phrase every time so that two looks at it are comparable. -
fugue-evo’sClosed, and it was wrong three times over. The entry read: “it does not compile there, so the workspace takes fugue-evo with default features off and refinement is single-threaded natively too, in the one place the engine is embarrassingly parallel.”parallelfeature on wasm32.Wrong about the blocker: fugue-evo#22 established that
checkpoint, notparallel, was the only thing that did not build on wasm32. Wrong about the remedy: enablingparallelwould change nothing here, because everyrayonuse in fugue-evo sits under#[cfg(feature = "classic")], and Auracle takes["std", "ppl"]and drives refinement itself throughinference::mh::EvolutionChain. And wrong about the prize: the harness is not waiting on single-threaded refinement.search_healthand therefinement_improves_poolfloor already spawn one thread per seed and saturate the machine, so parallelizing inside a refinement cannot make a 16-seed measurement faster: the cores are already busy.What is left is real but smaller than the entry implies, and it is a UX number rather than a harness one: latency on a single refinement, which is the app’s ⚡ button. Filed as that, not as a build-configuration change.
-
Bright and Body are entangled on a filtered bass (open). On Acid Line the cutoff is the obvious brightness knob, +3.2σ of centroid per unit, and the named-control wiring does not use it: the same move drops
bass_fractionby 4.6σ, which is Body’s axis, so the cross-talk gate is right to call it impure, and Bright reads search on the patch where a player most expects it. Solving in the six-dimensional named subspace instead of across φ was tried and reverted: with more knobs than named axes it can always cancel the cross-talk, and it did so musically: Ceiling’s “Bright” became a shorter release. Candidates: let Bright own a little of Body’s axis (a brightening is expected to thin a bass), or report a coupled control honestly (“brighter, and thinner”) instead of hiding it. Neither is decided. Update: the wiring now tries a control’s own sites first (Bright: cutoff, tone, …) and ranks knobs by effect rather than coefficient, and on the re-voiced Acid Line the cutoff alone clears the gate for Bright: purity 0.87, reach 2.85σ. The entanglement is still there, and now shows up on the other side. Body’s best move is the same cutoff turned the other way, soseparatemakes Body the search control. -
Grit hears noise, not saturation (open). Grit’s axis is
flatness_mean, and a drive on a tonal sound adds harmonics, which φ reads as Bright, not as flatness. On Iron Bass, a saw through a tube drive, drive moves flatness by 0.000σ per unit. Across 24 fresh-pool patches Grit reaches 0 to 1. A bitcrusher does move flatness. It is transparent only at 16 bits, though, where its slope is zero and the local measurement cannot see it, and it clips anything hotter than its ±5 V window. So PERFORM grafts nothing for Grit. What would fix it is a roughness descriptor in φ (inharmonic or beating partials, or Sethares-style sensory dissonance) with Grit’s axis on it. That is a φ change, and it needs aRENDER_EPOCHbump and a taste-model revalidation. -
Remaining quiver hardening.Closed.voct_to_hzgained a ±32-octave clamp in quiver-dsp 0.3.0, and Auracle has pinned a clamped quiver since the September 2026 audit (0.3.3 then, 0.4.0 now). The clamp left renders inside ±32 octaves unchanged, so noRENDER_EPOCHwas bumped for it; the render-cache namespace now carries the quiver version as its own coordinate (QUIVER_DSP_VERSION), which orphans the stored rows from 0.2.0 anyway. That is the right outcome, because for pathological CV (chainedOffsets past ±32 octaves) the two versions render differently: 0.2.0 recovered an infinite increment by phase reset, 0.3.x aliases at a finite ~THz pitch. Both are garbage the vet gate quarantines; they are not the same garbage. -
Frame silence is recognized only at exactly zero power (AU-F2, open).
audio.rssplits the phrase into chains at frames whose unnormalized FFT power is below1e-12, which is an amplitude of ≈2e-9 (−173 dBFS). Rests read as silent only because quiver’sAdsrsnaps exactly to 0 at the end of its release and the VCA is multiplicative; a release or chord tail that outlasts a 0.15–0.2 s rest never gets a chain break, and the flux fix (#51) and the segment features depend on one. The likely fix is a threshold relative to the phrase (−60 dB of global RMS, say). It moves φ for every patch with a tail, so it is aRENDER_EPOCHbump and owes the measurement (φ over prior draws before and after, andmake revalidate), which has not been made. Documented at the line rather than changed blind. -
The brightness cluster in φ_audio.
rolloff_mean,zcr_mean, andcentroid_meanare three genuine measurements of one perceptual thing. A fused prior over the cluster is now implemented and switched off, which is a more useful state than either “not done” or “done”.The VIFs quoted when this was written were 18.4 / 10.4 / 5.9; the ZCR DC removal, when it shipped, took them to 16.9 / 9.7 / 5.9, and
zcr_meanstopped tripping the collinearity flag. A third of the original argument was a coordinate bug rather than a modeling problem. They have risen since: today's sweep reads 18.7 / 12.0 / 6.8, andzcr_meanis over the flag again (Known collinearity has today's table).Two gates were run at ρ = 0.25 and they disagreed. The closed-loop gate, which scores θ recovery, improved (0.657 → 0.702). The 48-seed paired climb, which scores what the pool is worth to the listener, regressed: −0.579 ± 0.188 trimmed (−3.09 se), sign test 16 better / 32 worse (p = 0.029). So it ships at ρ = 0.
Both results are real because they measure different things: pooling an ill-conditioned ridge regularizes estimating θ, and biases the search that consumes θ. The general point outlives the feature: a VIF says these coordinates move together across patches, which is a fact about φ; fusing their coefficients asserts a listener’s preferences move together, which is a fact about people and does not follow. Re-open if the listener model ever gains a reason to believe it does; the sweep and both gates are there to re-run.
Directions the design has not raised
The counterpart to the open questions. An open question is a decision this design put on the table and has not settled; what follows was never on the table. Written down so that not considered and considered and rejected stop looking alike from the outside.
Everything here came out of a review pass in August 2026 that read the books and the crates and asked what the architecture already supports that nobody has argued about. Nothing on this page has been decided, except §13, which has since shipped and is kept as a short record, and several entries name the reason they might be wrong.
Why this is a fourth register
| Page | Holds | The state it records |
|---|---|---|
| Decisions log | A choice, and what it beat | Settled |
| Milestones | A deliverable and the gate that closed it | Done, or not |
| Open questions | Questions this design raised | Unsettled, and named |
| This page | Possibilities this design never raised | Unexamined |
| What the audition cannot hear | One premise, and where it costs | Assumed |
The last row is the odd one and the sharpest. An entry here is an opportunity; an entry there is a limit on what any amount of modeling can buy, because it names something the measurement does not contain. Two entries on this page, §2 and §3, have their strongest arguments over there rather than here.
The rule for leaving this page is what keeps it from becoming a wishlist. An entry graduates into open questions as soon as someone can state the measurement that would settle it, and into the decisions log once it is made. An entry that can be stated as neither is speculation and should be deleted rather than kept.
Each entry therefore names three things: the machinery that already exists, what stands in the way, and what would settle it. Most of these are one shell or one substitution away from working, which is the reason they are worth a page rather than an issue.
| # | Direction | Leans on |
|---|---|---|
| 1 | An evidence path for the questions that need real sessions | Profile = log + standardizer |
| 2 | Search toward a reference sound rather than toward a fitted taste | The target consumes as a black box |
| 3 | Declared context, which dissolves the per-style-phrase loop | presets::CATEGORIES, SessionConfig::phrase |
| 4 | Radio as the fix for observation volume, not as the third mode | The keep/kill likelihood, already fitted |
| 5 | A multisample export, as the cheap exit into a DAW | Deterministic headless rendering |
| 6 | Refinement that runs while nobody is watching | The native harnesses, the render farm |
| 7 | Counterfactual explanation: the edit with the largest predicted gain | Trace addresses, the structural edit ops |
| 8 | A map whose axes are styles rather than principal components | TasteMap, and its own converged flag |
| 9 | The taste crate as a general preference-learning library | φ enters by name, and nothing else is audio |
| 10 | The measurements as published results | The changelog already holds them |
| 11 | The production→brightness map, so audio taste can tilt proposals too | Every (term, spec) → φ ever computed |
| 12 | Building the cascade the φ split is justified by | φ_struct is render-free |
| 13 | Refinement seeds across the farm: shipped, now in the decisions log | The seeds are independent; the farm was idle |
| 14 | A per-session cutpoint offset, by the argument already made for | The design, one likelihood over |
| 15 | A sweep-denominated fit budget, as the alternative to capping K | fit_bench already measures the axis |
| 16 | A per-provenance likelihood temperature | ProvenanceScore, already computed |
The pattern entries 11–16 came out of
Six of these are one observation wearing six hats: Auracle is a better instrument than it is a consumer of its own instruments. It pays to generate labeled data about itself and then reads that data once, literally.
| Recorded | Consumed as |
|---|---|
Engine::style_shares, per fit | nothing yet (§1) |
Calibration split by Provenance | a display (§16) |
| Quarantine reasons | one scalar, QUARANTINE_FITNESS (§12) |
| The persistent render cache | an exact-match lookup (§11) |
LineageEvent, with utilities at event time | a caption (§7) |
A cache is a lookup table; a model is a lookup table that generalizes. Every row above is the first of those and could be the second.
1. The register is gated on evidence that cannot arrive
The fit-cost entry in open questions says this in as many
words: it is “open for want of data rather than for want of an instrument”,
and Engine::style_shares was built to collect exactly the rows that bear on
it: the ones where k == k_styles. Behind it sits the question the K cap is a
proxy for, which is whether a player ever uses five styles at all.
Those rows are on other people’s machines, and there is no path off them. Persistence is unambiguous: IndexedDB under the page’s origin, no account, no server, nothing transmitted, and the only backup that exists is a profile the player downloaded by hand.
So a project whose method is close the question by measuring it has a class of questions it cannot close. That is a structural fact about the architecture, not a backlog item, and it is the single most consequential thing on this page.
The mechanism already exists. A Profile is the log
plus its standardizer: self-contained, portable, raw stored by name,
already exposed as ⌘K’s Download your taste. What is missing is a
destination, not a format.
What this must not become. The decisions log keeps implicit signals out of the likelihood, and the guide is explicit that listen time, replays, and hovers are not recorded as preferences. That decision is about what the model is allowed to learn from, and it should survive this entry untouched. The property worth keeping is nothing leaves without a deliberate gesture at a file the player can read, which an opt-in donation of a downloaded profile satisfies and telemetry does not. A donation path that is a button plus a place to send the file (an issue attachment is sufficient; no server is required) keeps every word of persistence true.
What it would close, and what it opens. Directly: the K-cap question, whose
evidence is the rows where k == k_styles. Indirectly, and worth more, a
population prior on . Cold start today is 44 coordinates starting
from a prior mean of zero, against which the three-pick warm
start buys 18 observations in thirty
seconds. A hierarchical prior fitted across donated profiles is the only
available lever that moves the starting point rather than the rate, and
because the stack underneath is a probabilistic programming language, a
hierarchical prior is a model that can be written rather than an inference
engine that has to be built.
Two reasons it might be wrong, both worth stating before it is built. A donated corpus is self-selected: it answers how many styles does an enthusiast use, which is not the question. And a population prior asserts that other players’ preferences are evidence about each player’s, which is close to the assumption the max-of-experts design already refused at the level of one listener’s islands; refusing it within a person and accepting it across people needs an argument, not an analogy.
What would settle it. Cross-validation on donated profiles: does a population-prior warm start beat the three-pick warm start on held-out duels over a session’s first hundred observations? That is an offline measurement with no live user in it, and it can be run the day a corpus exists.
2. Target-directed search: “make it sound like this”
Partly built (Plan-005 task 11): a sound of your own. The engine measures a recording, names its nearest sounds and breeds a generation toward it, on the taste's target times a likelihood of the recording rather than on a distance alone. Measured, a recording keeps five coordinates of φ, not the twelve guessed below. What follows is the direction as it was written.
Every path through this system learns a utility. But the Boltzmann target consumes as a black box:
Nothing in the search requires to be fitted. Substituting a distance to a reference vector,
turns the whole apparatus (MH in trace space, reversible jump, vetting, the pool, and locks as exact conditional refinement) into a matcher, with no new algorithm. Locks are what make it more than a novelty: match this, and leave my filter section alone is a conditional match, which is a thing the machinery already does exactly rather than heuristically.
This is the most common real sound-design task, and it is the one shape of it the instrument cannot express today.
The distance can only be over the audio half, and that is a feature. A recording a player brings has no term, so it has no φ_struct at all: 26 of the 44 coordinates are not defined for it, and has to zero them. That is the right behavior rather than a limitation: sound like this, by whatever means, with the grammar prior left to supply the parsimony that keeps the means sane. A match that also scored structure would be asking the search to reproduce a topology the reference never had.
What actually stands in the way is φ_audio’s stimulus dependence.
is measured under the standard phrase; the
:p2 tag exists precisely because
a coordinate’s meaning is relative to the stimulus that produced it, and a
reference clip is not that phrase. Twelve of the eighteen
φ_audio coordinates are whole-phrase statistics and
survive the mismatch with a caveat about pitch content; the six
segment-local ones (held_centroid_std and the three motion bands, on
the first held note; high_ratio; chord_flatness_delta) find their roles
by property (first note, highest note, first chord), and arbitrary audio may
have none of them. The imputation
is already honest, since an absent coordinate reads as no evidence, but here
the absence lands on the axes describing timbral motion and register, which is
where a listener’s “sounds like” often lives.
The honest form is therefore a partial match with the matched subset named on screen, rather than a match that quietly scores twelve coordinates and calls it a likeness.
There is also a reason to want this beyond convenience, and it is the workflow argument: duels ask which of two a listener prefers, and sound design is more often the pursuit of something already imagined. Target-directed search is the missing half of that loop rather than an extra mode of it.
What would settle it. No human is needed for the first pass: hold out a
patch from the prior, treat its as the reference, and measure how far
refinement closes the distance against a random-restart baseline over paired
seeds, the same shape as the existing search_health measurements. If that
fails, the idea is dead before any UI is drawn.
3. Declared context, and the circularity it dissolves
The per-style audition phrase entry is blocked on a loop, and the entry states it precisely: a style is discovered (an inference from ), and is measured under a phrase, so a phrase chosen by style makes the stimulus depend on an inference that depends on the stimulus.
That loop is a property of discovery, not of per-context stimulus. It
disappears entirely if the context is declared: I am hunting a bass
tonight is a statement, not an inference, and a phrase chosen by it depends on
nothing that was measured. Comparability holds by the argument the entry
already makes: the phrase is a property of the SessionConfig, so everything
in a pool is auditioned under one stimulus and duels stay apples-to-apples.
The declared context also already exists, half-built and discarded at the model
boundary: presets::CATEGORIES is a seven-way family label (bass, lead,
keys, pad, texture, perc, weird) carried by all 62 presets, used for
copy and for spanning the warm start,
and read by nothing downstream.
What it costs. The utility becomes , and there are two shapes for that. Appending a context block to keeps the model linear and lets a single coefficient mean brightness matters more to me in a lead; a per-context set of style weights is the heavier design, multiplying the coefficients that must be paid for by evidence, which is the resource the cold start is already short of. The first is the one that fits the model that exists.
The cost that will actually bite is historical: an observation becomes a judgment about a (patch, context) pair, and every vote already in every log has no context. Under the by-name imputation rule that reads as the standardizer’s mean, which is honest and weak: the correct behavior, and still a real dilution of a player’s history the first time it ships.
This does not close the open question; it reroutes it. Per-style phrases stay exactly as open as they are. What changes is that the per-context half of the value (a bassline for basses, a chord swell for pads) becomes reachable without answering the circularity at all.
The musical argument for declaring context is stronger than this modeling one, and it is on the other page: a preference fitted over a whole history converges on the average of a taste, and a player needs the corner they are working in tonight.
4. Radio is a throughput fix, not the third mode
Grid and radio remain open, and keep/kill is a fitted likelihood whose only surface, the bank’s cut, records kills alone. The usual reading is two modes left to build. There is a sharper one.
The two unbuilt modes are the ones that produce observations in bulk (grid judges a generation at a time, radio never stops), and radio in particular produces them at the rate of listening rather than at the rate of deciding. A duel costs concentrated attention and a session yields tens of them. That difference is not a matter of degree for this model, because several of its mechanisms are denominated in observation counts:
- Recency weights a vote places back by . A player whose entire history is a few hundred observations lives inside the first half-life, and forgetting (built, documented, and figured) never does anything.
- K grows with evidence, so the multi-modal taste the max-of-experts design exists to represent needs enough log to discover a second style at all.
- Calibration is a Brier skill score against 0.5, plus random check duels. Its error bars are a sample-size problem.
So radio is not the third mode. It is what makes the model’s own dynamics observable, and it should be ranked on that rather than on effort.
The counter-argument, which is real. Keep/kill is the weakest signal per observation, and radio would flood the log with it. Worse, recency is denominated in log positions, so a flood of cheap observations pushes a considered duel past the half-life faster in wall-clock terms: the unit silently changes meaning when the observation rate changes. If radio ships, the recency weight probably has to become per-signal-kind, or be denominated in time rather than in positions. That is a modeling decision hiding inside a UI mode, and finding it before building is most of the value of writing this entry down.
5. The bank does not leave the browser
What comes out today is a WAV of the player’s own playing, a patch file, and a
profile. Everything a musician does next happens in a DAW.
Lineage names the intended shells (nih-plug VST3/CLAP, then
AUv3), and neither is started.
There is a cheaper exit that does not wait on a plugin, and the
determinism contract is what makes it nearly free: ,
and the render it comes from, is a pure function of , which is the property render_key and RENDER_EPOCH already
assert. A multisample export (a grid of offline renders across pitch and
velocity, plus an SFZ mapping, which is a plain-text format) puts any patch in
the bank into every DAW that loads a sampler, today, with no audio-thread work
and no shell.
A second, smaller one: the genome is a term with a compiler, so export this patch as quiver source costs a printer and makes a patch inspectable, diffable, and portable to a native host.
What it does not solve, and the entry should not pretend otherwise. A multisample is a snapshot of an instrument, not the instrument. Per-note modulation, the arpeggiator, anything that responds to how it is played, and the live parameter handles that make the rack worth touching do not survive sampling. This is a bridge that gets patches used while the real answer is built, and its value is entirely in being available years earlier.
6. The machine loop stops when the tab closes
The two-loop architecture describes a machine-paced loop that evaluates thousands of candidates against what it has learned. It does that only while a page is open and a person is sitting there.
Every piece of an offline mode exists. The harnesses (search_health,
learn_synthetic, closed_loop_sweep) run the real grammar → render → vet →
featurize pipeline natively; the render farm
already parallelizes; a Profile already round-trips. Leave it running and
come back to a pool that has been refined for an hour needs a shell, not an
algorithm.
Two things are worth saying before anyone builds it. The pool would be refined against a that is stale by exactly as long as the player was away. That is fine, because moves slower than the pool does, but it should be stated rather than discovered. And a browser tab cannot do this: background timers are throttled and the tab may simply be discarded. So this is a native direction, and it is the first argument for a non-web shell that is not a plugin.
7. Explanation stops at the coefficients
TASTE reports the map, with each pick drawn as the direction it taught, and LEARNING the styles and with credible intervals and the forecasts, scored. That is already more than the genre offers, and it is descriptive: it says what the model believes, not what to do about this patch.
The next rung is counterfactual, and trace
addresses make it close to free. Enumerate the
structural edit ops at every node plus a knob write at
every parameter site, score each result with the fitted , and report the
argmax: this patch sits 0.8 below your best; the single largest predicted gain
is node/0#cut +0.3. Every ingredient (the address scheme, the ops, the
utility, and the compiler that guarantees the result is playable) is built and
tested.
Two caveats that decide whether it ships honestly. is a fitted
quantity with uncertainty, so an argmax over it is an argmax over a surrogate:
the same move the RefineKeep::Best measurement found to
be lower-variance rather than better. A suggestion without its interval
overclaims in exactly the way this project’s calibration surfaces exist to prevent.
And a confidently-signed suggestion that is wrong is the failure a player
notices and does not forgive, which argues for reporting the top few with
intervals rather than one imperative.
This is also the concrete cash-out of the README’s claim against star-a-generation synths: that they cannot say why. Coefficients are an answer to that. A named edit is the answer a person asked for.
8. The map’s axes could be learned rather than principal
The taste map projects with PCA over standardized
(the top two principal axes, by power iteration), and TasteMap carries
a converged: [bool; 2] flag because the top two eigenvalues can near-tie.
The doc comment names the reason: the brightness cluster is three genuine
measurements of one perceptual thing. The map is already honest about being
potentially unstable.
An alternative is to project onto learned directions instead: the top two style vectors (orthogonalized), or utility against posterior standard deviation. Those axes are stable against the eigenvalue tie by construction. They are also nameable, which is the part that matters for a surface inviting someone to recognize territory: “more like your style 1” is a thing a person can hold, and “the first principal axis of a standardized feature matrix” is not.
Not obviously better, and the reason is worth keeping. A learned axis moves when the model does, so the territory changes meaning between fits. That is a different instability, moved from the solver to the model: arguably worse for a map, arguably better because it is explicable and can be announced.
What would settle it. Measure the rotation of each projection’s axes
between consecutive fits over a session and compare. That is a small
instrumentation on top of TasteMap and a number, not an opinion.
9. auracle-taste is domain-independent and welded to a synth
Strip the words. The crate learns a scalar utility over from three observation kinds (pairwise comparisons, ordinal ratings against fitted cutpoints, and a threshold decision), with a max-of-experts form for multi-modal preference, recency weighting, a calibration report, and a synthetic-user harness that falsifies the whole thing headlessly.
None of that is about audio. The only audio-shaped thing is , and it
enters by name through FitSet::build: the same property that makes
profiles survive a feature-set change makes the crate indifferent to what the
features measure. Fonts, color ramps, shader parameters, recipes,
hyperparameters: the model does not know the difference.
The reason to wait is real and should be the stated one. Extraction is an API commitment, and the project is 0.x with a save format that still moves; publishing a crate whose serialization is not settled buys a migration obligation to strangers. That is a timing argument, which is a much better reason than “no time”, and it means the entry has a trigger: revisit at 1.0, or when a second consumer actually exists.
10. The measurements are a result, and results travel
The acquisition entry is a well-powered null. BALD ties uniform random pairing on cosine similarity to , on rank correlation, and on excess nats across three regimes at 20 CRN-paired seeds, while beating dueling Thompson (t = 2.9 to 6.9) throughout. The literature on preference elicitation is full of acquisition functions and nearly empty of properly paired nulls, and a null is the result a practitioner most needs and least often finds.
The fused brightness prior is the same kind of thing: two gates that disagreed, with a general reason attached: a VIF is a fact about patches, and fusing coefficients asserts something about people, which does not follow.
Neither needs any work. They are written, sourced, and reproducible from a checkout. What is missing is a destination, and the gap between “recorded in a changelog” and “somewhere a person searching for the answer will find it” is the entire distance.
11. The model nobody has fitted is in the render cache
Proposals closes by explaining why only the structural half of tilts the grammar:
Turning
centroid_meaninto a proposal tilt would require a model of which productions raise brightness, which is a model nobody has fitted.
That model’s training set already exists, in quantity, and the project is
already paying to store it. Every the
system has ever computed is a labeled row, and the
persistent render cache is
exactly a table of them that survives reloads. pipeline_stats already draws
1,200 prior samples to compute the VIFs; the same draws are a design matrix. A
ridge fit of the eighteen audio coordinates on the twenty-six structural ones is
, which is the missing map.
Why it is worth more than it sounds. The coefficients a player can actually recognize in themselves (bright, slow attack, long tail) are exactly the ones that today only score. The structural half does double duty (scores and proposes); the audio half is half-employed. The asymmetry is documented and argued for honestly, but it has never been costed, and it is costed in the currency the tilt exists to buy: a search that looks where the model expects to find things.
The objection, and why the existing design already answers it. Brightness is a property of the composition, not of a module (a filter’s effect depends on what precedes it), so a linear map is crude. But a tilt needs only a sign and a rough magnitude, and it is already clamped to . The clamp exists to stop a confident coefficient starving a kind; it doubles as the rail that makes a crude map safe to consult.
What would settle it. The search_health --budget-ab shape: does
audio-tilted proposal weighting beat structural-only tilting on a synthetic
user’s true utility, over paired seeds? The harness that answered the 40 × 10
split answers this unchanged.
12. The screening cascade is cited, and unbuilt
auracle-features/src/structural.rs says, in the present tense:
These cost nothing (no compile, no render), which is what makes the screening cascade work: a struct-only surrogate prunes candidates before the expensive render path.
φ_struct and Refinement both repeat it, and it is part of the stated justification for being two-part at all.
Nothing in auracle-session prunes anything. SurrogateFitness::evaluate
calls featurize_memo for every candidate it is handed (a full
compile → render → vet → featurize), and there is no screen, cascade, or
pre-filter in the crate. This entry is therefore half a direction and half a
correction: either the cascade gets built, or structural.rs should say makes
possible rather than works, as the two reference pages now do.
The risk profile is unusually good, and it is worth naming. A screen can only ever waste opportunity; it can never corrupt evidence. Anything actually injected is still really rendered, really vetted, and really featurized, so the observation log is untouched no matter how wrong the screen is. Very little else in this system has that property.
Where not to build it. Refinement already says its affordability comes from the render memo rather than from screening, because the walk re-scores its own current state every step. So the payoff is in pool fill, which is the path nobody has looked at.
And the cheapest version needs no model at all. Every quarantined candidate
is a labeled row, and today the label is
consumed as a single scalar, QUARANTINE_FITNESS. A screen fitted on those
labels prunes exactly the candidates whose render was guaranteed to be thrown
away.
13. Ten independent walks, now run side by side
Shipped, and so no longer a direction: it left this page for the decisions log when it was built. It is kept here, short, because it was argued on this page first.
The entry observed that an MH walk is sequential but a generation’s ten
seeds are not: they are independent by construction, search_health
already walked them one thread per seed natively, and in the browser they ran
one after another while the render farm sat
idle. It named the blocker too: farm workers were stateless
(term, phrase) → φ, and a walk needs the kernel and the posterior.
What shipped crosses that blocker with data rather than state. A generation is
now jobs and a shared context (refinement):
refine_jobs hands out one job per seed (its tree, step budget, and its own
RNG seed) and one context (the tilted prior, the posterior, the standardizer,
the phrase), a pure run_walk walks a job on any worker, and refine_absorb
folds the results back in job order, so the pool is the serial path’s at
every width. The context crosses to each walk worker once per generation. In
the browser the walks run on a crew raised on demand
(walks on the farm), and a generation takes
about as long as its slowest walks rather than the sum of ten.
The prize the entry named, ⚡ latency, moved less than the entry hoped: ⚡ is one walk, so it still takes a walk’s time. What changed is that it walks on the farm, and the engine answers everything else while it does.
14. Stars have a global scale; keep/kill has a per-session one
Likelihoods argues for the per-session threshold exactly right:
Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days’ data would be degraded by the other’s.
That argument is fully general over absolute-scale signals, and the star cutpoints are fitted globally. Worse, the asymmetry runs the wrong way round from ship state: keep/kill reaches the log only as kills, from the bank’s cut, while stars are a signal players actually emit. So the mechanism exists on the one-sided signal and is missing from the full scale.
The cutpoints do already absorb drift, which the page says and which is true: a player who becomes harsher over months moves them rather than . What they cannot absorb is swing (Tuesday strict, Thursday generous), because a single global set of cutpoints has to average the two. Drift and swing are different phenomena and the model currently sees them as one.
The change is the smallest on this page: a per-session offset added to the cutpoints, the same shape as , one extra latent per session. It also interacts with §15: under a fixed step budget, adding sites silently thins every other coefficient, which is precisely the problem that entry is about.
15. The fit budget is denominated in steps, not sweeps
10,000 fixed steps over sites at is ~44 sweeps per site, against ~200 at . The posterior page states the consequence plainly: growing makes the fit both slower and statistically thinner. So the model that has learned the most about a player is fitted worse than a fresh one, and nothing on screen says so.
Open questions offers one remedy: cap at 3, gated
on style_share evidence. There is a second that has never been written down:
make the budget proportional to the site count, so that sweeps per site is
the constant and the step count is derived. Then the cost of a rich model shows
up honestly as wall time rather than dishonestly as degraded inference.
The two are different trades. Capping spends capability to buy cost; rescaling spends time to buy cost. Which is right depends on whether a mature fit’s wall time is actually a problem for a person, and that is a UX question nobody has asked, not a statistics question.
auracle-taste/examples/fit_bench.rs already measures the exact axis, so the
comparison is a harness run rather than a project.
16. Provenance is scored, and then ignored
Calibration splits Brier skill by
duel, heard_edit, self_report, and perform_offer, for a reason it states
outright:
...there is no reason to believe they are equally reliable. Scoring them against forecasts the model made before either answer arrived is the only way to find out.
The likelihood then sums all four with identical weight. The measurement was built, the answer is knowable, and nothing consumes it: the same shape as §1, one loop shorter.
A per-provenance likelihood temperature closes it. The design detail that decides whether it is honest: the temperature should be a fitted parameter, not the measured skill plugged in, because tempering a likelihood by a point estimate derived from the same data is circular. Fitted, it costs a site per provenance and buys a sentence worth having: the model learns how much to trust each way you talk to it.
Uses this was not designed for
Not directions so much as observations about what the engine already is.
Sound sets, not sounds. A style plus a diversity constraint yields a coherent family: twelve UI sounds in one voice, a game’s one-shot set, an earcon family that a person can learn. The engine is closer to this than it looks: the pool already holds diverse candidates, the map already measures spread, and what is missing is a set-level objective (maximize total utility subject to a pairwise distance floor), which is a submodular selection over the pool rather than a new search. It is also a different product with a different buyer.
A teaching artifact. This reference is most of a course on typed MH, reversible jump, Boltzmann targets, ordinal likelihoods, and calibration, with a runnable instrument attached and, the part no course has, negative results with their measurements.
Sound design without a modular interface. Every gesture the instrument requires reduces to which of these two. That is an accessibility property rather than a beginner property, and it is not claimed anywhere.
An instrument for a real empirical question. Whether one listener’s timbre
preference is multi-modal at all is testable, style_shares is already the
recording of it, and the answer is interesting whichever way it falls. Blocked
on §1, like
everything else here that needs people.
One risk, which is not a direction
This reference is ~5,100 lines of Markdown against 38,518 lines of Rust
(measured the day this page was written, and this page is 429 of the former),
beside a user guide, a brand spec, and a changelog that carries measurement
tables. That ratio is why this project is good, and it is also a wall.
CONTRIBUTING.md
invites contributions and enforces make check and make site-check. What it
does not say is which parts of the unwritten bar are negotiable: whether a
contributor changing a default is expected to arrive with a paired-seed
measurement, whether a new φ coordinate needs a VIF table, whether an open
question may be closed by argument.
Either answer is fine. A project may reasonably be a single-author artifact with the door decoratively open. The ambiguity is the thing that deters, and it costs one paragraph to remove.
What the audition cannot hear
One assumption runs through every page of this book: that preference measured under a fixed gesture is the same thing as musical taste. This page is where that assumption is examined and found to be doing more work than it can carry.
The argument is not new here. It is already in this book, made once, in the page that replaced the v1 phrase with the v2 one:
So the grammar could express patches the audition could never reveal, and the taste model was being asked to learn preferences over evidence that was not in . No amount of model improvement fixes that; it is a measurement problem.
From The standard phrase.
That reasoning was applied to four holes (slow pads, sub-Hz modulation, anything above Eb4, and polyphonic stacking), and each was closed with the cheapest segment that revealed it. Then it stopped. Everything below is the same sentence, still true, about the things the v2 phrase did not reach.
Why this is a separate register
Unraised directions holds possibilities the design never considered. This page holds something different and less comfortable: one premise the design commits to everywhere, and the eight places that commitment costs something. An entry there is an opportunity; an entry here is a limit on what any amount of modeling can currently buy.
| # | What cannot be heard | Costs |
|---|---|---|
| 1 | How a patch responds to playing, velocity above all | The object being modeled |
| 2 | Modulation rate and shape | The instrument’s best feature, unrewardable |
| 3 | How loud a patch natively is | A whole axis of ordinary preference |
| 4 | How a patch sits against other material | Why isolated judgments mislead |
| 5 | Anything tempo-relative | The difference between a sound and a part |
| 6 | What the player is looking for | Converges on the average of a taste |
| 7 | (a conflation rather than a gap) | A free elicitation improvement, unclaimed |
| 8 | (an asset already fitted, unexposed) | A control only this project could offer |
Seven and eight are not limits of the audition; they are places where the same premise has quietly shaped the product rather than the measurement. They are here because they come from the same root.
1. A patch is a function from performance to sound, and it is sampled once
NoteSpan, the whole of what the renderer knows about a note, is:
pub struct NoteSpan {
pub voct: f64, // pitch, V/Oct from C4
pub chord: usize, // additional gate-synced voices
pub on_start: usize,
pub on_end: usize,
}
There is no velocity field, anywhere in the render path. Every audition note is struck identically.
Three feet away in the same repository, the live instrument takes MIDI velocity, pitch bend, and sustain, and the guide documents playing it that way. So the instrument responds to a dimension the measurement holds constant.
The consequence is not a missing coordinate; it is a category error about what a candidate is. A patch is a function from performance to sound. evaluates that function at a single point and hands the result to a model that then speaks about patches. Everything downstream (, the styles, the map, and the calibration) is a faithful model of preference over point samples of instruments, presented as a model of preference over instruments.
What is invisible as a result, in rough order of how much a player would care:
- Velocity response. The single largest determinant of whether a patch is playable at all.
- Note-length behavior. Whether it blooms, or only holds.
- Retrigger under fast playing. Whether the envelope survives a run.
- Key tracking, beyond the one octave-up check
high_ratioperforms. - The arpeggiator. It ships in the instrument and appears in no audition.
This is the one thing on this page that cannot be fixed inside . Every other entry is a coordinate or a surface; this is a change to what is being measured. The in-idiom version is exactly the move the v2 phrase already made: a second render at a different velocity and articulation, contributing difference coordinates (brightness per velocity, level per octave) rather than more absolute ones. Two renders instead of one is the same cost-for-coverage trade the v2 phrase paid knowingly, and the segment-local coordinates are the precedent for measuring a contrast rather than a level.
2. Modulation is the distinctive claim, and its rate is not measured
The instrument’s distinguishing feature is that modulation is a whole chain: an
s&h rand → quantize → slew can reach a cutoff, the module list exists to
show where a modulator may legally go, and nearly every module carries a mod
slot.
Before the motion bands, this was everything recorded about that:
| Coordinate | Says |
|---|---|
mod_density (struct) | how much of the term is modulated |
mod_depth_mean (struct) | how deep the modulation is |
centroid_std (audio) | how much brightness moved over the phrase |
held_centroid_std (audio) | how much it moved within one held note |
Rate appeared nowhere. Shape appeared nowhere. A 0.3 Hz filter sweep and a 7 Hz tremolo at equal depth landed on near-identical coordinates. A random sample-and-hold and a sine LFO were indistinguishable to every one of them.
So “I like slow evolving movement and dislike fast wobble” was not a preference that is hard to learn. It was inexpressible, in exactly the sense the log-axis argument uses about brightness on a linear-Hz axis, and for the same reason: the model is linear in , so a distinction absent from the coordinates is absent from the hypothesis space.
The second-order consequence is worse than the first. Proposal tilts read the
structural coefficients,
and mod_density is one of them, so the tilt can learn more modulation
and cannot learn slower modulation. Before the motion bands the fitness
could not either, and the instrument’s most distinctive capability was the one
the search could not be rewarded for using well.
The measurement already has its window open. The held note exists, in the words of its own page, to reveal “sub-Hz modulation over a register-constant sustain”. The segment was built for this and then not measured for it. An autocorrelation of the centroid or RMS envelope across that span yields two coordinates:
- dominant modulation rate, on the shared octave axis every other frequency in already uses, and
- periodicity strength: how much of the movement is periodic at all, which is what separates an LFO from a random walk.
If two numbers could be added to , these are the two.
Status: rate is measured now; periodicity was tried and cannot be, in this window. Rate arrived as three motion bands rather than one dominant-rate number: a single “rate” coordinate is undefined for a static patch and meaningless for a random walk, while band energies are defined for everything and let a linear model hold slow yes, fast no as two coefficients of opposite sign. On the probe ladder the band that reads highest follows the LFO rate from 0.55 Hz to 13 Hz. Periodicity failed its own test: both an autocorrelation peak and the harmonic share of the modulation spectrum separate an LFO from a random walk at 2.7 Hz and above and not at all below 1.5 Hz, because the held span holds fewer than three slow cycles. That is the range evolving textures live in, so the coordinate was not shipped; it is now a stimulus-length question, and belongs with §1.
3. Loudness is normalized away, and the raw level is already computed
Normalizing every render to −18 LUFS is correct, and the reason given is the right one: loudness bias would poison the preference data.
The other half of that trade is not stated anywhere. Loudness is part of a
sound’s identity. Hits hard, sits back, has weight are ordinary,
strongly-held preferences, and they have been normalized out of existence by
design. crest and rms_std recover dynamics within a patch; absolute level
is gone.
What makes this an easy win rather than a lament is that the number already
exists. VetReport measures peak
and rms on the raw, pre-normalization render (deliberately, because the
gate’s thresholds are about real output level), and Features keeps both,
beside the raw loudness lufs_before. None of them reaches . The
quantity how loud is this patch natively is computed on every candidate this
project has ever rendered.
Adding as a coordinate reintroduces no playback bias, because playback stays normalized. The confound the loudness decision guards against is between what the player hears and what they are comparing, and a feature the model reads is neither. It simply lets the model see an axis that is currently invisible.
The honest caveat, which decides it: raw level is partly an artifact of gain staging inside the grammar rather than a property anyone hears. That makes it possibly noise, and it makes the question a single sweep on existing data rather than an argument.
4. Everything is judged in a silent room
No mix context, no drums, no key, no other material. Every duel asks which of these two do you prefer, in silence.
Musicians almost never choose sounds absolutely. The bass that wins in
isolation is routinely the one that disappears under a kick; the pad that wins
alone is the one that eats the vocal. carries bass_fraction and
three brightness measures, but nothing about spectral occupancy relative to
other material, because there is no other material for it to be relative to.
This is probably the deepest reason isolated-audition tools feel wrong to practitioners in a way that is hard to articulate, and it bites harder here than elsewhere: the premise of this project is that the instrument is learning what the player likes, and what it is learning is what they like in an empty room.
The scope that would test it is not “build a DAW”. One player-supplied backing loop, played underneath the audition, changes what every pick means, and it interacts directly with §3, because level against a bed is exactly the judgment normalization removes.
5. There is no tempo, and the presets are working around it by hand
An LFO’s rate is sampled as u01() and mapped to Hz. The live instrument has a
tempo (the arpeggiator’s, which MIDI clock can set) and a SYNC switch, but SYNC
reaches only step sequencers: it snaps each one’s rate to the nearest division
of the tempo. LFO rates, and everything the audition renders, have no tempo at
all. A Clock tempo exists for the euclidean sequencer’s own bpm port, and it
is not a session-level musical clock.
The evidence that this costs something is in this repository’s own preset source, in comments:
rate: 0.55, // ≈83 bpm
rate: 0.62, // ≈107 bpm
p0: 0.55, // ≈83 bpm
Preset authors are hand-computing, in comments, the thing a sync control would compute for an LFO. An unsynced modulator is among the most common reasons a good patch is unusable in a track: it can be set close by ear and it drifts over sixteen bars.
For an instrument whose output is meant to end up in music, tempo-relative rates are not a convenience. They are the difference between a sound and a part, and they would also give §2 a natural axis to express rate on, since a player’s preference about movement is far more plausibly “a cycle per bar” than “0.34 Hz”.
6. The loop is selection; sound design is pursuit
A duel asks which do you prefer. A player with a sound in their head wants get closer to this.
Locks plus ⚡ evolve from this is a genuine pursuit affordance and a good one.
But the primary loop is judging what the player is handed, and the
target-directed
entry in the other register is better understood from here: it is not a feature,
it is the missing half of a workflow.
There is a sharper form of this, and it is about what the model converges to. A preference fitted over everything a person has ever voted on approaches the average of their taste. Max-of-experts answers part of it (islands rather than a centroid, which is the whole reason for the max), but the islands are discovered from , not selected by intent. So on any given evening the instrument proposes toward the union of everything the player has ever liked, when what they need is the one corner they are working in tonight.
That is the musical argument for declared context, and it is a stronger one than the modeling argument on that page. It is the difference between a tool that knows the player and a tool that is useful today.
7. Comparability constrains the measurement, not the listener
The decisions log records the audition as “Standard 5.05 s phrase + free-play”, with the rationale “feature comparability requires fixed stimulus”.
The rationale is true of . It is not true of the person.
comes from a deterministic offline render. What a human hears while deciding is an independent choice, and could be anything at all, including the player’s own playing, on both patches, with the same lick. The coordinates would remain exactly as comparable, because nothing about them depends on what came out of the speakers during the vote.
So a duel in the player’s own hands costs nothing. It is a far more musical elicitation than voting on four notes chosen by the instrument, and it is the direct remedy for §1: a player testing velocity response themselves is measuring the dimension the phrase holds constant, even if the coordinates still cannot see it.
The cost, stated rather than hidden. The vote becomes a judgment about an
experience the coordinates only partly describe. That adds noise, and adds bias
if a player’s own gestures systematically emphasize something the phrase does
not. But it is noise in the measurement of something a player cares about,
against precision about something they do not. The project already owns the
instrument that would detect it, since
calibration by provenance exists
precisely to score differently-collected answers against each other rather than
assume they are equivalent. A PlayedDuel provenance would make this
measurable on arrival.
8. Six fixed controls, when the personal axis is already fitted
The rack exposes every trace address, and the word macro does not appear in the web app. A player performing wants two to four hands-on controls, not one per address in the term. PERFORM’s six named controls (Bright, Snap, Motion, Body, Grit, and Space) are macros in all but name, but each is a fixed direction in , the same for every player (Performance).
The interesting part is that the fitted model already contains the personal axes. A style is a direction in feature space whose meaning is what this player cares about. A macro that moves a patch along the model’s top learned direction is a control that is personal by construction (bite for one player, movement for another), and nothing else can offer it, because nothing else has fitted the model.
It is also the continuous form of the counterfactual explainer: same posterior, same trace addresses, same predicted-gain arithmetic. One reports a discrete edit; the other puts the direction under a finger.
If these were ordered by value per unit of work
- Two coordinates for modulation rate and periodicity (§2). Rate done, as three motion bands; periodicity blocked on stimulus length. Cheapest and highest value: it lets the search be rewarded for the instrument’s best feature, and the segment it needs already exists for this exact purpose.
log(raw rms)as a coordinate (§3). Already measured on every candidate, currently discarded; recovers an axis of ordinary preference at zero audition cost.- Velocity in the phrase, as difference coordinates (§1). The expensive one, and the only one that moves the modeled object from a sound toward an instrument.
- Tempo-relative modulation rates (§5). Turns output into something usable in a track, and gives §2 its natural axis.
- Declared context (§6), promoted from the other register on musical grounds.
- Duels in the player’s own hands (§7). Nearly free, conceptually the largest change, and measurable via provenance from the day it ships.
The summary worth keeping
This is an unusually rigorous instrument for measuring timbre preference under a fixed gesture, and much of the design reads as though that were the same thing as musical taste. It is not, and the gap between them is where every entry above lives.
The tool for closing it is already in the book. The grammar can express what the audition cannot reveal, and no amount of model improvement fixes a measurement problem: applied once, to four holes, and it deserves to be applied to the rest.
API documentation
Generated rustdoc for every crate in the workspace.
- auracle_grammar: the genome, typed PCFG, trace codec, compiler, structural edits, presets
- auracle_features: render, vet, LUFS-normalize, extract
- auracle_taste: the utility model, three likelihoods, MCMC posterior, standardization
- auracle_session: the two-loop engine, acquisition, calibration, persistence
- auracle_wasm:
WasmEngineandLivePoly
Built with cargo doc --workspace --no-deps, so the dependency crates are not
included. quiver, fugue-ppl
and fugue-evo have their own docs on docs.rs.
Where to start
The doc comments in this codebase carry a lot of the reasoning, and a few are worth reading directly rather than through this book’s summary of them:
| For | Read |
|---|---|
| The grammar’s site table | auracle_grammar::prior module docs |
| Why has families rather than per-module columns | auracle_features::structural module docs |
| The max-of-experts argument, and the correction | auracle_taste::model module docs |
| Why the standardizer’s threshold is | RUNAWAY_RATIO in auracle-taste’s standardize.rs (private, so read it in the source) |
| Why refinement is 40 steps × 10 seeds | auracle_session::SessionConfig::refine_steps |
| The acquisition measurement, in full | auracle_session::Acquisition |
| Why accuracy was replaced by Brier skill | auracle_session::calib module docs |
Building it locally
cargo doc --workspace --no-deps --open
# or, the target this site uses:
make site-api
What rustdoc covers
The generated docs are authoritative about signatures and invariants, and they are where the numbers live. They are deliberately quiet about the pipeline: no rustdoc page explains why vetting has to run before normalization, because that fact belongs to no single item.
That is the division of labor between this book and the API docs: the book owns the reasoning that spans crates, and rustdoc owns the reasoning that fits beside a definition.
Bibliography
The literature Auracle’s methods come from, grouped by where they appear. These are the specific results the implementation relies on, not a survey.
Preference learning
Bradley, R. A. and Terry, M. E. (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons. Biometrika 39(3–4), 324–345. → The duel likelihood, . Used in
Chu, W. and Ghahramani, Z. (2005). Preference Learning with Gaussian Processes. ICML. → The framing of preference data as observations of a latent utility. Auracle’s utility is linear in a fixed feature map rather than a GP, which is a deliberate trade of flexibility for interpretability and a tractable cold start.
McCullagh, P. (1980). Regression Models for Ordinal Data. JRSS B 42(2), 109–142. → The cumulative-logit model with learned cutpoints, which is how star ratings are treated as ordinal rather than as numbers. Used in
Brochu, E., de Freitas, N. and Ghosh, A. (2007). Active Preference Learning with Discrete Choice Data. NIPS. → Preferential Bayesian optimization: the loop of latent utility + expensive human oracle + cheap surrogate that Auracle’s two loops implement.
Active learning and acquisition
Houlsby, N., Huszár, F., Ghahramani, Z. and Lengyel, M. (2011). Bayesian Active Learning for Classification and Preference Learning. arXiv:1112.5745. → BALD: expected information gain about the parameters. Implemented and selectable; it ties uniform pairing on this problem at session horizon.
Yue, Y., Broder, J., Kleinberg, R. and Joachims, T. (2012). The K-armed Dueling Bandits Problem. JCSS 78(5), 1538–1556. → The dueling-bandit framing, and by extension the Thompson rule that measurably loses here because it optimizes best-arm identification rather than parameter recovery.
Calibration and scoring
Brier, G. W. (1950). Verification of Forecasts Expressed in Terms of Probability. Monthly Weather Review 78(1), 1–3. → The proper scoring rule that replaced accuracy. Why that mattered
Gneiting, T. and Raftery, A. E. (2007). Strictly Proper Scoring Rules, Prediction, and Estimation. JASA 102(477), 359–378. → What “proper” means, and why a rule that is not proper can be gamed by a model that hedges.
Dawid, A. P. (1984). Present Position and Potential Developments: Some
Personal Views. Statistical Theory: The Prequential Approach. JRSS A 147(2),
278–292. → Prequential evaluation: score each forecast before seeing its
outcome. This is exactly what record_duel does, and it is what makes the
reliability diagram out-of-sample.
DeGroot, M. H. and Fienberg, S. E. (1983). The Comparison and Evaluation of Forecasters. The Statistician 32, 12–22. → Reliability diagrams, and the calibration/refinement decomposition that explains why the shape of the failure is more informative than the scalar.
Monte Carlo
Metropolis, N. et al. (1953). Equation of State Calculations by Fast Computing Machines. J. Chem. Phys. 21(6), 1087–1092. Hastings, W. K. (1970). Monte Carlo Sampling Methods Using Markov Chains and Their Applications. Biometrika 57(1), 97–109. → The sampler.
Green, P. J. (1995). Reversible Jump Markov Chain Monte Carlo Computation and Bayesian Model Determination. Biometrika 82(4), 711–732. → Trans-dimensional moves: what a structural proposal is, since it changes the set of sites. Handled by fugue rather than by Auracle.
Del Moral, P., Doucet, A. and Jasra, A. (2006). Sequential Monte Carlo Samplers. JRSS B 68(3), 411–436. → Tempered SMC: the designed generation mechanism, and not what currently ships.
Kong, A., Liu, J. S. and Wong, W. H. (1994). Sequential Imputations and Bayesian Missing Data Problems. JASA 89(425), 278–288. → Effective sample size , the degeneracy diagnostic that signals a refit is due.
Douc, R. and Cappé, O. (2005). Comparison of Resampling Schemes for Particle Filtering. ISPA. → Systematic resampling, chosen over multinomial for determinism.
Stephens, M. (2000). Dealing with Label Switching in Mixture Models. JRSS B 62(4), 795–809. → Why per-component summaries of a mixture posterior need post-hoc alignment.
Numerics
Mächler, M. (2012). Accurately Computing log(1 − exp(−|a|)), Assessed by
the Rmpfr Package. Vignette of the R package Rmpfr. → log1mexp, the
that keeps a star rating’s log-probability exact far outside
its cutpoints and in a category squeezed nearly shut.
Used in
Probabilistic programming
Goodman, N. D. and Stuhlmüller, A. (2014). The Design and Implementation of Probabilistic Programming Languages. dippl.org. → The model-as-program framing that fugue implements and that makes the grammar a prior rather than a generator function.
Ritchie, D., Horsfall, P. and Goodman, N. D. (2016). Deep Amortized Inference for Probabilistic Generative Models. arXiv:1610.05735. → Context for what trace-based inference over structured programs makes possible.
Grammar-based genetic programming
Whigham, P. A. (1995). Grammatically-based Genetic Programming. Workshop on Genetic Programming. → Using a grammar to constrain the search space so every individual is valid: Auracle’s representation decision, with types in place of production rules.
Koza, J. R. (1992). Genetic Programming: On the Programming of Computers by Means of Natural Selection. MIT Press. → Tree-based GP, and the bloat problem that a prior rather than a penalty addresses. Auracle takes the tree representation and not Koza’s subtree crossover: every child grows from one seed.
Takagi, H. (2001). Interactive Evolutionary Computation: Fusion of the Capabilities of EC Optimization and Human Evaluation. Proc. IEEE 89(9), 1275–1296. → The canonical statement of interactive evolution’s user-fatigue bottleneck, which is the problem the two-loop architecture and the learned surrogate exist to solve.
Audio features and loudness
ITU-R BS.1770-4 (2015). Algorithms to measure audio programme loudness and true-peak audio level. → K-weighting, 400 ms gated blocks, the two gates. Implemented here
EBU R 128 (2020). Loudness normalisation and permitted maximum level of audio signals. → The practice around BS.1770 that makes −18 LUFS a sensible target.
Peeters, G. (2004). A large set of audio features for sound description. CUIDADO project report, IRCAM. → Spectral centroid, spread, flatness, rolloff, and flux, in the definitions φ_audio uses.
Bregman, A. S. (1990). Auditory Scene Analysis. MIT Press. → Background for why octave-based frequency axes and segment-local measurements are the right coordinates for a perceptual feature vector.
Dau, T., Kollmeier, B. & Kohlrausch, A. (1997). Modeling auditory processing of amplitude modulation. I. Detection and masking with narrow-band carriers. JASA 102(5), 2892–2905. → The modulation filterbank: hearing sorts envelope fluctuation by rate. Why motion is measured in bands and not as one variance.
McDermott, J. H. & Simoncelli, E. P. (2011). Sound texture perception via statistics of the auditory periphery. Neuron 71(5), 926–940. → Band-wise modulation power is much of what makes a texture recognizable; the grounding for treating motion rate as a first-class axis of taste.
Statistics of the feature space
Belsley, D. A., Kuh, E. and Welsch, R. E. (1980). Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley. → Variance inflation factors, the diagnostic that found two exact dependencies in φ_struct.
Haufe, S., Meinecke, F., Görgen, K., Dähne, S., Haynes, J.-D., Blankertz, B. and Bießmann, F. (2014). On the Interpretation of Weight Vectors of Linear Models in Multivariate Neuroimaging. NeuroImage 87, 96–110. → A direction’s pattern versus its filter , and why a wiring tried and not shipped.
Schäfer, J. and Strimmer, K. (2005). A Shrinkage Approach to Large-Scale Covariance Matrix Estimation and Implications for Functional Genomics. Statistical Applications in Genetics and Molecular Biology 4(1), Article 32. → The shrinkage that made that measurement fair.
Huber, P. J. (1981). Robust Statistics. Wiley. → Winsorizing, and the reasoning behind using it as a fault detector rather than routinely.
The libraries
- quiver: github.com/alexnodeland/quiver · docs.rs
- fugue-evo: github.com/alexnodeland/fugue-evo · docs.rs
- fugue-ppl: docs.rs
Lineage
Auracle is the third iteration of one idea, and the two before it are worth knowing about because what each lacked is what this one is for:
| Iteration | Year | Proved | Lacked |
|---|---|---|---|
| neuralCompressor (C++/Arduino pedal) | 2020 | The interaction model: human-driven GA, fit/unfit footswitch, mutate/crossover knobs | The engine: neither the EA nor the DSP was ever implemented |
| evosynth v1 (Next.js/Tone.js + FastAPI/DEAP) | 2025 | A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage tracking | Preference persistence (ratings died each generation), topology evolution, principled inference |
| Auracle | 2026– | (open) | (open) |
v0 had the interaction but no engine. v1 had an engine, but a naive one with no memory of the user.