discrec 0.1.1

Records Discord's audio. Press record.
//! Capture backends — the only platform-specific code in DiscRec.
//!
//! One trait, two implementations, selected at compile time. Everything
//! downstream (mixing, encoding, writing, UI) is shared.
//!
//! See `docs/spec/capture-interface.md` for the contract a backend must honor.

#[cfg(windows)]
pub mod windows;

#[cfg(target_os = "macos")]
pub mod macos;

#[cfg(not(any(windows, target_os = "macos")))]
compile_error!("DiscRec targets Windows and macOS only. See docs/02-constraints.md");

/// Which stream a frame came from.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Source {
    DiscordOutput,
    Microphone,
}

/// A block of audio, positioned by its own stream's clock.
///
/// Deliberately carries no arrival timestamp. Position must come from the
/// device's sample counter, never from when the callback fired — the two
/// streams have independent hardware clocks and drift apart.
/// See `docs/spec/mixing-and-timeline.md`.
#[derive(Clone, Debug)]
pub struct Frame {
    pub source: Source,
    pub sample_pos: u64,
    /// This stream's native channel count. The session converts to the mixer's
    /// format; the mixer itself never sees a mismatch.
    pub channels: u16,
    /// This stream's native sample rate, Hz.
    pub sample_rate: u32,
    pub samples: Vec<f32>,
}

#[derive(Copy, Clone, Debug)]
pub struct StreamFormat {
    pub sample_rate: u32,
    pub channels: u16,
}

#[derive(Debug)]
pub enum CaptureError {
    /// OS predates per-process audio capture.
    UnsupportedOs {
        needs: &'static str,
    },
    /// macOS TCC, or equivalent, refused.
    PermissionDenied,
    /// Stream opened but delivered digital silence. Never write this as a
    /// recording — see `docs/05-challenges.md`, P2.
    NoSignal,
    /// Discord is not running, or has no audio session.
    DiscordNotFound,
    Platform(String),
}

impl std::fmt::Display for CaptureError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CaptureError::UnsupportedOs { needs } => write!(f, "This needs {needs} or later."),
            CaptureError::PermissionDenied => {
                write!(f, "DiscRec needs permission to record audio.")
            }
            CaptureError::NoSignal => write!(f, "Started, but no audio is coming through."),
            CaptureError::DiscordNotFound => write!(f, "Start Discord first."),
            CaptureError::Platform(msg) => write!(f, "{msg}"),
        }
    }
}

impl std::error::Error for CaptureError {}

/// Where a backend delivers audio.
///
/// A channel rather than a closure: Phase 2 runs the Discord loopback and the
/// microphone on separate threads, each with its own device clock, and the
/// mixer reconciles them downstream. A shared `FnMut` would need a mutex on
/// the audio path for no benefit.
pub type FrameSink = std::sync::mpsc::Sender<Frame>;

pub trait CaptureBackend: Send {
    /// Attach to Discord and the default input, and begin delivering frames.
    ///
    /// Returns once both streams are running. Must fail rather than start a
    /// stream that carries nothing — a silent recording discovered a week
    /// later is the worst outcome this project has.
    fn start(&mut self, discord_pid: u32, sink: FrameSink) -> Result<(), CaptureError>;

    /// Stop both streams and join their threads.
    fn stop(&mut self) -> Result<(), CaptureError>;

    /// Format of the Discord stream, which is the timeline master. The
    /// microphone is resampled to match it.
    fn format(&self) -> StreamFormat;
}

#[cfg(windows)]
pub fn backend() -> Box<dyn CaptureBackend> {
    Box::new(windows::WasapiBackend::new())
}

#[cfg(target_os = "macos")]
pub fn backend() -> Box<dyn CaptureBackend> {
    Box::new(macos::CoreAudioBackend::new())
}