Skip to main content

unlit3dx/
audio.rs

1//! The audio threaded world: a callback-driven output stream fed by a ring.
2//!
3//! The audio thread owns the stream and a lock-free ring buffer; the
4//! operating system's callback pops whatever is newest and silences the
5//! rest, so a late simulation degrades to silence instead of stuttering.
6//! What sound plays is decided by the world's [`OnAudioFill`] components: the
7//! thread runs them once per filled chunk and sums their samples into the
8//! ring. The output's format is not known until its stream opens, so the
9//! thread spawns it as an [`AudioFormat`] component, marks the world with
10//! [`AudioOutputOpen`], and a caller subscribes to either — the format to
11//! build its sources at the rate the device really uses, the marker to know
12//! when the world can sound at all.
13
14use std::cell::RefCell;
15use std::time::Duration;
16
17use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
18use ringbuf::traits::{Consumer, Observer, Producer, Split};
19use ringbuf::{HeapProd, HeapRb};
20use unlit_ecs::{Entity, World, bundle};
21
22use crate::handle::WorldHandle;
23use crate::job::{JobSender, SendError};
24use crate::thread::FixedClock;
25use crate::world::WorldRunner;
26
27/// How many frames the filler generates per pass, the unit the pacing and
28/// the ring's headroom are measured in.
29const AUDIO_FILL_FRAMES: usize = 256;
30
31/// How an audio world gets its output, whichever way it is driven.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum AudioOutputMode {
34    /// No device: the host pulls the fills with [`dispatch_audio_fill`].
35    Pulled,
36    /// Open nothing until an [`AudioOutputRequest`] lands, then retry
37    /// every tick until a device appears. A page asks from the handler of
38    /// the user's click: the tick that honours the request runs inside the
39    /// browser's sticky activation, so the context it opens is allowed to
40    /// start. A browser cannot report an opening failure, so the world
41    /// keeps trying rather than giving up after one attempt. A desktop
42    /// world asks the same way when its caller decides when sound may
43    /// start, so one mode covers both drivers.
44    OnRequest,
45}
46
47/// How the audio world is set up before its first fill.
48#[derive(Clone, Debug)]
49pub struct AudioConfig {
50    /// The ring buffer's length, in frames per channel: the total latency
51    /// the filler can smooth over. Must hold at least two fill chunks, or
52    /// the filler would never see room to write one.
53    pub buffer_frames: usize,
54    /// The sample rate to ask the output device for, in hertz.
55    pub preferred_sample_rate: u32,
56    /// The command channel's capacity, in queued jobs.
57    pub command_capacity: usize,
58    /// How the world gets its output, whichever way it is driven.
59    ///
60    /// The default pulls none: a world nobody asked to sound stays silent,
61    /// whatever runs it. A page asks for the browser's output from a
62    /// user-gesture handler with [`request_audio_output`], because that is
63    /// where the browser allows a context to start.
64    pub output: AudioOutputMode,
65}
66
67impl Default for AudioConfig {
68    fn default() -> Self {
69        Self {
70            buffer_frames: 4096,
71            preferred_sample_rate: 48000,
72            command_capacity: 256,
73            output: AudioOutputMode::Pulled,
74        }
75    }
76}
77
78/// Where a fill behaviour runs among the world's other sources.
79///
80/// Lower runs first. Sources at the same order have no order between them: the
81/// dispatcher promises nothing about which of them adds its samples first, so a
82/// source whose contribution another has to follow states a different order.
83/// A source that only adds its own samples does not care where it lands in the
84/// band.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
86pub struct AudioFillOrder(pub i32);
87
88/// One chunk of interleaved audio a fill behaviour contributes to.
89///
90/// The chunk arrives silent and the behaviour adds its samples to it rather
91/// than overwriting them: adding is what lets several sources play at once.
92/// Samples past the first two channels of a wider stream are the right
93/// channel's, which is what a stereo source has to offer.
94pub struct AudioChunk<'a> {
95    /// The chunk's samples, interleaved frames, silent unless a behaviour
96    /// has already added to it.
97    pub out: &'a mut [f32],
98    /// The output's sample rate, in hertz.
99    pub sample_rate: u32,
100    /// The output's channel count.
101    pub channels: u16,
102}
103
104impl AudioChunk<'_> {
105    /// How many frames the chunk holds.
106    #[must_use]
107    pub fn frames(&self) -> usize {
108        self.out.len() / usize::from(self.channels.max(1))
109    }
110}
111
112/// The callback a fill behaviour runs: it adds its source's samples to the
113/// chunk it is handed.
114type AudioFillCallback = Box<dyn FnMut(&World, Entity, &mut AudioChunk)>;
115
116/// The state every fill behaviour shares: its callback and its order.
117struct AudioFillSlot {
118    callback: AudioFillCallback,
119    order: AudioFillOrder,
120}
121
122/// A source of audio samples: a component the audio thread runs once per
123/// filled chunk.
124///
125/// Spawn it on its own entity with [`ScopeMut::spawn`](unlit_ecs::ScopeMut::spawn), or
126/// bundle it with the state it renders from — a synthesizer, say — and
127/// spawn that entity directly. [`dispatch_audio_fill`] runs a world's sources and
128/// mixes them.
129pub struct OnAudioFill(AudioFillSlot);
130
131impl OnAudioFill {
132    /// Wrap `f` as an [`OnAudioFill`].
133    ///
134    /// `order` places it among the world's other sources; a source that
135    /// only adds its own samples can keep [`AudioFillOrder::default`], since
136    /// the sum does not depend on where it lands in the band.
137    #[must_use]
138    pub fn new(
139        order: AudioFillOrder,
140        f: impl FnMut(&World, Entity, &mut AudioChunk) + 'static,
141    ) -> Self {
142        Self(AudioFillSlot {
143            callback: Box::new(f),
144            order,
145        })
146    }
147
148    /// The order this source runs in.
149    #[must_use]
150    pub fn order(&self) -> AudioFillOrder {
151        self.0.order
152    }
153
154    /// Run this source for `entity`, adding its samples to `chunk`.
155    fn run(&mut self, world: &World, entity: Entity, chunk: &mut AudioChunk) {
156        (self.0.callback)(world, entity, chunk);
157    }
158}
159
160thread_local! {
161    /// The source list [`dispatch_audio_fill`] reuses across fills.
162    ///
163    /// The initializer is already a const block. Clippy's `thread_local`
164    /// analysis differs by target and reports a candidate anyway on Android,
165    /// so the lint is silenced here rather than expected: an expectation goes
166    /// unfulfilled on every target where it does not fire.
167    #[allow(
168        clippy::missing_const_for_thread_local,
169        reason = "the initializer is already a const block"
170    )]
171    static SOURCES: RefCell<Vec<(AudioFillOrder, Entity)>> = const { RefCell::new(Vec::new()) };
172}
173
174/// The factor the mixed samples scale by, read from the audio world.
175///
176/// The gain belongs to the output rather than to one source:
177/// [`dispatch_audio_fill`] sums every source and scales the sum once, so adding a
178/// source changes the mix without changing anyone's level. Soundfont presets
179/// leave a lot of headroom, so a mix of a few quiet voices usually wants a
180/// gain above one. Samples clamp to the unit range after the gain, so an
181/// overdriven mix distorts rather than wraps. A world carrying no gain
182/// passes its mix through unchanged.
183///
184/// A source runs before the gain is applied, so it sees the mix at the
185/// sources' own levels. One that mirrors the output — a recorder does —
186/// applies the gain itself rather than expecting the buffer it is handed to
187/// carry it.
188#[derive(Clone, Copy, Debug, Default, PartialEq)]
189pub struct GlobalAudioGain(pub f32);
190
191/// Run every [`OnAudioFill`] in `world` once, mixing their samples into `out`.
192///
193/// `out` is interleaved frames and is zeroed first; each source adds its
194/// contribution in [`AudioFillOrder`] order — the order is the only thing the
195/// dispatcher states, so sources at the same order run in no stated order.
196/// Then the world's [`GlobalAudioGain`] — one when it carries none — scales
197/// the sum, which clamps to the unit range. This is the whole of the audio
198/// thread's per-chunk work, and a caller stepping a world inline calls it the
199/// same way. The gain is the finished buffer's alone: every source ran before
200/// it, which is why a source that mirrors the output applies it itself.
201///
202/// A source must not change the world's set of sources while it runs: it
203/// has a shared `&World`, so structural work goes through
204/// [`unlit_ecs::Scope::queue`] and is applied by the world's own driver between fills.
205pub fn dispatch_audio_fill(world: &World, out: &mut [f32], sample_rate: u32, channels: u16) {
206    out.fill(0.0);
207    // The list has the same shape every fill, so it is handed out and taken
208    // back rather than allocated per chunk on the audio thread. Taking it also
209    // keeps the slot free while a source runs.
210    let mut sources = SOURCES.with_borrow_mut(std::mem::take);
211    sources.clear();
212    sources.extend(
213        world
214            .root_scope()
215            .query::<(&OnAudioFill, Entity)>()
216            .iter()
217            .map(|(on_audio_fill, entity)| (on_audio_fill.order(), entity)),
218    );
219    sources.sort_unstable_by_key(|(order, _entity)| *order);
220    let mut chunk = AudioChunk {
221        out,
222        sample_rate,
223        channels,
224    };
225    for (_, entity) in &sources {
226        if let Ok(mut fill) = world.root_scope().query_one::<&mut OnAudioFill>(*entity) {
227            fill.get().run(world, *entity, &mut chunk);
228        }
229    }
230    SOURCES.with_borrow_mut(|slot| *slot = sources);
231    let mut global_audio_gain_rows = world.root_scope().query::<&GlobalAudioGain>();
232
233    let gain = global_audio_gain_rows
234        .iter()
235        .next()
236        .map(|audio_gain| audio_gain.0)
237        .unwrap_or(1.0);
238    for sample in chunk.out.iter_mut() {
239        *sample = (*sample * gain).clamp(-1.0, 1.0);
240    }
241}
242
243/// The format the audio world's output stream opened at.
244///
245/// A source that renders at a fixed rate — a synthesizer, a sampler — is
246/// built at the rate the device actually uses, and that is not known until
247/// the stream opens. The audio thread spawns this component the moment it
248/// does, so a subscriber can build the world's [`OnAudioFill`] sources at
249/// that rate, like any other work for that world.
250#[derive(Clone, Copy, Debug, PartialEq, Eq)]
251pub struct AudioFormat {
252    /// The stream's sample rate, in hertz.
253    pub sample_rate: u32,
254    /// The stream's channel count.
255    pub channels: u16,
256}
257
258/// The marker the audio world carries once its output is open.
259///
260/// A world that opens a device gains it on the tick the stream comes up; a
261/// world whose host pulls its fills carries it from its first tick, because
262/// the host is the thing that would have opened a device. A subscriber reads
263/// it to tell a world with somewhere to play from one still waiting for a
264/// device, and combines it with whatever else it needs — the world carrying
265/// an [`OnAudioFill`] source, say — to decide when to start playing.
266#[derive(Clone, Copy, Debug)]
267pub struct AudioOutputOpen;
268
269/// The marker an [`AudioOutputMode::OnRequest`] audio world carries once a
270/// [`request_audio_output`] job has reached it. It stays for the world's
271/// life, so a subscriber can tell a world still waiting for its page's
272/// first click from one that has been asked and is only missing a device.
273#[derive(Clone, Copy, Debug)]
274pub struct AudioOutputRequest;
275
276/// Ask `sender`'s audio world to open its output device.
277///
278/// The request travels as a job: it lands in the world's queue and is
279/// honoured on the tick that drains it, which on the web is the same frame
280/// the user's click ran in — inside the browser's sticky activation, so
281/// the context that tick opens is allowed to start. Asking a world that
282/// does not open on request costs nothing; asking twice is harmless.
283///
284/// # Errors
285///
286/// Errors when the queue is full or the world has stopped, like any job.
287pub fn request_audio_output(sender: &JobSender) -> Result<(), SendError> {
288    sender.send(Box::new(|world: &mut World| {
289        if world
290            .root_scope()
291            .query::<&AudioOutputRequest>()
292            .iter()
293            .next()
294            .is_none()
295        {
296            world.root_scope_mut().spawn(bundle![AudioOutputRequest]);
297        }
298    }))
299}
300
301/// Everything the open output stream needs to keep running.
302struct OpenOutput {
303    /// The stream, kept alive by being held.
304    _stream: cpal::Stream,
305    /// The producer end of the ring the callback drains.
306    producer: HeapProd<f32>,
307    /// The sample rate the stream actually opened at, in hertz.
308    sample_rate: u32,
309    /// The channel count the stream actually opened at.
310    channels: u16,
311}
312
313/// Spawn the audio world driven by `R`: on its own thread
314/// ([`Threaded`](crate::world::Threaded)), or stepped by the host
315/// ([`Stepped`](crate::world::Stepped)).
316///
317/// The world's entity carries the command channel. A self-driven world opens
318/// the default output device on its first tick and keeps it open, spawning
319/// [`AudioFormat`] and [`AudioOutputOpen`] once it knows them; until then,
320/// and whenever no device exists, the world runs silently. A stepped world
321/// follows its [`AudioOutputMode`] config instead: [`AudioOutputMode::Pulled`]
322/// opens nothing — the host pulls its fills with [`dispatch_audio_fill`] — and
323/// [`AudioOutputMode::OnRequest`]
324/// waits for a [`request_audio_output`] job, which a page sends from a
325/// user-gesture handler because that is where a browser allows a context to
326/// start. Until the device comes up the world gains neither component. Either
327/// way the world mixes whatever [`OnAudioFill`] sources a caller has since
328/// queued into it, and a world nobody has installed stays silent.
329///
330/// Nothing is published until someone subscribes: a subscriber's collector
331/// runs once per tick, after the output's state has been updated.
332///
333/// Both runners share one tick body, so a stepped world opens its output and
334/// fills its ring exactly as a threaded one does; the only difference is
335/// that its host calls [`crate::world::step_stepped_world`] once per frame
336/// instead of a thread pacing itself.
337///
338/// # Panics
339///
340/// Panics when `buffer_frames` cannot hold two fill chunks, or when
341/// `command_capacity` is zero.
342pub fn spawn_audio_world_with<R: WorldRunner>(
343    host: &mut World,
344    config: AudioConfig,
345) -> WorldHandle {
346    // The filler writes in chunks of AUDIO_FILL_FRAMES frames, so a ring smaller
347    // than one chunk would never see room and the stream would stay silent
348    // forever. Two chunks leave room to top the ring up between wakeups.
349    assert!(
350        config.buffer_frames >= AUDIO_FILL_FRAMES * 2,
351        "buffer_frames must hold at least two fill chunks of {AUDIO_FILL_FRAMES} frames"
352    );
353    let mut output: Option<OpenOutput> = None;
354    // Whether an [`AudioOutputRequest`] has landed: once true, a world in
355    // [`AudioOutputMode::OnRequest`] keeps trying to open a device every
356    // tick, because a browser cannot report an opening failure to retry
357    // against.
358    let mut requested = false;
359    let mut scratch: Vec<f32> = Vec::new();
360    // The sounding state flips only when the output and the sources appear
361    // or vanish, so the log carries one line per transition.
362    let mut ready = false;
363    // The pacing clock follows the ring: it is created once the stream's
364    // sample rate is known and then keeps its own cadence, so a slow tick
365    // does not push every later wakeup out.
366    let mut clock: Option<FixedClock> = None;
367    R::spawn(
368        host,
369        "audio",
370        config.command_capacity,
371        |_| (),
372        move |world, control, (), _| {
373            if control.stop_requested() {
374                return false;
375            }
376            // Whether this tick wants a device open: [`AudioOutputMode::Pulled`] never,
377            // [`AudioOutputMode::OnRequest`] once its page has asked. The config speaks for
378            // every world, self-driven or stepped alike.
379            if let AudioOutputMode::OnRequest = config.output
380                && world
381                    .root_scope()
382                    .query::<&AudioOutputRequest>()
383                    .iter()
384                    .next()
385                    .is_some()
386            {
387                requested = true;
388            }
389            let wants_output = match config.output {
390                AudioOutputMode::Pulled => false,
391                AudioOutputMode::OnRequest => requested,
392            };
393            if wants_output && output.is_none() {
394                match open_output(&config) {
395                    Some(opened) => {
396                        log::info!(
397                            "the audio output opened at {} Hz, {} channels",
398                            opened.sample_rate,
399                            opened.channels
400                        );
401                        // Spawned once, before the first fill: whoever
402                        // waits on it builds their sources at the rate the
403                        // device really uses.
404                        world.root_scope_mut().spawn(bundle![AudioFormat {
405                            sample_rate: opened.sample_rate,
406                            channels: opened.channels,
407                        },]);
408                        // Half a chunk of pacing: the ring stays ahead of
409                        // the callback without the filler spinning.
410                        let pace = AUDIO_FILL_FRAMES as f64 / f64::from(opened.sample_rate) * 0.5;
411                        clock = Some(FixedClock::new(Duration::from_secs_f64(pace)));
412                        output = Some(opened);
413                    }
414                    None => {
415                        // A page's request can arrive before a device the
416                        // system is ready to grant exists; retrying is the
417                        // world's whole strategy, so this stays quiet.
418                        log::debug!("no audio output device is available yet");
419                    }
420                }
421            }
422            // A source takes time to build — a soundfont is parsed on this
423            // thread — so a device may be open for seconds before the world
424            // carries one. Log the transition, not the state, so the log
425            // stays quiet.
426            // A [`AudioOutputMode::Pulled`] world is open by definition: its host pulls the
427            // fills. One waiting for its page's first click — or for a
428            // device — is not, whatever its sources are doing.
429            let open = output.is_some() || config.output == AudioOutputMode::Pulled;
430            // The marker goes on the first tick the output is open, and the
431            // world keeps it: an entity's components are fixed once spawned.
432            if open
433                && world
434                    .root_scope()
435                    .query::<&AudioOutputOpen>()
436                    .iter()
437                    .next()
438                    .is_none()
439            {
440                world.root_scope_mut().spawn(bundle![AudioOutputOpen]);
441            }
442            let sounding = open
443                && world
444                    .root_scope()
445                    .query::<&OnAudioFill>()
446                    .iter()
447                    .next()
448                    .is_some();
449            if sounding != ready {
450                ready = sounding;
451                if sounding {
452                    log::info!("the audio world can sound");
453                } else {
454                    log::warn!("the audio world cannot sound");
455                }
456            }
457            if let Some(output) = output.as_mut() {
458                let chunk = AUDIO_FILL_FRAMES * usize::from(output.channels);
459                while output.producer.vacant_len() >= chunk {
460                    scratch.resize(chunk, 0.0);
461                    dispatch_audio_fill(world, &mut scratch, output.sample_rate, output.channels);
462                    output.producer.push_slice(&scratch);
463                }
464                if let Some(clock) = clock.as_mut() {
465                    clock.wait(control);
466                } else {
467                    control.wait_timeout(Duration::from_secs(1));
468                }
469            } else if control.self_driven() {
470                // A self-driven world with no device yet waits for one; a
471                // browser's context that failed to open is not worth
472                // retrying every frame, so a stepped world just returns.
473                control.wait_timeout(Duration::from_secs(1));
474            }
475            true
476        },
477    )
478}
479
480/// Open the default output stream and the ring feeding it, preferring a
481/// stereo f32 stream at the configured rate.
482fn open_output(config: &AudioConfig) -> Option<OpenOutput> {
483    let device = cpal::default_host().default_output_device()?;
484    let supported = device.supported_output_configs().ok()?;
485    let best = supported
486        .filter(|range| range.sample_format() == cpal::SampleFormat::F32)
487        .map(|range| {
488            let rate = config
489                .preferred_sample_rate
490                .clamp(range.min_sample_rate(), range.max_sample_rate());
491            range.with_sample_rate(rate)
492        })
493        .max_by_key(|stream| {
494            (
495                usize::from(stream.channels() == 2),
496                // The closer to the requested rate, the better.
497                u32::MAX - stream.sample_rate().abs_diff(config.preferred_sample_rate),
498            )
499        })?;
500    let stream_config: cpal::StreamConfig = best.into();
501    let channels = stream_config.channels;
502    let sample_rate = stream_config.sample_rate;
503    let (producer, mut consumer) =
504        HeapRb::<f32>::new(config.buffer_frames * usize::from(channels)).split();
505    let stream = device
506        .build_output_stream::<f32, _, _>(
507            stream_config,
508            move |out, _| {
509                // Underrun: play what is left, silence the rest.
510                let filled = consumer.pop_slice(out);
511                out[filled..].fill(0.0);
512            },
513            |error| log::error!("the audio output stream failed ({error})"),
514            None,
515        )
516        .ok()?;
517    stream.play().ok()?;
518    Some(OpenOutput {
519        _stream: stream,
520        producer,
521        sample_rate,
522        channels,
523    })
524}