518 lines
23 KiB
Rust
518 lines
23 KiB
Rust
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
|
||
//! ~1 Hz decode-stats drain for the HUD.
|
||
|
||
use jni::objects::JObject;
|
||
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
|
||
#[cfg(target_os = "android")]
|
||
use jni::objects::JString;
|
||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||
use jni::JNIEnv;
|
||
|
||
use super::{jni_guard, SessionHandle};
|
||
|
||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||
/// No-op if already started.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||
mut env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
surface: JObject,
|
||
decoder_name: JString,
|
||
low_latency_mode: jboolean,
|
||
ll_feature: jboolean,
|
||
is_tv: jboolean,
|
||
present_priority: jni::sys::jint,
|
||
smooth_buffer: jni::sys::jint,
|
||
panel_fps: jni::sys::jint,
|
||
) {
|
||
use super::VideoThread;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::Arc;
|
||
|
||
if handle == 0 {
|
||
return;
|
||
}
|
||
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
|
||
let decoder = env
|
||
.get_string(&decoder_name)
|
||
.ok()
|
||
.map(String::from)
|
||
.filter(|s| !s.is_empty());
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = h.video.lock().unwrap();
|
||
if guard.is_some() {
|
||
return; // already streaming
|
||
}
|
||
// SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any
|
||
// jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers).
|
||
let window = match unsafe {
|
||
ndk::native_window::NativeWindow::from_surface(
|
||
env.get_native_interface() as *mut _,
|
||
surface.as_raw() as *mut _,
|
||
)
|
||
} {
|
||
Some(w) => w,
|
||
None => {
|
||
log::error!("nativeStartVideo: no ANativeWindow from Surface");
|
||
return;
|
||
}
|
||
};
|
||
let shutdown = Arc::new(AtomicBool::new(false));
|
||
let client = h.client.clone();
|
||
let sd = shutdown.clone();
|
||
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||
let opts = crate::decode::DecodeOptions {
|
||
decoder_name: decoder,
|
||
ll_feature: ll_feature != 0,
|
||
low_latency_mode: low_latency_mode != 0,
|
||
is_tv: is_tv != 0,
|
||
present_priority,
|
||
smooth_buffer,
|
||
panel_hz: panel_fps,
|
||
};
|
||
let join = std::thread::Builder::new()
|
||
.name("pf-decode".into())
|
||
.spawn(move || crate::decode::run(client, window, sd, st, opts))
|
||
.ok();
|
||
*guard = Some(VideoThread { shutdown, join });
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||
/// device).
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(h.stats.decoder_label()) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||
/// session). No-op on `0`.
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_video();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||
/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles
|
||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow]`
|
||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||
/// the cumulative session total for older readers; 22–25 are the `display` stage from the
|
||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||
/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` =
|
||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||
/// newest-wins pacing)), or `null` when no decode thread is running.
|
||
/// Poll ~1 Hz from the UI; each call
|
||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||
/// the host build too (Kotlin only ever calls it on device).
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||
env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) -> jdoubleArray {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
if h.video.lock().unwrap().is_none() {
|
||
return std::ptr::null_mut(); // not streaming → no stats
|
||
}
|
||
let snap = h
|
||
.stats
|
||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||
let mode = h.client.mode();
|
||
let color = h.client.color;
|
||
let buf: [f64; 33] = [
|
||
snap.fps,
|
||
snap.mbps,
|
||
snap.e2e_p50_ms,
|
||
snap.e2e_p95_ms,
|
||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||
mode.width as f64,
|
||
mode.height as f64,
|
||
mode.refresh_hz as f64,
|
||
h.client.frames_dropped() as f64,
|
||
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
|
||
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
|
||
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||
h.client.bit_depth as f64,
|
||
color.primaries as f64,
|
||
color.transfer as f64,
|
||
h.client.chroma_format as f64,
|
||
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||
snap.hostnet_p50_ms,
|
||
snap.decode_p50_ms,
|
||
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||
snap.host_p50_ms,
|
||
snap.net_p50_ms,
|
||
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||
snap.lost as f64,
|
||
snap.skipped as f64,
|
||
snap.fec as f64,
|
||
snap.frames as f64,
|
||
// `display` stage (OnFrameRendered render timestamps): validity flag, the
|
||
// decoded→displayed stage p50, and the directly-measured capture→displayed headline
|
||
// pair that supersedes 2/3 whenever the flag is set (spec: the equation always tiles
|
||
// the headline interval, so endpoint and terms move together).
|
||
if snap.disp_valid { 1.0 } else { 0.0 },
|
||
snap.display_p50_ms,
|
||
snap.e2e_disp_p50_ms,
|
||
snap.e2e_disp_p95_ms,
|
||
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||
snap.pace_p50_ms,
|
||
snap.latch_p50_ms,
|
||
snap.presents as f64,
|
||
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||
// The `decode` stage's split (P3 science): feed = received→queued (hand-off +
|
||
// input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU
|
||
// overflow subset of `skipped` (decoder-health vs benign pacing drops).
|
||
snap.feed_p50_ms,
|
||
snap.codec_p50_ms,
|
||
snap.skipped_overflow as f64,
|
||
];
|
||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||
Ok(a) => a,
|
||
Err(_) => return std::ptr::null_mut(),
|
||
};
|
||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||
return std::ptr::null_mut();
|
||
}
|
||
arr.into_raw()
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||
/// build too. Cheap; safe on the UI thread.
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||
env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) -> jintArray {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mode = h.client.mode();
|
||
let buf: [i32; 3] = [
|
||
mode.width as i32,
|
||
mode.height as i32,
|
||
mode.refresh_hz as i32,
|
||
];
|
||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||
Ok(a) => a,
|
||
Err(_) => return std::ptr::null_mut(),
|
||
};
|
||
if env.set_int_array_region(&arr, 0, &buf).is_err() {
|
||
return std::ptr::null_mut();
|
||
}
|
||
arr.into_raw()
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
||
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
|
||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
enabled: jboolean,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||
h.stats.set_enabled(
|
||
enabled != 0,
|
||
h.client.frames_dropped(),
|
||
h.client.fec_recovered_shards(),
|
||
);
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||
/// streaming.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
low_latency_mode: jboolean,
|
||
) {
|
||
if handle == 0 {
|
||
return;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = h.audio.lock().unwrap();
|
||
if guard.is_some() {
|
||
return; // already playing
|
||
}
|
||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
|
||
Some(p) => *guard = Some(p),
|
||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||
}
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||
/// closing the session). No-op on `0`.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_audio();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStartMic(handle, echoCancel): Int` — start mic capture (AAudio input →
|
||
/// Opus → host `send_mic`). `echoCancel` opens the capture under the `VoiceCommunication` preset
|
||
/// (the HAL's echo canceller / noise suppressor) and allocates an audio session id; the return
|
||
/// value is that id (`> 0`), so Kotlin can attach the Java `AcousticEchoCanceler`/`NoiseSuppressor`
|
||
/// as a backstop — `0` when none was allocated (echoCancel off, the preset fell back to the plain
|
||
/// open, a `0` handle, or the mic failed entirely). Already running (a surface recreate) returns
|
||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||
/// the rest of the session streaming.
|
||
#[cfg(target_os = "android")]
|
||
#[no_mangle]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
echo_cancel: jboolean,
|
||
) -> jni::sys::jint {
|
||
if handle == 0 {
|
||
return 0;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = h.mic.lock().unwrap();
|
||
if let Some(m) = guard.as_ref() {
|
||
return m.session_id(); // already capturing — same stream, same session
|
||
}
|
||
// 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);
|
||
session_id
|
||
}
|
||
None => {
|
||
log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)");
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
||
/// 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(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_mic();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `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())
|
||
})
|
||
}
|