AudioProxy.Ffmpeg.Command (audio_proxy v0.7.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 enhance → loudnorm → volume → aresample → afade, and the order is load-bearing:

  • the enhance preset 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 after loudnorm would mean measuring loudness on audio the compressor was about to change, so the render would miss its own target.
  • loudnorm next, 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 an aresample ourselves; 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. See resample/2.
  • afade last, 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

source()

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

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

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

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"

enhance_chain(preset)

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

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