Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
48db1ca
feat(voice): support AirPods stem input mute
johnmatthewtennant Aug 20, 2026
f72e347
fix(voice): serialize mute handler teardown
johnmatthewtennant Aug 21, 2026
4885312
fix(voice): snapshot mute state during shutdown
johnmatthewtennant Aug 21, 2026
9414bec
fix(voice): signal shutdown before mute reset
johnmatthewtennant Aug 21, 2026
78cc70c
fix(voice): preserve accepted shutdown audio
johnmatthewtennant Aug 21, 2026
4e1896c
fix(voice): honor latched mute during worker shutdown
johnmatthewtennant Aug 21, 2026
43b8161
fix(voice): route AirPods mute through native capture
johnmatthewtennant Aug 21, 2026
44989e8
fix(voice): preserve native mute routing
johnmatthewtennant Aug 21, 2026
c065964
fix(voice): latch mute transitions
johnmatthewtennant Aug 21, 2026
635a081
fix(voice): satisfy cross-platform clippy
johnmatthewtennant Aug 21, 2026
4dbfadd
fix(voice): harden native microphone lifecycle
johnmatthewtennant Aug 21, 2026
8f64ee5
Revert "fix(voice): harden native microphone lifecycle"
johnmatthewtennant Aug 21, 2026
f2c1697
Revert "fix(voice): satisfy cross-platform clippy"
johnmatthewtennant Aug 21, 2026
00c9c7b
Revert "fix(voice): latch mute transitions"
johnmatthewtennant Aug 21, 2026
62a6f2f
Revert "fix(voice): preserve native mute routing"
johnmatthewtennant Aug 21, 2026
3a2430d
Revert "fix(voice): route AirPods mute through native capture"
johnmatthewtennant Aug 21, 2026
a9bc164
refactor(voice): minimize AirPods mute bridge
johnmatthewtennant Aug 21, 2026
8c86270
fix(voice): preserve mute lifecycle edges
johnmatthewtennant Aug 21, 2026
84c1a8a
fix(voice): restore AirPods mute opt-in
johnmatthewtennant Aug 21, 2026
777b199
docs(voice): preserve AirPods opt-in invariant
johnmatthewtennant Aug 21, 2026
e346916
fix(build): scope Swift tooling to macOS
johnmatthewtennant Aug 21, 2026
00f68af
fix(build): compile-gate Swift linker setup
johnmatthewtennant Aug 21, 2026
0165581
fix(voice): serialize microphone mute intent
johnmatthewtennant Aug 21, 2026
e0743d8
refactor(voice): remove diagnostic mute toast
johnmatthewtennant Aug 21, 2026
557ba7d
test(voice): restore stale startup coverage
johnmatthewtennant Aug 21, 2026
7f59ede
fix(voice): preserve early stem mute state
johnmatthewtennant Aug 21, 2026
fbc6485
fix(voice): hydrate native mute state
johnmatthewtennant Aug 21, 2026
6189189
fix(voice): reconcile equal-revision recovery
johnmatthewtennant Aug 21, 2026
c59653e
fix(voice): preserve current mute during recovery
johnmatthewtennant Aug 21, 2026
f5d1030
fix(voice): protect pending mute during recovery
johnmatthewtennant Aug 21, 2026
b6ceb10
test(voice): cover settled mute recovery race
johnmatthewtennant Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ exclude = ["plugins/app-test-driver"]
tauri-build = { version = "2", features = [] }
cc = "1"

[target.'cfg(target_os = "macos")'.build-dependencies]
swift-rs = { version = "1.0.7", features = ["build"] }

[dependencies]
anyhow = "1"
base64 = "0.22"
Expand Down Expand Up @@ -109,6 +112,7 @@ windows-sys = { version = "0.59", features = [
block2 = "0.6"
objc2 = "0.6"
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSImage", "NSMenu", "NSMenuItem", "NSAlert", "NSButton", "NSControl", "NSCell", "NSResponder", "NSView"] }
objc2-avf-audio = { version = "0.3.2", features = ["AVAudioApplication", "block2"] }
objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSError", "NSFileManager", "NSObject", "NSProcessInfo", "NSString", "NSURL"] }
objc2-user-notifications = "0.3.2"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ fn main() {
for framework in ["Foundation", "AVFoundation", "AudioToolbox", "CoreAudio"] {
println!("cargo:rustc-link-lib=framework={framework}");
}
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
swift_rs::SwiftLinker::new("14.0")
.with_package("BerdAirPodsBridge", "swift/BerdAirPodsBridge")
.link();
}
}

tauri_build::build()
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub mod local_mcp_inventory;
pub mod message_queues;
pub mod migration;
pub mod model_setup;
mod native_input_mute;
pub mod native_voice;
pub mod notifications;
#[cfg(feature = "block-voice-dictation")]
Expand Down
170 changes: 170 additions & 0 deletions src-tauri/src/commands/native_input_mute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};

pub fn start<F>(input_muted: &Arc<AtomicBool>, mute_epoch: &Arc<AtomicU64>, on_change: F) -> bool
where
F: Fn(bool) + Send + Sync + 'static,
{
clear(input_muted);

#[cfg(target_os = "macos")]
return match macos::install(Arc::clone(input_muted), Arc::clone(mute_epoch), on_change) {
Ok(()) => true,
Err(error) => {
log::info!("AirPods input mute listener is unavailable: {error}");
false
}
};

#[cfg(not(target_os = "macos"))]
{
let _ = (mute_epoch, on_change);
false
}
}

pub fn stop(input_muted: &Arc<AtomicBool>) {
clear(input_muted);

#[cfg(target_os = "macos")]
if let Err(error) = macos::uninstall() {
log::info!("Could not stop the AirPods input mute listener: {error}");
}
}

pub fn set_muted(
input_muted: &AtomicBool,
mute_epoch: &AtomicU64,
muted: bool,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
macos::set_muted(muted)?;
apply_change(input_muted, mute_epoch, muted, &|_| {});
Ok(())
}

#[cfg(not(target_os = "macos"))]
{
let _ = (input_muted, mute_epoch, muted);
Err("native microphone mute is only available on macOS".to_string())
}
}

fn clear(input_muted: &AtomicBool) {
input_muted.store(false, Ordering::Release);
}

#[cfg(any(target_os = "macos", test))]
fn apply_change(
input_muted: &AtomicBool,
mute_epoch: &AtomicU64,
muted: bool,
on_change: &dyn Fn(bool),
) {
if input_muted.swap(muted, Ordering::AcqRel) != muted {
if muted {
mute_epoch.fetch_add(1, Ordering::AcqRel);
}
on_change(muted);
}
}

#[cfg(target_os = "macos")]
mod macos {
use super::*;
use block2::RcBlock;
use objc2::runtime::Bool;
use objc2_avf_audio::AVAudioApplication;

extern "C" {
fn berd_airpods_capture_start() -> bool;
fn berd_airpods_capture_stop();
}

pub fn install<F>(
input_muted: Arc<AtomicBool>,
mute_epoch: Arc<AtomicU64>,
on_change: F,
) -> Result<(), String>
where
F: Fn(bool) + Send + Sync + 'static,
{
// SAFETY: Berd's minimum macOS version is 14.0, where
// AVAudioApplication and these selectors are public API.
let application = unsafe { AVAudioApplication::sharedInstance() };
let handler = RcBlock::new(move |muted: Bool| {
let muted = muted.as_bool();
apply_change(&input_muted, &mute_epoch, muted, &|muted| {
log::info!("AirPods input mute changed muted={muted}");
on_change(muted);
});
Bool::YES
});
// SAFETY: The block has the generated AVFAudio signature. The API
// copies and retains it until a later registration or cancellation.
if let Err(error) =
unsafe { application.setInputMuteStateChangeHandler_error(Some(&handler)) }
{
return Err(error.localizedDescription().to_string());
}
if let Err(error) = unsafe { application.setInputMuted_error(false) } {
let _ = unsafe { application.setInputMuteStateChangeHandler_error(None) };
return Err(error.localizedDescription().to_string());
}
// SAFETY: The Swift bridge owns one process-global AVAudioEngine and
// exposes a C-compatible lifecycle API.
if !unsafe { berd_airpods_capture_start() } {
let _ = unsafe { application.setInputMuteStateChangeHandler_error(None) };
return Err("macOS microphone capture could not start".to_string());
}
log::info!("AirPods input mute listener started");
Ok(())
}

pub fn uninstall() -> Result<(), String> {
// SAFETY: Berd targets macOS 14+, and nil is the documented way to
// cancel the process-wide handler at the end of a call lifecycle.
let application = unsafe { AVAudioApplication::sharedInstance() };
// Do not leave another Berd microphone feature inheriting the voice
// conversation's last input-mute state after its handler is gone.
let reset_result = unsafe { application.setInputMuted_error(false) }
.map_err(|error| error.localizedDescription().to_string());
let handler_result = unsafe { application.setInputMuteStateChangeHandler_error(None) }
.map_err(|error| error.localizedDescription().to_string());
unsafe { berd_airpods_capture_stop() };
reset_result.and(handler_result)
}

pub fn set_muted(muted: bool) -> Result<(), String> {
let application = unsafe { AVAudioApplication::sharedInstance() };
unsafe { application.setInputMuted_error(muted) }
.map_err(|error| error.localizedDescription().to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn lifecycle_boundary_clears_mute() {
let input_muted = Arc::new(AtomicBool::new(true));
clear(&input_muted);
assert!(!input_muted.load(Ordering::Acquire));
}

#[test]
fn mute_edge_advances_epoch_even_after_immediate_unmute() {
let input_muted = AtomicBool::new(false);
let mute_epoch = AtomicU64::new(0);

apply_change(&input_muted, &mute_epoch, true, &|_| {});
apply_change(&input_muted, &mute_epoch, false, &|_| {});

assert!(!input_muted.load(Ordering::Acquire));
assert_eq!(mute_epoch.load(Ordering::Acquire), 1);
}
}
Loading