Skip to content

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

Closed
AzimovS wants to merge 12 commits into
mainfrom
feat/meeting-detection
Closed

feat(detection): auto-detect meetings via microphone activity (macOS)#34
AzimovS wants to merge 12 commits into
mainfrom
feat/meeting-detection

Conversation

@AzimovS

@AzimovS AzimovS commented Apr 20, 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, a banner fires at a configurable sustain threshold. When the mic is released during an active recording, an "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: macOS only

The Windows (WASAPI IAudioSessionManager2) and Linux (libpulse-binding) samplers are in the tree, compile green on CI, and are ready to ship — but neither has been validated on real hardware by the author. The detection::spawn call, Tauri command registrations, and shutdown hook are #[cfg(target_os = "macos")]-gated so nothing runs on Windows/Linux. Those platforms will be enabled in follow-up PRs once each is validated end-to-end and a settings-level kill switch is in place (see "Follow-up work" below).

Why: if a platform-specific bug crashed the app on launch, users today have no in-app way to disable detection (show_meeting_detected / show_meeting_ended prefs only suppress the banner — the poll loop keeps running). Shipping all three platforms simultaneously conflates validated macOS code with compile-green-but-unexercised Win/Linux code. Gating keeps the work in-branch without taking on that risk.

Commits

  • 341c04f macOS via CoreAudio (Phase 1, shipped and validated end-to-end)
  • f448681 matcher refactor: App enum + per-platform alias tables
  • edd1424 Windows via WASAPI IAudioSessionManager2 (built, not enabled)
  • de84d74 Linux via libpulse-binding on a dedicated thread (built, not enabled)
  • bc76a58 fix: linux Mainloop requires &mut for lock/unlock (CI-caught)
  • ed812ea fix: windows imports — QueryFullProcessImageNameW lives in Threading, not ProcessStatus (CI-caught)
  • 81c3bec cfg-gate Win/Linux spawn to defer enablement until validated

Detection thresholds (macOS)

Signal Known apps (Zoom/Teams/browsers) 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 future "ignore this app" button).

macOS implementation

Uses cidre crate. On each tick:

  1. kAudioDevicePropertyDeviceIsRunningSomewhere as a cheap gate
  2. If hot, enumerate ca::System::processes() and filter by is_running_input()
  3. Resolve bundle ID via ca::Process::bundle_id()

Testing

  • 35 unit tests pass locally. Platform-gated #[cfg(...)] test modules cover macOS bundle-ID matching; Windows and Linux matcher tests also pass under their respective cfgs.
  • macOS tested end-to-end: Zoom → banner within 11s; meeting-ended fires after 32s of silence.

Manual test matrix (macOS)

Scenario macOS Expected
Zoom native in-call Banner within ~11s
Chrome + Google Meet Banner within ~11s ("a browser meeting")
Built-in dictation / voice memo NO banner (blocklist)
Meetily recording itself NO banner (self-filter)
Zoom in dock, no call NO banner
Meeting ended during recording Banner within ~32s

Post-deploy monitoring

  • Log queries to watch (all at info level, macOS only):
    • detection: Idle → Sustaining( — a 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 sampler
  • 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. Rollback is safe — revert this branch; detection simply stops working, no data impact.
  • Validation window: 72h post-release
  • Owner: @AzimovS

Follow-up work

Phase 2a: Windows enablement

  • Add settings-level detection_enabled kill switch (settings-file gate checked before spawn)
  • Validate end-to-end on real Windows hardware (native in-call Zoom, browser meetings, OBS/Wispr self-filter check)
  • Remove #[cfg(target_os = "macos")] gate for Windows in lib.rs

Phase 2b: Linux enablement

  • Same kill switch prerequisite
  • Validate on PipeWire pulse-compat (most modern distros) AND real PulseAudio — application.process.binary key semantics differ
  • Validate reconnect against a PulseAudio daemon restart
  • Remove #[cfg(target_os = "macos")] gate for Linux in lib.rs

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"

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 (now the roadmap for follow-up PRs)
  • docs/brainstorms/2026-04-20-meeting-auto-detect-brainstorm.md — origin discussion

🤖 Generated with Claude Code

AzimovS and others added 9 commits April 20, 2026 18:03
Observes which non-Meetily apps hold the default input device and fires
MeetingDetected / MeetingEnded banners at configurable thresholds — no
new permissions, no browser URL reading, no window-title parsing. Matches
the approach used by char (fastrepl/char) and Granola.

- detection/ module with poll-based service (1s cadence) driving a state
  machine: Idle → Sustaining → Detected → Ending → Idle
- macOS sampler via cidre: DeviceIsRunningSomewhere gates per-process
  enumeration (kAudioProcessPropertyIsRunningInput, bundle_id)
- Hybrid allowlist/blocklist matcher: named apps (Zoom/Teams/browsers)
  get their display name; unknown apps fire a generic banner; Meetily,
  dictation tools, screen recorders suppressed
- 10s sustain for known apps, 30s for unknown; 30s end-silence; no
  automatic dismissal on natural end (only explicit user dismiss)
- Notification gated on audio::recording_commands::IS_RECORDING, not
  the legacy RECORDING_FLAG (which the UI recording path never sets)
- Windows/Linux stub returns an empty snapshot — Phase 2 work
- 22 unit tests for state transitions, blocklist matching, display-name
  resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prepares for Phase 2 Windows/Linux samplers. The flat
KNOWN_MEETING_APPS/DEFINITELY_NOT_MEETINGS tuples would have needed
duplication for each platform's identifier flavor (bundle ID vs exe
basename vs binary name); replacing with an App enum + cfg-gated
ALIASES tables keeps the matching logic platform-agnostic.

- App enum (Zoom/Teams/Webex/FaceTime/Discord/Slack/Browser) holds
  canonical display name + priority rank
- Per-platform ALIASES: macOS bundle IDs, Windows exe names,
  Linux process binaries
- Per-platform DEFINITELY_NOT_MEETINGS with platform-appropriate
  self/dictation/recorder identifiers
- keys_match() encapsulates case-insensitive comparison on macOS/Windows
  (filesystem semantics) vs case-sensitive on Linux (process names)
- Tests split into per-platform cfg modules; all Phase 1 macOS tests
  retained; added Windows + Linux alias/block tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the no-op stub on Windows with a real IAudioSessionManager2
session enumerator. Matches the cadence and contract of the macOS
sampler: polled by the detection service, returns a MicSnapshot of
exe basenames for processes currently transmitting to the default
capture endpoint.

Algorithm (per tick):
- CoInitializeEx (thread-local, MTA, once per worker thread)
- CoCreateInstance IMMDeviceEnumerator
- GetDefaultAudioEndpoint(eCapture)
- IAudioSessionManager2 → GetSessionEnumerator → iterate
- Filter to AudioSessionStateActive
- Skip own PID and PID 0 (system session)
- OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) +
  QueryFullProcessImageNameW → basename → active_bundles

Graceful degradation: if WASAPI init fails at construction (headless
CI, broken audio subsystem), factory falls back to stub sampler so
the service keeps running. Same behaviour retrofitted for macOS.

Adds windows 0.58 dep under [target.'cfg(target_os = "windows")'].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the no-op stub on Linux with a real PulseAudio introspection
sampler. Works with both native PulseAudio and PipeWire's pulse-compat
layer (default on all major distros: Ubuntu 23.04+, Fedora 34+, etc).

Architecture:
- Dedicated native thread owns the Mainloop + Context (both are !Send
  and must stay on one thread).
- Shared snapshot via Arc<Mutex<Vec<String>>>; tokio-side snapshot()
  just clones the current contents.
- Poll get_source_output_info_list at 1s cadence on the pulse thread.
- Self-filter by std::process::id() via application.process.id proplist.
- Bundle key = application.process.binary (falls back to .name).
- Reconnect with backoff 1s→10s on State::Failed / State::Terminated;
  factory falls back to stub if initial connect times out at 3s.

Adds libpulse-binding 2.28 dep under [target.'cfg(target_os = "linux")'].

Limitation: users on pure-PipeWire systems without pulse-compat (rare)
get no detection. App starts normally; detection silently inactive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds lockfile entries for windows 0.58 (Win32 feature set) and
libpulse-binding 2.28. Generated by cargo check --lib.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…face

Resolves review findings 003, 004, 006, 011, 012, 013, 014, 015, 016, 017.

Lifecycle (003):
- DetectionService::shutdown() signals poll loop to exit at next tick
- Called from RunEvent::Exit before tauri starts dropping AppHandle /
  state. Removes the std::mem::forget(running) smell.

Dependency inversion (015):
- Audio layer calls DetectionService::set_recording(true/false) at each
  IS_RECORDING.store site instead of detection reaching back into
  audio::recording_commands. Advance() no longer takes is_recording param.

State machine UX (011, 012):
- Sustaining now upgrades to a strictly-higher-priority candidate when
  one arrives mid-sustain (Chrome → Zoom handoff case).
- Ending requires `ending_reacquire_confirm` (3s) of continuous mic
  activity before cancelling the silence counter. Absorbs sub-3s
  flicker without spurious MeetingEnded.

Agent-native + observability (013, 016):
- Emit `meeting-detected` / `meeting-ended` Tauri events alongside
  banner delivery. DetectedMeeting is already Serialize, so the payload
  is the canonical struct.
- New Tauri commands: `dismiss_detected_meeting(bundle_id)` and
  `get_detection_state()` returning DetectorPhaseSnapshot with phase
  name, display name, elapsed/remaining ms, and recording state.

Memory (006):
- DetectorState reaps expired `dismissed` entries every 60 advances
  (~1 min). HashMap no longer grows unbounded.

Privacy (004):
- Bundle IDs / raw process identifiers demoted from info! to debug!
  everywhere in the detection hot path. Display names (already
  sanitized — unknown apps say "a meeting") stay at info.

Dismiss subsystem (017):
- Not deleted — wired end-to-end via the new Tauri commands and the
  TTL reaper. Phase 4 "ignore this app" UI now has the Rust-side API
  it needs with no further backend work.

Trait naming (014 partial):
- Left SignalSampler name alone for now. Documented the
  pull-vs-event-driven concern in the plan; future change if another
  signal source appears.

Tests: 40 detection tests pass (added sustaining-priority-upgrade,
sub-3s flicker guard, phase_snapshot, reaper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves review findings 001, 002, 007.

Operation lifecycle (001):
Previously `collect_snapshot` acquired `mainloop.lock()`, issued the
introspect op, released the lock, and let the Operation drop when it
fell out of scope — outside the lock. libpulse-binding's documentation
requires the mainloop lock to be held during Operation drop
(pa_operation_unref), or else the pulse thread can race with the
operation's internal state. Now:
- Op is created under lock
- Lock is released while we wait for the callback
- Lock is re-acquired to drop the Op (→ pa_operation_unref)

Busy-wait removal (007):
Replaced the 10ms sleep-poll for `done.load()` with a std::sync::mpsc
sync_channel(1). Callback's End/Error arms `try_send(())`; the main
thread blocks on `recv_timeout(2s)`. Cuts median wait from ~15ms
(CLOCK_REALTIME granularity) down to the actual callback completion
time (~1-5ms).

Init timeout cleanup (002):
Previously the timeout branch set shared.shutdown=true and returned
without joining, leaking the worker thread. Now: poll-wait up to 2s
for `handle.is_finished()`, join if it exits, otherwise log a warn
and detach (thread terminates on next shutdown flag check anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gnostic

Resolves review findings 008, 009, 010.

Sidecar self-filter (009):
Added llama-helper + ffmpeg to Windows and Linux DEFINITELY_NOT_MEETINGS
so that Meetily-spawned sidecars — current or re-enabled later — don't
leak into the detector's snapshot and fire self-banners. These are the
executables in tauri.conf.json's externalBin set.

Dev bundle variants (010):
Added com.meetily.ai.dev and com.meetily.ai.debug to the macOS
blocklist so signed dev builds and debug builds self-filter correctly.
Doc note on matcher.rs points forks at the entries they need to update
if they rename the bundle.

EDR diagnostic (008):
Windows OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) can be denied
for EDR-protected processes (Defender for Endpoint, CrowdStrike,
SentinelOne). Previously the denial was silently dropped, and users
on those stacks saw the feature as permanently broken with no log
trail. Now: warn once per unique PID when OpenProcess returns
ACCESS_DENIED, with a hint that EDR is the likely cause. No log spam
(warn only fires on first-seen PID) and no PII (only the non-identifying
integer PID is logged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves review findings 019, 020 (partial).

Linux reconnect backoff (019):
Added ±20% jitter to the pulseaudio reconnect backoff to avoid
thundering-herd if multiple Meetily instances or users on a shared
host reconnect in lockstep after a daemon restart. Uses the workspace's
already-present rand 0.8.5.

macOS gate error log (020):
The DeviceIsRunningSomewhere property read previously swallowed errors
via unwrap_or(false). If the subsystem gets wedged we'd degrade to
"always idle" with zero diagnostics. Now logs at trace so a
developer with RUST_LOG=trace can see the breadcrumb.

SessionOutcome collapse (018): considered and deferred — the semantic
clarity of Connected vs ConnectFailed outweighs the ~15 LOC savings.

Remaining 020 items (over-documented comments, per-tick Arc churn,
Phase::clone, Windows 512-wchar buffer) are cosmetic and deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AzimovS

AzimovS commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed

Multi-agent code review surfaced 20 findings; 16 resolved in this branch.

P1 fixed — all four

  • 001 Linux pulse Operation dropped outside mainloop.lock() — now held through Op drop, using sync_channel to signal completion (commit 247bbdf)
  • 002 Linux init-timeout thread leak — 2s bounded join, detach with warn if not exited (commit 247bbdf)
  • 003 Detection task shutdown + mem::forget removal — DetectionService::shutdown() wired into RunEvent::Exit (commit d7ca115)
  • 004 Hot-path logs leaked bundle IDs — demoted raw identifiers from info to debug; only display names (matcher-sanitized) remain at info (commit d7ca115)

P2 fixed — 10 of 12

  • 006 Dismissed HashMap TTL reaper — retain() every 60 advances (~1 min) — commit d7ca115
  • 007 Linux 10ms busy-wait — replaced with sync_channel recv — commit 247bbdf
  • 008 Windows OpenProcess ACCESS_DENIED — warn once per PID with EDR hint — commit 3512698
  • 009 Sidecar self-filter gap — llama-helper + ffmpeg added to Windows + Linux blocklists — commit 3512698
  • 010 macOS hardcoded bundle ID — added com.meetily.ai.dev / .debug variants + fork note — commit 3512698
  • 011 Sustaining priority upgrade — higher-priority apps win the sustain slot mid-observation — commit d7ca115
  • 012 Ending flicker guard — requires 3s continuous reacquire to cancel silence timer — commit d7ca115
  • 013 Tauri event emission — meeting-detected / meeting-ended events alongside banners — commit d7ca115
  • 015 Recording-flag coupling — audio now pushes to detection via DetectionService::set_recording() — commit d7ca115
  • 016 Dismiss + state Tauri commands — dismiss_detected_meeting, get_detection_state — commit d7ca115

P3 fixed

  • 017 Dismiss subsystem dead-code — wired end-to-end via new commands; no longer dead
  • 019 Backoff jitter — ±20% on reconnect delays — commit 8ad7131

Deferred (low value for v1)

  • 005 COM apartment contamination — refactor Windows sampler to dedicated thread pattern like Linux. Real concern but needs Windows hardware to validate. Tracked as follow-up.
  • 014 SignalSampler trait rename — cosmetic; kept as-is with a plan-doc note on the pull-vs-event-driven question for future samplers.
  • 018 SessionOutcome enum collapse — cosmetic; kept for semantic clarity.
  • 020 Over-documented comments, per-tick Arc churn, Phase::clone, Windows buffer truncation — all cosmetic/marginal.

Test status

  • 40 detection unit tests pass locally on macOS (up from 35)
  • New tests: sustaining priority upgrade, 3s flicker guard, phase snapshot, TTL reaper
  • Pre-existing 2 failures in audio::device_detection unrelated to this PR
  • Windows/Linux code not compile-verified locally (cross-toolchain unavailable); ready for CI validation

Branch commits

8ad7131 chore(detection): backoff jitter + mac error-log breadcrumb
3512698 feat(detection): self-filter sidecars + dev-bundle variants + EDR diagnostic
247bbdf fix(detection): linux pulse Operation lifecycle + init-timeout cleanup
d7ca115 refactor(detection): shutdown lifecycle + UX fixes + agent-native surface
e6c244c chore: update Cargo.lock for windows + libpulse-binding deps
de84d74 feat(detection): add Linux mic-activity sampler via PulseAudio
edd1424 feat(detection): add Windows mic-activity sampler via WASAPI
f448681 refactor(detection): unify matcher on App enum with per-platform aliases
341c04f feat(detection): auto-detect meetings via microphone activity (macOS)

AzimovS added 3 commits April 20, 2026 22:35
libpulse-binding's threaded Mainloop::lock / unlock take &mut self.
collect_snapshot took &Mainloop so the four lock/unlock calls failed
with E0596 on linux targets. Bump the signature to &mut Mainloop and
pass the owned-mut from run_session. Caught by CI; untestable from
macOS because the module is #[cfg(target_os = "linux")] gated.
QueryFullProcessImageNameW and PROCESS_NAME_WIN32 live in
windows::Win32::System::Threading in windows 0.58, not ProcessStatus
(which only exposes K32*-prefixed PSAPI helpers in this version).
Move both imports and drop the now-unused Win32_System_ProcessStatus
feature. Verified against the local registry source.
macOS is the only platform the author has validated end-to-end. The
Windows (WASAPI) and Linux (libpulse-binding) samplers compile and
remain in the tree — only the spawn call, Tauri command registrations,
and shutdown hook are cfg-gated so the detection service never runs
on those platforms.

The sampler modules are already `#[cfg(target_os = ...)]`-gated; this
change ensures `DetectionService` is never constructed on Win/Linux,
so `notify_detection_recording_state` in recording_commands silently
no-ops via `try_state` returning None. Follow-up PRs per platform
will flip these cfgs once each is validated on real hardware and a
settings-level kill switch is in place.
@AzimovS AzimovS changed the title feat(detection): auto-detect meetings via microphone activity (macOS + Windows + Linux) feat(detection): auto-detect meetings via microphone activity (macOS) Apr 21, 2026
@AzimovS

AzimovS commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #35 — a clean-cut macOS-only subset of this work. The Windows (WASAPI) and Linux (libpulse-binding) samplers developed on this branch are preserved in the phase2-draft tag (git show phase2-draft) for use when follow-up PRs land.

Closing in favour of #35. Reasoning: shipping validated macOS code separately from unvalidated Win/Linux code avoids conflating confidence levels, and users of those platforms don't have an in-app way to disable detection today if something goes wrong. Phase 2 will land per-platform once a settings-level kill switch is in place.

@AzimovS AzimovS closed this Apr 21, 2026
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