lib/audio_proxy/options.ex
Rama main · réplica de lectura del repositorio original.
defmodule AudioProxy.Options do
@moduledoc """
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: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 `t: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.
"""
alias AudioProxy.OptionError
@formats ~w(mp3 opus ogg aac m4a flac wav peaks)a
@lossy ~w(mp3 opus ogg aac m4a)a
@lossless ~w(flac wav)a
# The preset vocabulary. One entry today, and a new entry is how the chain is
# ever allowed to change — see "Presets are pinned".
@enhance_presets ~w(voice)a
# Formats whose encoder exposes a quality scale for `q` to mean something,
# mapped to that scale's domain. PCM has neither scale, so `f:wav` is absent
# and `q` with it could only be ignored.
#
# The domains are each encoder's documented range, and the ceiling is load
# bearing: `f:flac/q:13` is rejected by ffmpeg itself ("invalid compression
# level"), which without this rule is a 500 where every other out-of-domain
# value is a 422. Above its range libmp3lame stops responding at all (10 and
# 11 encode byte-identically), which is the no-op case the peaks rule already
# refuses. The rest were measured to keep changing their output past the
# documented edge; they are bounded anyway, because "codec-specific number"
# (§3.1) means the codec's number, not any number.
@quality_ranges %{
mp3: {0.0, 9.0},
ogg: {-1.0, 10.0},
aac: {0.1, 2.0},
m4a: {0.1, 2.0},
opus: {0.0, 10.0},
flac: {0.0, 12.0}
}
@quality_formats Map.keys(@quality_ranges)
# flac is integer-only (its encoder takes s16 and s32); a float bit depth
# exists in wav alone.
@float_bit_depth_formats [:wav]
@bit_depths %{"16" => :bd16, "24" => :bd24, "32f" => :bd32f}
@bit_depth_tokens Map.new(@bit_depths, fn {token, atom} -> {atom, token} end)
@peak_formats %{"json" => :json, "dat" => :dat}
@peak_bits %{"8" => 8, "16" => 16}
@peak_format_tokens Map.new(@peak_formats, fn {token, atom} -> {atom, token} end)
# Millisecond precision cap (design): three decimal places, so float
# formatting can never destabilize a cache key.
@max_decimals 3
@decimal_re ~r/^-?\d+(\.\d{1,3})?$/
@non_negative_integer_re ~r/^\d+$/
@default_format :mp3
@default_peak_count 800
@default_peak_format :json
@default_peak_channels 1
@default_peak_bits 16
@default_norm {-16.0, -1.5, 11.0}
# Lossy encoders gain nothing above 48 kHz; §3.1 caps them there.
@lossy_sample_rate_cap 48_000
# Upper bounds so a mistyped signed URL fails here, as a 422 naming the
# segment, rather than downstream as an encoder error or an allocation the
# size of `pts`. Chosen well above any real request.
@max_bitrate 10_000
@max_sample_rate 384_000
@max_peak_count 100_000
@max_gain 100.0
# `exp` under the same rule, and it is the bound that matters rather than the
# date: without one, `exp:` followed by four hundred digits parses into a
# bignum, because `String.to_integer/1` is arbitrary precision. The value is
# the last second of year 9999 — no real generator reaches it, and a URL that
# does is a mistyped one, answered as a 422 naming its segment.
@max_expires_at 253_402_300_799
# The two classes, as the lists everything else derives from: `normalize/1`
# walks the variant list, `parse_segment/3` checks membership in the union.
@variant_keys ~w(bd br cb ch dl enhance f fade gain norm pk_bits pk_fmt pts q sr t)
@request_keys ~w(exp)
@keys Enum.sort(@variant_keys ++ @request_keys)
# The rule is one question: **can this option change the picture?** An option
# that cannot would hand byte-identical peaks output two cache keys, so it is
# refused outright rather than silently ignored.
#
# `br`, `q` and `bd` are encoder settings and peaks are never encoded, so
# none of them can move a pixel. `sr` cannot either: bucket boundaries are a
# fraction of the *total* sample count, so resampling changes how many samples
# fall in a bucket without changing the shape drawn from them. It is refused
# for that reason and no other — it does change the `sample_rate` and
# `samples_per_pixel` a consumer reads, which is an argument for admitting it
# one day, and one this list is not making today.
#
# Everything else is respected, because everything else changes the samples:
# `t`, `ch`, `fade`, `enhance`, and — since `peaks-follow-the-variant` —
# `gain` and `norm`. `norm` waited on the rate fix that change carried: the
# reducer budgets its buckets from the source's probed rate, and single-pass
# `loudnorm` used to re-rate the decode to 48 kHz behind it (240000 frames
# against a 220500-frame budget for one 5 s 44.1 kHz source, absorbed into
# the final bucket as a spike). `AudioProxy.Ffmpeg.Command` now resamples back
# to the source's own rate, so the budget is correct by construction.
@peaks_unsupported ~w(br q sr bd)
# `dl` and `cb` are opaque, but not arbitrary: a control byte in `dl` reaches
# a `Content-Disposition` header, and any control byte would break the
# newline separator that `AudioProxy.CacheKey` relies on to keep the options
# half and the source half of its digest input unambiguous.
@control_char_re ~r/[\x00-\x1f\x7f]/
# loudnorm's documented input ranges.
@norm_i_range {-70.0, -5.0}
@norm_tp_range {-9.0, 0.0}
@norm_lra_range {1.0, 50.0}
@type format :: :mp3 | :opus | :ogg | :aac | :m4a | :flac | :wav | :peaks
@type bit_depth :: :bd16 | :bd24 | :bd32f
@type peak_format :: :json | :dat
@type peak_bits :: 8 | 16
@typedoc "An enhancement preset: a name for a pinned filter chain."
@type enhance :: :voice
@typedoc "loudnorm targets: integrated LUFS, true peak dBTP, loudness range LU."
@type norm :: {float(), float(), float()}
@typedoc """
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.
"""
@type t :: %__MODULE__{
format: format(),
bitrate: pos_integer() | nil,
quality: float() | nil,
sample_rate: pos_integer() | nil,
channels: 1 | 2 | nil,
bit_depth: bit_depth() | nil,
trim_start: float() | nil,
trim_duration: float() | nil,
fade_in: float() | nil,
fade_out: float() | nil,
gain: float() | nil,
norm: norm() | nil,
enhance: enhance() | nil,
peak_count: pos_integer() | nil,
peak_format: peak_format() | nil,
peak_bits: peak_bits() | nil,
download: String.t() | nil,
cache_buster: String.t() | nil,
expires_at: pos_integer() | nil
}
defstruct format: @default_format,
bitrate: nil,
quality: nil,
sample_rate: nil,
channels: nil,
bit_depth: nil,
trim_start: nil,
trim_duration: nil,
fade_in: nil,
fade_out: nil,
gain: nil,
norm: nil,
enhance: nil,
peak_count: nil,
peak_format: nil,
peak_bits: nil,
download: nil,
cache_buster: nil,
expires_at: nil
@doc """
Parses an options string (or its already-split segments) into `t: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 parse(String.t() | [String.t()]) :: {:ok, t()} | {:error, OptionError.t()}
def parse(options) when is_binary(options) or is_list(options) do
options
|> segments()
|> Enum.reduce_while({%__MODULE__{}, MapSet.new()}, fn segment, {opts, seen} ->
case parse_segment(segment, opts, seen) do
{:ok, opts, seen} -> {:cont, {opts, seen}}
{:error, _} = error -> {:halt, error}
end
end)
|> case do
{:error, _} = error -> error
{opts, _seen} -> validate(opts)
end
end
@doc """
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`/`pk_bits` 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 validate(t()) :: {:ok, t()} | {:error, OptionError.t()}
def validate(%__MODULE__{} = opts) do
with :ok <- validate_bitrate_quality(opts),
:ok <- validate_bitrate_format(opts),
:ok <- validate_quality_format(opts),
:ok <- validate_quality_range(opts),
:ok <- validate_bit_depth(opts),
:ok <- validate_bit_depth_format(opts),
:ok <- validate_peaks_only(opts),
:ok <- validate_peak_options(opts),
:ok <- validate_sample_rate(opts),
:ok <- validate_fade(opts) do
{:ok, opts}
end
end
@doc """
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_count(t()) :: pos_integer()
def peak_count(%__MODULE__{peak_count: count}), do: count || @default_peak_count
@doc """
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
"""
@spec peak_format(t()) :: peak_format()
def peak_format(%__MODULE__{peak_format: format}), do: format || @default_peak_format
@doc """
The width of each serialized peak value. Meaningful under `f:peaks` only.
16 by default. `pk_bits:8` exists because peaks.js reads only 8-bit data.
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks")
iex> AudioProxy.Options.peak_bits(opts)
16
"""
@spec peak_bits(t()) :: peak_bits()
def peak_bits(%__MODULE__{peak_bits: bits}), do: bits || @default_peak_bits
@doc """
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_channels(t()) :: 1 | 2
def peak_channels(%__MODULE__{channels: channels}), do: channels || @default_peak_channels
@doc """
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`/`pk_bits` 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(t()) :: String.t()
def normalize(%__MODULE__{} = opts) do
variant_keys()
|> Enum.map(&{&1, render_key(&1, opts)})
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map_join("/", fn {_key, value} -> value end)
end
@doc """
Parses and normalizes in one step.
iex> AudioProxy.Options.normalize_string("br:96/f:opus")
{:ok, "br:96/f:opus"}
"""
@spec normalize_string(String.t() | [String.t()]) ::
{:ok, String.t()} | {:error, OptionError.t()}
def normalize_string(options) do
with {:ok, opts} <- parse(options), do: {:ok, normalize(opts)}
end
@doc """
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`.
"""
@spec keys() :: [String.t()]
def keys, do: @keys
@doc """
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.
"""
@spec variant_keys() :: [String.t()]
def variant_keys, do: @variant_keys
@doc """
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 enhance_presets() :: [enhance()]
def enhance_presets, do: @enhance_presets
@doc """
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 request_keys() :: [String.t()]
def request_keys, do: @request_keys
## Parsing
defp segments(options) when is_list(options), do: options
defp segments(""), do: []
defp segments(options), do: String.split(options, "/")
defp parse_segment("", _opts, _seen), do: {:error, OptionError.new("", :empty_segment)}
defp parse_segment(segment, opts, seen) do
case String.split(segment, ":", parts: 2) do
[_key] ->
{:error, OptionError.new(segment, :missing_value)}
[key, value] ->
cond do
key not in keys() -> {:error, OptionError.new(segment, :unknown_key)}
MapSet.member?(seen, key) -> {:error, OptionError.new(segment, :duplicate_key)}
true -> apply_value(key, value, segment, opts, seen)
end
end
end
defp apply_value(key, value, segment, opts, seen) do
case parse_value(key, value) do
{:ok, fields} -> {:ok, struct!(opts, fields), MapSet.put(seen, key)}
{:error, reason} -> {:error, OptionError.new(segment, reason)}
end
end
defp parse_value("f", value) do
with {:ok, format} <- enum(value, @formats), do: {:ok, format: format}
end
defp parse_value("br", value) do
with {:ok, bitrate} <- bounded_integer(value, @max_bitrate),
do: {:ok, bitrate: bitrate}
end
defp parse_value("q", value) do
with {:ok, quality} <- decimal(value), do: {:ok, quality: quality}
end
defp parse_value("sr", value) do
with {:ok, rate} <- bounded_integer(value, @max_sample_rate),
do: {:ok, sample_rate: rate}
end
defp parse_value("ch", "1"), do: {:ok, channels: 1}
defp parse_value("ch", "2"), do: {:ok, channels: 2}
defp parse_value("ch", _value), do: {:error, :invalid_value}
defp parse_value("bd", value) do
case Map.fetch(@bit_depths, value) do
{:ok, depth} -> {:ok, bit_depth: depth}
:error -> {:error, :invalid_value}
end
end
# `t:START[:DURATION]` — an omitted duration runs to the end of the source.
defp parse_value("t", value) do
case String.split(value, ":") do
[start] ->
with {:ok, start} <- non_negative_decimal(start), do: {:ok, trim_start: start}
[start, duration] ->
with {:ok, start} <- non_negative_decimal(start),
{:ok, duration} <- non_negative_decimal(duration),
:ok <- positive(duration) do
{:ok, trim_start: start, trim_duration: duration}
end
_ ->
{:error, :invalid_value}
end
end
# `fade:IN[:OUT]` — an omitted out-fade is zero, not a mirror of the in.
defp parse_value("fade", value) do
case String.split(value, ":") do
[fade_in] ->
with {:ok, fade_in} <- non_negative_decimal(fade_in),
do: {:ok, fade_in: fade_in, fade_out: 0.0}
[fade_in, fade_out] ->
with {:ok, fade_in} <- non_negative_decimal(fade_in),
{:ok, fade_out} <- non_negative_decimal(fade_out) do
{:ok, fade_in: fade_in, fade_out: fade_out}
end
_ ->
{:error, :invalid_value}
end
end
defp parse_value("gain", value) do
with {:ok, gain} <- decimal(value) do
if abs(gain) <= @max_gain, do: {:ok, gain: gain}, else: {:error, :out_of_range}
end
end
# `norm:ebu[:I[:TP[:LRA]]]` — omitted targets take the §3.2 defaults.
defp parse_value("norm", value) do
{_default_i, default_tp, default_lra} = @default_norm
case String.split(value, ":") do
["ebu"] ->
{:ok, norm: @default_norm}
["ebu", i] ->
build_norm(i, default_tp, default_lra)
["ebu", i, tp] ->
with {:ok, tp} <- norm_target(tp, @norm_tp_range), do: build_norm(i, tp, default_lra)
["ebu", i, tp, lra] ->
with {:ok, tp} <- norm_target(tp, @norm_tp_range),
{:ok, lra} <- norm_target(lra, @norm_lra_range) do
build_norm(i, tp, lra)
end
_ ->
{:error, :invalid_value}
end
end
# A preset name, not a description of one: the value vocabulary is closed, so
# an unknown name is a 422 here rather than a chain assembled from a URL.
defp parse_value("enhance", value) do
with {:ok, preset} <- enum(value, @enhance_presets), do: {:ok, enhance: preset}
end
defp parse_value("pts", value) do
with {:ok, count} <- bounded_integer(value, @max_peak_count),
do: {:ok, peak_count: count}
end
defp parse_value("pk_fmt", value) do
case Map.fetch(@peak_formats, value) do
{:ok, peak_format} -> {:ok, peak_format: peak_format}
:error -> {:error, :invalid_value}
end
end
defp parse_value("pk_bits", value) do
case Map.fetch(@peak_bits, value) do
{:ok, bits} -> {:ok, peak_bits: bits}
:error -> {:error, :invalid_value}
end
end
# `dl` and `cb` are opaque — still percent-encoded here, and part of the
# cache key verbatim — but control bytes are refused: see @control_char_re.
# The first request option (see "Two classes of option"). A timestamp in the
# past is *valid grammar* — it parses, and the expiry check downstream
# answers 410. Refusing it here would be the wrong error and would also make
# the 410 uncacheable in practice, since a URL's verdict would depend on when
# it was asked rather than on the URL.
defp parse_value("exp", value) do
with {:ok, seconds} <- bounded_integer(value, @max_expires_at),
do: {:ok, expires_at: seconds}
end
defp parse_value("dl", value) do
with {:ok, value} <- opaque(value), do: {:ok, download: value}
end
defp parse_value("cb", value) do
with {:ok, value} <- opaque(value), do: {:ok, cache_buster: value}
end
defp opaque(""), do: {:error, :invalid_value}
defp opaque(value) do
if Regex.match?(@control_char_re, value) do
{:error, :control_character}
else
{:ok, value}
end
end
defp build_norm(i, tp, lra) do
with {:ok, i} <- norm_target(i, @norm_i_range), do: {:ok, norm: {i, tp, lra}}
end
defp norm_target(value, {min, max}) do
with {:ok, number} <- decimal(value) do
if number >= min and number <= max, do: {:ok, number}, else: {:error, :out_of_range}
end
end
defp enum(value, allowed) do
case Enum.find(allowed, &(Atom.to_string(&1) == value)) do
nil -> {:error, :invalid_value}
atom -> {:ok, atom}
end
end
# Bounded above as well as below: `String.to_integer/1` is arbitrary
# precision, so without a ceiling `pts:10000000000` parses happily and
# becomes an allocation two slices later.
defp bounded_integer(value, max) do
if Regex.match?(@non_negative_integer_re, value) do
case String.to_integer(value) do
integer when integer in 1..max//1 -> {:ok, integer}
_out_of_range -> {:error, :out_of_range}
end
else
{:error, :invalid_integer}
end
end
# Decimals are accepted only in plain notation with at most three places;
# exponent forms and longer fractions are rejected rather than rounded, so
# a parsed value always renders back to the characters it came from.
defp decimal(value) do
cond do
Regex.match?(@decimal_re, value) -> {:ok, to_float(value)}
Regex.match?(~r/^-?\d+\.\d+$/, value) -> {:error, :excessive_precision}
true -> {:error, :invalid_number}
end
end
defp non_negative_decimal(value) do
with {:ok, number} <- decimal(value) do
if number < 0, do: {:error, :out_of_range}, else: {:ok, number}
end
end
defp positive(number) when number > 0, do: :ok
defp positive(_number), do: {:error, :out_of_range}
# `-0` is a legal spelling of zero that `Float.parse/1` faithfully turns into
# `-0.0`, and `-0.0 < 0` is false, so it survives every non-negative check.
# It then renders as "0" — meaning `fade:-0` and `fade:0` normalize alike and
# share a cache key — while still being a distinct term to `==` and to
# pattern matching. Collapsing it here, at the one funnel every decimal
# passes through, keeps that distinction from ever reaching a struct.
defp to_float(value) do
{number, ""} = Float.parse(value)
if number == 0.0, do: 0.0, else: number
end
## Validation
defp validate_bitrate_quality(%{bitrate: nil}), do: :ok
defp validate_bitrate_quality(%{quality: nil}), do: :ok
defp validate_bitrate_quality(opts) do
{:error, OptionError.new(render_key("q", opts), :mutually_exclusive, render_key("br", opts))}
end
# `br` and `q` reach ffmpeg as encoder settings, so they only mean something
# to an encoder that has them. `-b:a` on flac or PCM is accepted by ffmpeg
# and ignored, which would hand byte-identical output two cache keys — the
# same trap the peaks rule closes. Peaks fall to validate_peaks_only/1,
# which names the better reason.
defp validate_bitrate_format(%{bitrate: nil}), do: :ok
defp validate_bitrate_format(%{format: :peaks}), do: :ok
defp validate_bitrate_format(%{format: format}) when format in @lossy, do: :ok
defp validate_bitrate_format(opts) do
{:error,
OptionError.new(render_key("br", opts), :requires_lossy_format, render_key("f", opts))}
end
defp validate_quality_format(%{quality: nil}), do: :ok
defp validate_quality_format(%{format: :peaks}), do: :ok
defp validate_quality_format(%{format: format}) when format in @quality_formats, do: :ok
defp validate_quality_format(opts) do
{:error,
OptionError.new(render_key("q", opts), :unsupported_for_format, render_key("f", opts))}
end
defp validate_quality_range(%{quality: nil}), do: :ok
defp validate_quality_range(%{format: :peaks}), do: :ok
defp validate_quality_range(%{format: format, quality: quality} = opts) do
{min, max} = Map.fetch!(@quality_ranges, format)
if quality >= min and quality <= max do
:ok
else
{:error,
OptionError.new(render_key("q", opts), :out_of_range_for_format, render_key("f", opts))}
end
end
defp validate_bit_depth(%{bit_depth: nil}), do: :ok
defp validate_bit_depth(%{format: :peaks}), do: :ok
defp validate_bit_depth(%{format: format}) when format in @lossless, do: :ok
defp validate_bit_depth(opts) do
{:error,
OptionError.new(render_key("bd", opts), :requires_lossless_format, render_key("f", opts))}
end
# A 32-bit float depth has nowhere to go outside wav: flac encodes integers
# only, so `bd:32f/f:flac` would fail in ffmpeg rather than here.
defp validate_bit_depth_format(%{format: :peaks}), do: :ok
defp validate_bit_depth_format(%{bit_depth: :bd32f} = opts)
when opts.format not in @float_bit_depth_formats do
{:error,
OptionError.new(render_key("bd", opts), :unsupported_for_format, render_key("f", opts))}
end
defp validate_bit_depth_format(_opts), do: :ok
# Peaks are computed from the decoded source, so every option that changes
# those samples applies and only those that cannot change the picture are
# refused (§3.3). See `@peaks_unsupported` for which is which and why.
defp validate_peaks_only(%{format: :peaks} = opts) do
case Enum.find(@peaks_unsupported, &render_key(&1, opts)) do
nil ->
:ok
key ->
{:error,
OptionError.new(render_key(key, opts), :unsupported_for_peaks, render_key("f", opts))}
end
end
defp validate_peaks_only(_opts), do: :ok
defp validate_peak_options(%{format: :peaks}), do: :ok
defp validate_peak_options(opts) do
cond do
opts.peak_count -> peaks_only_error("pts", opts)
opts.peak_format -> peaks_only_error("pk_fmt", opts)
opts.peak_bits -> peaks_only_error("pk_bits", opts)
true -> :ok
end
end
defp peaks_only_error(key, opts) do
{:error,
OptionError.new(render_key(key, opts), :requires_peaks_format, render_key("f", opts))}
end
defp validate_sample_rate(%{sample_rate: nil}), do: :ok
defp validate_sample_rate(%{format: format} = opts) when format in @lossy do
if opts.sample_rate > @lossy_sample_rate_cap do
{:error,
OptionError.new(
render_key("sr", opts),
:sample_rate_above_lossy_cap,
render_key("f", opts)
)}
else
:ok
end
end
defp validate_sample_rate(_opts), do: :ok
# A fade-out starts at `duration - out`, so it needs a duration to count
# back from. Without a bounded trim the only way to find one is to probe the
# source, which the argv builder deliberately cannot do — so an unbounded
# fade-out is a 422 here rather than an unbuildable command later. A
# fade-in needs no such thing: it starts at zero.
defp validate_fade(%{fade_out: out, trim_duration: nil} = opts)
when is_float(out) and out > 0 do
{:error,
OptionError.new(render_key("fade", opts), :requires_bounded_trim, render_key("t", opts))}
end
defp validate_fade(%{trim_duration: nil}), do: :ok
defp validate_fade(%{fade_in: nil, fade_out: nil}), do: :ok
# Compared in integer milliseconds, not floats. Parsing pins every value to
# a three-decimal grid, but that grid is not exact in binary: 0.1 + 0.2 is
# 0.30000000000000004, so `t:0:0.3/fade:0.1:0.2` — a fade that exactly fills
# its trim — would be rejected as too long. Milliseconds are exact.
defp validate_fade(opts) do
fade = millis(opts.fade_in || 0.0) + millis(opts.fade_out || 0.0)
if fade > millis(opts.trim_duration) do
{:error,
OptionError.new(render_key("fade", opts), :fade_exceeds_duration, render_key("t", opts))}
else
:ok
end
end
defp millis(seconds), do: round(seconds * 1000)
## Normalization
defp render_key("f", opts), do: "f:" <> Atom.to_string(opts.format)
defp render_key("br", %{bitrate: nil}), do: nil
defp render_key("br", opts), do: "br:" <> Integer.to_string(opts.bitrate)
defp render_key("q", %{quality: nil}), do: nil
defp render_key("q", opts), do: "q:" <> render_number(opts.quality)
defp render_key("sr", %{sample_rate: nil}), do: nil
defp render_key("sr", opts), do: "sr:" <> Integer.to_string(opts.sample_rate)
# Peaks are the one format whose channel count does not follow the source, so
# `ch` materializes here the way `pts` and `pk_fmt` do below: the key has to
# name the mono downmix, or `f:peaks` and `f:peaks/ch:1` would be two keys for
# one set of bytes.
defp render_key("ch", %{format: :peaks} = opts) do
"ch:" <> Integer.to_string(peak_channels(opts))
end
defp render_key("ch", %{channels: nil}), do: nil
defp render_key("ch", opts), do: "ch:" <> Integer.to_string(opts.channels)
defp render_key("bd", %{bit_depth: nil}), do: nil
defp render_key("bd", opts), do: "bd:" <> Map.fetch!(@bit_depth_tokens, opts.bit_depth)
# `t` and `fade` each render from a pair of fields. `parse/1` always sets a
# pair together, but `normalize/1` is public and must stay total: a struct
# built by hand with only one half set renders with the other defaulted to
# zero rather than crashing or silently dropping the half that was set.
defp render_key("t", %{trim_start: nil, trim_duration: nil}), do: nil
defp render_key("t", %{trim_duration: nil} = opts) do
"t:" <> render_number(opts.trim_start || 0.0)
end
defp render_key("t", opts) do
"t:" <>
render_number(opts.trim_start || 0.0) <> ":" <> render_number(opts.trim_duration)
end
defp render_key("fade", %{fade_in: nil, fade_out: nil}), do: nil
defp render_key("fade", opts) do
"fade:" <>
render_number(opts.fade_in || 0.0) <> ":" <> render_number(opts.fade_out || 0.0)
end
defp render_key("gain", %{gain: nil}), do: nil
defp render_key("gain", opts), do: "gain:" <> render_number(opts.gain)
# No default materialization: absent means "no enhancement", which is a
# distinct variant from any preset, so there is nothing to spell out.
defp render_key("enhance", %{enhance: nil}), do: nil
defp render_key("enhance", opts), do: "enhance:" <> Atom.to_string(opts.enhance)
defp render_key("norm", %{norm: nil}), do: nil
defp render_key("norm", %{norm: {i, tp, lra}}) do
Enum.map_join([i, tp, lra], ":", &render_number/1) |> then(&("norm:ebu:" <> &1))
end
# Under f:peaks the defaults materialize. Elsewhere these keys are a
# validation failure, and rendering the value they were given is how the
# error names its own segment — hence the middle clause.
defp render_key("pts", %{format: :peaks} = opts) do
"pts:" <> Integer.to_string(peak_count(opts))
end
defp render_key("pts", %{peak_count: nil}), do: nil
defp render_key("pts", opts), do: "pts:" <> Integer.to_string(opts.peak_count)
defp render_key("pk_fmt", %{format: :peaks} = opts) do
"pk_fmt:" <> Map.fetch!(@peak_format_tokens, peak_format(opts))
end
defp render_key("pk_fmt", %{peak_format: nil}), do: nil
defp render_key("pk_fmt", opts) do
"pk_fmt:" <> Map.fetch!(@peak_format_tokens, opts.peak_format)
end
defp render_key("pk_bits", %{format: :peaks} = opts) do
"pk_bits:" <> Integer.to_string(peak_bits(opts))
end
defp render_key("pk_bits", %{peak_bits: nil}), do: nil
defp render_key("pk_bits", opts), do: "pk_bits:" <> Integer.to_string(opts.peak_bits)
defp render_key("dl", %{download: nil}), do: nil
defp render_key("dl", opts), do: "dl:" <> opts.download
defp render_key("cb", %{cache_buster: nil}), do: nil
defp render_key("cb", opts), do: "cb:" <> opts.cache_buster
@doc """
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 render_number(number()) :: String.t()
def render_number(number) when is_integer(number), do: Integer.to_string(number)
def render_number(number) when is_float(number) do
if number == trunc(number) do
number |> trunc() |> Integer.to_string()
else
number
|> :erlang.float_to_binary(decimals: @max_decimals)
|> String.trim_trailing("0")
# A sub-millisecond value rounds to "0.000", and trimming zeros would
# leave a bare "0." that does not re-parse. Only reachable from a
# hand-built struct — parsing rejects that precision — but normalize/1
# promises its output is always re-parseable.
|> String.trim_trailing(".")
end
end
end