Repository navigation
The quality ceiling: how good could this stream actually be? #42
Replies: 2 comments
|
Status check, 2026-09-22 (main at 23b983e) Done: steps 4 and 5 (Opus, AAC, FLAC and HLS mounts), and Icecast's queue/burst settings are now computed in seconds with a per-mount burst ( Still open: step 1, the default bitrate is still 128 ( |
|
Steps 1 to 3 shipped in #443: |
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-13, from the question "what's the highest quality of stream we could support?"
State of the tree: one
%mp3mount atSTREAM_BITRATE(default128), fed from a 44.1 kHzstereo float bus. Nothing in
radio.liqsets a frame rate, so that is Liquidsoap's default ratherthan a decision anybody made.
This is the companion to stream-formats, which is about which mounts to
serve. This one is about how good the audio on any of them can get, which is a different
question with a less flattering answer: the encoder is not the ceiling.
The short answer
The station cannot sound better than the file it was handed, and today most of those files are
Spotify's Ogg Vorbis 320. Everything downstream of that either preserves it or spends it. The
present output spends a lot of it: MP3 at 128 kbps is a second lossy generation over an already
lossy source, and tandem coding is where the audible damage in this chain is, not in the bitrate
number on its own.
So the honest ceiling, in order of what it buys:
Opus. This is nearly all of the available gain and the only stage the station fully controls.
source: FLAC of a decoded Vorbis 320 is a ~900 kbps perfect copy of a lossy record.
Navidrome at
streamFormat: 'raw'hands over the original file, so a FLAC library is a losslesssource today.
There is one ceiling no mount can lift: the DJ voice is a 24 kHz model. See stage 1 below.
Where quality is actually decided, stage by stage
1. The source file, which is the real ceiling
SHIM_BITRATEdefault320, nearest available file (main.go:54). The shim decrypts and never decodes, so it is Spotify's own file byte-for-bytestreamFormatdefaults toraw, meaning the server sends the file as stored, withmaxBitRatedeliberately not sent (navidrome.plugin.ts:237)mp3by default (DEFAULT_FORMATin kokoro.manifest.ts)stream/music/Two things fall straight out of this table and neither is a project:
streamFormataway fromrawunless bandwidth to the app is theconstraint. It is the only lossless input the station has and the form makes it one click to
throw away.
formatshould beflacorwav, notmp3. A break is a few seconds long, so thebytes are irrelevant, and it removes an entire lossy generation from the voice for free. It does
not fix the 24 kHz, which is the model.
2. The app's copy of the bytes: lossless already
TrackAudioServicestores what it fetched. Nothing inapps/apitranscodes audio;track.store.tsonly names the file so a decoder can be chosen. This stage costs nothing and needs no work.
3. Liquidsoap's internal frame: 44.1 kHz stereo, float
radio.liqsets nosettings.frame.*, so the pipeline runs at the 2.4.5 defaults: 44.1 kHz,stereo, float PCM internally. Consequences worth knowing before anybody "improves" it:
the encoder is float, so the only quantisation in the whole chain is the final encode. The
per-track
amplifyoff the measured loudness, thenormalize(target=-16.), the voice chain'scompressor and the
-1 dBFSbus limiter are all transparent by design and none of them is theceiling.
raising the frame rate to chase it would resample every ordinary 44.1 kHz record instead. The
trade only tips if an Opus mount lands: Opus is 48 kHz internally, so it either resamples at the
mount (default, costs one conversion on one output) or the whole bus moves to 48 kHz (costs a
conversion on nearly every input). Keep the bus at 44.1 — the music is 44.1.
4. The final encode: where the loss actually is
One
%mp3(bitrate=…)at 128 by default. This is the single biggest quality decision in the tree andit is already a setting:
stream.bitrate, astringfield insettings.registry.ts, rendered into
STREAM_BITRATEand adopted on the config-watch restart. Raising it is zero code.For a one-operator station, per-listener bandwidth is not a real constraint, which is why the first
row of the plan below is simply a bigger number in a box.
5. Delivery: the Icecast numbers are in BYTES, and that is a trap
icecast.xml.tmplsets<queue-size>524288</queue-size>and<burst-size>8192</burst-size>, andthe comment beside the burst reasons about it in seconds at 128 kbps ("8192 B ≈ 0.5s"). Both are
byte counts, so raising the bitrate silently shortens both in time:
burst-size8192queue-size524288The burst getting shorter is mostly fine and is even a latency win (it is pure backlog, see
stream-latency), but below roughly a tenth of a second players start
raggedly. The queue is the one that bites: it is the slow-client tolerance, and a lossless mount at
the current number drops listeners a 128k mount carried without complaint.
SETTLED, 2026-08-28. Both are now RENDERED rather than written as literals, in
stream.config.ts, and both are stated in the
code in SECONDS — which is the whole of the fix, since the config field is bytes and that is the
trap.
queue-sizeis derived from the HIGHEST enabled bitrate at 20 seconds, because it is oneglobal number and has to suit the hungriest mount.
burst-sizeis NOT: it is backlog everylistener pays as latency, so the global one stays sized for the MP3 mount at half a second and a
mount whose own bitrate makes that too short in time carries its own override in its
<mount>block, which Icecast supports per-mount. A FLAC mount gets ~56 kB where MP3 keeps 8192. An
unchanged 128 kbps station renders exactly the 524288 and 8192 it always did, which is what keeps
the config generation stable across the upgrade.
The plan, cheapest first
Status, 2026-08-28: steps 4 and 5 are built. The Opus, AAC and FLAC mounts and the HLS output
all exist and are opt-in settings; see stream-formats, which is now a record
of what shipped rather than a proposal. Steps 1-3 below are still the cheap unclaimed wins, and step
1 remains the largest single improvement available for zero code.
stream.bitrateto 320. One settings edit, no code, no new mount, no new encoder.Removes most of the tandem-coding loss and is the largest single improvement available.
Re-check
burst-sizeat the same time (0.2 s is still fine; it is the queue that matters at 320,and 13 s is still generous).
formattoflac. One config field. Removes a lossy generation fromthe station's own voice.
raw, and note in the console help that changing it is a qualitydecision rather than a bandwidth one.
per-bit gain lives, and which is also the answer to MP3 showing as a generic format in the 2.5
dashboard. Everything about building it is in that file; nothing here changes it.
and a vanity one for a Spotify-first station.
Verify before building any of it
%opus,%flacand especially%fdkaacmay or may not becompiled into
savonet/liquidsoap:v2.4.5— fdk-aac's licence keeps it out of a lot of builds.Check with
docker compose exec liquidsoap liquidsoap --list-pluginsbefore designing around anyof them;
%ffmpeg(format="adts", codec="aac")is the fallback for AAC on an ffmpeg-backed build.<audio>element, and the operator's hardware player is the other consumer. MP3 and AAC are safe
everywhere; Ogg Opus and Ogg FLAC are the ones to actually test in the browsers and on the box
that will play them. See now-playing-displays for the probe habit
this file is borrowing.
%mp3(bitrate=int_of_string(environment.get(…)))resolves once when Liquidsoap evaluates the script, which is why a settings change is adopted on
the
config-watch.shrestart rather than live. It cannot become arefthat moves under a runningencoder. stream-formats states this harder (a fixed set of literals) for
%opus/%fdkaac; check the actual behaviour per encoder before assuming either version.so cue points and loudness are unaffected by any of this.
Moved from
docs/todo/stream-quality-ceiling.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/stream-quality-ceiling.mdAll reactions