From 4c6aac654a0896699e5b047e89bfe9482afac596 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 10 Apr 2026 07:35:12 +0400 Subject: [PATCH] fix(android-audio): restart Oboe on speakerphone toggle + unbreak button UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build 4f2ad65 wired the Speaker button to AudioManager.setSpeakerphoneOn but user testing found that flipping speakerphone on an active Oboe VoiceCommunication stream silently tears down the AAudio streams on Pixel-class devices — both capture and playout stop producing data. Only ending the call and rejoining brings audio back (because the fresh Oboe open runs with the new routing already applied). Also the earpiece state showed up red in the UI because the button was getting the `.muted` CSS class when speakerphoneOn=false. Earpiece is a valid routing state, not a muted one. Fix set_speakerphone Tauri command: 1. Flip AudioManager.setSpeakerphoneOn via JNI (as before). 2. If the Oboe backend is currently running, stop it, sleep 50 ms to let AAudio finalise the transition, then start it again. The Rust send/recv tokio tasks keep running across the gap — they just read zero samples and write into the preserved ring buffers for a few frames, which is acceptable. The AudioBackend singleton's ring state is preserved across stop+start because it's in a 'static OnceLock. 3. Debounce the UI click via speakerphoneBusy + spkBtn.disabled so users can't queue up multiple toggles during the restart window. Fix main.ts Speaker button: - Remove the `.muted` classList toggle (added `.speaker-on` for CSS). - Update label text to "🔊 Speaker" / "🔈 Earpiece" for clarity. - On showCallScreen(), invoke is_speakerphone_on to sync the label with the real AudioManager state, so it matches reality after a rejoin (which was another symptom the user hit — the button label desynced from the actual routing after ending and restarting a call). - Debounce click + disable button while the restart is in flight. Drops #[allow(dead_code)] from wzp_native::audio_is_running now that it is actually called from the set_speakerphone restart guard. --- desktop/src-tauri/src/lib.rs | 22 ++++++++++++++++++-- desktop/src-tauri/src/wzp_native.rs | 1 - desktop/src/main.ts | 31 +++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f5fb090..5552113 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -354,13 +354,31 @@ async fn get_status(state: tauri::State<'_, Arc>) -> Result Result<(), String> { #[cfg(target_os = "android")] { - android_audio::set_speakerphone(on) + android_audio::set_speakerphone(on)?; + if wzp_native::is_loaded() && wzp_native::audio_is_running() { + tracing::info!(on, "set_speakerphone: restarting Oboe for route change"); + wzp_native::audio_stop(); + // Give AAudio a tick to finalise the stop before we re-open. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + wzp_native::audio_start() + .map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))?; + tracing::info!("set_speakerphone: Oboe restarted"); + } + Ok(()) } #[cfg(not(target_os = "android"))] { diff --git a/desktop/src-tauri/src/wzp_native.rs b/desktop/src-tauri/src/wzp_native.rs index 7c492f0..4f73e98 100644 --- a/desktop/src-tauri/src/wzp_native.rs +++ b/desktop/src-tauri/src/wzp_native.rs @@ -123,7 +123,6 @@ pub fn audio_write_playout(input: &[i16]) -> usize { unsafe { f(input.as_ptr(), input.len()) } } -#[allow(dead_code)] pub fn audio_is_running() -> bool { AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false) } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index fa37f96..57f3caf 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -495,6 +495,12 @@ function showCallScreen() { roomName.textContent = roomInput.value; callStatus.className = "status-dot"; statusInterval = window.setInterval(pollStatus, 250); + // Sync the Speaker/Earpiece label with the OS state (Android only; on + // desktop the command is a no-op returning false so we land on "Earpiece" + // which is fine because desktop has no routing concept). + invoke("is_speakerphone_on") + .then((on) => { speakerphoneOn = !!on; updateSpkLabel(); }) + .catch(() => { speakerphoneOn = false; updateSpkLabel(); }); } function showConnectScreen() { @@ -511,23 +517,36 @@ micBtn.addEventListener("click", async () => { try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {} }); -// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn so the -// same Oboe VoiceCommunication stream swaps between earpiece and -// loudspeaker without restarting. Desktop callers get a no-op command so -// the same UI works everywhere. +// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then +// stops and restarts the Oboe streams so AAudio reconfigures with the new +// routing. The Rust-side Tauri command handles the restart, we just swap +// the button label. +// +// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class +// (which would tint the button red); that was a bug in 0178cbd that made +// earpiece mode look like playback was off. A separate `.speaker-on` class +// is available for css styling if we want to visually indicate loud mode. let speakerphoneOn = false; +let speakerphoneBusy = false; function updateSpkLabel() { - spkBtn.classList.toggle("muted", !speakerphoneOn); - spkIcon.textContent = speakerphoneOn ? "Speaker" : "Earpiece"; + spkBtn.classList.toggle("speaker-on", speakerphoneOn); + spkBtn.classList.remove("muted"); + spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece"; } spkBtn.addEventListener("click", async () => { + if (speakerphoneBusy) return; // debounce — the restart takes ~60ms + speakerphoneBusy = true; const next = !speakerphoneOn; + spkBtn.disabled = true; try { await invoke("set_speakerphone", { on: next }); speakerphoneOn = next; updateSpkLabel(); } catch (e) { console.error("set_speakerphone failed:", e); + } finally { + spkBtn.disabled = false; + speakerphoneBusy = false; } }); hangupBtn.addEventListener("click", async () => {