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 ¬e.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}