side-huddle 0.2.1

Detect meetings locally and capture audio as WAV
Documentation
    //! Detect a Teams / Zoom / Google Meet session and deliver a WAV recording.
    //!
    //! # Quick start
    //! ```no_run
    //! use side_huddle::{MeetingListener, Event};
    //!
    //! let listener = MeetingListener::new();
    //!
    //! listener.on(|event| println!("{event:?}"));
    //!
    //! let l = listener.clone();
    //! listener.on(move |event| {
    //!     if let Event::MeetingDetected { .. } = event { l.record(); }
    //! });
    //!
    //! listener.start().unwrap();
    //! std::thread::park();
    //! ```

    mod apps;
    mod ffi;
    mod mix;
    mod monitor;
    mod platform;
    mod recorder;

    pub use recorder::MeetingListener;

    /// Window + meeting-detection utilities re-exported for examples and external consumers.
    #[cfg(target_os = "macos")]
    pub mod window {
        pub use crate::platform::darwin::window::{
            cg_window_owner, find_primary_window, window_bounds, window_exists,
        };

        /// Returns `(pid, friendly_app_name)` of the first app that currently
        /// has an active microphone session (CoreAudio `IsRunningInput`), or
        /// `(0, "")` if no meeting is detected.  Same detector used internally
        /// by `MeetingListener`.
        pub fn poll_active() -> (u32, String) {
            crate::platform::poll_active()
        }
    }

    // ── Public event type ─────────────────────────────────────────────────────

    /// All events emitted by [`MeetingListener`].
    ///
    /// Register handlers with [`MeetingListener::on`].
    /// Multiple handlers for the same event are all called in registration order.
    ///
    /// Lifecycle order for a recorded meeting:
    /// ```text
    /// PermissionStatus × N  (macOS only, on start)
    /// PermissionsGranted    (macOS only, once all perms OK)
    /// MeetingDetected       (meeting begins)
    /// MeetingUpdated        (title becomes known via window scan)
    /// RecordingStarted      (if record() was called)
    /// MeetingEnded          (meeting stops)
    /// RecordingEnded        (capture stopped, WAV being written)
    /// RecordingReady        (WAV file written to disk)
    /// ```
    #[derive(Debug, Clone)]
    pub enum Event {
        // ── Permissions ───────────────────────────────────────────────────────
        /// Status of an individual permission, emitted once per permission on
        /// [`MeetingListener::start`].  macOS only; not emitted on Windows / Linux
        /// where no permissions are required.
        PermissionStatus {
            permission: Permission,
            status:     PermissionGranted,
        },

        /// All required permissions are granted; recording can proceed.
        /// Emitted immediately on non-macOS platforms.
        PermissionsGranted,

        // ── Meeting lifecycle ─────────────────────────────────────────────────
        /// A Teams / Zoom / Google Meet session was detected (new start, or
        /// already in progress when the listener started).
        MeetingDetected { app: String, pid: u32 },

        /// Meeting metadata became known — currently the window title once the
        /// window watcher identifies the call window.
        MeetingUpdated { app: String, title: String },

        /// The meeting has ended.
        MeetingEnded { app: String },

        // ── Recording lifecycle ───────────────────────────────────────────────
        /// Audio capture has begun.  Fired when [`MeetingListener::record`]
        /// successfully starts the system audio tap.
        RecordingStarted { app: String },

        /// Audio capture has stopped.  The WAV is being written; expect
        /// [`Event::RecordingReady`] shortly after.
        RecordingEnded { app: String },

        /// A completed recording is ready.  Three WAV files are produced:
            /// - `mixed_path`  — tap + mic combined (full meeting audio)
            /// - `others_path` — system-tap only   (what other participants said)
            /// - `self_path`   — microphone only    (what you said)
            ///
            /// Only fired when [`MeetingListener::record`] (or
            /// [`MeetingListener::auto_record`]) was active during the meeting.
            RecordingReady {
                /// Tap + mic mixed — use for single-stream transcription
                mixed_path:  std::path::PathBuf,
                /// System tap only (other participants)
                others_path: std::path::PathBuf,
                /// Microphone only (local user)
                self_path:   std::path::PathBuf,
                app:         String,
            },

        // ── Capture health ────────────────────────────────────────────────────
        /// The audio or video capture stream was interrupted or resumed.
        /// For example, moving the meeting window to an inactive virtual desktop
        /// may interrupt capture.
        CaptureStatus { kind: CaptureKind, capturing: bool },

        // ── Errors ────────────────────────────────────────────────────────────
        /// An error occurred (e.g. the audio tap failed to start).
        Error { message: String },

        // ── Speaker diarization
        /// The set of visually-detected speaking participants changed.
        /// Empty \ means silence / no chromatic ring detected.
        /// macOS only; never emitted on other platforms.
        SpeakerChanged { speakers: Vec<String>, app: String },
    }

    // ── Supporting types ──────────────────────────────────────────────────────

    /// Which macOS system permission is being reported.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum Permission {
        /// Microphone access — required to capture local mic audio.
        Microphone,
        /// Screen Recording — required for the system audio tap (macOS 14.2+).
        ScreenCapture,
        /// Accessibility — required by some meeting detection methods.
        Accessibility,
    }

    /// The current grant status of a permission.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum PermissionGranted {
        /// Permission has been explicitly granted.
        Granted,
        /// The user has not yet been prompted (soft — the OS dialog will appear).
        NotRequested,
        /// The user explicitly denied the permission (hard failure).
        Denied,
    }

    /// Which media stream a [`CaptureStatus`] event refers to.
    ///
    /// [`CaptureStatus`]: Event::CaptureStatus
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum CaptureKind {
        Audio,
        Video,
    }

    // ── Internal detection types (monitor ↔ recorder only) ───────────────────

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub(crate) enum DetectionKind { Started, Updated, Ended, SpeakerChanged }

    #[derive(Debug, Clone)]
    pub(crate) struct Detection {
        pub(crate) kind:  DetectionKind,
        pub(crate) app:   String,
        /// Window title — set when kind == Updated.
        pub(crate) title: Option<String>,
        /// Process ID of the meeting app — set when kind == Started.
        pub(crate) pid:      u32,
        /// Speaker names — set when kind == SpeakerChanged.
        pub(crate) speakers: Vec<String>,
    }

    // ── Internal audio types ──────────────────────────────────────────────────

    #[derive(Debug, Clone)]
    pub(crate) struct AudioChunk {
        pub(crate) pcm: Vec<i16>,
    }

    pub(crate) struct Recording {
        pub(crate) rx:      crossbeam_channel::Receiver<AudioChunk>,
        pub(crate) stop_fn: Option<Box<dyn FnOnce() + Send>>,
    }

    impl Drop for Recording {
        fn drop(&mut self) {
            if let Some(f) = self.stop_fn.take() { f(); }
        }
    }

    // ── Errors ────────────────────────────────────────────────────────────────

    #[derive(Debug, thiserror::Error)]
    pub enum Error {
        #[error("monitor already started")]
        AlreadyStarted,
        #[error("platform init failed: {0}")]
        PlatformInit(String),
        #[error("recording failed: {0}")]
        RecordingFailed(String),
        #[error("macOS 14.2+ required for system audio tap (running {major}.{minor})")]
        MacOSVersionTooOld { major: u32, minor: u32 },
        #[error("permission denied — check Screen Recording / Microphone in System Settings")]
        PermissionDenied,
        #[error("{0}")]
        Other(String),
    }

    pub type Result<T> = std::result::Result<T, Error>;