audio_proxy
Transcode audio on demand, from a URL.
Point it at your audio and ask for a variant by URL: a 30-second preview, a mono file for speech-to-text, a normalised podcast MP3, a 24-bit FLAC excerpt. The options are in the path, so one master can serve all of them and you generate none of them in advance. If you know imgproxy, this is that, for audio.
Status: early,
v0.4.0. Transcoding works end to end and you can try it in about a minute. Sources live on a mounted directory or in S3-compatible object storage (see docs/s3-providers.md for which providers are covered); with a variant store configured — local ors3://— completed renders are kept and served back withRangesupport, so a variant is encoded once rather than per request. See the Roadmap.
Quick start
Point it at a directory of audio you already have. No signing key, no bucket, no config file.
docker run --rm -p 4000:4000 \
-e AP_ALLOW_INSECURE=true \
-e AP_LOCAL_ROOT=/audio \
-v /path/to/your/audio:/audio:ro \
ghcr.io/audioproxy/audioproxy:0.4.0
On Apple Silicon, add
--platform linux/amd64. The image is x86-64 only for now and runs under emulation; arm64 is its own slice.
Now ask for a variant, from another shell. SRC names a file relative to the directory you mounted, so track.wav means /path/to/your/audio/track.wav:
BASE=localhost:4000
SRC='plain/local://track.wav'
curl -s "$BASE/health"
# {"status":"ok","version":"0.4.0"}
# A 30-second preview: Opus at 96 kbps, fading in and out.
curl -o preview.opus "$BASE/insecure/f:opus/br:96/t:0:30/fade:1:1/$SRC"
# The same source as a small mono MP3, the shape speech wants.
curl -o speech.mp3 "$BASE/insecure/f:mp3/br:64/ch:1/sr:22050/$SRC"
Both start arriving while ffmpeg is still encoding: the response is chunked, not buffered to disk first. Change any option and you have a different variant, with no server-side configuration to add: the URL is the whole request.
To hear it rather than download it, save this as player.html and open it in a browser:
<audio controls src="http://localhost:4000/insecure/f:mp3/br:128/plain/local://track.wav"></audio>
Playback starts before the render finishes, which is the point. f:mp3 because every browser plays it; Safari will not play Ogg or Opus. For something to poke at rather than a single tag, examples/player.html has presets for every format and shows what the browser makes of the response.
While it renders, the duration reads as unknown and you cannot seek, because a render in progress has no length and nothing to seek into: it is delivered chunked, with no Content-Length and no Accept-Ranges. Once the whole thing has arrived the browser can scrub within what it holds, so on a short file this is barely visible. What you cannot do, at any point, is jump to a position that has not been received — without Accept-Ranges the browser has to fetch everything up to that point first, which on a long file is the difference between seeking and waiting. Range requests need a cached variant with a known size, so with a variant store configured this describes the first play only: every request after it declares a length and seeks normally. See Cache semantics.
Two things that matter beyond a first try:
AP_ALLOW_INSECUREis development only. It is what lets the literalinsecurestand in for a signature, so while it is on, anyone who can reach the port can render anything underAP_LOCAL_ROOT. Signing URLs is the real thing.- Mount the directory read-only (
:ro, above). Write access toAP_LOCAL_ROOTis write access to what the proxy will serve.
Running it has the shape you would actually deploy.
What you can do with it
The path is /{signature}/{options}/{source}. These examples show the range, using the literal insecure in place of a signature as the Quick start did.
BASE=localhost:4000
SRC='plain/local://piece.wav' # AP_LOCAL_ROOT/piece.wav
# A 30-second preview: Opus at 96 kbps, starting 12.5 s in,
# half-second fade in and one-second fade out.
curl "$BASE/insecure/f:opus/br:96/t:12.5:30/fade:0.5:1/$SRC"
# Waveform peaks to draw a player UI, 800 min/max pairs as JSON.
curl "$BASE/insecure/f:peaks/pts:800/$SRC"
# The same peaks in the compact binary form, which is what you want
# once the pair count gets large.
curl -o peaks.dat "$BASE/insecure/f:peaks/pts:4000/pk_fmt:dat/$SRC"
# Speech, small: 64 kbps mono MP3 at 22.05 kHz.
curl "$BASE/insecure/f:mp3/br:64/ch:1/sr:22050/$SRC"
# Normalised to −16 LUFS for podcast delivery.
curl "$BASE/insecure/f:mp3/br:128/norm:ebu/$SRC"
# Two minutes of 24-bit FLAC, offered to the browser as a download.
curl -OJ "$BASE/insecure/f:flac/bd:24/t:60:120/dl:excerpt.flac/$SRC"
# Mono 16 kHz WAV, the shape a speech-to-text pipeline wants.
curl "$BASE/insecure/f:wav/ch:1/sr:16000/$SRC"
# What is this file? (ffprobe metadata, as JSON)
curl "$BASE/insecure/info/$SRC"
Each URL describes its output completely, so the same URL always means the same bytes. The first request for a variant renders it and streams it while it encodes; later requests are served from the variant bucket with Range support.
Which of these work today? All of them, in the
0.4.0image above. Every option string here is checked against the parser by the test suite, so none of them are aspirational spellings.
Roadmap
No dates. It is built in small releases, each one usable, in roughly this order.
Working now (v0.4.0)
- Signed URLs, the full processing-options grammar, and the cache-key rules
- Transcoding to MP3, AAC/M4A, Opus, Vorbis, FLAC and WAV, with trimming, fades, loudness normalisation, channel and sample-rate control
- Renders stream while they encode
- Concurrent requests for the same variant share one render, with mid-render joiners catching up from the start
- Sources on a mounted directory or in S3: ffmpeg reads the object through a presigned URL, so a trim fetches only the bytes it needs, and an unreachable store answers
502rather than a404that would report a deletion that did not happen - A variant store on a local directory or in S3, so the cache survives a restart and is shared between nodes — and with it
AP_SERVE_MODE=redirect, where a hit answers302to a short-lived storage URL and the proxy leaves the hot path entirely f:peaks, waveform min/max data in audiowaveform's JSON and binary formats, cached like any other variant- A cap on simultaneous renders with a bounded wait queue, so a burst queues (and then sheds, with
Retry-After) instead of thrashing the machine;ffproberuns under a bound of its own GET /info, giving duration, sample rate, channels and tags, so clients can size their variant URLs to the sourceGET /ready, reporting queue depth with hysteresis, so a busy node leaves the pool without being restarted- A Prometheus
GET /metricsendpoint on a bind-restricted listener of its own, reporting queue depth, render durations, cache hit ratio and error rates. See Metrics - Video input refused rather than transcoded, enforced rather than intended
- A single container, published per release — and the package on hex, for embedding it in a release of your own
After that
- HTTPS sources, for stores that are not S3
- arm64 images, so Graviton/Ampere and Apple Silicon run natively
Under consideration
Seeking on a first play. The cache makes every request after the first one range-capable; the first still streams, so it can only be scrubbed within what has already arrived. A
/sync/{signature}/{options}/{source}URL would render fully before responding and hand back something seekable — trading time-to-first-byte for a working scrubber, and chosen by whoever writes thesrcrather than by the browser. That last point is what makes it possible at all: a browser cannot signal the intent itself, since it sendsRange: bytes=0-on every first media request, so keying offRangewould make all playback wait for a full render.Whether it gets built is still open, because warming the cache does the same job for nothing: fetch the URL once, discard it, then set
src, and the second request seeks normally.
Deliberately not planned
- Video. This is an audio proxy and refuses video input rather than becoming a general ffmpeg gateway; video transcoding is far more expensive and carries most of ffmpeg's CVE history. Refusing it is now enforced rather than intended — see The source must be audio.
Wanted, but not designed yet
- HLS and segmented streaming. A v2 goal rather than a rejected one: the URL space is reserved, and segmented output is the honest answer to mid-stream render failure, which plain chunked HTTP cannot signal. A segment is close to a variant with a trim, so signing, cache keys and deduplication carry over unchanged. The unsolved part is gapless boundaries, since encoding each segment independently gives each one its own encoder priming.
0.x means the URL contract can still change. It will settle at 1.0, after which a change to what an existing URL means, or to how cache keys are derived, is a major version. The per-slice detail, including rationale and trade-offs, lives in openspec/changes/.
Design
Sources live on a mounted directory, in S3, or in any HTTP-reachable store. Variants (transcodes, trimmed previews, waveform peaks) are rendered on demand by ffmpeg, streamed to the first requester as they encode, and teed to a variant bucket, so later requests for the same variant redirect to object storage and get Range support and byte-serving for free.
URLs are the entire API: no request bodies, no server-side state. Every variant is fully described by its processing options, which double as its cache key, and every URL is signed.
GET /{signature}/{options}/{source}
That is the design. The Roadmap says which parts of it exist today.
docs/audio-proxy-api-v1.md is the source of truth for the URL grammar, processing options, cache-key rules, response headers, and error codes. What follows here is the working subset; reach for the spec when you need the exact contract.
Running it
The container is the way to run this. It carries the release with its own Erlang runtime and the ffmpeg the renders are tested against, so there is nothing to install and nothing to keep in step.
The Quick start above runs it unsigned, for a first look. The difference in a real deployment is that AP_ALLOW_INSECURE is gone and a key and salt take its place:
docker run --rm -p 4000:4000 \
-e AP_KEY="$AP_KEY" -e AP_SALT="$AP_SALT" \
-e AP_LOCAL_ROOT=/audio \
-e AP_VARIANT_STORE=file:///var/cache/audio_proxy \
-e AP_SERVE_MODE=proxy \
-v /path/to/your/audio:/audio:ro \
-v audioproxy-cache:/var/cache/audio_proxy \
ghcr.io/audioproxy/audioproxy:0.4.0
That is the whole configuration for serving files off a mounted directory, with completed renders cached on a named volume: no credentials, no bucket, no database. Drop the two AP_VARIANT_* lines and it still works — every request just renders. The store is unbounded; the volume is yours to watch.
Pin a version.:0.4.0 and :sha-<commit> name an exact image; :0.4 follows patch releases; :latest and :edge move under you, and :edge is whatever last landed on main. Pinning matters more here than for most services, because a different ffmpeg encodes the same URL to different bytes, which is also why a pin bump always cuts a release. The pinned versions are in VERSIONS.md.
To run it from a checkout instead, for development or to build your own image:
mise install # Elixir and Erlang/OTP, pinned in .tool-versions
mix deps.get
PORT=4000 mix run --no-halt
That path needs ffmpeg and ffprobe on PATH. For a development container that already has them, and for the test suite, see docs/development.md.
Embedding it in your own release
The proxy is also published to hex as an OTP application, so it can run inside a BEAM node you already deploy rather than as a container of its own:
# mix.exs
{:audio_proxy, "~> 0.4"}
Know what starting it does before you add it.audio_proxy is an application, not a library: it has no API you call, and adding the dependency is the whole integration. When your release starts it, it
- reads and validates the
AP_*environment and refuses to boot on an invalid value, so a badAP_KEYor an unreadableAP_LOCAL_ROOTfails the node's start-up rather than the first request. See Configuration; there is nothing to put inconfig/*.exs, because none of it is read from there. - binds two listeners: the proxy on
AP_PORT(falling back toPORT, so it will contend with whatever else in the node reads that), and/metricsonAP_METRICS_PORT, bound to loopback. Ports are yours to keep distinct from the rest of the node's. - expects
ffmpegandffprobeonPATH, in the image your release ships. The proxy's own container carries a pinned pair (VERSIONS.md); an embedding one has to, too, and a different ffmpeg encodes the same URL to different bytes.
That is the contract, and it is the intended one: an embedder wants the proxy serving, not a set of functions. If you need the code without the listener, the honest answer today is to run the container.
API documentation is on hexdocs, and the package carries llms.txt and llms-full.txt so the full URL contract is in deps/audio_proxy/ at the version you depend on.
Signing URLs
Every URL is signed: the first path segment is base64url(HMAC-SHA256(key, salt ‖ rest-of-path)), computed over the exact bytes after the signature segment, leading / included, taken from the raw (still percent-encoded) request path. Key and salt are the hex-decoded values of AP_KEY/AP_SALT. Signatures are emitted unpadded; the canonical padded form is accepted on verification, but non-canonical spellings (over-padding, variant final characters) are rejected, so a signature cannot be respelled.
AudioProxy.Signature.sign/3 is the reference signer:
key = Base.decode16!("00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF") # AP_KEY
salt = Base.decode16!("FFEEDDCCBBAA99887766554433221100") # AP_SALT
rest = "/f:opus/br:96/plain/s3://masters/2026/piece-final.wav"
AudioProxy.Signature.sign(rest, key, salt)
# => "zfLTfPPhQ8kdeYYJOdagqPfog2nFk7KzDFUjtRAf_Ns"
→ the URL is /<signature><rest>, i.e. /zfLTfPPhQ8kdeYYJOdagqPfog2nFk7KzDFUjtRAf_Ns/f:opus/br:96/plain/s3://masters/2026/piece-final.wav.
Sign the URL-escaped path exactly as it will be requested. Verification runs over the raw request path, so re-encoding anything breaks the signature: a key with a space must appear as a%20track.wav in both the signature input and the request (or use the enc/ source form, which exists precisely to avoid escaping headaches). For non-Elixir clients, the same three lines in Ruby:
require "openssl"
require "base64"
key = "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF" # AP_KEY (hex)
salt = "FFEEDDCCBBAA99887766554433221100" # AP_SALT (hex)
path = "/f:opus/br:96/plain/s3://masters/2026/piece-final.wav"
sig = Base64.urlsafe_encode64(
OpenSSL::HMAC.digest("SHA256", [key].pack("H*"), [salt].pack("H*") + path),
padding: false
)
# => "zfLTfPPhQ8kdeYYJOdagqPfog2nFk7KzDFUjtRAf_Ns"
Generate a real key with openssl rand -hex 32. AP_KEY must decode to at least 32 bytes or the proxy refuses to boot. The key above is a published test vector used throughout the test suite; never use it as a real key.
Dev mode, never enable in production. Setting
AP_ALLOW_INSECURE=truemakes the literal segmentinsecurepass as a signature (/insecure/f:opus/…/plain/…). It exists for local development and smoke tests; with it on, anyone who can reach the proxy can render anything.
Rendering a variant
End to end, with a directory of your own audio and a real signature:
export AP_KEY=$(openssl rand -hex 32)
export AP_SALT=$(openssl rand -hex 16)
export AP_LOCAL_ROOT=/path/to/your/audio
PORT=4000 mix run --no-halt &
Sign the path (everything after the signature segment, leading / included) and request it:
REST='/f:mp3/br:128/t:0:30/plain/local://piece.wav'
SIG=$(ruby -ropenssl -rbase64 -e '
print Base64.urlsafe_encode64(
OpenSSL::HMAC.digest("SHA256", [ENV["AP_KEY"]].pack("H*"),
[ENV["AP_SALT"]].pack("H*") + ARGV[0]),
padding: false)' "$REST")
curl -D - -o preview.mp3 "localhost:4000/$SIG$REST"
HTTP/1.1 200 OK
transfer-encoding: chunked
content-type: audio/mpeg
cache-control: public, max-age=31536000, immutable, no-transform
etag: "6f1c…"
x-audio-proxy: MISS
The first bytes leave before ffmpeg has finished, so a long transcode starts playing immediately rather than after it completes. There is no Content-Length and no Accept-Ranges on this response: the length is not known when the head goes out. A request for the same variant once it is cached is framed differently, and that difference is worth reading before you write a client against either shape (Cache semantics).
x-audio-proxy says where the bytes came from. MISS is this request's own render. COALESCED means another request was already rendering exactly this variant and this one attached to it: the same bytes, no second ffmpeg. HIT means it came from the variant store and nothing was rendered at all. etag is the variant's cache key, which is a pure function of the normalized options and the source, so the same variant requested with its options in a different order carries the same one.
Requests for the same variant share a render, so a burst — a page that loads the same preview for fifty visitors at once — costs one encode rather than fifty. A request arriving mid-render is sent everything encoded so far, then the rest as it comes, so it gets the whole file and not the tail. A shared render is held in memory for as long as it runs, and output past AP_MAX_VARIANT_BYTES fails rather than growing without limit.
If the client goes away mid-stream, the render goes with it, unless someone else is still listening to the same one: closing the last connection kills the ffmpeg process rather than leaving it encoding into a socket nobody is reading. A render that fails before any bytes are sent is one of the JSON errors below; one that fails after them can only be signalled by cutting the connection short, so treat a chunked response that ends without its terminating chunk as a failed download.
At most AP_MAX_CONCURRENCY renders run at once. A request that needs one when they are all busy waits its turn, up to AP_QUEUE_SIZE of them; past that, the answer is a 429 with Retry-After rather than a machine with more encoders on it than cores. Waiting is invisible from the client's side — the response simply starts later — and requests that share a render share its slot, so the cap counts encodes rather than connections. A request that waits longer than AP_RENDER_TIMEOUT for a slot gets the same 429: the queue could not reach it in time, which is the client's cue to come back, not a failed render.
For what happens behind that (the subprocess, coalescing, slots, buffering, the timeout and the kill discipline) see docs/rendering.md.
Asking what a source is
info sits where the options go and answers with the source's own metadata, so a client can size a request to the file before making it — a preview URL that trims past the end of a track is an easy mistake to make blind.
REST='/info/plain/local://piece.wav'
curl -D - "localhost:4000/$SIG$REST"
HTTP/1.1 200 OK
content-type: application/json
cache-control: public, max-age=3600
etag: "a1f3…"
{"format":"wav","duration":184.32,"sample_rate":48000,"channels":2,
"bit_depth":16,"bitrate":1536000,"size":35389532,
"tags":{"title":"Sea Change","artist":"…"}}
| Field | Meaning |
|---|---|
format | The f: token this source would be, not the container's internal name: an MP4 is m4a, and Ogg is opus or ogg depending on what is inside it. A source in a container the proxy cannot itself produce is named plainly (matroska) rather than forced into a token |
duration | Seconds, as a float |
sample_rate, channels | The source's own, which is what a variant inherits when you leave sr or ch off |
bit_depth | Lossless sources only |
bitrate | Bits per second |
size | Bytes, from storage |
tags | Whatever the file carries — title, artist and the rest — as strings |
A field the source cannot answer is left out, never null. A lossy source has no bit_depth, so the key is simply absent; an untagged file has no tags. Test for the key, not for a value.
info takes no processing options: it describes the source, not a variant, so /info/br:128/… is a 422. A source with no audio in it at all — a video-only MP4, a text file — is a 415, and so is one that has both audio and video: the audio-only rule applies here too, so /info will not describe a video file for you. Cover art is not video.
Responses carry an ETag derived from the source object, so a client or CDN that has seen this metadata before revalidates for the price of a 304. Cache-Control is one hour rather than the year a rendered variant gets, and deliberately not immutable: a variant's URL describes its bytes exactly and can never go stale, while this describes a file somebody may re-upload tomorrow.
Probing is cheap — it reads the file's header and stops — so it does not queue behind renders and has its own, shorter, AP_PROBE_TIMEOUT. For the same reason AP_MAX_SRC_BYTES does not apply here: a source too large to render can still be described, which is what you want, since the long file is exactly the one you were going to ask for a trimmed preview of.
Cheap is not free, though, so probes have a ceiling of their own: AP_MAX_PROBE_CONCURRENCY, separate from AP_MAX_CONCURRENCY so that neither pool can starve the other. Requests reading the same source share one probe — several /info calls, several renditions, or an /info alongside a render — so the ceiling is reached by traffic naming many different sources at once. When it is, the answer is a 429 with Retry-After, the same as a full render queue; come back and there will almost certainly be room, since a probe lasts milliseconds.
Processing options
The options segments describe the variant completely, and their normalized form is the cache key. AudioProxy.Options parses, validates, and normalizes them; AudioProxy.CacheKey hashes the result. Invalid or conflicting options are rejected with an AudioProxy.OptionError naming the offending segment, which the HTTP layer will render as a 422.
{:ok, opts} = AudioProxy.Options.parse("f:opus/br:96/t:12.5:30/fade:0.5:1")
AudioProxy.Options.normalize(opts)
# => "br:96/f:opus/fade:0.5:1/t:12.5:30"
AudioProxy.CacheKey.derive!("br:96/f:opus", "s3://masters/piece.wav")
# => "00d89ba1cbfecacd4450ae5ca912f7153f4740bb5e81d96609f6bfdfbfde4099"
Supported options
| Key | Value | Notes |
|---|---|---|
f | mp3opusoggaacm4aflacwavpeaks | Output format; default mp3 |
br | positive integer, kbps | CBR/ABR bitrate; excludes q |
q | number | VBR quality; excludes br |
sr | positive integer, Hz | Resample; default is the source rate. An explicit sr above 48 kHz is rejected for lossy formats; capping the default for lossy sources is the renderer's job (§3.1) |
ch | 1 | 2 | Downmix |
bd | 16 | 24 | 32f | Bit depth, lossless formats only |
t | start[:duration], seconds | Trim. t:30 runs to the end; t:30:15 is 15 s from 30 s |
fade | in[:out], seconds | Applied inside the trimmed region; an omitted out-fade is 0 |
gain | signed number, dB | Static gain |
norm | ebu[:I[:TP[:LRA]]] | Loudness normalization; targets default to -16:-1.5:11. v1 runs loudnorm single-pass, good enough for previews but not for masters (§3.2) |
pts | positive integer | Peaks: number of min/max pairs; default 800 |
pk_fmt | json | dat | Peaks: output encoding; default json |
ch under f:peaks | 1 | 2 | Peaks default to mono rather than following the source; ch:2 gives per-channel pairs |
dl | filename | Sets Content-Disposition: attachment |
cb | opaque string | Cache-buster; participates in the cache key |
Decimals are accepted to three places (millisecond precision) and rejected beyond that rather than silently rounded, so float formatting can never destabilize a cache key. -0 is collapsed to 0 at parse time: it renders as 0, so it cannot be told apart in a cache key, and letting it into the struct would let it be told apart in the ffmpeg arguments. dl and cb values stay percent-encoded and are treated as opaque bytes.
Validation rules
Beyond each key's own value domain, these cross-key rules are enforced:
brandqare mutually exclusive.brrequires a lossy format;qrequires a format whose encoder has a quality scale (everything butwav).bdrequires a lossless format (flac,wav), andbd:32frequiresf:wav, since flac encodes integer samples only.qmust sit inside its codec's scale: mp3 0–9, ogg −1–10, aac/m4a 0.1–2, opus 0–10, flac 0–12.f:flac/q:13is refused by ffmpeg itself ("invalid compression level"), so refusing it here turns a 500 into a 422.ptsandpk_fmtrequiref:peaks.sris capped at 48 kHz for lossy formats.- A fade must fit inside the trimmed region when the trim is bounded, and a fade-out requires a bounded trim at all: its start is
duration - out, and without a duration there is nothing to count back from. A fade-in needs no trim, since it starts at zero.
Peaks add one more: f:peaks refuses br, q, sr, bd, gain and norm. Peaks are computed from the decoded source and respect only t, ch and fade (§3.3), so accepting an option that cannot change the output would hand byte-identical peaks two different cache keys. f:peaks/br:96 is a 422.
f:peaks is also the one format whose ch default is not "follow the source": with no ch it downmixes to mono, because a waveform UI draws one shape. That default is materialized into the cache key, so f:peaks and f:peaks/ch:1 are one variant rather than two.
Waveform peaks
f:peaks returns audiowaveform-compatible data, so it drops straight into peaks.js and the rest of that ecosystem. There was no reason to invent a schema.
pk_fmt:json (the default) is Content-Type: application/json:
{
"version": 2,
"channels": 1,
"sample_rate": 44100,
"samples_per_pixel": 5513,
"bits": 16,
"length": 800,
"data": [-31904, 31810, -32210, 32026, "…1596 more"]
}
data holds length × 2 × channels signed 16-bit integers: a minimum and a maximum per pixel, per channel, interleaved. pk_fmt:dat is the same numbers as application/octet-stream — audiowaveform's binary layout, a little-endian header of version, flags, sample rate, samples-per-pixel, length and channel count, then the pairs as int16. It is roughly a fifth the size of the JSON, which starts to matter past a few thousand pairs.
Drawing them with peaks.js is the URL and nothing else:
Peaks.init({
container: document.querySelector('#waveform'),
mediaElement: document.querySelector('audio'),
dataUri: {
json: `${BASE}/${sig}/f:peaks/pts:800/plain/local://piece.wav`,
// or, for the binary form:
// arraybuffer: `${BASE}/${sig}/f:peaks/pts:800/pk_fmt:dat/plain/local://piece.wav`
},
})
Pick pts to match the pixel width you will draw at. t narrows the region the peaks span, so f:peaks/t:30:15 describes the same fifteen seconds a f:mp3/t:30:15 preview does, and peaks are cached and range-served exactly like audio variants are.
The rules about br, q and bd:32f come from the same principle applied one layer down: ffmpeg accepts -b:a on a flac encode and ignores it, so f:flac/br:320 would be two cache keys for one file. Rejecting is cheaper than storing the duplicate.
Unknown keys, repeated keys, empty segments, and valueless segments are rejected too. There is no last-write-wins and no silent ignoring, because either would let two different URLs mean the same variant. Values are also bounded above (br ≤ 10000, sr ≤ 384000, pts ≤ 100000, |gain| ≤ 100) so a mistyped URL fails here as a 422 rather than downstream as a render error, and dl/cb reject control characters. That last rule is what makes the cache key's separator sound (see below).
Cache-key semantics
lowercase-hex(SHA-256(normalized-options ‖ "\n" ‖ canonical-source))
Normalization is what makes this deterministic: keys are sorted lexicographically, applicable defaults are materialized (f, the norm targets when norm is present, pts/pk_fmt under f:peaks), and every number is rendered minimally (30, never 30.0). So f:opus/br:96 and br:96/f:opus are one variant with one key, while any genuine difference, cb included, yields a different one. The "\n" is load-bearing: without it, ("", "/gain:3") and ("gain:3", "") would hash identical bytes, which is why control characters are refused in the only two options whose values are opaque.
Normalization is syntactic, not semantic: t:0, fade:0:0 and gain:0 are identity renders but keep their own keys, so those spellings cost duplicate cache objects. Collapsing them is tracked as follow-up work. The property suite (test/audio_proxy/options_property_test.exs) holds this line: normalization is idempotent, order-insensitive, and always re-parses.
Sources
The last portion of the path names what to render, in one of two forms:
| Form | Example |
|---|---|
plain/{source} | plain/s3://masters/2026/piece-final.wav |
enc/{base64url(source)} | enc/czM6Ly9tYXN0ZXJzLzIwMjYvcGllY2UtZmluYWwud2F2 |
Both name the same thing and produce the same cache key. enc/ exists because escaping a URL inside a URL is easy to get wrong: base64url the source as written and you are done.
In the plain/ form the source is percent-escaped, and it is unescaped exactly once. A space is %20, a literal percent is %25, and + is a literal plus. One consequence to watch: a source that already carries escapes has to be escaped again, so a URL ending in a%20b.wav is written plain/https://h/a%2520b.wav. Sign the source in the same spelling you request it in, because the signature covers the raw path.
The source must be audio
A source containing video is refused with a 415 (video_source), whatever variant was asked for. The audio track is not extracted: this is an audio proxy, and pointing it at a video library gets you an error rather than a slow, expensive render. Embedded cover art is not video — the tagged mp3s, flacs and m4a files in a normal catalogue render exactly as they always did.
The rule covers /info as well, so there is no endpoint that will describe a video file for you either. One consequence worth knowing while you are building URLs: a video source answers 415 on a GET but 200 on a HEAD of the same URL, because deciding costs a probe and HEAD does not run one (see Caching and CDNs).
Upgrading onto this rule: the check runs before a render, not before a cache hit — a hit is immutable bytes that were already rendered. So a variant store populated before this rule existed keeps serving whatever it holds, including audio that was extracted from a video source back when that was allowed, for the full year its Cache-Control claims. If that matters to you, purge the variant store (and the CDN in front of it); nothing else reaches those bytes.
local://: files under a configured root
local://{path} serves a path relative to AP_LOCAL_ROOT. Mount the directory you want served, read-only, and point the proxy at it:
docker run -p 4000:4000 \
-e AP_ALLOW_INSECURE=true \
-e AP_LOCAL_ROOT=/srv/audio \
-v /path/to/your/audio:/srv/audio:ro \
ghcr.io/audioproxy/audioproxy:0.4.0
# …renders /srv/audio/previews/track.wav
curl "$BASE/insecure/f:mp3/br:128/plain/local://previews/track.wav"
Unset AP_LOCAL_ROOT and local sources are refused outright: the root is the whole access-control story for disk. Mount it read-only. Write access to the root is equivalent to choosing what the proxy will serve.
docs/sources.md has the confinement rules, the path limits, and why the root never appears in a cache key.
s3:// and https://: remote sources
s3://{bucket}/{key} names an object in a bucket; https://{host}/{path} names a URL at an origin. Both need AP_SOURCE_ALLOWLIST to say which namespaces you serve:
| Entry | Matches |
|---|---|
masters | Exactly that bucket (case-sensitive) or host (case-folded) |
previews-* | Buckets beginning previews- |
*.media.example | media.example and any subdomain of it |
* | Everything |
The wildcards are asymmetric on purpose. A bucket namespace is yours, so a prefix glob gives nothing away; a host namespace is anyone's, so hosts take the mirror image, anchored to a label boundary — *.media.example accepts cdn.media.example and refuses media.example.evil.com. There is deliberately no host prefix glob: cdn.* reads as "our CDN" but would mean any host starting cdn., cdn.evil.com included, so it matches nothing at all.
Leave AP_SOURCE_ALLOWLIST unset and HTTPS sources are refused outright, while S3 sources are accepted — your bucket credentials are already the gate there, and a URL has no such backstop. An allowlisted host is trusted by definition: the proxy will fetch what it is pointed at, so point it at origins you control.
http:// and URLs carrying credentials (https://user:pass@…) are refused whatever the allowlist says.
s3:// renders and describes; https:// does not yet. An s3:// source is HEADed for its size and ETag and then handed to ffmpeg as a presigned URL, so ffmpeg ranges the object directly and no source bytes pass through the proxy — which is what makes t:0:30 on a two-hour master read only the seconds it needs. It needs the AWS_* credentials under Configuration. An https:// source still parses, canonicalizes and authorizes but answers the same blind 404 as a missing one, until add-https-source-backend lands.
AP_PRESIGN_TTL bounds the URL, not the read: ffmpeg has to open the object within the TTL, and the connection it opens outlives it. A long transcode does not need a long TTL.
A store that is unreachable answers 502, not 404 — an outage says nothing about whether your object is there. A bucket policy that refuses the proxy's credentials does answer 404, deliberately indistinguishable from a missing object; the log line names access_denied, so that is where an operator debugging a policy should look.
See docs/sources.md for the URL normalization rules, the limits, and the full allowlist grammar.
Errors
Failures are JSON, one shape everywhere: {"error": "…", "message": "…"}.
| Status | error | When |
|---|---|---|
401 | invalid_signature | Missing or invalid signature |
404 | not_found | The source is missing, unreadable, unparseable, or not one this proxy may serve, deliberately indistinguishable, so a 404 tells you nothing about what exists on disk |
413 | source_too_large | The source exceeds AP_MAX_SRC_BYTES. Renders only — /info describes a source of any size |
415 | undecodable_source | The source format is not decodable, or — on /info — carries no audio at all |
415 | video_source | The source contains a video stream, and this proxy serves audio only. Cover art is not video. See Sources |
422 | invalid_options | Invalid or conflicting options; the message names the offending segment |
429 | queue_full | A pool is full: either the render queue (or this request waited longer than AP_RENDER_TIMEOUT for a render slot), or AP_MAX_PROBE_CONCURRENCY probes are already running. Retry-After is set either way, and the two are deliberately indistinguishable to a client |
500 | render_failed | The render failed for a reason that is not yours: no encoder on the host, no disk space, a failure the proxy could not classify. Worth retrying |
500 | probe_failed | A probe failed for a reason that is not yours. Worth retrying. Both endpoints probe — /info is a probe, and a render runs one first to check the source is audio |
500 | not_configured | The storage backend the source names is misconfigured — no credentials, or a region/endpoint that is not the object's, which the store answers with a redirect. An operator has to fix it; retrying will not |
502 | upstream_unavailable | The storage backend could not be reached — it answered 5xx, or nothing at all. Says nothing about whether your object exists, and is no-store for that reason. Worth retrying |
504 | render_timeout | A render started and then exceeded AP_RENDER_TIMEOUT. Time spent waiting for a slot is a 429, not this |
504 | probe_timeout | A probe exceeded AP_PROBE_TIMEOUT. On a render URL this means the audio-only check ran out of time before any encoding started — raise AP_PROBE_TIMEOUT, not AP_RENDER_TIMEOUT |
A failure after the response has begun is not in this table and cannot be: see Rendering a variant.
Configuration
All configuration comes from AP_-prefixed environment variables. See docs/audio-proxy-api-v1.md §6 for intent and AudioProxy.Config for parsing. Values are read, typed, and validated once at boot; a malformed value aborts startup with an error naming the variable.
Note that the variables below are the full configuration surface for the design, so several of them are parsed and validated but not yet consumed by anything.
| Variable | Type | Default | Purpose |
|---|---|---|---|
AP_KEY | hex, ≥ 32 bytes decoded | unset | HMAC key for URL signatures |
AP_SALT | hex | unset | HMAC salt |
AP_ALLOW_INSECURE | boolean | false | Accept unsigned URLs (dev only) |
AP_SOURCE_ALLOWLIST | comma-separated | empty | Permitted buckets and hosts for s3:// and https:// sources. Empty accepts every bucket and refuses every host — see Sources |
AP_LOCAL_ROOT | existing directory | unset | Root for local:// sources; unset disables them. Must exist at boot, and may not be / |
AP_VARIANT_STORE | URL (file:///path, s3://bucket) | unset | Where rendered variants are written back; unset = no cache, always render. Probed for writability at boot. See Variant store |
AP_MAX_CONCURRENCY | positive integer | schedulers online | Max simultaneous ffmpeg processes. Requests that share a render share its slot, so this counts encodes, not connections |
AP_MAX_PROBE_CONCURRENCY | positive integer | 4 × AP_MAX_CONCURRENCY | Max simultaneous ffprobe processes. A ceiling of its own so a probe never queues behind a render; requests for the same source share a probe, so this counts probes, not connections. Raise it if you serve many distinct sources at once and see 429s while ffmpeg is idle |
AP_QUEUE_SIZE | non-negative integer | 32 | Requests that may wait for a render slot before the next one is answered 429 with Retry-After. 0 means no waiting at all |
AP_READY_QUEUE_THRESHOLD | non-negative integer, ≤ AP_QUEUE_SIZE | half AP_QUEUE_SIZE, rounded down (min 1) | Queue depth at which /ready answers 503; it recovers again at half the threshold, rounded down. 0 disables the check, which is what a single node wants. See Health and readiness |
AP_MAX_SRC_BYTES | positive integer | 2000000000 | Reject larger sources with 413, before any render starts. Does not apply to /info, which only reads headers |
AP_MAX_VARIANT_BYTES | positive integer | the effective AP_MAX_SRC_BYTES | Cap the bytes one render may hold in memory; output past it kills the render and fails the request mid-stream. Set it below AP_MAX_SRC_BYTES to accept large sources while producing small outputs. Raising it does not buy capacity — it bounds one render, so every concurrent slot may reach it; the lever for the total is AP_MAX_CONCURRENCY. See docs/capacity.md |
AP_RENDER_TIMEOUT | positive integer | 300 | Seconds a render may take before ffmpeg is killed and the request answered 504. Raise it for full-length transcodes of long masters; the default suits previews. See docs/rendering.md |
AP_PROBE_TIMEOUT | positive integer | 10 | Seconds an /info probe may take before ffprobe is killed and the request answered 504. Separate from AP_RENDER_TIMEOUT, and much shorter: a probe reads the file's header rather than decoding it. See Asking what a source is |
AP_SERVE_MODE | redirect | proxy | redirect | Serve cache hits by redirect or proxied. See Choosing a serve mode |
AP_PRESIGN_TTL | positive integer | 300 | Seconds a cache hit's presigned URL stays valid. Redirect mode only |
AP_LOG_LEVEL | debug | info | warning | error | info | Lowest level written to stdout. See Logs |
AP_METRICS_BIND | IP address literal | 127.0.0.1 | Interface the /metrics listener binds. The endpoint is unsigned, so this is its access control — widen it deliberately. A hostname is refused. See Metrics |
AP_METRICS_PORT | positive integer | 9568 | Port for the /metrics listener. Must differ from the listener port (AP_PORT, or PORT) — two listeners cannot share one, and the clash is refused at boot rather than left to an :eaddrinuse naming neither |
AP_S3_ENDPOINT | origin URL | unset | Talk to an S3-compatible store instead of AWS. See S3 credentials and docs/s3-providers.md |
AP_S3_ADDRESSING | virtual | path | virtual with no AP_S3_ENDPOINT, path with one | Whether a request names its bucket in the host (bucket.host/key) or in the path (host/bucket/key). Tigris requires virtual; see docs/s3-providers.md |
AP_S3_CA_BUNDLE | path to a PEM file | unset | Verify the store's certificate against this bundle instead of the system trust store, for a store behind a private CA. Must be readable at boot |
Booleans accept 1/true/yes/on and 0/false/no/off, case-insensitively. An empty value counts as unset.
The listener port is read from AP_PORT, then PORT, then 4000.
Sizing the container.AP_MAX_CONCURRENCY and AP_MAX_VARIANT_BYTES are the two variables that decide how much memory a container needs, and the answer is a lookup rather than a guess: a render's output is held in memory until the render ends, so a full-length transcode costs its own size while a source of any length costs nothing. docs/capacity.md opens with a matrix — find your output length and format in a row and your memory limit in a column, and read the largest safe AP_MAX_CONCURRENCY — plus the reverse table, for a concurrency you have already fixed. The formula, the measured per-format costs and the worked examples follow it as the derivation, including why full-length lossless output is refused by design. Read it before serving anything longer than a preview.
S3 credentials
S3 access is the one thing configured with standard AWS variables rather than AP_-prefixed ones, because every tool that produces credentials already writes those names:
| Variable | Required | Purpose |
|---|---|---|
AWS_ACCESS_KEY_ID | with the others | Access key |
AWS_SECRET_ACCESS_KEY | with the others | Secret key |
AWS_REGION (or AWS_DEFAULT_REGION) | with the others | Signing region — part of every signature, so there is nothing safe to guess |
AWS_SESSION_TOKEN | no | For temporary credentials |
They are validated as a group at boot: all three, or none. Half a credential signs nothing, and a container that starts and then fails its first S3 request is worse than one that does not start.
Credentials come from the environment only. There is no IMDS or STS lookup, so an EC2/EKS instance role does not work — supply keys. That is a known limitation, not an oversight.
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… AWS_REGION=eu-central-1 …
AP_S3_ENDPOINT points at something other than AWS — MinIO, localstack, or any S3-compatible store:
AP_S3_ENDPOINT=http://minio:9000 …
It takes an origin and nothing else: a path, query, fragment, or embedded credentials (http://key:secret@minio:9000) are all refused at boot. Credentials especially — they belong in the AWS_* variables, and accepting them here would ignore them silently.
Setting it also switches the addressing default from virtual-hosted (bucket.s3.region.amazonaws.com, what AWS requires) to path-style (endpoint/bucket/key, what most S3-compatible stores expect). Override either default with AP_S3_ADDRESSING, which some providers need — Tigris accepts only virtual-hosted:
AP_S3_ENDPOINT=https://fly.storage.tigris.dev AP_S3_ADDRESSING=virtual AWS_REGION=auto …
Set AP_S3_ADDRESSING=path if your bucket name is not a valid DNS label — one containing dots breaks certificate matching against *.s3.region.amazonaws.com, and underscores or capitals are not resolvable at all. Virtual-hosted addressing puts the bucket name in the hostname, so those names only work path-style.
Upgrading from
v0.2.0against AWS: requests were path-style before this variable existed and are now virtual-hosted, which is what AWS requires in regions launched after 2019. Buckets predating that, and the names described above, needAP_S3_ADDRESSING=path. Nothing changes for a deployment withAP_S3_ENDPOINTset.
A store behind a private certificate authority is reached over https:// by pointing AP_S3_CA_BUNDLE at a PEM bundle; it replaces the system trust store rather than adding to it. There is deliberately no way to switch certificate verification off.
docs/s3-providers.md has working configurations for Backblaze B2, DigitalOcean Spaces, Hetzner, Scaleway and Tigris: each one's endpoint, region and addressing conventions, which kind of credential to create, and the limitations to weigh before committing to a provider. It is also the honest account of what is tested — MinIO is, and nothing else is, AWS included.
Variant store
Point AP_VARIANT_STORE at where completed renders should be kept:
AP_VARIANT_STORE=s3://variants … # object storage
AP_VARIANT_STORE=file:///var/cache/audio_proxy AP_SERVE_MODE=proxy … # a directory
The scheme picks the backend, and each is validated at boot by the operation it needs — a write, and taking it back — so a store this deployment cannot write fails the container instead of discarding every write-back in silence. file:// names a directory that must exist and be writable. s3:// names a bucket and nothing else: no key prefix, and the credentials are the same AWS_* variables S3 credentials covers. Unset means no cache: every request renders.
With a store configured, every successful render is written back under its cache key, together with the headers it was served with — atomically, so a failed or cancelled render leaves nothing behind. It also changes what a disconnect means: the render of a variant nobody is waiting for anymore is completed into the store rather than cancelled, so the next request for it is a hit.
Prefer s3:// if you have object storage. The cache outlives the container, every node reads what any node rendered, and it is the only backend that can presign — which is what makes AP_SERVE_MODE=redirect, the default, work at all. See Choosing a serve mode.
Two things about a file:// store are yours to own:
- It is unbounded. Nothing evicts, expires, or size-caps it; it grows until the disk is full. Manage it like any cache directory you operate — a dedicated volume, disk alerts, a sweep of your choosing.
- It should live on a volume. A store on the container's writable layer disappears with the container, taking every cached variant with it.
A file:// store is also per-node: two nodes with separate directories each render a variant once. That is the intended trade — shared caches are what s3:// is for.
An s3:// store is unbounded in the same way, and there the lever is the bucket's own lifecycle rules rather than anything this proxy does. Worth setting one for incomplete multipart uploads too: a write-back aborts its own, but a hard kill of the container is not something it can clean up after.
Cache semantics
A request for a variant that is not stored renders it (MISS), or attaches to a render already running for it (COALESCED). A request for one that is renders nothing (HIT), and the two answer in different shapes:
MISS / COALESCED | HIT | |
|---|---|---|
| Status | 200 | 200, or 302 in redirect mode |
| Framing | transfer-encoding: chunked | content-length |
accept-ranges | absent | bytes |
A Range request | ignored, answered in full | 206 with the slice |
content-type, cache-control and etag are the same either way; so are the bytes. Both shapes start delivering before the variant is complete or fully read, so neither makes a client wait.
The one thing to take from this table: the same URL can answer in either shape, because which one you get depends on whether the variant happens to be cached at that moment. A client that assumes a length, or assumes a range will be honored, will be wrong on a cold cache; one that assumes chunked framing will be wrong on a warm one. Browsers handle both natively, which is why this is a note for anything else you write against the endpoint.
Two ways to make a first play seekable, if you need one: request the URL once and discard the response before setting src, or warm the cache after upload. Both leave the player facing a hit.
Choosing a serve mode
AP_SERVE_MODE=proxy serves hits from the store through the proxy. It works with every backend, keeps one hostname in front of clients, and means the proxy stays in the path for the bytes.
AP_SERVE_MODE=redirect (the default) answers a hit with a 302 to a presigned URL valid for AP_PRESIGN_TTL seconds, and storage serves the bytes. The proxy leaves the hot path entirely, which is the point of it, and the client cannot tell the difference: the variant arrives under the same Content-Type and Cache-Control a proxied hit would have sent, because the store holds the ones the write-back saved. Range is the store's to answer, natively.
Presigning is a capability of the store's backend, so redirect needs an s3:// store. Against a file:// one — which has no URLs to sign — it is refused at boot naming both variables; use proxy there.
Behind a CDN, prefer proxy. It is the mode that collaborates with an edge: one origin, cacheable immutable responses, Range served through the same URL the CDN already holds. redirect routes the media bytes around the edge to storage, so the CDN caches a 302 it must not keep (the response says no-store for exactly that reason: a stored redirect hands out expired URLs) and none of the audio.
Caching and CDNs
The proxy is built to sit behind a CDN without special configuration on either side: the URL names the variant completely, the ETag is the cache key, there are no cookies and no Vary, and changing cb busts every tier at once. Every response states how long it may be held rather than inheriting a framework default:
| Response | Cache-Control | Why |
|---|---|---|
200 media / peaks | public, max-age=31536000, immutable, no-transform | The URL encodes the variant, so it is immutable; no-transform keeps edge features from recompressing or mangling the bytes |
404 | max-age=10 | Sources appear — a file uploaded moments after the miss is served within seconds |
413, 415 | max-age=10 | Verdicts about the current source bytes, which a re-upload changes |
401, 422 | max-age=60 | Pure functions of the URL: a bad signature never becomes good, invalid options never become valid — only a deploy changes that |
302 (cache hit, redirect mode) | no-store | The Location is a credential with an expiry; a cached redirect hands out URLs that no longer work |
416 | no-store | The only response whose body depends on a request header, and nothing here sends Vary: Range |
429, 500, 502, 504 | no-store | Transient — caching a transient failure amplifies it (429 carries Retry-After, and a cached 502 would suppress the retry that would have worked) |
200 from /info | public, max-age=3600 | Not immutable, and not a year: the metadata describes a source somebody may re-upload, so caches must be able to revalidate it. See Asking what a source is |
/health, /ready, /metrics | no-store | Liveness is only worth anything fresh, a stored readiness verdict is advice about a load level that has since moved, and a cached scrape is a measurement of a moment that has passed |
The error rows are a deliberate relaxation, worth knowing if you operate a shared cache: without them every response would carry Plug's max-age=0, private, must-revalidate, so errors were previously not cacheable at all and never shareable. Dropping private is safe here because an error body is a pure function of the URL — no cookies, no auth headers, nothing per-user in it. The practical effect is that a hot 404 or a bad-signature storm is absorbed at the edge instead of reaching the origin every time. If you need the old behavior for a specific deployment, an edge rule overriding Cache-Control on 4xx is the place to do it; the proxy has no knob for it by design.
Three behaviors round out the CDN-facing surface:
- Revalidation costs no render. A request whose
If-None-Matchmatches the variant'sETaganswers304before the proxy touches storage or spawns anything — the ETag derives from the URL alone. The signature still gates: an unsigned request is401, matching validator or not. On/infothe validator comes from the source object rather than the URL, so revalidating there costs onestatand still no probe. - HEAD works.
HEADon a signed URL runs every check aGETruns — signature, options, source authorization and stat — with an empty body and no render. Errors answer asGETdoes, bodiless.HEAD /healthworks too. Two caveats if you use it to validate URLs. Because it neither decodes nor probes, it cannot report a source ffmpeg would reject or one that turns out to be video, so aHEADcan answer200where theGETanswers either415; everything decidable without a subprocess (401,404,413,422) matches theGETexactly. And it does not consult the variant cache, so it reports the render path's framing, never a hit'scontent-lengthor its302. Rangeon an uncached variant is ignored. ARangeheader on a variant that has to be rendered gets the full200chunked stream (RFC 9110 permits this), with noAccept-Rangesand no206. Range serving belongs to cached variants: a hit answers206in proxy mode, or redirects to storage that serves it natively. A range no byte of a cached variant can satisfy is a416; one this proxy does not implement (several ranges at once, a unit other thanbytes) is ignored, and answers the whole variant rather than failing. See Cache semantics.
Health and readiness
Two unsigned endpoints, and they are not interchangeable:
| Question it answers | What a failure means | |
|---|---|---|
GET /health | Is this running? | Restart it |
GET /ready | Should this node be sent new work? | Route elsewhere; do not restart |
/health is pure liveness and is unaffected by load: a node with a full queue still answers 200, because a busy proxy is a working proxy.
/ready reports how deep the render queue is:
$ curl -s localhost:4000/ready
{"status":"ready","queued":0,"threshold":16}
It answers 503 once queue depth reaches AP_READY_QUEUE_THRESHOLD, and 200 again once depth has fallen to half of it, rounded down. The gap between those two marks is deliberate — it means one busy period produces one not-ready period, rather than a node flipping in and out of the pool on every poll. Both endpoints support HEAD, and both are no-store.
On Kubernetes, point livenessProbe at /health and readinessProbe at /ready. Pointing a liveness probe at /ready restarts containers for being busy, which is the one mistake this pair exists to let you avoid.
Set AP_READY_QUEUE_THRESHOLD=0 on a single node: there is nowhere to route to, so shedding buys nothing. The default, half of AP_QUEUE_SIZE, is for fleets.
Running more than one node — the shared variant store it requires, load balancer recipes, Kubernetes and Fly.io wiring — is docs/scaling.md.
Logs
Everything goes to stdout, one line per completed request:
12:31:07.442 request_id=GMf5ECU8WG_tDMEAAAJC [info] render 200 opts=br:96/f:opus src=local://piece.wav cache=MISS 27141 bytes in 63.4ms
12:31:07.981 request_id=GMf5ECU9xK2sPQ1AAAJD [info] render 200 opts=br:96/f:opus src=local://piece.wav cache=COALESCED 27141 bytes in 2.8ms
12:31:09.118 request_id=GMf5EGolMMyBOD4AAA7B [info] render 422 invalid_options 74 bytes in 0.3ms
12:31:11.006 request_id=GMf5ECRh8raItPUAAAOl [warning] render 504 render_timeout opts=f:mp3 src=local://long.wav cache=MISS 72 bytes in 300004.7ms
Reading one left to right: the endpoint (render, health, or unknown for a path that matched no route), the status, the error code from the Errors table when the request failed, the normalized options string and the canonical source once the proxy has got far enough to know them (a 401 knows neither and omits both), whether the request rendered or shared one, the bytes sent, and how long it took.
cache= is the field to read before drawing conclusions from a duration. The second line above delivered the same 27 kB as the first in a fortieth of the time because it attached to the render already running for that variant — not because ffmpeg was fast.
request_id is on every line, and the same id comes back to the client in the x-request-id response header — so a report of "this URL was slow at 12:31" can be traced to the render behind it. Send your own x-request-id and it is used instead, which is what makes the log line up with a proxy or gateway in front.
Levels:
| Level | What appears |
|---|---|
error | Nothing routine: a render the host could not start at all (no encoder on PATH), a subprocess that survived SIGKILL, and crashes |
warning | 5xx and 504 responses, and the ffmpeg diagnostic behind a failed render |
info | Default. The above, plus one line per request, 4xx included: a 401 is a normal outcome for a public endpoint, not an incident |
debug | The above, plus /health, /ready and /metrics (silent otherwise, so a liveness probe every second and a scrape every fifteen do not become the log), the render lifecycle, and client disconnects |
Set the floor with AP_LOG_LEVEL; warning is the setting for a busy production instance that wants failures only.
Presigned URLs and credentials never appear. Sources are logged by their canonical identity (local://piece.wav), never by what ffmpeg was handed to read, and diagnostics quoted back from ffmpeg have their query strings stripped.
Structured JSON output is not implemented yet — it arrives with its own slice.
Metrics
GET /metrics answers in Prometheus text format. It is unsigned, and it is served on its own listener, bound by default to 127.0.0.1:9568 — a sidecar scraper and a kubectl port-forward reach it, and nothing off-host does. The bind is the access control, so widening it is a deliberate act:
$ curl -s localhost:9568/metrics | head -3
# HELP audio_proxy_renders_total Renders that finished, by output format and outcome.
# TYPE audio_proxy_renders_total counter
audio_proxy_renders_total{format="mp3",outcome="success"} 412
AP_PORT does not serve it — a request for /metrics there is a 404.
What is exported
| Metric | Type | Labels |
|---|---|---|
audio_proxy_renders_total | counter | format, outcome |
audio_proxy_render_duration_seconds | histogram | format, outcome |
audio_proxy_renders_running | gauge | — |
audio_proxy_render_slots_held | gauge | — |
audio_proxy_render_slots_capacity | gauge | — |
audio_proxy_render_queue_depth | gauge | — |
audio_proxy_render_queue_capacity | gauge | — |
audio_proxy_render_queue_rejections_total | counter | — |
audio_proxy_cache_lookups_total | counter | format, outcome |
audio_proxy_variant_store_write_failures_total | counter | — |
audio_proxy_http_requests_total | counter | endpoint, status |
format is the output format (mp3, opus, peaks, …). outcome on a render is success, cancelled (the client went away mid-stream), or the failure class from the Errors table (timeout, undecodable, not_found, …); on a cache lookup it is hit, miss or coalesced, the same three values the X-Audio-Proxy header carries. endpoint is the route class (render, info, health, ready, metrics, unknown) and status a code family (2xx, 4xx, …).
Every label is a fixed, bounded set. Nothing derived from a request — not the source, not the options, not the cache key — becomes a label, so no client can grow your scraper's series count.
renders_running counts renders, not requests: twenty clients coalesced onto one encode are one running render, which is what AP_MAX_CONCURRENCY counts too.
Duration buckets are fixed at 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120 and 300 seconds. They are not configurable, so the histogram stays aggregatable across nodes and across releases.
Scrape config
scrape_configs:
- job_name: audio_proxy
static_configs:
- targets: ["127.0.0.1:9568"]
In a container, 127.0.0.1 is the pod, not the host — so a sidecar scraper in the same pod works unchanged, and a scraper elsewhere needs AP_METRICS_BIND=0.0.0.0 plus a network policy that keeps the port to your monitoring namespace. On Kubernetes, declare the port on the pod and point a PodMonitor at it rather than exposing it through the Service that serves audio.
The four signals
# Saturation: how much of this node's render budget is in use. The leading
# signal is the queue behind it — the slots are full long before it grows.
sum(audio_proxy_render_slots_held) / sum(audio_proxy_render_slots_capacity)
sum(audio_proxy_render_queue_depth)
# Latency: the 95th percentile render, by format. Split by format because a
# full-length FLAC and a 30-second Opus preview are not the same workload.
histogram_quantile(
0.95,
sum by (format, le) (rate(audio_proxy_render_duration_seconds_bucket[5m]))
)
# Cache efficiency: the share of requests that cost no encode at all. This is
# the number the proxy exists to move.
sum(rate(audio_proxy_cache_lookups_total{outcome=~"hit|coalesced"}[5m]))
/ sum(rate(audio_proxy_cache_lookups_total[5m]))
# Errors: the share of renders that did not deliver, and the load being shed.
sum(rate(audio_proxy_renders_total{outcome!~"success|cancelled"}[5m]))
/ sum(rate(audio_proxy_renders_total[5m]))
rate(audio_proxy_render_queue_rejections_total[5m])
Two more worth an alert. audio_proxy_variant_store_write_failures_total moving at all means the cache has silently stopped filling — clients are being served, so nothing else reports it, and the hit ratio decays over hours. And audio_proxy_render_queue_depth is the leading signal for autoscaling: target it well below AP_READY_QUEUE_THRESHOLD so the fleet grows before nodes start shedding. See docs/scaling.md.
For AI agents
Two files at the repository root carry the API reference as markdown, per the llms.txt convention:
| File | What it is |
|---|---|
llms.txt | The index: what the proxy is, the URL shape, and links to everything else |
llms-full.txt | The whole reference in one document — URL grammar, every option and its value domain, the validation rules, cache-key derivation, response and caching semantics, the full error table, a worked signing example |
Point an agent at llms-full.txt and it has everything it needs to construct correct signed URLs; nothing else has to be fetched. They also ride in the hex package, so an embedder finds them in the dependency tree.
Read them at the tag you are running.GET /health reports the version, so curl -s $BASE/health and then reading these files at that tag gives you documentation matched to the deployment in front of you. That matters while the URL contract is still 0.x.
Three things in llms-full.txt are machine-checked rather than trusted: the set of option keys, against the parser; the set of error codes, against the error mapping; and the worked signing example, recomputed from the signer on every run. A new option or a new error code that goes undocumented fails CI. The rest — value ranges, defaults, the configuration table, the prose — is reviewed the way the README is, so treat the guards as covering the part that is easiest to forget rather than the whole document.
Stack
Elixir with Plug and Bandit, and no Phoenix, because there is no HTML to render and no channels to serve. ffmpeg runs as a subprocess rather than through libav bindings, so it does all decoding and encoding while Elixir stays orchestration; ffprobe backs /info. There is no database, no queue and no sidecar, because the only state is what lives in S3 and in the URLs themselves. That leaves one container to deploy and nothing to migrate.
License
Apache-2.0 for the proxy itself. ffmpeg is invoked as a subprocess, so nothing about its licensing reaches this source tree.
The published image is a separate question, because it is a distribution. It ships Debian's packages, ffmpeg among them, and Debian builds ffmpeg with --enable-gpl; handing those binaries to someone carries the GPL's obligations. Two things ride in the image to meet them:
- The license notices, at
/usr/share/doc/<package>/copyright, exactly as Debian ships them. - The corresponding source, listed in
/usr/share/audioproxy/SOURCES.txt: every Debian package the image installs, at its exact version, with the snapshot.debian.org URL for the source that binary was built from. That archive is version-exact and long-lived, so a link still resolves to the source behind this image after the suite has moved on. (The manifest covers what apt installed. The release alongside it, the bundled ERTS and the Elixir dependencies, is Apache-2.0 and MIT throughout and its source is this repository.)
Read either straight out of the image:
docker run --rm --entrypoint cat ghcr.io/audioproxy/audioproxy:latest /usr/share/audioproxy/SOURCES.txt
docker run --rm --entrypoint cat ghcr.io/audioproxy/audioproxy:latest /usr/share/doc/ffmpeg/copyright
Both are checked in CI on every build (present, complete, and resolving), so an image that reaches the registry has them. The image's org.opencontainers.image.licenses label names Apache-2.0 and the strongest copyleft in the mix; it is a signal, not an inventory, and those two files are the record.
Should a listed source ever become unreachable, the offer stands: open an issue at github.com/audioproxy/audioproxy and the corresponding source for that image will be provided, for three years from the date it was published.
Redistributing your own image built from this one inherits all of the above; keep /usr/share/doc and the manifest intact and it travels with the layers. Patents are a separate axis from licensing, and AAC in particular is still encumbered; if you offer f:aac or f:m4a commercially, that is worth its own opinion.
Documentation
| Document | What it covers |
|---|---|
| docs/audio-proxy-api-v1.md | The source of truth. URL grammar, every processing option, cache-key rules, response headers, error codes |
| llms.txt, llms-full.txt | The same contract as markdown, in one file, checked against the code — for agents, and for anyone who wants it in one page. See For AI agents |
| docs/sources.md | Source encodings and escaping, what is refused, the source-type contract and canonical identity |
| docs/s3-providers.md | Working configurations for Backblaze B2, DigitalOcean Spaces, Hetzner and Scaleway, and the limitations to know before committing to one |
| docs/development.md | Toolchain, per-slice worktrees and devcontainers, the test suite and its tags, CI, how a release is cut |
| examples/ | A one-file browser player for trying variants, and why it has to be served rather than opened |
| VERSIONS.md | What the image is built from: Debian, Elixir/OTP and ffmpeg pins, why not Alpine, and how to bump one |
| docs/ffmpeg-arguments.md | How options become ffmpeg arguments: filter order, per-format flags, known gaps |
| docs/rendering.md | How a render runs: the subprocess, the chunk stream, coalescing, buffering and lifecycle guarantees |
| docs/scaling.md | Running more than one node: the shared variant store it requires, what duplicate renders cost, load balancer recipes, Kubernetes probes and queue-depth autoscaling, Fly.io, Docker Swarm |
| docs/capacity.md | How much memory a container needs: a concurrency-per-memory-limit matrix, then the formula behind it, measured per-format costs, long-form worked examples, and what the CI guard asserts |
openspec/specs/ | Capability specs for what is built; openspec/changes/ holds what is planned |