auracle_session/naming.rs
1//! Musical names for patches, read off the measured features.
2//!
3//! A patch's topology signature (`ssaw·lp·ladr`, `noiz`) is precise and
4//! useless as a *name*: it describes the circuit, not the sound, and it
5//! collides constantly — a pool of prior draws produces six consecutive
6//! `noiz` rows, which is six rows the user cannot tell apart or refer to.
7//! Names are how people hold a bank of sounds in their head, and every synth
8//! that has ever shipped knows it: *First Bass*, *Cathedral*, *Glass Pad*.
9//!
10//! So a name here is `<character> <role>` — read off the extracted features
11//! and the amp envelope, not off the module list. `Bright Pluck` is a claim
12//! about the render the user can check with their ears; it stays true if the
13//! same sound is reached by a different circuit, and it changes when the sound
14//! changes. The signature stays available as separate metadata for anyone who
15//! wants the circuit.
16//!
17//! ## Why the buckets are quantiles and not thresholds
18//!
19//! The obvious implementation — a ladder of `if` tests against absolute
20//! feature values, first match wins — was measured in the running app and
21//! **concentrated catastrophically**: 13 of 40 bank rows came out `Glass Pad`,
22//! with numerals running to `Glass Pad 12`. Two independent causes, both
23//! structural rather than a matter of picking better constants:
24//!
25//! - The first test in the ladder claimed roughly half the pool by itself.
26//! Attack time is close to log-uniform over 1 ms–10 s, so *any* fixed attack
27//! threshold in the middle of that range splits the pool about evenly, and
28//! whichever branch is tested first swallows half the alphabet's traffic.
29//! - The standard audition phrase deliberately changes register (C4→Eb4→C3),
30//! which moves the spectral centroid of almost any un-lowpassed patch by
31//! roughly the amount an early `centroid_std` test was keyed to. That test
32//! matched nearly everything, so every character below it was unreachable.
33//!
34//! A nominal alphabet of ~110 names was delivering an effective 8–12. The fix
35//! is not better constants — any fixed constant is wrong for a pool that
36//! drifts as evolution concentrates it. Each axis is bucketed by **terciles of
37//! the pool's own distribution** ([`NameScale`]), so a bucket is one third of
38//! whatever the pool happens to be and the marginals stay flat however the
39//! pool moves. Role and character are then a 3×3 grid each: 81 names with
40//! uniform marginals by construction, instead of a ladder whose branches race.
41//!
42//! This makes a name *relative* to the bank it lives in — the same patch can
43//! be `Bright Pluck` in a dark bank and `Warm Pluck` in a bright one. That is
44//! the right trade: a name earns its keep by telling apart the patches in
45//! front of you, not by being a global coordinate. The topology signature is
46//! still there for anyone who wants an absolute one.
47//!
48//! ## Why quantiles alone would lie
49//!
50//! Terciles put a third of the pool in each bucket **whatever the pool is**.
51//! That is the property that fixes the concentration bug, and it is also a
52//! way to lie: hand this scheme forty patches that genuinely all sound like
53//! the same pad and it will still deal out thirty distinct names and tell the
54//! user they are thirty different things. "Thirteen of these are the same
55//! kind of pad" was bad UI when the old thresholds said it by accident, but
56//! it may well have been *true*.
57//!
58//! So each axis also carries a **just-noticeable difference** ([`Jnd`]): if
59//! the pool's own tercile cuts fall closer together than a listener could
60//! plausibly hear, that axis collapses to a single bucket and stops
61//! contributing to the name. A genuinely varied bank gets its full alphabet;
62//! a genuinely uniform one gets `Warm Pad`, `Warm Pad 2`, `Warm Pad 3`, which
63//! is the honest report. The numeral then carries real information — it says
64//! *these are variations of one thing*, not *the namer ran out of ideas*.
65//!
66//! These floors are the one place absolute constants belong here. They encode
67//! perception, which does not move when the pool does — unlike the thresholds
68//! this module started with, which were absolute claims about *pool
69//! structure* and were wrong the moment the pool drifted.
70
71use auracle_features::Features;
72use std::collections::HashSet;
73
74/// Role grid, indexed `[attack tercile][sustain tercile]` — articulation is
75/// what decides what you would reach for a sound *to do*.
76const ROLES: [[&str; 3]; 3] = [
77 ["Pluck", "Stab", "Lead"], // fast attack
78 ["Bell", "Key", "Drone"], // medium attack
79 ["Swell", "Pad", "Wash"], // slow attack
80];
81
82/// Character grid, indexed `[centroid tercile][flatness tercile]` — the
83/// adjective a musician reaches for first is brightness crossed with grit.
84const CHARACTERS: [[&str; 3]; 3] = [
85 ["Warm", "Round", "Murky"], // dark
86 ["Fat", "Soft", "Gritty"], // mid
87 ["Glass", "Bright", "Noisy"], // bright
88];
89
90/// Smallest difference on each axis worth giving a different word to.
91///
92/// Perceptual, not statistical: a pool whose whole spread sits inside one of
93/// these is a pool the listener hears as one thing, however cleanly the
94/// quantiles slice it.
95///
96/// - `CENTROID`: the brightness axis is `log_axis`, on which one octave at
97/// 44.1 kHz is ≈ 0.099. Half an octave is a real timbral step; less is not.
98/// - `FLATNESS`: tonal-to-noisy over `[0, 1]`.
99/// - `ATTACK`: `ln(attack + 5 ms)`, so this is a ratio — ≈ 1.4× longer.
100/// - `SUSTAIN`: normalized envelope sustain over `[0, 1]`.
101struct Jnd;
102impl Jnd {
103 const CENTROID: f64 = 0.05;
104 const FLATNESS: f64 = 0.06;
105 const ATTACK: f64 = 0.35;
106 const SUSTAIN: f64 = 0.12;
107}
108
109/// Tercile cut points of one feature over the reference pool, or `None` when
110/// the pool's spread on that axis is below hearing.
111#[derive(Clone, Copy, Debug, Default)]
112struct Cuts {
113 lo: f64,
114 hi: f64,
115 /// False when the pool does not actually vary on this axis.
116 active: bool,
117}
118
119impl Cuts {
120 /// Which third of the pool a value falls in; the middle bucket for
121 /// everything when the axis is inactive, so it contributes no word.
122 fn bucket(&self, x: f64) -> usize {
123 if !self.active {
124 1
125 } else if x < self.lo {
126 0
127 } else if x < self.hi {
128 1
129 } else {
130 2
131 }
132 }
133}
134
135fn terciles(mut values: Vec<f64>, jnd: f64) -> Cuts {
136 if values.is_empty() {
137 return Cuts::default();
138 }
139 values.sort_by(f64::total_cmp);
140 let n = values.len();
141 let (lo, hi) = (values[n / 3], values[(2 * n) / 3]);
142 // Judge the *whole* span, not the inter-cut gap. Requiring the middle
143 // two thirds to be spread as well sounds stricter but is wrong: a pool of
144 // mostly-tonal patches with a few genuinely noisy ones has tight terciles
145 // and an audible range, and suppressing the axis there would cost the
146 // noisy outliers the only word that describes them. Where the span is
147 // real but the mass is not, the cuts collapse together on their own and
148 // the axis quietly contributes one word to everybody — the same outcome,
149 // reached by arithmetic rather than by a second threshold.
150 let span = values[n - 1] - values[0];
151 Cuts {
152 lo,
153 hi,
154 active: span >= jnd,
155 }
156}
157
158/// The naming scale: where this pool's terciles actually fall.
159///
160/// Fit on the bank the names will be shown against, so the alphabet stays
161/// populated whatever the pool looks like — see the module doc for why fixed
162/// thresholds do not.
163#[derive(Clone, Copy, Debug, Default)]
164pub struct NameScale {
165 attack: Cuts,
166 sustain: Cuts,
167 centroid: Cuts,
168 flatness: Cuts,
169}
170
171impl NameScale {
172 /// Fit terciles from the pool the names will be shown against.
173 pub fn fit<'a>(pool: impl Iterator<Item = &'a Features> + Clone) -> Self {
174 Self {
175 attack: terciles(
176 pool.clone().map(|f| f.audio.attack_s).collect(),
177 Jnd::ATTACK,
178 ),
179 sustain: terciles(
180 pool.clone().map(|f| f.structural.amp_sustain).collect(),
181 Jnd::SUSTAIN,
182 ),
183 centroid: terciles(
184 pool.clone().map(|f| f.audio.centroid_mean).collect(),
185 Jnd::CENTROID,
186 ),
187 flatness: terciles(pool.map(|f| f.audio.flatness_mean).collect(), Jnd::FLATNESS),
188 }
189 }
190
191 /// `<character> <role>` for one candidate — e.g. `Bright Pluck`,
192 /// `Noisy Wash`, `Warm Drone`, `Glass Lead`.
193 ///
194 /// Not unique on its own; [`claim_name`] makes a set of them distinct.
195 pub fn name(&self, f: &Features) -> String {
196 let role = ROLES[self.attack.bucket(f.audio.attack_s)]
197 [self.sustain.bucket(f.structural.amp_sustain)];
198 let character = CHARACTERS[self.centroid.bucket(f.audio.centroid_mean)]
199 [self.flatness.bucket(f.audio.flatness_mean)];
200 format!("{character} {role}")
201 }
202}
203
204/// Take `base` if free, else the first `base N` that is, recording the claim.
205///
206/// Every name in the bank goes through here — **including user-given and
207/// preset ones**. Letting those bypass collision detection while still
208/// occupying the name is what allowed two bank rows to both read exactly
209/// `Glass Pad`: `Glass Pad` is a preset name, and the generator is entitled to
210/// produce it too.
211pub fn claim_name(base: &str, taken: &mut HashSet<String>) -> String {
212 if taken.insert(base.to_string()) {
213 return base.to_string();
214 }
215 // Start at 2: the unsuffixed name is conceptually "1".
216 for k in 2..usize::MAX {
217 let candidate = format!("{base} {k}");
218 if taken.insert(candidate.clone()) {
219 return candidate;
220 }
221 }
222 unreachable!("name space exhausted")
223}