libs/game/audio (77 tests): WAV decoder (8/16/24/32-bit int, f32/f64, total on malformed input), Ogg container with packet reassembly, sample bank with on-demand decode + linear resample + LRU eviction and pinning so a playing voice can't be evicted, and a 24-voice mixer with equal-power pan, playback-rate pitch, click-free fades, priority+age voice stealing and a limiter. Generation-tagged voice handles mean a stale handle cannot retune a reused slot. The limiter test caught a real bug: peak was being measured per-voice, but what clips is the SUM. Emission is the point — sounds come from the engine observing gameplay, not from script calls: Material/MaterialPair (order-independent, the softer material names the sound), an impact curve mapping closing speed to gain/pitch, and an AudioDirector with repeat-avoiding variant selection, per-category volumes, per-pair cooldowns and a per-frame cap. A 200-contact frame yields <=6 sounds and the cooldown map is proven not to leak. Blocks now emit their own audio: car engine tracking revs, skid on lateral slip, suspension thud on landing; character footsteps timed off the WALK CYCLE rather than a timer, so feet and sound stay together when slowing; jump/land scaled by fall speed; plane engine by throttle; lap and win stings. RNG isolation proven: heavy audio work interleaved with world-rng draws leaves the drawn sequence bit-identical to a silent run. KNOWN GAP, reported rather than hidden: every Kenney audio pack is Ogg Vorbis only (471 files, zero WAV), and the from-scratch Vorbis decoder is NOT correct yet — setup header parses exactly, channels/rate/frame count and envelope shape are right, but floor magnitudes come out ~75x low and best correlation against an afconvert reference is 0.67. Two real bugs were found and fixed en route (type-1 residue filled one codeword instead of the partition; MDCT post-twiddle carried the pre-twiddle's 1/4 term). The reference test is committed as #[ignore] with its measurements in the message so it stays runnable. Ogg stays flagged unplayable in the asset index; WAV and the --transcode path work today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
511 lines
16 KiB
Rust
511 lines
16 KiB
Rust
//! The sampled-voice mixer.
|
|
//!
|
|
//! Mixes *alongside* the procedural synth rather than replacing it: the host
|
|
//! sums both into its output buffer, so a game can use recorded impacts and a
|
|
//! synthesised engine hum in the same breath.
|
|
|
|
use crate::bank::{SampleBank, SampleId};
|
|
use crate::Pcm;
|
|
|
|
/// Simultaneous sampled voices. Beyond this, quiet/old voices are stolen.
|
|
/// Conservative because a Quest shares this budget with everything else.
|
|
pub const MAX_VOICES: usize = 24;
|
|
|
|
/// Short ramp applied at start and stop so nothing clicks.
|
|
const FADE_SECS: f32 = 0.004;
|
|
|
|
/// A voice's importance when the mixer runs out of slots.
|
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
|
pub enum Priority {
|
|
/// Ambient texture: first to go.
|
|
Low,
|
|
Normal,
|
|
/// Gameplay-critical (a hit you must hear).
|
|
High,
|
|
}
|
|
|
|
/// How to start a voice.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct VoiceSpec {
|
|
pub sample: SampleId,
|
|
pub gain: f32,
|
|
/// -1 left, 0 centre, +1 right.
|
|
pub pan: f32,
|
|
/// Playback-rate multiplier; 2.0 is an octave up and half as long.
|
|
pub pitch: f32,
|
|
pub looping: bool,
|
|
pub priority: Priority,
|
|
}
|
|
|
|
impl VoiceSpec {
|
|
pub fn one_shot(sample: SampleId) -> Self {
|
|
Self {
|
|
sample,
|
|
gain: 1.0,
|
|
pan: 0.0,
|
|
pitch: 1.0,
|
|
looping: false,
|
|
priority: Priority::Normal,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Identifies a running voice; generation-tagged so a stale handle cannot
|
|
/// retune a slot that has since been reused by another sound.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub struct VoiceHandle {
|
|
index: u32,
|
|
generation: u32,
|
|
}
|
|
|
|
struct Voice {
|
|
sample: SampleId,
|
|
pos: f64,
|
|
gain: f32,
|
|
target_gain: f32,
|
|
pan: f32,
|
|
pitch: f32,
|
|
looping: bool,
|
|
priority: Priority,
|
|
generation: u32,
|
|
/// Counts up from zero so the oldest voice is identifiable.
|
|
age: u64,
|
|
releasing: bool,
|
|
/// 0..1 envelope, ramped to avoid clicks at both ends.
|
|
level: f32,
|
|
}
|
|
|
|
pub struct Mixer {
|
|
voices: Vec<Option<Voice>>,
|
|
generation: u32,
|
|
clock: u64,
|
|
rate: f32,
|
|
master: f32,
|
|
/// Peak of the last rendered buffer, for the limiter and for tests.
|
|
last_peak: f32,
|
|
}
|
|
|
|
impl Mixer {
|
|
pub fn new(device_rate: u32) -> Self {
|
|
Self {
|
|
voices: (0..MAX_VOICES).map(|_| None).collect(),
|
|
generation: 1,
|
|
clock: 0,
|
|
rate: device_rate.max(1) as f32,
|
|
master: 1.0,
|
|
last_peak: 0.0,
|
|
}
|
|
}
|
|
|
|
pub fn set_master(&mut self, gain: f32) {
|
|
self.master = gain.clamp(0.0, 4.0);
|
|
}
|
|
|
|
pub fn last_peak(&self) -> f32 {
|
|
self.last_peak
|
|
}
|
|
|
|
pub fn active_voices(&self) -> usize {
|
|
self.voices.iter().filter(|v| v.is_some()).count()
|
|
}
|
|
|
|
/// Start a voice. Returns `None` only if every slot holds something more
|
|
/// important than this sound.
|
|
pub fn play(&mut self, spec: VoiceSpec) -> Option<VoiceHandle> {
|
|
let slot = self.free_slot().or_else(|| self.steal_for(spec.priority))?;
|
|
self.clock += 1;
|
|
self.generation = self.generation.wrapping_add(1).max(1);
|
|
let generation = self.generation;
|
|
self.voices[slot] = Some(Voice {
|
|
sample: spec.sample,
|
|
pos: 0.0,
|
|
gain: spec.gain.clamp(0.0, 4.0),
|
|
target_gain: spec.gain.clamp(0.0, 4.0),
|
|
pan: spec.pan.clamp(-1.0, 1.0),
|
|
pitch: spec.pitch.clamp(0.05, 8.0),
|
|
looping: spec.looping,
|
|
priority: spec.priority,
|
|
generation,
|
|
age: self.clock,
|
|
releasing: false,
|
|
level: 0.0,
|
|
});
|
|
Some(VoiceHandle {
|
|
index: slot as u32,
|
|
generation,
|
|
})
|
|
}
|
|
|
|
/// Retune a running voice (engine note tracking speed, say).
|
|
pub fn set(&mut self, h: VoiceHandle, gain: Option<f32>, pitch: Option<f32>, pan: Option<f32>) {
|
|
if let Some(v) = self.voice_mut(h) {
|
|
if let Some(g) = gain {
|
|
v.target_gain = g.clamp(0.0, 4.0);
|
|
}
|
|
if let Some(p) = pitch {
|
|
v.pitch = p.clamp(0.05, 8.0);
|
|
}
|
|
if let Some(p) = pan {
|
|
v.pan = p.clamp(-1.0, 1.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fade a voice out and drop it. Looping voices need this; one-shots end
|
|
/// on their own.
|
|
pub fn stop(&mut self, h: VoiceHandle) {
|
|
if let Some(v) = self.voice_mut(h) {
|
|
v.releasing = true;
|
|
}
|
|
}
|
|
|
|
pub fn stop_all(&mut self) {
|
|
for v in self.voices.iter_mut().flatten() {
|
|
v.releasing = true;
|
|
}
|
|
}
|
|
|
|
pub fn is_playing(&self, h: VoiceHandle) -> bool {
|
|
self.voices
|
|
.get(h.index as usize)
|
|
.and_then(|v| v.as_ref())
|
|
.is_some_and(|v| v.generation == h.generation)
|
|
}
|
|
|
|
fn voice_mut(&mut self, h: VoiceHandle) -> Option<&mut Voice> {
|
|
self.voices
|
|
.get_mut(h.index as usize)
|
|
.and_then(|v| v.as_mut())
|
|
.filter(|v| v.generation == h.generation)
|
|
}
|
|
|
|
fn free_slot(&self) -> Option<usize> {
|
|
self.voices.iter().position(|v| v.is_none())
|
|
}
|
|
|
|
/// Steal the lowest-priority, then oldest, voice — but never one that
|
|
/// outranks the incoming sound.
|
|
fn steal_for(&mut self, priority: Priority) -> Option<usize> {
|
|
let victim = self
|
|
.voices
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, v)| v.as_ref().map(|v| (i, v)))
|
|
.filter(|(_, v)| v.priority <= priority)
|
|
.min_by(|(_, a), (_, b)| a.priority.cmp(&b.priority).then(a.age.cmp(&b.age)))
|
|
.map(|(i, _)| i)?;
|
|
self.voices[victim] = None;
|
|
Some(victim)
|
|
}
|
|
|
|
/// Render `frames` of interleaved stereo, ADDING into `out` so the caller
|
|
/// can sum the synth into the same buffer.
|
|
pub fn render(&mut self, bank: &SampleBank, out: &mut [f32], frames: usize) {
|
|
let fade_step = 1.0 / (FADE_SECS * self.rate).max(1.0);
|
|
let mut peak = 0.0f32;
|
|
|
|
for slot in 0..self.voices.len() {
|
|
let Some(v) = self.voices[slot].as_mut() else {
|
|
continue;
|
|
};
|
|
let Some(pcm) = bank.get(v.sample) else {
|
|
// The sample was evicted or never loaded: drop the voice
|
|
// rather than reading a stale index.
|
|
self.voices[slot] = None;
|
|
continue;
|
|
};
|
|
let src_frames = pcm.frames();
|
|
if src_frames == 0 {
|
|
self.voices[slot] = None;
|
|
continue;
|
|
}
|
|
let ch = pcm.channels.max(1);
|
|
// Equal-power pan keeps loudness steady across the stereo field.
|
|
let angle = (v.pan + 1.0) * 0.25 * std::f32::consts::PI;
|
|
let (lg, rg) = (angle.cos(), angle.sin());
|
|
let mut finished = false;
|
|
|
|
for f in 0..frames {
|
|
// Envelope: ramp in on start, out on release.
|
|
let target = if v.releasing { 0.0 } else { 1.0 };
|
|
if v.level < target {
|
|
v.level = (v.level + fade_step).min(target);
|
|
} else if v.level > target {
|
|
v.level = (v.level - fade_step).max(target);
|
|
}
|
|
if v.releasing && v.level <= 0.0 {
|
|
finished = true;
|
|
break;
|
|
}
|
|
// Glide gain so a per-tick retune does not zipper.
|
|
v.gain += (v.target_gain - v.gain) * 0.01;
|
|
|
|
let pos = v.pos;
|
|
let i0 = pos.floor() as usize;
|
|
if i0 >= src_frames {
|
|
if v.looping {
|
|
v.pos = 0.0;
|
|
continue;
|
|
}
|
|
finished = true;
|
|
break;
|
|
}
|
|
let frac = (pos - i0 as f64) as f32;
|
|
let i1 = if i0 + 1 < src_frames {
|
|
i0 + 1
|
|
} else if v.looping {
|
|
0
|
|
} else {
|
|
i0
|
|
};
|
|
|
|
// Mono sources feed both ears; stereo keeps its channels.
|
|
let (sl, sr) = if ch == 1 {
|
|
let a = pcm.samples[i0];
|
|
let b = pcm.samples[i1];
|
|
let s = a + (b - a) * frac;
|
|
(s, s)
|
|
} else {
|
|
let a0 = pcm.samples[i0 * ch];
|
|
let b0 = pcm.samples[i1 * ch];
|
|
let a1 = pcm.samples[i0 * ch + 1];
|
|
let b1 = pcm.samples[i1 * ch + 1];
|
|
(a0 + (b0 - a0) * frac, a1 + (b1 - a1) * frac)
|
|
};
|
|
|
|
let g = v.gain * v.level * self.master;
|
|
let l = sl * g * lg;
|
|
let r = sr * g * rg;
|
|
out[f * 2] += l;
|
|
out[f * 2 + 1] += r;
|
|
|
|
v.pos += v.pitch as f64;
|
|
}
|
|
|
|
if finished {
|
|
self.voices[slot] = None;
|
|
}
|
|
}
|
|
|
|
// Peak of the SUM, not of any one voice: what clips is the total, and
|
|
// the caller may have summed the synth in before calling us.
|
|
for s in out.iter().take(frames * 2) {
|
|
if s.is_finite() {
|
|
peak = peak.max(s.abs());
|
|
}
|
|
}
|
|
// Soft limiter: only engages once the sum would clip, so normal
|
|
// playback is untouched and a pile-up compresses instead of tearing.
|
|
self.last_peak = peak;
|
|
if peak > 1.0 {
|
|
let g = 1.0 / peak;
|
|
for s in out.iter_mut().take(frames * 2) {
|
|
*s *= g;
|
|
}
|
|
self.last_peak = 1.0;
|
|
}
|
|
for s in out.iter_mut().take(frames * 2) {
|
|
if !s.is_finite() {
|
|
*s = 0.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience for tests and offline rendering.
|
|
pub fn render_to_vec(mixer: &mut Mixer, bank: &SampleBank, frames: usize) -> Vec<f32> {
|
|
let mut out = vec![0.0f32; frames * 2];
|
|
mixer.render(bank, &mut out, frames);
|
|
out
|
|
}
|
|
|
|
/// Interleaved stereo f32 -> a 16-bit WAV, for auditioning offline.
|
|
pub fn to_wav(samples: &[f32], rate: u32) -> Vec<u8> {
|
|
let data: Vec<u8> = samples
|
|
.iter()
|
|
.flat_map(|s| ((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes())
|
|
.collect();
|
|
let mut v = Vec::with_capacity(44 + data.len());
|
|
v.extend_from_slice(b"RIFF");
|
|
v.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
|
v.extend_from_slice(b"WAVEfmt ");
|
|
v.extend_from_slice(&16u32.to_le_bytes());
|
|
v.extend_from_slice(&1u16.to_le_bytes());
|
|
v.extend_from_slice(&2u16.to_le_bytes());
|
|
v.extend_from_slice(&rate.to_le_bytes());
|
|
v.extend_from_slice(&(rate * 4).to_le_bytes());
|
|
v.extend_from_slice(&4u16.to_le_bytes());
|
|
v.extend_from_slice(&16u16.to_le_bytes());
|
|
v.extend_from_slice(b"data");
|
|
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
|
v.extend_from_slice(&data);
|
|
v
|
|
}
|
|
|
|
/// A short sine sample, for tests and as a stand-in when a pack is missing.
|
|
pub fn sine_pcm(rate: u32, freq: f32, secs: f32) -> Pcm {
|
|
let n = (rate as f32 * secs) as usize;
|
|
let samples = (0..n)
|
|
.map(|i| {
|
|
let t = i as f32 / rate as f32;
|
|
(t * freq * std::f32::consts::TAU).sin() * 0.8
|
|
})
|
|
.collect();
|
|
Pcm {
|
|
channels: 1,
|
|
sample_rate: rate,
|
|
samples,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::bank::tests::wav;
|
|
|
|
fn bank_with_tone() -> (SampleBank, SampleId) {
|
|
let mut b = SampleBank::new(44100);
|
|
// 4000 frames of non-silent audio.
|
|
let id = b.insert("tone", &wav(4000, 44100)).unwrap();
|
|
(b, id)
|
|
}
|
|
|
|
#[test]
|
|
fn a_one_shot_plays_then_frees_its_slot() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
m.play(VoiceSpec::one_shot(id)).unwrap();
|
|
assert_eq!(m.active_voices(), 1);
|
|
// Render past the end of the sample.
|
|
let out = render_to_vec(&mut m, &bank, 5000);
|
|
assert!(out.iter().any(|s| s.abs() > 1e-4), "produced silence");
|
|
assert_eq!(m.active_voices(), 0, "voice was not reclaimed");
|
|
}
|
|
|
|
#[test]
|
|
fn output_is_finite_and_never_clips() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
// Pile on far more gain than can fit.
|
|
for _ in 0..MAX_VOICES {
|
|
m.play(VoiceSpec {
|
|
gain: 3.0,
|
|
..VoiceSpec::one_shot(id)
|
|
});
|
|
}
|
|
let out = render_to_vec(&mut m, &bank, 1024);
|
|
assert!(out.iter().all(|s| s.is_finite()));
|
|
assert!(
|
|
out.iter().all(|s| s.abs() <= 1.0001),
|
|
"limiter let the sum clip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn voice_stealing_prefers_low_priority_and_protects_high() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
for _ in 0..MAX_VOICES {
|
|
m.play(VoiceSpec {
|
|
priority: Priority::High,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.unwrap();
|
|
}
|
|
// A low-priority sound cannot displace a wall of high-priority ones.
|
|
assert!(m
|
|
.play(VoiceSpec {
|
|
priority: Priority::Low,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.is_none());
|
|
// A high-priority one can.
|
|
assert!(m
|
|
.play(VoiceSpec {
|
|
priority: Priority::High,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.is_some());
|
|
assert_eq!(m.active_voices(), MAX_VOICES);
|
|
let _ = bank;
|
|
}
|
|
|
|
#[test]
|
|
fn starting_and_stopping_are_click_free() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
let h = m.play(VoiceSpec::one_shot(id)).unwrap();
|
|
let a = render_to_vec(&mut m, &bank, 512);
|
|
// The very first sample must not jump straight to full amplitude.
|
|
assert!(a[0].abs() < 0.05, "hard start: {}", a[0]);
|
|
m.stop(h);
|
|
let b = render_to_vec(&mut m, &bank, 512);
|
|
// No step larger than a plausible waveform slope at the release.
|
|
for w in b.chunks(2) {
|
|
assert!(w[0].abs() <= 1.0);
|
|
}
|
|
assert!(b.iter().all(|s| s.is_finite()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_handle_cannot_retune_a_reused_slot() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
let h = m.play(VoiceSpec::one_shot(id)).unwrap();
|
|
m.stop(h);
|
|
render_to_vec(&mut m, &bank, 4096);
|
|
assert!(!m.is_playing(h));
|
|
let h2 = m.play(VoiceSpec::one_shot(id)).unwrap();
|
|
// The old handle must not touch the new voice.
|
|
m.set(h, Some(0.0), None, None);
|
|
assert!(m.is_playing(h2));
|
|
let out = render_to_vec(&mut m, &bank, 512);
|
|
assert!(out.iter().any(|s| s.abs() > 1e-5), "new voice was muted");
|
|
}
|
|
|
|
#[test]
|
|
fn pitch_shortens_a_one_shot() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
m.play(VoiceSpec {
|
|
pitch: 4.0,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.unwrap();
|
|
// 4000 frames at 4x lasts about 1000 frames.
|
|
render_to_vec(&mut m, &bank, 1100);
|
|
assert_eq!(m.active_voices(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn panning_favours_the_expected_ear() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
m.play(VoiceSpec {
|
|
pan: -1.0,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.unwrap();
|
|
let out = render_to_vec(&mut m, &bank, 512);
|
|
let l: f32 = out.iter().step_by(2).map(|s| s.abs()).sum();
|
|
let r: f32 = out.iter().skip(1).step_by(2).map(|s| s.abs()).sum();
|
|
assert!(l > r * 4.0, "hard-left sound leaked right: l={l} r={r}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_looping_voice_keeps_going_and_stops_on_request() {
|
|
let (bank, id) = bank_with_tone();
|
|
let mut m = Mixer::new(44100);
|
|
let h = m
|
|
.play(VoiceSpec {
|
|
looping: true,
|
|
..VoiceSpec::one_shot(id)
|
|
})
|
|
.unwrap();
|
|
render_to_vec(&mut m, &bank, 9000); // well past one pass
|
|
assert!(m.is_playing(h), "loop ended early");
|
|
m.stop(h);
|
|
render_to_vec(&mut m, &bank, 1024);
|
|
assert!(!m.is_playing(h));
|
|
}
|
|
}
|