tono_core/runtime/source.rs
1//! The crate's output seam: the [`AudioSource`] trait every adapter targets,
2//! the interleaved-stereo channel spread, and the allocation-free
3//! [`StreamSource`] over a streamable doc.
4
5use super::SCRATCH_FRAMES;
6use crate::dsl::SoundDoc;
7use crate::streaming::StreamGraph;
8
9/// A block-serving audio source: fill `out` (interleaved stereo L,R,L,R…) and
10/// return the number of frames written. Runs indefinitely. This is the single
11/// seam host output adapters target, so a `cpal` callback, an AudioWorklet, or a
12/// Bevy source never depend on a concrete engine type.
13///
14/// Implementations overwrite the **whole** `out` buffer (silence where there is
15/// nothing to play), so a caller may mix several sources through one scratch
16/// buffer without re-zeroing.
17pub trait AudioSource {
18 /// Fill `out` with the next block of interleaved-stereo audio.
19 fn fill(&mut self, out: &mut [f32]) -> usize;
20
21 /// Rewind the source to its start (playback position / phase to zero).
22 /// Defaults to a no-op; a looping source overrides it so a transport can
23 /// restart it from the top. [`AdaptiveMusic::reset`](crate::adaptive::AdaptiveMusic::reset)
24 /// calls this on each layer.
25 fn reset(&mut self) {}
26}
27
28/// Spread an interleaved-stereo buffer across a device's channel layout: mono
29/// devices get the mid (`0.5 * (l + r)`), stereo gets L/R, extra channels are
30/// zeroed. The one channel-adaptation every output adapter (cpal callback,
31/// AudioWorklet shim) needs — pure sample shuffling, no device dependency.
32/// `data` holds `channels` interleaved device channels; `stereo` holds the same
33/// frame count as L,R pairs.
34pub fn write_interleaved(data: &mut [f32], channels: usize, stereo: &[f32]) {
35 let channels = channels.max(1);
36 // Never read past the source: a caller handing a short `stereo` slice must
37 // not panic on the audio thread — fill only the frames we actually have.
38 let frames = (data.len() / channels).min(stereo.len() / 2);
39 for f in 0..frames {
40 let (l, r) = (stereo[f * 2], stereo[f * 2 + 1]);
41 let base = f * channels;
42 if channels == 1 {
43 data[base] = 0.5 * (l + r);
44 } else {
45 data[base] = l;
46 data[base + 1] = r;
47 for c in 2..channels {
48 data[base + c] = 0.0;
49 }
50 }
51 }
52}
53/// An [`AudioSource`] over the stateful streaming renderer: streams a
54/// doc's graph **indefinitely** — mono duplicated to stereo. Returns `None`
55/// for docs outside the streamable subset (the caller falls back to a
56/// buffer-backed [`Player`](crate::player::Player)/instance). This is how a game
57/// feeds the streaming renderer straight to a cpal / AudioWorklet callback
58/// for continuous generative content.
59///
60/// `fill` is allocation-free for blocks up to 8192 frames (the scratch is
61/// pre-allocated at construction); a larger block grows it once, on the first
62/// such call. Caveats past the document's `duration`: a `seq` yields silence
63/// past its pre-rendered buffer (only oscillators/noise truly run forever),
64/// and the baked peak-limit gain was measured over the document only —
65/// free-running output past `duration` is un-limited and can clip where the
66/// finite bounce could not.
67///
68/// Byte-identity: the offline bounce ends in a transparent sample-peak safety
69/// limit, a whole-buffer gain that cannot be computed causally. [`StreamSource::from_doc`] therefore measures the finite render's peak with
70/// one throwaway pass of the same deterministic graph (O(duration) time, O(1)
71/// memory) and bakes the identical constant gain, so the stream matches the
72/// bounce bit-for-bit over the document's duration.
73pub struct StreamSource {
74 graph: StreamGraph,
75 scratch: Vec<f32>,
76 /// The bounce's peak-limit gain (1.0 when the doc never exceeds the ceiling).
77 gain: f32,
78}
79
80impl StreamSource {
81 /// Build a streaming source for `doc`, or `None` if it isn't streamable.
82 pub fn from_doc(doc: &SoundDoc) -> Option<Self> {
83 let graph = StreamGraph::try_from_doc(doc)?;
84 // Probe pass: same graph, same bytes — find the peak the offline
85 // output stage would have limited against. The duration clamp mirrors
86 // the offline render paths so an unvalidated doc can't request an
87 // unbounded probe (or seq pre-render) here.
88 let mut probe = StreamGraph::try_from_doc(doc)?;
89 let mut remaining =
90 ((doc.duration.clamp(0.0, 600.0) * doc.sample_rate as f32).ceil() as usize).max(1);
91 let mut block = [0.0f32; 1024];
92 let mut peak = 0.0f32;
93 while remaining > 0 {
94 let take = block.len().min(remaining);
95 probe.fill(&mut block[..take]);
96 peak = block[..take].iter().fold(peak, |m, x| m.max(x.abs()));
97 remaining -= take;
98 }
99 let gain = if peak > crate::dsp::CEIL {
100 crate::dsp::CEIL / peak
101 } else {
102 1.0
103 };
104 Some(StreamSource {
105 graph,
106 // Pre-sized so common host blocks never allocate in `fill`.
107 scratch: vec![0.0; SCRATCH_FRAMES],
108 gain,
109 })
110 }
111}
112
113impl AudioSource for StreamSource {
114 fn fill(&mut self, out: &mut [f32]) -> usize {
115 let frames = out.len() / 2;
116 if self.scratch.len() < frames {
117 self.scratch.resize(frames, 0.0);
118 }
119 let mono = &mut self.scratch[..frames];
120 self.graph.fill(mono);
121 for f in 0..frames {
122 let v = mono[f] * self.gain;
123 out[f * 2] = v;
124 out[f * 2 + 1] = v;
125 }
126 frames
127 }
128}