fix: refine linux voice runtime behavior

This commit is contained in:
Edison Jwa
2026-05-25 18:26:49 +09:00
parent cd14aa7b98
commit d03ea937e6
9 changed files with 156 additions and 73 deletions
+4 -4
View File
@@ -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<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
+3 -1
View File
@@ -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")]
@@ -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<u32> {
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<u32> {
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<AudioDeviceInfo> {
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<AudioDeviceInfo> {
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<LinuxDevice> {
devices
}
fn visible_devices(devices: Vec<LinuxDevice>) -> Vec<LinuxDevice> {
let pipewire_labels = devices
.iter()
.filter(|device| device.backend == LinuxBackend::PipeWire)
.map(|device| normalized_device_label(&device.label))
.collect::<std::collections::HashSet<_>>();
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::<Vec<_>>().join(" ").to_lowercase()
}
fn list_pipewire_nodes(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, 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"));
}
}
+50 -34
View File
@@ -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<WorkerCmd>,
desc_tx: watch::Sender<PttBackendDescriptor>,
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<PttBackendDescriptor>,
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.