Skip to main content

shuyi_ui/audio/
added.rs

1//! The sounds a game adds at run time ([`AudioQueue::add_sound`]): samples
2//! made or read while the game runs (a mod's sounds, a synth, sounds read from
3//! the player's own files), which become cues as a manifest's are. The queue
4//! keeps their sizes against the limits, refuses a cue of the manifest, and
5//! silences a cue removed.
6
7use crate::prelude::*;
8use alloc::collections::{BTreeMap, BTreeSet};
9
10use shuyi_assets::audio::AudioManifest;
11
12use super::{AudioQueue, AudioRequest, Samples, SharedSamples};
13
14/// The most bytes of samples the sounds added hold at once, all together:
15/// 32 MiB, two bytes a sample.
16pub const ADDED_BYTES_MAX: u64 = 32 * 1024 * 1024;
17
18/// The longest a sound added may play, in seconds.
19pub const ADDED_SECONDS_MAX: u64 = 60;
20
21/// Why [`AudioQueue::add_sound`] refused a sound. The game's log says it too.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23#[non_exhaustive]
24pub enum AddSoundError {
25    /// The audio manifest has a sound cue of that name, which an added sound
26    /// can't replace.
27    #[error("sound \"{0}\" is a cue of the audio manifest: an added sound can't replace it")]
28    InManifest(String),
29    /// The sound plays longer than [`ADDED_SECONDS_MAX`].
30    #[error(
31        "sound \"{cue}\" plays {millis} milliseconds: an added sound plays {ADDED_SECONDS_MAX} seconds at most"
32    )]
33    TooLong {
34        /// The sound cue.
35        cue: String,
36        /// How long it plays, in milliseconds.
37        millis: u64,
38    },
39    /// The sounds added would hold more than [`ADDED_BYTES_MAX`] bytes.
40    #[error(
41        "sound \"{cue}\" would make the sounds added {bytes} bytes: they hold {ADDED_BYTES_MAX} at most"
42    )]
43    TooMuch {
44        /// The sound cue.
45        cue: String,
46        /// The bytes they would hold with it.
47        bytes: u64,
48    },
49}
50
51/// The queue's account of the sounds added.
52#[derive(Debug, Clone, Default, PartialEq)]
53pub(super) struct Added {
54    /// The manifest's sound cues, which can't be added.
55    manifest: BTreeSet<String>,
56    /// The cues added and not removed, with their bytes.
57    sounds: BTreeMap<String, u64>,
58    /// The bytes of `sounds`, all together.
59    bytes: u64,
60    /// The cues removed and not added again, each with whether a play of it
61    /// was logged.
62    removed: BTreeMap<String, bool>,
63    /// What was refused, or played after its removal, for the log.
64    problems: Vec<String>,
65}
66
67impl Added {
68    /// Whether `cue` was removed, so a play of it plays nothing: the first
69    /// such play since its removal is a problem for the log.
70    pub(super) fn silences(&mut self, cue: &str) -> bool {
71        let Some(logged) = self.removed.get_mut(cue) else {
72            return false;
73        };
74        if !*logged {
75            *logged = true;
76            self.problems.push(format!(
77                "sound \"{cue}\" was removed: it plays nothing until it is added again"
78            ));
79        }
80        true
81    }
82}
83
84impl<V> AudioQueue<V> {
85    /// Tells the queue the sound cues of `manifest`, which
86    /// [`add_sound`](Self::add_sound) refuses. The context tells it the
87    /// game's.
88    pub fn set_manifest(&mut self, manifest: &AudioManifest) {
89        self.added.manifest = manifest.sounds.keys().cloned().collect();
90    }
91
92    /// Makes `samples` the sound cue `cue`: from now on
93    /// [`play_sound`](Self::play_sound) and
94    /// [`start_sound`](Self::start_sound) of `cue` play it as a cue of the
95    /// manifest would, at its own volume, full, and once. Adding a cue added
96    /// before replaces it. Refused, with a problem in the game's log:
97    ///
98    /// - a cue the audio manifest has ([`AddSoundError::InManifest`]): a
99    ///   checked cue isn't replaced by accident;
100    /// - a sound longer than [`ADDED_SECONDS_MAX`]
101    ///   ([`AddSoundError::TooLong`]);
102    /// - a sound that would make the sounds added hold more than
103    ///   [`ADDED_BYTES_MAX`] ([`AddSoundError::TooMuch`]).
104    ///
105    /// ```
106    /// # use shuyi_ui::audio::{AudioQueue, Samples};
107    /// // A tenth of a second of a square wave, 8-bit, at 11,025 a second.
108    /// let bytes: Vec<u8> = (0..1102).map(|i| if i % 50 < 25 { 64 } else { 192 }).collect();
109    /// let beep = Samples::from_u8(11_025, 1, &bytes).unwrap();
110    /// let mut audio = AudioQueue::<()>::default();
111    /// audio.add_sound("beep", beep).unwrap();
112    /// audio.play_sound("beep");
113    /// assert_eq!(audio.pending().len(), 2);
114    /// ```
115    pub fn add_sound(&mut self, cue: &str, samples: Samples) -> Result<(), AddSoundError> {
116        let checked = self.check_added(cue, &samples);
117        if let Err(error) = &checked {
118            self.added.problems.push(error.to_string());
119            return checked;
120        }
121        let bytes = samples.bytes();
122        let old = self.added.sounds.insert(cue.to_owned(), bytes);
123        self.added.bytes = self.added.bytes - old.unwrap_or(0) + bytes;
124        self.added.removed.remove(cue);
125        self.requests.push(AudioRequest::AddSound {
126            cue: cue.to_owned(),
127            samples: SharedSamples::new(samples),
128        });
129        Ok(())
130    }
131
132    /// Whether `samples` may be added as `cue`.
133    fn check_added(&self, cue: &str, samples: &Samples) -> Result<(), AddSoundError> {
134        if self.added.manifest.contains(cue) {
135            return Err(AddSoundError::InManifest(cue.to_owned()));
136        }
137        let longest = u64::from(samples.rate()).saturating_mul(ADDED_SECONDS_MAX);
138        if samples.frames() > longest {
139            return Err(AddSoundError::TooLong {
140                cue: cue.to_owned(),
141                millis: samples.millis(),
142            });
143        }
144        let replaced = self.added.sounds.get(cue).copied().unwrap_or(0);
145        let bytes = (self.added.bytes - replaced).saturating_add(samples.bytes());
146        if bytes > ADDED_BYTES_MAX {
147            return Err(AddSoundError::TooMuch {
148                cue: cue.to_owned(),
149                bytes,
150            });
151        }
152        Ok(())
153    }
154
155    /// Removes the sound cue `cue` that [`add_sound`](Self::add_sound)
156    /// made, stopping what plays it and freeing its samples. From then on a
157    /// play of it plays nothing, and the first is a problem in the game's
158    /// log, until it is added again. A cue that wasn't added is a problem
159    /// in the log too.
160    pub fn remove_sound(&mut self, cue: &str) {
161        let Some(bytes) = self.added.sounds.remove(cue) else {
162            self.added
163                .problems
164                .push(format!("sound \"{cue}\" can't be removed: it wasn't added"));
165            return;
166        };
167        self.added.bytes -= bytes;
168        self.added.removed.insert(cue.to_owned(), false);
169        self.requests.push(AudioRequest::RemoveSound {
170            cue: cue.to_owned(),
171        });
172    }
173
174    /// Whether `cue` was added and not removed.
175    pub fn is_added(&self, cue: &str) -> bool {
176        self.added.sounds.contains_key(cue)
177    }
178
179    /// The bytes of samples the sounds added hold now, all together: at most
180    /// [`ADDED_BYTES_MAX`].
181    pub fn added_bytes(&self) -> u64 {
182        self.added.bytes
183    }
184
185    /// Whether `cue` was ever added, removed since or not: a play of it is
186    /// one of a cue the game made.
187    pub(crate) fn knows_added(&self, cue: &str) -> bool {
188        self.added.sounds.contains_key(cue) || self.added.removed.contains_key(cue)
189    }
190
191    /// Takes the problems for the game's log, oldest first:
192    /// [`Game`](crate::Game) logs them every frame.
193    pub fn take_problems(&mut self) -> Vec<String> {
194        core::mem::take(&mut self.added.problems)
195    }
196}
197
198#[cfg(test)]
199mod tests;