From 7838e06e2a65fec591b007181d85aa779a81367e Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Fri, 26 Jun 2026 15:26:45 +0200 Subject: [PATCH 01/14] Register connected AirPods and handle tray errors. --- linux-rust/src/main.rs | 20 +++++++++++++++++--- linux-rust/src/utils.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..34f46a917 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -10,7 +10,7 @@ use crate::bluetooth::managers::DeviceManagers; use crate::devices::enums::DeviceData; use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; -use crate::utils::{get_app_settings_path, get_devices_path}; +use crate::utils::{ensure_device_registered, get_app_settings_path, get_devices_path}; use bluer::{Address, InternalErrorKind}; use clap::Parser; use dbus::arg::{RefArg, Variant}; @@ -145,8 +145,16 @@ async fn async_main( command_tx: None, ui_tx: Some(ui_tx.clone()), }; - let handle = tray.spawn().await.unwrap(); - Some(handle) + match tray.spawn().await { + Ok(handle) => Some(handle), + Err(e) => { + log::warn!( + "Failed to start system tray ({e}); continuing without tray. \ + Your environment may lack a StatusNotifier/AppIndicator watcher." + ); + None + } + } }; let session = bluer::Session::new().await?; @@ -171,6 +179,11 @@ async fn async_main( .await? .unwrap_or_else(|| "Unknown".to_string()); info!("Found connected AirPods: {}, initializing.", name); + ensure_device_registered( + &device.address().to_string(), + &name, + devices::enums::DeviceType::AirPods, + ); let airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await; @@ -309,6 +322,7 @@ async fn async_main( .get::("org.bluez.Device1", "Name") .unwrap_or_else(|_| "Unknown".to_string()); info!("AirPods connected: {}, initializing", name); + ensure_device_registered(&addr_str, &name, devices::enums::DeviceType::AirPods); let handle_clone = tray_handle.clone(); let ui_tx_clone = ui_tx.clone(); let device_managers = device_managers.clone(); diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..91090973a 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -13,6 +13,47 @@ pub fn get_devices_path() -> PathBuf { .join("devices.json") } +/// Ensure a connected device is recorded in the devices file so it shows up in +/// the UI sidebar. Auto-detected AirPods don't go through the (currently +/// disabled) manual "add device" flow, so we persist them here on connect. +/// No-op if the device is already registered. +pub fn ensure_device_registered( + mac: &str, + name: &str, + type_: crate::devices::enums::DeviceType, +) { + use crate::devices::enums::DeviceData; + use std::collections::HashMap; + + let path = get_devices_path(); + let json = std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".to_string()); + let mut devices: HashMap = + serde_json::from_str(&json).unwrap_or_default(); + if devices.contains_key(mac) { + return; + } + log::info!("Registering device {} ({}) as {:?}", name, mac, type_); + devices.insert( + mac.to_string(), + DeviceData { + name: name.to_string(), + type_, + information: None, + }, + ); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match serde_json::to_string(&devices) { + Ok(updated) => { + if let Err(e) = std::fs::write(&path, updated) { + log::error!("Failed to write devices file: {}", e); + } + } + Err(e) => log::error!("Failed to serialize devices file: {}", e), + } +} + pub fn get_preferences_path() -> PathBuf { let config_dir = std::env::var("XDG_CONFIG_HOME") .unwrap_or_else(|_| format!("{}/.config", std::env::var("HOME").unwrap_or_default())); From 00ea90ae6dcc62045ab3906c3b69979e70c2fecc Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Fri, 26 Jun 2026 17:37:32 +0200 Subject: [PATCH 02/14] Implement Hi-Res Microphone support for AirPods. Adds AAC-ELD decoding using ffmpegs libavcodec and pipes the decoded 0x58 AACP stream into a virtual PipeWire input. --- linux-rust/Cargo.lock | 128 ++++++++- linux-rust/Cargo.toml | 4 + .../flatpak/me.kavishdevar.librepods.yaml | 8 + linux-rust/src/audio/agc.rs | 47 ++++ linux-rust/src/audio/eld.rs | 168 ++++++++++++ linux-rust/src/audio/hires_mic.rs | 123 +++++++++ linux-rust/src/audio/mod.rs | 9 + linux-rust/src/audio/output.rs | 253 ++++++++++++++++++ linux-rust/src/bluetooth/aacp.rs | 117 +++++++- linux-rust/src/bluetooth/aacp_audio.rs | 41 +++ linux-rust/src/bluetooth/mod.rs | 1 + linux-rust/src/devices/enums.rs | 2 + linux-rust/src/main.rs | 11 + linux-rust/src/ui/airpods.rs | 55 ++++ linux-rust/src/ui/window.rs | 1 + 15 files changed, 951 insertions(+), 17 deletions(-) create mode 100644 linux-rust/src/audio/agc.rs create mode 100644 linux-rust/src/audio/eld.rs create mode 100644 linux-rust/src/audio/hires_mic.rs create mode 100644 linux-rust/src/audio/mod.rs create mode 100644 linux-rust/src/audio/output.rs create mode 100644 linux-rust/src/bluetooth/aacp_audio.rs diff --git a/linux-rust/Cargo.lock b/linux-rust/Cargo.lock index 256397efd..3b2d2b0db 100644 --- a/linux-rust/Cargo.lock +++ b/linux-rust/Cargo.lock @@ -408,7 +408,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -422,6 +422,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -643,6 +661,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -665,6 +692,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -1313,6 +1351,20 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ffmpeg-sys-next" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" +dependencies = [ + "bindgen", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1682,6 +1734,12 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.16.0" @@ -2084,7 +2142,7 @@ dependencies = [ "approx", "getrandom 0.3.4", "image", - "itertools", + "itertools 0.14.0", "nalgebra", "num", "rand", @@ -2137,6 +2195,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2394,6 +2461,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "libpulse-simple-binding" +version = "2.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef" +dependencies = [ + "libpulse-binding", + "libpulse-simple-sys", + "libpulse-sys", +] + +[[package]] +name = "libpulse-simple-sys" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc" +dependencies = [ + "libpulse-sys", + "pkg-config", +] + [[package]] name = "libpulse-sys" version = "1.23.0" @@ -2429,6 +2517,7 @@ dependencies = [ "clap", "dbus", "env_logger", + "ffmpeg-sys-next", "futures", "hex", "iced", @@ -2436,6 +2525,7 @@ dependencies = [ "imageproc", "ksni", "libpulse-binding", + "libpulse-simple-binding", "log", "serde", "serde_json", @@ -2580,6 +2670,12 @@ dependencies = [ "paste", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2749,6 +2845,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2848,6 +2954,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3618,7 +3734,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -4625,6 +4741,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/linux-rust/Cargo.toml b/linux-rust/Cargo.toml index fb373a231..3e2975c6a 100644 --- a/linux-rust/Cargo.toml +++ b/linux-rust/Cargo.toml @@ -13,6 +13,7 @@ dbus = "0.9.10" hex = "0.4.3" iced = { version = "0.14.0", features = ["tokio", "image"] } libpulse-binding = "2.30.1" +libpulse-simple-binding = "2.29.0" ksni = "0.3.1" image = "0.25.8" imageproc = "0.26.1" @@ -22,6 +23,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" aes = "0.9.0-rc.4" futures = "0.3.32" +# AAC-ELD decode for the hi-res microphone. System libavcodec/libavutil via +# pkg-config (no bundled build); avformat/swscale/etc. are not needed. +ffmpeg-sys-next = { version = "8.1", default-features = false, features = ["avcodec"] } [profile.release] opt-level = "s" diff --git a/linux-rust/flatpak/me.kavishdevar.librepods.yaml b/linux-rust/flatpak/me.kavishdevar.librepods.yaml index 00a7b35c9..f935ce86a 100644 --- a/linux-rust/flatpak/me.kavishdevar.librepods.yaml +++ b/linux-rust/flatpak/me.kavishdevar.librepods.yaml @@ -7,6 +7,14 @@ sdk-extensions: command: librepods +# Hi-res microphone deps (verified present in org.freedesktop.Platform 25.08): +# - libavcodec.so.61 / libavutil.so.59 (FFmpeg 7.1) for AAC-ELD decode. +# ffmpeg-sys-next auto-detects this version at build time, so no pin needed. +# - libpulse-simple.so.0 for the PCM playback stream. +# The virtual mic is created over the existing --socket=pulseaudio (module load +# + playback are protocol calls; no extra finish-args, no pactl/pw-cat needed). +# Build note: ffmpeg-sys-next runs bindgen, which needs libclang from the SDK. + finish-args: - --socket=wayland - --socket=fallback-x11 diff --git a/linux-rust/src/audio/agc.rs b/linux-rust/src/audio/agc.rs new file mode 100644 index 000000000..707cc3afe --- /dev/null +++ b/linux-rust/src/audio/agc.rs @@ -0,0 +1,47 @@ +//! AGC (Automatic Gain Control) for audio processing. + +pub struct Agc { + envelope: f32, + gain: f32, +} + +impl Agc { + const TARGET: f32 = 0.25; // ≈ -12 dBFS + const MAX_GAIN: f32 = 8.0; // +18 dB + const ATTACK: f32 = 0.02; + const RELEASE: f32 = 0.001; + + pub fn new() -> Self { + Self { + envelope: 0.0, + gain: 1.0, + } + } + + pub fn process(&mut self, samples: &mut [i16]) { + for s in samples { + let x = *s as f32 / 32768.0; + let level = x.abs(); + + if level > self.envelope { + self.envelope += (level - self.envelope) * Self::ATTACK; + } else { + self.envelope += (level - self.envelope) * Self::RELEASE; + } + + // Target gain + let desired = (Self::TARGET / self.envelope.max(1e-4)).min(Self::MAX_GAIN); + + // Smooth gain + self.gain += (desired - self.gain) * 0.01; + + // Apply gain + let mut y = x * self.gain; + + // Soft limiter + y = y.tanh(); + + *s = (y * 32767.0) as i16; + } + } +} diff --git a/linux-rust/src/audio/eld.rs b/linux-rust/src/audio/eld.rs new file mode 100644 index 000000000..d492abc2f --- /dev/null +++ b/linux-rust/src/audio/eld.rs @@ -0,0 +1,168 @@ +//! AAC-ELD decoder using libavcodec (LGPL) for the AirPods proprietary hi-res uplink. +//! AOT 39, mono, 64000 Hz, 480-sample frame (7.5 ms), ~80 kbps VBR. +//! See https://ffmpeg-d.dpldocs.info/v3.1.1/ffmpeg.libavcodec.avcodec.AVCodecContext.html + +use ffmpeg_sys_next as ff; +use std::os::raw::c_int; +use std::ptr; + +pub const ELD_SAMPLE_RATE: u32 = 64000; +pub const ELD_FRAME_SAMPLES: usize = 480; +pub const ELD_CHANNELS: i32 = 1; + +const ELD_ASC: [u8; 4] = [0xF8, 0xE6, 0x30, 0x00]; +const ELD_CODING_RATE: c_int = 48000; +const ELD_INBUF_MAX: usize = 512; + +const PAD: usize = ff::AV_INPUT_BUFFER_PADDING_SIZE as usize; + +pub struct EldDecoder { + ctx: *mut ff::AVCodecContext, + pkt: *mut ff::AVPacket, + frame: *mut ff::AVFrame, + inbuf: Vec, +} + +unsafe impl Send for EldDecoder {} + +#[inline] +fn f_to_s16(s: f32) -> i16 { + (s.clamp(-1.0, 1.0) * 32767.0).round() as i16 +} + +impl EldDecoder { + // Open the AAC-ELD decoder. Returns None on failure. + pub fn new() -> Option { + unsafe { + // stop libavcodec from spamming stderr + ff::av_log_set_level(ff::AV_LOG_FATAL); + + let codec = ff::avcodec_find_decoder(ff::AVCodecID::AV_CODEC_ID_AAC); + if codec.is_null() { + return None; + } + let ctx = ff::avcodec_alloc_context3(codec); + if ctx.is_null() { + return None; + } + + let mut d = EldDecoder { + ctx, + pkt: ptr::null_mut(), + frame: ptr::null_mut(), + inbuf: vec![0u8; ELD_INBUF_MAX + PAD], + }; + + // extradata = ASC + let extradata = ff::av_mallocz(ELD_ASC.len() + PAD) as *mut u8; + if extradata.is_null() { + d.free(); + return None; + } + ptr::copy_nonoverlapping(ELD_ASC.as_ptr(), extradata, ELD_ASC.len()); + (*ctx).extradata = extradata; + (*ctx).extradata_size = ELD_ASC.len() as c_int; + (*ctx).sample_rate = ELD_CODING_RATE; + ff::av_channel_layout_default(&mut (*ctx).ch_layout, ELD_CHANNELS); + + if ff::avcodec_open2(ctx, codec, ptr::null_mut()) < 0 { + d.free(); + return None; + } + + d.pkt = ff::av_packet_alloc(); + d.frame = ff::av_frame_alloc(); + if d.pkt.is_null() || d.frame.is_null() { + d.free(); + return None; + } + Some(d) + } + } + + // Free all FFmpeg resources. + unsafe fn free(&mut self) { + unsafe { + if !self.frame.is_null() { + ff::av_frame_free(&mut self.frame); + } + if !self.pkt.is_null() { + ff::av_packet_free(&mut self.pkt); + } + if !self.ctx.is_null() { + ff::avcodec_free_context(&mut self.ctx); + } + } + } + + // Decode one access unit, appending interleaved i16 PCM to `out`. + // Returns the number of samples appended, or None on a decode error. + pub fn decode(&mut self, au: &[u8], out: &mut Vec) -> Option { + if au.is_empty() || au.len() > ELD_INBUF_MAX { + return None; + } + unsafe { + self.inbuf[..au.len()].copy_from_slice(au); + self.inbuf[au.len()..au.len() + PAD].fill(0); + (*self.pkt).data = self.inbuf.as_mut_ptr(); + (*self.pkt).size = au.len() as c_int; + + if ff::avcodec_send_packet(self.ctx, self.pkt) < 0 { + return None; + } + if ff::avcodec_receive_frame(self.ctx, self.frame) < 0 { + return None; + } + + let nch = (*self.frame).ch_layout.nb_channels; + let ns = (*self.frame).nb_samples; + if nch <= 0 || ns <= 0 { + return None; + } + let (nch, ns) = (nch as usize, ns as usize); + let total = ns * nch; + out.reserve(total); + + let data = &(*self.frame).data; + match (*self.frame).format { + f if f == ff::AVSampleFormat::AV_SAMPLE_FMT_FLTP as c_int => { + // native aac: planar float + for i in 0..ns { + for c in 0..nch { + let plane = data[c] as *const f32; + out.push(f_to_s16(*plane.add(i))); + } + } + } + f if f == ff::AVSampleFormat::AV_SAMPLE_FMT_FLT as c_int => { + let p = data[0] as *const f32; + for i in 0..total { + out.push(f_to_s16(*p.add(i))); + } + } + f if f == ff::AVSampleFormat::AV_SAMPLE_FMT_S16P as c_int => { + for i in 0..ns { + for c in 0..nch { + let plane = data[c] as *const i16; + out.push(*plane.add(i)); + } + } + } + f if f == ff::AVSampleFormat::AV_SAMPLE_FMT_S16 as c_int => { + let p = data[0] as *const i16; + for i in 0..total { + out.push(*p.add(i)); + } + } + _ => return None, + } + Some(total) + } + } +} + +impl Drop for EldDecoder { + fn drop(&mut self) { + unsafe { self.free() }; + } +} diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs new file mode 100644 index 000000000..c4d44f733 --- /dev/null +++ b/linux-rust/src/audio/hires_mic.rs @@ -0,0 +1,123 @@ +//! Hi-res microphone lifecycle: ties together the AACP 0x58 control stream, the +//! AAC-ELD decoder, and the PipeWire output. + +use std::thread::JoinHandle; +use std::time::Duration; + +use log::{error, info, warn}; +use tokio::sync::mpsc; + +use crate::audio::eld::{ELD_CHANNELS, ELD_FRAME_SAMPLES, ELD_SAMPLE_RATE, EldDecoder}; +use crate::audio::output::{self, Output}; +use crate::bluetooth::aacp::AACPManager; +use crate::bluetooth::aacp_audio; + +/// Delay after sending 0x58 START before resetting the A2DP transport +const A2DP_RESET_DELAY: Duration = Duration::from_millis(800); + +pub struct HiResMic { + addr: String, + decode_thread: Option>, +} + +impl HiResMic { + // Start the proprietary hi-res mic stream: + // - build the decoder + output + // - setup audio routing + // - spawn the decode thread + // - send 0x58 START + // - schedule the A2DP transport reset + pub async fn start(aacp: &AACPManager, addr: String) -> Option { + let decoder = EldDecoder::new()?; + let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; + + // Arm recv routing BEFORE the device starts emitting audio. + let rx = aacp.take_audio_channel().await; + let decode_thread = spawn_decode_thread(rx, decoder, output); + + if let Err(e) = aacp.send_start_audio().await { + error!("failed to send 0x58 START: {}", e); + // Tear the just-spawned thread back down. + aacp.clear_audio_channel().await; + return None; + } + info!("[aacp] microphone stream started"); + + let reset_addr = addr.clone(); + tokio::spawn(async move { + tokio::time::sleep(A2DP_RESET_DELAY).await; + let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&reset_addr)).await; + }); + + Some(HiResMic { + addr, + decode_thread: Some(decode_thread), + }) + } + + // Teardown: + // - 0x58 STOP + // - join the thread + // - A2DP transport reset + pub async fn stop(mut self, aacp: &AACPManager) { + if let Err(e) = aacp.send_stop_audio().await { + warn!("failed to send 0x58 STOP: {}", e); + } + aacp.clear_audio_channel().await; + + if let Some(handle) = self.decode_thread.take() { + let _ = tokio::task::spawn_blocking(move || handle.join()).await; + } + + let addr = std::mem::take(&mut self.addr); + let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&addr)).await; + } +} + +// The decode loop: +// - drain raw 0x58 SDUs +// - demux to AUs +// - decode to PCM +// - write to the virtual sink +// Runs on a seperate thread +fn spawn_decode_thread( + mut rx: mpsc::Receiver>, + mut decoder: EldDecoder, + mut output: Output, +) -> JoinHandle<()> { + std::thread::Builder::new() + .name("hires-decode".into()) + .spawn(move || { + let mut frames: u64 = 0; + let mut errors: u64 = 0; + let mut pcm: Vec = Vec::with_capacity(4096); + + while let Some(sdu) = rx.blocking_recv() { + pcm.clear(); + // Decode every AU in this SDU, then issue a single PCM write. + aacp_audio::demux_type58(&sdu, |au| match decoder.decode(au, &mut pcm) { + Some(_) => frames += 1, + None => errors += 1, + }); + + if !pcm.is_empty() && output.write(&pcm).is_err() { + warn!("hi-res output broke; stopping decode loop"); + break; + } + + if frames > 0 && frames % 400 == 0 { + let secs = frames as f64 * ELD_FRAME_SAMPLES as f64 / ELD_SAMPLE_RATE as f64; + info!( + "[audio] {} frames ({:.0}s), {} errors", + frames, secs, errors + ); + } + } + info!( + "[audio] hi-res decode loop ended ({} frames, {} errors)", + frames, errors + ); + // `output` drops here -> virtual sink/source unloaded. + }) + .expect("failed to spawn hires-decode thread") +} diff --git a/linux-rust/src/audio/mod.rs b/linux-rust/src/audio/mod.rs new file mode 100644 index 000000000..f3969a482 --- /dev/null +++ b/linux-rust/src/audio/mod.rs @@ -0,0 +1,9 @@ +//! Hi-res microphone audio pipeline: AAC-ELD decode and PCM output. +//! +//! Ported from the reference daemon at +//! `airpods-highres-bidirectional/daemon/src` (`eld.c`, `output.c`). + +pub mod agc; +pub mod eld; +pub mod hires_mic; +pub mod output; diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs new file mode 100644 index 000000000..784370136 --- /dev/null +++ b/linux-rust/src/audio/output.rs @@ -0,0 +1,253 @@ +//! PipeWire/PulseAudio output for the hi-res microphone: a private null-sink +//! fed by a playback stream, exposed as a real input via a remap-source + +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::{Context, FlagSet as ContextFlagSet}; +use libpulse_binding::def::{BufferAttr, Retval}; +use libpulse_binding::mainloop::standard::{IterateResult, Mainloop}; +use libpulse_binding::operation::State as OperationState; +use libpulse_binding::sample::{Format, Spec}; +use libpulse_binding::stream::Direction; +use libpulse_simple_binding::Simple; +use log::{error, info, warn}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use crate::audio::agc::Agc; + +const SINK_NAME: &str = "AirPodsHiRes_raw"; +const SOURCE_NAME: &str = "AirPodsHiRes"; + +pub struct Output { + simple: Option, + agc: Agc, + sink_module: u32, + source_module: u32, +} + +unsafe impl Send for Output {} + +impl Output { + // Create the virtual mic (null-sink + remap-source) and open the playback + // stream into it. Returns None on failure. + pub fn open(sample_rate: u32, channels: u8) -> Option { + unload_stale_modules(); + + let chan_map = if channels == 1 { + "mono" + } else { + "front-left,front-right" + }; + + let sink_args = format!( + "sink_name={SINK_NAME} channel_map={chan_map} \ + sink_properties=\"device.description=AirPods_HiRes_Sink node.driver=false priority.driver=0\"" + ); + let sink_module = match load_module("module-null-sink", &sink_args) { + Some(i) => i, + None => { + warn!("could not load module-null-sink"); + return None; + } + }; + + let source_args = format!( + "master={SINK_NAME}.monitor source_name={SOURCE_NAME} channel_map={chan_map} \ + source_properties=\"device.description=AirPods_HiRes_Mic node.driver=false priority.driver=0\"" + ); + let source_module = match load_module("module-remap-source", &source_args) { + Some(i) => i, + None => { + warn!("could not load module-remap-source"); + unload_module(sink_module); + return None; + } + }; + + let spec = Spec { + format: Format::S16le, + channels, + rate: sample_rate, + }; + if !spec.is_valid() { + error!("invalid sample spec: {} Hz, {} ch", sample_rate, channels); + unload_module(source_module); + unload_module(sink_module); + return None; + } + + let tlength = (sample_rate * channels as u32 * 2 / 100).max(1); + let attr = BufferAttr { + maxlength: u32::MAX, + tlength, + prebuf: u32::MAX, + minreq: u32::MAX, + fragsize: u32::MAX, + }; + + let simple = match Simple::new( + None, + "LibrePods", + Direction::Playback, + Some(SINK_NAME), + "AirPodsHiRes", + &spec, + None, + Some(&attr), + ) { + Ok(s) => s, + Err(e) => { + error!("could not open hi-res playback stream: {}", e); + unload_module(source_module); + unload_module(sink_module); + return None; + } + }; + + info!( + "[pw] hi-res mic ready: select '{}' as your microphone", + SOURCE_NAME + ); + Some(Output { + simple: Some(simple), + agc: Agc::new(), + sink_module, + source_module, + }) + } + + // Write interleaved s16 PCM into the sink + pub fn write(&mut self, pcm: &[i16]) -> Result<(), ()> { + let mut pcm = pcm.to_vec(); + + self.agc.process(&mut pcm); + + let bytes = unsafe { + std::slice::from_raw_parts( + pcm.as_ptr() as *const u8, + std::mem::size_of_val(pcm.as_slice()), + ) + }; + + match &self.simple { + Some(s) => s.write(bytes).map_err(|e| { + error!("hi-res playback stream broke: {}", e); + }), + None => Err(()), + } + } +} + +impl Drop for Output { + fn drop(&mut self) { + self.simple.take(); + unload_module(self.source_module); + unload_module(self.sink_module); + } +} + +// A2DP transport reset: +// We found that in some cases A2DP has to be suspended and resumed after a 0x58 mic start/stop +// to avoid a corrupted transport state of the airpods. +pub fn reset_a2dp(bdaddr: &str) { + let sink = format!("bluez_output.{}.1", bdaddr.replace(':', "_")); + let Some((mut mainloop, mut context)) = connect() else { + return; + }; + info!("[pw] suspend/resume {} to reset A2DP transport", sink); + let mut introspect = context.introspect(); + + let op = introspect.suspend_sink_by_name(&sink, true, None); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + let op = introspect.suspend_sink_by_name(&sink, false, None); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); +} + +fn connect() -> Option<(Mainloop, Context)> { + let mut mainloop = Mainloop::new()?; + let mut context = Context::new(&mainloop, "LibrePods-HiResMic")?; + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .ok()?; + loop { + match mainloop.iterate(false) { + IterateResult::Quit(_) | IterateResult::Err(_) => return None, + IterateResult::Success(_) => {} + } + match context.get_state() { + libpulse_binding::context::State::Ready => break, + libpulse_binding::context::State::Failed + | libpulse_binding::context::State::Terminated => return None, + _ => {} + } + } + Some((mainloop, context)) +} + +fn unload_stale_modules() { + let Some((mut mainloop, context)) = connect() else { + return; + }; + let stale: Rc>> = Rc::new(RefCell::new(Vec::new())); + let introspect = context.introspect(); + let op = introspect.get_module_info_list({ + let stale = stale.clone(); + move |result| { + if let ListResult::Item(item) = result { + if let Some(arg) = &item.argument { + if arg.contains(SINK_NAME) || arg.contains(SOURCE_NAME) { + stale.borrow_mut().push(item.index); + } + } + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + for index in stale.borrow().iter() { + warn!("[pw] unloading stale hi-res module {}", index); + unload_module(*index); + } +} + +fn load_module(name: &str, args: &str) -> Option { + let (mut mainloop, mut context) = connect()?; + let idx: Rc> = Rc::new(Cell::new(u32::MAX)); + let mut introspect = context.introspect(); + let op = introspect.load_module(name, args, { + let idx = idx.clone(); + move |index| idx.set(index) + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + match idx.get() { + u32::MAX => None, + i => Some(i), + } +} + +fn unload_module(index: u32) { + if index == u32::MAX { + return; + } + if let Some((mut mainloop, mut context)) = connect() { + let mut introspect = context.introspect(); + let op = introspect.unload_module(index, |_| {}); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + } +} diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index bed6ca853..f94b221ab 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -20,6 +20,25 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const POLL_INTERVAL: Duration = Duration::from_millis(200); const HEADER_BYTES: [u8; 4] = [0x04, 0x00, 0x04, 0x00]; +/// L2CAP recv buffer. 0x58 hi-res audio SDUs can exceed 1 KB; SOCK_SEQPACKET +/// silently truncates an undersized buffer, so this must comfortably exceed the +/// largest SDU +const RECV_BUF_LEN: usize = 4096; + +/// 0x58 microphone-stream control packets (include the 04 00 04 00 header). +const AACP_START_AUDIO: [u8; 19] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x01, 0x82, 0x00, 0x00, 0x00, + 0x04, 0x96, 0x00, +]; +const AACP_STOP_AUDIO: [u8; 12] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x01, +]; + +/// Bound for the audio SDU forwarding channel. Realtime: if the decoder falls +/// behind we drop SDUs (a brief glitch) rather than back-pressure the L2CAP +/// recv loop, which would stall control traffic too. +const AUDIO_CHANNEL_CAP: usize = 256; + pub mod opcodes { pub const SET_FEATURE_FLAGS: u8 = 0x4D; pub const REQUEST_NOTIFICATIONS: u8 = 0x0F; @@ -330,6 +349,8 @@ pub struct AACPManagerState { event_tx: Option>, pub devices: HashMap, pub airpods_mac: Option
, + /// When set, recv_thread forwards raw 0x58 audio SDUs here (hi-res mic). + pub audio_tx: Option>>, } impl AACPManagerState { @@ -353,6 +374,7 @@ impl AACPManagerState { event_tx: None, devices, airpods_mac: None, + audio_tx: None, } } } @@ -361,6 +383,8 @@ impl AACPManagerState { pub struct AACPManager { pub state: Arc>, tasks: Arc>>, + /// Active hi-res microphone capture, if any (proprietary 0x58 stream). + hires_mic: Arc>>, } impl AACPManager { @@ -368,6 +392,32 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), + hires_mic: Arc::new(Mutex::new(None)), + } + } + + /// Start the proprietary hi-res microphone capture. No-op if already active. + pub async fn start_hires_mic(&self) { + let mut guard = self.hires_mic.lock().await; + if guard.is_some() { + return; + } + let addr = self.state.lock().await.airpods_mac.map(|a| a.to_string()); + let Some(addr) = addr else { + error!("Cannot start hi-res mic: no AirPods address known"); + return; + }; + match crate::audio::hires_mic::HiResMic::start(self, addr).await { + Some(mic) => *guard = Some(mic), + None => error!("Failed to start hi-res microphone"), + } + } + + /// Stop the hi-res microphone capture. No-op if not active. + pub async fn stop_hires_mic(&self) { + let mic = self.hires_mic.lock().await.take(); + if let Some(mic) = mic { + mic.stop(self).await; } } @@ -921,6 +971,32 @@ impl AACPManager { } } + /// Create the channel that recv_thread forwards 0x58 audio SDUs to, and + /// return the receiving half for the hi-res decode task. Replaces any + /// previously installed channel. + pub async fn take_audio_channel(&self) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(AUDIO_CHANNEL_CAP); + let mut state = self.state.lock().await; + state.audio_tx = Some(tx); + rx + } + + /// Stop forwarding audio SDUs + pub async fn clear_audio_channel(&self) { + let mut state = self.state.lock().await; + state.audio_tx = None; + } + + /// Start the proprietary hi-res microphone stream (0x58 START). + pub async fn send_start_audio(&self) -> Result<()> { + self.send_packet(&AACP_START_AUDIO).await + } + + /// Stop the proprietary hi-res microphone stream (0x58 STOP). + pub async fn send_stop_audio(&self) -> Result<()> { + self.send_packet(&AACP_STOP_AUDIO).await + } + pub async fn send_notification_request(&self) -> Result<()> { let opcode = [opcodes::REQUEST_NOTIFICATIONS, 0x00]; let data = [0xFF, 0xFF, 0xFF, 0xFF]; @@ -1203,7 +1279,7 @@ impl AACPManager { } async fn recv_thread(manager: AACPManager, sp: Arc) { - let mut buf = vec![0u8; 1024]; + let mut buf = vec![0u8; RECV_BUF_LEN]; loop { match sp.recv(&mut buf).await { Ok(0) => { @@ -1212,6 +1288,19 @@ async fn recv_thread(manager: AACPManager, sp: Arc) { } Ok(n) => { let data = &buf[..n]; + + // Forward audio SDUs to the audio thread, leaving control SDUs for the control thread. + if crate::bluetooth::aacp_audio::is_audio(data) { + let audio_tx = { + let state = manager.state.lock().await; + state.audio_tx.clone() + }; + if let Some(tx) = audio_tx { + let _ = tx.try_send(data.to_vec()); + } + continue; + } + debug!("Received {} bytes: {}", n, hex::encode(data)); manager.receive_packet(data).await; } @@ -1236,20 +1325,20 @@ async fn send_thread(mut rx: mpsc::Receiver>, sp: Arc) { while let Some(data) = rx.recv().await { let mut attempts = 0; loop { - match sp.send(&data).await { - Ok(_) => { - debug!("Sent {} bytes: {}", data.len(), hex::encode(&data)); - break; - } - Err(e) if e.raw_os_error() == Some(107) && attempts < 10 => { - attempts += 1; - sleep(Duration::from_millis(100)).await; - } - Err(e) => { - error!("Failed to send data: {}", e); - return; + match sp.send(&data).await { + Ok(_) => { + debug!("Sent {} bytes: {}", data.len(), hex::encode(&data)); + break; + } + Err(e) if e.raw_os_error() == Some(107) && attempts < 10 => { + attempts += 1; + sleep(Duration::from_millis(100)).await; + } + Err(e) => { + error!("Failed to send data: {}", e); + return; + } } - } } } info!("Send thread finished."); diff --git a/linux-rust/src/bluetooth/aacp_audio.rs b/linux-rust/src/bluetooth/aacp_audio.rs new file mode 100644 index 000000000..e4d63d7fe --- /dev/null +++ b/linux-rust/src/bluetooth/aacp_audio.rs @@ -0,0 +1,41 @@ +//! AACP framing for the AirPods proprietary hi-res microphone stream +//! (AAC-ELD @ 64 kHz, message_type 0x58 over L2CAP PSM 0x1001). + +/// type-0x58 audio SDU layout: 22-byte header, then N x [ts:u32 LE][len:u8][au]. +const TYPE58_HEADER_LEN: usize = 22; + +#[inline] +fn u16le(b: &[u8], off: usize) -> u16 { + (b[off] as u16) | ((b[off + 1] as u16) << 8) +} + +/// Predicate for 0x58 *audio* frames (subtype 0x0001) +#[inline] +pub fn is_audio(sdu: &[u8]) -> bool { + sdu.len() >= 8 + && sdu[0] == 0x04 + && sdu[2] == 0x04 + && u16le(sdu, 4) == 0x58 + && u16le(sdu, 6) == 0x0001 +} + +/// Walk the sub-frames of one 0x58 audio SDU, invoking `emit` per AAC-ELD AU. +/// Returns the number of AUs emitted +pub fn demux_type58(sdu: &[u8], mut emit: impl FnMut(&[u8])) -> usize { + let mut off = TYPE58_HEADER_LEN; + let mut n = 0; + + while off + 5 <= sdu.len() { + let au_len = sdu[off + 4] as usize; + let start = off + 5; + let end = start + au_len; + + if end > sdu.len() { + break; + } + emit(&sdu[start..end]); + n += 1; + off = end; + } + n +} diff --git a/linux-rust/src/bluetooth/mod.rs b/linux-rust/src/bluetooth/mod.rs index dfd520770..a4813ea45 100644 --- a/linux-rust/src/bluetooth/mod.rs +++ b/linux-rust/src/bluetooth/mod.rs @@ -1,4 +1,5 @@ pub mod aacp; +pub mod aacp_audio; pub mod att; pub(crate) mod discovery; pub mod le; diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs index 5768d1802..10d3b2dc6 100644 --- a/linux-rust/src/devices/enums.rs +++ b/linux-rust/src/devices/enums.rs @@ -57,6 +57,8 @@ pub struct AirPodsState { pub conversation_awareness_enabled: bool, pub personalized_volume_enabled: bool, pub allow_off_mode: bool, + /// Local capture state of the proprietary hi-res microphone (UI-driven). + pub hires_mic_enabled: bool, pub battery: Vec, } diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 34f46a917..be21e6eff 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -1,3 +1,4 @@ +mod audio; mod bluetooth; mod devices; mod media_controller; @@ -22,6 +23,7 @@ use ksni::TrayMethods; use log::{debug, info, warn}; use std::collections::HashMap; use std::env; +use std::time::Duration; use std::sync::atomic::{AtomicBool}; use std::sync::Arc; use tokio::sync::RwLock; @@ -187,6 +189,7 @@ async fn async_main( let airpods_device = AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await; + let hires_aacp = airpods_device.aacp_manager.clone(); let mut managers = device_managers.write().await; // let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone()); let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone()); @@ -200,6 +203,14 @@ async fn async_main( )) { warn!("Failed to send DeviceConnected UI message: {:?}", e); } + // LIBREPODS_HIRES_MIC=1: auto-start the proprietary hi-res mic on + // connect (useful headless and for verification without the GUI). + if env::var("LIBREPODS_HIRES_MIC").is_ok() { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + hires_aacp.start_hires_mic().await; + }); + } } Err(_) => { info!("No connected AirPods found."); diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 9335b5e05..54a61d8ac 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -362,6 +362,59 @@ pub fn airpods_view<'a>( ) }; + let hires_mic_toggle = { + let aacp_manager_mic = aacp_manager.clone(); + let mac = mac.clone(); + let state = state.clone(); + container(row![ + column![ + text("Hi-Res Microphone").size(16), + text("Captures the AirPods' proprietary 64 kHz AAC-ELD uplink and exposes it as an 'AirPodsHiRes' input. Higher quality than the standard HFP mic.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(state.hires_mic_enabled) + .on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_mic.clone(); + run_async_in_thread(async move { + if is_enabled { + aacp_manager.start_hires_mic().await; + } else { + aacp_manager.stop_hires_mic().await; + } + }); + let mut state = state.clone(); + state.hires_mic_enabled = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(8) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + }; + let mut information_col = column![]; if let Some(device) = devices_list.get(mac_information.as_str()) { if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { @@ -516,6 +569,8 @@ pub fn airpods_view<'a>( Space::new().height(Length::from(20)), off_listening_mode_toggle, Space::new().height(Length::from(20)), + hires_mic_toggle, + Space::new().height(Length::from(20)), information_col ]) .padding(20) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..3f5ffb5c3 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -382,6 +382,7 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), + hires_mic_enabled: false, })); } Some(DeviceType::Nothing) => { From 220d20dd9897280ad8e32207b75ab22cd5b7e1cf Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Fri, 26 Jun 2026 19:34:42 +0200 Subject: [PATCH 03/14] Add audio level meter (UI change) for Hi-Res microphone --- linux-rust/src/audio/hires_mic.rs | 54 +++++++++++++++++++++---- linux-rust/src/audio/mod.rs | 5 --- linux-rust/src/audio/output.rs | 16 +++++--- linux-rust/src/bluetooth/aacp.rs | 9 ++++- linux-rust/src/ui/airpods.rs | 67 +++++++++++++++++++++++++++++-- linux-rust/src/ui/window.rs | 18 ++++++++- 6 files changed, 146 insertions(+), 23 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index c4d44f733..00dbef20f 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -1,6 +1,8 @@ //! Hi-res microphone lifecycle: ties together the AACP 0x58 control stream, the //! AAC-ELD decoder, and the PipeWire output. +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; use std::thread::JoinHandle; use std::time::Duration; @@ -15,6 +17,25 @@ use crate::bluetooth::aacp_audio; /// Delay after sending 0x58 START before resetting the A2DP transport const A2DP_RESET_DELAY: Duration = Duration::from_millis(800); +const LEVEL_RELEASE: f32 = 0.85; + +#[derive(Clone, Default)] +pub struct MicLevel(Arc); + +impl MicLevel { + pub fn new() -> Self { + Self(Arc::new(AtomicU32::new(0))) + } + + pub fn set(&self, level: f32) { + self.0.store(level.to_bits(), Ordering::Relaxed); + } + + pub fn get(&self) -> f32 { + f32::from_bits(self.0.load(Ordering::Relaxed)) + } +} + pub struct HiResMic { addr: String, decode_thread: Option>, @@ -27,13 +48,13 @@ impl HiResMic { // - spawn the decode thread // - send 0x58 START // - schedule the A2DP transport reset - pub async fn start(aacp: &AACPManager, addr: String) -> Option { + pub async fn start(aacp: &AACPManager, addr: String, level: MicLevel) -> Option { let decoder = EldDecoder::new()?; let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; // Arm recv routing BEFORE the device starts emitting audio. let rx = aacp.take_audio_channel().await; - let decode_thread = spawn_decode_thread(rx, decoder, output); + let decode_thread = spawn_decode_thread(rx, decoder, output, level); if let Err(e) = aacp.send_start_audio().await { error!("failed to send 0x58 START: {}", e); @@ -84,12 +105,14 @@ fn spawn_decode_thread( mut rx: mpsc::Receiver>, mut decoder: EldDecoder, mut output: Output, + level: MicLevel, ) -> JoinHandle<()> { std::thread::Builder::new() .name("hires-decode".into()) .spawn(move || { let mut frames: u64 = 0; let mut errors: u64 = 0; + let mut env: f32 = 0.0; let mut pcm: Vec = Vec::with_capacity(4096); while let Some(sdu) = rx.blocking_recv() { @@ -100,19 +123,34 @@ fn spawn_decode_thread( None => errors += 1, }); - if !pcm.is_empty() && output.write(&pcm).is_err() { - warn!("hi-res output broke; stopping decode loop"); - break; - } + let peak = if pcm.is_empty() { + 0.0 + } else { + match output.write(&pcm) { + Ok(peak) => peak, + Err(()) => { + warn!("hi-res output broke; stopping decode loop"); + break; + } + } + }; + + env = if peak >= env { + peak + } else { + env * LEVEL_RELEASE + }; + level.set(env); if frames > 0 && frames % 400 == 0 { let secs = frames as f64 * ELD_FRAME_SAMPLES as f64 / ELD_SAMPLE_RATE as f64; info!( - "[audio] {} frames ({:.0}s), {} errors", - frames, secs, errors + "[audio] {} frames ({:.0}s), {} errors, level {:.2}", + frames, secs, errors, env ); } } + level.set(0.0); info!( "[audio] hi-res decode loop ended ({} frames, {} errors)", frames, errors diff --git a/linux-rust/src/audio/mod.rs b/linux-rust/src/audio/mod.rs index f3969a482..0265b981b 100644 --- a/linux-rust/src/audio/mod.rs +++ b/linux-rust/src/audio/mod.rs @@ -1,8 +1,3 @@ -//! Hi-res microphone audio pipeline: AAC-ELD decode and PCM output. -//! -//! Ported from the reference daemon at -//! `airpods-highres-bidirectional/daemon/src` (`eld.c`, `output.c`). - pub mod agc; pub mod eld; pub mod hires_mic; diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 784370136..4df91f657 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -116,12 +116,17 @@ impl Output { }) } - // Write interleaved s16 PCM into the sink - pub fn write(&mut self, pcm: &[i16]) -> Result<(), ()> { + // Write s16 PCM into the sink + pub fn write(&mut self, pcm: &[i16]) -> Result { let mut pcm = pcm.to_vec(); self.agc.process(&mut pcm); + let peak = pcm + .iter() + .map(|&s| (s as f32 / 32768.0).abs()) + .fold(0.0f32, f32::max); + let bytes = unsafe { std::slice::from_raw_parts( pcm.as_ptr() as *const u8, @@ -130,9 +135,10 @@ impl Output { }; match &self.simple { - Some(s) => s.write(bytes).map_err(|e| { - error!("hi-res playback stream broke: {}", e); - }), + Some(s) => s + .write(bytes) + .map(|_| peak) + .map_err(|e| error!("hi-res playback stream broke: {}", e)), None => Err(()), } } diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index f94b221ab..1b64a3440 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -385,6 +385,7 @@ pub struct AACPManager { tasks: Arc>>, /// Active hi-res microphone capture, if any (proprietary 0x58 stream). hires_mic: Arc>>, + mic_level: crate::audio::hires_mic::MicLevel, } impl AACPManager { @@ -393,9 +394,14 @@ impl AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), hires_mic: Arc::new(Mutex::new(None)), + mic_level: crate::audio::hires_mic::MicLevel::new(), } } + pub fn mic_level(&self) -> f32 { + self.mic_level.get() + } + /// Start the proprietary hi-res microphone capture. No-op if already active. pub async fn start_hires_mic(&self) { let mut guard = self.hires_mic.lock().await; @@ -407,7 +413,7 @@ impl AACPManager { error!("Cannot start hi-res mic: no AirPods address known"); return; }; - match crate::audio::hires_mic::HiResMic::start(self, addr).await { + match crate::audio::hires_mic::HiResMic::start(self, addr, self.mic_level.clone()).await { Some(mic) => *guard = Some(mic), None => error!("Failed to start hi-res microphone"), } @@ -419,6 +425,7 @@ impl AACPManager { if let Some(mic) = mic { mic.stop(self).await; } + self.mic_level.set(0.0); } pub async fn connect(&mut self, addr: Address) { diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 54a61d8ac..e392aa8e1 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -366,7 +366,10 @@ pub fn airpods_view<'a>( let aacp_manager_mic = aacp_manager.clone(); let mac = mac.clone(); let state = state.clone(); - container(row![ + let mic_enabled = state.hires_mic_enabled; + let level = aacp_manager.mic_level().clamp(0.0, 1.0); + + let header = row![ column![ text("Hi-Res Microphone").size(16), text("Captures the AirPods' proprietary 64 kHz AAC-ELD uplink and exposes it as an 'AirPodsHiRes' input. Higher quality than the standard HFP mic.").size(12).style( @@ -395,8 +398,14 @@ pub fn airpods_view<'a>( .size(20) ] .align_y(Center) - .spacing(8) - ) + .spacing(8); + + let mut content = column![header].spacing(10); + if mic_enabled { + content = content.push(level_meter(level)); + } + + container(content) .padding(Padding{ top: 5.0, bottom: 5.0, @@ -578,6 +587,58 @@ pub fn airpods_view<'a>( .height(Length::Fill) } +fn level_meter<'a>(level: f32) -> iced::widget::Container<'a, Message> { + let filled = (level * 1000.0).round() as u16; + let rest = 1000u16.saturating_sub(filled); + let hot = level >= 0.9; + + let bar = container( + row![ + container(Space::new()) + .width(Length::FillPortion(filled)) + .height(Length::Fill) + .style(move |theme: &Theme| { + let mut s = container::Style::default(); + let color = if hot { + theme.palette().warning + } else { + theme.palette().success + }; + s.background = Some(Background::Color(color)); + s.border = Border::default().rounded(4); + s + }), + container(Space::new()).width(Length::FillPortion(rest)), + ] + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::from(10)) + .style(|theme: &Theme| { + let mut s = container::Style::default(); + s.background = Some(Background::Color(theme.palette().text.scale_alpha(0.12))); + s.border = Border::default().rounded(4); + s + }); + + container( + column![ + text("Input level").size(12).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + }), + bar, + ] + .spacing(4), + ) + .width(Length::Fill) + .padding(Padding { + bottom: 6.0, + ..Padding::ZERO + }) +} + fn run_async_in_thread(fut: F) where F: Future + Send + 'static, diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 3f5ffb5c3..3ebde6efb 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -108,6 +108,7 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), + MicLevelTick, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -263,6 +264,7 @@ impl App { Task::none() } Message::CopyToClipboard(data) => iced::clipboard::write(data), + Message::MicLevelTick => Task::none(), Message::BluetoothMessage(ui_message) => { match ui_message { BluetoothUIMessage::NoOp => { @@ -1302,7 +1304,21 @@ impl App { } fn subscription(&self) -> Subscription { - window::close_events().map(Message::WindowClosed) + let close = window::close_events().map(Message::WindowClosed); + + // Only tick while a hi-res mic is capturing. + let mic_active = self + .device_states + .values() + .any(|s| matches!(s, DeviceState::AirPods(a) if a.hires_mic_enabled)); + + if mic_active { + let tick = iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::MicLevelTick); + Subscription::batch([close, tick]) + } else { + close + } } } From e9bfac4c86859db0a04a7754e2d6ff5e660ac580 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Sun, 28 Jun 2026 20:41:24 +0200 Subject: [PATCH 04/14] Added ffmpeg headers to ci-linux-rust workflow --- .github/workflows/ci-linux-rust.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-linux-rust.yml b/.github/workflows/ci-linux-rust.yml index 1753132fe..092ce1eef 100644 --- a/.github/workflows/ci-linux-rust.yml +++ b/.github/workflows/ci-linux-rust.yml @@ -21,7 +21,8 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2 + sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2 \ + libavutil-dev libavcodec-dev libavformat-dev libavfilter-dev libavdevice-dev libswresample-dev libswscale-dev - name: Install AppImage tools run: | From a7aa069585aa2e36c9bc5adfb129305f2a23e396 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Tue, 30 Jun 2026 13:18:54 +0200 Subject: [PATCH 05/14] Implement activity monitor for hi-res mic The capture stream and decoder thread are now started or stopped automatically based on whether an application is actively recording from the persistent virtual input device. --- linux-rust/src/audio/hires_mic.rs | 215 ++++++++++++++++++++---------- linux-rust/src/audio/output.rs | 114 +++++++++++----- linux-rust/src/bluetooth/aacp.rs | 62 +++++++-- linux-rust/src/main.rs | 7 +- linux-rust/src/ui/airpods.rs | 21 +-- linux-rust/src/ui/window.rs | 2 +- 6 files changed, 286 insertions(+), 135 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 00dbef20f..2c4313ad5 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -1,111 +1,188 @@ -//! Hi-res microphone lifecycle: ties together the AACP 0x58 control stream, the -//! AAC-ELD decoder, and the PipeWire output. +//! Hi-res microphone lifecycle: a persistent virtual input device plus a monitor +//! that runs the AACP 0x58 capture only while an app is recording from it. use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::thread::JoinHandle; use std::time::Duration; use log::{error, info, warn}; +use tokio::sync::Notify; use tokio::sync::mpsc; +use tokio::task::JoinHandle as TokioHandle; use crate::audio::eld::{ELD_CHANNELS, ELD_FRAME_SAMPLES, ELD_SAMPLE_RATE, EldDecoder}; -use crate::audio::output::{self, Output}; +use crate::audio::output::{self, Output, VirtualMic}; use crate::bluetooth::aacp::AACPManager; use crate::bluetooth::aacp_audio; /// Delay after sending 0x58 START before resetting the A2DP transport const A2DP_RESET_DELAY: Duration = Duration::from_millis(800); - +/// How often the monitor polls the virtual source for activity +const POLL_INTERVAL: Duration = Duration::from_millis(400); const LEVEL_RELEASE: f32 = 0.85; #[derive(Clone, Default)] -pub struct MicLevel(Arc); +pub struct MicStatus { + level: Arc, + active: Arc, + app: Arc>>, +} -impl MicLevel { +impl MicStatus { pub fn new() -> Self { - Self(Arc::new(AtomicU32::new(0))) + Self::default() + } + + pub fn set_level(&self, level: f32) { + self.level.store(level.to_bits(), Ordering::Relaxed); + } + + pub fn level(&self) -> f32 { + f32::from_bits(self.level.load(Ordering::Relaxed)) } - pub fn set(&self, level: f32) { - self.0.store(level.to_bits(), Ordering::Relaxed); + pub fn active(&self) -> bool { + self.active.load(Ordering::Relaxed) } - pub fn get(&self) -> f32 { - f32::from_bits(self.0.load(Ordering::Relaxed)) + pub fn app(&self) -> Option { + self.app.lock().unwrap().clone() + } + + fn set_capture(&self, app: Option) { + self.active.store(app.is_some(), Ordering::Relaxed); + *self.app.lock().unwrap() = app; + } + + fn reset(&self) { + self.active.store(false, Ordering::Relaxed); + *self.app.lock().unwrap() = None; + self.set_level(0.0); } } pub struct HiResMic { - addr: String, - decode_thread: Option>, + _vmic: VirtualMic, + stop: Arc, + monitor: Option>, } impl HiResMic { - // Start the proprietary hi-res mic stream: - // - build the decoder + output - // - setup audio routing - // - spawn the decode thread - // - send 0x58 START - // - schedule the A2DP transport reset - pub async fn start(aacp: &AACPManager, addr: String, level: MicLevel) -> Option { - let decoder = EldDecoder::new()?; - let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; - - // Arm recv routing BEFORE the device starts emitting audio. - let rx = aacp.take_audio_channel().await; - let decode_thread = spawn_decode_thread(rx, decoder, output, level); - - if let Err(e) = aacp.send_start_audio().await { - error!("failed to send 0x58 START: {}", e); - // Tear the just-spawned thread back down. - aacp.clear_audio_channel().await; - return None; - } - info!("[aacp] microphone stream started"); - - let reset_addr = addr.clone(); - tokio::spawn(async move { - tokio::time::sleep(A2DP_RESET_DELAY).await; - let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&reset_addr)).await; - }); - + // Create the persistent virtual input and spawn the activity monitor. + pub async fn start(aacp: &AACPManager, addr: String, status: MicStatus) -> Option { + let vmic = VirtualMic::open(ELD_CHANNELS as u8)?; + let stop = Arc::new(Notify::new()); + // Spawn on the backend runtime, not the caller's (the UI toggle uses a + // throwaway runtime that would abort this task immediately). + let monitor = aacp + .runtime() + .spawn(monitor_loop(aacp.clone(), addr, status, stop.clone())); Some(HiResMic { - addr, - decode_thread: Some(decode_thread), + _vmic: vmic, + stop, + monitor: Some(monitor), }) } - // Teardown: - // - 0x58 STOP - // - join the thread - // - A2DP transport reset - pub async fn stop(mut self, aacp: &AACPManager) { - if let Err(e) = aacp.send_stop_audio().await { - warn!("failed to send 0x58 STOP: {}", e); + // Stop the monitor (tearing down any active capture) and unload the device. + pub async fn stop(mut self) { + self.stop.notify_one(); + if let Some(monitor) = self.monitor.take() { + let _ = monitor.await; } - aacp.clear_audio_channel().await; + } +} + +// A live capture session: the playback stream feeding the sink plus its decode +// thread, started when an app opens the mic. +struct Capture { + decode_thread: Option>, +} + +async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: Arc) { + let mut capture: Option = None; + info!("[hires] activity monitor started, watching '{}'", output::SOURCE_NAME); + + loop { + tokio::select! { + _ = stop.notified() => break, + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + + let app = tokio::task::spawn_blocking(|| output::source_consumer(output::SOURCE_NAME)) + .await + .unwrap_or(None); - if let Some(handle) = self.decode_thread.take() { - let _ = tokio::task::spawn_blocking(move || handle.join()).await; + match (app.is_some(), capture.is_some()) { + (true, false) => { + info!("[hires] recorder detected ({:?}), starting capture", app); + if let Some(c) = start_capture(&aacp, &addr, &status).await { + status.set_capture(app); + capture = Some(c); + } + } + (false, true) => { + info!("[hires] recorder gone, stopping capture"); + stop_capture(capture.take().unwrap(), &aacp, &addr).await; + status.reset(); + } + (true, true) => status.set_capture(app), + _ => {} } + } + + if let Some(c) = capture.take() { + stop_capture(c, &aacp, &addr).await; + } + status.reset(); +} + +async fn start_capture(aacp: &AACPManager, addr: &str, status: &MicStatus) -> Option { + let decoder = EldDecoder::new()?; + let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; + + let rx = aacp.take_audio_channel().await; + let decode_thread = spawn_decode_thread(rx, decoder, output, status.clone()); - let addr = std::mem::take(&mut self.addr); - let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&addr)).await; + if let Err(e) = aacp.send_start_audio().await { + error!("failed to send 0x58 START: {}", e); + aacp.clear_audio_channel().await; + return None; } + info!("[aacp] microphone stream started"); + + let reset_addr = addr.to_string(); + tokio::spawn(async move { + tokio::time::sleep(A2DP_RESET_DELAY).await; + let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&reset_addr)).await; + }); + + Some(Capture { + decode_thread: Some(decode_thread), + }) +} + +async fn stop_capture(mut capture: Capture, aacp: &AACPManager, addr: &str) { + if let Err(e) = aacp.send_stop_audio().await { + warn!("failed to send 0x58 STOP: {}", e); + } + aacp.clear_audio_channel().await; + + if let Some(handle) = capture.decode_thread.take() { + let _ = tokio::task::spawn_blocking(move || handle.join()).await; + } + + let addr = addr.to_string(); + let _ = tokio::task::spawn_blocking(move || output::reset_a2dp(&addr)).await; } -// The decode loop: -// - drain raw 0x58 SDUs -// - demux to AUs -// - decode to PCM -// - write to the virtual sink -// Runs on a seperate thread fn spawn_decode_thread( mut rx: mpsc::Receiver>, mut decoder: EldDecoder, mut output: Output, - level: MicLevel, + status: MicStatus, ) -> JoinHandle<()> { std::thread::Builder::new() .name("hires-decode".into()) @@ -117,7 +194,6 @@ fn spawn_decode_thread( while let Some(sdu) = rx.blocking_recv() { pcm.clear(); - // Decode every AU in this SDU, then issue a single PCM write. aacp_audio::demux_type58(&sdu, |au| match decoder.decode(au, &mut pcm) { Some(_) => frames += 1, None => errors += 1, @@ -135,12 +211,8 @@ fn spawn_decode_thread( } }; - env = if peak >= env { - peak - } else { - env * LEVEL_RELEASE - }; - level.set(env); + env = if peak >= env { peak } else { env * LEVEL_RELEASE }; + status.set_level(env); if frames > 0 && frames % 400 == 0 { let secs = frames as f64 * ELD_FRAME_SAMPLES as f64 / ELD_SAMPLE_RATE as f64; @@ -150,12 +222,11 @@ fn spawn_decode_thread( ); } } - level.set(0.0); + status.set_level(0.0); info!( "[audio] hi-res decode loop ended ({} frames, {} errors)", frames, errors ); - // `output` drops here -> virtual sink/source unloaded. }) .expect("failed to spawn hires-decode thread") } diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 4df91f657..16b4cd0f5 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -16,21 +16,17 @@ use std::rc::Rc; use crate::audio::agc::Agc; const SINK_NAME: &str = "AirPodsHiRes_raw"; -const SOURCE_NAME: &str = "AirPodsHiRes"; +pub const SOURCE_NAME: &str = "AirPodsHiRes"; -pub struct Output { - simple: Option, - agc: Agc, +pub struct VirtualMic { sink_module: u32, source_module: u32, } -unsafe impl Send for Output {} +unsafe impl Send for VirtualMic {} -impl Output { - // Create the virtual mic (null-sink + remap-source) and open the playback - // stream into it. Returns None on failure. - pub fn open(sample_rate: u32, channels: u8) -> Option { +impl VirtualMic { + pub fn open(channels: u8) -> Option { unload_stale_modules(); let chan_map = if channels == 1 { @@ -64,6 +60,34 @@ impl Output { } }; + info!( + "[pw] hi-res mic ready: select '{}' as your microphone", + SOURCE_NAME + ); + Some(VirtualMic { + sink_module, + source_module, + }) + } +} + +impl Drop for VirtualMic { + fn drop(&mut self) { + unload_module(self.source_module); + unload_module(self.sink_module); + } +} + +// Playback stream feeding the null-sink. Opened only while an app is recording. +pub struct Output { + simple: Simple, + agc: Agc, +} + +unsafe impl Send for Output {} + +impl Output { + pub fn open(sample_rate: u32, channels: u8) -> Option { let spec = Spec { format: Format::S16le, channels, @@ -71,8 +95,6 @@ impl Output { }; if !spec.is_valid() { error!("invalid sample spec: {} Hz, {} ch", sample_rate, channels); - unload_module(source_module); - unload_module(sink_module); return None; } @@ -98,25 +120,17 @@ impl Output { Ok(s) => s, Err(e) => { error!("could not open hi-res playback stream: {}", e); - unload_module(source_module); - unload_module(sink_module); return None; } }; - info!( - "[pw] hi-res mic ready: select '{}' as your microphone", - SOURCE_NAME - ); Some(Output { - simple: Some(simple), + simple, agc: Agc::new(), - sink_module, - source_module, }) } - // Write s16 PCM into the sink + // Write s16 PCM into the sink, returning the post-AGC peak pub fn write(&mut self, pcm: &[i16]) -> Result { let mut pcm = pcm.to_vec(); @@ -134,22 +148,56 @@ impl Output { ) }; - match &self.simple { - Some(s) => s - .write(bytes) - .map(|_| peak) - .map_err(|e| error!("hi-res playback stream broke: {}", e)), - None => Err(()), - } + self.simple + .write(bytes) + .map(|_| peak) + .map_err(|e| error!("hi-res playback stream broke: {}", e)) } } -impl Drop for Output { - fn drop(&mut self) { - self.simple.take(); - unload_module(self.source_module); - unload_module(self.sink_module); +// Name of the application recording from the virtual source, or None if idle. +pub fn source_consumer(name: &str) -> Option { + let (mut mainloop, context) = connect()?; + let introspect = context.introspect(); + + let index = Rc::new(Cell::new(u32::MAX)); + let op = introspect.get_source_info_by_name(name, { + let index = index.clone(); + move |result| { + if let ListResult::Item(item) = result { + index.set(item.index); + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + + let app = Rc::new(RefCell::new(None::)); + let idx = index.get(); + if idx != u32::MAX { + let op = introspect.get_source_output_info_list({ + let app = app.clone(); + move |result| { + if let ListResult::Item(item) = result { + if item.source == idx && app.borrow().is_none() { + let label = item + .proplist + .get_str("application.name") + .or_else(|| item.name.as_ref().map(|n| n.to_string())); + app.replace(label); + } + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } } + mainloop.quit(Retval(0)); + + let result = app.borrow().clone(); + result } // A2DP transport reset: diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 1b64a3440..b15d49f79 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinSet; @@ -383,9 +384,12 @@ impl AACPManagerState { pub struct AACPManager { pub state: Arc>, tasks: Arc>>, - /// Active hi-res microphone capture, if any (proprietary 0x58 stream). + hires_enabled: Arc, hires_mic: Arc>>, - mic_level: crate::audio::hires_mic::MicLevel, + mic_status: crate::audio::hires_mic::MicStatus, + /// Handle to the long-lived backend runtime, so the hi-res monitor task + /// survives even when armed from a throwaway runtime (the UI toggle thread). + runtime: tokio::runtime::Handle, } impl AACPManager { @@ -393,39 +397,65 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), + hires_enabled: Arc::new(AtomicBool::new(true)), hires_mic: Arc::new(Mutex::new(None)), - mic_level: crate::audio::hires_mic::MicLevel::new(), + mic_status: crate::audio::hires_mic::MicStatus::new(), + runtime: tokio::runtime::Handle::current(), } } pub fn mic_level(&self) -> f32 { - self.mic_level.get() + self.mic_status.level() } - /// Start the proprietary hi-res microphone capture. No-op if already active. - pub async fn start_hires_mic(&self) { + pub fn mic_active(&self) -> bool { + self.mic_status.active() + } + + pub fn mic_app(&self) -> Option { + self.mic_status.app() + } + + pub fn runtime(&self) -> &tokio::runtime::Handle { + &self.runtime + } + + pub async fn set_hires_mic_enabled(&self, enabled: bool) { + self.hires_enabled.store(enabled, Ordering::Relaxed); + if enabled { + self.arm_hires_mic().await; + } else { + self.disarm_hires_mic().await; + } + } + + // Create the virtual device + monitor if the feature is enabled, AirPods are + // connected, and it is not already armed. Called on enable and on connect. + pub async fn arm_hires_mic(&self) { + if !self.hires_enabled.load(Ordering::Relaxed) { + return; + } let mut guard = self.hires_mic.lock().await; if guard.is_some() { return; } let addr = self.state.lock().await.airpods_mac.map(|a| a.to_string()); let Some(addr) = addr else { - error!("Cannot start hi-res mic: no AirPods address known"); return; }; - match crate::audio::hires_mic::HiResMic::start(self, addr, self.mic_level.clone()).await { + match crate::audio::hires_mic::HiResMic::start(self, addr, self.mic_status.clone()).await { Some(mic) => *guard = Some(mic), None => error!("Failed to start hi-res microphone"), } } - /// Stop the hi-res microphone capture. No-op if not active. - pub async fn stop_hires_mic(&self) { + // Tear down the virtual device + monitor without clearing the enabled flag, + // so a later reconnect re-arms. Called on disable and on disconnect. + pub async fn disarm_hires_mic(&self) { let mic = self.hires_mic.lock().await.take(); if let Some(mic) = mic { - mic.stop(self).await; + mic.stop().await; } - self.mic_level.set(0.0); } pub async fn connect(&mut self, addr: Address) { @@ -1287,6 +1317,7 @@ impl AACPManager { async fn recv_thread(manager: AACPManager, sp: Arc) { let mut buf = vec![0u8; RECV_BUF_LEN]; + manager.arm_hires_mic().await; loop { match sp.recv(&mut buf).await { Ok(0) => { @@ -1324,8 +1355,11 @@ async fn recv_thread(manager: AACPManager, sp: Arc) { } } } - let mut state = manager.state.lock().await; - state.sender = None; + { + let mut state = manager.state.lock().await; + state.sender = None; + } + manager.disarm_hires_mic().await; } async fn send_thread(mut rx: mpsc::Receiver>, sp: Arc) { diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index be21e6eff..6a77833fa 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -23,7 +23,6 @@ use ksni::TrayMethods; use log::{debug, info, warn}; use std::collections::HashMap; use std::env; -use std::time::Duration; use std::sync::atomic::{AtomicBool}; use std::sync::Arc; use tokio::sync::RwLock; @@ -203,12 +202,10 @@ async fn async_main( )) { warn!("Failed to send DeviceConnected UI message: {:?}", e); } - // LIBREPODS_HIRES_MIC=1: auto-start the proprietary hi-res mic on - // connect (useful headless and for verification without the GUI). + // LIBREPODS_HIRES_MIC=1: enable the hi-res mic feature headlessly. if env::var("LIBREPODS_HIRES_MIC").is_ok() { tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(1)).await; - hires_aacp.start_hires_mic().await; + hires_aacp.set_hires_mic_enabled(true).await; }); } } diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index e392aa8e1..7593a4b3f 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -366,7 +366,8 @@ pub fn airpods_view<'a>( let aacp_manager_mic = aacp_manager.clone(); let mac = mac.clone(); let state = state.clone(); - let mic_enabled = state.hires_mic_enabled; + let mic_active = aacp_manager.mic_active(); + let mic_app = aacp_manager.mic_app(); let level = aacp_manager.mic_level().clamp(0.0, 1.0); let header = row![ @@ -384,11 +385,7 @@ pub fn airpods_view<'a>( .on_toggle(move |is_enabled| { let aacp_manager = aacp_manager_mic.clone(); run_async_in_thread(async move { - if is_enabled { - aacp_manager.start_hires_mic().await; - } else { - aacp_manager.stop_hires_mic().await; - } + aacp_manager.set_hires_mic_enabled(is_enabled).await; }); let mut state = state.clone(); state.hires_mic_enabled = is_enabled; @@ -401,8 +398,8 @@ pub fn airpods_view<'a>( .spacing(8); let mut content = column![header].spacing(10); - if mic_enabled { - content = content.push(level_meter(level)); + if mic_active { + content = content.push(level_meter(level, mic_app)); } container(content) @@ -587,10 +584,14 @@ pub fn airpods_view<'a>( .height(Length::Fill) } -fn level_meter<'a>(level: f32) -> iced::widget::Container<'a, Message> { +fn level_meter<'a>(level: f32, app: Option) -> iced::widget::Container<'a, Message> { let filled = (level * 1000.0).round() as u16; let rest = 1000u16.saturating_sub(filled); let hot = level >= 0.9; + let label = match app { + Some(app) => format!("In use by {}", app), + None => "Input level".to_string(), + }; let bar = container( row![ @@ -623,7 +624,7 @@ fn level_meter<'a>(level: f32) -> iced::widget::Container<'a, Message> { container( column![ - text("Input level").size(12).style(|theme: &Theme| { + text(label).size(12).style(|theme: &Theme| { let mut style = text::Style::default(); style.color = Some(theme.palette().text.scale_alpha(0.7)); style diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 3ebde6efb..a1bd92a0f 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -384,7 +384,7 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), - hires_mic_enabled: false, + hires_mic_enabled: true, })); } Some(DeviceType::Nothing) => { From eb6b73f926c8529277538e8423af0d1cf712284d Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Tue, 30 Jun 2026 13:26:11 +0200 Subject: [PATCH 06/14] Refactor settings management and made the HighResMic setting persistent. Introduce a centralized AppSettings struct to handle loading and saving of application configuration. --- linux-rust/src/bluetooth/aacp.rs | 4 +- linux-rust/src/ui/window.rs | 85 +++++++++++--------------------- linux-rust/src/utils.rs | 68 ++++++++++++++++++------- 3 files changed, 82 insertions(+), 75 deletions(-) diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index b15d49f79..3bc1ee58c 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -397,7 +397,9 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), - hires_enabled: Arc::new(AtomicBool::new(true)), + hires_enabled: Arc::new(AtomicBool::new( + crate::utils::AppSettings::load().hires_mic_enabled, + )), hires_mic: Arc::new(Mutex::new(None)), mic_status: crate::audio::hires_mic::MicStatus::new(), runtime: tokio::runtime::Handle::current(), diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index a1bd92a0f..dabed2fcf 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,7 +9,7 @@ use crate::devices::enums::{ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; -use crate::utils::{MyTheme, get_app_settings_path, get_devices_path}; +use crate::utils::{AppSettings, MyTheme, get_devices_path}; use bluer::{Address}; use iced::border::Radius; use iced::overlay::menu; @@ -76,6 +76,7 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + hires_mic_enabled: bool, } pub struct BluetoothState { @@ -148,25 +149,11 @@ impl App { (Some(id), open.map(Message::WindowOpened)) }; - let app_settings_path = get_app_settings_path(); - let settings = std::fs::read_to_string(&app_settings_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()); - let selected_theme = settings - .clone() - .and_then(|v| v.get("theme").cloned()) - .and_then(|t| serde_json::from_value(t).ok()) - .unwrap_or(MyTheme::Dark); - let tray_text_mode = settings - .clone() - .and_then(|v| v.get("tray_text_mode").cloned()) - .and_then(|ttm| serde_json::from_value(ttm).ok()) - .unwrap_or(false); - let stem_control = settings - .clone() - .and_then(|v| v.get("stem_control").cloned()) - .and_then(|s| serde_json::from_value(s).ok()) - .unwrap_or(false); + let app_settings = AppSettings::load(); + let selected_theme = app_settings.theme; + let tray_text_mode = app_settings.tray_text_mode; + let stem_control = app_settings.stem_control; + let hires_mic_enabled = app_settings.hires_mic_enabled; let bluetooth_state = BluetoothState::new(); @@ -218,11 +205,22 @@ impl App { device_managers, tray_text_mode, stem_control, + hires_mic_enabled, }, Task::batch(vec![open_task, wait_task]), ) } + fn save_settings(&self) { + AppSettings { + theme: self.selected_theme, + tray_text_mode: self.tray_text_mode, + stem_control: self.stem_control, + hires_mic_enabled: self.hires_mic_enabled, + } + .save(); + } + fn title(&self, _id: window::Id) -> String { "LibrePods".to_string() } @@ -249,18 +247,7 @@ impl App { } Message::ThemeSelected(theme) => { self.selected_theme = theme; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.save_settings(); Task::none() } Message::CopyToClipboard(data) => iced::clipboard::write(data), @@ -384,7 +371,7 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), - hires_mic_enabled: true, + hires_mic_enabled: self.hires_mic_enabled, })); } Some(DeviceType::Nothing) => { @@ -595,6 +582,12 @@ impl App { Task::none() } Message::StateChanged(mac, state) => { + if let DeviceState::AirPods(a) = &state + && a.hires_mic_enabled != self.hires_mic_enabled + { + self.hires_mic_enabled = a.hires_mic_enabled; + self.save_settings(); + } self.device_states.insert(mac.clone(), state); // if airpods, update the noise control state combo box based on allow off mode let type_ = { @@ -629,34 +622,12 @@ impl App { } Message::TrayTextModeChanged(is_enabled) => { self.tray_text_mode = is_enabled; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.save_settings(); Task::none() } Message::StemControlChanged(is_enabled) => { self.stem_control = is_enabled; - let app_settings_path = get_app_settings_path(); - let settings = serde_json::json!({ - "theme": self.selected_theme, - "tray_text_mode": self.tray_text_mode, - "stem_control": self.stem_control, - }); - debug!( - "Writing settings to {}: {}", - app_settings_path.to_str().unwrap(), - settings - ); - std::fs::write(app_settings_path, settings.to_string()).ok(); + self.save_settings(); Task::none() } } diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 91090973a..0ecc2a2fd 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -13,22 +13,13 @@ pub fn get_devices_path() -> PathBuf { .join("devices.json") } -/// Ensure a connected device is recorded in the devices file so it shows up in -/// the UI sidebar. Auto-detected AirPods don't go through the (currently -/// disabled) manual "add device" flow, so we persist them here on connect. -/// No-op if the device is already registered. -pub fn ensure_device_registered( - mac: &str, - name: &str, - type_: crate::devices::enums::DeviceType, -) { +pub fn ensure_device_registered(mac: &str, name: &str, type_: crate::devices::enums::DeviceType) { use crate::devices::enums::DeviceData; use std::collections::HashMap; let path = get_devices_path(); let json = std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".to_string()); - let mut devices: HashMap = - serde_json::from_str(&json).unwrap_or_default(); + let mut devices: HashMap = serde_json::from_str(&json).unwrap_or_default(); if devices.contains_key(mac) { return; } @@ -65,18 +56,17 @@ pub fn get_preferences_path() -> PathBuf { pub fn get_app_settings_path() -> PathBuf { let home = std::env::var("HOME").unwrap_or_default(); - let config_dir = std::env::var("XDG_CONFIG_HOME") - .unwrap_or_else(|_| format!("{}/.config", home)); + let config_dir = + std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home)); - let data_dir = std::env::var("XDG_DATA_HOME") - .unwrap_or_else(|_| format!("{}/.local/share", home)); + let data_dir = + std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| format!("{}/.local/share", home)); let new_path = PathBuf::from(&config_dir) .join("librepods") .join("app_settings.json"); - let old_path = PathBuf::from(&data_dir) - .join("app_settings.json"); + let old_path = PathBuf::from(&data_dir).join("app_settings.json"); // migrate if needed if old_path.exists() && !new_path.exists() { @@ -169,6 +159,50 @@ impl std::fmt::Display for MyTheme { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AppSettings { + pub theme: MyTheme, + pub tray_text_mode: bool, + pub stem_control: bool, + pub hires_mic_enabled: bool, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + theme: MyTheme::Dark, + tray_text_mode: false, + stem_control: false, + hires_mic_enabled: true, + } + } +} + +impl AppSettings { + pub fn load() -> Self { + std::fs::read_to_string(get_app_settings_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn save(&self) { + let path = get_app_settings_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match serde_json::to_string_pretty(self) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::error!("Failed to write app settings: {}", e); + } + } + Err(e) => log::error!("Failed to serialize app settings: {}", e), + } + } +} + impl From for Theme { fn from(my_theme: MyTheme) -> Self { match my_theme { From 697ed3ebf091c5305042cf3f0830e4210d0e260b Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Tue, 30 Jun 2026 14:02:45 +0200 Subject: [PATCH 07/14] Added the option to disable a2dp resetting. --- linux-rust/src/audio/output.rs | 3 ++ linux-rust/src/ui/airpods.rs | 25 ++++++------ linux-rust/src/ui/window.rs | 74 ++++++++++++++++++++++++++++++---- linux-rust/src/utils.rs | 2 + 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 16b4cd0f5..6ef252d5d 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -204,6 +204,9 @@ pub fn source_consumer(name: &str) -> Option { // We found that in some cases A2DP has to be suspended and resumed after a 0x58 mic start/stop // to avoid a corrupted transport state of the airpods. pub fn reset_a2dp(bdaddr: &str) { + if !crate::utils::AppSettings::load().a2dp_reset { + return; + } let sink = format!("bluez_output.{}.1", bdaddr.replace(':', "_")); let Some((mut mainloop, mut context)) = connect() else { return; diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 7593a4b3f..0aabacf0f 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -159,7 +159,7 @@ pub fn airpods_view<'a>( selected_background: Background::Color( theme.palette().primary.scale_alpha(0.3), ), - shadow: Default::default() + shadow: Default::default(), }) } ] @@ -373,7 +373,7 @@ pub fn airpods_view<'a>( let header = row![ column![ text("Hi-Res Microphone").size(16), - text("Captures the AirPods' proprietary 64 kHz AAC-ELD uplink and exposes it as an 'AirPodsHiRes' input. Higher quality than the standard HFP mic.").size(12).style( + text("Captures the AirPods' high-quality AAC-ELD microphone stream and exposes it as an 'AirPodsHiRes' input.").size(12).style( |theme: &Theme| { let mut style = text::Style::default(); style.color = Some(theme.palette().text.scale_alpha(0.7)); @@ -403,22 +403,21 @@ pub fn airpods_view<'a>( } container(content) - .padding(Padding{ + .padding(Padding { top: 5.0, bottom: 5.0, left: 18.0, right: 18.0, }) - .style( - |theme: &Theme| { - let mut style = container::Style::default(); - style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); - let mut border = Border::default(); - border.color = theme.palette().primary.scale_alpha(0.5); - style.border = border.rounded(16); - style - } - ) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }) }; let mut information_col = column![]; diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index dabed2fcf..64ae13ec1 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -10,20 +10,23 @@ use crate::ui::airpods::airpods_view; use crate::ui::messages::BluetoothUIMessage; use crate::ui::nothing::nothing_view; use crate::utils::{AppSettings, MyTheme, get_devices_path}; -use bluer::{Address}; +use bluer::Address; use iced::border::Radius; use iced::overlay::menu; use iced::widget::button::Style; use iced::widget::rule::FillMode; use iced::widget::{ Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, - text_input, toggler + text_input, toggler, +}; +use iced::{ + Background, Border, Center, Element, Font, Length, Padding, Program, Settings, Size, + Subscription, Task, Theme, daemon, window, }; -use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; use log::{debug, error}; use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::{Mutex, RwLock}; @@ -53,7 +56,11 @@ pub fn start_ui( .title(App::title) .settings(Settings { id: Some("librepods".to_string()), - fonts: vec![include_bytes!("../../assets/font/sf_pro.otf").as_slice().into()], + fonts: vec![ + include_bytes!("../../assets/font/sf_pro.otf") + .as_slice() + .into(), + ], default_font: Font::with_name("SF Pro Text"), ..Settings::default() }) @@ -77,6 +84,7 @@ pub struct App { tray_text_mode: bool, stem_control: bool, hires_mic_enabled: bool, + a2dp_reset: bool, } pub struct BluetoothState { @@ -109,6 +117,7 @@ pub enum Message { StateChanged(String, DeviceState), TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), + A2dpResetChanged(bool), MicLevelTick, } @@ -136,7 +145,6 @@ impl App { let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content); panes.resize(split.unwrap().1, 0.2); - let wait_task = Task::perform(wait_for_message(Arc::clone(&ui_rx)), |msg| msg); let (window, open_task) = if start_minimized { @@ -154,6 +162,7 @@ impl App { let tray_text_mode = app_settings.tray_text_mode; let stem_control = app_settings.stem_control; let hires_mic_enabled = app_settings.hires_mic_enabled; + let a2dp_reset = app_settings.a2dp_reset; let bluetooth_state = BluetoothState::new(); @@ -206,6 +215,7 @@ impl App { tray_text_mode, stem_control, hires_mic_enabled, + a2dp_reset, }, Task::batch(vec![open_task, wait_task]), ) @@ -217,6 +227,7 @@ impl App { tray_text_mode: self.tray_text_mode, stem_control: self.stem_control, hires_mic_enabled: self.hires_mic_enabled, + a2dp_reset: self.a2dp_reset, } .save(); } @@ -406,7 +417,8 @@ impl App { self.device_states.remove(&mac); - if matches!(&self.selected_tab, Tab::Device(selected_mac) if selected_mac == &mac) { + if matches!(&self.selected_tab, Tab::Device(selected_mac) if selected_mac == &mac) + { self.selected_tab = Tab::Device("none".to_string()); } @@ -630,6 +642,11 @@ impl App { self.save_settings(); Task::none() } + Message::A2dpResetChanged(is_enabled) => { + self.a2dp_reset = is_enabled; + self.save_settings(); + Task::none() + } } } @@ -1070,6 +1087,47 @@ impl App { ) .align_y(Center); + let a2dp_reset_value = self.a2dp_reset; + let a2dp_reset_toggle = container( + row![ + column![ + text("Reset A2DP transport").size(16), + text("Briefly suspends and resumes A2DP after the hi-res mic starts or stops. Disabling removes the short pause/stutter when the mic turns on or off, but on some setups it causes playback on one AirPod to drop once capture ends.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(a2dp_reset_value) + .on_toggle(move |is_enabled| { + Message::A2dpResetChanged(is_enabled) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1097,6 +1155,8 @@ impl App { tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + a2dp_reset_toggle, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 0ecc2a2fd..8fea2c100 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -166,6 +166,7 @@ pub struct AppSettings { pub tray_text_mode: bool, pub stem_control: bool, pub hires_mic_enabled: bool, + pub a2dp_reset: bool, } impl Default for AppSettings { @@ -175,6 +176,7 @@ impl Default for AppSettings { tray_text_mode: false, stem_control: false, hires_mic_enabled: true, + a2dp_reset: true, } } } From 7c072a2e71d92a541c11a40ef18c91fa358ea731 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Tue, 30 Jun 2026 18:24:05 +0200 Subject: [PATCH 08/14] Added AGC toggle and watchdog pattern for capturing SDU stalling. The main change is a watchdog pattern that handles missing SDUs by restarting the capture after N SDUs got lost. --- linux-rust/src/audio/hires_mic.rs | 60 +++++++++++++++++++++++++++---- linux-rust/src/audio/output.rs | 30 +++++++++------- linux-rust/src/ui/window.rs | 53 +++++++++++++++++++++++++++ linux-rust/src/utils.rs | 2 ++ 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 2c4313ad5..217e3c98b 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::thread::JoinHandle; -use std::time::Duration; +use std::time::{Duration, Instant}; use log::{error, info, warn}; use tokio::sync::Notify; @@ -21,6 +21,9 @@ use crate::bluetooth::aacp_audio; const A2DP_RESET_DELAY: Duration = Duration::from_millis(800); /// How often the monitor polls the virtual source for activity const POLL_INTERVAL: Duration = Duration::from_millis(400); +/// If no audio SDUs arrive for this long while a capture is active, the stream +/// is considered stalled and the capture is torn down and restarted. +const STALL_TIMEOUT: Duration = Duration::from_millis(2000); const LEVEL_RELEASE: f32 = 0.85; #[derive(Clone, Default)] @@ -99,11 +102,24 @@ impl HiResMic { // thread, started when an app opens the mic. struct Capture { decode_thread: Option>, + base: Instant, + last_sdu: Arc, +} + +impl Capture { + fn stalled(&self) -> bool { + let elapsed = self.base.elapsed().as_millis() as u64; + elapsed.saturating_sub(self.last_sdu.load(Ordering::Relaxed)) + > STALL_TIMEOUT.as_millis() as u64 + } } async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: Arc) { let mut capture: Option = None; - info!("[hires] activity monitor started, watching '{}'", output::SOURCE_NAME); + info!( + "[hires] activity monitor started, watching '{}'", + output::SOURCE_NAME + ); loop { tokio::select! { @@ -128,7 +144,21 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: stop_capture(capture.take().unwrap(), &aacp, &addr).await; status.reset(); } - (true, true) => status.set_capture(app), + (true, true) => { + status.set_capture(app); + if capture.as_ref().is_some_and(Capture::stalled) { + warn!( + "[hires] no audio for {}ms, restarting capture", + STALL_TIMEOUT.as_millis() + ); + stop_capture(capture.take().unwrap(), &aacp, &addr).await; + capture = start_capture(&aacp, &addr, &status).await; + if capture.is_none() { + warn!("[hires] capture restart failed; will retry on next poll"); + status.reset(); + } + } + } _ => {} } } @@ -144,7 +174,10 @@ async fn start_capture(aacp: &AACPManager, addr: &str, status: &MicStatus) -> Op let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; let rx = aacp.take_audio_channel().await; - let decode_thread = spawn_decode_thread(rx, decoder, output, status.clone()); + let base = Instant::now(); + let last_sdu = Arc::new(AtomicU64::new(0)); + let decode_thread = + spawn_decode_thread(rx, decoder, output, status.clone(), base, last_sdu.clone()); if let Err(e) = aacp.send_start_audio().await { error!("failed to send 0x58 START: {}", e); @@ -161,6 +194,8 @@ async fn start_capture(aacp: &AACPManager, addr: &str, status: &MicStatus) -> Op Some(Capture { decode_thread: Some(decode_thread), + base, + last_sdu, }) } @@ -183,6 +218,8 @@ fn spawn_decode_thread( mut decoder: EldDecoder, mut output: Output, status: MicStatus, + base: Instant, + last_sdu: Arc, ) -> JoinHandle<()> { std::thread::Builder::new() .name("hires-decode".into()) @@ -193,10 +230,15 @@ fn spawn_decode_thread( let mut pcm: Vec = Vec::with_capacity(4096); while let Some(sdu) = rx.blocking_recv() { + last_sdu.store(base.elapsed().as_millis() as u64, Ordering::Relaxed); pcm.clear(); aacp_audio::demux_type58(&sdu, |au| match decoder.decode(au, &mut pcm) { Some(_) => frames += 1, - None => errors += 1, + None => { + // insert a silent frame + errors += 1; + pcm.resize(pcm.len() + ELD_FRAME_SAMPLES * ELD_CHANNELS as usize, 0); + } }); let peak = if pcm.is_empty() { @@ -211,7 +253,11 @@ fn spawn_decode_thread( } }; - env = if peak >= env { peak } else { env * LEVEL_RELEASE }; + env = if peak >= env { + peak + } else { + env * LEVEL_RELEASE + }; status.set_level(env); if frames > 0 && frames % 400 == 0 { diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 6ef252d5d..3b4ef48f0 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -81,7 +81,7 @@ impl Drop for VirtualMic { // Playback stream feeding the null-sink. Opened only while an app is recording. pub struct Output { simple: Simple, - agc: Agc, + agc: Option, } unsafe impl Send for Output {} @@ -124,17 +124,24 @@ impl Output { } }; - Some(Output { - simple, - agc: Agc::new(), - }) + let agc = crate::utils::AppSettings::load().hires_mic_agc.then(Agc::new); + if agc.is_none() { + info!("[pw] AGC disabled; passing through raw hi-res capture"); + } + Some(Output { simple, agc }) } - // Write s16 PCM into the sink, returning the post-AGC peak + // Write s16 PCM into the sink, returning the (post-AGC) peak pub fn write(&mut self, pcm: &[i16]) -> Result { - let mut pcm = pcm.to_vec(); - - self.agc.process(&mut pcm); + let processed; + let pcm: &[i16] = if let Some(agc) = &mut self.agc { + let mut buf = pcm.to_vec(); + agc.process(&mut buf); + processed = buf; + &processed + } else { + pcm + }; let peak = pcm .iter() @@ -142,10 +149,7 @@ impl Output { .fold(0.0f32, f32::max); let bytes = unsafe { - std::slice::from_raw_parts( - pcm.as_ptr() as *const u8, - std::mem::size_of_val(pcm.as_slice()), - ) + std::slice::from_raw_parts(pcm.as_ptr() as *const u8, std::mem::size_of_val(pcm)) }; self.simple diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 64ae13ec1..cd94a4789 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -84,6 +84,7 @@ pub struct App { tray_text_mode: bool, stem_control: bool, hires_mic_enabled: bool, + hires_mic_agc: bool, a2dp_reset: bool, } @@ -118,6 +119,7 @@ pub enum Message { TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy StemControlChanged(bool), A2dpResetChanged(bool), + HiResMicAgcChanged(bool), MicLevelTick, } @@ -162,6 +164,7 @@ impl App { let tray_text_mode = app_settings.tray_text_mode; let stem_control = app_settings.stem_control; let hires_mic_enabled = app_settings.hires_mic_enabled; + let hires_mic_agc = app_settings.hires_mic_agc; let a2dp_reset = app_settings.a2dp_reset; let bluetooth_state = BluetoothState::new(); @@ -215,6 +218,7 @@ impl App { tray_text_mode, stem_control, hires_mic_enabled, + hires_mic_agc, a2dp_reset, }, Task::batch(vec![open_task, wait_task]), @@ -227,6 +231,7 @@ impl App { tray_text_mode: self.tray_text_mode, stem_control: self.stem_control, hires_mic_enabled: self.hires_mic_enabled, + hires_mic_agc: self.hires_mic_agc, a2dp_reset: self.a2dp_reset, } .save(); @@ -647,6 +652,11 @@ impl App { self.save_settings(); Task::none() } + Message::HiResMicAgcChanged(is_enabled) => { + self.hires_mic_agc = is_enabled; + self.save_settings(); + Task::none() + } } } @@ -1128,6 +1138,47 @@ impl App { ) .align_y(Center); + let hires_mic_agc_value = self.hires_mic_agc; + let hires_mic_agc_toggle = container( + row![ + column![ + text("Hi-res mic auto gain").size(16), + text("Automatically normalizes the hi-res microphone level. For most usecases this should remain on. Disable for a raw, unprocessed capture.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(hires_mic_agc_value) + .on_toggle(move |is_enabled| { + Message::HiResMicAgcChanged(is_enabled) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1157,6 +1208,8 @@ impl App { controls_settings_col, Space::new().height(Length::from(20)), a2dp_reset_toggle, + Space::new().height(Length::from(20)), + hires_mic_agc_toggle, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 8fea2c100..374fc5327 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -166,6 +166,7 @@ pub struct AppSettings { pub tray_text_mode: bool, pub stem_control: bool, pub hires_mic_enabled: bool, + pub hires_mic_agc: bool, pub a2dp_reset: bool, } @@ -176,6 +177,7 @@ impl Default for AppSettings { tray_text_mode: false, stem_control: false, hires_mic_enabled: true, + hires_mic_agc: true, a2dp_reset: true, } } From 25f44d76ae5502c966685d15b72ccb10a1b7469c Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Wed, 1 Jul 2026 00:59:55 +0200 Subject: [PATCH 09/14] Fix hi-res mic latency --- linux-rust/src/audio/hires_mic.rs | 42 +++++++++++++++---------------- linux-rust/src/audio/output.rs | 9 ++++--- linux-rust/src/bluetooth/aacp.rs | 2 ++ 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 217e3c98b..664b8c98f 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -31,6 +31,7 @@ pub struct MicStatus { level: Arc, active: Arc, app: Arc>>, + last_sdu: Arc>>, } impl MicStatus { @@ -62,8 +63,19 @@ impl MicStatus { fn reset(&self) { self.active.store(false, Ordering::Relaxed); *self.app.lock().unwrap() = None; + *self.last_sdu.lock().unwrap() = None; self.set_level(0.0); } + + /// Record that an audio SDU just arrived from the device. + pub fn mark_sdu(&self) { + *self.last_sdu.lock().unwrap() = Some(Instant::now()); + } + + /// Time since the last audio SDU, or None if none has arrived yet. + fn since_last_sdu(&self) -> Option { + self.last_sdu.lock().unwrap().map(|t| t.elapsed()) + } } pub struct HiResMic { @@ -102,16 +114,6 @@ impl HiResMic { // thread, started when an app opens the mic. struct Capture { decode_thread: Option>, - base: Instant, - last_sdu: Arc, -} - -impl Capture { - fn stalled(&self) -> bool { - let elapsed = self.base.elapsed().as_millis() as u64; - elapsed.saturating_sub(self.last_sdu.load(Ordering::Relaxed)) - > STALL_TIMEOUT.as_millis() as u64 - } } async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: Arc) { @@ -146,9 +148,10 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: } (true, true) => { status.set_capture(app); - if capture.as_ref().is_some_and(Capture::stalled) { + let stalled = status.since_last_sdu().is_some_and(|d| d > STALL_TIMEOUT); + if capture.is_some() && stalled { warn!( - "[hires] no audio for {}ms, restarting capture", + "[hires] no audio from device for {}ms, restarting capture", STALL_TIMEOUT.as_millis() ); stop_capture(capture.take().unwrap(), &aacp, &addr).await; @@ -174,10 +177,10 @@ async fn start_capture(aacp: &AACPManager, addr: &str, status: &MicStatus) -> Op let output = Output::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; let rx = aacp.take_audio_channel().await; - let base = Instant::now(); - let last_sdu = Arc::new(AtomicU64::new(0)); - let decode_thread = - spawn_decode_thread(rx, decoder, output, status.clone(), base, last_sdu.clone()); + // Start the stall grace period now; the device should deliver SDUs (which + // refresh this) well within STALL_TIMEOUT. + status.mark_sdu(); + let decode_thread = spawn_decode_thread(rx, decoder, output, status.clone()); if let Err(e) = aacp.send_start_audio().await { error!("failed to send 0x58 START: {}", e); @@ -194,8 +197,6 @@ async fn start_capture(aacp: &AACPManager, addr: &str, status: &MicStatus) -> Op Some(Capture { decode_thread: Some(decode_thread), - base, - last_sdu, }) } @@ -218,8 +219,6 @@ fn spawn_decode_thread( mut decoder: EldDecoder, mut output: Output, status: MicStatus, - base: Instant, - last_sdu: Arc, ) -> JoinHandle<()> { std::thread::Builder::new() .name("hires-decode".into()) @@ -230,7 +229,6 @@ fn spawn_decode_thread( let mut pcm: Vec = Vec::with_capacity(4096); while let Some(sdu) = rx.blocking_recv() { - last_sdu.store(base.elapsed().as_millis() as u64, Ordering::Relaxed); pcm.clear(); aacp_audio::demux_type58(&sdu, |au| match decoder.decode(au, &mut pcm) { Some(_) => frames += 1, diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 3b4ef48f0..36e52fb8f 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -98,9 +98,10 @@ impl Output { return None; } - let tlength = (sample_rate * channels as u32 * 2 / 100).max(1); + let bytes_per_sec = sample_rate * channels as u32 * 2; + let tlength = (bytes_per_sec * 80 / 1000).max(1); // ~80 ms target let attr = BufferAttr { - maxlength: u32::MAX, + maxlength: (bytes_per_sec * 150 / 1000).max(tlength), // ~150 ms cap tlength, prebuf: u32::MAX, minreq: u32::MAX, @@ -124,7 +125,9 @@ impl Output { } }; - let agc = crate::utils::AppSettings::load().hires_mic_agc.then(Agc::new); + let agc = crate::utils::AppSettings::load() + .hires_mic_agc + .then(Agc::new); if agc.is_none() { info!("[pw] AGC disabled; passing through raw hi-res capture"); } diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 3bc1ee58c..9c1e44553 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -1331,6 +1331,8 @@ async fn recv_thread(manager: AACPManager, sp: Arc) { // Forward audio SDUs to the audio thread, leaving control SDUs for the control thread. if crate::bluetooth::aacp_audio::is_audio(data) { + // Device-level liveness for the hi-res stall watchdog. + manager.mic_status.mark_sdu(); let audio_tx = { let state = manager.state.lock().await; state.audio_tx.clone() From ddc05810623d3fe2936e0dfa2c61afaf1535a731 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Wed, 1 Jul 2026 01:26:29 +0200 Subject: [PATCH 10/14] Fixed issue where using the tray to exit during active mic stream would corrupt connection. Exiting using the tray did not teardown the mic stream, leaving the connection in a corrupted state. --- linux-rust/src/main.rs | 51 ++++++++++++++++++++++++++++++++++++++- linux-rust/src/ui/tray.rs | 8 +++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 6a77833fa..d7b858f11 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -26,7 +26,7 @@ use std::env; use std::sync::atomic::{AtomicBool}; use std::sync::Arc; use tokio::sync::RwLock; -use tokio::sync::mpsc::unbounded_channel; +use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; #[derive(Parser)] struct Args { @@ -127,6 +127,9 @@ async fn async_main( } } + let (shutdown_tx, shutdown_rx) = unbounded_channel::<()>(); + spawn_shutdown_handler(device_managers.clone(), shutdown_rx); + let tray_handle = if args.no_tray { None } else { @@ -145,6 +148,7 @@ async fn async_main( allow_off_option: None, command_tx: None, ui_tx: Some(ui_tx.clone()), + shutdown_tx: Some(shutdown_tx.clone()), }; match tray.spawn().await { Ok(handle) => Some(handle), @@ -356,3 +360,48 @@ async fn async_main( conn.process(std::time::Duration::from_millis(1000))?; } } + +fn spawn_shutdown_handler( + device_managers: Arc>>, + mut shutdown_rx: UnboundedReceiver<()>, +) { + tokio::spawn(async move { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate()).ok(); + let mut sigint = signal(SignalKind::interrupt()).ok(); + let sigterm = async { + match sigterm.as_mut() { + Some(s) => s.recv().await.map(|_| ()), + None => std::future::pending().await, + } + }; + let sigint = async { + match sigint.as_mut() { + Some(s) => s.recv().await.map(|_| ()), + None => std::future::pending().await, + } + }; + tokio::select! { + _ = shutdown_rx.recv() => {} + _ = sigterm => {} + _ = sigint => {} + } + + info!("Shutting down: tearing down hi-res mic streams..."); + let cleanup = async { + let managers = device_managers.read().await; + for dm in managers.values() { + if let Some(aacp) = dm.get_aacp() { + aacp.disarm_hires_mic().await; + } + } + }; + if tokio::time::timeout(std::time::Duration::from_secs(3), cleanup) + .await + .is_err() + { + warn!("Shutdown cleanup timed out; exiting anyway"); + } + std::process::exit(0); + }); +} diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b3adbc53a..848fc1028 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -24,6 +24,7 @@ pub struct MyTray { pub allow_off_option: Option, pub command_tx: Option)>>, pub ui_tx: Option>, + pub shutdown_tx: Option>, } impl ksni::Tray for MyTray { @@ -185,7 +186,12 @@ impl ksni::Tray for MyTray { StandardItem { label: "Exit".into(), icon_name: "application-exit".into(), - activate: Box::new(|_| std::process::exit(0)), + activate: Box::new(|this: &mut Self| match &this.shutdown_tx { + Some(tx) => { + let _ = tx.send(()); + } + None => std::process::exit(0), + }), ..Default::default() } .into(), From d9da50b3d1713b1a49ed29eddc300be318b01d08 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Thu, 2 Jul 2026 14:09:30 +0200 Subject: [PATCH 11/14] Pause conversation awareness during mic capture Add a setting to automatically disable conversation detection when the Hi-Res microphone is capturing, restoring the previous state after capture ends. --- linux-rust/src/audio/hires_mic.rs | 16 +++++++++ linux-rust/src/bluetooth/aacp.rs | 34 ++++++++++++++++++- linux-rust/src/ui/airpods.rs | 39 +++++++++++---------- linux-rust/src/ui/window.rs | 56 ++++++++++++++++++++++++++++++- linux-rust/src/utils.rs | 2 ++ 5 files changed, 128 insertions(+), 19 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 664b8c98f..7f9797cff 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -118,6 +118,8 @@ struct Capture { async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: Arc) { let mut capture: Option = None; + // Conversation detection value saved while we override it off for capture. + let mut old_convo_state: Option = None; info!( "[hires] activity monitor started, watching '{}'", output::SOURCE_NAME @@ -139,11 +141,22 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: if let Some(c) = start_capture(&aacp, &addr, &status).await { status.set_capture(app); capture = Some(c); + + if crate::utils::AppSettings::load().hires_mic_pause_convo { + let was_enabled = aacp.conversation_detection_enabled().await; + old_convo_state = Some(was_enabled); + if was_enabled { + aacp.set_conversation_detection(false).await; + } + } } } (false, true) => { info!("[hires] recorder gone, stopping capture"); stop_capture(capture.take().unwrap(), &aacp, &addr).await; + if let Some(prev) = old_convo_state.take() { + aacp.set_conversation_detection(prev).await; + } status.reset(); } (true, true) => { @@ -168,6 +181,9 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: if let Some(c) = capture.take() { stop_capture(c, &aacp, &addr).await; + if let Some(prev) = old_convo_state.take() { + aacp.set_conversation_detection(prev).await; + } } status.reset(); } diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 9c1e44553..4178f802d 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -5,7 +5,7 @@ use bluer::{ Address, AddressType, Error, Result, l2cap::{SeqPacket, Socket, SocketAddr}, }; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; @@ -406,6 +406,38 @@ impl AACPManager { } } + pub async fn conversation_detection_enabled(&self) -> bool { + self.state + .lock() + .await + .control_command_status_list + .iter() + .find(|s| s.identifier == ControlCommandIdentifiers::ConversationDetectConfig) + .is_some_and(|s| s.value.first() == Some(&0x01)) + } + + pub async fn set_conversation_detection(&self, enabled: bool) { + let value = if enabled { 0x01 } else { 0x02 }; + info!("[aacp] setting conversation detection to {}", enabled); + if let Err(e) = self + .send_control_command( + ControlCommandIdentifiers::ConversationDetectConfig, + &[value], + ) + .await + { + warn!("[hires] failed to set conversation detection: {}", e); + return; + } + // Push UI change + if let Some(ref tx) = self.state.lock().await.event_tx { + let _ = tx.send(AACPEvent::ControlCommand(ControlCommandStatus { + identifier: ControlCommandIdentifiers::ConversationDetectConfig, + value: vec![value], + })); + } + } + pub fn mic_level(&self) -> f32 { self.mic_status.level() } diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs index 0aabacf0f..fc28656c0 100644 --- a/linux-rust/src/ui/airpods.rs +++ b/linux-rust/src/ui/airpods.rs @@ -22,6 +22,7 @@ pub fn airpods_view<'a>( devices_list: &HashMap, state: &'a AirPodsState, aacp_manager: Arc, + pause_convo: bool, // att_manager: Arc ) -> iced::widget::Container<'a, Message> { let mac = mac.to_string(); @@ -266,23 +267,27 @@ pub fn airpods_view<'a>( } ).width(Length::Fill), ].width(Length::Fill), - toggler(state.conversation_awareness_enabled) - .on_toggle(move |is_enabled| { - let aacp_manager = aacp_manager_conv_detect.clone(); - run_async_in_thread( - async move { - aacp_manager.send_control_command( - ControlCommandIdentifiers::ConversationDetectConfig, - if is_enabled { &[0x01] } else { &[0x02] } - ).await.expect("Failed to send Conversation Awareness command"); - } - ); - let mut state = state.clone(); - state.conversation_awareness_enabled = is_enabled; - Message::StateChanged(mac_audio.to_string(), DeviceState::AirPods(state)) - }) - .spacing(0) - .size(20) + { + // Locked while a capture manages it, so the user can't fight the override. + let convo_toggler = toggler(state.conversation_awareness_enabled) + .spacing(0) + .size(20); + if pause_convo && aacp_manager.mic_active() { + convo_toggler + } else { + convo_toggler.on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_conv_detect.clone(); + run_async_in_thread( + async move { + aacp_manager.set_conversation_detection(is_enabled).await; + } + ); + let mut state = state.clone(); + state.conversation_awareness_enabled = is_enabled; + Message::StateChanged(mac_audio.to_string(), DeviceState::AirPods(state)) + }) + } + } ] .align_y(Center) .spacing(8) diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index cd94a4789..b81cc5c05 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -85,6 +85,7 @@ pub struct App { stem_control: bool, hires_mic_enabled: bool, hires_mic_agc: bool, + hires_mic_pause_convo: bool, a2dp_reset: bool, } @@ -120,6 +121,7 @@ pub enum Message { StemControlChanged(bool), A2dpResetChanged(bool), HiResMicAgcChanged(bool), + HiResMicPauseConvoChanged(bool), MicLevelTick, } @@ -165,6 +167,7 @@ impl App { let stem_control = app_settings.stem_control; let hires_mic_enabled = app_settings.hires_mic_enabled; let hires_mic_agc = app_settings.hires_mic_agc; + let hires_mic_pause_convo = app_settings.hires_mic_pause_convo; let a2dp_reset = app_settings.a2dp_reset; let bluetooth_state = BluetoothState::new(); @@ -219,6 +222,7 @@ impl App { stem_control, hires_mic_enabled, hires_mic_agc, + hires_mic_pause_convo, a2dp_reset, }, Task::batch(vec![open_task, wait_task]), @@ -232,6 +236,7 @@ impl App { stem_control: self.stem_control, hires_mic_enabled: self.hires_mic_enabled, hires_mic_agc: self.hires_mic_agc, + hires_mic_pause_convo: self.hires_mic_pause_convo, a2dp_reset: self.a2dp_reset, } .save(); @@ -657,6 +662,11 @@ impl App { self.save_settings(); Task::none() } + Message::HiResMicPauseConvoChanged(is_enabled) => { + self.hires_mic_pause_convo = is_enabled; + self.save_settings(); + Task::none() + } } } @@ -876,7 +886,8 @@ impl App { id, &devices_list, state, - aacp_manager.clone() + aacp_manager.clone(), + self.hires_mic_pause_convo )) }) } @@ -1179,6 +1190,47 @@ impl App { ) .align_y(Center); + let hires_mic_pause_convo_value = self.hires_mic_pause_convo; + let hires_mic_pause_convo_toggle = container( + row![ + column![ + text("Pause conversation awareness during capture").size(16), + text("Turns off conversation awareness while the hi-res microphone is capturing, then restores it afterwards.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(hires_mic_pause_convo_value) + .on_toggle(move |is_enabled| { + Message::HiResMicPauseConvoChanged(is_enabled) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1210,6 +1262,8 @@ impl App { a2dp_reset_toggle, Space::new().height(Length::from(20)), hires_mic_agc_toggle, + Space::new().height(Length::from(20)), + hires_mic_pause_convo_toggle, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 374fc5327..1e179e103 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -167,6 +167,7 @@ pub struct AppSettings { pub stem_control: bool, pub hires_mic_enabled: bool, pub hires_mic_agc: bool, + pub hires_mic_pause_convo: bool, pub a2dp_reset: bool, } @@ -178,6 +179,7 @@ impl Default for AppSettings { stem_control: false, hires_mic_enabled: true, hires_mic_agc: true, + hires_mic_pause_convo: true, a2dp_reset: true, } } From 00cbdafedbcad56d8e6324211aea242a5b4f979c Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Thu, 2 Jul 2026 14:36:48 +0200 Subject: [PATCH 12/14] Fixed Airpods getting put in HFP when the high res mic is disabled mid capture. Disabling the high-res mic during capture removed the input device, making pipewire switch to the HFP mic. --- linux-rust/src/audio/hires_mic.rs | 68 ++++++++++++++++++++++--------- linux-rust/src/bluetooth/aacp.rs | 19 +++++++-- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 7f9797cff..d8fafc626 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -79,23 +79,28 @@ impl MicStatus { } pub struct HiResMic { - _vmic: VirtualMic, stop: Arc, monitor: Option>, } impl HiResMic { - // Create the persistent virtual input and spawn the activity monitor. + // Create the persistent virtual input and spawn the activity monitor. The + // monitor owns the virtual device and unloads it when it exits. pub async fn start(aacp: &AACPManager, addr: String, status: MicStatus) -> Option { let vmic = VirtualMic::open(ELD_CHANNELS as u8)?; let stop = Arc::new(Notify::new()); + let wake = aacp.hires_wake(); // Spawn on the backend runtime, not the caller's (the UI toggle uses a // throwaway runtime that would abort this task immediately). - let monitor = aacp - .runtime() - .spawn(monitor_loop(aacp.clone(), addr, status, stop.clone())); + let monitor = aacp.runtime().spawn(monitor_loop( + aacp.clone(), + addr, + status, + stop.clone(), + wake, + vmic, + )); Some(HiResMic { - _vmic: vmic, stop, monitor: Some(monitor), }) @@ -108,6 +113,10 @@ impl HiResMic { let _ = monitor.await; } } + + pub fn is_running(&self) -> bool { + self.monitor.as_ref().is_some_and(|h| !h.is_finished()) + } } // A live capture session: the playback stream feeding the sink plus its decode @@ -116,7 +125,14 @@ struct Capture { decode_thread: Option>, } -async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: Arc) { +async fn monitor_loop( + aacp: AACPManager, + addr: String, + status: MicStatus, + stop: Arc, + wake: Arc, + _vmic: VirtualMic, +) { let mut capture: Option = None; // Conversation detection value saved while we override it off for capture. let mut old_convo_state: Option = None; @@ -128,15 +144,18 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: loop { tokio::select! { _ = stop.notified() => break, + _ = wake.notified() => {} _ = tokio::time::sleep(POLL_INTERVAL) => {} } let app = tokio::task::spawn_blocking(|| output::source_consumer(output::SOURCE_NAME)) .await .unwrap_or(None); + let enabled = aacp.hires_mic_enabled(); + let recording = app.is_some(); - match (app.is_some(), capture.is_some()) { - (true, false) => { + match (enabled, recording, capture.is_some()) { + (true, true, false) => { info!("[hires] recorder detected ({:?}), starting capture", app); if let Some(c) = start_capture(&aacp, &addr, &status).await { status.set_capture(app); @@ -151,18 +170,10 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: } } } - (false, true) => { - info!("[hires] recorder gone, stopping capture"); - stop_capture(capture.take().unwrap(), &aacp, &addr).await; - if let Some(prev) = old_convo_state.take() { - aacp.set_conversation_detection(prev).await; - } - status.reset(); - } - (true, true) => { + (true, true, true) => { status.set_capture(app); let stalled = status.since_last_sdu().is_some_and(|d| d > STALL_TIMEOUT); - if capture.is_some() && stalled { + if stalled { warn!( "[hires] no audio from device for {}ms, restarting capture", STALL_TIMEOUT.as_millis() @@ -175,8 +186,27 @@ async fn monitor_loop(aacp: AACPManager, addr: String, status: MicStatus, stop: } } } + // Disabled while capturing: end the 0x58 stream now. + // The virtual source stays up feeding silence, so an active + // recorder isn't dropped onto the AirPods' HFP mic (handsfree). + (_, _, true) => { + info!( + "[hires] stopping capture (enabled={}, recording={})", + enabled, recording + ); + stop_capture(capture.take().unwrap(), &aacp, &addr).await; + if let Some(prev) = old_convo_state.take() { + aacp.set_conversation_detection(prev).await; + } + status.reset(); + } _ => {} } + + // Once disabled and nothing is recording from the source, unload it. + if !enabled && !recording { + break; + } } if let Some(c) = capture.take() { diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 4178f802d..7452679c9 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, Notify, mpsc}; use tokio::task::JoinSet; use tokio::time::{Instant, sleep}; @@ -386,6 +386,9 @@ pub struct AACPManager { tasks: Arc>>, hires_enabled: Arc, hires_mic: Arc>>, + /// Wakes the hi-res monitor so it re-polls promptly when the feature is + /// toggled, instead of waiting out the poll interval. + hires_wake: Arc, mic_status: crate::audio::hires_mic::MicStatus, /// Handle to the long-lived backend runtime, so the hi-res monitor task /// survives even when armed from a throwaway runtime (the UI toggle thread). @@ -401,6 +404,7 @@ impl AACPManager { crate::utils::AppSettings::load().hires_mic_enabled, )), hires_mic: Arc::new(Mutex::new(None)), + hires_wake: Arc::new(Notify::new()), mic_status: crate::audio::hires_mic::MicStatus::new(), runtime: tokio::runtime::Handle::current(), } @@ -454,12 +458,19 @@ impl AACPManager { &self.runtime } + pub fn hires_mic_enabled(&self) -> bool { + self.hires_enabled.load(Ordering::Relaxed) + } + + pub fn hires_wake(&self) -> Arc { + self.hires_wake.clone() + } + pub async fn set_hires_mic_enabled(&self, enabled: bool) { self.hires_enabled.store(enabled, Ordering::Relaxed); + self.hires_wake.notify_one(); if enabled { self.arm_hires_mic().await; - } else { - self.disarm_hires_mic().await; } } @@ -470,7 +481,7 @@ impl AACPManager { return; } let mut guard = self.hires_mic.lock().await; - if guard.is_some() { + if guard.as_ref().is_some_and(|m| m.is_running()) { return; } let addr = self.state.lock().await.airpods_mac.map(|a| a.to_string()); From 1f8948d5dd9fb8679968c51b3a296810cadffa53 Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Thu, 2 Jul 2026 18:15:21 +0200 Subject: [PATCH 13/14] Update A2DP reset to use card profiles and resume MPRIS players. --- linux-rust/src/audio/hires_mic.rs | 12 ++-- linux-rust/src/audio/output.rs | 96 +++++++++++++++++++++++++++++-- linux-rust/src/bluetooth/aacp.rs | 21 ++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index d8fafc626..5c872d2c6 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -161,12 +161,12 @@ async fn monitor_loop( status.set_capture(app); capture = Some(c); - if crate::utils::AppSettings::load().hires_mic_pause_convo { - let was_enabled = aacp.conversation_detection_enabled().await; - old_convo_state = Some(was_enabled); - if was_enabled { - aacp.set_conversation_detection(false).await; - } + // Only disable (and remember to restore) if it was on. + if crate::utils::AppSettings::load().hires_mic_pause_convo + && aacp.conversation_detection_enabled().await + { + old_convo_state = Some(true); + aacp.set_conversation_detection(false).await; } } } diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 36e52fb8f..3b858ebe4 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -12,6 +12,10 @@ use libpulse_simple_binding::Simple; use log::{error, info, warn}; use std::cell::{Cell, RefCell}; use std::rc::Rc; +use std::time::Duration; + +use dbus::blocking::Connection; +use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use crate::audio::agc::Agc; @@ -214,23 +218,105 @@ pub fn reset_a2dp(bdaddr: &str) { if !crate::utils::AppSettings::load().a2dp_reset { return; } - let sink = format!("bluez_output.{}.1", bdaddr.replace(':', "_")); + let card = format!("bluez_card.{}", bdaddr.replace(':', "_")); let Some((mut mainloop, mut context)) = connect() else { return; }; - info!("[pw] suspend/resume {} to reset A2DP transport", sink); let mut introspect = context.introspect(); - let op = introspect.suspend_sink_by_name(&sink, true, None); + let current_profile = Rc::new(RefCell::new(None::)); + let op = introspect.get_card_info_by_name(&card, { + let current_profile = current_profile.clone(); + move |result| { + if let ListResult::Item(item) = result { + *current_profile.borrow_mut() = item + .active_profile + .as_ref() + .and_then(|p| p.name.as_ref()) + .map(|n| n.to_string()); + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + + let Some(current_profile) = current_profile.borrow().clone() else { + warn!("[pw] no active profile on {}; skipping A2DP reset", card); + mainloop.quit(Retval(0)); + return; + }; + + // Resetting the a2dp transport can pause media players do to setting the crad profile to off + // Get all active media players + let players = playing_media_players(); + + info!( + "[pw] reset A2DP transport: {} off -> {}", + card, current_profile + ); + let op = introspect.set_card_profile_by_name(&card, "off", None); while op.get_state() == OperationState::Running { mainloop.iterate(false); } - std::thread::sleep(std::time::Duration::from_millis(200)); - let op = introspect.suspend_sink_by_name(&sink, false, None); + + let op = introspect.set_card_profile_by_name(&card, ¤t_profile, None); while op.get_state() == OperationState::Running { mainloop.iterate(false); } mainloop.quit(Retval(0)); + + // resume all media players after the reset + resume_media_players(&players); +} + +// MPRIS players currently reporting "Playing" (kdeconnect proxies excluded). +fn playing_media_players() -> Vec { + let Ok(conn) = Connection::new_session() else { + return Vec::new(); + }; + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let names: (Vec,) = match proxy.method_call("org.freedesktop.DBus", "ListNames", ()) { + Ok(n) => n, + Err(_) => return Vec::new(), + }; + names + .0 + .into_iter() + .filter(|s| { + s.starts_with("org.mpris.MediaPlayer2.") + && !s.starts_with("org.mpris.MediaPlayer2.kdeconnect.mpris_") + }) + .filter(|s| { + let proxy = conn.with_proxy(s, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + proxy + .get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") + .map(|st| st == "Playing") + .unwrap_or(false) + }) + .collect() +} + +fn resume_media_players(services: &[String]) { + if services.is_empty() { + return; + } + let Ok(conn) = Connection::new_session() else { + return; + }; + for service in services { + let proxy = conn.with_proxy(service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if proxy + .method_call::<(), _, &str, &str>("org.mpris.MediaPlayer2.Player", "Play", ()) + .is_ok() + { + info!("[pw] resumed media player after A2DP reset: {}", service); + } + } } fn connect() -> Option<(Mainloop, Context)> { diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index 7452679c9..2212a179d 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -433,8 +433,25 @@ impl AACPManager { warn!("[hires] failed to set conversation detection: {}", e); return; } - // Push UI change - if let Some(ref tx) = self.state.lock().await.event_tx { + // AirPods don't echo this back, so record it in our own status list + // (the source of truth for conversation_detection_enabled) and push it + // to the UI ourselves. + let mut state = self.state.lock().await; + if let Some(existing) = state + .control_command_status_list + .iter_mut() + .find(|s| s.identifier == ControlCommandIdentifiers::ConversationDetectConfig) + { + existing.value = vec![value]; + } else { + state + .control_command_status_list + .push(ControlCommandStatus { + identifier: ControlCommandIdentifiers::ConversationDetectConfig, + value: vec![value], + }); + } + if let Some(ref tx) = state.event_tx { let _ = tx.send(AACPEvent::ControlCommand(ControlCommandStatus { identifier: ControlCommandIdentifiers::ConversationDetectConfig, value: vec![value], From 99b0e580a93f2a26647f89816895ca1e5f70a3ec Mon Sep 17 00:00:00 2001 From: Luan Ademi Date: Tue, 14 Jul 2026 17:32:22 +0200 Subject: [PATCH 14/14] Moved from a null sink to a fifo file for inserting raw PCMs into the pipewire graph. --- linux-rust/Cargo.lock | 22 ------ linux-rust/Cargo.toml | 1 - linux-rust/src/audio/hires_mic.rs | 2 +- linux-rust/src/audio/output.rs | 110 ++++++++++-------------------- 4 files changed, 38 insertions(+), 97 deletions(-) diff --git a/linux-rust/Cargo.lock b/linux-rust/Cargo.lock index 3b2d2b0db..e7df42476 100644 --- a/linux-rust/Cargo.lock +++ b/linux-rust/Cargo.lock @@ -2461,27 +2461,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libpulse-simple-binding" -version = "2.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef" -dependencies = [ - "libpulse-binding", - "libpulse-simple-sys", - "libpulse-sys", -] - -[[package]] -name = "libpulse-simple-sys" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc" -dependencies = [ - "libpulse-sys", - "pkg-config", -] - [[package]] name = "libpulse-sys" version = "1.23.0" @@ -2525,7 +2504,6 @@ dependencies = [ "imageproc", "ksni", "libpulse-binding", - "libpulse-simple-binding", "log", "serde", "serde_json", diff --git a/linux-rust/Cargo.toml b/linux-rust/Cargo.toml index 3e2975c6a..0ddbfee4d 100644 --- a/linux-rust/Cargo.toml +++ b/linux-rust/Cargo.toml @@ -13,7 +13,6 @@ dbus = "0.9.10" hex = "0.4.3" iced = { version = "0.14.0", features = ["tokio", "image"] } libpulse-binding = "2.30.1" -libpulse-simple-binding = "2.29.0" ksni = "0.3.1" image = "0.25.8" imageproc = "0.26.1" diff --git a/linux-rust/src/audio/hires_mic.rs b/linux-rust/src/audio/hires_mic.rs index 5c872d2c6..88fb61568 100644 --- a/linux-rust/src/audio/hires_mic.rs +++ b/linux-rust/src/audio/hires_mic.rs @@ -87,7 +87,7 @@ impl HiResMic { // Create the persistent virtual input and spawn the activity monitor. The // monitor owns the virtual device and unloads it when it exits. pub async fn start(aacp: &AACPManager, addr: String, status: MicStatus) -> Option { - let vmic = VirtualMic::open(ELD_CHANNELS as u8)?; + let vmic = VirtualMic::open(ELD_SAMPLE_RATE, ELD_CHANNELS as u8)?; let stop = Arc::new(Notify::new()); let wake = aacp.hires_wake(); // Spawn on the backend runtime, not the caller's (the UI toggle uses a diff --git a/linux-rust/src/audio/output.rs b/linux-rust/src/audio/output.rs index 3b858ebe4..ce9d3c929 100644 --- a/linux-rust/src/audio/output.rs +++ b/linux-rust/src/audio/output.rs @@ -1,16 +1,14 @@ -//! PipeWire/PulseAudio output for the hi-res microphone: a private null-sink -//! fed by a playback stream, exposed as a real input via a remap-source +//! PipeWire/PulseAudio output for the hi-res microphone use libpulse_binding::callbacks::ListResult; use libpulse_binding::context::{Context, FlagSet as ContextFlagSet}; -use libpulse_binding::def::{BufferAttr, Retval}; +use libpulse_binding::def::Retval; use libpulse_binding::mainloop::standard::{IterateResult, Mainloop}; use libpulse_binding::operation::State as OperationState; -use libpulse_binding::sample::{Format, Spec}; -use libpulse_binding::stream::Direction; -use libpulse_simple_binding::Simple; use log::{error, info, warn}; use std::cell::{Cell, RefCell}; +use std::fs::{File, OpenOptions}; +use std::io::Write; use std::rc::Rc; use std::time::Duration; @@ -19,18 +17,22 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; use crate::audio::agc::Agc; -const SINK_NAME: &str = "AirPodsHiRes_raw"; pub const SOURCE_NAME: &str = "AirPodsHiRes"; +// FIFO the pipe-source reads from and that Output writes PCM into. +fn fifo_path() -> String { + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{dir}/librepods-hires.fifo") +} + pub struct VirtualMic { - sink_module: u32, - source_module: u32, + module: u32, } unsafe impl Send for VirtualMic {} impl VirtualMic { - pub fn open(channels: u8) -> Option { + pub fn open(sample_rate: u32, channels: u8) -> Option { unload_stale_modules(); let chan_map = if channels == 1 { @@ -39,27 +41,18 @@ impl VirtualMic { "front-left,front-right" }; - let sink_args = format!( - "sink_name={SINK_NAME} channel_map={chan_map} \ - sink_properties=\"device.description=AirPods_HiRes_Sink node.driver=false priority.driver=0\"" - ); - let sink_module = match load_module("module-null-sink", &sink_args) { - Some(i) => i, - None => { - warn!("could not load module-null-sink"); - return None; - } - }; + let fifo = fifo_path(); + let _ = std::fs::remove_file(&fifo); // drop any stale FIFO from a prior run - let source_args = format!( - "master={SINK_NAME}.monitor source_name={SOURCE_NAME} channel_map={chan_map} \ + let args = format!( + "source_name={SOURCE_NAME} file={fifo} format=s16le rate={sample_rate} \ + channels={channels} channel_map={chan_map} \ source_properties=\"device.description=AirPods_HiRes_Mic node.driver=false priority.driver=0\"" ); - let source_module = match load_module("module-remap-source", &source_args) { + let module = match load_module("module-pipe-source", &args) { Some(i) => i, None => { - warn!("could not load module-remap-source"); - unload_module(sink_module); + warn!("could not load module-pipe-source"); return None; } }; @@ -68,63 +61,34 @@ impl VirtualMic { "[pw] hi-res mic ready: select '{}' as your microphone", SOURCE_NAME ); - Some(VirtualMic { - sink_module, - source_module, - }) + Some(VirtualMic { module }) } } impl Drop for VirtualMic { fn drop(&mut self) { - unload_module(self.source_module); - unload_module(self.sink_module); + unload_module(self.module); + let _ = std::fs::remove_file(fifo_path()); } } -// Playback stream feeding the null-sink. Opened only while an app is recording. +// Writes PCM into the pipe-source's FIFO. Opened only while an app is recording. pub struct Output { - simple: Simple, + fifo: File, agc: Option, } unsafe impl Send for Output {} impl Output { - pub fn open(sample_rate: u32, channels: u8) -> Option { - let spec = Spec { - format: Format::S16le, - channels, - rate: sample_rate, - }; - if !spec.is_valid() { - error!("invalid sample spec: {} Hz, {} ch", sample_rate, channels); - return None; - } - - let bytes_per_sec = sample_rate * channels as u32 * 2; - let tlength = (bytes_per_sec * 80 / 1000).max(1); // ~80 ms target - let attr = BufferAttr { - maxlength: (bytes_per_sec * 150 / 1000).max(tlength), // ~150 ms cap - tlength, - prebuf: u32::MAX, - minreq: u32::MAX, - fragsize: u32::MAX, - }; - - let simple = match Simple::new( - None, - "LibrePods", - Direction::Playback, - Some(SINK_NAME), - "AirPodsHiRes", - &spec, - None, - Some(&attr), - ) { - Ok(s) => s, + pub fn open(_sample_rate: u32, _channels: u8) -> Option { + // O_RDWR never blocks on a FIFO and keeps the pipe from ever seeing + // "all writers closed"; we only ever write to it. + let path = fifo_path(); + let fifo = match OpenOptions::new().read(true).write(true).open(&path) { + Ok(f) => f, Err(e) => { - error!("could not open hi-res playback stream: {}", e); + error!("could not open hi-res fifo {}: {}", path, e); return None; } }; @@ -135,10 +99,10 @@ impl Output { if agc.is_none() { info!("[pw] AGC disabled; passing through raw hi-res capture"); } - Some(Output { simple, agc }) + Some(Output { fifo, agc }) } - // Write s16 PCM into the sink, returning the (post-AGC) peak + // Write s16 PCM into the FIFO, returning the (post-AGC) peak pub fn write(&mut self, pcm: &[i16]) -> Result { let processed; let pcm: &[i16] = if let Some(agc) = &mut self.agc { @@ -159,10 +123,10 @@ impl Output { std::slice::from_raw_parts(pcm.as_ptr() as *const u8, std::mem::size_of_val(pcm)) }; - self.simple - .write(bytes) + self.fifo + .write_all(bytes) .map(|_| peak) - .map_err(|e| error!("hi-res playback stream broke: {}", e)) + .map_err(|e| error!("hi-res fifo write broke: {}", e)) } } @@ -351,7 +315,7 @@ fn unload_stale_modules() { move |result| { if let ListResult::Item(item) = result { if let Some(arg) = &item.argument { - if arg.contains(SINK_NAME) || arg.contains(SOURCE_NAME) { + if arg.contains(SOURCE_NAME) { stale.borrow_mut().push(item.index); } }