Skip to main content

side_huddle/
recorder.rs

1    //! Event-emitter based meeting listener.
2
3    use std::io::Write as _;
4    use std::path::{Path, PathBuf};
5    use std::sync::{Arc, Mutex, RwLock};
6    use std::sync::atomic::{AtomicBool, Ordering};
7    use std::thread;
8
9    use crate::mix::mix_recordings;
10    use crate::monitor::Monitor;
11    use crate::platform;
12    use crate::{Detection, DetectionKind, Event, Permission, PermissionGranted, Recording, Result};
13
14    // ── Public type ───────────────────────────────────────────────────────────
15
16    /// Detects meetings and emits lifecycle events.
17    ///
18    /// Cheaply cloneable — all clones share the same state.  Capture a clone
19    /// inside an `on` handler to call [`record`](Self::record) or [`stop`](Self::stop).
20    #[derive(Clone)]
21    pub struct MeetingListener {
22        inner: Arc<Inner>,
23    }
24
25    struct Inner {
26        config:      Mutex<Config>,
27        handlers:    RwLock<Vec<Box<dyn Fn(&Event) + Send + Sync + 'static>>>,
28        auto_record: AtomicBool,
29        meeting:     Mutex<MeetingState>,
30        monitor:     Mutex<Option<Monitor>>,
31    }
32
33    struct Config {
34        sample_rate: u32,
35        chunk_ms:    u32,
36        output_dir:  PathBuf,
37    }
38
39    impl Default for Config {
40        fn default() -> Self {
41            Self {
42                sample_rate: 16_000,
43                chunk_ms:    200,
44                output_dir:  std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
45            }
46        }
47    }
48
49    struct MeetingState {
50        in_meeting: bool,
51        app:        String,
52        recording:  Option<Recording>,
53    }
54
55    // ── Public API ────────────────────────────────────────────────────────────
56
57    impl MeetingListener {
58        /// Create a listener with default settings (16 kHz, current directory).
59        pub fn new() -> Self {
60            Self {
61                inner: Arc::new(Inner {
62                    config:      Mutex::new(Config::default()),
63                    handlers:    RwLock::new(Vec::new()),
64                    auto_record: AtomicBool::new(false),
65                    meeting:     Mutex::new(MeetingState {
66                        in_meeting: false,
67                        app:        String::new(),
68                        recording:  None,
69                    }),
70                    monitor: Mutex::new(None),
71                }),
72            }
73        }
74
75        /// Set PCM sample rate (default: 16 000 Hz). Call before [`start`](Self::start).
76        pub fn sample_rate(&self, hz: u32) -> &Self {
77            self.inner.config.lock().unwrap().sample_rate = hz;
78            self
79        }
80
81        /// Set WAV output directory (default: cwd). Call before [`start`](Self::start).
82        pub fn output_dir<P: Into<PathBuf>>(&self, dir: P) -> &Self {
83            self.inner.config.lock().unwrap().output_dir = dir.into();
84            self
85        }
86
87        /// Register an event handler.  All registered handlers receive every
88        /// event in registration order — register as many as you need.
89        ///
90        /// Clone `self` to call [`record`](Self::record) from inside a handler:
91        /// ```no_run
92        /// # use side_huddle::{MeetingListener, Event};
93        /// let listener = MeetingListener::new();
94        /// let l = listener.clone();
95        /// listener.on(move |e| {
96        ///     if let Event::MeetingDetected { .. } = e { l.record(); }
97        /// });
98        /// ```
99        pub fn on<F: Fn(&Event) + Send + Sync + 'static>(&self, f: F) -> &Self {
100            self.inner.handlers.write().unwrap().push(Box::new(f));
101            self
102        }
103
104        /// Record every detected meeting automatically — no need to call
105        /// [`record`](Self::record) from a handler.
106        pub fn auto_record(&self) -> &Self {
107            self.inner.auto_record.store(true, Ordering::Relaxed);
108            self
109        }
110
111        /// Start recording the current meeting.
112        ///
113        /// Call from within a [`Event::MeetingDetected`] handler to opt in.
114        /// No-op if no meeting is active or a recording is already running.
115        /// Emits [`Event::RecordingStarted`] on success, [`Event::Error`] on failure.
116        pub fn record(&self) {
117            let (sample_rate, chunk_ms, output_dir) = {
118                let cfg = self.inner.config.lock().unwrap();
119                (cfg.sample_rate, cfg.chunk_ms, cfg.output_dir.clone())
120            };
121
122            let mut state = self.inner.meeting.lock().unwrap();
123            if !state.in_meeting || state.recording.is_some() { return; }
124
125            let app = state.app.clone();
126
127            let tap = match platform::start_tap(sample_rate, chunk_ms) {
128                Ok(r)  => r,
129                Err(e) => {
130                    drop(state);
131                    emit(&self.inner, &Event::Error { message: e.to_string() });
132                    return;
133                }
134            };
135            let mic = match platform::start_mic(sample_rate, chunk_ms) {
136                Ok(r)  => r,
137                Err(e) => {
138                    drop(state);
139                    emit(&self.inner, &Event::Error { message: e.to_string() });
140                    return;
141                }
142            };
143
144            let mixed = mix_recordings(tap, mic, sample_rate);
145            let rx    = mixed.rx.clone();
146            let path  = output_dir.join(format!("{}-meeting.wav", unix_secs()));
147            state.recording = Some(mixed);
148            drop(state);
149
150            emit(&self.inner, &Event::RecordingStarted { app: app.clone() });
151
152            let inner = Arc::clone(&self.inner);
153            thread::spawn(move || {
154                let mut pcm: Vec<i16> = Vec::new();
155                for chunk in rx.iter() { pcm.extend_from_slice(&chunk.pcm); }
156                if pcm.is_empty() { return; }
157
158                emit(&inner, &Event::RecordingEnded { app: app.clone() });
159
160                if write_wav(&path, &pcm, sample_rate).is_ok() {
161                    emit(&inner, &Event::RecordingReady { path, app });
162                } else {
163                    emit(&inner, &Event::Error {
164                        message: format!("failed to write WAV"),
165                    });
166                }
167            });
168        }
169
170        /// Start monitoring.  Emits [`Event::PermissionStatus`] ×N and
171        /// [`Event::PermissionsGranted`] before the first detection event.
172        pub fn start(&self) -> Result<()> {
173            // Check and emit permission status (macOS only; instant on other platforms)
174            check_and_emit_permissions(&self.inner);
175
176            let mut mon    = Monitor::new();
177            let inner_ref  = Arc::clone(&self.inner);
178
179            mon.on_detection(move |det: Detection| {
180                on_detection(&inner_ref, det);
181            });
182
183            mon.start()?;
184            *self.inner.monitor.lock().unwrap() = Some(mon);
185            Ok(())
186        }
187
188        /// Stop monitoring and cancel any active recording.
189        pub fn stop(&self) {
190            if let Some(mon) = self.inner.monitor.lock().unwrap().take() {
191                mon.stop();
192            }
193            self.inner.meeting.lock().unwrap().recording = None;
194        }
195    }
196
197    impl Default for MeetingListener {
198        fn default() -> Self { Self::new() }
199    }
200
201    // ── Permission checking ───────────────────────────────────────────────────
202
203    fn check_and_emit_permissions(inner: &Arc<Inner>) {
204        #[cfg(target_os = "macos")]
205        {
206            let sc = check_screen_capture();
207            emit(inner, &Event::PermissionStatus {
208                permission: Permission::ScreenCapture,
209                status:     sc,
210            });
211            // Microphone: we report NotRequested until the first record() attempt
212            emit(inner, &Event::PermissionStatus {
213                permission: Permission::Microphone,
214                status:     PermissionGranted::NotRequested,
215            });
216            if sc == PermissionGranted::Granted {
217                emit(inner, &Event::PermissionsGranted);
218            }
219        }
220        #[cfg(not(target_os = "macos"))]
221        {
222            // Windows / Linux need no system permissions for audio capture
223            emit(inner, &Event::PermissionsGranted);
224        }
225    }
226
227    #[cfg(target_os = "macos")]
228    fn check_screen_capture() -> PermissionGranted {
229        // CGPreflightScreenCaptureAccess() returns true if the process has the
230        // Screen Recording permission in System Settings.
231        extern "C" { fn CGPreflightScreenCaptureAccess() -> bool; }
232        if unsafe { CGPreflightScreenCaptureAccess() } {
233            PermissionGranted::Granted
234        } else {
235            PermissionGranted::NotRequested
236        }
237    }
238
239    // ── Detection dispatch ────────────────────────────────────────────────────
240
241    fn on_detection(inner: &Arc<Inner>, det: Detection) {
242        match det.kind {
243            DetectionKind::Started => {
244                {
245                    let mut m = inner.meeting.lock().unwrap();
246                    m.in_meeting = true;
247                    m.app        = det.app.clone();
248                }
249                emit(inner, &Event::MeetingDetected { app: det.app.clone() });
250
251                if inner.auto_record.load(Ordering::Relaxed) {
252                    MeetingListener { inner: Arc::clone(inner) }.record();
253                }
254            }
255
256            DetectionKind::Updated => {
257                // Window title became known — emit MeetingUpdated
258                if let Some(title) = det.title {
259                    emit(inner, &Event::MeetingUpdated { app: det.app, title });
260                }
261            }
262
263            DetectionKind::Ended => {
264                // Stop any running recording (closes tap → accumulation thread
265                // notices channel disconnect → emits RecordingEnded + RecordingReady)
266                inner.meeting.lock().unwrap().recording = None;
267                emit(inner, &Event::MeetingEnded { app: det.app });
268                inner.meeting.lock().unwrap().in_meeting = false;
269            }
270        }
271    }
272
273    // ── Helpers ───────────────────────────────────────────────────────────────
274
275    fn emit(inner: &Arc<Inner>, event: &Event) {
276        let handlers = inner.handlers.read().unwrap();
277        for h in handlers.iter() { h(event); }
278    }
279
280    fn unix_secs() -> u64 {
281        std::time::SystemTime::now()
282            .duration_since(std::time::UNIX_EPOCH)
283            .map(|d| d.as_secs())
284            .unwrap_or(0)
285    }
286
287    fn write_wav(path: &Path, pcm: &[i16], sample_rate: u32) -> std::io::Result<()> {
288        let mut f     = std::fs::File::create(path)?;
289        let data_len  = (pcm.len() * 2) as u32;
290        let byte_rate = sample_rate * 2;
291
292        f.write_all(b"RIFF")?;
293        f.write_all(&(36 + data_len).to_le_bytes())?;
294        f.write_all(b"WAVE")?;
295        f.write_all(b"fmt ")?;
296        f.write_all(&16u32.to_le_bytes())?;
297        f.write_all(&1u16.to_le_bytes())?;
298        f.write_all(&1u16.to_le_bytes())?;
299        f.write_all(&sample_rate.to_le_bytes())?;
300        f.write_all(&byte_rate.to_le_bytes())?;
301        f.write_all(&2u16.to_le_bytes())?;
302        f.write_all(&16u16.to_le_bytes())?;
303        f.write_all(b"data")?;
304        f.write_all(&data_len.to_le_bytes())?;
305        for &s in pcm { f.write_all(&s.to_le_bytes())?; }
306        Ok(())
307    }