feat(android): the mic has an off switch you can reach mid-stream
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<NativeClient>, echo_cancel: bool) -> Option<MicCapture> {
|
||||
///
|
||||
/// `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<NativeClient>,
|
||||
echo_cancel: bool,
|
||||
muted: Arc<AtomicBool>,
|
||||
) -> Option<MicCapture> {
|
||||
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<NativeClient>,
|
||||
rx: Receiver<Vec<f32>>,
|
||||
free_tx: SyncSender<Vec<f32>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
muted: Arc<AtomicBool>,
|
||||
captured: Arc<AtomicU64>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
) {
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// 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<AtomicBool>,
|
||||
}
|
||||
|
||||
struct VideoThread {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user