Skip to main content

kui_core/
audio.rs

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