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;