From 253e0bbe7c2084a246ccdafeeb496567bf51cb72 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:41:09 +0200 Subject: [PATCH] feat(android): the mic has an off switch you can reach mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 gave the Android client a mic worth using. It gave it no way to stop talking: leaving the stream, or digging through Settings to turn the whole feature off, were the only ways to stop the room being heard. Now a tap (or Select + Y on a pad) mutes it, and the screen says so while it lasts. How muting gates the capture, and why that way. The AAudio input stream is never stopped: a stop/start would re-run the input-preset fallback ladder and re-prime the buffers on every toggle — hundreds of milliseconds, and possibly a landing on a different rung, silently losing the HAL echo canceller wave 1 went to some trouble to get. Instead the encode loop reads an AtomicBool per 10 ms frame and, while it is set, drains the frame out of its ring and drops it there — the last point before it would have become an Opus packet. Nothing is encoded, nothing is sent, and the realtime capture callback is untouched, so its allocation-free discipline and the queue policy stay exactly as wave 1 verified them. A toggle costs one atomic store and takes effect on the next 10 ms boundary. The frame counter keeps advancing across a mute, because it numbers the captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees is exactly the audio that never came: its de-jitter conceals at most a few frames of it before the pump's 600 ms stale-gap flush resets the chain outright, which is the right reading of a mute. Encoding silence instead would have kept a pointless uplink and a host-side ring alive for its whole duration. Mute is per session and nothing is persisted — a new stream always starts unmuted, and no new setting exists. The flag lives on the session handle rather than on the capture, so the mic stop/start a surface recreate performs brings the user's choice back with it, with no window in which the fresh capture could send an unmuted frame. The control is offered on the evidence that a capture is actually running (nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO denied, or every AAudio input rung refused, there is nothing on screen to lie about. On touch it is a pill in the corner the stats HUD doesn't use — the one in-stream control, so it sits above the gesture layer to take its own taps — dim while live, a red "Muted" badge while it isn't. On TV that badge is the indicator alone: Select + Y is the control there, and a focusable button would fight the game for the D-pad. Y is deliberately not one of the exit chord's buttons, so neither chord can be reached through the other. One honest consequence of keeping the stream open: the platform's recording indicator stays lit while muted, because the mic really is still open. What stops is the encode and the send. Co-Authored-By: Claude Fable 5 --- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 125 ++++++++++++++++++ .../io/unom/punktfunk/kit/GamepadRouter.kt | 29 +++- .../io/unom/punktfunk/kit/NativeBridge.kt | 34 ++++- clients/android/native/src/mic.rs | 44 +++++- clients/android/native/src/session/connect.rs | 2 + clients/android/native/src/session/mod.rs | 7 + clients/android/native/src/session/planes.rs | 63 ++++++++- 7 files changed, 296 insertions(+), 8 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 4b97feaa..d60ddb7d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -28,12 +28,20 @@ import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -44,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext @@ -107,6 +116,31 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // All three touch points run on the main thread; a plain list is race-free. val micEffects = remember { mutableListOf() } + // In-stream mic mute. Per SESSION and never persisted (no setting backs it): a new stream + // always starts unmuted. The authoritative flag lives on the native handle, which is why a mute + // survives the mic stop/start a surface recreate performs — this state is the UI's mirror of + // it, and survives the same recreate because the composition outlives the surface. + var micMuted by remember(handle) { mutableStateOf(false) } + // Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what + // nativeMicActive reports. A device that refused every AAudio input rung gets no mute control + // rather than one that lies about a mic being heard. + var micRunning by remember(handle) { mutableStateOf(false) } + // Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path + // needs it: the touch button confirms itself by changing under the finger, but a chord has no + // on-screen state of its own, and "did that register?" is exactly the doubt to answer. + var micHint by remember { mutableStateOf(null) } + LaunchedEffect(micHint) { + if (micHint != null) { + delay(1600) + micHint = null + } + } + // The one place mute is toggled — Compose state + the native flag, always together. + val setMicMuted = { muted: Boolean -> + micMuted = muted + NativeBridge.nativeSetMicMuted(handle, muted) + } + // Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline: // the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per // frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets @@ -297,6 +331,16 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. router.onExitArmed = { armed -> exitArming = armed } + // Select + Y toggles the mic — the couch reach for the on-screen mute button, which a + // gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing + // to mute, and claiming otherwise would be the lie the control exists to avoid). + router.onMicChord = { + if (micRunning) { + val next = !micMuted + setMicMuted(next) + micHint = if (next) "Microphone muted" else "Microphone live" + } + } // Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured // (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look. // The local cursor is hidden over the stream — the host's own cursor, composited into @@ -493,6 +537,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down + router.onMicChord = null // same: no mute toggle on buttons released during teardown router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null // Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor. @@ -626,6 +671,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { if (initialSettings.echoCancel) { attachMicEffects(sessionId, micEffects) } + // Did a capture actually open? That — not the setting — is what + // puts the mute control on screen. A restart after a surface + // recreate comes back already muted if the user muted: the flag + // lives on the session handle, so nothing has to be re-applied. + micRunning = NativeBridge.nativeMicActive(handle) } } @@ -639,6 +689,10 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { if (!closed.get()) { releaseMicEffects(micEffects) NativeBridge.nativeStopMic(handle) + // No capture, no control — but the MUTE state is deliberately left + // standing (native keeps it on the handle), so the restart in + // surfaceCreated brings the user's choice back with it. + micRunning = false NativeBridge.nativeStopAudio(handle) NativeBridge.nativeStopVideo(handle) } @@ -713,6 +767,20 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } }, ) + // Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual + // overlays above it has to sit on top of the gesture layer to receive its own taps (it + // costs the stream that small corner of touch area, which is why it exists only while a + // capture actually runs). On TV it is the indicator alone: the Select + Y chord is the + // control there, and a focusable button would fight the game for the D-pad. + if (micRunning && (micMuted || !isTv)) { + MicMuteControl( + muted = micMuted, + onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }), + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + ) + } + // Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger. + micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } } } @@ -742,6 +810,63 @@ private fun releaseMicEffects(effects: MutableList) { effects.clear() } +/** + * The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the + * uplink is live, a red **Muted** badge while it isn't — so the state that matters is the loud one, + * readable at couch distance and impossible to mistake for the stream's own picture. + * + * [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord + * is the control); non-null makes the badge itself the touch target. Rendering it at all is the + * caller's decision — it means a capture is genuinely running. + */ +@Composable +private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(10.dp) + Row( + modifier = modifier + .clip(shape) + .background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f)) + .then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic, + // Spoken state first, then the action — a talkback user needs to know they are muted + // before they need to know how to stop being muted. + contentDescription = if (muted) { + "Microphone muted. Activate to unmute." + } else { + "Microphone live. Activate to mute." + }, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + if (muted) { + Spacer(Modifier.width(6.dp)) + Text("Muted", color = Color.White, fontSize = 14.sp) + } + } +} + +/** + * Transient confirmation that the mic chord (Select + Y) registered. The badge above already says + * *muted*, but nothing on screen says *un*muted — and "did that press do anything?" is the whole + * doubt a chord with no button under the finger creates. Same pill vocabulary as the other + * in-stream cues; the caller clears it after a beat. + */ +@Composable +private fun MicChordHint(text: String, modifier: Modifier = Modifier) { + Text( + text, + modifier = modifier + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The * chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 46c365ce..3da87f82 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -65,6 +65,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ var onExitArmed: ((armed: Boolean) -> Unit)? = null + /** + * Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on + * a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user + * cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires + * immediately: muting is the kind of thing you want to have already happened, and the on-screen + * indicator makes an accidental toggle self-evident. The buttons still go to the host — the + * chord adds a meaning to them rather than swallowing them, exactly as the exit chord does. + */ + var onMicChord: (() -> Unit)? = null + private val mainHandler = Handler(Looper.getMainLooper()) /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ private var pendingExit: Runnable? = null @@ -108,14 +118,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** * One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s - * transitions: forward the wire event, track held state, and arm/disarm the exit chord. + * transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire + * the mic-mute chord ([MIC_CHORD]). */ private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) { if (down) { if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) + val wasHeld = slot.held slot.held = slot.held or bit // Full chord now held on this pad → start the hold countdown (idempotent while held). if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() + // Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press + // (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member + // that leaves the whole chord held. Any other button pressed while Select + Y are down + // fails the middle test, so the toggle happens once per chord, not once per press. + if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) { + onMicChord?.invoke() + } } else { if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) slot.held = slot.held and bit.inv() @@ -351,6 +370,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ const val EXIT_HOLD_MS = 1000L + /** + * Mic-mute chord: Select + Y. Y is deliberately NOT one of [EXIT_CHORD]'s buttons, so no + * way of reaching the exit chord can pass through this one on the way (and vice versa) — + * and Select is a menu button rather than a twitch action, which makes the pair unlikely + * to occur inside real play. + */ + const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y + /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000 } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index b54b9c53..4b335343 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -300,9 +300,41 @@ object NativeBridge { */ external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int - /** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */ + /** + * Stop + join the mic thread and close the AAudio input stream. No-op on `0`. Leaves the + * session's mute state ([nativeSetMicMuted]) alone — a surface recreate stops and restarts the + * mic, and a user who muted must stay muted through it. + */ external fun nativeStopMic(handle: Long) + /** + * Mute/unmute the mic uplink mid-stream. Muting does NOT stop the capture: the AAudio input + * stream, the input preset it settled on and its primed buffers stay as they are, and the + * encode loop drops each 10 ms frame instead of encoding + sending it — so room audio is never + * encoded and nothing goes on the wire, while a toggle costs an atomic store and takes effect + * on the next 10 ms boundary (a stop/start would re-run the preset fallback ladder and re-prime + * buffers every time). + * + * Sticky for the SESSION — the flag lives on the handle, not on the capture — so the mic + * restart a surface recreate performs comes back muted, with no window for an unmuted frame to + * escape; a fresh session always starts unmuted. Nothing here is persisted. No-op on `0`. + * Cheap (one atomic store); UI-safe. + * + * One honest consequence of keeping the stream open: the platform's own recording indicator + * stays lit while muted, because the mic really is still open. What stops is the encode and the + * send — no captured audio leaves the process. + */ + external fun nativeSetMicMuted(handle: Long, muted: Boolean) + + /** + * Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has + * [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than + * on the user's setting: a device that refused every AAudio input rung (or a missing + * RECORD_AUDIO grant) then shows no control instead of a lie about a mic being heard. `false` + * on a `0` handle. Cheap; UI-safe. + */ + external fun nativeMicActive(handle: Long): Boolean + // ---- Input: Kotlin captures, Rust forwards to the host (send_input) ---- /** Relative mouse move; dx/dy are device-pixel deltas (screen +y down). */ diff --git a/clients/android/native/src/mic.rs b/clients/android/native/src/mic.rs index b4587319..45d0547f 100644 --- a/clients/android/native/src/mic.rs +++ b/clients/android/native/src/mic.rs @@ -9,6 +9,10 @@ //! the host decodes any Opus frame ≤ 120 ms with its stereo decoder (mono packets upmix), so this //! needs no protocol change; speech gains nothing from stereo, and the shorter frame shaves a //! buffering interval off the uplink. +//! +//! **Mute** is a flag the encode loop reads per 10 ms frame, never a stream teardown: the AAudio +//! input stream, the input-preset ladder it settled on and its primed buffers all survive a +//! mute/unmute untouched, so toggling costs an atomic load and nothing else. use ndk::audio::{ AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode, @@ -63,7 +67,16 @@ impl MicCapture { /// preset deliberately bypasses them, which is why the host used to hear its own stream back /// from a speaker-playing phone) — and allocates an audio session id for Kotlin's Java-effect /// backstop. `None` on failure (the caller leaves the rest of the session streaming). - pub fn start(client: Arc, echo_cancel: bool) -> Option { + /// + /// `muted` is the SESSION's live mic-mute flag (owned by `SessionHandle`, not by this capture), + /// honoured per frame by [`encode_loop`]. Sharing it rather than owning it is what makes mute + /// survive the mic stop/start a surface recreate performs — and means a capture started while + /// muted never encodes its first frame, so there is no window for one to escape. + pub fn start( + client: Arc, + echo_cancel: bool, + muted: Arc, + ) -> Option { let captured = Arc::new(AtomicU64::new(0)); // Chunks discarded on the capture thread (free-list empty / encoder lagging); logged // throttled from the encode worker. @@ -211,7 +224,7 @@ impl MicCapture { let sd = shutdown.clone(); let join = std::thread::Builder::new() .name("pf-mic".into()) - .spawn(move || encode_loop(client, rx, free_tx, sd, captured, dropped)) + .spawn(move || encode_loop(client, rx, free_tx, sd, muted, captured, dropped)) .ok(); Some(MicCapture { @@ -241,11 +254,16 @@ impl Drop for MicCapture { /// Consumer: drain captured f32 → accumulate → Opus `encode_float` 10 ms mono frames → `send_mic`. /// Drained chunk buffers go back to the callback's free-list; the encode scratch is reused across /// frames (only the packet Vec handed to `send_mic` is allocated per frame — it's sent away owned). +/// +/// While `muted` is set a formed frame is dropped instead of encoded (see the frame loop) — the +/// capture side keeps running exactly as it does unmuted, so nothing about the stream, its ring or +/// its backlog behaviour changes across a toggle. fn encode_loop( client: Arc, rx: Receiver>, free_tx: SyncSender>, shutdown: Arc, + muted: Arc, captured: Arc, dropped: Arc, ) { @@ -279,6 +297,7 @@ fn encode_loop( let mut seq: u32 = 0; let mut sent: u64 = 0; let mut stale: u64 = 0; // frames shed by the backlog self-heal (see BACKLOG_MAX_FRAMES) + let mut muted_frames: u64 = 0; // frames dropped unencoded because the user muted let mut peak = 0f32; // loudest |sample| since the last log — tells speech from silence while !shutdown.load(Ordering::Relaxed) { @@ -308,6 +327,22 @@ fn encode_loop( stale += (excess / frame) as u64; } while ring.len() >= frame { + // Muted: drop the frame at the last point before it would become an Opus packet — + // room audio is never encoded and nothing goes on the wire. `seq` still advances, + // because it numbers the captured 10 ms TIMELINE rather than the datagrams: the gap + // the host then sees is exactly the audio that never came, which its de-jitter reads + // as at most a few concealment frames before the pump's 600 ms stale-gap flush resets + // the chain outright — the right reading of a mute. (Encoding silence instead would + // keep a pointless uplink and a host-side ring alive for the whole mute.) `peak` is + // the loudest sample the UPLINK carried since the last log, so a dropped frame resets + // rather than raises it. + if muted.load(Ordering::Relaxed) { + ring.drain(..frame); + seq = seq.wrapping_add(1); + muted_frames += 1; + peak = 0.0; + continue; + } for (dst, src) in pcm.iter_mut().zip(ring.drain(..frame)) { *dst = src; } @@ -326,7 +361,7 @@ fn encode_loop( if sent % 500 == 0 { log::info!( "mic: sent={sent} captured_frames={} dropped_chunks={} \ - stale_frames={stale} peak={peak:.3}", + stale_frames={stale} muted_frames={muted_frames} peak={peak:.3}", captured.load(Ordering::Relaxed), dropped.load(Ordering::Relaxed), ); @@ -338,7 +373,8 @@ fn encode_loop( } } log::info!( - "mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale})", + "mic: stopped (sent={sent} captured_frames={} dropped_chunks={} stale_frames={stale} \ + muted_frames={muted_frames})", captured.load(Ordering::Relaxed), dropped.load(Ordering::Relaxed), ); diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index e8ab5652..4eb3edca 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -250,6 +250,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo audio: Mutex::new(None), #[cfg(target_os = "android")] mic: Mutex::new(None), + // A fresh session is never muted (mute is per-session UI state, not a setting). + mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; Box::into_raw(Box::new(handle)) as jlong } diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index 9fc90216..59927da8 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -61,6 +61,13 @@ pub(crate) struct SessionHandle { audio: Mutex>, #[cfg(target_os = "android")] mic: Mutex>, + /// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's + /// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`] + /// for the same reason the stats gate is: the mic stops and restarts across a surface + /// recreate, and a mute the user set must come back with it — with no window in which the + /// fresh capture could send an unmuted frame. Per session and never persisted: a new session + /// starts unmuted. + pub mic_muted: Arc, } struct VideoThread { diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 97040f8f..6c5b3aea 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -415,7 +415,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic( if let Some(m) = guard.as_ref() { return m.session_id(); // already capturing — same stream, same session } - match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0) { + // The capture SHARES the session's mute flag, so one started while muted stays muted (and + // sends nothing) from its very first frame — see `SessionHandle::mic_muted`. + match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) { Some(m) => { let session_id = m.session_id(); *guard = Some(m); @@ -429,7 +431,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic( } /// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input -/// stream (without closing the session). No-op on `0`. +/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a +/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it. #[cfg(target_os = "android")] #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic( @@ -445,3 +448,59 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic( } }) } + +/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream. +/// +/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung +/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply +/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset +/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different +/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one +/// relaxed load per frame there, and takes effect on the very next 10 ms boundary. +/// +/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a +/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a +/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic +/// store, so it links on the host build too. +/// +/// One honest consequence of keeping the stream open: the platform's own recording indicator stays +/// lit while muted, because the mic really is still open. What stops is the encode and the send — +/// no captured audio leaves the process. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted( + _env: JNIEnv, + _this: JObject, + handle: jlong, + muted: jboolean, +) { + jni_guard((), || { + if handle != 0 { + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.mic_muted + .store(muted != 0, std::sync::atomic::Ordering::Relaxed); + } + }) +} + +/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only +/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream +/// mute control is offered on this evidence rather than on the user's setting, so a device that +/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a +/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock). +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jboolean { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + jboolean::from(h.mic.lock().unwrap().is_some()) + }) +}