Normalized options → ffmpeg argument vector (API doc §3).
This is the last leg of the round-trip the project is built around: parse →
normalize → cache key → identical ffmpeg args. build/3 is a pure
function of a validated AudioProxy.Options.t/0 and the input URL, so two
option strings that normalize alike produce byte-identical argv, which is
what makes a cache key a promise about bytes rather than about a URL.
iex> {:ok, opts} = AudioProxy.Options.parse("f:opus/br:96/t:12.5:30/fade:0.5:1")
iex> AudioProxy.Ffmpeg.Command.build(opts, "https://example.test/a.wav", type: :http)
["-nostdin", "-hide_banner", "-loglevel", "error",
"-protocol_whitelist", "https,tls,tcp",
"-ss", "12.5", "-t", "30", "-i", "https://example.test/a.wav",
"-vn", "-sn", "-dn", "-af", "afade=t=in:st=0:d=0.5,afade=t=out:st=29:d=1",
"-c:a", "libopus", "-b:a", "96k", "-f", "ogg", "pipe:1"]Shape of the argv
Arguments are emitted in a fixed order and never conditionally reordered — list equality is the tested contract, so a stable shape is the whole point:
- baseline flags (
-nostdin -hide_banner -loglevel error), - the input protocol whitelist, before
-iso it binds the input, - input-side seek (
-ss/-t) before-i, - the input URL as one argv element,
-vn -sn -dn(we ship audio, and nothing else — see Audio only),- the filtergraph, then
-ac, then the codec/muxer arguments, -f <muxer> pipe:1.
Seeking before -i is not a micro-optimization: ffmpeg's HTTP client turns
it into a Range request, so t:3600:30 on a two-hour master reads the
thirty seconds it needs and nothing else. That is the reason sources are
handed to ffmpeg as presigned URLs instead of piped through the BEAM.
Because the seek happens on the input, the trimmed region starts at t=0 for
everything downstream, which is exactly the frame fade is specified in.
Injection safety
There is no shell. The argv is a flat list of complete arguments, so a
source URL containing ;, $(…), quotes or spaces is one element and stays
data. Nothing user-supplied reaches the filtergraph either: dl and cb
never appear in argv at all, and every filter value is a number that
AudioProxy.Options has already parsed and bounded, re-rendered here
through AudioProxy.Options.render_number/1.
No URL content can become an ffmpeg flag, either, and that is asserted
rather than argued: allowed_flags/0 publishes every flag this module can
emit, and the property test walks a generated argv position by position,
checking each flag against that list and each value against the flag it
follows. A value that happens to start with - — f:ogg/q:-1 renders
["-q:a", "-1"] — is therefore not mistaken for a flag, and a flag that
arrived from anywhere but this module has nowhere to hide.
Audio only
Every argv disables non-audio streams (-vn -sn -dn) and restricts ffmpeg's
input protocols (-protocol_whitelist), unconditionally, for every format
and for the peaks PCM path. Both are defence in depth behind the render
action's probe gate, which is what actually rejects a video source with 415:
these two are what hold if the gate is ever bypassed, reordered, or asked
about a source it cannot see inside.
The protocol set is derived from the resolved source's type, never from
the input string and never from an env knob — a knob would reopen the hole
this closes. The sets are disjoint by construction: a local source gets
file and so cannot reach the network, a remote one gets https,tls,tcp
and so cannot reach the filesystem. concat:, subfile: and friends are
reachable from neither, which is what stops a crafted or redirecting source
from pivoting ffmpeg across a boundary. See protocols/1.
Filter order
The chain is enhance → loudnorm → volume → aresample → afade, and the
order is load-bearing:
- the
enhancepreset first, because it is source conditioning: it cleans up what was recorded, and every stage after it is a statement about the signal that comes out. Putting it afterloudnormwould mean measuring loudness on audio the compressor was about to change, so the render would miss its own target. loudnormnext, because normalizing after a staticgainwould undo it —gainwithnormmeans "normalize, then offset".aresampleafterloudnorm, because single-passloudnormresamples its output to 192 kHz. Whennormis given withoutsrwe therefore append anaresampleourselves; without it every normalized render would be a 192 kHz file (or a silent auto-resample by the encoder). The target is the source's own rate, for every format, because that is §3.1's default for every format — the stage undoes an implementation detail and has to land where the render would have been without it. Only where no probe supplied a rate does it fall back to 48 kHz. Seeresample/2.afadelast, so the fade shape survives the stages above it.
What the builder does not know
build/3 takes a source/0 because three decisions genuinely cannot be
made from the options alone. The protocol whitelist is one, and it is
required. The other two are the source's own properties, and both exist
because §3.1 defines an absent option as "follow the source": with no bd a
lossless variant follows the source's bit depth, and with no sr a
normalized render resamples back to the source's rate. Both are optional
keys, and omitting either keeps the documented fallback — 16-bit and 48 kHz
— rather than guessing.
The render action supplies both from the probe its audio-only gate already runs, so on the mounted pipeline they are always present; a caller that builds argv without a probe (the suite, mostly) gets the fallbacks.
Peaks
f:peaks builds raw interleaved s16le PCM on stdout — the input to
AudioProxy.Peaks, not its output. Encoding options are refused for peaks by
AudioProxy.Options; every option that changes the samples applies — t,
ch, fade, enhance, gain and norm — since a picture that disagreed
with the audio playing under it would be the defect.
Frame count is what makes that safe, and it is a property of the chain rather
than of taste: every filter is rate-preserving, and the one that is not
(loudnorm, which hands back 192 kHz) is followed by an aresample back to
the source's own rate. So the decode emits the frames
AudioProxy.Peaks.Render budgeted from its probe, and the sample_rate the
header reports describes the samples actually reduced. ch is the one option
peaks read differently: absent, it means mono rather than "follow the source",
because the reducer has to know the interleaving up front and a waveform UI
draws one shape.
Summary
Types
What the builder needs to know about the source itself.
A resolved source's type tag, as its module reports it.
Functions
Every flag build/3 can ever emit, sorted.
Builds the ffmpeg argument vector for options reading from input_url.
The Content-Type for a variant.
The pinned filter chain for an enhance preset.
The ffmpeg input protocol whitelist for a resolved source's type.
Whether flag is followed by a value argument.
Types
@type source() :: [ type: source_type(), bit_depth: AudioProxy.Options.bit_depth(), sample_rate: pos_integer() ]
What the builder needs to know about the source itself.
:type is the resolved source's tag (AudioProxy.Source.Type.tag/0) and is
required: it is what the input protocol whitelist is derived from, and a
default would be a guess about which side of the network/filesystem boundary
this render sits on.
:bit_depth and :sample_rate are optional and are the source's own, as the
probe reported them. Each exists because an option §3.1 documents as
following the source cannot do so without them: a lossless variant with no
bd follows the depth, and a normalized render with no sr resamples back to
the rate. Omitting either keeps the documented fallback (16-bit, 48 kHz).
This does not weaken the round-trip invariant, but it is worth stating the invariant precisely. The cache key hashes the normalized options and the source, so equal keys imply the same source and therefore the same type, the same source metadata, and — within one deployment — the same argv.
The qualifier is not new to this key: input_url is what
AudioProxy.Source.ffmpeg_input/1 produced, and for a remote source that is a
presigned URL whose host, scheme and signature all come from deployment
configuration. Argv has therefore never been portable across deployments, and
protocols/1's :s3 clause (which reads AP_S3_ENDPOINT) adds nothing in
kind. What the cache needs is that argv be a deterministic function of the key
on the box serving it, and it is. Neither the presigned URL nor the protocol
set changes a single output byte, so two deployments still render identical
variants for identical keys.
@type source_type() :: :local | :s3 | :http
A resolved source's type tag, as its module reports it.
Functions
@spec allowed_flags() :: [String.t()]
Every flag build/3 can ever emit, sorted.
Published so the argv-allowlist property test compares against this module's own vocabulary rather than a copy that can drift. Two things are asserted against it: that every flag in a built argv is a member (reality ⊆ allowlist), and that the list itself contains no video, subtitle or stream- mapping flag (allowlist ∩ denylist = ∅).
@spec build(AudioProxy.Options.t(), String.t(), source()) :: [String.t()]
Builds the ffmpeg argument vector for options reading from input_url.
options must already be valid — AudioProxy.Options.parse/1 and
validate/1 are the gate, and every rule they enforce is a precondition
here (a bounded trim behind every fade-out, a lossless format behind every
bd, and so on). input_url is passed through verbatim as a single argv
element; it is never parsed, escaped, or interpolated.
source must carry the resolved source's :type — see source/0 and
protocols/1.
The result is the argument list after the program name, ready for
Port.open/2 with :args.
iex> {:ok, opts} = AudioProxy.Options.parse("f:wav/bd:24")
iex> AudioProxy.Ffmpeg.Command.build(opts, "/srv/audio/k.aif", type: :local)
...> |> Enum.take(-8)
["-vn", "-sn", "-dn", "-c:a", "pcm_s24le", "-f", "wav", "pipe:1"]With no bd, a lossless variant follows the source when its depth is known:
iex> {:ok, opts} = AudioProxy.Options.parse("f:wav")
iex> AudioProxy.Ffmpeg.Command.build(opts, "/srv/audio/k.aif",
...> type: :local, bit_depth: :bd24)
...> |> Enum.take(-5)
["-c:a", "pcm_s24le", "-f", "wav", "pipe:1"]And a normalized render follows the source's sample rate the same way:
iex> {:ok, opts} = AudioProxy.Options.parse("f:flac/norm:ebu")
iex> AudioProxy.Ffmpeg.Command.build(opts, "/srv/audio/k.aif",
...> type: :local, sample_rate: 96_000)
...> |> Enum.slice(-7, 2)
["-af", "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=96000"]
@spec content_type(AudioProxy.Options.t() | AudioProxy.Options.format()) :: String.t()
The Content-Type for a variant.
Takes a format atom, or an AudioProxy.Options.t/0 — peaks need the
latter, since pk_fmt decides between JSON and the compact binary form.
iex> AudioProxy.Ffmpeg.Command.content_type(:m4a)
"audio/mp4"
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks/pk_fmt:dat")
iex> AudioProxy.Ffmpeg.Command.content_type(opts)
"application/octet-stream"
@spec enhance_chain(AudioProxy.Options.enhance()) :: String.t()
The pinned filter chain for an enhance preset.
Published because the pinning rule needs somewhere to be asserted: the suite compares this against a literal, so changing a chain fails a test naming the rule instead of silently re-rendering every cached variant that asked for the old one. A preset value maps to exactly these characters forever; an improved chain is a new value.
An unknown preset raises — AudioProxy.Options is the gate, and a name it
accepts with no chain here should crash this module's tests rather than
render unenhanced audio under an enhanced key.
iex> AudioProxy.Ffmpeg.Command.enhance_chain(:voice)
"highpass=f=80,afftdn=nr=12:nf=-30,deesser=i=0.4:m=0.5:f=0.5:s=o,acompressor=threshold=0.125:ratio=3:attack=20:release=250:makeup=2,alimiter=limit=0.977:level=disabled"
@spec protocols(source_type()) :: String.t()
The ffmpeg input protocol whitelist for a resolved source's type.
One entry per source type, and deliberately not configurable: the whole point is that the set follows from what the source is, so no request and no environment variable can widen it.
iex> AudioProxy.Ffmpeg.Command.protocols(:local)
"file"
iex> AudioProxy.Ffmpeg.Command.protocols(:http)
"https,tls,tcp"A source type added later has no clause here, so it raises rather than
inheriting somebody else's protocol set — the same discipline
AudioProxy.ErrorJSON applies to its own rows, and for the same reason: the
mistake should crash that slice's tests, not quietly open a protocol.
One clause is not a pure function of its argument, despite the spec: :s3
reads AP_S3_ENDPOINT, because the presigned URL ffmpeg is handed carries the
endpoint's own scheme. See source/0 for why that does not weaken the
round-trip invariant — the same endpoint is already in the input URL.
Whether flag is followed by a value argument.
The other half of what the property test needs: without it, walking an argv
cannot tell the flag -t from the value -1 that f:ogg/q:-1 renders,
and a check that only looked at leading hyphens would have to choose between
a false alarm and a hole.
iex> AudioProxy.Ffmpeg.Command.takes_value?("-b:a")
true
iex> AudioProxy.Ffmpeg.Command.takes_value?("-vn")
false