The processing-options grammar (API doc §3): parse, validate, normalize.
Options are the API. A variant is fully described by its variant options, and the normalized options string is what the cache key hashes — so this module owns three obligations, in order:
parse/1—/-separatedkey:valuesegments intot/0, with per-key value domains enforced.validate/1— cross-key rules that no single parser can see (brexcludesq,bdis lossless-only, …).parse/1runs it for you.normalize/1— back to a canonical string: defaults materialized, keys sorted, numbers rendered minimally.
Normalization is the determinism boundary. Any two option strings
describing the same variant must produce byte-identical normalized forms,
because AudioProxy.CacheKey hashes that string directly. Two rules keep
it stable: decimals are capped at millisecond precision (three places) at
parse time, and every number is rendered by the same render_number/1.
Failures are AudioProxy.OptionError.t/0 values naming the offending
segment; the HTTP slice maps them to 422.
iex> {:ok, opts} = AudioProxy.Options.parse("br:96/f:opus")
iex> AudioProxy.Options.normalize(opts)
"br:96/f:opus"Two classes of option
Every key belongs to exactly one class, and the class decides what the value is allowed to reach:
- Variant options (
variant_keys/0) — everything that describes the rendered bytes. They normalize into the canonical string, so they are the cache key, and they become ffmpeg arguments. - Request options (
request_keys/0) — parsed and validated here, and covered by the signature for free because they are path bytes, but excluded from the canonical string, the cache key and the ffmpeg arguments.expis the first of them.
The exclusion is the whole point. exp carries a per-recipient timestamp,
and a timestamp inside the cache key would mint one render per listener for
bytes that are identical. So two URLs differing only in a request option
share a key and coalesce into one render, and the round-trip property holds
per class: a variant option round-trips to an identical cache key, a request
option round-trips to the signed path alone.
iex> {:ok, a} = AudioProxy.Options.parse("f:opus/exp:2000000000")
iex> {:ok, b} = AudioProxy.Options.parse("f:opus/exp:2000000001")
iex> AudioProxy.Options.normalize(a) == AudioProxy.Options.normalize(b)
truePresets are pinned
enhance names a curated chain rather than describing one. That makes the
value a promise about bytes: enhance:voice maps to an exact filter chain
forever, and improving the chain mints a new value (voice2) rather than
mutating this one.
The alternative is not tenable. Variants are served under
Cache-Control: immutable and addressed by a key derived from the options
string, so a preset whose chain changed would hand two different renders the
same key: an operator's CDN would keep serving the old bytes, a cold cache
would produce the new ones, and nothing in the URL would distinguish them.
Pinning is what keeps the key honest, and it is asserted rather than
remembered — AudioProxy.Ffmpeg.Command.enhance_chain/1 is compared against
a literal in the suite, so changing the chain fails a test that names this
rule.
Known limits
Semantic no-ops keep their own identity: t:0 (a trim from zero to the end),
fade:0:0 and gain:0 render exactly what the bare options render, but
normalize to distinct strings and therefore distinct cache keys. The mapping
from options to key is syntactic on purpose; collapsing no-ops is tracked as
follow-up work rather than done silently here.
Summary
Types
An enhancement preset: a name for a pinned filter chain.
loudnorm targets: integrated LUFS, true peak dBTP, loudness range LU.
A parsed variant description.
Functions
Every enhance preset name, as atoms.
Renders opts as its canonical options string — the cache-key input.
Parses and normalizes in one step.
Parses an options string (or its already-split segments) into t/0.
How many channels a peaks render reduces.
How many min/max pairs opts asks for. Meaningful under f:peaks only.
Which serialization opts asks for. Meaningful under f:peaks only.
Renders a number in the canonical minimal form used across the round-trip.
The keys that describe the request rather than the variant.
Checks the cross-key rules that per-key parsing cannot see.
The keys that describe the rendered bytes, in canonical (sorted) order.
Types
@type bit_depth() :: :bd16 | :bd24 | :bd32f
@type enhance() :: :voice
An enhancement preset: a name for a pinned filter chain.
@type format() :: :mp3 | :opus | :ogg | :aac | :m4a | :flac | :wav | :peaks
loudnorm targets: integrated LUFS, true peak dBTP, loudness range LU.
@type peak_format() :: :json | :dat
@type t() :: %AudioProxy.Options{ bit_depth: bit_depth() | nil, bitrate: pos_integer() | nil, cache_buster: String.t() | nil, channels: 1 | 2 | nil, download: String.t() | nil, enhance: enhance() | nil, expires_at: pos_integer() | nil, fade_in: float() | nil, fade_out: float() | nil, format: format(), gain: float() | nil, norm: norm() | nil, peak_count: pos_integer() | nil, peak_format: peak_format() | nil, quality: float() | nil, sample_rate: pos_integer() | nil, trim_duration: float() | nil, trim_start: float() | nil }
A parsed variant description.
nil means "not given": the renderer falls back to the source's own
property (sample_rate, channels) or skips the stage entirely (gain,
norm, trim_start). trim_duration is nil for an open-ended trim
(t:30 runs to the end of the source).
expires_at is the one field that describes the request rather than the
variant — see the "Two classes of option" section. It never reaches
normalize/1, and so never reaches the cache key or the ffmpeg arguments.
Functions
@spec enhance_presets() :: [enhance()]
Every enhance preset name, as atoms.
Published for the pinning guard: the suite asserts this list and each value's chain, so widening the vocabulary is a deliberate edit in two places rather than a chain quietly acquiring a new spelling. See "Presets are pinned".
iex> AudioProxy.Options.enhance_presets()
[:voice]
@spec keys() :: [String.t()]
Every option key parse/1 recognizes, in canonical (sorted) order.
Both classes, so this is the documented surface — what
AudioProxy.LlmsDocsTest checks the published options table against. The
cache-key subset is variant_keys/0.
Renders opts as its canonical options string — the cache-key input.
Keys are sorted lexicographically, applicable defaults are materialized
(f, and ch/pts/pk_fmt under f:peaks, and the norm targets when
norm is present), and numbers are rendered minimally (30, not 30.0).
Only variant_keys/0 are walked, so a request option is absent from the
result by construction — that is what makes two URLs differing only in exp
one variant.
The result always re-parses, and normalizing it again is byte-identical.
iex> {:ok, opts} = AudioProxy.Options.parse("norm:ebu")
iex> AudioProxy.Options.normalize(opts)
"f:mp3/norm:ebu:-16:-1.5:11"
@spec normalize_string(String.t() | [String.t()]) :: {:ok, String.t()} | {:error, AudioProxy.OptionError.t()}
Parses and normalizes in one step.
iex> AudioProxy.Options.normalize_string("br:96/f:opus")
{:ok, "br:96/f:opus"}
@spec parse(String.t() | [String.t()]) :: {:ok, t()} | {:error, AudioProxy.OptionError.t()}
Parses an options string (or its already-split segments) into t/0.
Accepts "f:opus/br:96" or ["f:opus", "br:96"]; an empty string yields
the defaults. Unknown keys, repeated keys, and out-of-domain values fail,
and validate/1 runs on the result so cross-key conflicts fail here too.
iex> {:ok, opts} = AudioProxy.Options.parse("f:opus/t:12.5:30")
iex> {opts.format, opts.trim_start, opts.trim_duration}
{:opus, 12.5, 30.0}
@spec peak_channels(t()) :: 1 | 2
How many channels a peaks render reduces.
Unlike every other format, f:peaks does not follow the source when ch is
absent: it downmixes to mono. A waveform UI draws one shape, and following a
stereo source would double the payload for a picture almost nobody asks for.
ch:2 still gets per-channel peaks.
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks")
iex> AudioProxy.Options.peak_channels(opts)
1
@spec peak_count(t()) :: pos_integer()
How many min/max pairs opts asks for. Meaningful under f:peaks only.
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks")
iex> AudioProxy.Options.peak_count(opts)
800
@spec peak_format(t()) :: peak_format()
Which serialization opts asks for. Meaningful under f:peaks only.
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks")
iex> AudioProxy.Options.peak_format(opts)
:json
Renders a number in the canonical minimal form used across the round-trip.
Whole values lose their fraction entirely (30, not 30.0) and fractions
keep at most the three places parsing allowed. Every number in a normalized
options string goes through here, and so does every number
AudioProxy.Ffmpeg.Command puts in a filter expression — one rendering, so
the options string and the ffmpeg arguments cannot disagree about a value.
iex> AudioProxy.Options.render_number(30.0)
"30"
@spec request_keys() :: [String.t()]
The keys that describe the request rather than the variant.
Signed like any other path bytes, excluded from the canonical options string, the cache key and the ffmpeg arguments. See "Two classes of option".
@spec validate(t()) :: {:ok, t()} | {:error, AudioProxy.OptionError.t()}
Checks the cross-key rules that per-key parsing cannot see.
Run by parse/1; public so a hand-built struct can be checked too. Rules, in
the order they are reported: br excludes q; bd needs a lossless format;
br needs a lossy format, and q needs a format with a quality scale and a
value inside that codec's range; bd needs
a lossless format, and bd:32f needs f:wav; f:peaks refuses encoding and
loudness options; pts/pk_fmt need f:peaks; sr is capped at 48 kHz for
lossy formats; a fade must fit inside a bounded trim, and a fade-out needs
that trim to be bounded at all.
@spec variant_keys() :: [String.t()]
The keys that describe the rendered bytes, in canonical (sorted) order.
normalize/1 walks exactly this list, which is what keeps a request option
out of the cache key by construction rather than by remembering to skip it.