audiofox 1.1.0

Cross platform audio capture made easy!
Documentation
// SPDX-License-Identifier: GPL-2.0-or-later WITH Classpath-exception-2.0
// Audiofox, the audio capturer!
// Copyright (c) 2026 foxberries

use std::slice;
use std::sync::mpsc;

use anyhow::Context;
use log::{debug, error, info};

use crate::common::callback::AudioCallback;
use crate::common::chunk::Chunk;
use crate::common::result::{Error, Result};
use crate::common::source::AudioSource;
use crate::common::stream::{CHANNELS, SAMPLE_RATE, latency};
use crate::platform::platform::link::LinkState;
use crate::platform::windows::link::BLOCKALIGN;
use crate::platform::windows::manager::Command;

/// Maximum time to wait for a new packet before ending the stream.
const MAX_TIME_MS: u32 = 1000;

/// Valid responses from the Stream Manager.
#[derive(Debug)]
pub enum Response {
    Ready,
    Error(Error),
}

/// Start a stream with one or more links.
pub fn start_stream(
    sender: &mpsc::Sender<Response>,
    receiver: &mpsc::Receiver<Command>,
    sources: &[AudioSource],
    callback: &AudioCallback,
    frames: usize,
) {
    if let Some(mut clock) = link(sender, &sources[0], frames) {
        if sources.len() == 1 {
            process(sender, receiver, &sources[0], clock.buffer.len(), || {
                // Wait for the next packet
                clock.handle.wait_for_event(MAX_TIME_MS)?;

                // Read the buffer
                clock.capture_client
                    .read_from_device(&mut clock.buffer)
                    .context("Failed to read audio data")?;

                // Emit the Chunk
                callback(Chunk::from(clock.buffer.as_ref()));

                Ok(())
            });
        } else {
            // Link to remaining sources
            let mut links = Vec::with_capacity(sources.len());
            links.push(clock);

            for source in &sources[1..] {
                if let Some(link) = link(sender, source, frames) {
                    links.push(link);
                } else {
                    return;
                }
            }

            // Allocate the mixdown buffer
            let mut mixdown = vec![0f32; links[0].buffer.len() / size_of::<f32>()].into_boxed_slice();
            process(sender, receiver, &sources[0], links[0].buffer.len(), || {
                // Wait for the next packet
                links[0].handle.wait_for_event(MAX_TIME_MS)?;

                // Read each buffer
                for link in &mut links {
                    link.capture_client
                        .read_from_device(&mut link.buffer)
                        .context("Failed to read audio data")?;
                }

                // Mixdown and emit the Chunk
                stereo_mixdown(&links, &mut mixdown);
                callback(Chunk::from(mixdown.as_ref()));

                Ok(())
            });
        }
    }
}

/// Link to an [`AudioSource`], propagating an [`Error`] to the manager on failure.
fn link(
    sender: &mpsc::Sender<Response>,
    source: &AudioSource,
    frames: usize,
) -> Option<LinkState> {
    match LinkState::link(source, frames).with_context(|| {
        format!("Failed to link to source id={}, name={}", source.id, source.name)
    }) {
        Ok(link) => Some(link),
        Err(e) => {
            send_logged!(sender, Response::Error(e));
            None
        }
    }
}

/// Uniform stream logic for single and multi-source streams.
#[inline]
fn process<F>(
    sender: &mpsc::Sender<Response>,
    receiver: &mpsc::Receiver<Command>,
    clock: &AudioSource,
    buffer_size: usize,
    mut process: F,
) where
    F: FnMut() -> Result<()>,
{
    // Send the Ready response
    send_logged!(sender, Response::Ready);
    
    debug!("using source id={}, name={} as clock", clock.id, clock.name);
    info!(
        "capturing at rate={}, channels={}, latency={:.2}ms",
        SAMPLE_RATE, CHANNELS, latency(buffer_size / BLOCKALIGN)
    );

    // Run the process loop
    match || -> Result<()> {
        loop {
            // Check if the link handle has been dropped
            if matches!(receiver.try_recv(), Ok(Command::Unlink)) {
                break Ok(());
            }

            process()?;
        }
    }() {
        Ok(()) => info!("stream stopped"),
        // If any one link encounters an error, assume that all other links would fail, and stop
        // the stream with an error. This does not occur if the target app is closed or killed.
        // See below for errors that can induce a stream failure:
        // https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudiorenderclient-releasebuffer#return-value
        Err(e) => error!("stream stopped due to error: {e}"),
    }
}

/// Mixdown any number of audio buffers into stereo.
#[inline]
fn stereo_mixdown(links: &[LinkState], mixdown: &mut [f32]) {
    for (i, link) in links.iter().enumerate() {
        // SAFETY: This is safe because the data is guaranteed to be F32LE.
        let samples = unsafe {
            slice::from_raw_parts(
                link.buffer.as_ptr().cast::<f32>(),
                link.buffer.len() / size_of::<f32>(),
            )
        };

        // If this is the first link, write to mixdown
        if i == 0 {
            mixdown.copy_from_slice(samples);
        }

        // If this is the last link, apply soft limiter
        else if i == links.len() - 1 {
            for (j, sample) in samples.iter().enumerate() {
                let sum = mixdown[j] + sample;
                mixdown[j] = sum.tanh();
            }
        }

        // Otherwise, add to mixdown
        else {
            for (j, sample) in samples.iter().enumerate() {
                mixdown[j] += sample;
            }
        }
    }
}