Media helpers for Mob apps, through the platform's own decoders: what's in a media file, its audio as PCM for speech-to-text, and a video frame as a JPEG.
{:ok, %{duration_ms: 12_480, has_video: true, width: 1080, height: 1920}} =
MobMedia.info(path)
{:ok, pcm} = MobMedia.audio_pcm(path) # 16 kHz mono s16le
{:ok, text} = MobWhisper.transcribe(pcm)
{:ok, jpeg} = MobMedia.frame(path, 4_000, max_px: 1024)Android decodes with MediaExtractor + MediaCodec and grabs frames with
MediaMetadataRetriever; iOS uses AVAssetReader and
AVAssetImageGenerator. Anything the platform's decoders handle works
(verified on both: m4a/AAC, mp3, wav, ogg/opus, mp4/H.264). No
ffmpeg, no permission: path is a local file the app can already read
(from Mob.Files.pick/2, mob_photos, mob_camera, Mob.Audio, ...), not a
content:// URI.
Sync, off the schedulers
Every function blocks the calling process until the answer is ready and returns it. The work itself runs on a native thread (a Kotlin thread pool on Android, an operation queue on iOS); the NIF only queues it and returns, and the native side sends the result back as a message. No BEAM scheduler, normal or dirty, is held while a file decodes.
Decoding a long recording takes a while (10-30% of its length on a
mid-range Android phone), so don't call audio_pcm/2 from a screen's
handle_event/handle_info directly: the screen would stop answering
taps until it returns. Run it in a task and send yourself the result:
screen = self()
Task.start(fn -> send(screen, {:pcm_ready, MobMedia.audio_pcm(path)}) end)Each call runs in its own short-lived process, so a call that times out
(:timeout option) returns {:error, :timeout} at once while the native
work finishes in the background and its temporary output is deleted. A
caller that dies is handled the same way.
Memory
audio_pcm/2 never holds the whole decode in native memory: the platform
side streams the PCM into a file as it decodes. Without :to, that file is
temporary and is read into the binary audio_pcm/2 returns (16 kHz mono is
1.9 MB a minute, 115 MB an hour). For long recordings pass to: path: the
PCM stays in that file ({:ok, path}), and you can read it a window at a
time (whisper works in 30 s windows: 960 000 bytes at 16 kHz mono):
{:ok, pcm_path} = MobMedia.audio_pcm(video, to: Path.join(dir, "audio.pcm"))
{:ok, fd} = :file.open(pcm_path, [:read, :binary, :raw])
{:ok, window} = :file.pread(fd, 0, 960_000)Errors
:not_found—pathdoesn't exist.:unsupported— not a format the platform can open or decode.:no_audio/:no_video— the file has no such track.:decode_failed— the decoder failed part way (a damaged file).:io_error— the output couldn't be written or read back.:timeout— the:timeoutoption ran out.:unavailable— the NIF isn't linked into this build (or you're on the host withoutMobMedia.Engine.Fake).{:jni, reason}(Android) /:alloc(iOS) — the NIF couldn't queue the job; seeMobMedia.Engine.Native.
Engines
The decoding is done by a MobMedia.Engine: MobMedia.Engine.Native when
the NIF is linked, MobMedia.Engine.Unavailable otherwise. Tests and
agents use MobMedia.Engine.Fake (real WAV decoding, scripted answers for
anything else):
config :mob_media, engine: MobMedia.Engine.Fake
Summary
Types
Why an operation failed; see the moduledoc.
Functions
Decode the audio of path (an audio file, or a video's audio track) to
signed 16-bit little-endian interleaved PCM, the format
MobWhisper.transcribe/2 takes.
Whether media can be decoded in this build (the NIF is linked, or an engine is configured).
The engine in use: config :mob_media, :engine if set, otherwise
MobMedia.Engine.Native when the NIF is linked, otherwise
MobMedia.Engine.Unavailable.
The video frame of path at at_ms milliseconds, as a JPEG binary.
Duration, video dimensions, which tracks the file has, and its MIME type.
Types
@type info() :: %{ duration_ms: non_neg_integer() | nil, width: pos_integer() | nil, height: pos_integer() | nil, has_audio: boolean(), has_video: boolean(), mime: String.t() | nil }
What info/2 returns. Fields the file doesn't have are nil.
@type reason() :: :not_found | :unsupported | :no_audio | :no_video | :decode_failed | :io_error | :timeout | :unavailable | {:jni, atom()} | term()
Why an operation failed; see the moduledoc.
Functions
Decode the audio of path (an audio file, or a video's audio track) to
signed 16-bit little-endian interleaved PCM, the format
MobWhisper.transcribe/2 takes.
Options:
:rate— sample rate in Hz (default 16000).:channels—1(default; channels averaged) or2.:to— write the PCM to this file instead of returning it, and return{:ok, to}. The file appears only when the decode succeeded (it's written beside it and renamed). See "Memory" in the moduledoc.:timeout— ms, default:infinity.
{:error, :no_audio} when the file has no audio track. The PCM covers the
whole track: its length (MobMedia.PCM.duration_ms/3) matches the duration
within a few tens of milliseconds, except AAC files, whose container
duration includes encoder priming and padding (about 130 ms longer).
@spec available?() :: boolean()
Whether media can be decoded in this build (the NIF is linked, or an engine is configured).
@spec engine() :: module()
The engine in use: config :mob_media, :engine if set, otherwise
MobMedia.Engine.Native when the NIF is linked, otherwise
MobMedia.Engine.Unavailable.
@spec frame(Path.t(), non_neg_integer(), keyword()) :: {:ok, binary()} | {:error, reason()}
The video frame of path at at_ms milliseconds, as a JPEG binary.
The frame is the one shown at that time (exact, not the nearest keyframe),
upright (the video's rotation applied), scaled down so neither side exceeds
:max_px. A time past the end gives the last frame; if the platform can't
decode the exact frame there, the nearest earlier keyframe.
Options:
:max_px— longest side in pixels (default 1280); never scales up.nilkeeps the full size.:quality— JPEG quality 1..100 (default 85).:timeout— ms, default:infinity.
{:error, :no_video} for a file without a video track.
Duration, video dimensions, which tracks the file has, and its MIME type.
{:ok, %{duration_ms: 3_021, width: nil, height: nil,
has_audio: true, has_video: false, mime: "audio/mp4"}}width/height are the size the video is shown at: a portrait phone
video recorded as 1920x1080 with a 90° rotation reports 1080x1920, the same
orientation frame/3 returns. mime is the container type (Android reads
the container; iOS goes by the file name's extension) with common aliases folded: audio/x-wav is audio/wav,
audio/x-m4a is audio/mp4; nil when unknown.
Options: :timeout (ms, default :infinity).