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..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 @@ -1555,6 +1555,94 @@ 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. 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 { + 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(sessionId) + 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) { + 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) + } + } finally { + v.release() + } + } catch (e: Throwable) { + // Usually a SecurityException: RECORD_AUDIO not granted at runtime. + android.util.Log.w("MobBridge", "audioOutputLevel($source) failed: ${e.message}") + floatArrayOf(2f) // needs_record_audio + } + } // ── 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..3798197 --- /dev/null +++ b/test/mob_new/templates/android_audio_output_probes_test.exs @@ -0,0 +1,34 @@ +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 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" + + assert src =~ "MEASUREMENT_MODE_PEAK_RMS", + "audioOutputLevel must use peak/rms measurement mode" + end +end