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