audiofox 1.0.0

Cross platform audio capture made easy!
// 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::builder::ChunkBuilder;
use crate::common::callback::AudioCallback;
use crate::common::chunk::Chunk;
use crate::common::result::{Error, Result};
use crate::common::source::AudioSource;
use crate::platform::platform::link::{LinkState, TIMEOUT_MS};
use crate::platform::windows::link::PACKET_FRAMES;
use crate::platform::windows::manager::Command;

/// 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,
) {
    let mut links = Vec::with_capacity(sources.len());
    let mut mixdown = ChunkBuilder::<f32, PACKET_FRAMES>::new();
    let mut builder = ChunkBuilder::new_f32();

    // Link to each source
    for source in sources {
        match LinkState::link(source)
            .with_context(|| format!("Failed to link to source id={}, name={}", source.id, source.name))
        {
            Ok(link) => links.push(link),
            Err(e) => {
                send_logged!(sender, Response::Error(e));
                return;
            }
        }
    }

    // Send the ready response
    send_logged!(sender, Response::Ready);
    debug!("using source id={}, name={} as clock", sources[0].id, sources[0].name);

    match || -> Result<()> {
        loop {
            // Wait for the next packet, with the first link as the clock
            links[0].event_handle.wait_for_event(TIMEOUT_MS)?;

            // Check if the link handle has been dropped
            if matches!(receiver.try_recv(), Ok(Command::Unlink)) {
                break Ok(());
            }

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

            // Mixdown the samples
            stereo_mixdown(&links, &mut mixdown);

            // Bulk-copy the samples over to the ChunkBuilder
            if let Some(data) = builder.extend_from_slice(mixdown.data.as_slice()) {
                // If data is returned, we're at capacity
                callback(Chunk::from(&builder));
                // Reset the ChunkBuilder with the overflow
                builder.reset_with_overflow(data);
            }
        }
    }() {
        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.
fn stereo_mixdown(chunks: &[LinkState], mixdown: &mut ChunkBuilder<f32, PACKET_FRAMES>) {
    // Sum samples from all chunks
    for (i, chunk) in chunks.iter().enumerate() {
        // SAFETY: This is safe because the data is guaranteed to be F32LE.
        let samples = unsafe {
            slice::from_raw_parts(chunk.buffer.data.as_ptr().cast::<f32>(), PACKET_FRAMES)
        };
        // If this is the first chunk, write to mixdown
        if i == 0 {
            mixdown.data.copy_from_slice(samples);
        }
        // If this is the last chunk, apply soft limiter
        else if i == chunks.len() - 1 {
            for (j, sample) in samples.iter().enumerate() {
                let sum = mixdown.data[j] + sample;
                mixdown.data[j] = sum.tanh();
            }
        }
        // Otherwise, add to mixdown
        else {
            for (j, sample) in samples.iter().enumerate() {
                mixdown.data[j] += sample;
            }
        }
    }
}