AudioProxy.Ffmpeg.Command (audio_proxy v0.4.0)

Copy Markdown View Source

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:

  1. baseline flags (-nostdin -hide_banner -loglevel error),
  2. the input protocol whitelist, before -i so it binds the input,
  3. input-side seek (-ss/-t) before -i,
  4. the input URL as one argv element,
  5. -vn -sn -dn (we ship audio, and nothing else — see Audio only),
  6. the filtergraph, then -ac, then the codec/muxer arguments,
  7. -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 loudnorm → volume → aresample → afade, and the order is load-bearing:

  • loudnorm first, because normalizing after a static gain would undo it — gain with norm means "normalize, then offset".
  • aresample after loudnorm, because single-pass loudnorm resamples its output to 192 kHz. When norm is given without sr we therefore append aresample=48000 ourselves; without it every normalized render would be a 192 kHz file (or a silent auto-resample by the encoder). 48 kHz is the API's own lossy ceiling (§3.1) and universally supported, but it does mean norm on a 96 kHz lossless master downsamples. Fixing that needs the source's real rate, which this module deliberately does not know.
  • afade last, so the fade shape survives the stages above it.

What the builder does not know

build/3 takes a source/0 because two decisions genuinely cannot be made from the options alone. The protocol whitelist is one, and it is required. The other is optional: with no bd, a lossless variant should follow the source's own bit depth, the way sr follows its sample rate (§3.1). Until the /info probe exists to supply it, the fallback is 16-bit — documented, not silent.

Peaks

f:peaks builds raw interleaved s16le PCM on stdout — the input to AudioProxy.Peaks, not its output. Encoding and loudness options are already refused for peaks by AudioProxy.Options; t, ch and fade apply, since all three change the samples a waveform would be drawn from. 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 ffmpeg input protocol whitelist for a resolved source's type.

Whether flag is followed by a value argument.

Types

source()

@type source() :: [type: source_type(), bit_depth: AudioProxy.Options.bit_depth()]

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 is optional, and exists because a lossless variant cannot pick a sane default without knowing the source's depth; omitting it keeps the documented 16-bit fallback.

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.

source_type()

@type source_type() :: :local | :s3 | :http

A resolved source's type tag, as its module reports it.

Functions

allowed_flags()

@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 = ∅).

build(options, input_url, source)

@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"]

content_type(options)

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"

protocols(atom)

@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.

takes_value?(flag)

@spec takes_value?(String.t()) :: boolean()

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