From 24ac4afb68f3f47b2a666594f08577acf2e560b9 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:08 +0200 Subject: [PATCH 1/3] Adds button to publish microphone via platform audio --- README.md | 1 + src/media/mic_track.rs | 68 ++++++++++++++++++++++++++++++++++++++++++ src/media/mod.rs | 9 ++++-- src/room/menu_bar.rs | 7 +++++ src/service.rs | 17 ++++++++++- 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 src/media/mic_track.rs diff --git a/README.md b/README.md index 4d6858b..c06449e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ An example of building a cross-platform, GUI application using the [LiveKit Rust - [x] Connect to multiple LiveKit rooms - [x] Use either pre-generated token or project API key/secret - [x] Publish test tracks +- [x] Publish local microphone audio (platform audio) - [x] Subscribe to tracks - [x] Simulate fault scenarios (e.g., reconnect, migration, etc.) - [x] Send/receive remote procedure calls (RPC) diff --git a/src/media/mic_track.rs b/src/media/mic_track.rs new file mode 100644 index 0000000..5c5d20a --- /dev/null +++ b/src/media/mic_track.rs @@ -0,0 +1,68 @@ +use livekit::options::TrackPublishOptions; +use livekit::prelude::*; +use std::sync::Arc; + +/// Publishes real microphone audio via the platform Audio Device Module (ADM). +/// Unlike [`crate::media::SineTrack`], frames are captured automatically by +/// WebRTC, so there is no generation task. +pub struct MicTrack { + room: Arc, + handle: Option, +} + +struct TrackHandle { + track: LocalAudioTrack, + // Held for its lifetime to keep the platform ADM recording; dropping it + // releases the ADM. Underscore-prefixed: held for its drop, never read. + _platform_audio: PlatformAudio, +} + +impl MicTrack { + pub fn new(room: Arc) -> Self { + Self { room, handle: None } + } + + pub fn is_published(&self) -> bool { + self.handle.is_some() + } + + pub async fn publish(&mut self) -> Result<(), RoomError> { + let platform_audio = match PlatformAudio::new() { + Ok(audio) => audio, + Err(err) => { + log::error!("failed to initialize platform audio: {err}"); + return Ok(()); // stay unpublished so the toggle can retry + } + }; + + let track = LocalAudioTrack::create_audio_track("microphone", platform_audio.rtc_source()); + + self.room + .local_participant() + .publish_track( + LocalTrack::Audio(track.clone()), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await?; + + self.handle = Some(TrackHandle { + track, + _platform_audio: platform_audio, + }); + Ok(()) + } + + pub async fn unpublish(&mut self) -> Result<(), RoomError> { + if let Some(handle) = self.handle.take() { + self.room + .local_participant() + .unpublish_track(&handle.track.sid()) + .await?; + } + + Ok(()) + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs index 547ea54..bdf0bda 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -1,12 +1,15 @@ //! Media plumbing over WebRTC, independent of the rest of the app (no `crate::` -//! deps): the synthetic local sources we publish — a generated logo video -//! ([`LogoTrack`]) and a sine-wave audio track ([`SineTrack`]) — plus the -//! [`VideoRenderer`] that turns incoming video frames into egui textures. +//! deps): the local sources we publish — a generated logo video +//! ([`LogoTrack`]), a synthetic sine-wave audio track ([`SineTrack`]), and a +//! real microphone audio track ([`MicTrack`]) — plus the [`VideoRenderer`] that +//! turns incoming video frames into egui textures. pub mod logo_track; +pub mod mic_track; pub mod sine_track; pub mod video_renderer; pub use logo_track::LogoTrack; +pub use mic_track::MicTrack; pub use sine_track::{SineParameters, SineTrack}; pub use video_renderer::VideoRenderer; diff --git a/src/room/menu_bar.rs b/src/room/menu_bar.rs index 7aa394b..a4f0a00 100644 --- a/src/room/menu_bar.rs +++ b/src/room/menu_bar.rs @@ -29,6 +29,13 @@ fn publish_menu(ui: &mut egui::Ui, service: &LkService) { if ui.button("Sine Wave").clicked() { let _ = service.send(AsyncCmd::ToggleSine); } + if ui + .button("Microphone") + .on_hover_text("Publish real microphone audio via platform audio (ADM)") + .clicked() + { + let _ = service.send(AsyncCmd::ToggleMic); + } if ui.button("Data Track").clicked() { let _ = service.send(AsyncCmd::ToggleDataTrack); } diff --git a/src/service.rs b/src/service.rs index 0abc1f1..f3deb13 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,4 +1,4 @@ -use crate::media::{LogoTrack, SineParameters, SineTrack}; +use crate::media::{LogoTrack, MicTrack, SineParameters, SineTrack}; use livekit::{ SimulateScenario, StreamByteOptions, StreamTextOptions, e2ee::{E2eeOptions, EncryptionType, key_provider::*}, @@ -25,6 +25,7 @@ pub enum AsyncCmd { }, ToggleLogo, ToggleSine, + ToggleMic, ToggleDataTrack, SubscribeTrack { publication: RemoteTrackPublication, @@ -147,6 +148,7 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei room: Arc, logo_track: LogoTrack, sine_track: SineTrack, + mic_track: MicTrack, data_track: Option, } @@ -187,6 +189,7 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei room: new_room.clone(), logo_track: LogoTrack::new(new_room.clone()), sine_track: SineTrack::new(new_room.clone(), SineParameters::default()), + mic_track: MicTrack::new(new_room.clone()), data_track: None, }); @@ -232,6 +235,18 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei } } } + AsyncCmd::ToggleMic => { + if let Some(state) = running_state.as_mut() { + let result = if state.mic_track.is_published() { + state.mic_track.unpublish().await + } else { + state.mic_track.publish().await + }; + if let Err(err) = result { + log::error!("failed to toggle microphone: {err}"); + } + } + } AsyncCmd::ToggleDataTrack => { if let Some(state) = running_state.as_mut() { if let Some(track) = state.data_track.take() { From 14d660befe718b7357cdce1ea12d8caf7be0fa44 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:21:02 +0200 Subject: [PATCH 2/3] Mic button in status bar with color indicator --- src/media/mic_track.rs | 6 +++++- src/room/menu_bar.rs | 7 ------- src/room/status_bar.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/media/mic_track.rs b/src/media/mic_track.rs index 5c5d20a..908c48d 100644 --- a/src/media/mic_track.rs +++ b/src/media/mic_track.rs @@ -18,6 +18,9 @@ struct TrackHandle { } impl MicTrack { + /// Track name used when publishing; also how the UI detects the mic is live. + pub const TRACK_NAME: &str = "microphone"; + pub fn new(room: Arc) -> Self { Self { room, handle: None } } @@ -35,7 +38,8 @@ impl MicTrack { } }; - let track = LocalAudioTrack::create_audio_track("microphone", platform_audio.rtc_source()); + let track = + LocalAudioTrack::create_audio_track(Self::TRACK_NAME, platform_audio.rtc_source()); self.room .local_participant() diff --git a/src/room/menu_bar.rs b/src/room/menu_bar.rs index a4f0a00..7aa394b 100644 --- a/src/room/menu_bar.rs +++ b/src/room/menu_bar.rs @@ -29,13 +29,6 @@ fn publish_menu(ui: &mut egui::Ui, service: &LkService) { if ui.button("Sine Wave").clicked() { let _ = service.send(AsyncCmd::ToggleSine); } - if ui - .button("Microphone") - .on_hover_text("Publish real microphone audio via platform audio (ADM)") - .clicked() - { - let _ = service.send(AsyncCmd::ToggleMic); - } if ui.button("Data Track").clicked() { let _ = service.send(AsyncCmd::ToggleDataTrack); } diff --git a/src/room/status_bar.rs b/src/room/status_bar.rs index 3a5304b..5c2c8aa 100644 --- a/src/room/status_bar.rs +++ b/src/room/status_bar.rs @@ -1,4 +1,7 @@ +use crate::media::MicTrack; use crate::room::RoomContext; +use crate::service::AsyncCmd; +use crate::style::Palette; use crate::ui::status_badge::StatusBadge; use livekit::prelude::*; @@ -37,6 +40,13 @@ impl egui::Widget for StatusBar<'_> { .room .filter(|r| r.connection_state() == ConnectionState::Connected); + let mic_active = room.is_some_and(|r| { + r.local_participant() + .track_publications() + .values() + .any(|p| p.name().as_str() == MicTrack::TRACK_NAME) + }); + ui.horizontal(|ui| { if let Some(err) = connection_failure { ui.add(StatusBadge::error(format!("Connection failed: {err}"))); @@ -63,6 +73,24 @@ impl egui::Widget for StatusBar<'_> { { actions.disconnect = true; } + let palette = Palette::for_theme(ui.theme()); + let mic_label = if mic_active { + egui::RichText::new("Microphone").color(palette.bg_1) + } else { + egui::RichText::new("Microphone") + }; + let mut mic_button = egui::Button::new(mic_label).min_size(size); + if mic_active { + mic_button = mic_button.fill(palette.fg_success); + } + let mic_tooltip = if mic_active { + "Microphone is publishing — click to stop (platform audio / ADM)" + } else { + "Publish microphone audio via platform audio (ADM)" + }; + if ui.add(mic_button).on_hover_text(mic_tooltip).clicked() { + let _ = ctx.service.send(AsyncCmd::ToggleMic); + } } else if ui .add(egui::Button::new("Reconnect").min_size(size)) .clicked() From c5bd66c38357fe41d5cd0ed8bd6a25ebec7785ba Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:00:50 +0200 Subject: [PATCH 3/3] Platform audio mute instead of dispose, create on startup instead of on unmute --- cspell.yml | 1 + src/media/mic_track.rs | 52 +++++++++++++++++++----------------------- src/service.rs | 36 ++++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/cspell.yml b/cspell.yml index dc39d36..0edb599 100644 --- a/cspell.yml +++ b/cspell.yml @@ -25,6 +25,7 @@ words: - noninteractive - nutf8 - oneshot + - playout - publicsans - rsplit - rustls diff --git a/src/media/mic_track.rs b/src/media/mic_track.rs index 908c48d..9a5098e 100644 --- a/src/media/mic_track.rs +++ b/src/media/mic_track.rs @@ -2,19 +2,15 @@ use livekit::options::TrackPublishOptions; use livekit::prelude::*; use std::sync::Arc; -/// Publishes real microphone audio via the platform Audio Device Module (ADM). -/// Unlike [`crate::media::SineTrack`], frames are captured automatically by -/// WebRTC, so there is no generation task. +/// Publishes real microphone audio via the shared platform Audio Device Module +/// (ADM). The [`PlatformAudio`] handle is owned by the connection (see +/// `RunningState` in `crate::service`) because it also drives remote-audio +/// playout; this type only controls the mic — it starts/stops ADM recording and +/// publishes/unpublishes the track on toggle. Frames are captured automatically +/// by WebRTC, so (unlike [`crate::media::SineTrack`]) there is no generation task. pub struct MicTrack { room: Arc, - handle: Option, -} - -struct TrackHandle { - track: LocalAudioTrack, - // Held for its lifetime to keep the platform ADM recording; dropping it - // releases the ADM. Underscore-prefixed: held for its drop, never read. - _platform_audio: PlatformAudio, + track: Option, } impl MicTrack { @@ -22,21 +18,18 @@ impl MicTrack { pub const TRACK_NAME: &str = "microphone"; pub fn new(room: Arc) -> Self { - Self { room, handle: None } + Self { room, track: None } } pub fn is_published(&self) -> bool { - self.handle.is_some() + self.track.is_some() } - pub async fn publish(&mut self) -> Result<(), RoomError> { - let platform_audio = match PlatformAudio::new() { - Ok(audio) => audio, - Err(err) => { - log::error!("failed to initialize platform audio: {err}"); - return Ok(()); // stay unpublished so the toggle can retry - } - }; + pub async fn publish(&mut self, platform_audio: &PlatformAudio) -> Result<(), RoomError> { + // Resume mic capture (initializes recording on the first start). + if let Err(err) = platform_audio.start_recording() { + log::error!("failed to start platform audio recording: {err}"); + } let track = LocalAudioTrack::create_audio_track(Self::TRACK_NAME, platform_audio.rtc_source()); @@ -52,21 +45,24 @@ impl MicTrack { ) .await?; - self.handle = Some(TrackHandle { - track, - _platform_audio: platform_audio, - }); + self.track = Some(track); Ok(()) } - pub async fn unpublish(&mut self) -> Result<(), RoomError> { - if let Some(handle) = self.handle.take() { + pub async fn unpublish(&mut self, platform_audio: &PlatformAudio) -> Result<(), RoomError> { + if let Some(track) = self.track.take() { self.room .local_participant() - .unpublish_track(&handle.track.sid()) + .unpublish_track(&track.sid()) .await?; } + // Pause capture (turns off the OS mic indicator); the ADM stays alive for + // remote-audio playout and is disposed only when the connection ends. + if let Err(err) = platform_audio.stop_recording() { + log::error!("failed to stop platform audio recording: {err}"); + } + Ok(()) } } diff --git a/src/service.rs b/src/service.rs index f3deb13..addf4f9 100644 --- a/src/service.rs +++ b/src/service.rs @@ -150,6 +150,10 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei sine_track: SineTrack, mic_track: MicTrack, data_track: Option, + // The platform ADM, held for the whole connection: it drives remote-audio + // playout and is shared with `mic_track` for capture. Dropped on + // disconnect (when `RunningState` is taken), which releases the ADM. + platform_audio: Option, } let mut running_state = None; @@ -185,12 +189,26 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei tokio::spawn(room_task(inner.clone(), events)); let new_room = Arc::new(new_room); + + // Enable the platform ADM for the connection so remote audio + // plays out; also shared with the mic for capture. + let platform_audio = match PlatformAudio::new() { + Ok(audio) => Some(audio), + Err(err) => { + log::error!( + "failed to init platform audio (remote audio + mic disabled): {err}" + ); + None + } + }; + running_state = Some(RunningState { room: new_room.clone(), logo_track: LogoTrack::new(new_room.clone()), sine_track: SineTrack::new(new_room.clone(), SineParameters::default()), mic_track: MicTrack::new(new_room.clone()), data_track: None, + platform_audio, }); // Allow direct access to the room from the UI (Used for sync access) @@ -237,13 +255,19 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei } AsyncCmd::ToggleMic => { if let Some(state) = running_state.as_mut() { - let result = if state.mic_track.is_published() { - state.mic_track.unpublish().await + // Clone the ref-counted handle to sidestep a disjoint borrow of + // `state` (mic_track is borrowed mutably below). + if let Some(platform_audio) = state.platform_audio.clone() { + let result = if state.mic_track.is_published() { + state.mic_track.unpublish(&platform_audio).await + } else { + state.mic_track.publish(&platform_audio).await + }; + if let Err(err) = result { + log::error!("failed to toggle microphone: {err}"); + } } else { - state.mic_track.publish().await - }; - if let Err(err) = result { - log::error!("failed to toggle microphone: {err}"); + log::error!("cannot toggle microphone: platform audio unavailable"); } } }