Skip to content

feat(voice): mute microphone from AirPods controls - #160

Merged
johnmatthewtennant merged 31 commits into
mainfrom
jtennant/airpods-stem-mute
Aug 22, 2026
Merged

feat(voice): mute microphone from AirPods controls#160
johnmatthewtennant merged 31 commits into
mainfrom
jtennant/airpods-stem-mute

Conversation

@johnmatthewtennant

@johnmatthewtennant johnmatthewtennant commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

AirPods and supported Beats mute controls now mute microphone input during an active Berd voice conversation. Berd keeps its existing browser audio capture, while a lifecycle-scoped native macOS input session opts Berd into headset mute delivery and drives the same AVAudioApplication path used by the UI button. Muting clears queued and partially decoded speech without affecting assistant output.

Verified on AirPods Pro 3: both the stem control and Berd microphone button toggle the UI and play the native mute and unmute indicator sounds.

Reviewer-reproducible examples

On macOS 14 or later with supported AirPods or Beats:

  1. Start a Berd voice conversation and confirm speech is submitted normally.
  2. Press the headset mute control. Confirm Berd's microphone control changes to muted, the native mute sound plays, and speech is not submitted.
  3. Press it again. Confirm the control returns to unmuted, the native unmute sound plays, and speech resumes.
  4. Click Berd's microphone button in both directions. Confirm it produces the same native status sounds and behavior.
  5. While muted, let assistant speech play. Confirm output audio is unaffected.

@johnmatthewtennant johnmatthewtennant changed the title feat(voice): support AirPods stem input mute feat(voice): mute microphone from AirPods controls Aug 21, 2026
@johnmatthewtennant
johnmatthewtennant force-pushed the jtennant/airpods-stem-mute branch from 9440025 to b555feb Compare August 21, 2026 22:17
}
// Observing this notification and reading the state opts this capture
// session into AirPods mute delivery. Rust's handler remains the event path.
inputMuteObserver = NotificationCenter.default.addObserver(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Adapted from voice-conversation-cli native capture and mute-observer lifecycle: https://github.com/squareup/voice-conversation-cli/blob/f33dcd81631c7eba4c33ac8b8e646f8681090165/Sources/VoiceCLI/MicCapture.swift and https://github.com/squareup/voice-conversation-cli/blob/f33dcd81631c7eba4c33ac8b8e646f8681090165/Sources/VoiceCLI/MenuBarController.swift#L591-L605

Berd retains its WebKit PCM path, so this adaptation installs a silent native tap and preserves route-change restart behavior plus the empirically required input-mute notification opt-in.

Reproduce the full comparison:
git diff --no-index <(git -C ~/Development/voice-conversation-cli show f33dcd81631c7eba4c33ac8b8e646f8681090165:Sources/VoiceCLI/MicCapture.swift) src-tauri/swift/BerdAirPodsBridge/Sources/BerdAirPodsBridge/BerdAirPodsBridge.swift

Relevant adaptation:

- final class MicCapture: NSObject, Sendable {
+ private final class AirPodsCapture: @unchecked Sendable {
- inputNode.installTap(...buffer delivery...)
+ inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { _, _ in }
+ inputMuteObserver = NotificationCenter.default.addObserver(
+     forName: AVAudioApplication.inputMuteStateChangeNotification,
+     object: nil,
+     queue: nil
+ ) { _ in
+     _ = AVAudioApplication.shared.isInputMuted
+ }

@@ -0,0 +1,19 @@
#import "BerdObjCExceptionCatch.h"

// Adapted from voice-conversation-cli's VCTryObjCBlock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Adapted from voice-conversation-cli Objective-C exception containment: https://github.com/squareup/voice-conversation-cli/blob/f33dcd81631c7eba4c33ac8b8e646f8681090165/Sources/ObjCExceptionCatch/ObjCExceptionCatch.m

This keeps AVAudioEngine Objective-C exceptions from terminating Berd during device and route transitions.

Reproduce:
git diff --no-index <(git -C ~/Development/voice-conversation-cli show f33dcd81631c7eba4c33ac8b8e646f8681090165:Sources/ObjCExceptionCatch/ObjCExceptionCatch.m) src-tauri/swift/BerdAirPodsBridge/Sources/BerdObjCExceptionCatch/BerdObjCExceptionCatch.m

Actual adaptation:

- BOOL VCTryObjCBlock(...)
+ BOOL BerdTryObjCBlock(...)
- @"NSExceptionName": exception.name,
- domain: @"com.voice-conversation.objc-exception"
+ domain: @"com.berd.objc-exception"

@johnmatthewtennant
johnmatthewtennant marked this pull request as ready for review August 21, 2026 22:19
@johnmatthewtennant
johnmatthewtennant requested a review from a team August 21, 2026 22:19

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated code review

REQUEST_CHANGES: the native mute boundary introduces an unguarded last-completion-wins race that can leave the microphone opposite the user's latest choice. Two non-blocking issues also remain: the new mute toast bypasses localization, and a lifecycle race regression test was weakened so it no longer exercises a stale response. Supplied GitHub evidence was inspected: all eight check runs were completed successfully, while the combined commit status was pending with no commit statuses; required checks still govern merge readiness.

Deterministic publication result: 1 blocking and 2 non-blocking finding(s) publishable; 0 duplicate(s) suppressed.

Comment thread src/features/voice-conversation/api/voiceConversation.ts
Comment thread src/features/voice-conversation/hooks/useVoiceConversationController.ts Outdated
@johnmatthewtennant
johnmatthewtennant force-pushed the jtennant/airpods-stem-mute branch from d64e1b4 to 7f59ede Compare August 21, 2026 23:27

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated code review

COMMENT: one non-blocking lifecycle consistency issue remains. The native status exposes whether mute control is available but not the current native mute state, so a renderer that initializes or reloads during an already-muted conversation cannot reconstruct the user-visible control state. The earlier stale-request race, localization concern, and stale-start test gap are fixed at this head. Supplied GitHub evidence was inspected: Clean-room frozen install and DCO Check succeeded; the frontend and three platform Rust checks were still in progress, and the combined commit status was pending. Required checks still govern merge readiness.

Deterministic publication result: 0 blocking and 1 non-blocking finding(s) publishable; 0 duplicate(s) suppressed.

Comment thread src-tauri/src/commands/native_voice.rs

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated code review

APPROVE: fresh static review of the full exact three-dot comparison found no publishable findings. Final self-check covered headset and UI mute/unmute flows, renderer recovery, browser/native state consistency, lifecycle start/stop/failure, queued and partial audio discard, async ordering and never-completes behavior, stale status/event races, ownership and consent boundaries, accessibility, localization, navigation, design-system rules, and test honesty. The previously reported stale-request race, localization issue, stale-start coverage gap, and unrecoverable mute state are fixed at this head and have substantive human-account replies in resolved threads. Supplied GitHub evidence was inspected: all eight check runs completed successfully; the combined commit status remained pending with no commit statuses, and required checks still govern merge readiness.

Deterministic publication result: 0 blocking and 0 non-blocking finding(s) publishable; 0 duplicate(s) suppressed.

Pending checks: 1 check(s) are not complete.

This approval reflects the completed code review only; merge readiness remains governed by the repository's required checks.

@johnmatthewtennant
johnmatthewtennant merged commit 874e078 into main Aug 22, 2026
8 checks passed
@johnmatthewtennant
johnmatthewtennant deleted the jtennant/airpods-stem-mute branch August 22, 2026 00:36
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.

2 participants