auracle_features/phrase.rs
1//! The standard audition phrase.
2//!
3//! Audio features are only comparable across patches when every patch is
4//! rendered under an **identical stimulus** — same notes, same timing, same
5//! RNG seed for noise. This module owns that stimulus.
6//!
7//! ## The v2 phrase, and why each segment exists
8//!
9//! The original 3-note phrase (0.6 s stab / 0.25 s stab / 0.8 s low note) was
10//! the loop's weakest link, and the deficit *compounded* with every
11//! correctness fix: it could not discriminate slow pads (a 2 s attack was
12//! silent for most of the stimulus), anything modulated below ~1 Hz (no
13//! register-constant segment long enough to hold a modulation cycle),
14//! anything above Eb4 (its highest note), or how a patch stacks
15//! polyphonically (strictly monophonic) — so the grammar could express
16//! patches the audition could never reveal, and the taste model was asked to
17//! learn preferences over evidence that wasn't in φ. The v2 default covers
18//! each hole with the cheapest segment that reveals it:
19//!
20//! 1. **C4 held 1.8 s** — the attack window (onset → next onset) is now
21//! 2.0 s instead of 0.75 s, and a register-constant sustain long enough
22//! that sub-Hz modulation completes most of a cycle
23//! ([`crate::audio::AudioFeatures::held_centroid_std`] measures it here).
24//! 2. **C5 stab** — one octave above the old ceiling; with the fixed 0.5
25//! keytracking this is where dark patches reveal whether they speak at all
26//! up high ([`crate::audio::AudioFeatures::high_ratio`]).
27//! 3. **C4+E4 dyad** ([`Note::chord`]) — a second compiled voice, gate-synced
28//! with the main voice, reveals intermodulation and mud when stacked
29//! ([`crate::audio::AudioFeatures::chord_flatness_delta`]). A dyad rather
30//! than a triad because render cost is per-voice-second and pairwise
31//! intermodulation is the first-order phenomenon.
32//! 4. **C3 held + 1.1 s release window** — bass register, and kept *last* so
33//! the tail measurement (final 300 ms) still sees release length and
34//! delay/reverb tails, not a truncated chord decay.
35//!
36//! ~5.0 s of audio, ~2× the render cost of v1 (measured; the dyad's second
37//! voice is the difference between wall seconds and rendered voice-seconds).
38//! Changing the stimulus changes what every audio feature *means*, which is
39//! why [`crate::audio::AudioFeatures::NAMES`] carry a stimulus tag — see the
40//! migration note there.
41
42use serde::{Deserialize, Serialize};
43
44/// One note of the phrase.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Note {
47 /// Pitch as V/Oct offset from C4.
48 pub voct: f64,
49 /// Gate-on duration, seconds.
50 pub on_s: f64,
51 /// Gate-off duration after the note, seconds.
52 pub off_s: f64,
53 /// Additional simultaneous pitches (V/Oct from C4), each rendered by its
54 /// own compiled voice, gate-synced with this note. Empty for a mono note.
55 ///
56 /// Chord voices start cold at this note's onset (exactly how live voice
57 /// allocation behaves) and, after the shared gate closes, keep ticking
58 /// until their own output parks on silence — a truncated release tail is
59 /// a broadband click that would poison every spectral feature.
60 #[serde(default)]
61 pub chord: Vec<f64>,
62}
63
64/// The audition stimulus: notes, sample rate, and the RNG seed used for any
65/// stochastic module (noise, drift) so renders are bit-reproducible.
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct PhraseSpec {
68 /// Sample rate in Hz.
69 pub sample_rate: f64,
70 /// Seed installed into quiver's thread-local RNG before rendering.
71 pub seed: u64,
72 /// The phrase notes, played in order.
73 pub notes: Vec<Note>,
74}
75
76impl Default for PhraseSpec {
77 fn default() -> Self {
78 Self {
79 sample_rate: 44_100.0,
80 seed: 0xE05_F00D,
81 notes: vec![
82 // Held root: C4, long enough that slow attacks and sub-Hz
83 // modulation are audible facts rather than invisible ones.
84 Note {
85 voct: 0.0,
86 on_s: 1.80,
87 off_s: 0.20,
88 chord: Vec::new(),
89 },
90 // High stab: C5 — the register the old phrase never visited.
91 Note {
92 voct: 1.0,
93 on_s: 0.30,
94 off_s: 0.15,
95 chord: Vec::new(),
96 },
97 // Stacked dyad: C4 + E4 on a second voice.
98 Note {
99 voct: 0.0,
100 on_s: 0.50,
101 off_s: 0.20,
102 chord: vec![4.0 / 12.0],
103 },
104 // Low held note with a long release window: C3, kept last so
105 // the tail features measure release/reverb, not a chord cut.
106 Note {
107 voct: -1.0,
108 on_s: 0.80,
109 off_s: 1.10,
110 chord: Vec::new(),
111 },
112 ],
113 }
114 }
115}
116
117impl PhraseSpec {
118 /// Total rendered length in samples.
119 pub fn total_samples(&self) -> usize {
120 self.notes
121 .iter()
122 .map(|n| ((n.on_s + n.off_s) * self.sample_rate) as usize)
123 .sum()
124 }
125
126 /// Total rendered length in seconds.
127 pub fn total_seconds(&self) -> f64 {
128 self.notes.iter().map(|n| n.on_s + n.off_s).sum()
129 }
130
131 /// Largest number of voices gated on simultaneously anywhere in the
132 /// phrase (1 for a purely monophonic spec). The vet gate's peak ceiling
133 /// scales with this: N legitimate voices can legitimately sum to N× one
134 /// voice's level, and that summing is signal, not runaway.
135 pub fn max_voices(&self) -> usize {
136 1 + self.notes.iter().map(|n| n.chord.len()).max().unwrap_or(0)
137 }
138}