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