game-toolkit-dosaudio 0.1.6

Emulated DOS-era sound hardware for Rust games: PC speaker, AdLib/OPL3, Sound Blaster 1 to 16, MPU-401 with General MIDI and MT-32, and Tandy PSG on one shared hardware clock
Documentation
use std::sync::{
    Arc,
    atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
};

use crate::{Error, Result};

const MAX_RATE: u32 = 192_000;
const UNITY: u32 = 65_536;

struct VoiceState {
    stopped: AtomicBool,
    finished: AtomicBool,
    volume: AtomicU32,
    position: AtomicUsize,
    handles: AtomicUsize,
}

/// Cloneable controls for one software-mixed unsigned mono sample.
///
/// Dropping the last handle requests a stop. Controls take effect at the next
/// output sample generated by [`SfxMixer::render`]. Completion and progress
/// describe the generated refill buffer, ahead of its eventual DMA playback.
pub struct SfxVoiceHandle {
    state: Arc<VoiceState>,
}

impl SfxVoiceHandle {
    /// Stop this voice at the next rendered output sample.
    pub fn stop(&self) {
        self.state.stopped.store(true, Ordering::Release);
    }

    /// Set gain between zero and one, quantized to 1/65536 increments.
    /// Non-finite values mute the voice; finite values are clamped.
    pub fn set_volume(&self, volume: f32) {
        let volume = if volume.is_finite() {
            volume.clamp(0.0, 1.0)
        } else {
            0.0
        };
        self.state
            .volume
            .store((volume * UNITY as f32).round() as u32, Ordering::Release);
    }

    /// Whether the source ended, was stolen, its mixer was dropped, or a stop
    /// was requested. Stopping does not retract already generated samples.
    pub fn is_finished(&self) -> bool {
        self.state.finished.load(Ordering::Acquire) || self.state.stopped.load(Ordering::Acquire)
    }

    /// Source samples whose time intervals have elapsed, capped at source length.
    /// Downsampling includes skipped source samples; upsampling can emit several
    /// output samples before this number increases.
    pub fn samples_played(&self) -> usize {
        self.state.position.load(Ordering::Acquire)
    }
}

impl Clone for SfxVoiceHandle {
    fn clone(&self) -> Self {
        self.state.handles.fetch_add(1, Ordering::Relaxed);
        Self {
            state: Arc::clone(&self.state),
        }
    }
}

impl Drop for SfxVoiceHandle {
    fn drop(&mut self) {
        if self.state.handles.fetch_sub(1, Ordering::AcqRel) == 1 {
            self.stop();
        }
    }
}

struct Voice {
    samples: Arc<[u8]>,
    state: Arc<VoiceState>,
    source_rate: u32,
    position: usize,
    phase: u32,
    finished: bool,
}

impl Voice {
    fn inactive(&self) -> bool {
        self.finished || self.state.stopped.load(Ordering::Acquire)
    }

    fn next(&mut self, output_rate: u32) -> i64 {
        if self.inactive() {
            self.finished = true;
            self.state.finished.store(true, Ordering::Release);
            return 0;
        }
        let signed = i64::from(self.samples[self.position]) - 128;
        let value = signed * i64::from(self.state.volume.load(Ordering::Acquire));
        // Both rates are at most MAX_RATE, so the sum fits easily in u32.
        self.phase += self.source_rate;
        self.position = self
            .position
            .saturating_add((self.phase / output_rate) as usize)
            .min(self.samples.len());
        self.phase %= output_rate;
        self.state.position.store(self.position, Ordering::Release);
        if self.position == self.samples.len() {
            self.finished = true;
            self.state.finished.store(true, Ordering::Release);
        }
        value
    }
}

/// A fixed-capacity unsigned 8-bit mono mixer for DMA refill buffers.
///
/// Each voice uses zero-order hold resampling with an exact integer phase ratio
/// of source rate to output rate. Rates need not divide each other, and rendering
/// in different chunk sizes gives identical output. This resampler intentionally
/// does not filter aliases when downsampling.
///
/// Signed amplitudes (`byte - 128`) are summed with Q16 volume, truncated toward
/// zero once after mixing, clipped to -128 through 127, then biased back to u8.
/// With no active voices, output is 128. New voices start at full volume.
///
/// Construction allocates the voice slots. Triggering may allocate or release
/// resources and belongs on a control/refill thread. Rendering never allocates
/// or frees: finished voices retain their buffers until a later trigger replaces
/// them or the mixer is dropped. A full mixer steals the oldest active voice.
pub struct SfxMixer {
    voices: Vec<Option<Voice>>,
    sample_rate: u32,
}

impl SfxMixer {
    /// Reserve 1 through 1024 voices and an output rate of 1 through 192000 Hz.
    pub fn new(voice_limit: usize, sample_rate: u32) -> Result<Self> {
        if !(1..=1024).contains(&voice_limit) {
            return Err(Error::Config("SFX voice limit must be between 1 and 1024"));
        }
        Self::validate_rate(sample_rate)?;
        Ok(Self {
            voices: std::iter::repeat_with(|| None).take(voice_limit).collect(),
            sample_rate,
        })
    }

    fn validate_rate(rate: u32) -> Result<()> {
        if !(1..=MAX_RATE).contains(&rate) {
            return Err(Error::Config(
                "SFX sample rate must be between 1 and 192000 Hz",
            ));
        }
        Ok(())
    }

    /// Start a nonempty unsigned mono sample, reusing an inactive slot or stealing
    /// the oldest active voice. Invalid inputs leave existing voices untouched.
    /// The returned handle must be retained for the voice to keep playing.
    pub fn trigger(
        &mut self,
        samples: impl Into<Arc<[u8]>>,
        source_rate: u32,
    ) -> Result<SfxVoiceHandle> {
        Self::validate_rate(source_rate)?;
        let samples = samples.into();
        if samples.is_empty() {
            return Err(Error::Config("SFX sample must not be empty"));
        }
        let state = Arc::new(VoiceState {
            stopped: AtomicBool::new(false),
            finished: AtomicBool::new(false),
            volume: AtomicU32::new(UNITY),
            position: AtomicUsize::new(0),
            handles: AtomicUsize::new(1),
        });
        let voice = Voice {
            samples,
            state: Arc::clone(&state),
            source_rate,
            position: 0,
            phase: 0,
            finished: false,
        };
        let index = self
            .voices
            .iter()
            .position(|slot| slot.as_ref().is_none_or(Voice::inactive))
            .unwrap_or(0);
        if let Some(previous) = self.voices[index].take() {
            previous.state.finished.store(true, Ordering::Release);
            // Buffer/control destruction happens here, never during render.
        }
        // Slots remain ordered from oldest to newest without an age counter
        // that can overflow. Rotation only moves the already allocated slots.
        self.voices[index..].rotate_left(1);
        *self
            .voices
            .last_mut()
            .expect("validated nonzero voice limit") = Some(voice);
        Ok(SfxVoiceHandle { state })
    }

    /// Generate unsigned mono output without allocating or releasing resources.
    pub fn render(&mut self, out: &mut [u8]) {
        for sample in out {
            let mut sum = 0_i64;
            for voice in self.voices.iter_mut().flatten() {
                sum += voice.next(self.sample_rate);
            }
            *sample = ((sum / i64::from(UNITY)).clamp(-128, 127) + 128) as u8;
        }
    }

    /// Output rate used for every generated DMA refill buffer.
    pub fn sample_rate(&self) -> u32 {
        self.sample_rate
    }

    /// Voices still playing, excluding sources with pending stop requests.
    pub fn active_voices(&self) -> usize {
        self.voices
            .iter()
            .flatten()
            .filter(|voice| !voice.inactive())
            .count()
    }
}

impl Drop for SfxMixer {
    fn drop(&mut self) {
        for voice in self.voices.iter().flatten() {
            voice.state.finished.store(true, Ordering::Release);
        }
    }
}