kui_native/audio.rs
1//! The audio device behind the core's [`AudioCommand`]s, used by the runner
2//! and by hosts that drive a [`Core`](kui_core::Core) themselves.
3//!
4//! The core queues audio commands as data (`Core::take_audio_commands`);
5//! the runner hands them to [`Audio::apply`] after every input dispatch and
6//! every frame, and polls finished playbacks back into the core
7//! (`Core::audio_ended`) so tagged ones become `sound` events. Two things
8//! only the device can know go back the same way: a `Stop` that landed on
9//! a sound still playing (`Core::audio_truncated`) and a play the device
10//! refused (`Core::audio_refused`).
11//!
12//! Backed by kira (cpal underneath) behind the `audio` cargo feature; with
13//! the feature off every command is dropped and the UI runs the same. The
14//! device opens lazily on the first play, so an app that never plays
15//! anything never starts an audio thread; a device that refuses to open
16//! logs once and drops every later command. Decoded sounds are cached per
17//! `SoundId` and dropped on `Unload`.
18//!
19//! [`blip`] and [`wav_pcm16`] synthesize a short WAV for examples and tests
20//! that want a sound without shipping one.
21
22use kui_core::{AudioCommand, AudioDevice, AudioEnv, PlaybackId, SharedResources};
23
24pub use backend::Audio;
25
26/// A `Stop` that landed on a sound still playing, and how far into the
27/// sound (seconds) it was; `Core::audio_truncated` turns it into a
28/// `truncated-playback` warning on the node.
29pub type Truncated = (PlaybackId, f64);
30
31/// What the device answered back on an [`Audio::apply`]: the two things
32/// only it can know. The core turns each into a warning on the node that
33/// asked.
34#[derive(Default, Debug)]
35pub struct Answered {
36 /// Stops that landed on a sound still playing.
37 pub truncated: Vec<Truncated>,
38 /// Plays the device would not take.
39 pub refused: Vec<PlaybackId>,
40}
41
42/// Encodes mono float samples (-1..1) as a 16-bit PCM WAV file, ready for
43/// `Core::add_sound`.
44///
45/// ```rust
46/// let wav = kui_native::audio::wav_pcm16(44_100, &[0.0, 0.5, -0.5]);
47/// assert_eq!(&wav[..4], b"RIFF");
48/// assert_eq!(wav.len(), 44 + 3 * 2);
49/// ```
50pub fn wav_pcm16(sample_rate: u32, samples: &[f32]) -> Vec<u8> {
51 let data_len = (samples.len() * 2) as u32;
52 let mut out = Vec::with_capacity(44 + data_len as usize);
53 out.extend_from_slice(b"RIFF");
54 out.extend_from_slice(&(36 + data_len).to_le_bytes());
55 out.extend_from_slice(b"WAVEfmt ");
56 out.extend_from_slice(&16u32.to_le_bytes()); // chunk size
57 out.extend_from_slice(&1u16.to_le_bytes()); // PCM
58 out.extend_from_slice(&1u16.to_le_bytes()); // mono
59 out.extend_from_slice(&sample_rate.to_le_bytes());
60 out.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
61 out.extend_from_slice(&2u16.to_le_bytes()); // block align
62 out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
63 out.extend_from_slice(b"data");
64 out.extend_from_slice(&data_len.to_le_bytes());
65 for s in samples {
66 let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
67 out.extend_from_slice(&v.to_le_bytes());
68 }
69 out
70}
71
72/// A short decaying sine of `hz` lasting `ms`, as a WAV file: a click for
73/// examples and tests. `gain` is 0 to 1.
74pub fn blip(sample_rate: u32, hz: f32, ms: f32, gain: f32) -> Vec<u8> {
75 let n = (sample_rate as f32 * ms / 1000.0) as usize;
76 let samples: Vec<f32> = (0..n)
77 .map(|i| {
78 let t = i as f32 / sample_rate as f32;
79 let env = 1.0 - i as f32 / n as f32;
80 (t * hz * std::f32::consts::TAU).sin() * env * env * gain
81 })
82 .collect();
83 wav_pcm16(sample_rate, &samples)
84}
85
86#[cfg(feature = "audio")]
87mod backend {
88 use std::collections::HashMap;
89 use std::io::Cursor;
90 use std::sync::{Arc, mpsc};
91 use std::time::Duration;
92
93 use kira::sound::PlaybackState;
94 use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
95 use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween};
96 use kui_core::SoundId;
97
98 use super::*;
99
100 /// The output device and the sounds playing on it. The runner owns
101 /// one; a host driving a core itself makes one with [`Audio::new`] and
102 /// calls [`Audio::apply`] and [`Audio::poll_ended`] on its own turns.
103 pub struct Audio {
104 device: Device,
105 /// Commands that arrived while the device was still opening, in
106 /// order, with the registry to decode their sounds from. Applied
107 /// the moment it is open: a click sound plays a few ms late
108 /// rather than the frame stalling for the open.
109 pending: Vec<(AudioCommand, SharedResources)>,
110 decoded: HashMap<SoundId, StaticSoundData>,
111 playing: HashMap<PlaybackId, StaticSoundHandle>,
112 /// Stops applied to a handle that was still playing, drained by
113 /// `apply` — including the ones a `flush_pending` produced.
114 truncated: Vec<Truncated>,
115 /// Plays the device would not take, since the last drain. Buffered
116 /// rather than returned inline because a refusal can happen inside
117 /// `flush_pending`, which runs from the poll as well as from
118 /// `apply`; [`Audio::apply`] is where the driver collects them.
119 refused: Vec<PlaybackId>,
120 }
121
122 /// The output device, which takes ~90 ms to open on macOS — six frames
123 /// — so it is opened on its own thread and never on the loop's. Before
124 /// this, the counter's first click stalled for the open (its buttons
125 /// carry a click sound) and every later one flew.
126 enum Device {
127 Closed,
128 Opening(mpsc::Receiver<Result<AudioManager<DefaultBackend>, String>>),
129 Open(Box<AudioManager<DefaultBackend>>),
130 /// The device refused to open; commands are dropped after one
131 /// warning.
132 Failed,
133 }
134
135 impl Default for Audio {
136 fn default() -> Self {
137 Self::new()
138 }
139 }
140
141 impl Audio {
142 /// A closed device; nothing opens until the first command.
143 pub fn new() -> Self {
144 Audio {
145 device: Device::Closed,
146 pending: Vec::new(),
147 decoded: HashMap::new(),
148 playing: HashMap::new(),
149 truncated: Vec::new(),
150 refused: Vec::new(),
151 }
152 }
153
154 /// Starts opening the device if nothing has yet. Cheap to call
155 /// every frame; the driver calls it once the session holds a
156 /// sound, so the device is open by the time a click asks for one.
157 pub fn warm(&mut self) {
158 if !matches!(self.device, Device::Closed) {
159 return;
160 }
161 let (tx, rx) = mpsc::channel();
162 self.device = Device::Opening(rx);
163 std::thread::Builder::new()
164 .name("kui-audio-open".into())
165 .spawn(move || {
166 let opened =
167 AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())
168 .map_err(|e| e.to_string());
169 let _ = tx.send(opened);
170 })
171 .expect("spawn the audio-open thread");
172 }
173
174 /// Whether the device is still opening: commands wait.
175 fn opening(&mut self) -> bool {
176 let Device::Opening(rx) = &self.device else {
177 return false;
178 };
179 match rx.try_recv() {
180 Ok(Ok(m)) => self.device = Device::Open(Box::new(m)),
181 Ok(Err(e)) => {
182 eprintln!("kui: audio device unavailable ({e}); sounds are dropped");
183 self.device = Device::Failed;
184 }
185 Err(mpsc::TryRecvError::Empty) => return true,
186 Err(mpsc::TryRecvError::Disconnected) => {
187 eprintln!(
188 "kui: audio device unavailable (the open thread died); sounds are dropped"
189 );
190 self.device = Device::Failed;
191 }
192 }
193 false
194 }
195
196 /// The device, once open. Starts the open if nothing has.
197 fn manager(&mut self) -> Option<&mut AudioManager<DefaultBackend>> {
198 self.warm();
199 if self.opening() {
200 return None;
201 }
202 match &mut self.device {
203 Device::Open(m) => Some(m),
204 _ => None,
205 }
206 }
207
208 /// Applies what waited for the device, once it is open. Nothing
209 /// happens while it is still opening, or before anything waited.
210 fn flush_pending(&mut self) {
211 if self.pending.is_empty() || self.opening() {
212 return;
213 }
214 let pending = std::mem::take(&mut self.pending);
215 for (cmd, resources) in pending {
216 self.apply_one(cmd, &resources);
217 }
218 }
219
220 /// The decoded sound, decoding (and caching) on first use.
221 fn decoded(
222 &mut self,
223 sound: SoundId,
224 resources: &SharedResources,
225 ) -> Option<StaticSoundData> {
226 if let Some(d) = self.decoded.get(&sound) {
227 return Some(d.clone());
228 }
229 let bytes: Arc<[u8]> = resources.sound(sound)?;
230 match StaticSoundData::from_cursor(Cursor::new(bytes)) {
231 Ok(d) => {
232 self.decoded.insert(sound, d.clone());
233 Some(d)
234 }
235 Err(e) => {
236 eprintln!("kui: sound failed to decode: {e}");
237 None
238 }
239 }
240 }
241
242 /// Applies queued commands to the device; while it is still opening
243 /// they wait, in order. Returns what the device answered back: the
244 /// stops that landed on a sound still playing and the plays it
245 /// would not take. A command that waited for the device reports on
246 /// the apply that flushes it, not the one that queued it.
247 pub fn apply(&mut self, cmds: Vec<AudioCommand>, resources: &SharedResources) -> Answered {
248 if !cmds.is_empty() {
249 self.warm();
250 self.flush_pending();
251 if self.opening() {
252 self.pending
253 .extend(cmds.into_iter().map(|c| (c, resources.clone())));
254 } else {
255 for cmd in cmds {
256 self.apply_one(cmd, resources);
257 }
258 }
259 }
260 Answered {
261 truncated: std::mem::take(&mut self.truncated),
262 refused: std::mem::take(&mut self.refused),
263 }
264 }
265
266 fn apply_one(&mut self, cmd: AudioCommand, resources: &SharedResources) {
267 {
268 match cmd {
269 AudioCommand::Play {
270 playback,
271 sound,
272 volume,
273 looped,
274 fade_in_ms,
275 } => {
276 let Some(mut data) = self.decoded(sound, resources) else {
277 // Nothing to play and nothing that will ever
278 // end: the core has to hear so a tagged node
279 // stops waiting.
280 self.refused.push(playback);
281 return;
282 };
283 data = data.volume(db(volume));
284 if looped {
285 data = data.loop_region(..);
286 }
287 if fade_in_ms > 0.0 {
288 data = data.fade_in_tween(tween(fade_in_ms));
289 }
290 // `apply` and `flush_pending` only reach here once
291 // the open has answered, so no device is a device
292 // that failed to open: a machine with no output
293 // (CI, a container, a muted VM). The play is
294 // refused like any other the device would not
295 // take — a view sequenced on `sound ended` must
296 // not hang on it (AR20).
297 let Some(m) = self.manager() else {
298 self.refused.push(playback);
299 return;
300 };
301 match m.play(data) {
302 Ok(h) => {
303 self.playing.insert(playback, h);
304 }
305 // The device's voices are all held (128, with
306 // released playbacks among them). Routed rather
307 // than printed: a stderr line is not something
308 // the view waiting on this sound can hear.
309 Err(_) => self.refused.push(playback),
310 }
311 }
312 AudioCommand::Stop { playback, fade_ms } => {
313 if let Some(mut h) = self.playing.remove(&playback) {
314 // Whether the sound was still running is what
315 // the core is missing; ask before stopping.
316 if h.state() != PlaybackState::Stopped {
317 self.truncated.push((playback, h.position()));
318 }
319 h.stop(tween(fade_ms));
320 }
321 }
322 AudioCommand::SetVolume {
323 playback,
324 volume,
325 tween_ms,
326 } => {
327 if let Some(h) = self.playing.get_mut(&playback) {
328 h.set_volume(db(volume), tween(tween_ms));
329 }
330 }
331 AudioCommand::Pause { playback, fade_ms } => {
332 if let Some(h) = self.playing.get_mut(&playback) {
333 h.pause(tween(fade_ms));
334 }
335 }
336 AudioCommand::Resume { playback, fade_ms } => {
337 if let Some(h) = self.playing.get_mut(&playback) {
338 h.resume(tween(fade_ms));
339 }
340 }
341 AudioCommand::MasterVolume { volume, tween_ms } => {
342 if let Some(m) = self.manager() {
343 m.main_track().set_volume(db(volume), tween(tween_ms));
344 }
345 }
346 AudioCommand::Unload { sound } => {
347 self.decoded.remove(&sound);
348 }
349 }
350 }
351 }
352
353 /// Playbacks that finished on their own since the last poll
354 /// (stopped ones were already forgotten by `Stop`). Also where a
355 /// command that waited for the device starts, since the driver
356 /// polls while anything is active.
357 pub fn poll_ended(&mut self) -> Vec<PlaybackId> {
358 self.flush_pending();
359 let ended: Vec<PlaybackId> = self
360 .playing
361 .iter()
362 .filter(|(_, h)| h.state() == PlaybackState::Stopped)
363 .map(|(p, _)| *p)
364 .collect();
365 for p in &ended {
366 self.playing.remove(p);
367 }
368 ended
369 }
370
371 /// Whether the device is open or opening. An open output stream is
372 /// a real-time thread that runs whether or not anything is playing,
373 /// so a driver closes it after a quiet spell.
374 pub fn holds_device(&self) -> bool {
375 matches!(self.device, Device::Opening(_) | Device::Open(_))
376 }
377
378 /// Lets the output device go; the decoded-sound cache stays, so the
379 /// next play pays the open again but not the decode. Does nothing
380 /// while anything is playing or waiting, or while the device is
381 /// still opening: call it once [`Self::active`] has been false for
382 /// a while, and again if it did not take.
383 pub fn close(&mut self) {
384 if self.active() || self.opening() {
385 return;
386 }
387 if matches!(self.device, Device::Open(_)) {
388 self.device = Device::Closed;
389 }
390 }
391
392 /// Whether any playback is live or waiting on the device to open,
393 /// or an answer is waiting for the next `apply`. Keep polling while
394 /// it is true.
395 pub fn active(&self) -> bool {
396 !self.playing.is_empty()
397 || !self.pending.is_empty()
398 || !self.refused.is_empty()
399 || !self.truncated.is_empty()
400 }
401
402 /// The reading a view gets as `env.audio`: the device's state and
403 /// the playbacks started or waiting.
404 pub fn env(&self) -> AudioEnv {
405 AudioEnv {
406 device: match self.device {
407 Device::Closed => AudioDevice::Closed,
408 Device::Opening(_) => AudioDevice::Opening,
409 Device::Open(_) => AudioDevice::Open,
410 Device::Failed => AudioDevice::Failed,
411 },
412 live: (self.playing.len() + self.pending.len()) as u32,
413 }
414 }
415 }
416
417 /// Linear amplitude to kira's decibels (0 = silence).
418 fn db(volume: f32) -> Decibels {
419 if volume <= 0.0 {
420 Decibels::SILENCE
421 } else {
422 Decibels(20.0 * volume.log10())
423 }
424 }
425
426 fn tween(ms: f32) -> Tween {
427 Tween {
428 duration: Duration::from_secs_f32(ms.max(0.0) / 1000.0),
429 ..Default::default()
430 }
431 }
432
433 #[cfg(test)]
434 mod tests {
435 use super::*;
436
437 /// An idle device is let go, and a warm that was never commanded
438 /// is the case that matters: nothing but `close` calls `opening`,
439 /// so a device warmed at launch and never played sits in `Opening`
440 /// with a live output stream behind it — 94 buffer callbacks a
441 /// second, and the whole of an idle app's CPU. Skipped where there
442 /// is no device to open (CI), since there is then nothing to close.
443 #[test]
444 fn a_warm_device_nothing_used_is_closed_and_reopens() {
445 let mut audio = Audio::new();
446 audio.warm();
447 assert!(audio.holds_device(), "warm holds the device");
448 // Let the open land, the way the ~90 ms one does in an app.
449 // Ten seconds, not three: right after a windowed smoke round
450 // (seventy processes each opening and closing the HAL) the
451 // open took over three on a Mac, and a slow open is not a
452 // failed one — this test read as red for it (2026-09-14).
453 let deadline = std::time::Instant::now() + Duration::from_secs(10);
454 while matches!(audio.device, Device::Opening(_)) && std::time::Instant::now() < deadline
455 {
456 std::thread::sleep(Duration::from_millis(20));
457 audio.close();
458 }
459 if matches!(audio.device, Device::Failed) {
460 return; // no device on this machine: nothing to close
461 }
462 assert!(!audio.holds_device(), "an unused device is let go");
463 // And the app is not deaf afterwards.
464 audio.warm();
465 assert!(audio.holds_device(), "a closed device warms again");
466 }
467
468 /// A device with something playing is not closed under it.
469 #[test]
470 fn a_playing_device_is_not_closed() {
471 use kui_core::{Core, PlayOptions};
472 let mut core = Core::new();
473 let s = core.add_sound(super::super::blip(44_100, 220.0, 2_000.0, 0.05));
474 core.play(s, PlayOptions::default());
475 let mut audio = Audio::new();
476 audio.apply(core.take_audio_commands(), &core.resources);
477 let deadline = std::time::Instant::now() + Duration::from_secs(3);
478 while audio.playing.is_empty() && audio.active() && std::time::Instant::now() < deadline
479 {
480 std::thread::sleep(Duration::from_millis(20));
481 audio.poll_ended();
482 }
483 if matches!(audio.device, Device::Failed) {
484 return; // no device: nothing plays and nothing is held
485 }
486 audio.close();
487 assert!(audio.holds_device(), "a sound mid-flight keeps the device");
488 }
489
490 /// A device that failed to open refuses every play, so the
491 /// core hears `refused` and a view sequenced on `sound ended`
492 /// does not hang. Before, the play was dropped on the floor and
493 /// neither ended nor was refused. Forced rather than found: a
494 /// machine with a device cannot fail to open one on demand.
495 #[test]
496 fn a_device_that_failed_to_open_refuses_a_play() {
497 use kui_core::{Core, PlayOptions};
498 let mut core = Core::new();
499 let s = core.add_sound(super::super::blip(44_100, 660.0, 30.0, 0.1));
500 let p = core.play(s, PlayOptions::default());
501 let mut audio = Audio::new();
502 audio.device = Device::Failed;
503 let answered = audio.apply(core.take_audio_commands(), &core.resources);
504 assert_eq!(answered.refused, vec![p], "{answered:?}");
505 assert!(answered.truncated.is_empty());
506 assert!(
507 !audio.active(),
508 "nothing waits on a device that will not open"
509 );
510 // And one that waited for the open and then found it failed:
511 // reported on the apply that flushes it.
512 let mut audio = Audio::new();
513 let (_tx, rx) = mpsc::channel();
514 audio.device = Device::Opening(rx);
515 let p2 = core.play(s, PlayOptions::default());
516 let answered = audio.apply(core.take_audio_commands(), &core.resources);
517 assert!(answered.refused.is_empty(), "still opening: the play waits");
518 // And it counts while it waits (F63): `env.audio.live` is 1
519 // beside `device: opening`, which is what the pomodoro's
520 // smoke test asserts on the click's frame.
521 assert_eq!(audio.env().live, 1, "a play waiting on the open counts");
522 drop(_tx); // the open thread "died"
523 // The poll is what flushes what waited; the next apply hands
524 // the answer back.
525 assert!(audio.poll_ended().is_empty());
526 let answered = audio.apply(Vec::new(), &core.resources);
527 assert_eq!(answered.refused, vec![p2], "{answered:?}");
528 // ... and the refused play leaves the count with the apply
529 // that refused it: `failed` / 0.
530 assert_eq!(audio.env().device, kui_core::AudioDevice::Failed);
531 assert_eq!(audio.env().live, 0, "refused, so no longer counted");
532 }
533
534 /// The decoder needs no device: a synthesized WAV round-trips.
535 #[test]
536 fn synthesized_wav_decodes() {
537 let bytes = super::super::wav_pcm16(8000, &[0.0; 800]);
538 let data = StaticSoundData::from_cursor(Cursor::new(bytes)).expect("decodes");
539 assert_eq!(data.num_frames(), 800);
540 assert_eq!(data.sample_rate, 8000);
541 }
542
543 /// Through the real device when the machine has one (the blip
544 /// plays and reports ended); without one the backend degrades to
545 /// dropping commands, which is the CI case. Either way the first
546 /// command never waits for the device: the open is on a thread,
547 /// and the play that arrived first starts once it is open.
548 #[test]
549 fn plays_through_a_device_or_degrades_gracefully() {
550 use kui_core::{Core, PlayOptions};
551 let mut core = Core::new();
552 let s = core.add_sound(super::super::blip(44_100, 660.0, 30.0, 0.1));
553 let p = core.play(s, PlayOptions::default());
554 let mut audio = Audio::new();
555 let t = std::time::Instant::now();
556 let answered = audio.apply(core.take_audio_commands(), &core.resources);
557 assert!(
558 answered.truncated.is_empty() && answered.refused.is_empty(),
559 "a play truncates nothing and is not refused: {answered:?}"
560 );
561 assert!(
562 t.elapsed() < Duration::from_millis(20),
563 "the first play does not wait for the device: {:?}",
564 t.elapsed()
565 );
566 assert!(audio.active(), "the play waits for the device");
567 let deadline = std::time::Instant::now() + Duration::from_secs(3);
568 let mut ended = Vec::new();
569 while ended.is_empty() && audio.active() && std::time::Instant::now() < deadline {
570 std::thread::sleep(Duration::from_millis(20));
571 ended = audio.poll_ended();
572 }
573 if matches!(audio.device, Device::Failed) {
574 return; // no device: dropped, no panic
575 }
576 assert_eq!(ended, vec![p], "the 30ms blip reports ended");
577 assert!(!audio.active());
578 }
579
580 /// A `Stop` that lands on a handle still playing comes back
581 /// as a truncation, with where the sound was — the fact the core
582 /// cannot see. Through the real device when the machine has one;
583 /// without one nothing ever plays, so nothing is truncated, and
584 /// the assertion is that it stays quiet rather than guessing.
585 #[test]
586 fn a_stop_on_a_playing_sound_comes_back_as_a_truncation() {
587 use kui_core::{Core, PlayOptions};
588 let mut core = Core::new();
589 // Two seconds: long enough that the stop below lands inside it
590 // whatever the machine is doing.
591 let s = core.add_sound(super::super::blip(44_100, 220.0, 2_000.0, 0.05));
592 let p = core.play(s, PlayOptions::default());
593 let mut audio = Audio::new();
594 audio.apply(core.take_audio_commands(), &core.resources);
595 // Wait for the device to open and the sound to be running.
596 let deadline = std::time::Instant::now() + Duration::from_secs(3);
597 while audio.playing.is_empty() && audio.active() && std::time::Instant::now() < deadline
598 {
599 std::thread::sleep(Duration::from_millis(20));
600 audio.poll_ended();
601 }
602 if matches!(audio.device, Device::Failed) || audio.playing.is_empty() {
603 return; // no device: nothing played, so nothing was cut off
604 }
605
606 core.stop(p, 0.0);
607 let cut = audio
608 .apply(core.take_audio_commands(), &core.resources)
609 .truncated;
610 assert_eq!(cut.len(), 1, "the 2s blip was still playing: {cut:?}");
611 assert_eq!(cut[0].0, p);
612 assert!(
613 cut[0].1 >= 0.0 && cut[0].1 < 2.0,
614 "cut inside the sound: {}s",
615 cut[0].1
616 );
617 assert!(
618 audio
619 .apply(core.take_audio_commands(), &core.resources)
620 .truncated
621 .is_empty(),
622 "reported once"
623 );
624 }
625
626 #[test]
627 fn blip_is_a_valid_wav_of_the_asked_length() {
628 let bytes = super::super::blip(44_100, 880.0, 50.0, 0.5);
629 let data = StaticSoundData::from_cursor(Cursor::new(bytes)).expect("decodes");
630 assert_eq!(data.num_frames(), 2205);
631 }
632 }
633}
634
635#[cfg(not(feature = "audio"))]
636mod backend {
637 use super::*;
638
639 /// The `audio` feature is off: every command is dropped.
640 #[derive(Default)]
641 pub struct Audio;
642
643 impl Audio {
644 pub fn new() -> Self {
645 Audio
646 }
647
648 pub fn warm(&mut self) {}
649
650 pub fn holds_device(&self) -> bool {
651 false
652 }
653
654 pub fn close(&mut self) {}
655
656 pub fn apply(
657 &mut self,
658 _cmds: Vec<AudioCommand>,
659 _resources: &SharedResources,
660 ) -> Answered {
661 Answered::default()
662 }
663
664 pub fn poll_ended(&mut self) -> Vec<PlaybackId> {
665 Vec::new()
666 }
667
668 pub fn active(&self) -> bool {
669 false
670 }
671
672 pub fn env(&self) -> AudioEnv {
673 AudioEnv::default()
674 }
675 }
676}