From a681de7e5cf1d4ebd9400e004c46d437759848fe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 21:53:01 +0200 Subject: [PATCH] feat(android): the mic goes through the echo canceller, and the host stops hearing itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture stream opened under AAudio's default VoiceRecognition input preset, which deliberately bypasses the HAL's acoustic echo canceller — so a phone playing the game audio out of its own speaker fed that audio straight back to the host. Two layers fix it, both behind a new "Echo cancellation" setting (default ON, next to the Microphone toggle in the touch and console settings, per-profile like every tier-P setting): - Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS on the capture path) and allocates an audio session id. The open ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some HALs refuse the preset or a session id outright, and a mic without echo cancellation still beats no mic; the last rungs are exactly the preset-less open this always did. - Kotlin backstop: nativeStartMic now returns the allocated session id (0 = none), and StreamScreen hangs the Java AcousticEchoCanceler + NoiseSuppressor off it (guarded by isAvailable), releasing them on every mic-stop path — the surface teardown and the final dispose — so a surface recreate re-attaches instead of leaking effect engines. The playback stream is untouched: retagging it voice/communication would route it through the phone-call chain and regress quality. Co-Authored-By: Claude Fable 5 --- .../unom/punktfunk/GamepadSettingsScreen.kt | 5 + .../main/kotlin/io/unom/punktfunk/Profiles.kt | 13 ++- .../main/kotlin/io/unom/punktfunk/Settings.kt | 12 +++ .../io/unom/punktfunk/SettingsScreen.kt | 8 ++ .../kotlin/io/unom/punktfunk/StreamScreen.kt | 46 +++++++- .../io/unom/punktfunk/kit/NativeBridge.kt | 14 ++- clients/android/native/src/mic.rs | 102 ++++++++++++++---- clients/android/native/src/session/planes.rs | 33 ++++-- 8 files changed, 193 insertions(+), 40 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 52072df3..11477d32 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -395,6 +395,11 @@ private fun buildSettingsRows( "mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.", s.micEnabled, ) { update(s.copy(micEnabled = it)) }, + toggle( + "echoCancel", null, "Echo cancellation", + "Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.", + s.echoCancel, + ) { update(s.copy(echoCancel = it)) }, choice( "padType", "Controllers", "Controller type", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt index 5cb6c3c8..085febf7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt @@ -38,6 +38,7 @@ data class SettingsOverlay( val compositor: Int? = null, val audioChannels: Int? = null, val micEnabled: Boolean? = null, + val echoCancel: Boolean? = null, val touchMode: TouchMode? = null, val mouseMode: MouseMode? = null, val invertScroll: Boolean? = null, @@ -70,6 +71,7 @@ data class SettingsOverlay( compositor = compositor ?: base.compositor, audioChannels = audioChannels ?: base.audioChannels, micEnabled = micEnabled ?: base.micEnabled, + echoCancel = echoCancel ?: base.echoCancel, touchMode = touchMode ?: base.touchMode, mouseMode = mouseMode ?: base.mouseMode, invertScroll = invertScroll ?: base.invertScroll, @@ -103,6 +105,7 @@ data class SettingsOverlay( compositor = if (after.compositor != before.compositor) after.compositor else compositor, audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels, micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled, + echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel, touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode, mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode, invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll, @@ -128,6 +131,7 @@ data class SettingsOverlay( "compositor" -> copy(compositor = null) "audio_channels" -> copy(audioChannels = null) "mic_enabled" -> copy(micEnabled = null) + "echo_cancel" -> copy(echoCancel = null) "touch_mode" -> copy(touchMode = null) "mouse_mode" -> copy(mouseMode = null) "invert_scroll" -> copy(invertScroll = null) @@ -150,6 +154,7 @@ data class SettingsOverlay( if (compositor != null) add("compositor") if (audioChannels != null) add("audio_channels") if (micEnabled != null) add("mic_enabled") + if (echoCancel != null) add("echo_cancel") if (touchMode != null) add("touch_mode") if (mouseMode != null) add("mouse_mode") if (invertScroll != null) add("invert_scroll") @@ -180,6 +185,7 @@ data class SettingsOverlay( compositor?.let { j.put("compositor", it) } audioChannels?.let { j.put("audio_channels", it) } micEnabled?.let { j.put("mic_enabled", it) } + echoCancel?.let { j.put("echo_cancel", it) } touchMode?.let { j.put("touch_mode", it.name) } mouseMode?.let { j.put("mouse_mode", it.storedName) } invertScroll?.let { j.put("invert_scroll", it) } @@ -198,9 +204,9 @@ data class SettingsOverlay( /** Keys this build models; everything else in a stored overlay is carried through. */ private val KNOWN = setOf( "width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec", - "hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode", - "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode", - "present_priority", "smooth_buffer", + "hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel", + "touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", + "low_latency_mode", "present_priority", "smooth_buffer", ) internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay( @@ -214,6 +220,7 @@ data class SettingsOverlay( compositor = j.optIntOrNull("compositor"), audioChannels = j.optIntOrNull("audio_channels"), micEnabled = j.optBooleanOrNull("mic_enabled"), + echoCancel = j.optBooleanOrNull("echo_cancel"), touchMode = j.optStringOrNull("touch_mode") ?.let { n -> TouchMode.entries.firstOrNull { it.name == n } }, mouseMode = j.optStringOrNull("mouse_mode") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 7ab44805..8cb9b2c4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -41,6 +41,15 @@ data class Settings( * the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */ val codec: String = "auto", val micEnabled: Boolean = false, + /** + * Cancel acoustic echo on the mic uplink (plus noise suppression): the capture opens under + * the VoiceCommunication preset so the HAL's own AEC/NS process it, with the Java effects + * attached as a backstop where available. On by default — a phone/tablet plays the game audio + * out of the same device its mic hears, so without this the host hears its own stream back. + * Turn off for a headset-only setup where the untouched full-band capture sounds better. + * Only meaningful while [micEnabled] is on. + */ + val echoCancel: Boolean = true, /** * How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to * [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full @@ -209,6 +218,7 @@ class SettingsStore(context: Context) { audioChannels = prefs.getInt(K_AUDIO_CH, 2), codec = prefs.getString(K_CODEC, "auto") ?: "auto", micEnabled = prefs.getBoolean(K_MIC, false), + echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true), statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null) ?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } } // Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off; @@ -254,6 +264,7 @@ class SettingsStore(context: Context) { .putInt(K_AUDIO_CH, s.audioChannels) .putString(K_CODEC, s.codec) .putBoolean(K_MIC, s.micEnabled) + .putBoolean(K_ECHO_CANCEL, s.echoCancel) .putString(K_STATS_VERBOSITY, s.statsVerbosity.name) .putString(K_TOUCH_MODE, s.touchMode.name) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) @@ -282,6 +293,7 @@ class SettingsStore(context: Context) { const val K_AUDIO_CH = "audio_channels" const val K_CODEC = "codec" const val K_MIC = "mic_enabled" + const val K_ECHO_CANCEL = "echo_cancel" const val K_STATS_VERBOSITY = "stats_verbosity" /** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 20ae8660..26fb5821 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -794,6 +794,14 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: field = "mic_enabled", onCheckedChange = onMicChange, ) + ToggleRow( + title = "Echo cancellation", + subtitle = "Filters the stream's own audio out of the mic pickup", + checked = s.echoCancel, + enabled = s.micEnabled, + field = "echo_cancel", + onCheckedChange = { on -> update(s.copy(echoCancel = on)) }, + ) } } 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 f93c0a01..4b97feaa 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 @@ -9,6 +9,9 @@ import android.content.IntentFilter import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.hardware.usb.UsbManager +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AudioEffect +import android.media.audiofx.NoiseSuppressor import android.net.wifi.WifiManager import android.os.Build import android.text.InputType @@ -97,6 +100,13 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED + // The Java AEC/NS pair backstopping the native VoiceCommunication capture preset, hung off the + // audio session id `nativeStartMic` returns. Attached in surfaceCreated (where the mic starts) + // and released on every path that stops the mic — the surface teardown AND the final dispose — + // so a surface recreate re-attaches to the fresh stream instead of leaking effect engines. + // All three touch points run on the main thread; a plain list is race-free. + val micEffects = remember { mutableListOf() } + // 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 @@ -519,6 +529,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { activity?.requestedOrientation = priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED // Leaving the stream: stop the mic + audio + decode threads and tear down the session. + releaseMicEffects(micEffects) NativeBridge.nativeStopMic(handle) NativeBridge.nativeStopAudio(handle) NativeBridge.nativeStopVideo(handle) @@ -609,7 +620,13 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { .roundToInt(), ) NativeBridge.nativeStartAudio(handle, lowLatencyMode) - if (micWanted) NativeBridge.nativeStartMic(handle) + if (micWanted) { + val sessionId = + NativeBridge.nativeStartMic(handle, initialSettings.echoCancel) + if (initialSettings.echoCancel) { + attachMicEffects(sessionId, micEffects) + } + } } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} @@ -620,6 +637,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // DisposableEffect has closed it, the handle is freed; dereferencing it // here is the use-after-free that crashed on back-navigation. if (!closed.get()) { + releaseMicEffects(micEffects) NativeBridge.nativeStopMic(handle) NativeBridge.nativeStopAudio(handle) NativeBridge.nativeStopVideo(handle) @@ -698,6 +716,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } } +/** + * Attach the Java echo-canceller + noise-suppressor pair to the mic stream's audio session — the + * backstop for HALs whose VoiceCommunication capture path doesn't cancel on its own (the native + * side already opened the stream under that preset). [sessionId] `<= 0` means native allocated no + * session (echo cancellation off, or the preset fell back to the plain open), so there is nothing + * to hang an effect on. Created effects land in [into] for [releaseMicEffects]; `create()` + * returning null (unsupported / claimed) is quietly nothing — the HAL preset still does its part. + * Needs no extra permission: the effect APIs attach to our own recording session. + */ +private fun attachMicEffects(sessionId: Int, into: MutableList) { + if (sessionId <= 0) return + if (AcousticEchoCanceler.isAvailable()) { + AcousticEchoCanceler.create(sessionId)?.let { it.setEnabled(true); into.add(it) } + } + if (NoiseSuppressor.isAvailable()) { + NoiseSuppressor.create(sessionId)?.let { it.setEnabled(true); into.add(it) } + } +} + +/** Release every attached mic effect engine. Idempotent — the list is cleared, and both stop + * paths (surface teardown, final dispose) may call it in either order. */ +private fun releaseMicEffects(effects: MutableList) { + effects.forEach { runCatching { it.release() } } + effects.clear() +} + /** * 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/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index ed03a18c..b54b9c53 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 @@ -288,11 +288,17 @@ object NativeBridge { external fun nativeStopAudio(handle: Long) /** - * Start mic uplink: AAudio input → Opus (48 kHz stereo, 20 ms) → host (`send_mic` / 0xCB), all in - * Rust. No-op if already running. The caller MUST hold RECORD_AUDIO; otherwise the AAudio input - * stream fails to open and the rest of the session keeps streaming. + * Start mic uplink: AAudio input → Opus (48 kHz mono, 10 ms) → host (`send_mic` / 0xCB), all in + * Rust. [echoCancel] opens the capture under the VoiceCommunication preset (the HAL's own echo + * canceller / noise suppressor) and allocates an audio session id; the return value is that id + * (`> 0`) so the caller can attach the Java [android.media.audiofx.AcousticEchoCanceler] / + * [android.media.audiofx.NoiseSuppressor] as a backstop — `0` when none was allocated + * (echoCancel off, the device refused the preset and the open fell back to the plain path, or + * the mic failed entirely). No-op if already running (returns the running capture's id). The + * caller MUST hold RECORD_AUDIO; otherwise the AAudio input stream fails to open and the rest + * of the session keeps streaming. */ - external fun nativeStartMic(handle: Long) + external fun nativeStartMic(handle: Long, echoCancel: Boolean): Int /** Stop + join the mic thread and close the AAudio input stream. No-op on `0`. */ external fun nativeStopMic(handle: Long) diff --git a/clients/android/native/src/mic.rs b/clients/android/native/src/mic.rs index 23cb2172..b4587319 100644 --- a/clients/android/native/src/mic.rs +++ b/clients/android/native/src/mic.rs @@ -11,8 +11,8 @@ //! buffering interval off the uplink. use ndk::audio::{ - AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode, - AudioStream, AudioStreamBuilder, + AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode, + AudioSharingMode, AudioStream, AudioStreamBuilder, SessionId, }; use punktfunk_core::client::NativeClient; use std::collections::VecDeque; @@ -48,15 +48,22 @@ const BACKLOG_KEEP_FRAMES: usize = 2; /// Owned by [`crate::session::SessionHandle`]: the live AAudio input stream + the encode thread. pub struct MicCapture { _stream: AudioStream, // dropping it stops + closes the AAudio input stream + /// The audio-session id AAudio allocated (`> 0`) when echo cancellation asked for one — the + /// hook Kotlin hangs the Java `AcousticEchoCanceler`/`NoiseSuppressor` on. `0` = none. + session_id: i32, shutdown: Arc, join: Option>, } impl MicCapture { /// Open AAudio (LowLatency, 48 kHz/mono/f32) for **input** with a realtime callback that - /// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. `None` on - /// failure (the caller leaves the rest of the session streaming). - pub fn start(client: Arc) -> Option { + /// forwards captured PCM to a channel, then spawn the Opus encode + uplink thread. With + /// `echo_cancel` the stream opens under the `VoiceCommunication` input preset — the HAL's own + /// echo canceller / noise suppressor on the capture path (the default `VoiceRecognition` + /// 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 { let captured = Arc::new(AtomicU64::new(0)); // Chunks discarded on the capture thread (free-list empty / encoder lagging); logged // throttled from the encode worker. @@ -64,7 +71,9 @@ impl MicCapture { // One open attempt at a given sharing mode (same pattern as [`crate::audio`]: `open_stream` // consumes the builder AND the callback, so each try rebuilds the channels it captures). - let try_open = |sharing: AudioSharingMode| -> ndk::audio::Result<( + let try_open = |sharing: AudioSharingMode, + voice: bool| + -> ndk::audio::Result<( AudioStream, Receiver>, SyncSender>, @@ -111,13 +120,25 @@ impl MicCapture { AudioCallbackResult::Continue }; - let stream = AudioStreamBuilder::new()? + // NOTE: no `.frames_per_data_callback(...)`: AAudio's own docs call leaving it unset + // the lowest-latency path (the callback then runs at the device's optimal burst, + // while pinning a size inserts an adaptation buffer), and the encode side re-chunks + // to 10 ms frames regardless of how the bursts arrive. + let mut builder = AudioStreamBuilder::new()? .direction(AudioDirection::Input) .sample_rate(SAMPLE_RATE) .channel_count(CHANNELS as i32) .format(AudioFormat::PCM_Float) .performance_mode(AudioPerformanceMode::LowLatency) - .sharing_mode(sharing) + .sharing_mode(sharing); + if voice { + // VoiceCommunication routes the capture through the HAL's AEC/NS; the allocated + // session id (`None` = allocate) is what Kotlin attaches the Java effects to. + builder = builder + .input_preset(AudioInputPreset::VoiceCommunication) + .session_id(None); + } + let stream = builder .data_callback(Box::new(callback)) .error_callback(Box::new(|_s, e| { log::warn!("mic: AAudio error (device reroute/disconnect?): {e:?}"); @@ -126,21 +147,52 @@ impl MicCapture { Ok((stream, rx, free_tx)) }; - // Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to Shared - // when the device refuses (no MMAP, mic claimed, …). The started-log below prints the mode - // the device actually GRANTED (`share=`). - let (stream, rx, free_tx) = match try_open(AudioSharingMode::Exclusive) { - Ok(opened) => opened, - Err(e) => { - log::info!("mic: Exclusive open failed ({e}) — retrying Shared"); - match try_open(AudioSharingMode::Shared) { - Ok(opened) => opened, - Err(e) => { - log::error!("mic: open_stream (RECORD_AUDIO granted?): {e}"); - return None; - } + // Exclusive first — MMAP-exclusive is AAudio's lowest-latency path — falling back to + // Shared when the device refuses (no MMAP, mic claimed, …); and each sharing mode with + // the voice preset before without it, because some HALs reject VoiceCommunication (or a + // session id) outright and a mic without echo cancellation still beats no mic. The + // ladder's last rungs are exactly the preset-less open this always did. The started-log + // below prints what the device actually GRANTED (`share=`/`session=`). + let attempts: &[(AudioSharingMode, bool)] = if echo_cancel { + &[ + (AudioSharingMode::Exclusive, true), + (AudioSharingMode::Shared, true), + (AudioSharingMode::Exclusive, false), + (AudioSharingMode::Shared, false), + ] + } else { + &[ + (AudioSharingMode::Exclusive, false), + (AudioSharingMode::Shared, false), + ] + }; + let mut opened = None; + for &(sharing, voice) in attempts { + match try_open(sharing, voice) { + Ok(o) => { + opened = Some(o); + break; } + Err(e) => log::info!( + "mic: open {sharing:?}{} failed ({e}) — trying the next fallback", + if voice { "+VoiceCommunication" } else { "" }, + ), } + } + let (stream, rx, free_tx) = match opened { + Some(o) => o, + None => { + log::error!("mic: open_stream (RECORD_AUDIO granted?): every mode refused"); + return None; + } + }; + + // The session id AAudio actually allocated (only a voice rung asks for one): `> 0` is the + // handle Kotlin hangs the Java AcousticEchoCanceler/NoiseSuppressor off as the HAL + // preset's backstop; `0` = none, nothing to attach. + let session_id = match stream.session_id() { + SessionId::Allocated(id) => id.get(), + SessionId::None => 0, }; if let Err(e) = stream.request_start() { @@ -148,7 +200,7 @@ impl MicCapture { return None; } log::info!( - "mic: AAudio input started rate={} ch={} fmt={:?} share={:?}", + "mic: AAudio input started rate={} ch={} fmt={:?} share={:?} session={session_id}", stream.sample_rate(), stream.channel_count(), stream.format(), @@ -164,10 +216,16 @@ impl MicCapture { Some(MicCapture { _stream: stream, + session_id, shutdown, join, }) } + + /// The audio-session id AAudio allocated (`> 0`; see [`MicCapture::start`]), `0` = none. + pub fn session_id(&self) -> i32 { + self.session_id + } } impl Drop for MicCapture { diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 366dc17a..97040f8f 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -390,28 +390,41 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio( }) } -/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`). -/// No-op if already running or on a `0` handle. Caller MUST hold RECORD_AUDIO; a failure (e.g. no -/// permission) leaves the rest of the session streaming. +/// `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; + 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 guard.is_some() { - return; // already capturing + 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()) { - Some(m) => *guard = Some(m), - None => log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)"), + match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0) { + 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 + } } }