fix(android-audio): restart Oboe on speakerphone toggle + unbreak button UI
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m25s

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.
This commit is contained in:
Siavash Sameni
2026-04-10 07:35:12 +04:00
parent 4f2ad65418
commit 4c6aac654a
3 changed files with 45 additions and 9 deletions

View File

@@ -354,13 +354,31 @@ async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus
/// Switch the call audio between earpiece (`on=false`) and loudspeaker
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
/// JNI; on desktop it's a no-op that always succeeds.
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
/// with the new routing — without the restart, changing the speakerphone
/// state mid-call silently tears down the running AAudio streams on some
/// OEMs and both capture + playout stop producing data.
///
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
/// window; they just observe empty reads / writes against the
/// process-global ring buffers, which is fine because the ring state
/// is preserved across stop+start.
#[tauri::command]
#[allow(unused_variables)]
async fn set_speakerphone(on: bool) -> 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"))]
{

View File

@@ -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)
}

View File

@@ -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<boolean>("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 () => {