Skip to main content

kui_native/
audio.rs

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