audiobox/assets/mod.rs
1//! Background loading and a cache of decoded sounds. Requires `assets`.
2//!
3//! [`AudioCache`] retains shared decoded sounds within a byte budget. Accessing a
4//! sound makes it most recently used. Admission evicts the least recently used
5//! sounds whose only reference is the cache's; a caller, pending load result
6//! or mixer voice holding an [`Arc<Audio>`] pins that entry. Failed admission leaves
7//! existing entries intact. Sharing one `Arc` under multiple keys pins those entries
8//! conservatively until the other references disappear. Weak references also pin an
9//! entry, so a concurrent weak upgrade cannot race eviction.
10//!
11//! The budget accounts for sample and channel vector capacities, the `Audio` and
12//! entry structs, and key/metadata string storage. It excludes allocator bookkeeping,
13//! collection spare capacity and metadata tree nodes. It is a retained-data budget,
14//! not a bound on process memory: encoded inputs, decoder scratch space and sounds
15//! held outside the cache have separate lifetimes.
16//!
17//! On native targets, `Loader` owns one worker and one cache. File reads and
18//! decoding happen on that worker. Requests use a bounded queue; queue saturation
19//! returns [`ErrorCode::LimitExceeded`](crate::ErrorCode::LimitExceeded). A successful
20//! decode is returned even when it cannot be cached. Keys identify immutable assets:
21//! a cache hit ignores newly supplied bytes or changes to a file. Use a new key or
22//! a new loader when content changes. Relative paths are made absolute at submission
23//! using [`std::path::absolute`], without resolving symlinks. Paths that remain
24//! differently spelled after that conversion are distinct keys.
25//!
26//! All cache and loader operations belong on a game's control/loading thread, never
27//! in the audio callback. Pass the resulting `Arc<Audio>` to the mixer. The cache
28//! works on WebAssembly; the thread-based loader is only available on native targets.
29//!
30//! ```
31//! # #[cfg(not(target_family = "wasm"))]
32//! # fn main() -> audiobox::Result<()> {
33//! use audiobox::assets::{Loader, LoaderConfig};
34//! # use audiobox::{Audio, AudioFormat, encode};
35//! # let bytes = encode(&Audio::new(vec![vec![0.25; 128]], 48_000)?, AudioFormat::Wav)?;
36//! let loader = Loader::new(LoaderConfig::default())?;
37//! let mut loading = loader.load_bytes("jump", bytes)?;
38//! // Once per game frame, on the control thread:
39//! if let Some(result) = loading.try_take() {
40//! let sound = result?; // Arc<Audio>, ready for MixerHandle::play.
41//! }
42//! # Ok(())
43//! # }
44//! # #[cfg(target_family = "wasm")]
45//! # fn main() {}
46//! ```
47use crate::{Audio, Error, Result};
48use std::collections::VecDeque;
49use std::path::PathBuf;
50use std::sync::Arc;
51
52#[cfg(not(target_family = "wasm"))]
53mod loader;
54#[cfg(not(target_family = "wasm"))]
55pub use loader::{LoadHandle, Loader, LoaderConfig};
56
57/// Names and file paths occupy separate namespaces. Paths are compared literally.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub enum AssetKey {
60 Name(String),
61 Path(PathBuf),
62}
63
64impl AssetKey {
65 fn bytes(&self) -> usize {
66 match self {
67 Self::Name(name) => name.capacity(),
68 Self::Path(path) => path.capacity(),
69 }
70 }
71}
72
73struct Entry {
74 key: AssetKey,
75 audio: Arc<Audio>,
76 bytes: usize,
77}
78
79/// A least-recently-used cache that skips entries with external references.
80/// See the module documentation for budget accounting and thread responsibilities.
81pub struct AudioCache {
82 entries: VecDeque<Entry>, // Oldest first; no wrapping timestamp counter.
83 budget: usize,
84 used: usize,
85}
86
87impl AudioCache {
88 /// An empty cache with a retained-data budget in bytes. Zero disables admission.
89 pub fn new(budget_bytes: usize) -> Self {
90 Self {
91 entries: VecDeque::new(),
92 budget: budget_bytes,
93 used: 0,
94 }
95 }
96
97 pub fn budget_bytes(&self) -> usize {
98 self.budget
99 }
100
101 /// Accounted bytes of retained entries, including those pinned by callers.
102 pub fn used_bytes(&self) -> usize {
103 self.used
104 }
105
106 pub fn len(&self) -> usize {
107 self.entries.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
111 self.entries.is_empty()
112 }
113
114 /// Get a shared sound and make its entry most recently used.
115 pub fn get(&mut self, key: &AssetKey) -> Option<Arc<Audio>> {
116 let at = self.entries.iter().position(|entry| &entry.key == key)?;
117 let entry = self.entries.remove(at).unwrap();
118 let audio = Arc::clone(&entry.audio);
119 self.entries.push_back(entry);
120 Some(audio)
121 }
122
123 /// Admit a sound, evicting unused entries in LRU order as needed.
124 ///
125 /// Returns `LimitExceeded` without changing entries when the sound is too large,
126 /// all sufficient victims are pinned, or replacing a different pinned sound.
127 /// Inserting the same `Arc` at its existing key only refreshes recency.
128 pub fn insert(&mut self, key: AssetKey, audio: Arc<Audio>) -> Result<()> {
129 let existing = self.entries.iter().position(|entry| entry.key == key);
130 if let Some(at) = existing {
131 let entry = &mut self.entries[at];
132 if Arc::ptr_eq(&entry.audio, &audio) {
133 let entry = self.entries.remove(at).unwrap();
134 self.entries.push_back(entry);
135 return Ok(());
136 }
137 if Arc::get_mut(&mut entry.audio).is_none() {
138 return Err(Error::limit("Cannot replace a sound still in use"));
139 }
140 }
141 let bytes = entry_bytes(&key, &audio)?;
142 if bytes > self.budget {
143 return Err(Error::limit("Sound exceeds the cache budget"));
144 }
145 let old_bytes = existing.map_or(0, |at| self.entries[at].bytes);
146 let retained = self.used - old_bytes;
147 let needed = retained.saturating_sub(self.budget - bytes);
148 let available: usize = self
149 .entries
150 .iter_mut()
151 .enumerate()
152 .filter_map(|(at, entry)| {
153 (Some(at) != existing && Arc::get_mut(&mut entry.audio).is_some())
154 .then_some(entry.bytes)
155 })
156 .sum();
157 if available < needed {
158 return Err(Error::limit("Cache budget is pinned by sounds in use"));
159 }
160 if let Some(at) = existing {
161 self.entries.remove(at);
162 self.used -= old_bytes;
163 }
164 while self.used > self.budget - bytes {
165 let at = self
166 .entries
167 .iter_mut()
168 .position(|entry| Arc::get_mut(&mut entry.audio).is_some())
169 .unwrap();
170 self.used -= self.entries.remove(at).unwrap().bytes;
171 }
172 self.entries.push_back(Entry { key, audio, bytes });
173 self.used += bytes;
174 Ok(())
175 }
176}
177
178fn entry_bytes(key: &AssetKey, audio: &Audio) -> Result<usize> {
179 let mut bytes = size_of::<Entry>() + size_of::<Audio>();
180 let mut add = |n: usize| -> Result<()> {
181 bytes = bytes
182 .checked_add(n)
183 .ok_or_else(|| Error::limit("Cache size overflow"))?;
184 Ok(())
185 };
186 add(key.bytes())?;
187 add(audio.data.capacity() * size_of::<Vec<f32>>())?;
188 for channel in &audio.data {
189 add(channel.capacity() * size_of::<f32>())?;
190 }
191 for (key, value) in &audio.info.metadata {
192 add(size_of::<(String, String)>())?;
193 add(key.capacity())?;
194 add(value.capacity())?;
195 }
196 for (_, value) in audio.info.tags.fields() {
197 if let Some(value) = value {
198 add(value.capacity())?;
199 }
200 }
201 if let Some(cover) = &audio.info.tags.cover {
202 add(cover.mime_type.capacity())?;
203 add(cover.data.capacity())?;
204 }
205 Ok(bytes)
206}