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_pcm;
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    /// Holds the stop functions for an active recording.
50    /// Dropping this calls both tap and mic stop functions.
51    struct RecordingHandle {
52        _tap_stop: Option<Box<dyn FnOnce() + Send>>,
53        _mic_stop: Option<Box<dyn FnOnce() + Send>>,
54    }
55
56    impl Drop for RecordingHandle {
57        fn drop(&mut self) {
58            if let Some(f) = self._tap_stop.take() { f(); }
59            if let Some(f) = self._mic_stop.take() { f(); }
60        }
61    }
62
63    struct MeetingState {
64        in_meeting: bool,
65        app:        String,
66        recording:  Option<RecordingHandle>,
67    }
68
69    // ── Public API ────────────────────────────────────────────────────────────
70
71    impl MeetingListener {
72        /// Create a listener with default settings (16 kHz, current directory).
73        pub fn new() -> Self {
74            Self {
75                inner: Arc::new(Inner {
76                    config:      Mutex::new(Config::default()),
77                    handlers:    RwLock::new(Vec::new()),
78                    auto_record: AtomicBool::new(false),
79                    meeting:     Mutex::new(MeetingState {
80                        in_meeting: false,
81                        app:        String::new(),
82                        recording:  None,
83                    }),
84                    monitor: Mutex::new(None),
85                }),
86            }
87        }
88
89        /// Set PCM sample rate (default: 16 000 Hz). Call before [`start`](Self::start).
90        pub fn sample_rate(&self, hz: u32) -> &Self {
91            self.inner.config.lock().unwrap().sample_rate = hz;
92            self
93        }
94
95        /// Set WAV output directory (default: cwd). Call before [`start`](Self::start).
96        pub fn output_dir<P: Into<PathBuf>>(&self, dir: P) -> &Self {
97            self.inner.config.lock().unwrap().output_dir = dir.into();
98            self
99        }
100
101        /// Register an event handler.  All registered handlers receive every
102        /// event in registration order — register as many as you need.
103        ///
104        /// Clone `self` to call [`record`](Self::record) from inside a handler:
105        /// ```no_run
106        /// # use side_huddle::{MeetingListener, Event};
107        /// let listener = MeetingListener::new();
108        /// let l = listener.clone();
109        /// listener.on(move |e| {
110        ///     if let Event::MeetingDetected { .. } = e { l.record(); }
111        /// });
112        /// ```
113        pub fn on<F: Fn(&Event) + Send + Sync + 'static>(&self, f: F) -> &Self {
114            self.inner.handlers.write().unwrap().push(Box::new(f));
115            self
116        }
117
118        /// Record every detected meeting automatically — no need to call
119        /// [`record`](Self::record) from a handler.
120        pub fn auto_record(&self) -> &Self {
121            self.inner.auto_record.store(true, Ordering::Relaxed);
122            self
123        }
124
125        /// Open System Settings to grant the permissions required for recording.
126        ///
127        /// On macOS, Screen Recording cannot be requested via an inline dialog — the OS
128        /// only provides a way to open System Settings → Privacy & Security → Screen
129        /// Recording.  After the user grants access there and the app restarts (or calls
130        /// [`start`](Self::start) again), recording will succeed.
131        ///
132        /// Microphone permission is handled separately: the inline OS dialog is shown
133        /// automatically the first time [`record`](Self::record) is called.
134        ///
135        /// Emits [`Event::PermissionStatus`] for each permission and
136        /// [`Event::PermissionsGranted`] if Screen Capture is already granted.
137        /// Safe to call multiple times (idempotent).
138        pub fn request_permissions(&self) {
139            #[cfg(target_os = "macos")]
140            {
141                // CGRequestScreenCaptureAccess: returns true if already granted,
142                // otherwise opens System Settings → Screen Recording and returns false.
143                // There is no blocking inline dialog for this permission — the user must
144                // grant it manually, then the listener needs to be restarted.
145                extern "C" { fn CGRequestScreenCaptureAccess() -> bool; }
146                unsafe { CGRequestScreenCaptureAccess(); }
147            }
148            // Re-check all permissions and broadcast current status.
149            // Emits PermissionsGranted if Screen Capture is now (or already was) granted.
150            check_and_emit_permissions(&self.inner);
151        }
152
153        /// Start recording the current meeting.
154        ///
155        /// Call from within a [`Event::MeetingDetected`] handler to opt in.
156        /// No-op if no meeting is active or a recording is already running.
157        /// Emits [`Event::RecordingStarted`] on success, [`Event::Error`] on failure.
158        pub fn record(&self) {
159            // macOS: check Screen Recording first — it's the hard gate for the audio
160            // tap. If it's denied, bail immediately rather than showing a microphone
161            // dialog that would only confuse the user (the tap fails regardless).
162            #[cfg(target_os = "macos")]
163            {
164                if check_screen_capture() == PermissionGranted::Denied {
165                    emit(&self.inner, &Event::PermissionStatus {
166                        permission: Permission::ScreenCapture,
167                        status:     PermissionGranted::Denied,
168                    });
169                    emit(&self.inner, &Event::Error {
170                        message: "Screen Recording access required — call request_permissions() \
171                                  to open System Settings, grant access, then restart the listener"
172                            .into(),
173                    });
174                    return;
175                }
176            }
177
178            // macOS: check/request microphone permission outside all locks so we can
179            // safely block for the dialog.
180            #[cfg(target_os = "macos")]
181            {
182                match check_microphone() {
183                    PermissionGranted::Granted => {}
184                    PermissionGranted::NotRequested => {
185                        // Never been asked — show the system dialog now.
186                        let status = request_microphone_access();
187                        emit(&self.inner, &Event::PermissionStatus {
188                            permission: Permission::Microphone,
189                            status,
190                        });
191                        if status != PermissionGranted::Granted {
192                            emit(&self.inner, &Event::Error {
193                                message: "Microphone access denied — grant permission in System Settings > Privacy > Microphone".into(),
194                            });
195                            return;
196                        }
197                    }
198                    PermissionGranted::Denied => {
199                        emit(&self.inner, &Event::PermissionStatus {
200                            permission: Permission::Microphone,
201                            status: PermissionGranted::Denied,
202                        });
203                        emit(&self.inner, &Event::Error {
204                            message: "Microphone access denied — grant permission in System Settings > Privacy > Microphone".into(),
205                        });
206                        return;
207                    }
208                }
209            }
210
211            let (sample_rate, chunk_ms, output_dir) = {
212                let cfg = self.inner.config.lock().unwrap();
213                (cfg.sample_rate, cfg.chunk_ms, cfg.output_dir.clone())
214            };
215
216            let mut state = self.inner.meeting.lock().unwrap();
217            if !state.in_meeting || state.recording.is_some() { return; }
218
219            let app = state.app.clone();
220
221            let tap = match platform::start_tap(sample_rate, chunk_ms) {
222                Ok(r)  => r,
223                Err(e) => { drop(state); emit(&self.inner, &Event::Error { message: e.to_string() }); return; }
224            };
225            let mic = match platform::start_mic(sample_rate, chunk_ms) {
226                Ok(r)  => r,
227                Err(e) => { drop(state); emit(&self.inner, &Event::Error { message: e.to_string() }); return; }
228            };
229
230            // Extract stop functions and receivers before consuming the Recording objects
231            let mut tap = tap; let mut mic = mic;
232            let tap_stop = tap.stop_fn.take();
233            let mic_stop = mic.stop_fn.take();
234            let tap_rx   = tap.rx.clone();
235            let mic_rx   = mic.rx.clone();
236            drop(tap); drop(mic);
237
238            let stem         = output_dir.join(format!("{}-meeting", unix_secs()));
239            let mixed_path   = stem.with_extension("wav");
240            let others_path  = PathBuf::from(format!("{}-others.wav",  stem.display()));
241            let self_path    = PathBuf::from(format!("{}-self.wav",    stem.display()));
242
243            // Store stop handles so stop_recording() can halt both streams
244            state.recording = Some(RecordingHandle {
245                _tap_stop: tap_stop,
246                _mic_stop: mic_stop,
247            });
248            drop(state);
249
250            emit(&self.inner, &Event::RecordingStarted { app: app.clone() });
251
252            let inner = Arc::clone(&self.inner);
253            thread::spawn(move || {
254                // Drain tap and mic concurrently into separate PCM buffers
255                use std::sync::mpsc::sync_channel;
256                let (tap_tx, tap_done) = sync_channel::<Vec<i16>>(0);
257                let (mic_tx, mic_done) = sync_channel::<Vec<i16>>(0);
258
259                thread::spawn(move || {
260                    let mut pcm = Vec::new();
261                    for chunk in tap_rx { pcm.extend_from_slice(&chunk.pcm); }
262                    let _ = tap_tx.send(pcm);
263                });
264                thread::spawn(move || {
265                    let mut pcm = Vec::new();
266                    for chunk in mic_rx { pcm.extend_from_slice(&chunk.pcm); }
267                    let _ = mic_tx.send(pcm);
268                });
269
270                let others_pcm = tap_done.recv().unwrap_or_default();
271                let self_pcm   = mic_done.recv().unwrap_or_default();
272
273                if others_pcm.is_empty() && self_pcm.is_empty() { return; }
274
275                let mixed_pcm = mix_pcm(&others_pcm, &self_pcm);
276
277                emit(&inner, &Event::RecordingEnded { app: app.clone() });
278
279                let ok = write_wav(&others_path, &others_pcm, sample_rate).is_ok()
280                    &    write_wav(&self_path,   &self_pcm,   sample_rate).is_ok()
281                    &    write_wav(&mixed_path,  &mixed_pcm,  sample_rate).is_ok();
282
283                if ok {
284                    emit(&inner, &Event::RecordingReady {
285                        mixed_path,
286                        others_path,
287                        self_path,
288                        app,
289                    });
290                } else {
291                    emit(&inner, &Event::Error { message: "failed to write WAV files".into() });
292                }
293            });
294        }
295
296        /// Start monitoring.  Emits [`Event::PermissionStatus`] ×N and
297        /// [`Event::PermissionsGranted`] before the first detection event.
298        pub fn start(&self) -> Result<()> {
299            // Check and emit permission status (macOS only; instant on other platforms)
300            check_and_emit_permissions(&self.inner);
301
302            let mut mon    = Monitor::new();
303            let inner_ref  = Arc::clone(&self.inner);
304
305            mon.on_detection(move |det: Detection| {
306                on_detection(&inner_ref, det);
307            });
308
309            mon.start()?;
310            *self.inner.monitor.lock().unwrap() = Some(mon);
311            Ok(())
312        }
313
314        /// Stop monitoring and cancel any active recording.
315        pub fn stop(&self) {
316            if let Some(mon) = self.inner.monitor.lock().unwrap().take() {
317                mon.stop();
318            }
319            self.inner.meeting.lock().unwrap().recording = None;
320        }
321    }
322
323    impl Default for MeetingListener {
324        fn default() -> Self { Self::new() }
325    }
326
327    // ── Permission checking ───────────────────────────────────────────────────
328
329    fn check_and_emit_permissions(inner: &Arc<Inner>) {
330        #[cfg(target_os = "macos")]
331        {
332            let sc  = check_screen_capture();
333            let mic = check_microphone();
334            emit(inner, &Event::PermissionStatus {
335                permission: Permission::ScreenCapture,
336                status:     sc,
337            });
338            emit(inner, &Event::PermissionStatus {
339                permission: Permission::Microphone,
340                status:     mic,
341            });
342            if sc == PermissionGranted::Granted {
343                emit(inner, &Event::PermissionsGranted);
344            }
345        }
346        #[cfg(not(target_os = "macos"))]
347        {
348            // Windows / Linux need no system permissions for audio capture
349            emit(inner, &Event::PermissionsGranted);
350        }
351    }
352
353    #[cfg(target_os = "macos")]
354    fn check_screen_capture() -> PermissionGranted {
355        extern "C" { fn CGPreflightScreenCaptureAccess() -> bool; }
356        if unsafe { CGPreflightScreenCaptureAccess() } {
357            PermissionGranted::Granted
358        } else {
359            // CGPreflightScreenCaptureAccess() returns false for BOTH "never asked"
360            // and "explicitly denied" — no public API distinguishes them.
361            // Map both to Denied: the app cannot tap system audio in either case,
362            // and downstream code should show "Permission needed" rather than
363            // offering "Record & Transcribe" (which would fail at start_tap() anyway
364            // and incorrectly trigger the mic dialog along the way).
365            PermissionGranted::Denied
366        }
367    }
368
369    /// Check microphone permission via the ObjC runtime without requiring
370    /// AVFoundation to be an explicit Rust dependency.
371    /// Calls [AVCaptureDevice authorizationStatusForMediaType: @"soun"].
372    /// Returns: NotRequested=not yet asked, Denied=blocked, Granted=approved.
373    #[cfg(target_os = "macos")]
374    fn check_microphone() -> PermissionGranted {
375        use std::ffi::c_void;
376        type ID  = *mut c_void;
377        type SEL = *const c_void;
378
379        extern "C" {
380            fn objc_getClass(name: *const u8)    -> *const c_void;
381            fn sel_registerName(name: *const u8) -> SEL;
382        }
383
384        let msg_send_ptr = unsafe {
385            libc::dlsym(libc::RTLD_DEFAULT, b"objc_msgSend\0".as_ptr() as _)
386        };
387        if msg_send_ptr.is_null() { return PermissionGranted::NotRequested; }
388
389        // Ensure AVFoundation is loaded — on macOS 14+ classes register lazily
390        // even when the binary links the framework.
391        unsafe {
392            libc::dlopen(
393                b"/System/Library/Frameworks/AVFoundation.framework/AVFoundation\0".as_ptr() as *const libc::c_char,
394                libc::RTLD_LAZY | libc::RTLD_GLOBAL,
395            );
396        }
397
398        unsafe {
399            let ns_string_cls = objc_getClass(b"NSString\0".as_ptr());
400            let av_device_cls = objc_getClass(b"AVCaptureDevice\0".as_ptr());
401            if ns_string_cls.is_null() || av_device_cls.is_null() {
402                return PermissionGranted::NotRequested;
403            }
404
405            // [NSString stringWithUTF8String:"soun"]  (AVMediaTypeAudio constant)
406            let sel_utf8 = sel_registerName(b"stringWithUTF8String:\0".as_ptr());
407            type FnStr = unsafe extern "C" fn(*const c_void, SEL, *const u8) -> ID;
408            let fn_str: FnStr = std::mem::transmute(msg_send_ptr);
409            let media_type = fn_str(ns_string_cls, sel_utf8, b"soun\0".as_ptr());
410            if media_type.is_null() { return PermissionGranted::NotRequested; }
411
412            // [AVCaptureDevice authorizationStatusForMediaType: mediaType]
413            // NSInteger: 0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized
414            let sel_auth = sel_registerName(b"authorizationStatusForMediaType:\0".as_ptr());
415            type FnAuth = unsafe extern "C" fn(*const c_void, SEL, ID) -> isize;
416            let fn_auth: FnAuth = std::mem::transmute(msg_send_ptr);
417            match fn_auth(av_device_cls, sel_auth, media_type) {
418                3 => PermissionGranted::Granted,
419                1 | 2 => PermissionGranted::Denied,
420                _ => PermissionGranted::NotRequested,  // 0 = not yet determined
421            }
422        }
423    }
424
425    /// Synchronously request microphone access via
426    /// [AVCaptureDevice requestAccessForMediaType:completionHandler:].
427    /// Blocks the calling thread until the user responds to the system dialog.
428    /// Safe to call even if permission was already granted (returns immediately).
429    #[cfg(target_os = "macos")]
430    fn request_microphone_access() -> PermissionGranted {
431        use std::ffi::c_void;
432        use std::sync::atomic::{AtomicBool, Ordering};
433
434        type ID  = *mut c_void;
435        type SEL = *const c_void;
436
437        extern "C" {
438            fn objc_getClass(name: *const u8)              -> *const c_void;
439            fn sel_registerName(name: *const u8)           -> SEL;
440            fn dispatch_semaphore_create(value: isize)     -> *mut c_void;
441            fn dispatch_semaphore_signal(sema: *mut c_void) -> isize;
442            fn dispatch_semaphore_wait(sema: *mut c_void, timeout: u64) -> isize;
443            fn dispatch_release(obj: *mut c_void);
444        }
445
446        // Objective-C block ABI: void(^)(BOOL)
447        // Flags = 0 → no copy/dispose helpers → ObjC does a bitwise copy of the struct,
448        // which is exactly what we want (the pointers remain valid throughout the wait).
449        #[repr(C)]
450        struct BoolBlock {
451            isa:      *const c_void,
452            flags:    i32,
453            reserved: i32,
454            invoke:   unsafe extern "C" fn(*const BoolBlock, bool),
455            desc:     *const BlockDesc,
456            granted:  *const AtomicBool,   // captured: where to write the result
457            sema:     *mut c_void,         // captured: semaphore to signal
458        }
459
460        #[repr(C)]
461        struct BlockDesc { reserved: usize, size: usize }
462        static BLOCK_DESC: BlockDesc = BlockDesc {
463            reserved: 0,
464            size:     core::mem::size_of::<BoolBlock>(),
465        };
466
467        unsafe extern "C" fn block_invoke(block: *const BoolBlock, granted: bool) {
468            (*(*block).granted).store(granted, Ordering::SeqCst);
469            dispatch_semaphore_signal((*block).sema);
470        }
471
472        let msg_send_ptr = unsafe {
473            libc::dlsym(libc::RTLD_DEFAULT, b"objc_msgSend\0".as_ptr() as _)
474        };
475        let stack_block_isa = unsafe {
476            libc::dlsym(libc::RTLD_DEFAULT, b"_NSConcreteStackBlock\0".as_ptr() as _)
477        };
478        if msg_send_ptr.is_null() || stack_block_isa.is_null() {
479            return PermissionGranted::NotRequested;
480        }
481
482        // Force AVFoundation class registration before calling objc_getClass.
483        unsafe {
484            libc::dlopen(
485                b"/System/Library/Frameworks/AVFoundation.framework/AVFoundation\0".as_ptr() as *const libc::c_char,
486                libc::RTLD_LAZY | libc::RTLD_GLOBAL,
487            );
488        }
489
490        let granted = AtomicBool::new(false);
491
492        unsafe {
493            let sema = dispatch_semaphore_create(0);
494            if sema.is_null() { return PermissionGranted::NotRequested; }
495
496            let mut block = BoolBlock {
497                isa:      stack_block_isa,
498                flags:    0,
499                reserved: 0,
500                invoke:   block_invoke,
501                desc:     &BLOCK_DESC,
502                granted:  &granted,
503                sema,
504            };
505
506            let ns_string_cls = objc_getClass(b"NSString\0".as_ptr());
507            let av_device_cls = objc_getClass(b"AVCaptureDevice\0".as_ptr());
508            if ns_string_cls.is_null() || av_device_cls.is_null() {
509                dispatch_release(sema);
510                return PermissionGranted::NotRequested;
511            }
512
513            let sel_utf8 = sel_registerName(b"stringWithUTF8String:\0".as_ptr());
514            type FnStr = unsafe extern "C" fn(*const c_void, SEL, *const u8) -> ID;
515            let fn_str: FnStr = core::mem::transmute(msg_send_ptr);
516            let media_type = fn_str(ns_string_cls, sel_utf8, b"soun\0".as_ptr());
517
518            let sel_req = sel_registerName(b"requestAccessForMediaType:completionHandler:\0".as_ptr());
519            type FnReq = unsafe extern "C" fn(*const c_void, SEL, ID, *mut BoolBlock);
520            let fn_req: FnReq = core::mem::transmute(msg_send_ptr);
521            fn_req(av_device_cls, sel_req, media_type, &mut block);
522
523            // Wait for the user to respond (or already-granted to call the handler immediately)
524            dispatch_semaphore_wait(sema, u64::MAX);
525            dispatch_release(sema);
526        }
527
528        if granted.load(Ordering::SeqCst) {
529            PermissionGranted::Granted
530        } else {
531            PermissionGranted::Denied
532        }
533    }
534
535
536    // ── Detection dispatch ────────────────────────────────────────────────────
537
538    fn on_detection(inner: &Arc<Inner>, det: Detection) {
539        match det.kind {
540            DetectionKind::Started => {
541                {
542                    let mut m = inner.meeting.lock().unwrap();
543                    m.in_meeting = true;
544                    m.app        = det.app.clone();
545                }
546                emit(inner, &Event::MeetingDetected { app: det.app.clone(), pid: det.pid });
547
548                if inner.auto_record.load(Ordering::Relaxed) {
549                    MeetingListener { inner: Arc::clone(inner) }.record();
550                }
551            }
552
553            DetectionKind::Updated => {
554                // Window title became known — emit MeetingUpdated
555                if let Some(title) = det.title {
556                    emit(inner, &Event::MeetingUpdated { app: det.app, title });
557                }
558            }
559
560            DetectionKind::Ended => {
561                // Stop any running recording (closes tap → accumulation thread
562                // notices channel disconnect → emits RecordingEnded + RecordingReady)
563                inner.meeting.lock().unwrap().recording = None;
564                emit(inner, &Event::MeetingEnded { app: det.app });
565                inner.meeting.lock().unwrap().in_meeting = false;
566            }
567
568            DetectionKind::SpeakerChanged => {
569                emit(inner, &Event::SpeakerChanged { speakers: det.speakers, app: det.app });
570            }
571        }
572    }
573
574    // ── Helpers ───────────────────────────────────────────────────────────────
575
576    fn emit(inner: &Arc<Inner>, event: &Event) {
577        let handlers = inner.handlers.read().unwrap();
578        for h in handlers.iter() { h(event); }
579    }
580
581    fn unix_secs() -> u64 {
582        std::time::SystemTime::now()
583            .duration_since(std::time::UNIX_EPOCH)
584            .map(|d| d.as_secs())
585            .unwrap_or(0)
586    }
587
588    fn write_wav(path: &Path, pcm: &[i16], sample_rate: u32) -> std::io::Result<()> {
589        let mut f     = std::fs::File::create(path)?;
590        let data_len  = (pcm.len() * 2) as u32;
591        let byte_rate = sample_rate * 2;
592
593        f.write_all(b"RIFF")?;
594        f.write_all(&(36 + data_len).to_le_bytes())?;
595        f.write_all(b"WAVE")?;
596        f.write_all(b"fmt ")?;
597        f.write_all(&16u32.to_le_bytes())?;
598        f.write_all(&1u16.to_le_bytes())?;
599        f.write_all(&1u16.to_le_bytes())?;
600        f.write_all(&sample_rate.to_le_bytes())?;
601        f.write_all(&byte_rate.to_le_bytes())?;
602        f.write_all(&2u16.to_le_bytes())?;
603        f.write_all(&16u16.to_le_bytes())?;
604        f.write_all(b"data")?;
605        f.write_all(&data_len.to_le_bytes())?;
606        for &s in pcm { f.write_all(&s.to_le_bytes())?; }
607        Ok(())
608    }