mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Game audio: escape the raw NUL bytes in a library.rs test literal
The WAV-header byte string held literal NUL bytes, so git stored the whole file as binary (no diffs). Written as \x00 escapes; same bytes at compile time.
This commit is contained in:
1 file changed
+250
-250
@@ -1,250 +1,250 @@
|
||||
//! The decoded audio described by private/audio/audio_manifest.json. Clips are
|
||||
//! read on first use (sample banks are small; an ambience bed is ~25 MB of
|
||||
//! PCM) and kept until released.
|
||||
use bevy::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
const MANIFEST_VERSION: u32 = 3;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct Entry {
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// A rolling grain: its slow-to-fast recording cut into loopable speed bands.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Grain {
|
||||
bands: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// One layer of a retail patch record: members as
|
||||
/// (sample, gain, gain range, pitch, probability) — see audio_formats.splc_patches.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct Group {
|
||||
pub mode: u8,
|
||||
pub members: Vec<(usize, f32, f32, f32, f32)>,
|
||||
}
|
||||
|
||||
/// Retail SPLC patch tree of one bank: records (layers played together) and
|
||||
/// containers (pick one record).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct Patches {
|
||||
pub records: Vec<Vec<Group>>,
|
||||
pub containers: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Manifest {
|
||||
version: u32,
|
||||
ambience: BTreeMap<String, Entry>,
|
||||
grains: BTreeMap<String, Grain>,
|
||||
wheels: BTreeMap<String, Entry>,
|
||||
banks: BTreeMap<String, Vec<Entry>>,
|
||||
#[serde(default)]
|
||||
patches: BTreeMap<String, Patches>,
|
||||
}
|
||||
|
||||
/// A loaded sound. `key` identifies the file for per-sound voice limits;
|
||||
/// `peak` is its loudest sample (0..1 of full scale), measured on load.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Clip {
|
||||
pub handle: Handle<AudioSource>,
|
||||
pub key: Arc<str>,
|
||||
pub peak: f32,
|
||||
}
|
||||
|
||||
/// Loudest sample of a PCM16 WAV as a fraction of full scale (1.0 if unreadable).
|
||||
pub(crate) fn wav_peak(bytes: &[u8]) -> f32 {
|
||||
let mut at = 12;
|
||||
while at + 8 <= bytes.len() {
|
||||
let size = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let body = at + 8;
|
||||
if &bytes[at..at + 4] == b"data" {
|
||||
let data = &bytes[body..(body + size).min(bytes.len())];
|
||||
let max = data.chunks_exact(2).map(|s| i16::from_le_bytes([s[0], s[1]]).unsigned_abs()).max().unwrap_or(0);
|
||||
return (max as f32 / 32768.0).max(1e-3);
|
||||
}
|
||||
at = body + size + (size & 1);
|
||||
}
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct Library {
|
||||
root: PathBuf,
|
||||
manifest: Manifest,
|
||||
loaded: HashMap<String, Clip>,
|
||||
failed: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
fn safe_relative(file: &str) -> bool {
|
||||
let path = Path::new(file);
|
||||
// ':' also rules out drive prefixes on platforms that would parse "C:" as a name.
|
||||
!file.is_empty() && !file.contains(':') && path.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub(crate) fn load(asset_root: &Path) -> Result<Self, String> {
|
||||
let root = asset_root.join("private/audio");
|
||||
let path = root.join("audio_manifest.json");
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if manifest.version != MANIFEST_VERSION {
|
||||
return Err(format!("{}: unsupported version {}", path.display(), manifest.version));
|
||||
}
|
||||
let files = manifest.ambience.values().chain(manifest.grains.values().flat_map(|g| &g.bands)).chain(manifest.wheels.values())
|
||||
.chain(manifest.banks.values().flatten());
|
||||
if let Some(bad) = files.map(|e| &e.file).find(|f| !safe_relative(f)) {
|
||||
return Err(format!("{}: invalid file path {bad:?}", path.display()));
|
||||
}
|
||||
info!(
|
||||
"Game audio: {} ambience beds, {} rolling grains, {} sample banks",
|
||||
manifest.ambience.len(), manifest.grains.len(), manifest.banks.len()
|
||||
);
|
||||
Ok(Self { root, manifest, loaded: HashMap::new(), failed: Default::default() })
|
||||
}
|
||||
|
||||
fn clip(&mut self, assets: &mut Assets<AudioSource>, file: &str) -> Option<Clip> {
|
||||
if let Some(clip) = self.loaded.get(file) {
|
||||
return Some(clip.clone());
|
||||
}
|
||||
if self.failed.contains(file) {
|
||||
return None;
|
||||
}
|
||||
match std::fs::read(self.root.join(file)) {
|
||||
Ok(bytes) => {
|
||||
let peak = wav_peak(&bytes);
|
||||
let clip = Clip { handle: assets.add(AudioSource { bytes: bytes.into() }), key: file.into(), peak };
|
||||
self.loaded.insert(file.to_owned(), clip.clone());
|
||||
Some(clip)
|
||||
}
|
||||
Err(error) => {
|
||||
// Once per file: a damaged install must not spam the log every frame.
|
||||
warn!("Game audio {file}: {error}");
|
||||
self.failed.insert(file.to_owned());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ambience(&mut self, assets: &mut Assets<AudioSource>, name: &str) -> Option<Clip> {
|
||||
let entry = self.manifest.ambience.get(name)?;
|
||||
let file = entry.file.clone();
|
||||
self.clip(assets, &file)
|
||||
}
|
||||
|
||||
/// Number of speed bands of a rolling grain (0 if absent).
|
||||
pub(crate) fn grain_bands(&self, name: &str) -> usize {
|
||||
self.manifest.grains.get(name).map_or(0, |g| g.bands.len())
|
||||
}
|
||||
|
||||
pub(crate) fn grain(&mut self, assets: &mut Assets<AudioSource>, name: &str, band: usize) -> Option<Clip> {
|
||||
let entry = self.manifest.grains.get(name)?.bands.get(band)?;
|
||||
let file = entry.file.clone();
|
||||
self.clip(assets, &file)
|
||||
}
|
||||
|
||||
pub(crate) fn wheels(&mut self, assets: &mut Assets<AudioSource>, name: &str) -> Option<Clip> {
|
||||
let entry = self.manifest.wheels.get(name)?;
|
||||
let file = entry.file.clone();
|
||||
self.clip(assets, &file)
|
||||
}
|
||||
|
||||
pub(crate) fn sample(&mut self, assets: &mut Assets<AudioSource>, bank: &str, index: usize) -> Option<Clip> {
|
||||
let entry = self.manifest.banks.get(bank)?.get(index)?;
|
||||
let file = entry.file.clone();
|
||||
self.clip(assets, &file)
|
||||
}
|
||||
|
||||
/// Read every grain band, wheel spin and listed bank sample now, so
|
||||
/// gameplay never waits on the disk (~20 MB). Returns the clip count.
|
||||
pub(crate) fn preload(&mut self, assets: &mut Assets<AudioSource>, samples: &[(&str, &[usize])]) -> usize {
|
||||
let mut files: Vec<String> = self.manifest.grains.values().flat_map(|g| &g.bands)
|
||||
.chain(self.manifest.wheels.values()).map(|e| e.file.clone()).collect();
|
||||
for (bank, indices) in samples {
|
||||
if let Some(entries) = self.manifest.banks.get(*bank) {
|
||||
files.extend(indices.iter().filter_map(|&i| entries.get(i)).map(|e| e.file.clone()));
|
||||
}
|
||||
}
|
||||
files.iter().filter(|file| self.clip(assets, file).is_some()).count()
|
||||
}
|
||||
|
||||
/// A bank's retail patch tree (SPLC banks only).
|
||||
pub(crate) fn patches(&self, bank: &str) -> Option<&Patches> {
|
||||
self.manifest.patches.get(bank)
|
||||
}
|
||||
|
||||
/// Every sample patch `id` of `bank` can play (all records of a container,
|
||||
/// every layer member), for preloading.
|
||||
pub(crate) fn patch_samples(&self, bank: &str, id: usize) -> Vec<usize> {
|
||||
let Some(patches) = self.patches(bank) else { return Vec::new() };
|
||||
let records = patches.records.len();
|
||||
let ids = if id < records { vec![id] } else { patches.containers.get(id - records).cloned().unwrap_or_default() };
|
||||
let mut samples: Vec<usize> = ids.iter().filter_map(|&r| patches.records.get(r)).flatten()
|
||||
.flat_map(|group| group.members.iter().map(|member| member.0)).collect();
|
||||
samples.sort_unstable();
|
||||
samples.dedup();
|
||||
samples
|
||||
}
|
||||
|
||||
/// Drop a clip's PCM once nothing plays it (rodio keeps its own copy while playing).
|
||||
pub(crate) fn release(&mut self, assets: &mut Assets<AudioSource>, clip: &Clip) {
|
||||
if self.loaded.remove(&*clip.key).is_some() {
|
||||
assets.remove(clip.handle.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wav_peak_reads_the_data_chunk() {
|
||||
let mut wav = b"RIFF | ||||