AudioProxy.Options (audio_proxy v0.4.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 options segments, 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"

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

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

t()

A parsed variant description.

Functions

The option keys recognized by parse/1, 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.

Checks the cross-key rules that per-key parsing cannot see.

Types

bit_depth()

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

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,
  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).

Functions

keys()

@spec keys() :: [String.t()]

The option keys recognized by parse/1, in canonical (sorted) order.

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

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"

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.