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}