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>;