diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 1f9776a..fe9ece5 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -198,7 +198,8 @@ Future sendChatMessage({ /// On non-Android targets or before a voice session opens the /// section is omitted. Per SDD-090 every field in that section is /// a device-side technical scalar — no PII admitted. -String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics(); +Future exportDiagnostics() => + RustLib.instance.api.crateApiExportDiagnostics(); /// Wire the identity persistence store to a platform-private /// directory. Should be called once on app start after Flutter has diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 755189b..df5ef0e 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -101,7 +101,7 @@ abstract class RustLibApi extends BaseApi { Stream crateApiEventsStream(); - String crateApiExportDiagnostics(); + Future crateApiExportDiagnostics(); Future crateApiGetAudioProcessingConfig(); @@ -469,16 +469,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]); @override - String crateApiExportDiagnostics() { - return handler.executeSync( - SyncTask( - callFfi: () { + Future crateApiExportDiagnostics() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!; + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); }, codec: SseCodec( decodeSuccessData: sse_decode_String, - decodeErrorData: null, + decodeErrorData: sse_decode_bridge_error, ), constMeta: kCrateApiExportDiagnosticsConstMeta, argValues: [], diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 54f1530..696cea5 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -1234,11 +1234,11 @@ impl ChanoraSession { audio.set_output_muted(muted); } } - // Input mute must also stop local outbound voice production - // so the transmit selector stays in sync with the server-side - // mic mute. Output mute is playback-only and must not affect - // the mic gate. - let mic_disabled = state.local_input_muted; + // Muting the speaker is treated as a local deafen: it must + // also clamp microphone TX so users cannot keep talking while + // unable to hear replies. This does not send server-side + // input-mute; it only drives the local transmit gate. + let mic_disabled = state.local_input_muted || state.local_output_muted; self.voice_selector.set_hard_mute(mic_disabled); Ok(()) } diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 6a8aabd..bdc80b5 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1774,11 +1774,11 @@ impl AudioEngine { } /// Latest Android voice-audio diagnostics snapshot (SDD-112 item - /// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets - /// this always returns `None`. On Android it returns `Some(...)` + /// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)` /// once `AndroidVoiceUnit::open()` has published a snapshot; the - /// slot is cleared on `close()` / `Drop`. Per SDD-090 the - /// snapshot contains only device-side technical scalars — no PII. + /// slot is cleared on `close()` / `Drop`. Per SDD-090 the snapshot + /// contains only device-side technical scalars — no PII. + #[cfg(target_os = "android")] pub fn android_diagnostics( &self, ) -> Option { diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index eed388e..24bb352 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -6,7 +6,7 @@ //! ## What's wired in this Beta //! //! * Default-input capture via platform audio backends (Oboe on Android, -//! VoiceProcessingIO on Apple platforms, cpal/SDL elsewhere) +//! VoiceProcessingIO on Apple platforms, PipeWire/PulseAudio on Linux, cpal on Windows) //! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus` //! * Forward encoded frames to the protocol crate as `OutPacket`s //! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler` @@ -32,6 +32,7 @@ pub mod audio_processing; pub mod debug_wav; mod engine; pub mod frame; +#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))] pub mod mobile_voice_backend; pub mod mode_stack; pub(crate) mod opus_voice; @@ -44,6 +45,7 @@ pub mod transmit_mode; pub mod transmit_selector; pub mod vad; pub mod voice_activity; +#[cfg(any(target_os = "android", target_os = "ios", test))] pub(crate) mod voice_render; #[cfg(target_os = "linux")] diff --git a/crates/chanora_audio/src/linux_pipewire_input.rs b/crates/chanora_audio/src/linux_pipewire_input.rs index bafa478..5924662 100644 --- a/crates/chanora_audio/src/linux_pipewire_input.rs +++ b/crates/chanora_audio/src/linux_pipewire_input.rs @@ -91,6 +91,7 @@ pub(crate) fn input_device_id(node_id: u32) -> String { format!("{INPUT_ID_PREFIX}{node_id}") } +#[cfg(test)] pub(crate) fn input_device_node_id_from_id(id: &str) -> Option { id.strip_prefix(INPUT_ID_PREFIX)?.parse().ok() } @@ -99,6 +100,7 @@ pub(crate) fn output_device_id(node_id: u32) -> String { format!("{OUTPUT_ID_PREFIX}{node_id}") } +#[cfg(test)] pub(crate) fn output_device_node_id_from_id(id: &str) -> Option { id.strip_prefix(OUTPUT_ID_PREFIX)?.parse().ok() } @@ -111,23 +113,25 @@ fn pulse_output_device_id(name: &str) -> String { format!("{PULSE_OUTPUT_ID_PREFIX}{name}") } +#[cfg(test)] fn pulse_input_name_from_id(id: &str) -> Option<&str> { id.strip_prefix(PULSE_INPUT_ID_PREFIX) } +#[cfg(test)] fn pulse_output_name_from_id(id: &str) -> Option<&str> { id.strip_prefix(PULSE_OUTPUT_ID_PREFIX) } pub(crate) fn list_input_devices(default_label: Option<&str>) -> Vec { - list_devices(LinuxDirection::Input) + visible_devices(list_devices(LinuxDirection::Input)) .iter() .map(|device| device.to_audio_device_info(default_label)) .collect() } pub(crate) fn list_output_devices(default_label: Option<&str>) -> Vec { - list_devices(LinuxDirection::Output) + visible_devices(list_devices(LinuxDirection::Output)) .iter() .map(|device| device.to_audio_device_info(default_label)) .collect() @@ -249,6 +253,25 @@ fn list_devices(direction: LinuxDirection) -> Vec { devices } +fn visible_devices(devices: Vec) -> Vec { + let pipewire_labels = devices + .iter() + .filter(|device| device.backend == LinuxBackend::PipeWire) + .map(|device| normalized_device_label(&device.label)) + .collect::>(); + devices + .into_iter() + .filter(|device| { + device.backend == LinuxBackend::PipeWire + || !pipewire_labels.contains(&normalized_device_label(&device.label)) + }) + .collect() +} + +fn normalized_device_label(label: &str) -> String { + label.split_whitespace().collect::>().join(" ").to_lowercase() +} + fn list_pipewire_nodes(direction: LinuxDirection) -> Result, String> { pw::init(); let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?; @@ -928,7 +951,8 @@ mod tests { use super::{ input_device_id, input_device_node_id_from_id, output_device_id, output_device_node_id_from_id, pulse_input_device_id, pulse_input_name_from_id, - pulse_output_device_id, pulse_output_name_from_id, + pulse_output_device_id, pulse_output_name_from_id, visible_devices, LinuxBackend, + LinuxDevice, LinuxDirection, }; #[test] @@ -958,4 +982,32 @@ mod tests { Some("alsa_output.pci-0000_00_1f.3") ); } + + #[test] + fn visible_devices_hides_pulse_duplicates_when_pipewire_matches() { + let devices = visible_devices(vec![ + LinuxDevice { + backend: LinuxBackend::PipeWire, + direction: LinuxDirection::Output, + raw_id: "42".to_string(), + label: "Built-in Audio Analog Stereo".to_string(), + }, + LinuxDevice { + backend: LinuxBackend::PulseAudio, + direction: LinuxDirection::Output, + raw_id: "alsa_output.pci-0000".to_string(), + label: " built-in audio analog stereo ".to_string(), + }, + LinuxDevice { + backend: LinuxBackend::PulseAudio, + direction: LinuxDirection::Output, + raw_id: "bluez_output.headset".to_string(), + label: "Headset".to_string(), + }, + ]); + + assert_eq!(devices.len(), 2); + assert!(devices.iter().any(|device| device.backend == LinuxBackend::PipeWire)); + assert!(devices.iter().any(|device| device.label == "Headset")); + } } diff --git a/crates/chanora_audio/src/ptt_backends/linux.rs b/crates/chanora_audio/src/ptt_backends/linux.rs index 5de3492..1f6a788 100644 --- a/crates/chanora_audio/src/ptt_backends/linux.rs +++ b/crates/chanora_audio/src/ptt_backends/linux.rs @@ -17,11 +17,13 @@ //! * `try_select` runs a synchronous portal-version probe on //! the blocking proxy. Failure → `None` → Focused fallback. //! * `start(gate, binding)` spawns a worker tokio task that -//! owns an async `zbus::Connection`, calls `CreateSession`, -//! then `BindShortcuts` (sentinel id `"chanora-ptt"`). The -//! portal opens its own system dialog asking the user to -//! pick a key; while the dialog is open the engine continues -//! at L0Focused — the audio path is not blocked. +//! owns an async `zbus::Connection` and calls `CreateSession`. +//! If a binding was already persisted, it also calls +//! `BindShortcuts` (sentinel id `"chanora-ptt"`). Otherwise +//! `BindShortcuts` waits for the user's explicit Configure +//! action, which is when the portal opens its system dialog. +//! While the dialog is open the engine continues at L0Focused — +//! the audio path is not blocked. //! * Once the user accepts, the task subscribes to //! `Activated`/`Deactivated` signals scoped to the session //! handle and calls `gate.set(true/false)` accordingly. @@ -219,7 +221,7 @@ struct BackendInner { enum WorkerCmd { /// Rebind: re-issue `BindShortcuts` on the same session. - Rebind, + Bind, /// Stop: close the session and exit. Stop, } @@ -296,9 +298,10 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend { // the bridge's tokio runtime, so this is satisfied. let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let desc_tx = self.desc_tx.clone(); + let bind_on_start = binding.input_class != PttInputClass::None; let class_hint = binding.input_class; let worker = tokio::spawn(async move { - run_worker(gate, cmd_rx, desc_tx, class_hint).await; + run_worker(gate, cmd_rx, desc_tx, class_hint, bind_on_start).await; }); // Stash command + worker handles. `try_lock` is fine: the // backend isn't yet shared, and `start` is called once at @@ -350,7 +353,7 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend { // only — the portal decides the actual binding. if let Ok(inner) = self.inner.try_lock() { if let Some(tx) = inner.cmd_tx.as_ref() { - let _ = tx.send(WorkerCmd::Rebind); + let _ = tx.send(WorkerCmd::Bind); return Ok(()); } } @@ -373,6 +376,7 @@ async fn run_worker( mut cmd_rx: mpsc::UnboundedReceiver, desc_tx: watch::Sender, initial_class_hint: PttInputClass, + bind_on_start: bool, ) { // Open an async D-Bus session connection. If this fails we // emit a warning and exit; the descriptor stays at L0Focused @@ -417,26 +421,13 @@ async fn run_worker( "linux ptt: portal session created" ); - // Bind the initial shortcut. The portal opens its own dialog. - match bind_shortcut(&proxy, &conn, &session_handle).await { - Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)), - Err(BindError::Cancelled) => { - warn!( - target: "chanora_audio", - bind_status = "cancelled", - "linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused" - ); - publish_l0(&desc_tx); - } - Err(BindError::Failed(e)) => { - warn!( - target: "chanora_audio", - bind_status = "failed", - error = %e, - "linux ptt: BindShortcuts failed; descriptor stays at L0Focused" - ); - publish_l0(&desc_tx); - } + if bind_on_start { + run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await; + } else { + info!( + target: "chanora_audio", + "linux ptt: portal session ready; waiting for explicit BindShortcuts request" + ); } // Subscribe to Activated / Deactivated signals scoped to the @@ -468,12 +459,8 @@ async fn run_worker( tokio::select! { cmd = cmd_rx.recv() => { match cmd { - Some(WorkerCmd::Rebind) => { - match bind_shortcut(&proxy, &conn, &session_handle).await { - Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)), - Err(BindError::Cancelled) => publish_l0(&desc_tx), - Err(BindError::Failed(_)) => publish_l0(&desc_tx), - } + Some(WorkerCmd::Bind) => { + run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await; } Some(WorkerCmd::Stop) | None => { // Close the portal session via the @@ -586,6 +573,35 @@ async fn bind_shortcut( Ok(class) } +async fn run_bind_shortcut( + proxy: &GlobalShortcutsProxy<'_>, + conn: &AsyncConnection, + session_handle: &zbus::zvariant::OwnedObjectPath, + desc_tx: &watch::Sender, + class_hint: PttInputClass, +) { + match bind_shortcut(proxy, conn, session_handle).await { + Ok(class) => publish_bound(desc_tx, class.unwrap_or(class_hint)), + Err(BindError::Cancelled) => { + warn!( + target: "chanora_audio", + bind_status = "cancelled", + "linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused" + ); + publish_l0(desc_tx); + } + Err(BindError::Failed(e)) => { + warn!( + target: "chanora_audio", + bind_status = "failed", + error = %e, + "linux ptt: BindShortcuts failed; descriptor stays at L0Focused" + ); + publish_l0(desc_tx); + } + } +} + /// Wait for the `Response` signal on `request_path`. Returns the /// `results` dict on success (response code 0) or an Error /// otherwise. diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 3d38e1e..29ee1b7 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -816,10 +816,9 @@ pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> { Ok(()) } -/// Toggle self output-mute (speaker). Mutes locally *and* informs -/// the server. The server uses this for the channel icon next to -/// the client name; the local mute kicks in immediately even -/// before the server acknowledges. +/// Toggle self output-mute (speaker). Mutes locally, informs the +/// server, and clamps local microphone transmit while speaker mute +/// is active so the user cannot continue talking while deafened. pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_self_muted(None, Some(muted)).await }) @@ -1230,8 +1229,7 @@ impl From for BridgeAudioProcessingStats { /// On non-Android targets or before a voice session opens the /// section is omitted. Per SDD-090 every field in that section is /// a device-side technical scalar — no PII admitted. -#[frb(sync)] -pub fn export_diagnostics() -> String { +pub async fn export_diagnostics() -> String { let metadata = vec![ ( "crate_version".to_string(), @@ -1247,12 +1245,15 @@ pub fn export_diagnostics() -> String { // diagnostics snapshot from the process-global slot published // by AndroidVoiceUnit::open(). Returns None on non-Android and // before any voice session has opened. + #[cfg(target_os = "android")] let android_audio_yaml = chanora_audio::mobile_voice_backend::current_android_audio_diagnostics() .map(|d| d.to_yaml_fragment()); - let audio_health = runtime().block_on(async { session().audio_processing_stats().await.ok() }); - let network_info = runtime().block_on(async { session().network_diagnostics_summary().await }); - let protocol_events = runtime().block_on(async { session().drain_protocol_events().await }); + #[cfg(not(target_os = "android"))] + let android_audio_yaml: Option = None; + let audio_health = session().audio_processing_stats().await.ok(); + let network_info = session().network_diagnostics_summary().await; + let protocol_events = session().drain_protocol_events().await; let android_audio_yaml = android_audio_yaml.map(|mut yaml| { if let Some(stats) = audio_health { yaml.push_str(&format!( diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 1ac3001..90eca61 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -370,15 +370,16 @@ fn wire__crate__api__events_stream_impl( ) } fn wire__crate__api__export_diagnostics_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { debug_name: "export_diagnostics", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, move || { let message = unsafe { @@ -391,10 +392,15 @@ fn wire__crate__api__export_diagnostics_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); deserializer.end(); - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::export_diagnostics())?; - Ok(output_ok) - })()) + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::export_diagnostics().await; + Ok(output_ok) + })() + .await, + ) + } }, ) } @@ -2509,6 +2515,7 @@ fn pde_ffi_dispatcher_primary_impl( 7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), 8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len), 9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__export_diagnostics_impl(port, ptr, rust_vec_len, data_len), 11 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), 12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), @@ -2552,7 +2559,6 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), 15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len), 16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len), 17 => wire__crate__api__handle_media_services_reset_with_route_impl(