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.
Full documentation: docs.audioproxy.dev, covering how to render, sign, configure, deploy and observe.
Status: early,
v0.7.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; HTTPS sources are designed but not yet rendering, per the roadmap below. 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.7.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'
# 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"
# Waveform peaks to draw a player UI, 800 min/max pairs as JSON.
curl "$BASE/insecure/f:peaks/pts:800/$SRC"
Each response starts arriving while ffmpeg is still encoding: it 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.
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. Real deployments sign every URL.- Mount the directory read-only (
:ro, above). Write access toAP_LOCAL_ROOTis write access to what the proxy will serve.
The quickstart guide has the same thing at more length, plus a browser player. Transforms is every option the URL accepts, arranged by what you are trying to do.
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}
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. The Roadmap says which parts of it exist today.
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. A real deployment drops AP_ALLOW_INSECURE and gives the proxy a key and salt instead:
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.7.0
Pin a version.:0.7.0 and :sha-<commit> name an exact image; :0.7 follows patch releases; :latest and :edge move under you. 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. See docs/development.md for a development container that already has them, and for the test suite.
The proxy is also published to hex as an OTP application ({:audio_proxy, "~> 0.6"}), so it can run inside a BEAM node you already deploy. It is an application rather than a library: adding the dependency is the whole integration, and starting it reads the AP_* environment, binds two listeners, and expects ffmpeg on PATH. Configuration covers what that commits you to.
Roadmap
No dates. It is built in small releases, each one usable, in roughly this order.
Working now (v0.7.0)
- Signed URLs, the full processing-options grammar, and the cache-key rules
- Expiring URLs:
exp:<unix-seconds>time-boxes one URL without rotating the key, and because it is not part of the cache key, minting a fresh short-lived URL per page view still resolves to one render - Transcoding to MP3, AAC/M4A, Opus, Vorbis, FLAC and WAV, with trimming, fades, loudness normalisation, channel and sample-rate control
enhance:voice, a speech-cleanup preset (high-pass, denoise, de-ess, compress, limit) behind one option value, so it renders and caches as a single variant. A preset name is pinned to its chain permanently: an improved chain ships as a new value rather than changing what an existing URL returns- Renders stream while they encode, and concurrent requests for the same variant share one render
- Sources on a mounted directory or in S3, read by ffmpeg through a presigned URL, so a trim fetches only the bytes it needs
- 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. The store can carry its ownAP_VARIANT_S3_*credentials and endpoint, so sources and variants may live with different providers or under different principals f:peaks, waveform min/max data in audiowaveform's JSON and binary formats, drawn from the variant the same URL would play —enhance,gainandnormmove the picture, so a waveform under a normalized player is normalized too- A cap on simultaneous renders with a bounded wait queue, so a burst queues and then sheds rather than thrashing the machine. The queue also carries admission classes, so background work can be made to yield to a live listener; nothing here sets one yet, and the queue is plain FIFO until something does
GET /infofor source metadata,GET /readyfor queue-aware readiness, and a PrometheusGET /metricson a bind-restricted listener of its own- Video input refused rather than transcoded, enforced rather than intended
- Optional CORS (
AP_ALLOW_ORIGIN), off by default - A single container, published per release, and the package on hex
After that: HTTPS sources, for stores that are not S3; arm64 images, so Graviton/Ampere and Apple Silicon run natively.
Under consideration: a /sync/ URL that renders fully before responding, trading time-to-first-byte for a seekable first play. Still open, because warming the cache does the same job for nothing: fetch the URL once, discard it, then set src.
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.
Wanted, but not designed yet: HLS and segmented streaming. A v2 goal rather than a rejected one, and the URL space is reserved. 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/.
Documentation
Start at docs.audioproxy.dev. It is the goal-first documentation: how to render a variant, sign a URL, configure the proxy, choose a provider, run more than one node, and read a running one.
| Document | What it covers |
|---|---|
| docs.audioproxy.dev | Start here. Quickstart, transforms, signing, sources, rendering, configuration, variant store, caching, operations, S3 providers, scaling, capacity, Rails |
| 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. See For AI agents |
| docs/development.md | Toolchain, per-slice worktrees and devcontainers, the test suite and its tags, CI, how a release is cut |
| docs/ffmpeg-arguments.md | How options become ffmpeg arguments: filter order, per-format flags, known gaps |
| docs/operations.md | Reading a running proxy: the request log and its levels, every exported metric, scrape config, the four signals to alert on |
| docs/ | The authored-from upstream for the site's guide pages: sources, rendering, scaling, capacity, s3-providers |
| VERSIONS.md | What the image is built from: Debian, Elixir/OTP and ffmpeg pins, why not Alpine, and how to bump one |
| examples/ | A one-file browser player for trying variants, and why it has to be served rather than opened |
openspec/ | specs/ holds the capabilities that are built; changes/ holds what is planned |
For AI agents
Two files at the repository root carry the API reference as markdown, per the llms.txt convention: llms.txt is the index, and llms-full.txt is the whole reference in one document. 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.
Four 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; the set of environment variables, against the ones AudioProxy.Config reads; and the worked signing example, recomputed from the signer on every run. A new option, error code or variable that goes undocumented fails CI. The rest, meaning value ranges, the defaults themselves and the prose, is reviewed rather than enforced.
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.