From 18b3f3eb7da0b0a6d69f0741a32ad1707c4c2e6f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 29 Jun 2026 14:05:56 -0600 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Mob.Audio=20output=20probes=20?= =?UTF-8?q?=E2=80=94=20verify=20sound=20is=20actually=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob can verify visual output in-process via screenshot/3, but had no equivalent for audio. This adds two read-only probes that answer "is sound actually coming out right now": - Mob.Audio.output_status/0 — {volume, muted, route, other_audio}. Cheap, no permission. Catches the common silence causes (muted, volume 0, dead route). iOS AVAudioSession / Android AudioManager. - Mob.Audio.output_level/1 — {rms_db, peak_db} | :silent | {:error, _}. Reads actual signal energy, the part output_status and `adb dumpsys audio` cannot answer. source: :mix (default) taps the global output mix so it sees native players that bypass Mob.Audio (e.g. a game's own AudioTrack); source: :mob taps Mob.Audio's own player. Native wiring mirrors screenshot/3 + open_settings/1: -export/-nifs/stub in mob_nif.erl, native table entries in mob_nif.zig and mob_nif.m, and cacheOptional + null-guard for the app-owned Android bridge methods so a drifted MobBridge.kt no-ops instead of crash-looping boot. iOS level-2 uses AVAudioPlayer metering (self-contained); Android uses a Visualizer on session 0 (needs RECORD_AUDIO). output_level is a dirty IO NIF. Honest asymmetry: for audio that bypasses Mob.Audio, level-2 works on Android (global-mix tap) but not iOS (sandbox forbids it) — documented, not papered over. Decoders are pure Elixir and unit-tested on host; the Android Kotlin bridge methods ship in the mob_new template (separate PR). Host: 969 mob tests pass, formatters clean. Device verification (boot + live level reads) is the gate for the native paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- android/jni/mob_nif.zig | 82 ++++++++++++++ decisions/2026-06-29-audio-output-probes.md | 72 ++++++++++++ ios/mob_nif.m | 66 +++++++++++ lib/mob/audio.ex | 119 ++++++++++++++++++++ src/mob_nif.erl | 7 ++ test/mob/audio_test.exs | 50 ++++++++ 6 files changed, 396 insertions(+) create mode 100644 decisions/2026-06-29-audio-output-probes.md diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index b55ba62..8f35a61 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -259,6 +259,11 @@ pub const BridgeMethods = extern struct { audio_play_at: jni.JMethodID = null, audio_stop_playback: jni.JMethodID = null, audio_set_volume: jni.JMethodID = null, + // Output probes — optional (cacheOptional); a drifted MobBridge.kt that + // predates them simply leaves these null and the NIFs return an error + // atom instead of crashing nif_load. + audio_output_status: jni.JMethodID = null, + audio_output_level: jni.JMethodID = null, motion_start: jni.JMethodID = null, motion_stop: jni.JMethodID = null, take_launch_notification: jni.JMethodID = null, @@ -2101,6 +2106,74 @@ export fn nif_open_settings( return erts.ok(env); } +// nif_audio_output_status/0 — {Volume, Muted, RouteCode, OtherAudio} as four +// doubles (decoded by Mob.Audio.output_status/0). MobBridge.audioOutputStatus() +// returns float[4] = [volume0..1, muted(0/1), routeCode, otherAudio(0/1)]. +// Optional bridge method: an older MobBridge.kt leaves it null → return all +// zeros (Mob.Audio decodes that as route :none, which reads as "unknown-ish" +// rather than crashing). +export fn nif_audio_output_status( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var vals: [4]f32 = @splat(0); + if (Bridge.audio_output_status != null) { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.audio_output_status); + if (arr != null) { + jni.getFloatArrayRegion(jenv, arr, 0, 4, &vals); + jni.deleteLocalRef(jenv, arr); + } + detachIfAttached(attached); + } + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + erts.enif_make_double(env, @floatCast(vals[3])), + }); +} + +// nif_audio_output_level/1 — {RmsDb, PeakDb} as two doubles, or an error atom. +// Source is "mix" (global output mix, needs RECORD_AUDIO) | "mob" (Mob's own +// player). MobBridge.audioOutputLevel(String) returns float[2] = [rms_db, +// peak_db] on success, or null on failure (no permission / no signal / +// unsupported), which we surface as an atom Mob.Audio maps to {:error, _}. +export fn nif_audio_output_level( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.audio_output_level == null) return erts.atom(env, "unsupported_on_platform"); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const source = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(source); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jsource = jni.newStringUTF(jenv, source); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.audio_output_level, jsource); + jni.deleteLocalRef(jenv, jsource); + if (arr == null) { + detachIfAttached(attached); + // Kotlin signals "couldn't measure" with null; the common cause on + // the :mix path is the missing RECORD_AUDIO permission. + return erts.atom(env, "needs_record_audio"); + } + var vals: [2]f32 = @splat(0); + jni.getFloatArrayRegion(jenv, arr, 0, 2, &vals); + jni.deleteLocalRef(jenv, arr); + detachIfAttached(attached); + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + }); +} + // nif_share_text/1 — system share sheet (Intent ACTION_SEND text/plain). export fn nif_share_text( env: ?*erts.ErlNifEnv, @@ -3374,6 +3447,10 @@ fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) c if (!cacheRequired(jenv, "audio_play_at", "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", &Bridge.audio_play_at)) return -1; if (!cacheRequired(jenv, "audio_stop_playback", "()V", &Bridge.audio_stop_playback)) return -1; if (!cacheRequired(jenv, "audio_set_volume", "(Ljava/lang/String;)V", &Bridge.audio_set_volume)) return -1; + // Output probes are optional so a drifted MobBridge.kt that predates them + // no-ops (NIF returns an error atom) instead of failing nif_load. + cacheOptional(jenv, "audioOutputStatus", "()[F", &Bridge.audio_output_status); + cacheOptional(jenv, "audioOutputLevel", "(Ljava/lang/String;)[F", &Bridge.audio_output_level); if (!cacheRequired(jenv, "storage_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_dir)) return -1; if (!cacheRequired(jenv, "storage_save_to_media_store", "(JLjava/lang/String;Ljava/lang/String;)V", &Bridge.storage_save_to_media_store)) return -1; if (!cacheRequired(jenv, "storage_external_files_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_external_files_dir)) return -1; @@ -3497,6 +3574,11 @@ const nif_funcs = [_]erts.ErlNifFunc{ .{ .name = "audio_play_at", .arity = 3, .fptr = nif_audio_play_at, .flags = 0 }, .{ .name = "audio_stop_playback", .arity = 0, .fptr = nif_audio_stop_playback, .flags = 0 }, .{ .name = "audio_set_volume", .arity = 1, .fptr = nif_audio_set_volume, .flags = 0 }, + .{ .name = "audio_output_status", .arity = 0, .fptr = nif_audio_output_status, .flags = 0 }, + // Dirty IO: the Android side briefly settles a Visualizer measurement + // window, and iOS dispatch_syncs to the main queue — keep it off the + // regular schedulers. + .{ .name = "audio_output_level", .arity = 1, .fptr = nif_audio_output_level, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, .{ .name = "motion_start", .arity = 2, .fptr = nif_motion_start, .flags = 0 }, .{ .name = "motion_stop", .arity = 0, .fptr = nif_motion_stop, .flags = 0 }, .{ .name = "take_launch_notification", .arity = 0, .fptr = nif_take_launch_notification, .flags = 0 }, diff --git a/decisions/2026-06-29-audio-output-probes.md b/decisions/2026-06-29-audio-output-probes.md new file mode 100644 index 0000000..ae7fe4f --- /dev/null +++ b/decisions/2026-06-29-audio-output-probes.md @@ -0,0 +1,72 @@ +# Audio output probes — verify sound is actually working + +- Date: 2026-06-29 +- Status: accepted + +## Context + +Mob can verify *visual* output in-process via the `screenshot/3` NIF (reads the +composited framebuffer; see `Mob.Test`). There was no equivalent for *audio*: +nothing could answer "is sound actually coming out right now." This surfaced +bringing up Doom (the `mob_doom` plugin) in a mob app — Doom drives its own +`AudioTrack` at 11025 Hz from a polling thread, and there was no programmatic +way to tell working audio from silence. + +`adb shell dumpsys audio` / `dumpsys media.audio_flinger` already answer much of +this from outside the app on Android (active players + state, stream volume + +mute, mixer-track underrun counters). But they cannot distinguish a live signal +from pushed silence, and they do not exist on iOS. These probes are the +in-process, cross-platform layer on top of that. + +Audio has no single "final surface" the way the framebuffer is for video, so we +expose two probes at two vantage points rather than one screenshot-equivalent. + +## Decision + +Add two read-only NIF-backed functions to `Mob.Audio`: + +- `output_status/0` → `%{volume, muted, route, other_audio}`. Cheap, + synchronous, no permission. Catches the common "no sound" causes (muted, + volume 0, dead route). iOS: `AVAudioSession`. Android: `AudioManager`. +- `output_level/1` → `{rms_db, peak_db}` | `:silent` | `{:error, reason}`. Reads + actual signal energy — the part `output_status` and `adb` cannot answer. Takes + a `:source`: + - `:mix` (default) — taps the **global output mix**, so it observes audio from + native players that bypass `Mob.Audio` (the Doom case). Android: `Visualizer` + on session 0 (needs `RECORD_AUDIO`). iOS: unsupported (sandbox forbids a + global-mix tap) → `{:error, :unsupported_on_platform}`. + - `:mob` — taps only `Mob.Audio`'s own player. iOS: `AVAudioPlayer` metering + (free, no permission). Android: reads the global mix (same path as `:mix`). + +Native wiring mirrors `screenshot/3` and `open_settings/1`: `-export` + `-nifs` + +stub in `src/mob_nif.erl`; native table entries in `android/jni/mob_nif.zig` and +`ios/mob_nif.m`; and **`cacheOptional` + null-guard** for the app-owned Android +bridge methods so a drifted `MobBridge.kt` no-ops (NIF returns an error atom) +instead of failing `nif_load` and crash-looping boot (the 0.7.6 lesson). The +Android bridge methods ship in the `mob_new` template; iOS level-2 is +self-contained in `mob_nif.m`. `output_level` is a dirty IO NIF (Android settles +a Visualizer window; iOS `dispatch_sync`s to the main queue). + +The NIFs return only doubles / bare atoms (no term-building in C/Zig); `Mob.Audio` +decodes route codes and the `:silent`/`:error` shapes in pure Elixir +(`decode_status/1`, `decode_level/1`, unit-tested on host). + +## Consequences + +- **Honest asymmetry:** for audio that bypasses `Mob.Audio` (Doom-style), level-2 + works on Android (global-mix tap, costs `RECORD_AUDIO`) but **not** on iOS + (level-1 only). Documented in the moduledoc rather than papered over. +- Both probes observe only the device's own output, never system-wide capture of + other apps — correct for "is the audio mob/this app is producing working," + not "did a human hear it" (that would need a mic loopback, deliberately out of + scope). +- Verification idiom is `play → sleep a beat → output_level`, since metering is + instantaneous and only valid while audio plays. +- **Device verification is the gate** (host `mix test` can't exercise native): a + native-table mismatch is a silent boot-time crash, so confirm the app still + boots, then that `output_level(source: :mix)` reads non-silent during playback + and `:silent` otherwise. The Android `Visualizer` one-shot (create → enable → + 60 ms settle → measure → release) is the part most worth confirming on real + hardware. +- Follow-up: pairs with the in-process screenshot work for agent-driven testing, + and gives `mob_midi`'s pending tone primitive something to assert against. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index b5801c1..90faffa 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2062,6 +2062,67 @@ static ERL_NIF_TERM nif_open_settings(ErlNifEnv *env, int argc, const ERL_NIF_TE return enif_make_atom(env, "ok"); } +// nif_audio_output_status/0 — {Volume, Muted, RouteCode, OtherAudio} as four +// doubles (decoded by Mob.Audio.output_status/0). iOS has no direct mute flag, +// so Muted is inferred from outputVolume == 0. RouteCode mirrors the Android +// encoding: 1=speaker, 2=headphones, 3=bluetooth, 4=receiver, 0=none. +static ERL_NIF_TERM nif_audio_output_status(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + AVAudioSession *session = [AVAudioSession sharedInstance]; + double volume = (double)session.outputVolume; + double other = session.isOtherAudioPlaying ? 1.0 : 0.0; + double route = 0.0; + for (AVAudioSessionPortDescription *out in session.currentRoute.outputs) { + NSString *t = out.portType; + if ([t isEqualToString:AVAudioSessionPortBuiltInSpeaker]) + route = 1.0; + else if ([t isEqualToString:AVAudioSessionPortHeadphones]) + route = 2.0; + else if ([t isEqualToString:AVAudioSessionPortBluetoothA2DP] || + [t isEqualToString:AVAudioSessionPortBluetoothLE] || + [t isEqualToString:AVAudioSessionPortBluetoothHFP]) + route = 3.0; + else if ([t isEqualToString:AVAudioSessionPortBuiltInReceiver]) + route = 4.0; + if (route != 0.0) + break; + } + double muted = (volume <= 0.0) ? 1.0 : 0.0; + return enif_make_tuple4(env, enif_make_double(env, volume), enif_make_double(env, muted), + enif_make_double(env, route), enif_make_double(env, other)); +} + +// nif_audio_output_level/1 — {RmsDb, PeakDb} as two doubles, or an error atom. +// iOS cannot tap the global output mix (sandbox), so "mix" is unsupported; +// "mob" meters Mob.Audio's own AVAudioPlayer (metering is enabled when the +// player is created). +static ERL_NIF_TERM nif_audio_output_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + NSString *source = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + if (![source isEqualToString:@"mob"]) + return enif_make_atom(env, "unsupported_on_platform"); + + __block double rms = -160.0; + __block double peak = -160.0; + __block BOOL playing = NO; + dispatch_sync(dispatch_get_main_queue(), ^{ + AVAudioPlayer *player = g_audio_player; + if (player && player.playing) { + playing = YES; + [player updateMeters]; + rms = (double)[player averagePowerForChannel:0]; + peak = (double)[player peakPowerForChannel:0]; + } + }); + if (!playing) + return enif_make_atom(env, "not_playing"); + return enif_make_tuple2(env, enif_make_double(env, rms), enif_make_double(env, peak)); +} + // ── NIF: share_text/1 ───────────────────────────────────────────────────────── // Opens the iOS share sheet with plain text. Fire-and-forget. @@ -2786,6 +2847,9 @@ static ERL_NIF_TERM nif_audio_play(ErlNifEnv *env, int argc, const ERL_NIF_TERM player.delegate = g_player_delegate; player.volume = (float)volume; player.numberOfLoops = loop ? -1 : 0; + // Enable metering so Mob.Audio.output_level(source: :mob) can read the + // signal level without a separate tap. Cheap; off by default otherwise. + player.meteringEnabled = YES; g_audio_player = player; [player play]; }); @@ -5982,6 +6046,8 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"audio_play_at", 3, nif_audio_play_at, 0}, {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, {"audio_set_volume", 1, nif_audio_set_volume, 0}, + {"audio_output_status", 0, nif_audio_output_status, 0}, + {"audio_output_level", 1, nif_audio_output_level, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"tts_speak", 2, nif_tts_speak, 0}, {"tts_stop", 0, nif_tts_stop, 0}, {"motion_start", 2, nif_motion_start, 0}, diff --git a/lib/mob/audio.ex b/lib/mob/audio.ex index e6a0476..734bd84 100644 --- a/lib/mob/audio.ex +++ b/lib/mob/audio.ex @@ -28,10 +28,45 @@ defmodule Mob.Audio do # → handle_info({:audio, :playback_error, %{reason: reason}}, socket) iOS: `AVAudioPlayer` / `AVPlayer`. Android: `MediaPlayer`. + + ## Output probes — is sound actually working? + + Two read-only probes answer "is audio coming out right now," the audio + analog of `Mob.Test`'s in-process `screenshot/2` for video. Use them in + tests and agent-driven verification. + + Mob.Audio.output_status() + # => %{volume: 0.8, muted: false, route: :speaker, other_audio: false} + + Mob.Audio.output_level(source: :mix) + # => {-18.4, -6.1} # {rms_db, peak_db}, or :silent + + `output_status/0` is a cheap, permission-free read of the system audio + config (volume, mute, route). It catches the common "no sound" causes: + muted, volume 0, routed to a disconnected sink. `output_level/1` reads + actual signal energy so you can tell live audio from pushed silence — the + part `output_status` (and `adb dumpsys audio`) cannot answer. + + `output_level/1` takes a `:source`: + + - `:mix` (default) — taps the **global output mix**, so it observes ALL + audio including playback from native code that bypasses `Mob.Audio` + (e.g. an app driving its own `AudioTrack`/`AudioUnit`). On Android this + requires the `RECORD_AUDIO` permission (a global-mix tap counts as + capture) and returns `{:error, :needs_record_audio}` without it. On + iOS the sandbox forbids a global-mix tap, so `:mix` returns + `{:error, :unsupported_on_platform}` — fall back to `output_status/0`. + - `:mob` — taps only `Mob.Audio`'s own player. Free and permission-free + on iOS (`AVAudioPlayer` metering); narrower on Android. + + Metering is instantaneous and only meaningful while audio is playing, so + the idiom is `play → sleep a beat → output_level`. """ @type format :: :aac | :wav @type quality :: :low | :medium | :high + @type route :: :speaker | :headphones | :bluetooth | :receiver | :none | :unknown + @type level_source :: :mix | :mob @doc """ Start recording audio from the microphone. @@ -150,4 +185,88 @@ defmodule Mob.Audio do def play_at_opts(opts) do %{"volume" => Keyword.get(opts, :volume, 1.0) * 1.0} end + + @doc """ + Read the current system audio output configuration. + + Returns `%{volume: float, muted: boolean, route: route(), other_audio: + boolean}`. Cheap, synchronous, no permission. The first thing to check + when verifying sound: a `volume` of `0.0`, `muted: true`, or a `route` of + `:none` explains silence regardless of what a player is doing. + + `volume` is normalized 0.0–1.0 (the media stream volume on Android, + `AVAudioSession.outputVolume` on iOS). `route` is the active output sink. + `other_audio` is true when another app is already playing (iOS + `isOtherAudioPlaying` / Android `isMusicActive`). + """ + @spec output_status() :: %{ + volume: float(), + muted: boolean(), + route: route(), + other_audio: boolean() + } + def output_status do + decode_status(:mob_nif.audio_output_status()) + end + + @doc false + @spec decode_status(term()) :: %{ + volume: float(), + muted: boolean(), + route: route(), + other_audio: boolean() + } + def decode_status({volume, muted, route_code, other_audio}) do + %{ + volume: volume, + muted: muted >= 0.5, + route: decode_route(route_code), + other_audio: other_audio >= 0.5 + } + end + + def decode_status(_), do: %{volume: 0.0, muted: false, route: :unknown, other_audio: false} + + @doc """ + Read the current output signal level as `{rms_db, peak_db}` (dBFS, e.g. + `{-18.0, -6.0}`), or `:silent` when there is no measurable signal. + + This is the probe that distinguishes live audio from pushed silence — the + one thing `output_status/0` and `adb dumpsys audio` cannot tell you. + Metering is instantaneous and only valid while audio plays, so call it as + `play → sleep a beat → output_level`. + + Options: + - `source: :mix` (default) — global output mix (sees all audio, incl. + native players that bypass `Mob.Audio`). Android needs `RECORD_AUDIO`; + unsupported on iOS (see module docs). + - `source: :mob` — `Mob.Audio`'s own player only. + + Returns `{:error, reason}` when unavailable: `:needs_record_audio` + (Android `:mix` without permission), `:unsupported_on_platform` (iOS + `:mix`), or `:not_playing` (no active player for `:mob`). + """ + @spec output_level(keyword()) :: {float(), float()} | :silent | {:error, atom()} + def output_level(opts \\ []) do + source = Keyword.get(opts, :source, :mix) + decode_level(:mob_nif.audio_output_level(Atom.to_string(source))) + end + + @doc false + @spec decode_level(term()) :: {float(), float()} | :silent | {:error, atom()} + def decode_level({_rms, peak}) when peak <= -120.0, do: :silent + def decode_level({rms, peak}), do: {rms, peak} + def decode_level(reason) when is_atom(reason), do: {:error, reason} + def decode_level(_), do: {:error, :unknown} + + # Native side returns a numeric route code (kept numeric to avoid building + # atoms in C/Zig); decode here. + @spec decode_route(number()) :: route() + defp decode_route(1), do: :speaker + defp decode_route(2), do: :headphones + defp decode_route(3), do: :bluetooth + defp decode_route(4), do: :receiver + defp decode_route(0), do: :none + defp decode_route(code) when is_float(code), do: decode_route(round(code)) + defp decode_route(_), do: :unknown end diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 883e2b2..122bc35 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -33,6 +33,9 @@ audio_play_at/3, audio_stop_playback/0, audio_set_volume/1, + %% Audio output probes — verify sound is actually working (see Mob.Audio) + audio_output_status/0, + audio_output_level/1, %% Text-to-speech (no permission required) tts_speak/2, tts_stop/0, @@ -149,6 +152,8 @@ audio_play_at/3, audio_stop_playback/0, audio_set_volume/1, + audio_output_status/0, + audio_output_level/1, tts_speak/2, tts_stop/0, motion_start/2, @@ -267,6 +272,8 @@ audio_play(_Path, _OptsJson) -> erlang:nif_error(not_loaded). audio_play_at(_Path, _OptsJson, _AtWallMs) -> erlang:nif_error(not_loaded). audio_stop_playback() -> erlang:nif_error(not_loaded). audio_set_volume(_Volume) -> erlang:nif_error(not_loaded). +audio_output_status() -> erlang:nif_error(not_loaded). +audio_output_level(_Source) -> erlang:nif_error(not_loaded). tts_speak(_Text, _OptsJson) -> erlang:nif_error(not_loaded). tts_stop() -> erlang:nif_error(not_loaded). motion_start(_Sensors, _Interval) -> erlang:nif_error(not_loaded). diff --git a/test/mob/audio_test.exs b/test/mob/audio_test.exs index 14b137e..ad78248 100644 --- a/test/mob/audio_test.exs +++ b/test/mob/audio_test.exs @@ -125,4 +125,54 @@ defmodule Mob.AudioTest do end end end + + describe "decode_status/1" do + test "maps the native 4-tuple to a status map" do + assert Audio.decode_status({0.8, 0.0, 1.0, 0.0}) == + %{volume: 0.8, muted: false, route: :speaker, other_audio: false} + end + + test "muted and other_audio are booleans from the 0/1 flags" do + status = Audio.decode_status({0.0, 1.0, 0.0, 1.0}) + assert status.muted == true + assert status.other_audio == true + assert status.route == :none + end + + test "route codes decode to atoms (float codes from the NIF too)" do + assert Audio.decode_status({0.5, 0.0, 2.0, 0.0}).route == :headphones + assert Audio.decode_status({0.5, 0.0, 3.0, 0.0}).route == :bluetooth + assert Audio.decode_status({0.5, 0.0, 4.0, 0.0}).route == :receiver + end + + test "an unknown route code is :unknown, not a crash" do + assert Audio.decode_status({0.5, 0.0, 99.0, 0.0}).route == :unknown + end + + test "a non-tuple (e.g. NIF not loaded) yields a safe default" do + assert Audio.decode_status(:error) == + %{volume: 0.0, muted: false, route: :unknown, other_audio: false} + end + end + + describe "decode_level/1" do + test "passes through {rms, peak} when there is signal" do + assert Audio.decode_level({-18.0, -6.0}) == {-18.0, -6.0} + end + + test "a peak at or below -120 dB reads as :silent" do + assert Audio.decode_level({-160.0, -160.0}) == :silent + assert Audio.decode_level({-130.0, -120.0}) == :silent + end + + test "an atom result becomes {:error, atom}" do + assert Audio.decode_level(:needs_record_audio) == {:error, :needs_record_audio} + assert Audio.decode_level(:unsupported_on_platform) == {:error, :unsupported_on_platform} + assert Audio.decode_level(:not_playing) == {:error, :not_playing} + end + + test "an unexpected shape becomes {:error, :unknown}" do + assert Audio.decode_level(42) == {:error, :unknown} + end + end end From 915d1ddca629ac94b8990d60153ad380fc4acf2b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Mon, 29 Jun 2026 14:46:15 -0600 Subject: [PATCH 2/2] =?UTF-8?q?Audio=20probes:=20device-verified=20scope?= =?UTF-8?q?=20=E2=80=94=20:mob=20own-session,=20:mix=20unsupported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device verification on a moto g power (2021), Android 11, corrected the design: a session-0 (global output mix) Visualizer fails with ERROR_NO_INIT for a normal app even with RECORD_AUDIO + MODIFY_AUDIO_SETTINGS — global output capture is privileged. So the global-mix `:mix` path can't work in the core framework. Revised output_level/1: - default source is now :mob — meters Mob.Audio's own player. iOS via AVAudioPlayer metering; Android via a Visualizer on the player's OWN audio session (audioPlayer.audioSessionId), which works with RECORD_AUDIO. - :mix returns {:error, :unsupported_on_platform} on both platforms. True global/foreign-app capture is deferred to a separate MediaProjection-based plugin (test-env dep) — see the decision doc. The Android NIF now decodes a length-coded return (float[2] = level; float[1] = error code 1 unsupported / 2 needs_record_audio / 3 not_playing). Verified on hardware (dist-RPC into doom_demo): output_status → %{route: :speaker, volume: 0.2, muted: false} output_level(:mob) → {-34.8, -31.8} playing; {:error, :not_playing} idle output_level(:mix) → {:error, :unsupported_on_platform} no RECORD_AUDIO → {:error, :needs_record_audio} app boots cleanly (NIF table OK) output_status/0 is unchanged and fully verified. Companion template update: mob_new (own-session Visualizer, no session-0 tap). Co-Authored-By: Claude Opus 4.8 (1M context) --- android/jni/mob_nif.zig | 42 +++++++---- decisions/2026-06-29-audio-output-probes.md | 77 +++++++++++++++------ lib/mob/audio.ex | 47 ++++++++----- 3 files changed, 111 insertions(+), 55 deletions(-) diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 8f35a61..ab40254 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2139,10 +2139,14 @@ export fn nif_audio_output_status( } // nif_audio_output_level/1 — {RmsDb, PeakDb} as two doubles, or an error atom. -// Source is "mix" (global output mix, needs RECORD_AUDIO) | "mob" (Mob's own -// player). MobBridge.audioOutputLevel(String) returns float[2] = [rms_db, -// peak_db] on success, or null on failure (no permission / no signal / -// unsupported), which we surface as an atom Mob.Audio maps to {:error, _}. +// Source is "mob" (Mob's own player session) | "mix". The global output mix is +// privileged on modern Android (a normal app gets ERROR_NO_INIT attaching a +// Visualizer to session 0), so "mix" is unsupported here — global device-audio +// capture lives in a separate MediaProjection-based plugin. MobBridge returns: +// float[2] = [rms_db, peak_db] → success +// float[1] = [code] → 1 unsupported_on_platform, 2 needs_record_audio, +// 3 not_playing (no active Mob.Audio player) +// null → generic error export fn nif_audio_output_level( env: ?*erts.ErlNifEnv, argc: c_int, @@ -2160,18 +2164,30 @@ export fn nif_audio_output_level( jni.deleteLocalRef(jenv, jsource); if (arr == null) { detachIfAttached(attached); - // Kotlin signals "couldn't measure" with null; the common cause on - // the :mix path is the missing RECORD_AUDIO permission. - return erts.atom(env, "needs_record_audio"); + return erts.atom(env, "error"); + } + const len = jni.getArrayLength(jenv, arr); + if (len >= 2) { + var vals: [2]f32 = @splat(0); + jni.getFloatArrayRegion(jenv, arr, 0, 2, &vals); + jni.deleteLocalRef(jenv, arr); + detachIfAttached(attached); + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + }); } - var vals: [2]f32 = @splat(0); - jni.getFloatArrayRegion(jenv, arr, 0, 2, &vals); + // Length-1 array carries an error code the Kotlin couldn't express otherwise. + var code: [1]f32 = @splat(0); + if (len == 1) jni.getFloatArrayRegion(jenv, arr, 0, 1, &code); jni.deleteLocalRef(jenv, arr); detachIfAttached(attached); - return erts.makeTuple(env, .{ - erts.enif_make_double(env, @floatCast(vals[0])), - erts.enif_make_double(env, @floatCast(vals[1])), - }); + return switch (@as(i32, @intFromFloat(code[0]))) { + 1 => erts.atom(env, "unsupported_on_platform"), + 2 => erts.atom(env, "needs_record_audio"), + 3 => erts.atom(env, "not_playing"), + else => erts.atom(env, "error"), + }; } // nif_share_text/1 — system share sheet (Intent ACTION_SEND text/plain). diff --git a/decisions/2026-06-29-audio-output-probes.md b/decisions/2026-06-29-audio-output-probes.md index ae7fe4f..b8739d6 100644 --- a/decisions/2026-06-29-audio-output-probes.md +++ b/decisions/2026-06-29-audio-output-probes.md @@ -31,12 +31,18 @@ Add two read-only NIF-backed functions to `Mob.Audio`: - `output_level/1` → `{rms_db, peak_db}` | `:silent` | `{:error, reason}`. Reads actual signal energy — the part `output_status` and `adb` cannot answer. Takes a `:source`: - - `:mix` (default) — taps the **global output mix**, so it observes audio from - native players that bypass `Mob.Audio` (the Doom case). Android: `Visualizer` - on session 0 (needs `RECORD_AUDIO`). iOS: unsupported (sandbox forbids a - global-mix tap) → `{:error, :unsupported_on_platform}`. - - `:mob` — taps only `Mob.Audio`'s own player. iOS: `AVAudioPlayer` metering - (free, no permission). Android: reads the global mix (same path as `:mix`). + - `:mob` (default) — meters `Mob.Audio`'s own player. iOS: `AVAudioPlayer` + metering (free, no permission). Android: `Visualizer` on the player's **own + audio session** (needs `RECORD_AUDIO`, runtime-granted). `{:error, + :not_playing}` when no `Mob.Audio` playback is active. + - `:mix` — *would* tap the global output mix to observe audio that bypasses + `Mob.Audio` (a game's own `AudioTrack`, another app). **Not available to a + normal app** on either platform → `{:error, :unsupported_on_platform}`. + Device-verified: a session-0 `Visualizer` on Android 11 fails with + `ERROR_NO_INIT` even with `RECORD_AUDIO` + `MODIFY_AUDIO_SETTINGS` (global + output capture is privileged); iOS forbids it by sandbox. Global + device-audio capture belongs in a separate MediaProjection-based plugin + intended as a **test-environment dependency**, not the core framework. Native wiring mirrors `screenshot/3` and `open_settings/1`: `-export` + `-nifs` + stub in `src/mob_nif.erl`; native table entries in `android/jni/mob_nif.zig` and @@ -53,20 +59,45 @@ decodes route codes and the `:silent`/`:error` shapes in pure Elixir ## Consequences -- **Honest asymmetry:** for audio that bypasses `Mob.Audio` (Doom-style), level-2 - works on Android (global-mix tap, costs `RECORD_AUDIO`) but **not** on iOS - (level-1 only). Documented in the moduledoc rather than papered over. -- Both probes observe only the device's own output, never system-wide capture of - other apps — correct for "is the audio mob/this app is producing working," - not "did a human hear it" (that would need a mic loopback, deliberately out of - scope). -- Verification idiom is `play → sleep a beat → output_level`, since metering is - instantaneous and only valid while audio plays. -- **Device verification is the gate** (host `mix test` can't exercise native): a - native-table mismatch is a silent boot-time crash, so confirm the app still - boots, then that `output_level(source: :mix)` reads non-silent during playback - and `:silent` otherwise. The Android `Visualizer` one-shot (create → enable → - 60 ms settle → measure → release) is the part most worth confirming on real - hardware. -- Follow-up: pairs with the in-process screenshot work for agent-driven testing, - and gives `mob_midi`'s pending tone primitive something to assert against. +- **Honest scope:** the in-app probes verify *your own* audio only. Metering a + foreign native player (a bundled game's `AudioTrack`, another app) is not + possible for a normal app on either platform — the global-mix tap is privileged + on Android and forbidden on iOS. That capability is deferred to a separate + capture plugin (below). For the immediate "is the bundled game's audio working" + question, `adb shell dumpsys media.audio_flinger` (active track + underruns) is + the answer, no in-app probe needed. +- Both probes observe only the device's own output, never "did a human hear it" + (that would need a mic loopback, deliberately out of scope). +- Verification idiom is `play → sleep a beat → output_level`; metering is + instantaneous and only valid while audio plays. The Android `Visualizer` + occasionally returns its `-96 dB` floor if a measurement window hasn't filled, + so sample a few times. + +## Device verification (moto g power 2021, Android 11) — 2026-06-29 + +Verified on hardware via `mix mob.connect` dist-RPC into a `doom_demo` build: + +- App **boots** with the new NIF table (stable pid) — the boot-critical check for + a native-table mismatch. +- `output_status/0` → `%{route: :speaker, volume: 0.2, muted: false, other_audio: + false}`. +- `output_level(:mob)` while a local tone looped → `{-34.8, -31.8}` (real signal); + idle / after stop → `{:error, :not_playing}`. +- `output_level(:mix)` → `{:error, :unsupported_on_platform}`. +- Without `RECORD_AUDIO` granted at runtime → `{:error, :needs_record_audio}`. +- Disproved the original design: a session-0 `Visualizer` returns `ERROR_NO_INIT` + even with `RECORD_AUDIO` + `MODIFY_AUDIO_SETTINGS`. This is why `:mix` is + unsupported in core. + +## Follow-up: separate device-audio capture plugin (test-env dependency) + +True global/foreign-app output capture on Android is achievable only via +`MediaProjection` + `AudioPlaybackCaptureConfiguration` (API 29+), which pops a +one-time system consent dialog and can capture other apps' output. That UX is +unacceptable in a shipped app but fine in a dev/test harness. Plan: a separate +`mob_*` capture plugin, added as a test-environment dep, exposing a +`capture_level/0`-style probe backed by `AudioPlaybackCapture`. This is where the +"meter Doom's own audio" capability lives. iOS has no equivalent (no +inter-app/system output capture), so that plugin is Android-only. Pairs with the +in-process screenshot work for agent-driven testing, and gives `mob_midi`'s +pending tone primitive something to assert against. diff --git a/lib/mob/audio.ex b/lib/mob/audio.ex index 734bd84..acc684a 100644 --- a/lib/mob/audio.ex +++ b/lib/mob/audio.ex @@ -38,7 +38,8 @@ defmodule Mob.Audio do Mob.Audio.output_status() # => %{volume: 0.8, muted: false, route: :speaker, other_audio: false} - Mob.Audio.output_level(source: :mix) + Mob.Audio.play(socket, "blip.wav") + Mob.Audio.output_level(source: :mob) # => {-18.4, -6.1} # {rms_db, peak_db}, or :silent `output_status/0` is a cheap, permission-free read of the system audio @@ -49,15 +50,23 @@ defmodule Mob.Audio do `output_level/1` takes a `:source`: - - `:mix` (default) — taps the **global output mix**, so it observes ALL - audio including playback from native code that bypasses `Mob.Audio` - (e.g. an app driving its own `AudioTrack`/`AudioUnit`). On Android this - requires the `RECORD_AUDIO` permission (a global-mix tap counts as - capture) and returns `{:error, :needs_record_audio}` without it. On - iOS the sandbox forbids a global-mix tap, so `:mix` returns - `{:error, :unsupported_on_platform}` — fall back to `output_status/0`. - - `:mob` — taps only `Mob.Audio`'s own player. Free and permission-free - on iOS (`AVAudioPlayer` metering); narrower on Android. + - `:mob` (default) — meters `Mob.Audio`'s own player. iOS reads the + `AVAudioPlayer` meter (free, no permission); Android attaches a + `Visualizer` to the player's own audio session (needs `RECORD_AUDIO`, + granted at runtime — without it you get `{:error, :needs_record_audio}`). + Returns `{:error, :not_playing}` when no `Mob.Audio` playback is active. + - `:mix` — *would* tap the global output mix to observe audio that bypasses + `Mob.Audio` (a game's own `AudioTrack`, another app). This is **not + available to a normal app**: iOS forbids it (sandbox) and modern Android + treats a session-0 `Visualizer` as privileged (`ERROR_NO_INIT` even with + `RECORD_AUDIO`). So `:mix` returns `{:error, :unsupported_on_platform}` + on both platforms. Global device-audio capture lives in a separate, + MediaProjection-based capture plugin intended as a test-environment + dependency, not here. + + So in-app these probes verify *your own* audio. To check audio from a + foreign native player (e.g. a bundled game) without that plugin, read + `adb shell dumpsys media.audio_flinger` (active track + underrun counts). Metering is instantaneous and only meaningful while audio is playing, so the idiom is `play → sleep a beat → output_level`. @@ -237,18 +246,18 @@ defmodule Mob.Audio do `play → sleep a beat → output_level`. Options: - - `source: :mix` (default) — global output mix (sees all audio, incl. - native players that bypass `Mob.Audio`). Android needs `RECORD_AUDIO`; - unsupported on iOS (see module docs). - - `source: :mob` — `Mob.Audio`'s own player only. - - Returns `{:error, reason}` when unavailable: `:needs_record_audio` - (Android `:mix` without permission), `:unsupported_on_platform` (iOS - `:mix`), or `:not_playing` (no active player for `:mob`). + - `source: :mob` (default) — meters `Mob.Audio`'s own player. Android needs + `RECORD_AUDIO` (runtime-granted); iOS uses `AVAudioPlayer` metering. + - `source: :mix` — the global output mix. Unsupported for a normal app on + both platforms (see module docs); use the separate capture plugin. + + Returns `{:error, reason}` when unavailable: `:not_playing` (no active + `Mob.Audio` player), `:needs_record_audio` (Android, permission not granted + at runtime), or `:unsupported_on_platform` (`:mix`). """ @spec output_level(keyword()) :: {float(), float()} | :silent | {:error, atom()} def output_level(opts \\ []) do - source = Keyword.get(opts, :source, :mix) + source = Keyword.get(opts, :source, :mob) decode_level(:mob_nif.audio_output_level(Atom.to_string(source))) end