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}