mirage-engine 0.1.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::thread;

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};

use crate::sound::data::SampleRate;
use crate::sound::mixer::Command;
use crate::sound::output::MixRate;
use crate::sound::voices::{MixFrame, Mixer};

/// Frames the decoding thread mixes at a time.
const BLOCK: usize = 256;

/// Mixed blocks that may wait to be played, which is how far the mix
/// runs ahead of what is heard.
const BLOCKS_AHEAD: usize = 4;

/// Commands that may wait for the mix; a frame submits few of them.
const MAX_WAITING_COMMANDS: usize = 256;

/// The device's stream and the thread that feeds it.
///
/// The mix is made on a thread of its own because dragging a window
/// stalls the event loop, and audio may not stall with it.
pub(crate) struct Backend {
    commands: SyncSender<Command>,
    rate: MixRate,
    /// Dropping the stream stops the device and, with it, the thread.
    _stream: cpal::Stream,
}

impl Backend {
    /// The device's own rate, which the mix runs at.
    pub(crate) fn rate(&self) -> Option<MixRate> {
        Some(self.rate)
    }

    pub(crate) fn frame(&mut self, commands: impl Iterator<Item = Command>) {
        for command in commands {
            if self.commands.try_send(command).is_err() {
                log::debug!("the mix is not keeping up; ignoring a sound");
            }
        }
    }

    /// The desktop needs no gesture before it plays.
    pub(crate) fn unlocked(&self) -> bool {
        true
    }

    /// The desktop plays already, so this does nothing.
    pub(crate) fn unlock(&mut self) {}
}

pub(crate) fn open() -> Option<Backend> {
    let Some(device) = cpal::default_host().default_output_device() else {
        log::debug!("this machine has no audio device; playing nothing");
        return None;
    };
    let supported = device
        .default_output_config()
        .inspect_err(|error| log::debug!("the audio device offered no format: {error}"))
        .ok()?;

    let (commands, taken) = sync_channel(MAX_WAITING_COMMANDS);
    let (mixed, played) = sync_channel(BLOCKS_AHEAD);
    let (spent, reused) = sync_channel(BLOCKS_AHEAD);
    let rate = MixRate(SampleRate::new(supported.sample_rate()));
    let width = usize::from(supported.channels());

    let stream = match supported.sample_format() {
        cpal::SampleFormat::I16 => stream::<i16>(&device, &supported, width, played, spent),
        cpal::SampleFormat::U16 => stream::<u16>(&device, &supported, width, played, spent),
        _ => stream::<f32>(&device, &supported, width, played, spent),
    };
    let stream = stream
        .inspect_err(|error| log::debug!("the audio device refused a stream: {error}"))
        .ok()?;
    stream
        .play()
        .inspect_err(|error| log::debug!("the audio device would not start: {error}"))
        .ok()?;

    thread::spawn(move || mix(rate, taken, mixed, reused));
    Some(Backend {
        commands,
        rate,
        _stream: stream,
    })
}

/// Mixes block after block until the device stops taking them.
///
/// Blocking is what paces this thread: the mix runs exactly as far
/// ahead as the device allows.
fn mix(
    rate: MixRate,
    commands: Receiver<Command>,
    mixed: SyncSender<Vec<MixFrame>>,
    reused: Receiver<Vec<MixFrame>>,
) {
    let mut mixer = Mixer::new(rate);
    loop {
        for command in commands.try_iter() {
            mixer.apply(command);
        }

        let mut block = reused
            .try_recv()
            .unwrap_or_else(|_| vec![MixFrame::SILENT; BLOCK]);
        block.resize(BLOCK, MixFrame::SILENT);
        mixer.mix(&mut block);
        if mixed.send(block).is_err() {
            return;
        }
    }
}

/// A stream that reads mixed blocks and writes them to the device in
/// whatever format it takes.
fn stream<T: SizedSample + FromSample<f32>>(
    device: &cpal::Device,
    supported: &cpal::SupportedStreamConfig,
    width: usize,
    mixed: Receiver<Vec<MixFrame>>,
    spent: SyncSender<Vec<MixFrame>>,
) -> Result<cpal::Stream, cpal::Error> {
    let mut block: Vec<MixFrame> = Vec::new();
    let mut taken = 0;

    device.build_output_stream(
        supported.config(),
        move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
            for frame in data.chunks_mut(width) {
                if taken == block.len() {
                    taken = 0;
                    if !block.is_empty() {
                        let _ = spent.try_send(core::mem::take(&mut block));
                    }
                    match mixed.try_recv() {
                        Ok(next) => block = next,
                        // The mix has not caught up; the device's thread
                        // must never block, so the frame is silence.
                        Err(_) => {
                            silence(frame);
                            continue;
                        }
                    }
                }

                let Some(&heard) = block.get(taken) else {
                    silence(frame);
                    continue;
                };
                taken += 1;
                for (channel, sample) in frame.iter_mut().enumerate() {
                    *sample = T::from_sample(heard.ear(channel));
                }
            }
        },
        |error| log::debug!("the audio device stopped: {error}"),
        None,
    )
}

fn silence<T: SizedSample + FromSample<f32>>(frame: &mut [T]) {
    frame.fill(T::from_sample(0.0));
}