Skip to content

feat(detection): auto-detect meetings via microphone activity (macOS) - #35

Merged
AzimovS merged 3 commits into
mainfrom
feat/meeting-detection-macos
Apr 21, 2026
Merged

feat(detection): auto-detect meetings via microphone activity (macOS)#35
AzimovS merged 3 commits into
mainfrom
feat/meeting-detection-macos

Conversation

@AzimovS

@AzimovS AzimovS commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Adds meeting auto-detection via microphone activity on macOS. When a non-Meetily app holds the default input device past a sustain threshold, a "Meeting detected — " banner fires. When the mic is released during an active Meetily recording, a "Meeting ended — tap to stop recording" banner fires.

Matches the approach used by char (fastrepl/char) and Granola — no browser URL reading, no window title parsing, no new permissions.

Scope & rationale

macOS only. Windows (WASAPI) and Linux (PulseAudio) samplers are deferred to follow-up PRs. The App enum and stub-sampler factory shape make adding those platforms a matter of slotting in platform files, not rearchitecting.

Why not all three platforms in one PR (see #34 history and the phase2-draft tag for the record):

  • macOS is the only platform the author has validated end-to-end
  • Shipping untested Win/Linux code conflates confidence levels — users of those platforms today have no in-app way to disable detection if something goes wrong, because show_meeting_detected / show_meeting_ended prefs only suppress the banner (the poll loop keeps running)
  • Phase 2 is uncertain; carrying dead platform code in the tree costs review friction and maintenance drag
  • The phase2-draft tag (git show phase2-draft) preserves the Win/Linux work for when / if it picks up

Detection thresholds

Signal Known apps (Zoom / Teams / browsers / FaceTime / Discord / Slack) Unknown apps
Meeting detected 10s sustain 30s sustain
Meeting ended 30s silence (any) 30s silence (any)

After natural meeting-end the state machine returns cleanly to Idle — no automatic 10-min dismissal. Dismissal exists as an internal API for a future "ignore this app" banner action.

Implementation

  • state.rs — pure state machine (Idle → Sustaining → Detected → Ending → Idle) parameterized over Instant; 23 unit tests cover priority upgrades, flicker, reacquire debouncing, dismissal cooldowns, and the "not recording" suppression path.
  • matcher.rs — hybrid allowlist + blocklist. Blocklist filters Meetily itself (incl. .dev and .debug variants), Apple dictation / voice-memos, third-party Whisper apps (superwhisper, macwhisper, Wispr Flow), and screen recorders (OBS, Loom, ScreenFlow). Allowlist maps bundle IDs to a cross-platform App enum preserved for future enablement.
  • signals/mic_activity/macos.rs — CoreAudio via cidre. kAudioDevicePropertyDeviceIsRunningSomewhere as a cheap gate; per-process kAudioProcessPropertyIsRunningInput enumeration only when hot. Bundle IDs from kAudioProcessPropertyBundleID (macOS 14.2+).
  • signals/mic_activity/stub.rs — no-op fallback used on non-macOS targets. Detection task still runs but never sees mic activity, so the state machine stays idle and nothing user-visible happens. Zero platform-specific code on Win/Linux means zero new crash surface.
  • service.rs — 1s poll loop; DetectionService registered in Tauri state; shutdown() gated on RunEvent::Exit so the poll task exits before state drop.
  • audio::recording_commands — pushes recording state into the service via try_state so detection knows whether to fire meeting-ended banners without depending on audio internals.

Tauri surface

  • Commands: dismiss_detected_meeting(bundle_id), get_detection_state()
  • Events: meeting-detected, meeting-ended (payload: { display_name, bundle_id })
  • Debug dropdown in About includes "Meeting detected (auto)" / "Meeting ended (auto)" entries that fire the banners through the real production notification path

Testing

  • 23 unit tests pass locally (macOS). State machine + matcher tests are platform-agnostic; matcher tests cover bundle-ID case-insensitivity, blocklist hits, priority ordering, and all public helpers.
  • End-to-end validated on the author's machine:
    • Zoom native in-call → banner within ~11s ✅
    • Meetily recording itself → no banner (self-filter) ✅
    • Built-in dictation → no banner (blocklist) ✅
    • Meeting ended during active recording → banner within ~32s ✅
    • Zoom in dock / no call → no banner ✅

Post-deploy monitoring

  • Log queries to watch (all at info level):
    • detection: Idle → Sustaining( — candidate crossed observation threshold
    • detection: Sustaining(...) → Detected — banner fired
    • detection: Ending(...) → Idle — natural meeting-end (with / without banner based on recording state)
    • mic-activity snapshot failed — CoreAudio sampler error
    • Meeting detection disabled — failed to init mic-activity sampler — fallback to stub
  • Expected healthy behaviour: idle CPU <0.1%; no "snapshot failed" logs during normal operation
  • Rollback trigger: user reports of phantom banners for non-meeting apps (>1 report per 24h in first 72h). Rollback is safe — revert this PR; detection simply stops working, no data impact.
  • Validation window: 72h post-release on macOS
  • Owner: @AzimovS

Follow-up work

Phase 2a: Windows enablement (deferred to a later PR)

  • Add settings-level detection_enabled kill switch (settings-file gate checked before spawn)
  • Validate end-to-end on real Windows hardware
  • Add a WASAPI IAudioSessionManager2-based sampler (draft available in phase2-draft tag)

Phase 2b: Linux enablement (deferred)

  • Same kill switch prerequisite
  • Validate on PipeWire pulse-compat AND real PulseAudio (application.process.binary key semantics differ)
  • Validate reconnect against a PulseAudio daemon restart
  • Add a libpulse-binding-based sampler (draft available in phase2-draft tag)

Kill switch design (blocker for Phase 2)

  • Add detection_enabled: bool to NotificationSettings (default true on mac, false on Win/Linux until validated per-user)
  • Read the setting in detection::spawn and short-circuit if disabled
  • Surface toggle in Settings UI
  • Support doc: "if the app misbehaves after enabling detection, edit notifications.json, set detection_enabled: false, relaunch"

Supersedes

Closes #34 — this PR is a clean-cut of the macOS-only subset. The Win/Linux work from that PR is preserved in the phase2-draft tag.

Plan documents

  • docs/plans/2026-04-20-feat-detect-meeting-start-and-end-plan.md — overall plan (Phase 1 + scoping decisions)
  • docs/plans/2026-04-20-feat-meeting-detection-phase-2-windows-linux-plan.md — detailed Phase 2 plan (roadmap for follow-up PRs)
  • docs/brainstorms/2026-04-20-meeting-auto-detect-brainstorm.md — origin discussion

🤖 Generated with Claude Code

AzimovS added 2 commits April 21, 2026 09:15
Scaffolds the notification plumbing for mic-activity meeting
detection (landing in the next commit). Adds `MeetingDetected(String)`
and `MeetingEnded(String)` enum variants alongside their settings
toggles (`show_meeting_detected` / `show_meeting_ended`, default on),
manager helpers, and the "Meeting detected — tap to start recording"
/ "Meeting ended — tap to stop recording" body text. Surfaces both
in the About debug dropdown so they can be fired manually.
Observes which non-Meetily apps hold the default input device. When a
known meeting app (Zoom, Teams, FaceTime, Discord, Slack, any browser)
sustains mic activity for 10s, fires a "Meeting detected — <app>"
banner. Unknown apps wait 30s. During an active Meetily recording, a
30s silence window after mic release fires "Meeting ended — tap to
stop recording".

Matches the approach used by `char` (fastrepl/char) and Granola: no
browser URL reading, no window title parsing, no new permissions.

Implementation:
- `state.rs`: pure state machine (`Idle → Sustaining → Detected →
  Ending → Idle`) parameterized over `Instant`; 23 unit tests cover
  priority upgrades, flicker, reacquire debouncing, dismissal
  cooldowns, and the "not recording" suppression path.
- `matcher.rs`: hybrid allowlist + blocklist. Blocklist filters
  Meetily itself, Apple dictation/voice-memos, third-party whisper
  apps, and screen recorders. Allowlist maps bundle IDs to a cross-
  platform `App` enum (preserved for future Windows/Linux enablement).
- `signals/mic_activity/macos.rs`: CoreAudio via `cidre`. Cheap gate
  on `kAudioDevicePropertyDeviceIsRunningSomewhere`, per-process
  enumeration only when hot.
- `signals/mic_activity/stub.rs`: no-op fallback. Used on non-macOS
  targets so the detection service compiles and runs idle — no
  banners ever fire, but the task loop is harmless.
- `service.rs`: 1s poll loop, `DetectionService` handle registered in
  Tauri state; shutdown gated on `RunEvent::Exit`.
- `audio::recording_commands`: pushes recording state into the
  service via `try_state` so detection knows whether to fire
  meeting-ended banners without depending on audio internals.
- Tauri command surface: `dismiss_detected_meeting`,
  `get_detection_state` for UI / agent observation. Events emitted
  as `meeting-detected` / `meeting-ended`.

Scope: macOS only. Windows (WASAPI) and Linux (PulseAudio) samplers
are deferred to follow-up PRs so they can be validated on real
hardware and shipped behind a settings-level kill switch. The
`App` enum and stub-sampler factory shape make adding those platforms
a matter of slotting in platform files, not rearchitecting.
- set_recording moved to Arc<AtomicBool> on DetectionService; poll loop
  syncs it into state before each advance. Removes the try_lock/spawn
  fallback that could reorder rapid start/stop calls and wrongly gate
  the MeetingEnded banner.
- DetectorPhaseSnapshot.bundle_id exposed for non-idle phases so agents
  can act on get_detection_state alone (dismiss_detected_meeting
  requires the raw bundle_id).
- meeting-detected / meeting-ended Tauri events gated on the matching
  notification preference and emit DetectedMeetingEvent { display_name }
  only. bundle_id stays Rust-side; agents read it via the command
  surface, not the event bus.
- NSMicrophoneUsageDescription updated to cover the new process-
  enumeration capability introduced by CoreAudio mic-holder detection.

The NotificationManager OnceCell migration (003) was dropped after
review: the race it addressed is narrow (racing first-callers during
startup eager-init) and the fallout benign (duplicate settings-file
write, idempotent delegate re-registration). Not worth ~100 LOC of
churn across every notification command handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AzimovS
AzimovS force-pushed the feat/meeting-detection-macos branch from 025451a to 951ea4b Compare April 21, 2026 09:23
@AzimovS
AzimovS merged commit a835eaf into main Apr 21, 2026
2 of 3 checks passed
@AzimovS AzimovS mentioned this pull request Apr 24, 2026
6 tasks
AzimovS added a commit that referenced this pull request Apr 24, 2026
Prepares a release containing the work merged since v0.1.15:
- feat(detection): mic-activity meeting auto-detection on macOS (#35)
- feat(transcription): retry with backoff + in-transcript failure
  placeholder (#39)
- feat(summary): TownHall template (#30), current template name in
  dropdown (#31), specificity prompt tweak (#37)
- feat(remote): test-connection button + model selection surface
  improvements (#28, #29)
- fix(notifications): migrate to UNUserNotificationCenter (#32),
  SAFETY + fallback race fixes (#33), unbundled-dev crash guard (#38),
  drop OS recording banners and flip auto_save default to false (#36)
- chore(about): drop Zackriya services CTA (#40)

Behavior change to call out in release notes: fresh installs (and
users with no stored recording_preferences.json) now default
auto_save to false — audio files are not written to disk unless the
user opts in via Recording Settings. Existing users with saved
preferences are unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant