φ_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 "I like my 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 judgement 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 log-axised:
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 recognisable as a texture at all (McDermott & Simoncelli 2011). The three coordinates are that filterbank, cut to three bands.
Over the held span, starting once the note has arrived so the attack is not
read as motion — 250 ms after onset, or later if the level (smoothed over
≈ 46 ms) has not yet reached 97% of its peak — two trajectories are taken at a
256-sample hop (≈ 172 frames/s — the spectral features' own 43 frames/s would
fold the fast band): brightness
in octaves, and level
, where one unit
is 6 dB — one doubling, the same currency as an octave of brightness — and 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 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", "long tail" are things the DIRECTIONS tab can name and a person can recognise in their own preferences.
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 1200 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.4 |
zcr_mean | ≈ 10.4 |
centroid_mean | ≈ 5.9 |
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). The right fix is a shared or fused prior over the cluster, which is a modelling change rather than a feature change, and is not done.
For contrast, φ_struct had two exact linear
dependencies, which is a different and worse problem and was fixed by dropping
columns.