kui_core/audio.rs
1//! Audio as data: sounds are session resources, and playing one is a
2//! command the frame driver drains and applies to the device.
3//!
4//! Register a sound's bytes with `Core::add_sound` (a [`SoundId`]), then
5//! play it one of three ways: `NodeSpec::click_sound` / `hover_sound` on a
6//! node; `Ui::audio` with an [`AudioSpec`], a playback that runs for as
7//! long as the view declares the node; or `Core::play` / `Ui::play` with
8//! [`PlayOptions`], plus `stop`, `set_volume`, `pause`, `resume` and
9//! `set_master_volume` for a host that holds the core. The windowed runner
10//! drains the resulting [`AudioCommand`]s; a host driving its own loop
11//! drains `Core::take_audio_commands`. The core never touches a device, so
12//! a headless test asserts on the queue.
13//!
14//! ```rust
15//! use kui_core::{AudioCommand, AudioSpec, Core, NodeSpec, PlayOptions, Size};
16//!
17//! let mut core = Core::new();
18//! let chime = core.add_sound(b"RIFF....WAVE".to_vec()); // the file's bytes
19//!
20//! // Imperative: start it now at half volume and ask for an `ended` event.
21//! let playback = core.play(chime, PlayOptions::default().volume(0.5).tag("chime"));
22//!
23//! // Declarative, in a view: a click sound, and a loop that plays while declared.
24//! let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
25//! ui.leaf_keyed("go", NodeSpec::row().size(80.0, 24.0).on_click("go").click_sound(chime));
26//! ui.audio_keyed("music", AudioSpec::new(chime).looped().volume(0.3));
27//! ui.finish();
28//!
29//! // What a driver does with the queue.
30//! for cmd in core.take_audio_commands() {
31//! match cmd {
32//! AudioCommand::Play { playback, sound, looped, .. } => {
33//! println!("play {sound:?} as {playback:?} (loop: {looped})");
34//! }
35//! AudioCommand::Stop { playback, .. } => println!("stop {playback:?}"),
36//! other => println!("{}", other.kind_name()),
37//! }
38//! }
39//! core.stop(playback, 0.0);
40//! ```
41//!
42//! A playback started with a tag comes back as
43//! `{kind:"sound", phase:"ended", playback, tag}` on the origin that
44//! started it once the driver reports it finished (`Core::audio_ended`),
45//! not when something stopped it. A play the device refused comes back as
46//! `phase:"refused"` (`Core::audio_refused`), so nothing waits on an
47//! `ended` that cannot come. A stop that cut a one-shot off mid-sound is
48//! reported as the [`crate::diag::TRUNCATED_PLAYBACK`] warning
49//! (`Core::audio_truncated`); [`AudioSpec::finish`] is the usual answer.
50
51use rustc_hash::FxHashMap;
52
53use crate::input::UiEvent;
54use crate::key::Key;
55use crate::resources::SoundId;
56use crate::tree::OriginId;
57use crate::value::Value;
58use crate::window::WindowId;
59
60/// One playback instance. Allocated by the core when the play command is
61/// queued, so callers get it synchronously without a driver round trip;
62/// 0 is never issued.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
64pub struct PlaybackId(pub u64);
65
66/// How to start a playback (`Core::play`).
67#[derive(Clone, Debug, PartialEq)]
68pub struct PlayOptions {
69 /// Linear amplitude, 0..1 (1 = as recorded).
70 pub volume: f32,
71 pub looped: bool,
72 /// Fade in from silence over this many ms (0 = none).
73 pub fade_in_ms: f32,
74 /// Carried back in the `ended` event; `None` = no event.
75 pub tag: Option<Value>,
76}
77
78impl Default for PlayOptions {
79 fn default() -> Self {
80 Self {
81 volume: 1.0,
82 looped: false,
83 fade_in_ms: 0.0,
84 tag: None,
85 }
86 }
87}
88
89impl PlayOptions {
90 pub fn volume(mut self, v: f32) -> Self {
91 self.volume = v;
92 self
93 }
94
95 pub fn looped(mut self) -> Self {
96 self.looped = true;
97 self
98 }
99
100 pub fn fade_in(mut self, ms: f32) -> Self {
101 self.fade_in_ms = ms;
102 self
103 }
104
105 /// Asks for an `ended` event carrying this tag.
106 pub fn tag(mut self, tag: impl Into<Value>) -> Self {
107 self.tag = Some(tag.into());
108 self
109 }
110}
111
112/// What an `audio` node declares each frame (`Core::audio_node`).
113#[derive(Clone, Debug, PartialEq)]
114pub struct AudioSpec {
115 pub src: SoundId,
116 /// Linear amplitude, 0..1; changes apply to the running playback.
117 pub volume: f32,
118 pub looped: bool,
119 /// Holds the playback (resumes when cleared).
120 pub paused: bool,
121 /// What the node going away means: `false` stops the playback, `true`
122 /// releases it — it finishes on its own. See [`AudioSpec::finish`].
123 pub finish: bool,
124 /// Carried back in the `ended` event; `None` = no event.
125 pub tag: Option<Value>,
126}
127
128impl AudioSpec {
129 pub fn new(src: SoundId) -> Self {
130 Self {
131 src,
132 volume: 1.0,
133 looped: false,
134 paused: false,
135 finish: false,
136 tag: None,
137 }
138 }
139
140 pub fn volume(mut self, v: f32) -> Self {
141 self.volume = v;
142 self
143 }
144
145 pub fn looped(mut self) -> Self {
146 self.looped = true;
147 self
148 }
149
150 pub fn paused(mut self, paused: bool) -> Self {
151 self.paused = paused;
152 self
153 }
154
155 /// The node's *removal* releases the playback instead of stopping it:
156 /// it plays to its end, and a `tag` still reports `ended` when it gets
157 /// there. Only removal changes — a `looped` playback still stops (it
158 /// has no end to reach), and a changed `src` still restarts, since that
159 /// is a replacement rather than a departure. A playback that is
160 /// `paused` when its node goes has nothing to finish and the driver
161 /// holds it forever, so a view that pauses should stop rather than
162 /// release. Without this, a one-shot the view wants heard whole has to
163 /// stay declared for the asset's length, which the view does not know.
164 ///
165 /// This is also what
166 /// [`truncated-playback`](crate::diag::TRUNCATED_PLAYBACK) asks for: a
167 /// one-shot removed mid-sound raises that warning, and the answer to
168 /// it is either this flag or a node kept declared until the `ended`
169 /// event. A node that means to cut the sound off says so by stopping
170 /// what it started (`Core::stop`), which is not reported.
171 ///
172 /// A released playback is not free: it holds one of the device's 128
173 /// voices until its file ends, and the 129th play is refused (reported
174 /// as [`crate::diag::PLAYBACK_REFUSED`] and, for a tagged node,
175 /// `{kind:"sound", phase:"refused"}`). A loop would hold a voice for
176 /// ever, which is why a loop is stopped rather than released.
177 pub fn finish(mut self) -> Self {
178 self.finish = true;
179 self
180 }
181
182 pub fn tag(mut self, tag: impl Into<Value>) -> Self {
183 self.tag = Some(tag.into());
184 self
185 }
186}
187
188/// An audio intent for the frame driver. Durations are ms; volumes are
189/// linear amplitude. Drivers ignore playbacks they no longer hold.
190#[derive(Clone, Debug, PartialEq)]
191pub enum AudioCommand {
192 Play {
193 playback: PlaybackId,
194 sound: SoundId,
195 volume: f32,
196 looped: bool,
197 fade_in_ms: f32,
198 },
199 Stop {
200 playback: PlaybackId,
201 fade_ms: f32,
202 },
203 SetVolume {
204 playback: PlaybackId,
205 volume: f32,
206 tween_ms: f32,
207 },
208 Pause {
209 playback: PlaybackId,
210 fade_ms: f32,
211 },
212 Resume {
213 playback: PlaybackId,
214 fade_ms: f32,
215 },
216 MasterVolume {
217 volume: f32,
218 tween_ms: f32,
219 },
220 /// The sound was unregistered: drop any decoded copy.
221 Unload {
222 sound: SoundId,
223 },
224}
225
226impl AudioCommand {
227 /// The command's wire name: `play`, `stop`, `setVolume`, `pause`,
228 /// `resume`, `masterVolume`, `unload`.
229 pub fn kind_name(&self) -> &'static str {
230 match self {
231 AudioCommand::Play { .. } => "play",
232 AudioCommand::Stop { .. } => "stop",
233 AudioCommand::SetVolume { .. } => "setVolume",
234 AudioCommand::Pause { .. } => "pause",
235 AudioCommand::Resume { .. } => "resume",
236 AudioCommand::MasterVolume { .. } => "masterVolume",
237 AudioCommand::Unload { .. } => "unload",
238 }
239 }
240
241 /// `{kind, ...}` with what the variant carries: `playback` (a small
242 /// counter, an integer), `sound` (a resource id, spelled by `h`),
243 /// `volume`, `loop`, and the durations in ms as `fade_in`, `fade`,
244 /// `tween`.
245 pub fn to_value(&self, h: crate::value::Handles) -> Value {
246 let pb = |p: PlaybackId| Value::Int(p.0 as i64);
247 let mut out = vec![("kind".to_string(), Value::str(self.kind_name()))];
248 let mut push = |k: &str, v: Value| out.push((k.to_string(), v));
249 match *self {
250 AudioCommand::Play {
251 playback,
252 sound,
253 volume,
254 looped,
255 fade_in_ms,
256 } => {
257 push("playback", pb(playback));
258 push("sound", (h.id)(sound.to_ffi()));
259 push("volume", Value::float(volume));
260 push("loop", Value::Bool(looped));
261 push("fade_in", Value::float(fade_in_ms));
262 }
263 AudioCommand::Stop { playback, fade_ms }
264 | AudioCommand::Pause { playback, fade_ms }
265 | AudioCommand::Resume { playback, fade_ms } => {
266 push("playback", pb(playback));
267 push("fade", Value::float(fade_ms));
268 }
269 AudioCommand::SetVolume {
270 playback,
271 volume,
272 tween_ms,
273 } => {
274 push("playback", pb(playback));
275 push("volume", Value::float(volume));
276 push("tween", Value::float(tween_ms));
277 }
278 AudioCommand::MasterVolume { volume, tween_ms } => {
279 push("volume", Value::float(volume));
280 push("tween", Value::float(tween_ms));
281 }
282 AudioCommand::Unload { sound } => push("sound", (h.id)(sound.to_ffi())),
283 }
284 Value::Map(out)
285 }
286}
287
288/// A playback that asked for an `ended` event.
289struct Tagged {
290 origin: OriginId,
291 /// The window whose frame declared the node (or whose core called
292 /// `play`), so the event lands there and not on whichever window's
293 /// driver drained the device.
294 window: WindowId,
295 key: Key,
296 tag: Value,
297}
298
299/// An `audio` node's retained playback.
300struct Mounted {
301 playback: PlaybackId,
302 spec: AudioSpec,
303}
304
305/// What a mounted `audio` node is keyed by. The store is the session's
306/// (one device), but a mount is one window's: it is reconciled against
307/// *that window's* frame, so a second window's frame declaring no
308/// `<audio>` says nothing about the first's.
309type Mount = (WindowId, Key);
310
311/// Why a one-shot playback was cut off — what
312/// [`diag::TRUNCATED_PLAYBACK`](crate::diag::TRUNCATED_PLAYBACK) reports
313/// once the driver confirms the sound was still running.
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum Why {
316 /// The node declaring it went away without [`AudioSpec::finish`].
317 Removed,
318 /// The node changed its `src`, which replaces the playback.
319 Restarted,
320}
321
322impl Why {
323 /// The verb for the message ("removed at 0.5 s").
324 pub(crate) fn verb(self) -> &'static str {
325 match self {
326 Why::Removed => "removed",
327 Why::Restarted => "restarted",
328 }
329 }
330}
331
332/// Stops the driver has not answered for yet are remembered so a
333/// truncation can name its node; a headless core has no driver to answer,
334/// so the map is capped and the oldest entry — the lowest [`PlaybackId`],
335/// which they are issued in — makes room for a newer one.
336const MAX_STOPPED: usize = 256;
337
338/// Playback bookkeeping on the session: the command queue, the tagged
339/// playbacks awaiting their `ended` event, and the `audio` nodes' retained
340/// playbacks. The queue and the ids are the session's, because the
341/// process has one device; the mounts are keyed by window as well as by
342/// node, because each is reconciled against one window's frame
343/// (`finish_frame` hands in the window it finished, and only that
344/// window's slice is diffed).
345#[derive(Default)]
346pub struct AudioStore {
347 next: u64,
348 commands: Vec<AudioCommand>,
349 tagged: FxHashMap<PlaybackId, Tagged>,
350 mounted: FxHashMap<Mount, Mounted>,
351 /// `audio` nodes declared this frame, in tree order, each with the
352 /// window whose frame declared it.
353 declared: Vec<(Mount, OriginId, AudioSpec)>,
354 /// One-shots `reconcile` stopped, awaiting the driver's word on
355 /// whether they were still playing (`Core::audio_truncated`). The
356 /// core cannot know that itself: `ended` is the driver's too, an
357 /// untagged one-shot leaves no `tagged` entry to have heard it, and a
358 /// headless `Ctx` has no driver at all — so nothing here is a warning
359 /// until something answers for it.
360 stopped: FxHashMap<PlaybackId, (Key, Why)>,
361}
362
363impl AudioStore {
364 fn alloc(&mut self) -> PlaybackId {
365 self.next += 1;
366 PlaybackId(self.next)
367 }
368
369 /// Queues a play; `origin`/`window`/`key` say where an `ended` event
370 /// lands.
371 pub(crate) fn play(
372 &mut self,
373 origin: OriginId,
374 window: WindowId,
375 key: Key,
376 sound: SoundId,
377 opts: PlayOptions,
378 ) -> PlaybackId {
379 let playback = self.alloc();
380 self.commands.push(AudioCommand::Play {
381 playback,
382 sound,
383 volume: opts.volume,
384 looped: opts.looped,
385 fade_in_ms: opts.fade_in_ms,
386 });
387 if let Some(tag) = opts.tag {
388 self.tagged.insert(
389 playback,
390 Tagged {
391 origin,
392 window,
393 key,
394 tag,
395 },
396 );
397 }
398 playback
399 }
400
401 /// Stops a playback; it will not report `ended`.
402 pub(crate) fn stop(&mut self, playback: PlaybackId, fade_ms: f32) {
403 self.tagged.remove(&playback);
404 self.commands.push(AudioCommand::Stop { playback, fade_ms });
405 }
406
407 /// Remembers a stop that may have cut a sound off, so the driver's
408 /// answer has a node to land on. Bounded: at the cap the oldest
409 /// unanswered stop is dropped.
410 fn record_stop(&mut self, playback: PlaybackId, key: Key, why: Why) {
411 if self.stopped.len() >= MAX_STOPPED
412 && let Some(oldest) = self.stopped.keys().min().copied()
413 {
414 self.stopped.remove(&oldest);
415 }
416 self.stopped.insert(playback, (key, why));
417 }
418
419 /// The driver reports it stopped `playback` while the sound was still
420 /// running. Returns the node it was declared on and why it was cut,
421 /// once — a stop that landed after the sound ended, or one of a
422 /// playback nothing recorded (an imperative `stop`, a loop, a
423 /// `finish` release), answers nothing.
424 pub(crate) fn truncated(&mut self, playback: PlaybackId) -> Option<(Key, Why)> {
425 self.stopped.remove(&playback)
426 }
427
428 pub(crate) fn set_volume(&mut self, playback: PlaybackId, volume: f32, tween_ms: f32) {
429 self.commands.push(AudioCommand::SetVolume {
430 playback,
431 volume,
432 tween_ms,
433 });
434 }
435
436 pub(crate) fn pause(&mut self, playback: PlaybackId, fade_ms: f32) {
437 self.commands
438 .push(AudioCommand::Pause { playback, fade_ms });
439 }
440
441 pub(crate) fn resume(&mut self, playback: PlaybackId, fade_ms: f32) {
442 self.commands
443 .push(AudioCommand::Resume { playback, fade_ms });
444 }
445
446 pub(crate) fn master_volume(&mut self, volume: f32, tween_ms: f32) {
447 self.commands
448 .push(AudioCommand::MasterVolume { volume, tween_ms });
449 }
450
451 pub(crate) fn unload(&mut self, sound: SoundId) {
452 self.commands.push(AudioCommand::Unload { sound });
453 }
454
455 /// Drains the queued commands (what `Core::take_audio_commands` hands
456 /// the driver).
457 pub fn take_commands(&mut self) -> Vec<AudioCommand> {
458 std::mem::take(&mut self.commands)
459 }
460
461 /// Commands queued and not yet drained.
462 pub fn pending(&self) -> &[AudioCommand] {
463 &self.commands
464 }
465
466 /// The playback an `audio` node of `window` holds, if it is mounted.
467 pub fn playback_of(&self, window: WindowId, key: Key) -> Option<PlaybackId> {
468 self.mounted.get(&(window, key)).map(|m| m.playback)
469 }
470
471 /// Whether any `audio` node of `window` is mounted. The scene corpus's
472 /// coverage derivation reads it: an `audio` element builds no tree
473 /// node, so a mounted playback is the only trace one leaves.
474 #[cfg(feature = "conformance")]
475 pub(crate) fn any_mounted(&self, window: WindowId) -> bool {
476 self.mounted.keys().any(|(w, _)| *w == window)
477 }
478
479 /// An `audio` node declared this frame by `window`; reconciled when
480 /// that window's frame finishes.
481 pub(crate) fn declare(
482 &mut self,
483 window: WindowId,
484 key: Key,
485 origin: OriginId,
486 spec: AudioSpec,
487 ) {
488 self.declared.push(((window, key), origin, spec));
489 }
490
491 /// The driver reported a playback finished on its own. Returns the
492 /// `ended` event when the playback asked for one.
493 pub(crate) fn ended(&mut self, playback: PlaybackId) -> Option<UiEvent> {
494 // It reached its end, so a stop queued for it in the same breath
495 // cut nothing off.
496 self.stopped.remove(&playback);
497 let t = self.tagged.remove(&playback)?;
498 Some(UiEvent {
499 origin: t.origin,
500 window: t.window,
501 key: t.key,
502 payload: Value::map([
503 ("kind", Value::str("sound")),
504 ("phase", Value::str("ended")),
505 ("playback", Value::Int(playback.0 as i64)),
506 ("tag", t.tag),
507 ]),
508 slot: None,
509 })
510 }
511
512 /// The driver refused a play — the device's voices are all held, or
513 /// the sound did not decode. The playback never started, so it will
514 /// never reach [`Self::ended`]: a tagged one is handed the same event
515 /// with `phase: "refused"` instead, which unsticks a view waiting on
516 /// the sound and still tells it the sound was not heard. The warning
517 /// comes back whether or not anything was waiting — an untagged
518 /// refusal is silent otherwise.
519 ///
520 /// The `audio` node's mount is left alone: unmounting it would have
521 /// the next frame re-declare, replay and be refused again, one line
522 /// per frame, where leaving it mounted costs one.
523 pub(crate) fn refused(
524 &mut self,
525 playback: PlaybackId,
526 ) -> (Option<UiEvent>, crate::diag::Warning) {
527 let warning = crate::diag::playback_refused(self.key_of(playback), playback);
528 let event = self.tagged.remove(&playback).map(|t| UiEvent {
529 origin: t.origin,
530 window: t.window,
531 key: t.key,
532 payload: Value::map([
533 ("kind", Value::str("sound")),
534 ("phase", Value::str("refused")),
535 ("playback", Value::Int(playback.0 as i64)),
536 ("tag", t.tag),
537 ]),
538 slot: None,
539 });
540 (event, warning)
541 }
542
543 /// The node a warning about a playback hangs on: the node that asked
544 /// for the sound when one did — a tagged playback's, or the `audio`
545 /// element that mounted it — and the root otherwise, which is where an
546 /// imperative `play` and a `click_sound` start from anyway.
547 fn key_of(&self, playback: PlaybackId) -> Key {
548 if let Some(t) = self.tagged.get(&playback) {
549 return t.key;
550 }
551 self.mounted
552 .iter()
553 .find(|(_, m)| m.playback == playback)
554 .map(|((_, k), _)| *k)
555 .unwrap_or(Key::ROOT)
556 }
557
558 /// Diffs `window`'s frame's `audio` nodes against the playbacks
559 /// mounted for that window — and that window only: another window's
560 /// mounts are neither started nor stopped by a frame that is not
561 /// theirs. New keys start, missing keys stop, a changed `src`/`looped`
562 /// restarts, `volume`/`paused` changes apply live. A one-shot that
563 /// finished stays mounted silently until its node goes away — so a
564 /// view re-rendering does not replay it. A missing key that asked to
565 /// [`finish`](AudioSpec::finish) is released rather than stopped.
566 /// Every other stop of a one-shot is remembered (see `truncated`) in
567 /// case the driver says the sound was still running.
568 pub(crate) fn reconcile(&mut self, window: WindowId) {
569 // Declarations are pushed while a frame is built and taken when it
570 // finishes, so what is here is normally one window's; another
571 // window's are left for its own finish.
572 let (declared, others): (Vec<_>, Vec<_>) = std::mem::take(&mut self.declared)
573 .into_iter()
574 .partition(|(mount, _, _)| mount.0 == window);
575 self.declared = others;
576 let mut seen: Vec<Key> = Vec::with_capacity(declared.len());
577 for (mount, origin, spec) in declared {
578 let key = mount.1;
579 if seen.contains(&key) {
580 continue;
581 }
582 seen.push(key);
583 let restart = match self.mounted.get(&mount) {
584 None => true,
585 Some(m) => m.spec.src != spec.src || m.spec.looped != spec.looped,
586 };
587 if restart {
588 if let Some(old) = self.mounted.remove(&mount) {
589 self.stop(old.playback, 0.0);
590 // A replaced one-shot is cut off exactly as a removed
591 // one is; `finish` does not release it (it is not a
592 // departure), so it is also the opt-out here.
593 if !old.spec.looped && !old.spec.finish {
594 self.record_stop(old.playback, key, Why::Restarted);
595 }
596 }
597 let opts = PlayOptions {
598 volume: spec.volume,
599 looped: spec.looped,
600 fade_in_ms: 0.0,
601 tag: spec.tag.clone(),
602 };
603 let playback = self.play(origin, window, key, spec.src, opts);
604 if spec.paused {
605 self.pause(playback, 0.0);
606 }
607 self.mounted.insert(mount, Mounted { playback, spec });
608 continue;
609 }
610 let m = self.mounted.get_mut(&mount).expect("mounted");
611 let playback = m.playback;
612 if m.spec.volume != spec.volume {
613 self.commands.push(AudioCommand::SetVolume {
614 playback,
615 volume: spec.volume,
616 tween_ms: 0.0,
617 });
618 }
619 if m.spec.paused != spec.paused {
620 self.commands.push(if spec.paused {
621 AudioCommand::Pause {
622 playback,
623 fade_ms: 0.0,
624 }
625 } else {
626 AudioCommand::Resume {
627 playback,
628 fade_ms: 0.0,
629 }
630 });
631 }
632 if m.spec.tag != spec.tag {
633 match (&spec.tag, self.tagged.get_mut(&playback)) {
634 (Some(tag), Some(t)) => t.tag = tag.clone(),
635 (Some(tag), None) => {
636 self.tagged.insert(
637 playback,
638 Tagged {
639 origin,
640 window,
641 key,
642 tag: tag.clone(),
643 },
644 );
645 }
646 (None, _) => {
647 self.tagged.remove(&playback);
648 }
649 }
650 }
651 m.spec = spec;
652 }
653 // A departure stops the playback, unless the node asked to be
654 // released — then it is forgotten here and finishes on the device,
655 // keeping its `tagged` entry so `ended` still arrives. A looped one
656 // is stopped whatever it asked: it has no end to run to.
657 let gone: Vec<(Key, PlaybackId, bool, bool)> = self
658 .mounted
659 .iter()
660 .filter(|((w, k), _)| *w == window && !seen.contains(k))
661 .map(|((_, k), m)| (*k, m.playback, m.spec.finish, m.spec.looped))
662 .collect();
663 for (key, playback, finish, looped) in gone {
664 self.mounted.remove(&(window, key));
665 if finish && !looped {
666 continue;
667 }
668 self.stop(playback, 0.0);
669 // A one-shot that did not ask to be released is the case
670 // `truncated-playback` is about — if it was still running,
671 // which only the driver can say.
672 if !looped && !finish {
673 self.record_stop(playback, key, Why::Removed);
674 }
675 }
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::resources::{Resources, SessionId};
683
684 fn sound() -> SoundId {
685 Resources::new(SessionId::next()).add_sound(vec![0; 4])
686 }
687
688 #[test]
689 fn play_allocates_ids_and_only_tagged_playbacks_report_ended() {
690 let mut a = AudioStore::default();
691 let s = sound();
692 let quiet = a.play(
693 OriginId::HOST,
694 WindowId::MAIN,
695 Key::ROOT,
696 s,
697 PlayOptions::default(),
698 );
699 let loud = a.play(
700 OriginId::HOST,
701 WindowId::MAIN,
702 Key::ROOT,
703 s,
704 PlayOptions::default().tag(Value::str("t")),
705 );
706 assert_ne!(quiet, loud);
707 assert_eq!(a.take_commands().len(), 2);
708 assert!(a.ended(quiet).is_none());
709 let ev = a.ended(loud).expect("tagged playback reports ended");
710 assert_eq!(ev.kind(), Some("sound"));
711 assert_eq!(ev.payload.get_str("tag"), Some("t"));
712 assert_eq!(ev.payload.get_int("playback"), Some(loud.0 as i64));
713 // Reported once.
714 assert!(a.ended(loud).is_none());
715 }
716
717 #[test]
718 fn stop_cancels_the_ended_event() {
719 let mut a = AudioStore::default();
720 let s = sound();
721 let p = a.play(
722 OriginId::HOST,
723 WindowId::MAIN,
724 Key::ROOT,
725 s,
726 PlayOptions::default().tag(Value::Null),
727 );
728 a.stop(p, 0.0);
729 assert!(a.ended(p).is_none());
730 }
731
732 /// F29: the node's removal releases the playback, so a one-shot the
733 /// view wants heard whole no longer has to stay declared for a length
734 /// the view has to guess at.
735 #[test]
736 fn a_removed_node_that_asked_to_finish_is_not_stopped() {
737 let mut a = AudioStore::default();
738 let s = sound();
739 let k = Key::ROOT.str("chime");
740 a.declare(
741 WindowId::MAIN,
742 k,
743 OriginId::HOST,
744 AudioSpec::new(s).finish(),
745 );
746 a.reconcile(WindowId::MAIN);
747 assert!(matches!(
748 a.take_commands().as_slice(),
749 [AudioCommand::Play { .. }]
750 ));
751
752 // Gone: released, not stopped — and the store forgets it, so a
753 // later re-declare of the same key starts a new playback.
754 a.reconcile(WindowId::MAIN);
755 assert_eq!(a.take_commands(), vec![]);
756 assert!(a.playback_of(WindowId::MAIN, k).is_none());
757 }
758
759 /// Release is meaningless for a loop — there is no end to run to — so
760 /// the flag changes nothing and removal still stops it.
761 #[test]
762 fn a_removed_loop_stops_even_when_it_asked_to_finish() {
763 let mut a = AudioStore::default();
764 let s = sound();
765 let k = Key::ROOT.str("bed");
766 a.declare(
767 WindowId::MAIN,
768 k,
769 OriginId::HOST,
770 AudioSpec::new(s).looped().finish(),
771 );
772 a.reconcile(WindowId::MAIN);
773 let p = a.playback_of(WindowId::MAIN, k).unwrap();
774 a.take_commands();
775
776 a.reconcile(WindowId::MAIN);
777 assert_eq!(
778 a.take_commands(),
779 vec![AudioCommand::Stop {
780 playback: p,
781 fade_ms: 0.0
782 }]
783 );
784 }
785
786 /// The `ended` event is what the release hands the view instead of the
787 /// guessed duration, so it has to survive the node going away — unlike
788 /// a stop, which cancels it (`stop_cancels_the_ended_event`).
789 #[test]
790 fn a_released_playback_still_reports_ended() {
791 let mut a = AudioStore::default();
792 let s = sound();
793 let k = Key::ROOT.str("chime");
794 let spec = {
795 let mut spec = AudioSpec::new(s).finish();
796 spec.tag = Some(Value::str("chime"));
797 spec
798 };
799 a.declare(WindowId::MAIN, k, OriginId::HOST, spec);
800 a.reconcile(WindowId::MAIN);
801 let p = a.playback_of(WindowId::MAIN, k).unwrap();
802 a.take_commands();
803
804 a.reconcile(WindowId::MAIN);
805 assert_eq!(a.take_commands(), vec![]);
806 let ev = a.ended(p).expect("a released playback still reports ended");
807 assert_eq!(ev.key, k);
808 assert_eq!(ev.payload.get_str("tag"), Some("chime"));
809 }
810
811 /// F35: the device refuses a play past its 128 voices, and the
812 /// playback that never starts never ends — so a tagged node waiting
813 /// for `ended` would wait forever. It hears `refused` instead, on the
814 /// key it declared, and can tell the two phases apart.
815 #[test]
816 fn a_refused_tagged_playback_reports_refused_and_warns() {
817 let mut a = AudioStore::default();
818 let s = sound();
819 let k = Key::ROOT.str("chime");
820 let p = a.play(
821 OriginId::HOST,
822 WindowId::MAIN,
823 k,
824 s,
825 PlayOptions::default().tag(Value::str("chime")),
826 );
827 let (event, warning) = a.refused(p);
828 let ev = event.expect("a tagged playback hears the refusal");
829 assert_eq!(ev.key, k);
830 assert_eq!(ev.kind(), Some("sound"));
831 assert_eq!(
832 ev.payload.get_str("phase"),
833 Some("refused"),
834 "told apart from the `ended` that will never come"
835 );
836 assert_eq!(ev.payload.get_str("tag"), Some("chime"));
837 assert_eq!(ev.payload.get_int("playback"), Some(p.0 as i64));
838 assert_eq!(warning.code, crate::diag::PLAYBACK_REFUSED);
839 assert_eq!(warning.key, k);
840
841 // Reported once: the refusal is consumed like an end.
842 assert!(a.refused(p).0.is_none());
843 assert!(a.ended(p).is_none(), "and it can never end afterwards");
844 }
845
846 /// An untagged play — a `click_sound`, a bare `Core::play` — has no
847 /// view waiting on it, so the warning is the whole report.
848 #[test]
849 fn a_refused_untagged_playback_is_the_warning_alone() {
850 let mut a = AudioStore::default();
851 let s = sound();
852 let p = a.play(
853 OriginId::HOST,
854 WindowId::MAIN,
855 Key::ROOT,
856 s,
857 PlayOptions::default(),
858 );
859 let (event, warning) = a.refused(p);
860 assert!(event.is_none(), "nothing asked to hear about this one");
861 assert_eq!(warning.code, crate::diag::PLAYBACK_REFUSED);
862 assert_eq!(warning.key, Key::ROOT);
863 }
864
865 /// An `audio` node's playback survives the node — that is what
866 /// `finish` means — so a refusal after the release still has the tag
867 /// to report on, and the key the node declared it under.
868 #[test]
869 fn a_refused_released_playback_still_reports() {
870 let mut a = AudioStore::default();
871 let s = sound();
872 let k = Key::ROOT.str("chime");
873 let spec = {
874 let mut spec = AudioSpec::new(s).finish();
875 spec.tag = Some(Value::str("chime"));
876 spec
877 };
878 a.declare(WindowId::MAIN, k, OriginId::HOST, spec);
879 a.reconcile(WindowId::MAIN);
880 let p = a.playback_of(WindowId::MAIN, k).unwrap();
881 a.take_commands();
882
883 // Gone: released, and the store forgets the mount.
884 a.reconcile(WindowId::MAIN);
885 assert!(a.playback_of(WindowId::MAIN, k).is_none());
886
887 let (event, warning) = a.refused(p);
888 let ev = event.expect("a released playback still hears the refusal");
889 assert_eq!(ev.key, k);
890 assert_eq!(ev.payload.get_str("phase"), Some("refused"));
891 assert_eq!(warning.key, k);
892 }
893
894 /// A mounted `audio` node without a tag hangs its warning on the node
895 /// rather than the root, so the line names the element that asked.
896 #[test]
897 fn an_untagged_audio_node_warns_on_its_own_key() {
898 let mut a = AudioStore::default();
899 let s = sound();
900 let k = Key::ROOT.str("bed");
901 a.declare(
902 WindowId::MAIN,
903 k,
904 OriginId::HOST,
905 AudioSpec::new(s).looped(),
906 );
907 a.reconcile(WindowId::MAIN);
908 let p = a.playback_of(WindowId::MAIN, k).unwrap();
909
910 let (event, warning) = a.refused(p);
911 assert!(event.is_none());
912 assert_eq!(warning.key, k);
913 }
914
915 #[test]
916 fn audio_nodes_reconcile_by_key() {
917 let mut a = AudioStore::default();
918 let s = sound();
919 let k = Key::ROOT.str("music");
920 a.declare(
921 WindowId::MAIN,
922 k,
923 OriginId::HOST,
924 AudioSpec::new(s).looped(),
925 );
926 a.reconcile(WindowId::MAIN);
927 let cmds = a.take_commands();
928 assert!(matches!(
929 cmds.as_slice(),
930 [AudioCommand::Play { looped: true, .. }]
931 ));
932 let p = a.playback_of(WindowId::MAIN, k).unwrap();
933
934 // Same declaration: nothing.
935 a.declare(
936 WindowId::MAIN,
937 k,
938 OriginId::HOST,
939 AudioSpec::new(s).looped(),
940 );
941 a.reconcile(WindowId::MAIN);
942 assert!(a.take_commands().is_empty());
943
944 // Volume + pause apply live.
945 a.declare(
946 WindowId::MAIN,
947 k,
948 OriginId::HOST,
949 AudioSpec::new(s).looped().volume(0.5).paused(true),
950 );
951 a.reconcile(WindowId::MAIN);
952 let cmds = a.take_commands();
953 assert_eq!(
954 cmds,
955 vec![
956 AudioCommand::SetVolume {
957 playback: p,
958 volume: 0.5,
959 tween_ms: 0.0
960 },
961 AudioCommand::Pause {
962 playback: p,
963 fade_ms: 0.0
964 }
965 ]
966 );
967
968 // Gone: stopped.
969 a.reconcile(WindowId::MAIN);
970 assert_eq!(
971 a.take_commands(),
972 vec![AudioCommand::Stop {
973 playback: p,
974 fade_ms: 0.0
975 }]
976 );
977 assert!(a.playback_of(WindowId::MAIN, k).is_none());
978 }
979
980 #[test]
981 fn changing_src_restarts() {
982 let mut a = AudioStore::default();
983 let mut r = Resources::new(SessionId::next());
984 let (s1, s2) = (r.add_sound(vec![0; 4]), r.add_sound(vec![1; 4]));
985 let k = Key::ROOT.str("fx");
986 a.declare(WindowId::MAIN, k, OriginId::HOST, AudioSpec::new(s1));
987 a.reconcile(WindowId::MAIN);
988 let p1 = a.playback_of(WindowId::MAIN, k).unwrap();
989 a.take_commands();
990 a.declare(WindowId::MAIN, k, OriginId::HOST, AudioSpec::new(s2));
991 a.reconcile(WindowId::MAIN);
992 let cmds = a.take_commands();
993 assert!(matches!(
994 cmds.as_slice(),
995 [
996 AudioCommand::Stop { playback, .. },
997 AudioCommand::Play { sound, .. }
998 ] if *playback == p1 && *sound == s2
999 ));
1000 }
1001}