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    /// Window + meeting-detection utilities re-exported for examples and external consumers.
30    #[cfg(target_os = "macos")]
31    pub mod window {
32        pub use crate::platform::darwin::window::{
33            cg_window_owner, find_primary_window, window_bounds, window_exists,
34        };
35
36        /// Returns `(pid, friendly_app_name)` of the first app that currently
37        /// has an active microphone session (CoreAudio `IsRunningInput`), or
38        /// `(0, "")` if no meeting is detected.  Same detector used internally
39        /// by `MeetingListener`.
40        pub fn poll_active() -> (u32, String) {
41            crate::platform::poll_active()
42        }
43    }
44
45    // ── Public event type ─────────────────────────────────────────────────────
46
47    /// All events emitted by [`MeetingListener`].
48    ///
49    /// Register handlers with [`MeetingListener::on`].
50    /// Multiple handlers for the same event are all called in registration order.
51    ///
52    /// Lifecycle order for a recorded meeting:
53    /// ```text
54    /// PermissionStatus × N  (macOS only, on start)
55    /// PermissionsGranted    (macOS only, once all perms OK)
56    /// MeetingDetected       (meeting begins)
57    /// MeetingUpdated        (title becomes known via window scan)
58    /// RecordingStarted      (if record() was called)
59    /// MeetingEnded          (meeting stops)
60    /// RecordingEnded        (capture stopped, WAV being written)
61    /// RecordingReady        (WAV file written to disk)
62    /// ```
63    #[derive(Debug, Clone)]
64    pub enum Event {
65        // ── Permissions ───────────────────────────────────────────────────────
66        /// Status of an individual permission, emitted once per permission on
67        /// [`MeetingListener::start`].  macOS only; not emitted on Windows / Linux
68        /// where no permissions are required.
69        PermissionStatus {
70            permission: Permission,
71            status:     PermissionGranted,
72        },
73
74        /// All required permissions are granted; recording can proceed.
75        /// Emitted immediately on non-macOS platforms.
76        PermissionsGranted,
77
78        // ── Meeting lifecycle ─────────────────────────────────────────────────
79        /// A Teams / Zoom / Google Meet session was detected (new start, or
80        /// already in progress when the listener started).
81        MeetingDetected { app: String, pid: u32 },
82
83        /// Meeting metadata became known — currently the window title once the
84        /// window watcher identifies the call window.
85        MeetingUpdated { app: String, title: String },
86
87        /// The meeting has ended.
88        MeetingEnded { app: String },
89
90        // ── Recording lifecycle ───────────────────────────────────────────────
91        /// Audio capture has begun.  Fired when [`MeetingListener::record`]
92        /// successfully starts the system audio tap.
93        RecordingStarted { app: String },
94
95        /// Audio capture has stopped.  The WAV is being written; expect
96        /// [`Event::RecordingReady`] shortly after.
97        RecordingEnded { app: String },
98
99        /// A completed recording is ready.  Three WAV files are produced:
100            /// - `mixed_path`  — tap + mic combined (full meeting audio)
101            /// - `others_path` — system-tap only   (what other participants said)
102            /// - `self_path`   — microphone only    (what you said)
103            ///
104            /// Only fired when [`MeetingListener::record`] (or
105            /// [`MeetingListener::auto_record`]) was active during the meeting.
106            RecordingReady {
107                /// Tap + mic mixed — use for single-stream transcription
108                mixed_path:  std::path::PathBuf,
109                /// System tap only (other participants)
110                others_path: std::path::PathBuf,
111                /// Microphone only (local user)
112                self_path:   std::path::PathBuf,
113                app:         String,
114            },
115
116        // ── Capture health ────────────────────────────────────────────────────
117        /// The audio or video capture stream was interrupted or resumed.
118        /// For example, moving the meeting window to an inactive virtual desktop
119        /// may interrupt capture.
120        CaptureStatus { kind: CaptureKind, capturing: bool },
121
122        // ── Errors ────────────────────────────────────────────────────────────
123        /// An error occurred (e.g. the audio tap failed to start).
124        Error { message: String },
125
126        // ── Speaker diarization
127        /// The set of visually-detected speaking participants changed.
128        /// Empty \ means silence / no chromatic ring detected.
129        /// macOS only; never emitted on other platforms.
130        SpeakerChanged { speakers: Vec<String>, app: String },
131    }
132
133    // ── Supporting types ──────────────────────────────────────────────────────
134
135    /// Which macOS system permission is being reported.
136    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
137    pub enum Permission {
138        /// Microphone access — required to capture local mic audio.
139        Microphone,
140        /// Screen Recording — required for the system audio tap (macOS 14.2+).
141        ScreenCapture,
142        /// Accessibility — required by some meeting detection methods.
143        Accessibility,
144    }
145
146    /// The current grant status of a permission.
147    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
148    pub enum PermissionGranted {
149        /// Permission has been explicitly granted.
150        Granted,
151        /// The user has not yet been prompted (soft — the OS dialog will appear).
152        NotRequested,
153        /// The user explicitly denied the permission (hard failure).
154        Denied,
155    }
156
157    /// Which media stream a [`CaptureStatus`] event refers to.
158    ///
159    /// [`CaptureStatus`]: Event::CaptureStatus
160    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
161    pub enum CaptureKind {
162        Audio,
163        Video,
164    }
165
166    // ── Internal detection types (monitor ↔ recorder only) ───────────────────
167
168    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
169    pub(crate) enum DetectionKind { Started, Updated, Ended, SpeakerChanged }
170
171    #[derive(Debug, Clone)]
172    pub(crate) struct Detection {
173        pub(crate) kind:  DetectionKind,
174        pub(crate) app:   String,
175        /// Window title — set when kind == Updated.
176        pub(crate) title: Option<String>,
177        /// Process ID of the meeting app — set when kind == Started.
178        pub(crate) pid:      u32,
179        /// Speaker names — set when kind == SpeakerChanged.
180        pub(crate) speakers: Vec<String>,
181    }
182
183    // ── Internal audio types ──────────────────────────────────────────────────
184
185    #[derive(Debug, Clone)]
186    pub(crate) struct AudioChunk {
187        pub(crate) pcm: Vec<i16>,
188    }
189
190    pub(crate) struct Recording {
191        pub(crate) rx:      crossbeam_channel::Receiver<AudioChunk>,
192        pub(crate) stop_fn: Option<Box<dyn FnOnce() + Send>>,
193    }
194
195    impl Drop for Recording {
196        fn drop(&mut self) {
197            if let Some(f) = self.stop_fn.take() { f(); }
198        }
199    }
200
201    // ── Errors ────────────────────────────────────────────────────────────────
202
203    #[derive(Debug, thiserror::Error)]
204    pub enum Error {
205        #[error("monitor already started")]
206        AlreadyStarted,
207        #[error("platform init failed: {0}")]
208        PlatformInit(String),
209        #[error("recording failed: {0}")]
210        RecordingFailed(String),
211        #[error("macOS 14.2+ required for system audio tap (running {major}.{minor})")]
212        MacOSVersionTooOld { major: u32, minor: u32 },
213        #[error("permission denied — check Screen Recording / Microphone in System Settings")]
214        PermissionDenied,
215        #[error("{0}")]
216        Other(String),
217    }
218
219    pub type Result<T> = std::result::Result<T, Error>;