Skip to main content

side_huddle/
lib.rs

1    //! Detect a Teams / Zoom / Google Meet session and deliver a WAV recording.
2    //!
3    //! # Quick start
4    //! ```no_run
5    //! use side_huddle::{MeetingListener, Event};
6    //!
7    //! let listener = MeetingListener::new();
8    //!
9    //! listener.on(|event| println!("{event:?}"));
10    //!
11    //! let l = listener.clone();
12    //! listener.on(move |event| {
13    //!     if let Event::MeetingDetected { .. } = event { l.record(); }
14    //! });
15    //!
16    //! listener.start().unwrap();
17    //! std::thread::park();
18    //! ```
19
20    mod apps;
21    mod ffi;
22    mod mix;
23    mod monitor;
24    mod platform;
25    mod recorder;
26
27    pub use recorder::MeetingListener;
28
29    // ── Public event type ─────────────────────────────────────────────────────
30
31    /// All events emitted by [`MeetingListener`].
32    ///
33    /// Register handlers with [`MeetingListener::on`].
34    /// Multiple handlers for the same event are all called in registration order.
35    ///
36    /// Lifecycle order for a recorded meeting:
37    /// ```text
38    /// PermissionStatus × N  (macOS only, on start)
39    /// PermissionsGranted    (macOS only, once all perms OK)
40    /// MeetingDetected       (meeting begins)
41    /// MeetingUpdated        (title becomes known via window scan)
42    /// RecordingStarted      (if record() was called)
43    /// MeetingEnded          (meeting stops)
44    /// RecordingEnded        (capture stopped, WAV being written)
45    /// RecordingReady        (WAV file written to disk)
46    /// ```
47    #[derive(Debug, Clone)]
48    pub enum Event {
49        // ── Permissions ───────────────────────────────────────────────────────
50        /// Status of an individual permission, emitted once per permission on
51        /// [`MeetingListener::start`].  macOS only; not emitted on Windows / Linux
52        /// where no permissions are required.
53        PermissionStatus {
54            permission: Permission,
55            status:     PermissionGranted,
56        },
57
58        /// All required permissions are granted; recording can proceed.
59        /// Emitted immediately on non-macOS platforms.
60        PermissionsGranted,
61
62        // ── Meeting lifecycle ─────────────────────────────────────────────────
63        /// A Teams / Zoom / Google Meet session was detected (new start, or
64        /// already in progress when the listener started).
65        MeetingDetected { app: String },
66
67        /// Meeting metadata became known — currently the window title once the
68        /// window watcher identifies the call window.
69        MeetingUpdated { app: String, title: String },
70
71        /// The meeting has ended.
72        MeetingEnded { app: String },
73
74        // ── Recording lifecycle ───────────────────────────────────────────────
75        /// Audio capture has begun.  Fired when [`MeetingListener::record`]
76        /// successfully starts the system audio tap.
77        RecordingStarted { app: String },
78
79        /// Audio capture has stopped.  The WAV is being written; expect
80        /// [`Event::RecordingReady`] shortly after.
81        RecordingEnded { app: String },
82
83        /// A completed WAV recording is available at `path`.
84        /// Only fired when [`MeetingListener::record`] (or
85        /// [`MeetingListener::auto_record`]) was active during the meeting.
86        RecordingReady { path: std::path::PathBuf, app: String },
87
88        // ── Capture health ────────────────────────────────────────────────────
89        /// The audio or video capture stream was interrupted or resumed.
90        /// For example, moving the meeting window to an inactive virtual desktop
91        /// may interrupt capture.
92        CaptureStatus { kind: CaptureKind, capturing: bool },
93
94        // ── Errors ────────────────────────────────────────────────────────────
95        /// An error occurred (e.g. the audio tap failed to start).
96        Error { message: String },
97    }
98
99    // ── Supporting types ──────────────────────────────────────────────────────
100
101    /// Which macOS system permission is being reported.
102    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
103    pub enum Permission {
104        /// Microphone access — required to capture local mic audio.
105        Microphone,
106        /// Screen Recording — required for the system audio tap (macOS 14.2+).
107        ScreenCapture,
108        /// Accessibility — required by some meeting detection methods.
109        Accessibility,
110    }
111
112    /// The current grant status of a permission.
113    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
114    pub enum PermissionGranted {
115        /// Permission has been explicitly granted.
116        Granted,
117        /// The user has not yet been prompted (soft — the OS dialog will appear).
118        NotRequested,
119        /// The user explicitly denied the permission (hard failure).
120        Denied,
121    }
122
123    /// Which media stream a [`CaptureStatus`] event refers to.
124    ///
125    /// [`CaptureStatus`]: Event::CaptureStatus
126    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
127    pub enum CaptureKind {
128        Audio,
129        Video,
130    }
131
132    // ── Internal detection types (monitor ↔ recorder only) ───────────────────
133
134    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
135    pub(crate) enum DetectionKind { Started, Updated, Ended }
136
137    #[derive(Debug, Clone)]
138    pub(crate) struct Detection {
139        pub(crate) kind:  DetectionKind,
140        pub(crate) app:   String,
141        /// Window title — set when kind == Updated.
142        pub(crate) title: Option<String>,
143    }
144
145    // ── Internal audio types ──────────────────────────────────────────────────
146
147    #[derive(Debug, Clone)]
148    pub(crate) struct AudioChunk {
149        pub(crate) pcm: Vec<i16>,
150    }
151
152    pub(crate) struct Recording {
153        pub(crate) rx:      crossbeam_channel::Receiver<AudioChunk>,
154        pub(crate) stop_fn: Option<Box<dyn FnOnce() + Send>>,
155    }
156
157    impl Drop for Recording {
158        fn drop(&mut self) {
159            if let Some(f) = self.stop_fn.take() { f(); }
160        }
161    }
162
163    // ── Errors ────────────────────────────────────────────────────────────────
164
165    #[derive(Debug, thiserror::Error)]
166    pub enum Error {
167        #[error("monitor already started")]
168        AlreadyStarted,
169        #[error("platform init failed: {0}")]
170        PlatformInit(String),
171        #[error("recording failed: {0}")]
172        RecordingFailed(String),
173        #[error("macOS 14.2+ required for system audio tap (running {major}.{minor})")]
174        MacOSVersionTooOld { major: u32, minor: u32 },
175        #[error("permission denied — check Screen Recording / Microphone in System Settings")]
176        PermissionDenied,
177        #[error("{0}")]
178        Other(String),
179    }
180
181    pub type Result<T> = std::result::Result<T, Error>;