From cb63969ba7d9d1c469e03f1c65f871bfca0b20f0 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 29 Jun 2026 14:06:24 -0600 Subject: [PATCH 1/2] Scaffold MobBridge audio output probe methods for Mob.Audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the app-owned Kotlin bridge methods that back mob's new audio output probes (Mob.Audio.output_status/0, output_level/1): - audioOutputStatus(): FloatArray — volume / mute / route / other-audio via AudioManager, returned as float[4] for the NIF to decode. - audioOutputLevel(source): FloatArray? — peak/RMS dB from a short-lived Visualizer on the global output mix (session 0), so it observes audio from native players that bypass Mob.Audio (e.g. a game's own AudioTrack). Returns null on failure (usually RECORD_AUDIO not granted); the mob NIF caches it with cacheOptional so a drifted bridge no-ops. ktlint clean (mix test --only lint); a focused template test pins both methods, the session-0 mix tap, and the peak/RMS measurement mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/main/java/MobBridge.kt.eex | 81 +++++++++++++++++++ .../android_audio_output_probes_test.exs | 30 +++++++ 2 files changed, 111 insertions(+) create mode 100644 test/mob_new/templates/android_audio_output_probes_test.exs diff --git a/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex b/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex index 8070327..86986e5 100644 --- a/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex +++ b/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex @@ -1555,6 +1555,87 @@ object MobBridge { } } } + + /** Called from nif_audio_output_status via JNI — reads system audio config + * so Mob.Audio.output_status/0 can answer "is sound configured to play". + * Returns float[4] = [volume0..1, muted(0/1), routeCode, otherAudio(0/1)]. + * routeCode: 1=speaker 2=headphones 3=bluetooth 4=receiver 0=none. */ + @JvmStatic + fun audioOutputStatus(): FloatArray { + val activity = activityRef?.get() ?: return FloatArray(4) + val am = activity.getSystemService(Activity.AUDIO_SERVICE) as? AudioManager + ?: return FloatArray(4) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC).toFloat() + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() + val volume = if (max > 0f) cur / max else 0f + val muted = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + am.isStreamMute(AudioManager.STREAM_MUSIC) + ) { + 1f + } else { + 0f + } + val other = if (am.isMusicActive) 1f else 0f + // Best-effort active route: Android routes media to BT/wired when one + // is connected, so pick the highest-priority connected output. + var route = 1f // builtin speaker + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + var hasBt = false + var hasWired = false + for (d in am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + when (d.type) { + android.media.AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + -> hasBt = true + android.media.AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + android.media.AudioDeviceInfo.TYPE_WIRED_HEADSET, + android.media.AudioDeviceInfo.TYPE_USB_HEADSET, + -> hasWired = true + } + } + route = if (hasBt) 3f else if (hasWired) 2f else 1f + } + return floatArrayOf(volume, muted, route, other) + } + + /** Called from nif_audio_output_level via JNI — reads the actual output + * signal level so Mob.Audio.output_level/1 can tell live audio from + * silence. Returns float[2] = [rms_db, peak_db] from a short-lived + * Visualizer on the global output mix (session 0), which observes ALL + * audio — including native players (e.g. a game's own AudioTrack) that + * bypass Mob.Audio. Returns null on failure; the usual cause is the + * RECORD_AUDIO permission not being granted (a global-mix tap counts as + * capture). `source` is "mix" | "mob"; Android reads the global mix for + * both. */ + @JvmStatic + fun audioOutputLevel(source: String): FloatArray? { + return try { + val v = android.media.audiofx.Visualizer(0) + try { + v.measurementMode = android.media.audiofx.Visualizer.MEASUREMENT_MODE_PEAK_RMS + v.captureSize = android.media.audiofx.Visualizer.getCaptureSizeRange()[1] + v.enabled = true + // Let the measurement window collect a few audio frames before + // reading; an immediate read returns the silence sentinel. + Thread.sleep(60) + val m = android.media.audiofx.Visualizer.MeasurementPeakRms() + val rc = v.getMeasurementPeakRms(m) + v.enabled = false + if (rc != android.media.audiofx.Visualizer.SUCCESS) { + null + } else { + // mPeak / mRms are in millibels (1/100 dB). + floatArrayOf(m.mRms / 100f, m.mPeak / 100f) + } + } finally { + v.release() + } + } catch (e: Throwable) { + android.util.Log.w("MobBridge", "audioOutputLevel($source) failed: ${e.message}") + null + } + } // ── Mob.Peripheral.VendorUsb ───────────────────────────────────────────── // // Android USB host. Talks to USB devices over bulk endpoints — surfaced to diff --git a/test/mob_new/templates/android_audio_output_probes_test.exs b/test/mob_new/templates/android_audio_output_probes_test.exs new file mode 100644 index 0000000..a8a7790 --- /dev/null +++ b/test/mob_new/templates/android_audio_output_probes_test.exs @@ -0,0 +1,30 @@ +defmodule MobNew.Templates.AndroidAudioOutputProbesTest do + use ExUnit.Case, async: true + + @android Path.expand("../../../priv/templates/mob.new/android/app/src/main", __DIR__) + @bridge Path.join(@android, "java/MobBridge.kt.eex") + + # The audio output probes (Mob.Audio.output_status/0, output_level/1) call + # app-owned MobBridge methods over JNI. The native side caches them with + # cacheOptional + null-guard, so a drifted bridge that lacks them no-ops + # rather than failing nif_load — but a freshly generated app must ship them + # for the probes to actually work. This pins their presence in the template. + test "host MobBridge.kt template scaffolds the audio output probe methods" do + src = File.read!(@bridge) + + assert src =~ "fun audioOutputStatus(): FloatArray", + "MobBridge.kt.eex must scaffold audioOutputStatus() for Mob.Audio.output_status/0" + + assert src =~ "fun audioOutputLevel(source: String): FloatArray?", + "MobBridge.kt.eex must scaffold audioOutputLevel(source) for Mob.Audio.output_level/1" + + # The level probe reads the global output mix (session 0) so it observes + # audio from native players that bypass Mob.Audio (e.g. a game's AudioTrack). + assert src =~ "Visualizer(0)", + "audioOutputLevel must tap the global output mix (session 0)" + + # JNI return signatures the native cache expects: both return float[] (`[F`). + assert src =~ "MEASUREMENT_MODE_PEAK_RMS", + "audioOutputLevel must use peak/rms measurement mode" + end +end From 903299568634fcbb033ad3cb84247ca8912a1771 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 29 Jun 2026 14:46:28 -0600 Subject: [PATCH 2/2] Audio probe template: meter own player session, not session 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device verification (moto g power 2021, Android 11) showed a session-0 Visualizer fails with ERROR_NO_INIT for a normal app even with RECORD_AUDIO + MODIFY_AUDIO_SETTINGS — global output capture is privileged. So audioOutputLevel now meters Mob.Audio's OWN player session (audioPlayer.audioSessionId), which works with RECORD_AUDIO, and returns a length-1 error code (1 unsupported / 2 needs_record_audio / 3 not_playing) for the NIF to map. "mix" is reported unsupported; global capture moves to a separate MediaProjection plugin. Template test updated to assert the own-session tap and that session 0 is not used. ktlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/main/java/MobBridge.kt.eex | 29 ++++++++++++------- .../android_audio_output_probes_test.exs | 16 ++++++---- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex b/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex index 86986e5..5d31e21 100644 --- a/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex +++ b/priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex @@ -1601,17 +1601,23 @@ object MobBridge { /** Called from nif_audio_output_level via JNI — reads the actual output * signal level so Mob.Audio.output_level/1 can tell live audio from - * silence. Returns float[2] = [rms_db, peak_db] from a short-lived - * Visualizer on the global output mix (session 0), which observes ALL - * audio — including native players (e.g. a game's own AudioTrack) that - * bypass Mob.Audio. Returns null on failure; the usual cause is the - * RECORD_AUDIO permission not being granted (a global-mix tap counts as - * capture). `source` is "mix" | "mob"; Android reads the global mix for - * both. */ + * silence. Meters Mob.Audio's OWN player session (`source` == "mob") with a + * short-lived Visualizer; RECORD_AUDIO is sufficient for an own-session tap. + * + * Returns: float[2] = [rms_db, peak_db] on success, else a length-1 error + * code the NIF maps to an atom — 1 unsupported_on_platform, 2 + * needs_record_audio, 3 not_playing. + * + * "mix" (the global output mix) is unsupported: attaching a Visualizer to + * session 0 is privileged on modern Android (ERROR_NO_INIT for a normal + * app), so global device-audio capture lives in a separate + * MediaProjection-based plugin, not here. */ @JvmStatic - fun audioOutputLevel(source: String): FloatArray? { + fun audioOutputLevel(source: String): FloatArray { + if (source != "mob") return floatArrayOf(1f) // unsupported_on_platform + val sessionId = audioPlayer?.audioSessionId ?: return floatArrayOf(3f) // not_playing return try { - val v = android.media.audiofx.Visualizer(0) + val v = android.media.audiofx.Visualizer(sessionId) try { v.measurementMode = android.media.audiofx.Visualizer.MEASUREMENT_MODE_PEAK_RMS v.captureSize = android.media.audiofx.Visualizer.getCaptureSizeRange()[1] @@ -1623,7 +1629,7 @@ object MobBridge { val rc = v.getMeasurementPeakRms(m) v.enabled = false if (rc != android.media.audiofx.Visualizer.SUCCESS) { - null + floatArrayOf(2f) // needs_record_audio (most common measure failure) } else { // mPeak / mRms are in millibels (1/100 dB). floatArrayOf(m.mRms / 100f, m.mPeak / 100f) @@ -1632,8 +1638,9 @@ object MobBridge { v.release() } } catch (e: Throwable) { + // Usually a SecurityException: RECORD_AUDIO not granted at runtime. android.util.Log.w("MobBridge", "audioOutputLevel($source) failed: ${e.message}") - null + floatArrayOf(2f) // needs_record_audio } } // ── Mob.Peripheral.VendorUsb ───────────────────────────────────────────── diff --git a/test/mob_new/templates/android_audio_output_probes_test.exs b/test/mob_new/templates/android_audio_output_probes_test.exs index a8a7790..3798197 100644 --- a/test/mob_new/templates/android_audio_output_probes_test.exs +++ b/test/mob_new/templates/android_audio_output_probes_test.exs @@ -15,15 +15,19 @@ defmodule MobNew.Templates.AndroidAudioOutputProbesTest do assert src =~ "fun audioOutputStatus(): FloatArray", "MobBridge.kt.eex must scaffold audioOutputStatus() for Mob.Audio.output_status/0" - assert src =~ "fun audioOutputLevel(source: String): FloatArray?", + assert src =~ "fun audioOutputLevel(source: String): FloatArray", "MobBridge.kt.eex must scaffold audioOutputLevel(source) for Mob.Audio.output_level/1" - # The level probe reads the global output mix (session 0) so it observes - # audio from native players that bypass Mob.Audio (e.g. a game's AudioTrack). - assert src =~ "Visualizer(0)", - "audioOutputLevel must tap the global output mix (session 0)" + # The level probe meters Mob.Audio's OWN player session (an own-session tap + # works with RECORD_AUDIO). It must NOT attach to session 0 — the global + # output mix is privileged on modern Android (ERROR_NO_INIT for a normal + # app); global capture lives in a separate MediaProjection plugin. + assert src =~ "audioPlayer?.audioSessionId", + "audioOutputLevel(:mob) must meter the app's own player session" + + refute src =~ "Visualizer(0)", + "audioOutputLevel must not tap session 0 — privileged on modern Android" - # JNI return signatures the native cache expects: both return float[] (`[F`). assert src =~ "MEASUREMENT_MODE_PEAK_RMS", "audioOutputLevel must use peak/rms measurement mode" end