Skip to main content

auracle_features/
render.rs

1//! Headless rendering of a compiled voice under the standard phrase.
2//!
3//! Determinism contract: quiver's thread-local RNG is re-seeded from
4//! [`PhraseSpec::seed`] immediately before ticking, and the patch is compiled
5//! fresh per render, so `(term, spec)` → bit-identical samples on any thread.
6
7use auracle_grammar::{compile, PatchTree};
8use quiver::PatchError;
9
10use crate::phrase::PhraseSpec;
11
12/// Where one phrase note sits in the rendered buffer, and what it was — the
13/// role information segment-local features key on ([`crate::audio`] finds the
14/// held note, the highest note and the chord note by *property*, never by
15/// position, so custom test phrases degrade gracefully).
16#[derive(Clone, Copy, Debug)]
17pub struct NoteSpan {
18    /// Pitch of the note's primary voice, V/Oct from C4.
19    pub voct: f64,
20    /// Number of additional chord voices gate-synced with this note.
21    pub chord: usize,
22    /// Sample index where the gate opened.
23    pub on_start: usize,
24    /// Sample index where the gate closed (exclusive end of the on-span).
25    pub on_end: usize,
26}
27
28/// A rendered phrase: mono samples normalized from quiver's ±5 V audio level
29/// to nominal ±1.0.
30#[derive(Clone, Debug)]
31pub struct RenderedPhrase {
32    /// Mono samples (left/right average), nominal ±1.0 full scale.
33    pub samples: Vec<f64>,
34    /// Sample rate in Hz.
35    pub sample_rate: f64,
36    /// Sample index where each note's gate opens (for attack-time features).
37    pub note_onsets: Vec<usize>,
38    /// Gate spans and roles of each note, in phrase order.
39    pub spans: Vec<NoteSpan>,
40}
41
42/// A chord voice: its own compiled copy of the patch, alive from its note's
43/// onset until its release tail parks on silence.
44struct ChordVoice {
45    voice: auracle_grammar::CompiledVoice,
46    /// Consecutive below-threshold samples seen since the gate closed.
47    quiet_run: usize,
48    /// Gate is closed and the tail has decayed — stop ticking.
49    parked: bool,
50    /// Gate currently open (ignore silence while held: a slow attack is
51    /// silent and must not be parked).
52    gated: bool,
53}
54
55/// Silence threshold and run length for parking a released chord voice —
56/// the same judgment the live engine makes when it stops ticking a silent
57/// voice, deterministic here because the render itself is.
58const PARK_ABS: f64 = 1e-6;
59const PARK_RUN: usize = 1024;
60
61/// Compile `tree` and render it playing the phrase.
62///
63/// Chord notes ([`crate::phrase::Note::chord`]) are rendered by additional
64/// compiled voices summed into the same buffer with **no attenuation**: two
65/// voices sounding at once being louder and denser than one is exactly the
66/// polyphonic-stacking information the stimulus exists to capture, whole-
67/// phrase loudness is normalized downstream, and the vet ceiling scales with
68/// [`crate::phrase::PhraseSpec::max_voices`]. Chord voices tick from their
69/// note's onset (cold start, like live voice allocation), share the note's
70/// gate, and after release keep ticking until their output parks on silence
71/// so a long tail is never truncated into a click. Tick order per sample is
72/// fixed (main voice, then chord voices in pitch order), which keeps the
73/// thread-local RNG draw sequence — and therefore the render — deterministic.
74pub fn render_phrase(tree: &PatchTree, spec: &PhraseSpec) -> Result<RenderedPhrase, PatchError> {
75    let mut voice = compile(tree, spec.sample_rate)?;
76    // Chord voices for the note being (or last) played. Compiled lazily at
77    // the first chord note; a mono spec pays nothing.
78    let mut chord_voices: Vec<ChordVoice> = Vec::new();
79
80    // Determinism: fix the stochastic-module RNG for this render.
81    quiver::rng::seed(spec.seed);
82
83    let mut samples = Vec::with_capacity(spec.total_samples());
84    let mut note_onsets = Vec::with_capacity(spec.notes.len());
85    let mut spans = Vec::with_capacity(spec.notes.len());
86
87    let tick_all =
88        |voice: &mut auracle_grammar::CompiledVoice, chord: &mut Vec<ChordVoice>| -> f64 {
89            let (l, r) = voice.patch.tick();
90            let mut s = (l + r) * 0.5 / 5.0;
91            for cv in chord.iter_mut().filter(|cv| !cv.parked) {
92                let (cl, cr) = cv.voice.patch.tick();
93                let c = (cl + cr) * 0.5 / 5.0;
94                s += c;
95                if !cv.gated {
96                    if c.abs() < PARK_ABS {
97                        cv.quiet_run += 1;
98                        if cv.quiet_run >= PARK_RUN {
99                            cv.parked = true;
100                        }
101                    } else {
102                        cv.quiet_run = 0;
103                    }
104                }
105            }
106            s
107        };
108
109    for note in &spec.notes {
110        // Retire the previous note's chord voices only once parked; a voice
111        // still ringing keeps ticking into this note, tail intact.
112        if !note.chord.is_empty() {
113            chord_voices.retain(|cv| !cv.parked);
114            for &voct in &note.chord {
115                let v = compile(tree, spec.sample_rate)?;
116                v.pitch.set(voct);
117                v.gate.set(5.0);
118                chord_voices.push(ChordVoice {
119                    voice: v,
120                    quiet_run: 0,
121                    parked: false,
122                    gated: true,
123                });
124            }
125        }
126
127        voice.pitch.set(note.voct);
128        let on_start = samples.len();
129        note_onsets.push(on_start);
130        voice.gate.set(5.0);
131        for _ in 0..(note.on_s * spec.sample_rate) as usize {
132            let s = tick_all(&mut voice, &mut chord_voices);
133            samples.push(s);
134        }
135        let on_end = samples.len();
136        voice.gate.set(0.0);
137        for cv in chord_voices.iter_mut().filter(|cv| cv.gated) {
138            cv.voice.gate.set(0.0);
139            cv.gated = false;
140        }
141        for _ in 0..(note.off_s * spec.sample_rate) as usize {
142            let s = tick_all(&mut voice, &mut chord_voices);
143            samples.push(s);
144        }
145        spans.push(NoteSpan {
146            voct: note.voct,
147            chord: note.chord.len(),
148            on_start,
149            on_end,
150        });
151    }
152
153    Ok(RenderedPhrase {
154        samples,
155        sample_rate: spec.sample_rate,
156        note_onsets,
157        spans,
158    })
159}
160
161/// A playback-ready audition buffer.
162///
163/// `f32` because that is the **only** form a stored render is ever consumed
164/// in — every consumer in the tree converts at the boundary for WebAudio
165/// (`auracle_wasm`'s `render_of` / `edit_render`). Storing it converted
166/// halves resident audio and removes a per-request conversion pass.
167///
168/// One-way door, stated explicitly: **features are never derived from an
169/// `Audition`.** [`crate::featurize`] measures on the f64 [`RenderedPhrase`]
170/// and always will; anything that wants φ from a term must featurize it, not
171/// analyze its audition buffer.
172#[derive(Clone, Debug)]
173pub struct Audition {
174    /// Mono samples, nominal ±1.0 full scale, loudness-normalized.
175    pub samples: Vec<f32>,
176    /// Sample rate in Hz.
177    pub sample_rate: f64,
178}
179
180impl Audition {
181    /// Resident bytes of the sample buffer (for memo accounting).
182    pub fn bytes(&self) -> usize {
183        self.samples.len() * std::mem::size_of::<f32>()
184    }
185}
186
187impl RenderedPhrase {
188    /// The playback-ready view of this render.
189    pub fn to_audition(&self) -> Audition {
190        Audition {
191            samples: self.samples.iter().map(|s| *s as f32).collect(),
192            sample_rate: self.sample_rate,
193        }
194    }
195}
196
197/// Re-derive the audition buffer of an already-featurized term **without**
198/// re-running the loudness analysis, using the `gain_db` its
199/// [`crate::Features`] recorded.
200///
201/// Bit-identical to what [`crate::featurize`] produced for the same term:
202/// [`crate::loudness::normalize_to`] measures a gain and then applies it as a
203/// *uniform scalar multiply* over the buffer, so replaying the recorded gain
204/// reproduces the same products exactly. `gain_db` is stored already bounded —
205/// by `loudness::MAX_GAIN_DB` above and by `loudness::PEAK_CEILING` below — so
206/// no bound is re-applied here. Re-applying would be a no-op; *not* applying
207/// is what keeps this in lockstep with the one place the decision is made.
208///
209/// That single-scalar shape is why the peak ceiling is a gain reduction rather
210/// than a limiter: a limiter would have to exist here too, identically, forever.
211///
212/// This is the second code path that must stay in lockstep with `featurize`'s
213/// normalization forever; `render_playback_is_bit_identical` is the test that
214/// keeps it honest.
215pub fn render_playback(
216    tree: &PatchTree,
217    spec: &PhraseSpec,
218    gain_db: f64,
219) -> Result<Audition, PatchError> {
220    let mut render = render_phrase(tree, spec)?;
221    let gain = 10f64.powf(gain_db / 20.0);
222    for s in render.samples.iter_mut() {
223        *s *= gain;
224    }
225    Ok(render.to_audition())
226}