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/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 new file mode 100644 index 0000000..9a5098e --- /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 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, + track: Option, +} + +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, track: None } + } + + pub fn is_published(&self) -> bool { + self.track.is_some() + } + + 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()); + + self.room + .local_participant() + .publish_track( + LocalTrack::Audio(track.clone()), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await?; + + self.track = Some(track); + Ok(()) + } + + pub async fn unpublish(&mut self, platform_audio: &PlatformAudio) -> Result<(), RoomError> { + if let Some(track) = self.track.take() { + self.room + .local_participant() + .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/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/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() diff --git a/src/service.rs b/src/service.rs index 0abc1f1..addf4f9 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,7 +148,12 @@ 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, + // 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; @@ -183,11 +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) @@ -232,6 +253,24 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei } } } + AsyncCmd::ToggleMic => { + if let Some(state) = running_state.as_mut() { + // 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 { + log::error!("cannot toggle microphone: platform audio unavailable"); + } + } + } AsyncCmd::ToggleDataTrack => { if let Some(state) = running_state.as_mut() { if let Some(track) = state.data_track.take() {