Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ words:
- noninteractive
- nutf8
- oneshot
- playout
- publicsans
- rsplit
- rustls
Expand Down
68 changes: 68 additions & 0 deletions src/media/mic_track.rs
Original file line number Diff line number Diff line change
@@ -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<Room>,
track: Option<LocalAudioTrack>,
}

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<Room>) -> 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(())
}
}
9 changes: 6 additions & 3 deletions src/media/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 28 additions & 0 deletions src/room/status_bar.rs
Original file line number Diff line number Diff line change
@@ -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::*;

Expand Down Expand Up @@ -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}")));
Expand All @@ -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()
Expand Down
41 changes: 40 additions & 1 deletion src/service.rs
Original file line number Diff line number Diff line change
@@ -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::*},
Expand All @@ -25,6 +25,7 @@ pub enum AsyncCmd {
},
ToggleLogo,
ToggleSine,
ToggleMic,
ToggleDataTrack,
SubscribeTrack {
publication: RemoteTrackPublication,
Expand Down Expand Up @@ -147,7 +148,12 @@ async fn service_task(inner: Arc<ServiceInner>, mut cmd_rx: mpsc::UnboundedRecei
room: Arc<Room>,
logo_track: LogoTrack,
sine_track: SineTrack,
mic_track: MicTrack,
data_track: Option<LocalDataTrack>,
// 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<PlatformAudio>,
}

let mut running_state = None;
Expand Down Expand Up @@ -183,11 +189,26 @@ async fn service_task(inner: Arc<ServiceInner>, 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)
Expand Down Expand Up @@ -232,6 +253,24 @@ async fn service_task(inner: Arc<ServiceInner>, 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() {
Expand Down
Loading