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()) + }) +}