Skip to main content

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}