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: | 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..5c872d2c6 --- /dev/null +++ b/linux-rust/src/audio/hires_mic.rs @@ -0,0 +1,322 @@ +//! 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::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +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, 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); +/// 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)] +pub struct MicStatus { + level: Arc, + active: Arc, + app: Arc>>, + last_sdu: Arc>>, +} + +impl MicStatus { + pub fn new() -> Self { + 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 active(&self) -> bool { + self.active.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.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 { + stop: Arc, + monitor: Option>, +} + +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 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(), + wake, + vmic, + )); + Some(HiResMic { + stop, + monitor: Some(monitor), + }) + } + + // 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; + } + } + + 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 +// 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, + 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; + info!( + "[hires] activity monitor started, watching '{}'", + output::SOURCE_NAME + ); + + 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 (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); + capture = Some(c); + + // 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; + } + } + } + (true, true, true) => { + status.set_capture(app); + let stalled = status.since_last_sdu().is_some_and(|d| d > STALL_TIMEOUT); + if stalled { + warn!( + "[hires] no audio from device 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(); + } + } + } + // 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() { + stop_capture(c, &aacp, &addr).await; + if let Some(prev) = old_convo_state.take() { + aacp.set_conversation_detection(prev).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; + // 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); + 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; +} + +fn spawn_decode_thread( + mut rx: mpsc::Receiver>, + mut decoder: EldDecoder, + mut output: Output, + status: MicStatus, +) -> 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() { + pcm.clear(); + aacp_audio::demux_type58(&sdu, |au| match decoder.decode(au, &mut pcm) { + Some(_) => frames += 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() { + 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 + }; + 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; + info!( + "[audio] {} frames ({:.0}s), {} errors, level {:.2}", + frames, secs, errors, env + ); + } + } + status.set_level(0.0); + info!( + "[audio] hi-res decode loop ended ({} frames, {} errors)", + frames, errors + ); + }) + .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..0265b981b --- /dev/null +++ b/linux-rust/src/audio/mod.rs @@ -0,0 +1,4 @@ +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..3b858ebe4 --- /dev/null +++ b/linux-rust/src/audio/output.rs @@ -0,0 +1,403 @@ +//! 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 std::time::Duration; + +use dbus::blocking::Connection; +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"; + +pub struct VirtualMic { + sink_module: u32, + source_module: u32, +} + +unsafe impl Send for VirtualMic {} + +impl VirtualMic { + pub fn open(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; + } + }; + + 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: 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, + Err(e) => { + error!("could not open hi-res playback stream: {}", e); + return None; + } + }; + + 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 + pub fn write(&mut self, pcm: &[i16]) -> Result { + 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() + .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, std::mem::size_of_val(pcm)) + }; + + self.simple + .write(bytes) + .map(|_| peak) + .map_err(|e| error!("hi-res playback stream broke: {}", e)) + } +} + +// 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: +// 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 card = format!("bluez_card.{}", bdaddr.replace(':', "_")); + let Some((mut mainloop, mut context)) = connect() else { + return; + }; + let mut introspect = context.introspect(); + + 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); + } + + 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)> { + 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..2212a179d 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -5,13 +5,14 @@ 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; 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}; @@ -20,6 +21,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 +350,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 +375,7 @@ impl AACPManagerState { event_tx: None, devices, airpods_mac: None, + audio_tx: None, } } } @@ -361,6 +384,15 @@ impl AACPManagerState { pub struct AACPManager { pub state: Arc>, 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). + runtime: tokio::runtime::Handle, } impl AACPManager { @@ -368,6 +400,123 @@ impl AACPManager { AACPManager { state: Arc::new(Mutex::new(AACPManagerState::new())), tasks: Arc::new(Mutex::new(JoinSet::new())), + hires_enabled: Arc::new(AtomicBool::new( + 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(), + } + } + + 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; + } + // 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], + })); + } + } + + pub fn mic_level(&self) -> f32 { + self.mic_status.level() + } + + 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 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; + } + } + + // 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.as_ref().is_some_and(|m| m.is_running()) { + return; + } + let addr = self.state.lock().await.airpods_mac.map(|a| a.to_string()); + let Some(addr) = addr else { + return; + }; + 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"), + } + } + + // 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().await; } } @@ -921,6 +1070,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 +1378,8 @@ impl AACPManager { } async fn recv_thread(manager: AACPManager, sp: Arc) { - let mut buf = vec![0u8; 1024]; + let mut buf = vec![0u8; RECV_BUF_LEN]; + manager.arm_hires_mic().await; loop { match sp.recv(&mut buf).await { Ok(0) => { @@ -1212,6 +1388,21 @@ 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) { + // 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() + }; + 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; } @@ -1228,28 +1419,31 @@ 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) { 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 f43f575b2..d7b858f11 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; @@ -10,7 +11,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}; @@ -25,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 { @@ -126,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 { @@ -144,9 +148,18 @@ async fn async_main( allow_off_option: None, command_tx: None, ui_tx: Some(ui_tx.clone()), + shutdown_tx: Some(shutdown_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,9 +184,15 @@ 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; + 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()); @@ -187,6 +206,12 @@ async fn async_main( )) { warn!("Failed to send DeviceConnected UI message: {:?}", e); } + // LIBREPODS_HIRES_MIC=1: enable the hi-res mic feature headlessly. + if env::var("LIBREPODS_HIRES_MIC").is_ok() { + tokio::spawn(async move { + hires_aacp.set_hires_mic_enabled(true).await; + }); + } } Err(_) => { info!("No connected AirPods found."); @@ -309,6 +334,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(); @@ -334,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/airpods.rs b/linux-rust/src/ui/airpods.rs index 9335b5e05..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(); @@ -159,7 +160,7 @@ pub fn airpods_view<'a>( selected_background: Background::Color( theme.palette().primary.scale_alpha(0.3), ), - shadow: Default::default() + shadow: Default::default(), }) } ] @@ -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) @@ -362,6 +367,64 @@ pub fn airpods_view<'a>( ) }; + let hires_mic_toggle = { + let aacp_manager_mic = aacp_manager.clone(); + let mac = mac.clone(); + let state = state.clone(); + 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![ + column![ + text("Hi-Res Microphone").size(16), + 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)); + 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 { + aacp_manager.set_hires_mic_enabled(is_enabled).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); + + let mut content = column![header].spacing(10); + if mic_active { + content = content.push(level_meter(level, mic_app)); + } + + container(content) + .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 +579,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) @@ -523,6 +588,62 @@ pub fn airpods_view<'a>( .height(Length::Fill) } +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![ + 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(label).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/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(), diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 4574b97ce..b81cc5c05 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -9,21 +9,24 @@ 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 bluer::{Address}; +use crate::utils::{AppSettings, MyTheme, 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, - 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() }) @@ -76,6 +83,10 @@ pub struct App { selected_device_type: Option, tray_text_mode: bool, stem_control: bool, + hires_mic_enabled: bool, + hires_mic_agc: bool, + hires_mic_pause_convo: bool, + a2dp_reset: bool, } pub struct BluetoothState { @@ -108,6 +119,10 @@ 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), + HiResMicAgcChanged(bool), + HiResMicPauseConvoChanged(bool), + MicLevelTick, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -134,7 +149,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 { @@ -147,25 +161,14 @@ 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 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(); @@ -217,11 +220,28 @@ impl App { device_managers, tray_text_mode, stem_control, + hires_mic_enabled, + hires_mic_agc, + hires_mic_pause_convo, + a2dp_reset, }, 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, + hires_mic_agc: self.hires_mic_agc, + hires_mic_pause_convo: self.hires_mic_pause_convo, + a2dp_reset: self.a2dp_reset, + } + .save(); + } + fn title(&self, _id: window::Id) -> String { "LibrePods".to_string() } @@ -248,21 +268,11 @@ 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), + Message::MicLevelTick => Task::none(), Message::BluetoothMessage(ui_message) => { match ui_message { BluetoothUIMessage::NoOp => { @@ -382,6 +392,7 @@ impl App { status.identifier == ControlCommandIdentifiers::AllowOffOption && matches!(status.value.as_slice(), [0x01]) }), + hires_mic_enabled: self.hires_mic_enabled, })); } Some(DeviceType::Nothing) => { @@ -416,7 +427,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()); } @@ -592,6 +604,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_ = { @@ -626,34 +644,27 @@ 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() + } + Message::A2dpResetChanged(is_enabled) => { + self.a2dp_reset = is_enabled; + self.save_settings(); + Task::none() + } + Message::HiResMicAgcChanged(is_enabled) => { + self.hires_mic_agc = is_enabled; + self.save_settings(); + Task::none() + } + Message::HiResMicPauseConvoChanged(is_enabled) => { + self.hires_mic_pause_convo = is_enabled; + self.save_settings(); Task::none() } } @@ -875,7 +886,8 @@ impl App { id, &devices_list, state, - aacp_manager.clone() + aacp_manager.clone(), + self.hires_mic_pause_convo )) }) } @@ -1096,6 +1108,129 @@ 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 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 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( @@ -1123,6 +1258,12 @@ impl App { tray_text_mode_toggle, Space::new().height(Length::from(20)), controls_settings_col, + Space::new().height(Length::from(20)), + 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) @@ -1301,7 +1442,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 + } } } diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs index 88ee466a8..1e179e103 100644 --- a/linux-rust/src/utils.rs +++ b/linux-rust/src/utils.rs @@ -13,6 +13,38 @@ pub fn get_devices_path() -> PathBuf { .join("devices.json") } +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())); @@ -24,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() { @@ -128,6 +159,56 @@ 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, + pub hires_mic_agc: bool, + pub hires_mic_pause_convo: bool, + pub a2dp_reset: bool, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + theme: MyTheme::Dark, + tray_text_mode: false, + stem_control: false, + hires_mic_enabled: true, + hires_mic_agc: true, + hires_mic_pause_convo: true, + a2dp_reset: 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 {