Repository navigation
Audio formats: what the station serves, and the one output still not built #39
Closed
robert-dean
started this conversation in
Ideas
Replies: 1 comment
|
Closing: the mounts and HLS are built, and the archive output moved to #450. The rest of this discussion is a record of how the formats were chosen. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Written: 2026-08-09, when there was one
%mp3mount and this file was a proposal.Rewritten: 2026-08-28, when the mounts and the HLS output landed. What follows is a record of
what shipped and why, not a plan.
Amended: 2026-09-01, when the HLS MP3 variant was dropped. See "Why HLS carries AAC alone".
State of the tree: MP3 always, plus Opus, AAC and FLAC as opt-in Icecast mounts, plus an opt-in
HLS output carrying AAC. All four extras are off on a station nobody has configured.
This file is about WHICH outputs the station serves. For how good the audio on any of them can get,
and why the ceiling is the source rather than the encoder, see
stream-quality-ceiling.
Incoming needs nothing
Worth stating because it looks like a gap and is not. Liquidsoap decodes whatever it downloads, and
the pinned
savonet/liquidsoap:v2.4.5image is ffmpeg-backed, so every format the station couldplausibly be handed already resolves:
Vorbis from the Spotify track shim (which decrypts rather than decodes, so it hands over Spotify's
own file untouched).
SEGMENT_CONTENT_TYPESinapps/api/src/modules/render/segment.store.ts
already declares
mp3,wav,ogg,flacandm4a, and the speech capability carries a mediatype alongside the bytes so a plugin's output is labelled rather than sniffed.
stream/music/.The one sharp edge. Liquidsoap chooses a decoder from the URL's extension and the response's
content type. A pushed URL carrying neither is the case that fails, and it fails as silence —
the request resolves, the queue reports not-ready, and the mount falls through to the local bed.
Anything that grows a new way of handing a URL to the player should make sure one of the two is
always present.
Outgoing, as built
stream.bitratestream.opusEnabledstream.aacEnabledstream.flacEnabledstream.hlsEnabledThe mount PATHS are fixed (
MOUNT_PATHS,/live.mp3and its siblings), andstreamMounts(stream.settings.ts) is the single place
that knows which outputs exist. Four consumers read it — the config renderer, the audience
gate, the edge and the console — and any of them deriving the list separately is how a listener on a
mount nobody counted stops holding the station on air.
MP3 is the floor and that is a compatibility fact, not a quality one. A Sonos takes MP3, AAC or
WMA for a manually added radio URL and nothing else (supported formats);
a car head unit and a hardware radio are narrower still. So AAC, not Opus, is the format that widens
reach, which inverts the ordering this file originally proposed.
Per-listener bandwidth is per-output, so an unused one costs only its encoder. That is not
nothing — an encoder is real CPU in the stream container, 24/7 — which is why every one of them is
off by default rather than something a station pays for without asking.
Why HLS is here, and why it carries AAC alone
Not for quality and not for latency (it is worse on both counts than a mount). An Icecast mount is
one long-lived TCP connection, so a phone moving between wifi and mobile changes its source address,
the socket dies, and the stream simply ends with no reconnect. Nothing on the server side carries a
TCP connection across that and no Icecast setting touches it. HLS is a sequence of ordinary HTTP
requests for small files, so the same handoff costs at most one segment fetch and the player asks
again.
AAC only. Opus and FLAC need fragmented MP4, which Liquidsoap supports since 2.4.3 and which
carries no in-stream metadata at all, so those variants would play, on the few clients that
manage them, with no idea what is on. They stay on their Icecast mounts where their metadata works.
Sonos reads ID3v2 out of an HLS stream and ICY out of everything else
(HLS docs), which is what this produces.
MP3 was the second variant until 2026-09-01, and it was closing the output to every
Chromium-based browser. The spec allows packed MP3 audio and Liquidsoap writes it; Safari and
ffmpeg take it and Chromium does not. A
%mp3segment fails Chromium's demuxer outright(
DEMUXER_ERROR_COULD_NOT_PARSE) or, worse, hangs the media element atloadstartwithreadyState 0and never fireserror, so the player sits there looking like a station that wentoff the air. What turned that from a fallback into a total failure is variant SELECTION: Chromium
picks the lowest
BANDWIDTHon a cold start, which was the MP3 one, every time. Measured in Braveagainst the live station:
/hls/aac.m3u8on its own played clean,/hls/mp3.m3u8hungindefinitely, and
/live.m3u8chose MP3 and failed on 4 attempts out of 4, while an AAC-only masterplayed on 3 of 3. The reach argument that put it there was backwards. MP3 is the compatibility
floor on an Icecast MOUNT, where it stays and where MP3-only clients still find it. Inside HLS it
was not a wider net, it was the only thing a large class of clients ever tried.
If a second variant is ever wanted, the move is MPEG-2 TS, not the packed-audio one again. Two
things to know before attempting it. Chromium's support for MP3-in-TS is unverified. And
Liquidsoap's
%mp3HLS segments are not frame-aligned: each begins with 76 to 113 bytes of theprevious frame's tail, where the
%fdkaacones start exactly on an ADTS sync. That is a real defectand it is a SEPARATE one. Repairing the alignment by hand and replaying only moved Chromium's error
to
CHUNK_DEMUXER_ERROR_APPEND_FAILED.It runs whenever it is enabled and does NOT follow the mount lease. The instinct is to write
segments only while the station is on air, and it is wrong: the playlist has to exist before anybody
can tune in, and tuning in is what opens the audience gate. An output that only wrote while on air
could never be listened to. Disk is bounded by
segments + segments_overhead.The app serves the playlists and nginx serves the segments. An HLS player holds no connection
open, so nothing can be asked how many are listening; a live player must re-fetch the media playlist
every target duration, so a playlist request is a per-client heartbeat that arrives on its own. That
count is load-bearing under
playout.airMode: audience. The two alternatives were rejected:auth_requestin nginx would put the API in the path of every listener's own connection, which iswhat
<authentication type="url">did before it was removed fromicecast.xml, and countingentries in an access log is the "zero and unknown are the same number" failure
AudienceWatchexists to refuse.
What the encoders actually needed
Measured on
savonet/liquidsoap:v2.4.5, 2026-08-28, and each of these cost something to learn:%fdkaacincluded, which its licence usually keeps out ofbuilds.
--list-pluginsis NOT a way to check: it lists noopusline at all on this image while%opusworks fine. Probe withignore(%encoder(...))in a file, and keep%mp3in the list as acontrol, since a broken harness reports every encoder missing in exactly the same way.
44.1 kHz because the music is.
%ogg(%flac), not%flac: the bare encoder writes a FLAC file, whoseheader declares a total sample count a stream does not have.
that set.
%mp3genuinely does take a computed one; the others are documented as wanting aliteral at parse time.
output.icecast's guess. Itis
send_icy_metadata : bool?, NOTicy_metadata, which is the list of metadata FIELDS an updatecarries. The Ogg case is genuinely contested — some clients read an Ogg comment header once and
freeze on the first title, others parse in-band tags and are broken BY the ICY channel — so no
value suits both and the one in use is a visible line rather than a default to discover.
Everything is fed from
bus, the brick-wall limiter, and never fromradio. One output taken offradioskips the -1 dBFS ceiling and hands a listener the inter-sample peaks it exists to leaveheadroom for.
The two Icecast numbers that are counted in bytes
queue-sizeandburst-sizeare byte counts, so raising a bitrate silently shortens both in TIME.Both are now rendered from the enabled bitrates rather than written as literals; the derivation and
why the two are treated differently is in
stream-quality-ceiling. Each extra mount also gets its own
<mount>block with credentials, because a mount that declares none has nothing to authorise amount-scoped
/admin/metadataagainst and every ICY title for it is silently refused.Still not built: an archive output
An
output.filewithreopen_when={0m0s}writing hourly MP3s of the bus. Cheap to add and usefulfor a station nobody was listening to at the time, at the cost of a second encoder running
permanently. Unlike the HLS output it SHOULD follow the lease — the argument that keeps HLS running
unconditionally is that a listener has to be able to arrive, and nobody arrives at an archive — so
without that it records hours of the local bed playing to an empty mount.
What this cost to build, which is the part worth reading
Three failures took the station off air during this work, and every one of them passed the check
that was supposed to catch it. They are recorded properly in
stream/README.md; the pattern is what belongs here.
A check that does not exercise the failure mode is not a check.
liquidsoap --checktype-checks and evaluates a script and never STARTS an output, so an output that type-checks and
then fails on startup passes it and takes every other output down with it.
hlsout.check.liqwaswritten to close that gap and then passed while the real script was broken, because it hardcoded a
bitrate where the real script read an empty variable. A harness that paraphrases the code proves
nothing about the code. What found the bug first try was running the actual
radio.liq, with theactual rendered environment, redirected onto a spare mount name and harbour port so it could not
touch the live station. That probe is the check worth keeping.
The three bugs, for the shape of them rather than the detail: an optional setting arrives as the
empty STRING rather than absent, so
environment.get's default never applies andint_of_string("")raises during startup;
^~on an nginx location means "do not consider regex locations" and sosilently swallowed the playlists it was meant to sit beside, answering 200 off the disk and looking
perfectly correct while no listener was ever counted; and a parameter named
sourceshadows one ofLiquidsoap's own namespaces, which is not a type error and surfaces a hundred lines away as
Warning 4: Unused variable bus.Two of the three produced a correct-looking result. That is the thing to carry forward: on this
seam, the failure mode is not an error message, it is a station that looks fine and is not.
Moved from
docs/todo/stream-formats.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/stream-formats.mdAll reactions