diff --git a/linux-rust/Cargo.lock b/linux-rust/Cargo.lock index 256397efd..1a1f0dc97 100644 --- a/linux-rust/Cargo.lock +++ b/linux-rust/Cargo.lock @@ -118,6 +118,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -408,7 +418,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -422,6 +432,25 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "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 +672,25 @@ 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-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -665,6 +713,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -786,6 +845,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1313,6 +1378,24 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fdk-aac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb67e142688083cb9afb63f2424203fc98c4e7afb494bf912b60b55513b177e" +dependencies = [ + "fdk-aac-sys", +] + +[[package]] +name = "fdk-aac-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24516d2611506d5cb1833555adc75f6baf9fe2706b9c13e6fc33a6b22c51ca83" +dependencies = [ + "cc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1682,6 +1765,12 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.16.0" @@ -2084,7 +2173,7 @@ dependencies = [ "approx", "getrandom 0.3.4", "image", - "itertools", + "itertools 0.14.0", "nalgebra", "num", "rand", @@ -2137,6 +2226,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" @@ -2429,6 +2527,7 @@ dependencies = [ "clap", "dbus", "env_logger", + "fdk-aac", "futures", "hex", "iced", @@ -2436,13 +2535,42 @@ dependencies = [ "imageproc", "ksni", "libpulse-binding", + "libspa", "log", + "pipewire", "serde", "serde_json", "tokio", "uuid", ] +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.11.0", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.4", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + [[package]] name = "lilt" version = "0.8.1" @@ -2580,6 +2708,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 +2883,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" @@ -3375,6 +3519,31 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.4", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3618,7 +3787,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3964,6 +4133,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4287,6 +4465,25 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -4456,6 +4653,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4479,13 +4691,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -4625,6 +4843,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" diff --git a/linux-rust/Cargo.toml b/linux-rust/Cargo.toml index fb373a231..235f55a3b 100644 --- a/linux-rust/Cargo.toml +++ b/linux-rust/Cargo.toml @@ -22,6 +22,15 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" aes = "0.9.0-rc.4" futures = "0.3.32" +fdk-aac = { version = "0.8", optional = true } +pipewire = { version = "0.10", optional = true } +libspa = { version = "0.10", optional = true } + +[features] +default = [] +# Microphone streaming needs a non-free AAC-ELD decoder, so it stays opt-in: +# cargo build --release --features mic +mic = ["dep:fdk-aac", "dep:pipewire", "dep:libspa"] [profile.release] opt-level = "s" diff --git a/linux-rust/src/audio/decoder.rs b/linux-rust/src/audio/decoder.rs new file mode 100644 index 000000000..f52c2a429 --- /dev/null +++ b/linux-rust/src/audio/decoder.rs @@ -0,0 +1,44 @@ +//! AAC-ELD decoding for the AirPods microphone stream. + +use super::eld::{AUDIO_SPECIFIC_CONFIG, PRESENTATION_SAMPLE_RATE}; +use fdk_aac::dec::{Decoder, DecoderError, Transport}; + +/// Largest PCM frame the decoder is allowed to produce, in samples. +const PCM_CAPACITY: usize = 4096; + +pub struct EldDecoder { + decoder: Decoder, + pcm: Vec, +} + +impl EldDecoder { + pub fn new() -> Result { + let mut decoder = Decoder::new(Transport::Raw); + // The buds send raw access units, so the decoder is configured from the + // AudioSpecificConfig rather than from in-band headers. + decoder.config_raw(&AUDIO_SPECIFIC_CONFIG)?; + Ok(Self { + decoder, + pcm: vec![0i16; PCM_CAPACITY], + }) + } + + /// Decodes one access unit, returning the interleaved PCM samples it produced. + pub fn decode(&mut self, unit: &[u8]) -> Result<&[i16], DecoderError> { + self.decoder.fill(unit)?; + self.decoder.decode_frame(&mut self.pcm)?; + + let info = self.decoder.stream_info(); + let samples = (info.frameSize as usize) * (info.numChannels as usize); + Ok(&self.pcm[..samples.min(self.pcm.len())]) + } + + pub fn channels(&self) -> u32 { + self.decoder.stream_info().numChannels.max(1) as u32 + } + + /// Rate the samples should be played back at - see PRESENTATION_SAMPLE_RATE. + pub fn sample_rate(&self) -> u32 { + PRESENTATION_SAMPLE_RATE + } +} diff --git a/linux-rust/src/audio/eld.rs b/linux-rust/src/audio/eld.rs new file mode 100644 index 000000000..d6d3f2651 --- /dev/null +++ b/linux-rust/src/audio/eld.rs @@ -0,0 +1,97 @@ +//! AirPods stream their microphone as AAC-ELD inside AACP packets, which lets +//! playback stay on A2DP instead of dropping to HFP call quality. + +/// AACP opcode carrying microphone data. +pub const AUDIO_STREAM_OPCODE: u8 = 0x58; + +/// Bytes before the first access unit in a microphone SDU. +const SDU_HEADER_SIZE: usize = 22; + +/// Asks the buds to start sending microphone audio over AACP. +pub const START_AUDIO_STREAM: [u8; 19] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x01, 0x82, 0x00, 0x00, 0x00, + 0x04, 0x96, 0x00, +]; + +/// Stops the stream. Must be sent when done: buds left streaming keep the audio +/// stack in a state where PipeWire falls back to HFP and playback degrades. +pub const STOP_AUDIO_STREAM: [u8; 12] = [ + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x01, +]; + +/// AudioSpecificConfig describing the stream: AAC-ELD, 48 kHz coding rate. +pub const AUDIO_SPECIFIC_CONFIG: [u8; 4] = [0xF8, 0xE6, 0x30, 0x00]; + +/// Rate the decoded PCM is presented at. The coding rate in the config above is +/// 48 kHz, but the buds pace frames for 64 kHz playback, so decoding at the +/// coding rate stretches the audio. +pub const PRESENTATION_SAMPLE_RATE: u32 = 64_000; + +/// True when the packet is a microphone SDU rather than a control message. +pub fn is_audio_packet(packet: &[u8]) -> bool { + packet.len() >= 8 + && packet[0] == 0x04 + && packet[2] == 0x04 + && packet[4] == AUDIO_STREAM_OPCODE + && packet[5] == 0x00 + && packet[6] == 0x01 + && packet[7] == 0x00 +} + +/// Splits a microphone SDU into access units, stopping at the first truncated +/// one rather than reading past the packet. +pub fn access_units(packet: &[u8]) -> Vec<&[u8]> { + if !is_audio_packet(packet) || packet.len() < SDU_HEADER_SIZE { + return Vec::new(); + } + + let mut units = Vec::new(); + let mut offset = SDU_HEADER_SIZE; + while offset + 5 <= packet.len() { + // 4 bytes little-endian timestamp, then one length byte. + let length = packet[offset + 4] as usize; + let start = offset + 5; + let Some(end) = start.checked_add(length) else { + break; + }; + if end > packet.len() { + break; + } + units.push(&packet[start..end]); + offset = end; + } + units +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sdu(payload: &[u8]) -> Vec { + let mut p = vec![0u8; SDU_HEADER_SIZE]; + p[0] = 0x04; + p[2] = 0x04; + p[4] = AUDIO_STREAM_OPCODE; + p[6] = 0x01; + p.extend_from_slice(payload); + p + } + + #[test] + fn rejects_control_packets() { + assert!(!is_audio_packet(&[0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02])); + } + + #[test] + fn splits_consecutive_units() { + let packet = sdu(&[1, 0, 0, 0, 3, 0x11, 0x22, 0x33, 2, 0, 0, 0, 2, 0x44, 0x55]); + let units = access_units(&packet); + assert_eq!(units, vec![&[0x11u8, 0x22, 0x33][..], &[0x44u8, 0x55][..]]); + } + + #[test] + fn stops_at_truncated_unit() { + let packet = sdu(&[1, 0, 0, 0, 3, 0x11, 0x22, 0x33, 2, 0, 0, 0, 9, 0x44]); + assert_eq!(access_units(&packet), vec![&[0x11u8, 0x22, 0x33][..]]); + } +} diff --git a/linux-rust/src/audio/mic.rs b/linux-rust/src/audio/mic.rs new file mode 100644 index 000000000..92d9168fe --- /dev/null +++ b/linux-rust/src/audio/mic.rs @@ -0,0 +1,67 @@ +//! Wires the AirPods microphone stream to a PipeWire source: AACP access units +//! are decoded and published without touching the playback profile. + +use super::decoder::EldDecoder; +use super::source::MicSource; +use crate::bluetooth::aacp::AACPManager; +use log::{error, info, warn}; +use std::sync::Arc; +use tokio::task::JoinHandle; + +/// Consecutive decode failures tolerated before giving up on the stream. +const MAX_DECODE_ERRORS: u32 = 50; + +pub struct MicStream { + manager: AACPManager, + task: JoinHandle<()>, +} + +impl MicStream { + /// Starts streaming. Playback stays on A2DP for the whole session. + pub async fn start(manager: AACPManager) -> Result { + let mut decoder = EldDecoder::new().map_err(|e| format!("AAC-ELD decoder: {e}"))?; + let source = Arc::new( + MicSource::start(decoder.sample_rate(), decoder.channels()) + .map_err(|e| format!("PipeWire source: {e}"))?, + ); + + let mut units = manager + .start_audio_stream() + .await + .map_err(|e| format!("start request: {e}"))?; + + let task = tokio::spawn(async move { + let mut errors = 0u32; + let mut frames = 0u64; + while let Some(unit) = units.recv().await { + match decoder.decode(&unit) { + Ok(pcm) => { + source.push(pcm); + frames += 1; + errors = 0; + } + Err(e) => { + errors += 1; + if errors >= MAX_DECODE_ERRORS { + error!("Too many AAC-ELD decode failures ({e}), stopping"); + break; + } + warn!("AAC-ELD decode failed: {e}"); + } + } + } + info!("Microphone stream ended after {frames} frames"); + }); + + Ok(Self { manager, task }) + } + + /// Stops the stream. Skipping this leaves the buds streaming, which makes + /// the audio stack fall back to HFP and degrades playback. + pub async fn stop(self) { + self.task.abort(); + if let Err(e) = self.manager.stop_audio_stream().await { + warn!("Could not stop the microphone stream cleanly: {e}"); + } + } +} diff --git a/linux-rust/src/audio/mod.rs b/linux-rust/src/audio/mod.rs new file mode 100644 index 000000000..765ddfca8 --- /dev/null +++ b/linux-rust/src/audio/mod.rs @@ -0,0 +1,8 @@ +pub mod eld; + +#[cfg(feature = "mic")] +pub mod decoder; +#[cfg(feature = "mic")] +pub mod mic; +#[cfg(feature = "mic")] +pub mod source; diff --git a/linux-rust/src/audio/source.rs b/linux-rust/src/audio/source.rs new file mode 100644 index 000000000..9acd29897 --- /dev/null +++ b/linux-rust/src/audio/source.rs @@ -0,0 +1,173 @@ +//! Publishes decoded AirPods microphone audio as a PipeWire source, so any +//! application can record from it while playback stays on A2DP. + +use log::{error, info}; +use pipewire as pw; +use pw::properties::properties; +use pw::spa; +use spa::pod::Pod; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +const SAMPLE_SIZE: usize = std::mem::size_of::(); + +/// Upper bound on buffered audio. Anything older is dropped rather than played +/// late: a microphone that lags behind is worse than one that skips. +const MAX_QUEUED_SAMPLES: usize = 64_000 / 2; // ~0.5 s at the presentation rate + +enum Command { + Quit, +} + +pub struct MicSource { + samples: Arc>>, + sender: pw::channel::Sender, + thread: Option>, +} + +impl MicSource { + /// Starts the PipeWire node on its own thread. + pub fn start(rate: u32, channels: u32) -> std::io::Result { + let samples = Arc::new(Mutex::new(VecDeque::new())); + let (sender, receiver) = pw::channel::channel(); + + let queue = Arc::clone(&samples); + let thread = std::thread::Builder::new() + .name("librepods-mic".into()) + .spawn(move || { + if let Err(e) = run(queue, receiver, rate, channels) { + error!("Microphone source stopped: {e}"); + } + })?; + + Ok(Self { + samples, + sender, + thread: Some(thread), + }) + } + + /// Queues decoded samples for delivery to whoever is recording. + pub fn push(&self, pcm: &[i16]) { + let Ok(mut queue) = self.samples.lock() else { + return; + }; + queue.extend(pcm.iter().copied()); + if queue.len() > MAX_QUEUED_SAMPLES { + // Nothing is recording, or the reader fell behind. Dropping the + // oldest audio keeps latency bounded; logging every overflow would + // flood the log at 130 frames a second. + let excess = queue.len() - MAX_QUEUED_SAMPLES; + queue.drain(..excess); + } + } +} + +impl Drop for MicSource { + fn drop(&mut self) { + let _ = self.sender.send(Command::Quit); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + info!("Microphone source removed"); + } +} + +fn run( + samples: Arc>>, + receiver: pw::channel::Receiver, + rate: u32, + channels: u32, +) -> Result<(), pw::Error> { + pw::init(); + + let mainloop = pw::main_loop::MainLoopRc::new(None)?; + let context = pw::context::ContextRc::new(&mainloop, None)?; + let core = context.connect_rc(None)?; + + let stream = pw::stream::StreamBox::new( + &core, + "librepods-mic", + properties! { + *pw::keys::MEDIA_TYPE => "Audio", + *pw::keys::MEDIA_CATEGORY => "Capture", + // Audio/Source is what makes this node show up as a microphone. + *pw::keys::MEDIA_CLASS => "Audio/Source", + *pw::keys::NODE_NAME => "librepods_mic", + *pw::keys::NODE_DESCRIPTION => "AirPods Microphone (LibrePods)", + }, + )?; + + let stride = SAMPLE_SIZE * channels as usize; + let _listener = stream + .add_local_listener_with_user_data(samples) + .process(move |stream, samples| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + let data = &mut buffer.datas_mut()[0]; + let Some(slice) = data.data() else { + return; + }; + + let wanted = slice.len() / SAMPLE_SIZE; + let mut written = 0; + if let Ok(mut queue) = samples.lock() { + while written < wanted { + let Some(sample) = queue.pop_front() else { + break; + }; + let start = written * SAMPLE_SIZE; + slice[start..start + SAMPLE_SIZE].copy_from_slice(&sample.to_le_bytes()); + written += 1; + } + } + // Silence rather than stale audio when the buds fall behind. + for i in written..wanted { + let start = i * SAMPLE_SIZE; + slice[start..start + SAMPLE_SIZE].copy_from_slice(&0i16.to_le_bytes()); + } + + let chunk = data.chunk_mut(); + *chunk.offset_mut() = 0; + *chunk.stride_mut() = stride as _; + *chunk.size_mut() = (wanted * SAMPLE_SIZE) as _; + }) + .register()?; + + let mut audio_info = spa::param::audio::AudioInfoRaw::new(); + audio_info.set_format(spa::param::audio::AudioFormat::S16LE); + audio_info.set_rate(rate); + audio_info.set_channels(channels); + + let values: Vec = pw::spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &pw::spa::pod::Value::Object(pw::spa::pod::Object { + type_: pw::spa::sys::SPA_TYPE_OBJECT_Format, + id: pw::spa::sys::SPA_PARAM_EnumFormat, + properties: audio_info.into(), + }), + ) + .map_err(|_| pw::Error::CreationFailed)? + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).ok_or(pw::Error::CreationFailed)?]; + + stream.connect( + spa::utils::Direction::Output, + None, + pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS, + &mut params, + )?; + + let _receiver = receiver.attach(mainloop.loop_(), { + let mainloop = mainloop.clone(); + move |_| mainloop.quit() + }); + + info!("Microphone source published as \"AirPods Microphone (LibrePods)\" at {rate} Hz"); + mainloop.run(); + Ok(()) +} diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index bed6ca853..1ea0f55e4 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -360,6 +360,8 @@ impl AACPManagerState { #[derive(Clone)] pub struct AACPManager { pub state: Arc>, + /// Microphone sink, kept out of `state` so audio never contends for it. + audio_subscriber: Arc>>>>, tasks: Arc>>, } @@ -367,6 +369,7 @@ impl AACPManager { pub fn new() -> Self { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), + audio_subscriber: Arc::new(Mutex::new(None)), tasks: Arc::new(Mutex::new(JoinSet::new())), } } @@ -438,9 +441,62 @@ impl AACPManager { tasks.spawn(send_thread(rx, seq_packet)); } + /// Hands microphone access units to the audio pipeline. Called before + /// control parsing so audio never reaches the command handlers or the logs. + async fn receive_audio_packet(&self, packet: &[u8]) { + let subscriber = self.audio_subscriber.lock().await.clone(); + let Some(subscriber) = subscriber else { + return; + }; + for unit in crate::audio::eld::access_units(packet) { + if subscriber.send(unit.to_vec()).is_err() { + break; + } + } + } + + /// Starts the microphone stream and returns its access units. Playback stays + /// on A2DP: this path never asks the audio stack for an HFP microphone. + pub async fn start_audio_stream(&self) -> Result>> { + let (tx, rx) = mpsc::unbounded_channel(); + *self.audio_subscriber.lock().await = Some(tx); + self.send_packet(&crate::audio::eld::START_AUDIO_STREAM) + .await?; + info!("Started AirPods microphone stream"); + Ok(rx) + } + + /// Stops the stream. Buds left streaming keep the audio stack in a state + /// where playback falls back to HFP, so this must run on every teardown. + pub async fn stop_audio_stream(&self) -> Result<()> { + *self.audio_subscriber.lock().await = None; + let result = self + .send_packet(&crate::audio::eld::STOP_AUDIO_STREAM) + .await; + info!("Stopped AirPods microphone stream"); + result + } + async fn send_packet(&self, data: &[u8]) -> Result<()> { - let state = self.state.lock().await; - if let Some(sender) = &state.sender { + // The sender is cloned and the lock released before awaiting: holding + // the state mutex across a full channel stalls every other task, + // including the receive path, and wedges the whole manager. + let sender = match tokio::time::timeout( + std::time::Duration::from_secs(2), + self.state.lock(), + ) + .await + { + Ok(state) => state.sender.clone(), + Err(_) => { + error!("send_packet: state mutex held for over 2s, giving up"); + return Err(Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "state mutex busy", + ))); + } + }; + if let Some(sender) = sender { sender.send(data.to_vec()).await.map_err(|e| { error!("Failed to send packet to channel: {}", e); Error::from(std::io::Error::new( @@ -495,6 +551,10 @@ impl AACPManager { } pub async fn receive_packet(&self, packet: &[u8]) { + if crate::audio::eld::is_audio_packet(packet) { + self.receive_audio_packet(packet).await; + return; + } if !packet.starts_with(&HEADER_BYTES) { debug!( "Received packet does not start with expected header: {}", @@ -1203,7 +1263,9 @@ impl AACPManager { } async fn recv_thread(manager: AACPManager, sp: Arc) { - let mut buf = vec![0u8; 1024]; + // Microphone SDUs are far larger than control packets and a short buffer + // truncates them, so the receive buffer is sized for audio. + let mut buf = vec![0u8; 8192]; loop { match sp.recv(&mut buf).await { Ok(0) => { @@ -1212,7 +1274,11 @@ async fn recv_thread(manager: AACPManager, sp: Arc) { } Ok(n) => { let data = &buf[..n]; - debug!("Received {} bytes: {}", n, hex::encode(data)); + // Audio arrives ~130 times a second; hex-dumping it floods the + // log and burns CPU, so only control traffic is logged. + if !crate::audio::eld::is_audio_packet(data) { + debug!("Received {} bytes: {}", n, hex::encode(data)); + } manager.receive_packet(data).await; } Err(e) => { diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index 69918bf3a..c38e5f75b 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -1,5 +1,6 @@ -use crate::bluetooth::aacp::BatteryStatus; +use crate::bluetooth::aacp::{AACPEvent, BatteryComponent, BatteryInfo, BatteryStatus}; use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::BluetoothUIMessage; use crate::ui::tray::MyTray; use crate::utils::{ah, get_devices_path, get_preferences_path}; use aes::Aes128; @@ -15,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::mpsc::UnboundedSender; fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&Array::from(*key)); @@ -46,7 +48,10 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { hash == computed_hash } -pub async fn start_le_monitor(tray_handle: Option>) -> bluer::Result<()> { +pub async fn start_le_monitor( + tray_handle: Option>, + ui_tx: UnboundedSender, +) -> bluer::Result<()> { let session = Session::new().await?; let adapter = session.default_adapter().await?; adapter.set_powered(true).await?; @@ -143,6 +148,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue if matched_airpods_mac.is_some() { let mut events = dev.events().await?; let tray_handle_clone = tray_handle.clone(); + let ui_tx_clone = ui_tx.clone(); let connecting_macs_clone = Arc::clone(&connecting_macs); tokio::spawn(async move { while let Some(ev) = events.next().await { @@ -246,6 +252,7 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue ); } } + } else { info!( "Auto-connect is disabled for {}, not attempting to connect.", matched_airpods_mac.as_ref().unwrap() @@ -336,6 +343,36 @@ pub async fn start_le_monitor(tray_handle: Option>) -> blue .await; } + // The tray is not the only consumer: the window shows the + // case level too, and over AACP the case reports itself as + // disconnected whenever the buds are outside it. + if let Some(mac) = matched_airpods_mac.as_ref() { + let battery_info = [ + (BatteryComponent::Left, left_byte, left_battery, left_charging), + (BatteryComponent::Right, right_byte, right_battery, right_charging), + (BatteryComponent::Case, case_byte, case_battery, case_charging), + ] + .into_iter() + .filter(|(_, raw, _, _)| *raw != 0xff) + .map(|(component, _, level, charging)| BatteryInfo { + component, + level: level as u8, + status: if charging { + BatteryStatus::Charging + } else { + BatteryStatus::NotCharging + }, + }) + .collect::>(); + + if !battery_info.is_empty() { + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac.clone(), + AACPEvent::BatteryInfo(battery_info), + )); + } + } + debug!( "Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", if left_byte == 0xff { diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs index f0e876cf0..221a106ff 100644 --- a/linux-rust/src/devices/airpods.rs +++ b/linux-rust/src/devices/airpods.rs @@ -142,6 +142,41 @@ impl AirPodsDevice { } }); + // The microphone runs over AACP, so it never asks the audio stack for an + // HFP mic and playback keeps its A2DP profile. + #[cfg(feature = "mic")] + { + let (mic_tx, mut mic_rx) = tokio::sync::mpsc::unbounded_channel::(); + if let Some(handle) = &tray_handle { + handle + .update(|tray: &mut MyTray| tray.mic_tx = Some(mic_tx.clone())) + .await; + } + + let mic_manager = aacp_manager.clone(); + tokio::spawn(async move { + let mut stream: Option = None; + while let Some(enable) = mic_rx.recv().await { + match (enable, stream.take()) { + (true, None) => { + match crate::audio::mic::MicStream::start(mic_manager.clone()).await { + Ok(started) => stream = Some(started), + Err(e) => log::error!("Could not start the microphone: {e}"), + } + } + (true, running) => stream = running, + (false, Some(running)) => running.stop().await, + (false, None) => {} + } + } + // The channel closes when the device goes away; leaving the buds + // streaming would push the audio stack back to HFP. + if let Some(running) = stream.take() { + running.stop().await; + } + }); + } + let mc_listener = media_controller.lock().await; let aacp_manager_clone_listener = aacp_manager.clone(); mc_listener diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index f43f575b2..1879e14e9 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; @@ -87,15 +88,20 @@ fn main() -> iced::Result { // Run headless without UI info!("Running in headless mode (no GUI)"); let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx, device_managers)).unwrap(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } Ok(()) } else { // Run with UI let device_managers_clone = device_managers.clone(); std::thread::spawn(|| { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async_main(ui_tx, device_managers_clone)) - .unwrap(); + if let Err(e) = rt.block_on(async_main(ui_tx, device_managers_clone)) { + log::error!("LibrePods could not start: {e}"); + std::process::exit(1); + } }); ui::window::start_ui(ui_rx, args.start_minimized, device_managers) @@ -143,20 +149,38 @@ async fn async_main( listening_mode: None, allow_off_option: None, command_tx: None, + mic_tx: None, + mic_enabled: false, ui_tx: Some(ui_tx.clone()), }; let handle = tray.spawn().await.unwrap(); Some(handle) }; - let session = bluer::Session::new().await?; - let adapter = session.default_adapter().await?; - adapter.set_powered(true).await?; + let session = bluer::Session::new().await.inspect_err(|e| { + log::error!( + "Cannot talk to BlueZ over D-Bus: {e}. Is the bluetooth service running? \ + Check with `systemctl status bluetooth`." + ) + })?; + let adapter = session.default_adapter().await.inspect_err(|e| { + log::error!( + "No Bluetooth adapter available: {e}. Make sure an adapter is present \ + and not blocked - see `rfkill list bluetooth`." + ) + })?; + adapter.set_powered(true).await.inspect_err(|e| { + log::error!( + "Cannot power on the Bluetooth adapter: {e}. It is likely soft-blocked, \ + try `rfkill unblock bluetooth`." + ) + })?; let le_tray_clone = tray_handle.clone(); + let le_ui_tx = ui_tx.clone(); tokio::spawn(async move { info!("Starting LE monitor..."); - if let Err(e) = start_le_monitor(le_tray_clone).await { + if let Err(e) = start_le_monitor(le_tray_clone, le_ui_tx).await { log::error!("LE monitor error: {}", e); } }); diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs index 5bd68b1a2..5a80a6d5f 100644 --- a/linux-rust/src/media_controller.rs +++ b/linux-rust/src/media_controller.rs @@ -29,6 +29,7 @@ struct OwnedCardInfo { index: u32, proplist: Proplist, profiles: Vec, + active_profile: Option, } #[derive(Clone, Debug)] @@ -301,75 +302,133 @@ impl MediaController { } } + /// Number of one-second attempts to wait for A2DP profiles to be enumerated. + const A2DP_ENUMERATION_ATTEMPTS: u32 = 5; + + /// Currently active profile of the card, used to avoid pointless switches. + async fn get_active_profile(&self, card_index: u32) -> Option { + tokio::task::spawn_blocking(move || { + get_card_info_list_sync() + .iter() + .find(|c| c.index == card_index) + .and_then(|c| c.active_profile.clone()) + }) + .await + .unwrap_or(None) + } + + /// Resolves the card by Bluetooth MAC and refreshes the cached index. + /// PipeWire can assign a different index after a disconnect/reconnect. + async fn refresh_device_index(&self) -> Option { + let mac = self.state.lock().await.connected_device_mac.clone(); + if mac.is_empty() { + return None; + } + let index = self.get_audio_device_index(&mac).await; + self.state.lock().await.device_index = index; + index + } + + /// Waits for the card to expose an A2DP profile, re-resolving the card on + /// every attempt. Sleeps between attempts, but never after the last one. + async fn wait_for_a2dp_profile(&self, attempts: u32) -> bool { + for attempt in 0..attempts { + if self.refresh_device_index().await.is_some() && self.is_a2dp_profile_available().await + { + return true; + } + if attempt + 1 < attempts { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + false + } + + async fn restart_wire_plumber(&self) -> bool { + info!("Restarting WirePlumber to rediscover A2DP profiles"); + let result = Command::new("systemctl") + .args(["--user", "restart", "wireplumber"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + info!("WirePlumber restarted successfully"); + tokio::time::sleep(Duration::from_secs(2)).await; + true + } + _ => { + error!("Failed to restart WirePlumber. Do you use wireplumber?"); + false + } + } + } + pub async fn activate_a2dp_profile(&self) { debug!("Entering activate_a2dp_profile"); - let state = self.state.lock().await; - if state.connected_device_mac.is_empty() { + if self.state.lock().await.connected_device_mac.is_empty() { warn!("Connected device MAC is empty, cannot activate A2DP profile"); return; } - let device_index = state.device_index; - let mac = state.connected_device_mac.clone(); - drop(state); - - let mut current_device_index = device_index; - - if current_device_index.is_none() { - warn!("Device index not found, trying to get it."); - current_device_index = self.get_audio_device_index(&mac).await; - if let Some(idx) = current_device_index { - self.state.lock().await.device_index = Some(idx); - } else { - warn!("Could not get device index. Cannot activate A2DP profile."); - return; - } + // Always resolve the card by MAC first: a reconnect can change its index. + if self.refresh_device_index().await.is_none() { + warn!("Could not get device index. Cannot activate A2DP profile."); + return; } if !self.is_a2dp_profile_available().await { - warn!("A2DP profile not available, attempting to restart WirePlumber"); - if self.restart_wire_plumber().await { - let mut state = self.state.lock().await; - state.device_index = self - .get_audio_device_index(&state.connected_device_mac) - .await; - debug!( - "Updated device_index after WirePlumber restart: {:?}", - state.device_index - ); - if !self.is_a2dp_profile_available().await { - error!("A2DP profile still not available after WirePlumber restart"); + // A freshly connected card can show up before its profiles are + // enumerated. Give that a grace period before restarting + // WirePlumber, which interrupts playback for several seconds. + warn!("A2DP profile not available yet, waiting for enumeration"); + if !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + warn!("A2DP profile still missing, restarting WirePlumber as a last resort"); + if !self.restart_wire_plumber().await + || !self + .wait_for_a2dp_profile(Self::A2DP_ENUMERATION_ATTEMPTS) + .await + { + error!("A2DP profile unavailable, skipping profile activation"); return; } - } else { - error!("Could not restart WirePlumber, A2DP profile unavailable"); - return; } } + let Some(idx) = self.state.lock().await.device_index else { + error!("Device index not available for activating profile."); + return; + }; + + // Leave an already-active A2DP variant alone. Switching profiles + // recreates the PipeWire sink and stops the stream that triggered this + // activation, so players pause again right after the user pressed play. + if let Some(active) = self.get_active_profile(idx).await + && active.starts_with("a2dp") + { + debug!("A2DP profile {} already active, leaving it unchanged", active); + return; + } + let preferred_profile = self.get_preferred_a2dp_profile().await; if preferred_profile.is_empty() { error!("No suitable A2DP profile found"); return; } - let device_index = self.state.lock().await.device_index; - if let Some(idx) = device_index { - info!("Activating A2DP profile for AirPods: {}", preferred_profile); - let profile_name = preferred_profile.clone(); - let success = - tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) - .await - .unwrap_or(false); - - if success { - info!("Successfully activated A2DP profile: {}", preferred_profile); - } else { - warn!("Failed to activate A2DP profile: {}", preferred_profile); - } + info!("Activating A2DP profile for AirPods: {}", preferred_profile); + let profile_name = preferred_profile.clone(); + let success = tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) + .await + .unwrap_or(false); + + if success { + info!("Successfully activated A2DP profile: {}", preferred_profile); } else { - error!("Device index not available for activating profile."); + warn!("Failed to activate A2DP profile: {}", preferred_profile); } } @@ -564,7 +623,7 @@ impl MediaController { return cached_profile; } - for profile in ["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"] { + for profile in crate::utils::load_preferred_codec().profile_order() { if self.is_profile_available(index, profile).await { info!("Selected best available A2DP profile: {}", profile); self.state.lock().await.cached_a2dp_profile = profile.to_string(); @@ -594,25 +653,6 @@ impl MediaController { .unwrap_or(false) } - async fn restart_wire_plumber(&self) -> bool { - info!("Restarting WirePlumber to rediscover A2DP profiles"); - let result = Command::new("systemctl") - .args(["--user", "restart", "wireplumber"]) - .output(); - - match result { - Ok(output) if output.status.success() => { - info!("WirePlumber restarted successfully"); - tokio::time::sleep(Duration::from_secs(2)).await; - true - } - _ => { - error!("Failed to restart WirePlumber. Do you use wireplumber?"); - false - } - } - } - async fn get_audio_device_index(&self, mac: &str) -> Option { if mac.is_empty() { return None; @@ -920,10 +960,15 @@ fn get_card_info_list_sync() -> Vec { name: p.name.as_ref().map(|n| n.to_string()), }) .collect(); + let active_profile = item + .active_profile + .as_ref() + .and_then(|p| p.name.as_ref().map(|n| n.to_string())); cards.borrow_mut().push(OwnedCardInfo { index: item.index, proplist: item.proplist.clone(), profiles, + active_profile, }); } } diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs index b3adbc53a..04e8f5f17 100644 --- a/linux-rust/src/ui/tray.rs +++ b/linux-rust/src/ui/tray.rs @@ -23,6 +23,9 @@ pub struct MyTray { pub listening_mode: Option, pub allow_off_option: Option, pub command_tx: Option)>>, + /// Requests the microphone stream to start (true) or stop (false). + pub mic_tx: Option>, + pub mic_enabled: bool, pub ui_tx: Option>, } @@ -182,6 +185,22 @@ impl ksni::Tray for MyTray { ..Default::default() } .into(), + CheckmarkItem { + label: "AirPods Microphone".into(), + checked: self.mic_enabled, + // Only offered by builds that ship the AAC-ELD decoder. + enabled: self.mic_tx.is_some(), + activate: Box::new(|this: &mut Self| { + if let Some(tx) = &this.mic_tx { + let enable = !this.mic_enabled; + if tx.send(enable).is_ok() { + this.mic_enabled = enable; + } + } + }), + ..Default::default() + } + .into(), StandardItem { label: "Exit".into(), icon_name: "application-exit".into(), diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..82f221060 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,14 +9,14 @@ 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::{MyTheme, PreferredCodec, get_app_settings_path, get_devices_path}; 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, + Space, button, column, combo_box, container, pane_grid, pick_list, row, rule, scrollable, text, text_input, toggler }; use iced::{Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, daemon, window, Settings, Program}; @@ -76,6 +76,7 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + preferred_codec: PreferredCodec, } pub struct BluetoothState { @@ -108,6 +109,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), + PreferredCodecChanged(PreferredCodec), } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -166,6 +168,11 @@ impl App { .and_then(|v| v.get("stem_control").cloned()) .and_then(|s| serde_json::from_value(s).ok()) .unwrap_or(false); + let preferred_codec = settings + .clone() + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default(); let bluetooth_state = BluetoothState::new(); @@ -217,6 +224,7 @@ impl App { device_managers, tray_text_mode, stem_control, + preferred_codec, }, Task::batch(vec![open_task, wait_task]), ) @@ -253,6 +261,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -631,6 +640,24 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, + }); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::PreferredCodecChanged(codec) => { + self.preferred_codec = codec; + 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, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -647,6 +674,7 @@ impl App { "theme": self.selected_theme, "tray_text_mode": self.tray_text_mode, "stem_control": self.stem_control, + "preferred_codec": self.preferred_codec, }); debug!( "Writing settings to {}: {}", @@ -927,6 +955,45 @@ impl App { } } Tab::Settings => { + let preferred_codec_picker = container( + row![ + column![ + text("Preferred audio codec").size(16), + text("Codec to activate for playback. The others are used as fallbacks if the chosen one is unavailable.").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), + pick_list( + PreferredCodec::ALL, + Some(self.preferred_codec), + Message::PreferredCodecChanged, + ) + ] + .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 tray_text_mode_toggle = container( row![ column![ @@ -1096,6 +1163,26 @@ impl App { ) .align_y(Center); + let audio_settings_col = column![ + container( + text("Audio").size(20).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + ) + ) + .padding(Padding{ + top: 0.0, + bottom: 0.0, + left: 18.0, + right: 18.0, + }), + preferred_codec_picker + ] + .spacing(12); + let controls_settings_col = column![ container( text("Controls").size(20).style( @@ -1123,6 +1210,8 @@ impl App { tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + audio_settings_col, ] ) .padding(20) diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..697e933a6 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -51,6 +51,60 @@ pub fn get_app_settings_path() -> PathBuf { new_path } +/// Preferred A2DP codec. The chosen one is tried first, the rest act as fallbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum PreferredCodec { + #[default] + Aac, + SbcXq, + Sbc, +} + +impl PreferredCodec { + pub const ALL: [PreferredCodec; 3] = [Self::Aac, Self::SbcXq, Self::Sbc]; + + /// PulseAudio/PipeWire card profile name for this codec. + pub fn profile_name(self) -> &'static str { + match self { + Self::Aac => "a2dp-sink", + Self::SbcXq => "a2dp-sink-sbc_xq", + Self::Sbc => "a2dp-sink-sbc", + } + } + + /// Profiles to try, most preferred first. + pub fn profile_order(self) -> Vec<&'static str> { + std::iter::once(self.profile_name()) + .chain( + Self::ALL + .iter() + .filter(|c| **c != self) + .map(|c| c.profile_name()), + ) + .collect() + } +} + +impl std::fmt::Display for PreferredCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Aac => "AAC", + Self::SbcXq => "SBC-XQ", + Self::Sbc => "SBC", + }) + } +} + +/// Reads the preferred codec from the app settings file, falling back to AAC. +pub fn load_preferred_codec() -> PreferredCodec { + std::fs::read_to_string(get_app_settings_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("preferred_codec").cloned()) + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default() +} + fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let mut swapped_key = *key; swapped_key.reverse(); diff --git a/linux/README.md b/linux/README.md index 742d99115..3fe2de6f4 100644 --- a/linux/README.md +++ b/linux/README.md @@ -101,6 +101,37 @@ systemctl --user enable --now mpris-proxy - View battery levels - Control playback +## Microphone (AAC-ELD) + +AirPods can stream their microphone over AACP instead of HFP, so playback keeps +its A2DP profile and full quality while the microphone is in use. Enabling it +publishes a PipeWire source named **AirPods Microphone (LibrePods)** that any +application can record from. + +This needs a decoder for AAC-ELD, which is not part of `fdk-aac-free`, so the +feature is opt-in: + +```bash +# Fedora: fdk-aac lives in RPM Fusion (nonfree) +sudo dnf install fdk-aac-devel pipewire-devel clang + +cargo build --release --features mic +``` + +Turn it on from **AirPods Microphone** in the tray menu, then pick the source in +your application. + +One WirePlumber policy gets in the way. `bluetooth.autoswitch-to-headset-profile` +reacts to *any* capture stream by pausing media players in preparation for an HFP +switch, so playback pauses even though this microphone never needs HFP: + +```bash +wpctl settings --save bluetooth.autoswitch-to-headset-profile false +``` + +With that disabled the headset microphone is no longer offered automatically - +which is the point, since this source replaces it without degrading playback. + ## Hearing Aid To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. But, to adjust the settings and set the audiogram, you need to use a different script which is located in this folder as `hearing_aid.py`. You can run it with: