Skip to main content

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}