From 9006a082b073694fefa1d83695d7f23a0ad5d34e Mon Sep 17 00:00:00 2001 From: Sam Sartor Date: Wed, 18 Feb 2026 04:53:41 +0000 Subject: [PATCH] Refactor the imp/gui bondary to use real traits (#18) # Summary Introduces a trait-based platform abstraction layer that makes the boundary between platform-specific and shared code explicit and compile-time verified. The TLDR version of this new trait stuff works: 1. Define a `PlatformInterface` trait. 2. Each platform defines a zero-sized struct implementing the trait (ex `WebPlatform`). 3. Create an ifdef'd type alias on those structs: ```rust #[cfg(feature = "web")] pub type Platform = web::WebPlatform; #[cfg(all(feature = "desktop"))] pub type Platform = desktop::DesktopPlatform; #[cfg(all(feature = "mobile", not(feature = "web")))] pub type Platform = mobile::MobilePlatform; ``` 5. Add a compile time assertion that `Platform` implements `PlatformInterface`. # Motivation Previously, platform code used a mix of pub use re-exports and #[cfg] blocks that made it difficult to understand what each platform must implement. The new trait-based approach provides: - Clear documentation of the platform contract - Compile-time verification that all platforms implement required functionality - Ability to cargo check without feature flags (via stub platform) # Changes New traits in imp/mod.rs: - PlatformInterface - logging, permissions, network, config, storage. Overall this the trait that platforms must satify to compile. - AudioSystemInterface - audio system initialization and recording - AudioPlayerInterface - opus audio playback Type aliases: - Platform, AudioSystem, AudioPlayer resolve to the correct types based on feature flags Call site updates: - Changed from imp::function() to Platform::function() syntax - Removed ImpRead/ImpWrite helper traits in favor of direct bounds # Testing Manual testing reveals that Web and Desktop still work, I (Liam) have not tested the mobile version beyond compilation. Co-authored-by: Liam Warfield Reviewed-on: https://git.ohea.xyz/mumble/mumble-web2/pulls/18 Co-authored-by: Sam Sartor Co-committed-by: Sam Sartor --- gui/Cargo.toml | 1 - gui/src/app.rs | 18 +-- gui/src/effects.rs | 20 ++-- gui/src/imp/connect.rs | 5 + gui/src/imp/desktop.rs | 128 ++++++++++++-------- gui/src/imp/mobile.rs | 124 +++++++++++-------- gui/src/imp/mod.rs | 171 ++++++++++++++++++++++++--- gui/src/imp/native_audio.rs | 71 ++++++----- gui/src/imp/stub.rs | 119 +++++++++++++++++++ gui/src/imp/web.rs | 230 +++++++++++++++++++----------------- gui/src/lib.rs | 22 ++-- gui/src/main.rs | 4 +- 12 files changed, 621 insertions(+), 292 deletions(-) create mode 100644 gui/src/imp/stub.rs diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 0433118..7e29572 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -146,7 +146,6 @@ desktop = [ "rfd/xdg-portal", "etcetera", ] - mobile = [ "dioxus/mobile", "tokio", diff --git a/gui/src/app.rs b/gui/src/app.rs index d60dad1..a56e647 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -6,7 +6,7 @@ use mumble_web2_common::{ClientConfig, ServerStatus}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; -use crate::imp; +use crate::imp::{Platform, PlatformInterface as _}; pub type ChannelId = u32; pub type UserId = u32; @@ -690,12 +690,12 @@ pub fn LoginView(config: Resource) -> Element { use_resource(move || async move { let client = reqwest::Client::new(); loop { - *last_status.write_unchecked() = Some(imp::get_status(&client).await); - imp::sleep(std::time::Duration::from_secs_f32(1.0)).await; + *last_status.write_unchecked() = Some(Platform::get_status(&client).await); + Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; } }); - let mut address_input = use_signal(|| imp::load_server_url()); + let mut address_input = use_signal(|| Platform::load_server_url()); let address = use_memo(move || { if let Some(addr) = address_input() { addr.clone() @@ -706,14 +706,14 @@ pub fn LoginView(config: Resource) -> Element { } }); - let previous_username = imp::load_username(); + let previous_username = Platform::load_username(); let mut username = use_signal(|| previous_username.unwrap_or(String::new())); let do_connect = move |_| { //let _ = set_default_username(&username.read()); - let _ = imp::set_default_username(&username.read()); + let _ = Platform::set_default_username(&username.read()); if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { - imp::set_default_server(&address.read()); + Platform::set_default_server(&address.read()); } net.send(Connect { address: address.read().clone(), @@ -860,13 +860,13 @@ pub fn app() -> Element { use_coroutine(|rx: UnboundedReceiver| super::network_entrypoint(rx)); let config = use_resource(|| async move { - match imp::load_config().await { + match Platform::load_config().await { Ok(config) => config, Err(_) => ClientConfig::default(), } }); - imp::request_permissions(); + Platform::request_permissions(); rsx!( document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } diff --git a/gui/src/effects.rs b/gui/src/effects.rs index ef87bcd..58f3abe 100644 --- a/gui/src/effects.rs +++ b/gui/src/effects.rs @@ -7,7 +7,7 @@ use std::cell::RefCell; use std::sync::Arc; use tracing::{error, info}; -use crate::imp; +use crate::imp::SpawnHandle; static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); // TODO: make this user configurable. @@ -32,10 +32,7 @@ enum DenoisingModelState { Availible(Box), } -fn with_denoising_model( - spawn: &imp::SpawnHandle, - func: impl FnOnce(&mut DfTract) -> O, -) -> Option { +fn with_denoising_model(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option { // Using a thread local is super gross, but DfTract is not Send (so it can never leave the current // thread) while AudioProcessing itself might change threads whenever. thread_local! { @@ -89,7 +86,7 @@ fn with_denoising_model( pub struct AudioProcessor { denoise: bool, - spawn: imp::SpawnHandle, + spawn: SpawnHandle, buffer: Vec, noise_floor: f32, /// Whether we were transmitting in the previous frame @@ -102,7 +99,7 @@ impl AudioProcessor { pub fn new_plain() -> Self { AudioProcessor { denoise: false, - spawn: imp::SpawnHandle::current(), + spawn: SpawnHandle::current(), buffer: Vec::new(), noise_floor: DEFAULT_NOISE_FLOOR, was_transmitting: false, @@ -113,7 +110,7 @@ impl AudioProcessor { pub fn new_denoising() -> Self { AudioProcessor { denoise: true, - spawn: imp::SpawnHandle::current(), + spawn: SpawnHandle::current(), buffer: Vec::new(), noise_floor: DEFAULT_NOISE_FLOOR, was_transmitting: false, @@ -123,7 +120,12 @@ impl AudioProcessor { } impl AudioProcessor { - pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec) -> TransmitState { + pub fn process( + &mut self, + audio: &[f32], + channels: usize, + output: &mut Vec, + ) -> TransmitState { let mut include_raw = true; if self.denoise { with_denoising_model(&self.spawn, |df| { diff --git a/gui/src/imp/connect.rs b/gui/src/imp/connect.rs index 8136e2f..e7d3ce7 100644 --- a/gui/src/imp/connect.rs +++ b/gui/src/imp/connect.rs @@ -108,3 +108,8 @@ pub async fn network_connect( pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { bail!("status not supported on desktop yet") } + +#[allow(unused)] +pub use tokio::spawn; +#[allow(unused)] +pub type SpawnHandle = tokio::runtime::Handle; diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 8a41f31..34a59f8 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,12 +1,83 @@ +use crate::app::Command; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use mumble_web2_common::ClientConfig; +use mumble_web2_common::{ClientConfig, ServerStatus}; use std::collections::HashMap; -pub use tokio::runtime::Handle as SpawnHandle; -pub use tokio::task::spawn; -pub use tokio::time::sleep; +use std::time::Duration; -pub use super::connect::*; -pub use super::native_audio::*; +/// Desktop platform implementation using Tokio and native audio. +pub struct DesktopPlatform; + +impl super::PlatformInterface for DesktopPlatform { + type AudioSystem = super::native_audio::NativeAudioSystem; + + async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; + } + + async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { + proxy_url: None, + cert_hash: None, + any_server: true, + }) + } + + fn load_username() -> Option { + let config = load_config_map(); + config.get("username").cloned() + } + + fn load_server_url() -> Option { + let config = load_config_map(); + config.get("server").cloned() + } + + fn set_default_username(username: &str) -> Option<()> { + let mut config = load_config_map(); + config.insert("username".to_string(), username.to_string()); + save_config_map(&config).ok() + } + + fn set_default_server(server: &str) -> Option<()> { + let mut config = load_config_map(); + config.insert("server".to_string(), server.to_string()); + save_config_map(&config).ok() + } + + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + super::connect::network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + super::connect::get_status(client).await + } + + fn init_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::filter::EnvFilter; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_target(true) + .with_level(true) + .with_env_filter(env_filter) + .init(); + } + + fn request_permissions() { + // No-op on desktop + } +} fn get_config_path() -> std::path::PathBuf { let strategy = choose_app_strategy(AppStrategyArgs { @@ -35,48 +106,3 @@ fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { std::fs::write(&config_path, contents)?; Ok(()) } - -pub fn set_default_username(username: &str) -> Option<()> { - let mut config = load_config_map(); - config.insert("username".to_string(), username.to_string()); - save_config_map(&config).ok() -} - -pub fn set_default_server(server: &str) -> Option<()> { - let mut config = load_config_map(); - config.insert("server".to_string(), server.to_string()); - save_config_map(&config).ok() -} - -pub fn load_username() -> Option { - let config = load_config_map(); - config.get("username").cloned() -} - -pub fn load_server_url() -> Option { - let config = load_config_map(); - config.get("server").cloned() -} - -pub async fn load_config() -> color_eyre::Result { - Ok(ClientConfig { - proxy_url: None, - cert_hash: None, - any_server: true, - }) -} - -pub fn init_logging() { - use tracing::level_filters::LevelFilter; - use tracing_subscriber::filter::EnvFilter; - - let env_filter = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(); - - tracing_subscriber::fmt() - .with_target(true) - .with_level(true) - .with_env_filter(env_filter) - .init(); -} diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index b632934..cac7b86 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -1,61 +1,85 @@ -use android_permissions::{PermissionManager, RECORD_AUDIO}; -use jni::{objects::JObject, JavaVM}; -use mumble_web2_common::ClientConfig; +use crate::app::Command; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; +use mumble_web2_common::{ClientConfig, ServerStatus}; +use std::future::Future; +use std::time::Duration; -use std::collections::HashMap; -pub use tokio::runtime::Handle as SpawnHandle; -pub use tokio::task::spawn; -pub use tokio::time::sleep; +/// Mobile platform implementation using Tokio, native audio, and Android permissions. +pub struct MobilePlatform; -pub use super::connect::*; -pub use super::native_audio::*; +impl super::PlatformInterface for MobilePlatform { + type AudioSystem = super::native_audio::NativeAudioSystem; -pub fn set_default_username(username: &str) -> Option<()> { - None + async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { + proxy_url: None, + cert_hash: None, + any_server: true, + }) + } + + fn load_username() -> Option { + None + } + + fn load_server_url() -> Option { + None + } + + fn set_default_username(_username: &str) -> Option<()> { + None + } + + fn set_default_server(server: &str) -> Option<()> { + None + } + + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + super::connect::network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + super::connect::get_status(client).await + } + + fn init_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::filter::EnvFilter; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_target(true) + .with_level(true) + .with_env_filter(env_filter) + .init(); + } + + fn request_permissions() { + request_recording_permission(); + } + + async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; + } } -pub fn set_default_server(server: &str) -> Option<()> { - None -} - -pub fn load_username() -> Option { - None -} - -pub fn load_server_url() -> Option { - None -} - -pub async fn load_config() -> color_eyre::Result { - Ok(ClientConfig { - proxy_url: None, - cert_hash: None, - any_server: true, - }) -} - -pub fn init_logging() { - use tracing::level_filters::LevelFilter; - use tracing_subscriber::filter::EnvFilter; - - let env_filter = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(); - - tracing_subscriber::fmt() - .with_target(true) - .with_level(true) - .with_env_filter(env_filter) - .init(); -} - -#[cfg(feature = "mobile")] -pub fn request_permissions() { - request_recording_permission(); -} +#[cfg(not(target_os = "android"))] +pub fn request_recording_permission() {} #[cfg(target_os = "android")] pub fn request_recording_permission() { + use android_permissions::{PermissionManager, RECORD_AUDIO}; + use jni::{objects::JObject, JavaVM}; + let ctx = ndk_context::android_context(); let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() }; let activity = unsafe { JObject::from_raw(ctx.context().cast()) }; diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index 106e281..a657d31 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -1,29 +1,166 @@ -#[cfg(feature = "web")] -mod web; +//! Platform abstraction layer +//! +//! This module defines traits that each platform (web, desktop, mobile) must implement. +//! The traits make the platform boundary explicit and provide compile-time verification. +#![allow(async_fn_in_trait)] + +use crate::{app::Command, effects::AudioProcessor}; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; +use mumble_web2_common::{ClientConfig, ServerStatus}; +use std::future::Future; +use std::time::Duration; + +// ============================================================================ +// Trait Definitions +// ============================================================================ + +/// Platform-specific audio subsystem for capturing microphone input and creating playback streams. +/// +/// The audio system handles Opus encoding internally - callers receive encoded frames +/// ready for network transmission. +pub trait AudioSystemInterface: Sized { + /// The player type returned by [`create_player`](Self::create_player). + type AudioPlayer: AudioPlayerInterface; + + /// Initialize the audio system. + async fn new() -> Result; + + /// Set the processor for the microphone input, mainly noise cancellation settings. + fn set_processor(&self, processor: AudioProcessor); + + /// Begin listening to microphone input, calling the `each` function with + /// encoded opus frames. + fn start_recording( + &mut self, + each: impl FnMut(Vec, bool) + Send + 'static, + ) -> Result<(), Error>; + + /// Begin playback of an audio stream, returning an object that can be passed opus frames. + fn create_player(&mut self) -> Result; +} + +/// A handle to an active audio playback stream for a single remote user. +/// +/// Each connected user gets their own `AudioPlayer` instance, which decodes +/// incoming Opus frames and outputs PCM audio to the platform's audio device. +/// The player manages its own decoder state and output buffer. +pub trait AudioPlayerInterface { + /// Decode and play an Opus-encoded audio frame. + fn play_opus(&mut self, payload: &[u8]); +} + +/// This is the main trait that each platform must implement. It combines all +/// platform-specific functionality into a single interface, providing compile-time +/// verification that all platforms implement the required functionality. +pub trait PlatformInterface { + type AudioSystem: AudioSystemInterface; + + /// Initialize logging for the platform. + fn init_logging(); + + /// Request runtime permissions (Android audio recording, etc.). + fn request_permissions(); + + /// Establish a connection to the Mumble server and run the network loop. + fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> impl Future>; + + /// Get server status (user count, version, etc.). + fn get_status( + client: &reqwest::Client, + ) -> impl Future>; + + /// Load the proxy overrides (proxy URL, cert hash, etc.). + fn load_config() -> impl Future>; + + /// Load saved username. + fn load_username() -> Option; + + /// Load saved server URL. + fn load_server_url() -> Option; + + /// Save the default username. + fn set_default_username(username: &str) -> Option<()>; + + /// Save the default server URL. + fn set_default_server(server: &str) -> Option<()>; + + /// Async sleep for the given duration. + fn sleep(duration: Duration) -> impl Future; +} + +// ============================================================================ +// Platform Modules +// ============================================================================ #[cfg(any(feature = "desktop", feature = "mobile"))] mod connect; -#[cfg(any(feature = "desktop", feature = "mobile"))] -mod native_audio; - #[cfg(feature = "desktop")] mod desktop; #[cfg(feature = "mobile")] mod mobile; +#[cfg(any(feature = "desktop", feature = "mobile"))] +mod native_audio; +mod stub; +#[cfg(feature = "web")] +mod web; -#[cfg(feature = "desktop")] -pub use desktop::*; -#[cfg(feature = "mobile")] -pub use mobile::*; +// ============================================================================ +// Platform Type Alias +// ============================================================================ -#[cfg(feature = "mobile")] -pub use mobile::request_permissions; +#[cfg(feature = "web")] +pub type Platform = web::WebPlatform; -#[cfg(any(feature = "desktop", feature = "web"))] -pub fn request_permissions() {} +#[cfg(all(feature = "desktop", not(feature = "web")))] +pub type Platform = desktop::DesktopPlatform; -#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))] -pub use web::*; +#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))] +pub type Platform = mobile::MobilePlatform; -#[cfg(any(feature = "desktop"))] -pub use desktop::*; +#[cfg(all( + not(feature = "mobile"), + not(feature = "web"), + not(feature = "desktop") +))] +pub type Platform = stub::StubPlatform; + +pub type AudioSystem = ::AudioSystem; +pub type AudioPlayer = ::AudioPlayer; + +// ======================== +// Platform Async Runtime +// ======================== + +// Note: these can not be part of the Platform because they differ in Send requiremets +#[cfg(all(any(feature = "desktop", feature = "mobile"), not(feature = "web")))] +pub use connect::{spawn, SpawnHandle}; +#[cfg(all( + not(feature = "desktop"), + not(feature = "mobile"), + not(feature = "web") +))] +pub use stub::{spawn, SpawnHandle}; +#[cfg(feature = "web")] +pub use web::{spawn, SpawnHandle}; + +// ======================= +// Compile-time Assertions +// ======================= +const _: () = { + fn assert_platform() {} + + // Check each implementation, and prevent warnings that the implementations are unused. + #[cfg(feature = "web")] + let _ = assert_platform::; + #[cfg(feature = "desktop")] + let _ = assert_platform::; + #[cfg(feature = "mobile")] + let _ = assert_platform::; + let _ = assert_platform::; +}; diff --git a/gui/src/imp/native_audio.rs b/gui/src/imp/native_audio.rs index 3778aac..ed3eb43 100644 --- a/gui/src/imp/native_audio.rs +++ b/gui/src/imp/native_audio.rs @@ -1,19 +1,12 @@ use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use color_eyre::eyre::{eyre, Error}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; -use futures::io::{AsyncRead, AsyncWrite}; use std::mem::replace; use std::sync::Arc; use std::sync::Mutex; use tracing::{error, info, warn}; -pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} -impl ImpRead for T {} - -pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {} -impl ImpWrite for T {} - -pub struct AudioSystem { +pub struct NativeAudioSystem { output: cpal::Device, input: cpal::Device, processors: AudioProcessorSender, @@ -52,28 +45,7 @@ fn encode_and_send( type Buffer = Arc>>>; -impl AudioSystem { - pub async fn new() -> Result { - // TODO - let host = cpal::default_host(); - let name = host.id(); - let processors = AudioProcessorSender::default(); - Ok(AudioSystem { - output: host - .default_output_device() - .ok_or(eyre!("no output devices from {name:?}"))?, - input: host - .default_input_device() - .ok_or(eyre!("no input devices from {name:?}"))?, - processors, - recording_stream: None, - }) - } - - pub fn set_processor(&self, processor: AudioProcessor) { - self.processors.store(Some(processor)) - } - +impl NativeAudioSystem { fn choose_config( &self, configs: impl Iterator, @@ -103,8 +75,32 @@ impl AudioSystem { .cloned() .ok_or(eyre!("no supported stream configs")) } +} - pub fn start_recording( +impl super::AudioSystemInterface for NativeAudioSystem { + type AudioPlayer = NativeAudioPlayer; + + async fn new() -> Result { + let host = cpal::default_host(); + let name = host.id(); + let processors = AudioProcessorSender::default(); + Ok(NativeAudioSystem { + output: host + .default_output_device() + .ok_or(eyre!("no output devices from {name:?}"))?, + input: host + .default_input_device() + .ok_or(eyre!("no input devices from {name:?}"))?, + processors, + recording_stream: None, + }) + } + + fn set_processor(&self, processor: AudioProcessor) { + self.processors.store(Some(processor)) + } + + fn start_recording( &mut self, mut each: impl FnMut(Vec, bool) + Send + 'static, ) -> Result<(), Error> { @@ -124,7 +120,8 @@ impl AudioSystem { if let Some(new_processor) = processors.take() { current_processor = new_processor; } - let state = current_processor.process(frame, config.channels as usize, &mut output_buffer); + let state = + current_processor.process(frame, config.channels as usize, &mut output_buffer); encode_and_send(state, &mut output_buffer, &mut encoder, &mut each); }; @@ -144,7 +141,7 @@ impl AudioSystem { } } - pub fn create_player(&mut self) -> Result { + fn create_player(&mut self) -> Result { let config = self.choose_config(self.output.supported_output_configs()?)?; info!( "creating player on {:?} with {:#?}", @@ -182,7 +179,7 @@ impl AudioSystem { )? }; stream.play()?; - Ok(AudioPlayer { + Ok(NativeAudioPlayer { decoder, stream, buffer, @@ -191,15 +188,15 @@ impl AudioSystem { } } -pub struct AudioPlayer { +pub struct NativeAudioPlayer { decoder: opus::Decoder, stream: cpal::Stream, buffer: Buffer, tmp: Vec, } -impl AudioPlayer { - pub fn play_opus(&mut self, payload: &[u8]) { +impl super::AudioPlayerInterface for NativeAudioPlayer { + fn play_opus(&mut self, payload: &[u8]) { let len = match self.decoder.decode(payload, &mut self.tmp, false) { Ok(l) => l, Err(e) => { diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs new file mode 100644 index 0000000..d03cd5e --- /dev/null +++ b/gui/src/imp/stub.rs @@ -0,0 +1,119 @@ +/// Stub implementation of the platform interface, so that we can +/// `cargo check` without any --feature flags. +use crate::effects::AudioProcessor; +use color_eyre::eyre::Error; +use dioxus::hooks::UnboundedReceiver; +use mumble_web2_common::{ClientConfig, ServerStatus}; +use std::future::Future; + +pub struct StubPlatform; + +impl super::PlatformInterface for StubPlatform { + type AudioSystem = StubAudioSystem; + + fn init_logging() { + panic!("stubbed platform") + } + + fn request_permissions() { + panic!("stubbed platform") + } + + fn network_connect( + _address: String, + _username: String, + _event_rx: &mut UnboundedReceiver, + _gui_config: &ClientConfig, + ) -> impl Future> { + async { panic!("stubbed platform") } + } + + fn get_status( + _client: &reqwest::Client, + ) -> impl Future> { + async { panic!("stubbed platform") } + } + + fn load_config() -> impl Future> { + async { panic!("stubbed platform") } + } + + fn load_username() -> Option { + panic!("stubbed platform") + } + + fn load_server_url() -> Option { + panic!("stubbed platform") + } + + fn set_default_username(_username: &str) -> Option<()> { + panic!("stubbed platform") + } + + fn set_default_server(_server: &str) -> Option<()> { + panic!("stubbed platform") + } + + fn sleep(_duration: std::time::Duration) -> impl Future { + async { panic!("stubbed platform") } + } +} + +pub struct StubAudioSystem; + +impl super::AudioSystemInterface for StubAudioSystem { + type AudioPlayer = StubAudioPlayer; + + async fn new() -> Result { + panic!("stubbed platform") + } + + fn set_processor(&self, _processor: AudioProcessor) { + panic!("stubbed platform") + } + + fn start_recording( + &mut self, + _each: impl FnMut(Vec, bool) + Send + 'static, + ) -> Result<(), Error> { + panic!("stubbed platform") + } + + fn create_player(&mut self) -> Result { + panic!("stubbed platform") + } +} + +pub struct StubAudioPlayer; + +impl super::AudioPlayerInterface for StubAudioPlayer { + fn play_opus(&mut self, _payload: &[u8]) { + panic!("stubbed platform") + } +} + +#[allow(unused)] +pub struct SpawnHandle; + +impl SpawnHandle { + #[allow(unused)] + pub fn spawn(&self, _future: F) + where + F: Future + 'static, + { + panic!("stubbed platform") + } + + #[allow(unused)] + pub fn current() -> Self { + SpawnHandle + } +} + +#[allow(unused)] +pub fn spawn(_future: F) +where + F: Future + 'static, +{ + panic!("stubbed platform") +} diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 4a6339c..1a55ec4 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -3,7 +3,6 @@ use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use color_eyre::eyre::{bail, eyre, Error}; use crossbeam::atomic::AtomicCell; use dioxus::prelude::*; -use futures::{AsyncRead, AsyncWrite}; use gloo_timers::future::TimeoutFuture; use js_sys::Float32Array; use mumble_protocol::control::ClientControlCodec; @@ -29,7 +28,6 @@ use web_sys::AudioWorkletNode; use web_sys::EncodedAudioChunk; use web_sys::EncodedAudioChunkInit; use web_sys::EncodedAudioChunkType; -use web_sys::MediaStream; use web_sys::MediaStreamConstraints; use web_sys::MessageEvent; use web_sys::WebTransport; @@ -39,16 +37,119 @@ use web_sys::WorkletOptions; use web_sys::{console, window}; use web_sys::{AudioContext, AudioDataCopyToOptions}; +#[allow(unused)] pub use wasm_bindgen_futures::spawn_local as spawn; -pub trait ImpRead: AsyncRead + Unpin + 'static {} -impl ImpRead for T {} +#[allow(unused)] +#[derive(Clone)] +pub struct SpawnHandle; -pub trait ImpWrite: AsyncWrite + Unpin + 'static {} -impl ImpWrite for T {} +impl SpawnHandle { + pub fn spawn(&self, future: F) + where + F: Future + 'static, + { + wasm_bindgen_futures::spawn_local(future); + } -pub async fn sleep(d: Duration) { - TimeoutFuture::new(d.as_millis() as u32).await + pub fn current() -> Self { + SpawnHandle + } +} + +/// Web platform implementation using WebTransport and Web Audio API. +pub struct WebPlatform; + +impl super::PlatformInterface for WebPlatform { + type AudioSystem = WebAudioSystem; + + fn init_logging() { + // copied from tracing_web example usage + + use tracing_subscriber::fmt::format::Pretty; + use tracing_subscriber::prelude::*; + use tracing_web::{performance_layer, MakeWebConsoleWriter}; + + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) // Only partially supported across browsers + .without_time() // std::time is not available in browsers + .with_writer(MakeWebConsoleWriter::new()) // write events to the console + .with_filter(LevelFilter::DEBUG); + let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); + + tracing_subscriber::registry() + .with(fmt_layer) + .with(perf_layer) + .init(); + + info!("logging initiated"); + } + + fn request_permissions() { + // No-op on web + } + + async fn load_config() -> color_eyre::Result { + let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") { + Some(url) => Url::parse(url)?, + None => absolute_url("config")?, + }; + info!("loading config from {}", config_url); + + let config = reqwest::get(config_url) + .await? + .json::() + .await?; + + Ok(config) + } + + fn load_username() -> Option { + web_sys::window() + .unwrap() + .local_storage() + .ok()?? + .get_item("username") + .ok()? + } + + fn load_server_url() -> Option { + None + } + + fn set_default_username(username: &str) -> Option<()> { + web_sys::window()? + .local_storage() + .ok()?? + .set_item("username", username) + .ok() + } + + fn set_default_server(_server: &str) -> Option<()> { + None + } + + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + Ok(client + .get(absolute_url("status")?) + .send() + .await? + .json::() + .await?) + } + + async fn sleep(duration: Duration) { + TimeoutFuture::new(duration.as_millis() as u32).await; + } } trait ResultExt { @@ -73,7 +174,7 @@ impl ResultExt for Result { } } -pub struct AudioSystem { +pub struct WebAudioSystem { webctx: AudioContext, processors: AudioProcessorSender, } @@ -104,8 +205,10 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> { Ok(()) } -impl AudioSystem { - pub async fn new() -> Result { +impl super::AudioSystemInterface for WebAudioSystem { + type AudioPlayer = WebAudioPlayer; + + async fn new() -> Result { // Create MediaStreams to playback decoded audio // The audio context is used to reproduce audio. let webctx = configure_audio_context(); @@ -113,17 +216,14 @@ impl AudioSystem { let processors = AudioProcessorSender::default(); - Ok(AudioSystem { webctx, processors }) + Ok(WebAudioSystem { webctx, processors }) } - pub fn set_processor(&self, processor: AudioProcessor) { + fn set_processor(&self, processor: AudioProcessor) { self.processors.store(Some(processor)) } - pub fn start_recording( - &mut self, - each: impl FnMut(Vec, bool) + 'static, - ) -> Result<(), Error> { + fn start_recording(&mut self, each: impl FnMut(Vec, bool) + 'static) -> Result<(), Error> { let audio_context_worklet = self.webctx.clone(); let processors = self.processors.clone(); spawn(async move { @@ -135,7 +235,7 @@ impl AudioSystem { Ok(()) } - pub fn create_player(&mut self) -> Result { + fn create_player(&mut self) -> Result { let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?; // Connect worklet to destination @@ -188,14 +288,14 @@ impl AudioSystem { decoder_error.forget(); output.forget(); - Ok(AudioPlayer(audio_decoder)) + Ok(WebAudioPlayer(audio_decoder)) } } -pub struct AudioPlayer(AudioDecoder); +pub struct WebAudioPlayer(AudioDecoder); -impl AudioPlayer { - pub fn play_opus(&mut self, payload: &[u8]) { +impl super::AudioPlayerInterface for WebAudioPlayer { + fn play_opus(&mut self, payload: &[u8]) { let js_audio_payload = Uint8Array::from(payload); let _ = self.0.decode( &EncodedAudioChunk::new(&EncodedAudioChunkInit::new( @@ -418,94 +518,8 @@ pub async fn network_connect( crate::network_loop(username, event_rx, reader, writer).await } -pub fn set_default_username(username: &str) -> Option<()> { - web_sys::window()? - .local_storage() - .ok()?? - .set_item("username", username) - .ok() -} - -pub fn set_default_server(username: &str) -> Option<()> { - None -} - -pub fn load_username() -> Option { - web_sys::window() - .unwrap() - .local_storage() - .ok()?? - .get_item("username") - .ok()? -} - -pub fn load_server_url() -> Option { - None -} - pub fn absolute_url(path: &str) -> Result { let window: web_sys::Window = web_sys::window().expect("no global `window` exists"); let location = window.location(); Ok(Url::parse(&location.href().ey()?)?.join(path)?) } - -pub async fn load_config() -> color_eyre::Result { - let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") { - Some(url) => Url::parse(url)?, - None => absolute_url("config")?, - }; - info!("loading config from {}", config_url); - - let config = reqwest::get(config_url) - .await? - .json::() - .await?; - - Ok(config) -} - -pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - Ok(client - .get(absolute_url("status")?) - .send() - .await? - .json::() - .await?) -} - -pub fn init_logging() { - // copied from tracing_web example usage - - use tracing_subscriber::fmt::format::Pretty; - use tracing_subscriber::prelude::*; - use tracing_web::{performance_layer, MakeWebConsoleWriter}; - - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) // Only partially supported across browsers - .without_time() // std::time is not available in browsers - .with_writer(MakeWebConsoleWriter::new()) // write events to the console - .with_filter(LevelFilter::DEBUG); - let perf_layer = performance_layer().with_details_from_fields(Pretty::default()); - - tracing_subscriber::registry() - .with(fmt_layer) - .with(perf_layer) - .init(); - - info!("logging initiated"); -} - -pub struct SpawnHandle; - -impl SpawnHandle { - pub fn current() -> Self { - SpawnHandle - } - - pub fn spawn(&self, future: F) - where - F: Future + 'static, - { - spawn(future); - } -} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index b44e5fc..bb875b2 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -7,11 +7,12 @@ use asynchronous_codec::FramedWrite; use color_eyre::eyre::{bail, Error}; use dioxus::prelude::*; use futures::select; +use futures::AsyncRead; +use futures::AsyncWrite; use futures::FutureExt as _; use futures::SinkExt as _; use futures::StreamExt as _; use futures_channel::mpsc::UnboundedSender; -pub use imp::spawn; use msghtml::process_message_html; use mumble_protocol::control::msgs; use mumble_protocol::control::ControlCodec; @@ -27,7 +28,10 @@ use tracing::error; use tracing::info; use crate::effects::AudioProcessor; -use crate::imp::AudioSystem; +use crate::imp::{ + AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform, + PlatformInterface as _, +}; pub mod app; mod effects; @@ -47,7 +51,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver) { *STATE.server.write() = Default::default(); *STATE.status.write() = ConnectionState::Connecting; - if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await { + if let Err(error) = + Platform::network_connect(address, username, &mut event_rx, &config).await + { error!("could not connect {:?}", error); *STATE.status.write() = ConnectionState::Failed(error.to_string()); } else { @@ -56,7 +62,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver) { } } -pub async fn network_loop( +pub async fn network_loop( username: String, event_rx: &mut UnboundedReceiver, mut reader: FramedRead>, @@ -105,12 +111,12 @@ pub async fn network_loop( break; } - imp::sleep(Duration::from_millis(3000)).await; + Platform::sleep(Duration::from_millis(3000)).await; } }); } - let mut audio = imp::AudioSystem::new().await?; + let mut audio = AudioSystem::new().await?; { let send_chan = send_chan.clone(); let mut sequence_num = 0; @@ -296,8 +302,8 @@ fn accept_command( fn accept_packet( msg: ControlPacket, - audio_context: &mut imp::AudioSystem, - player_map: &mut HashMap, + audio_context: &mut AudioSystem, + player_map: &mut HashMap, ) -> Result<(), Error> { match msg { ControlPacket::UDPTunnel(u) => { diff --git a/gui/src/main.rs b/gui/src/main.rs index 0e2f19f..a4185c6 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,6 +1,6 @@ -use mumble_web2_gui::{app, imp}; +use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _}; pub fn main() { - imp::init_logging(); + Platform::init_logging(); dioxus::launch(app::app); }