AudioProxy.Options (audio_proxy v0.7.0)

Copy Markdown View Source

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/-separated key:value segments into t/0, with per-key value domains enforced.
  • validate/1 — cross-key rules that no single parser can see (br excludes q, bd is lossless-only, …). parse/1 runs 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. exp is 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)
true

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

t()

A parsed variant description.

Functions

Every enhance preset name, as atoms.

Every option key parse/1 recognizes, in canonical (sorted) order.

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

bit_depth()

@type bit_depth() :: :bd16 | :bd24 | :bd32f

enhance()

@type enhance() :: :voice

An enhancement preset: a name for a pinned filter chain.

format()

@type format() :: :mp3 | :opus | :ogg | :aac | :m4a | :flac | :wav | :peaks

norm()

@type norm() :: {float(), float(), float()}

loudnorm targets: integrated LUFS, true peak dBTP, loudness range LU.

peak_format()

@type peak_format() :: :json | :dat

t()

@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

enhance_presets()

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

keys()

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

normalize(opts)

@spec normalize(t()) :: String.t()

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"

normalize_string(options)

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

parse(options)

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

peak_channels(options)

@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

peak_count(options)

@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

peak_format(options)

@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

render_number(number)

@spec render_number(number()) :: String.t()

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"

request_keys()

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

validate(opts)

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

variant_keys()

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