Skip to main content

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}