Skip to main content

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}