My com­puters are setup in a way that most would con­sider awk­ward. In­spired some­what by the idea of a large main­frame with thin ter­min­als ac­cess­ing it, the most (and only) cap­able com­puter I own is a head­less home serv­er. This server is re­spons­ible for com­pil­ing all the code I work on, run­ning all of the ser­vices I de­pend on (wire­guard, dns, etc.) and stor­ing the all of my files. My ex­tens­ive mu­sic lib­rary, most of it en­coded in FLAC, is stored on this ma­chine as well.

Un­til quite re­cently I never really found my­self want­ing to ac­cess the mu­sic stored on the server from devices other than my laptop. As thus, shar­ing the en­tire mu­sic lib­rary with any devices that might need to ac­cess it via the SMB pro­tocol made a fair bit of sense. I would then run a stand­ard me­dia player such as quod­libet to play the mu­sic on the laptop. Janky? Yes. But it did work okay for a long time.

Then, I found my­self want­ing to ac­cess this mu­sic lib­rary from a phone, Steam Deck or, heck, a rooted ro­bot va­cuum too. As far as the phone is con­cerned, I could still use Google’s un­main­tained, but ser­vice­able, sam­ba-­doc­u­ment­s-­pro­vider to mount and ac­cess the mu­sic lib­rary on my phone, much the same way I was ac­cess­ing it on the laptop. While I could­n’t really find a full-fea­tured me­dia player that would work well with this setup, I could still point mpv at spe­cific tracks I wanted it to play. It worked sur­pris­ingly well for how clunky the setup really was. Un­til the re­lease of An­droid 13, that is, which broke sam­ba-­doc­u­ment­s-­pro­vider en­tirely.

While there are al­tern­at­ives to the broken app – ci­f­s-­doc­u­ment­s-­pro­vider is one – this break­age was a great op­por­tun­ity to ex­plore how I could stream­line the ex­per­i­ence. I tend to leave it up to the me­dia player to pick the al­bums to play, and my lib­rary too large to keep track of it any­how, so my very own web ra­dio seemed like a prom­ising av­enue to ex­plore.

Icecast, IceS, Ez­stream

Re­sources on the in­ter­net will read­ily point any­body who’s look­ing to set up a web ra­dio to Icecast. Many of these re­sources will of­ten provide some ba­sic ex­amples with ezstream or per­haps ices, and in­deed, these ex­amples are enough to get a very ba­sic setup that can play a song. But that’s no ra­dio, not yet.

As I was ex­per­i­ment­ing with these tools, fur­ther lim­it­a­tions kept sur­fa­cing. Ez­stream does not sup­port out­put­ting FLAC in any shape or form, for ex­ample. IceS sup­posedly does sup­port FLAC, but it not be­ing pack­aged in nix­p­kgs made it a non-­op­tion. I really have bet­ter things to do than tak­ing on main­ten­ance of one more nix­p­kgs pack­age.

But most im­port­antly, it was­n’t at all clear how to get a proper web ra­dio with playl­ists and all other ba­sic func­tions go­ing with this tool­ing. Writ­ing heaps of bash to glue these pieces to­gether some­how did­n’t really sound like my defin­i­tion of fun. At all.


Li­quid­soap is a tool ori­gin­ally build pre­cisely to handle re­quire­ments of web ra­dios and has since evolved into a more gen­eral me­dia stream gen­er­at­or. It has many dif­fer­ent fea­tures such as cor­rect in­put fail­over, blank de­tec­tion and strip­ping. Many of these fea­tures are, ad­mit­tedly, of little rel­ev­ance to me. What really drew me to this pro­ject is its’ prom­ise that hav­ing a FLAC-stream­ing web ra­dio is go­ing to be no harder to achieve than a more tra­di­tional Vor­bis- or MP3-­based vari­ant.

This pro­ject comes with a well writ­ten and easy to di­gest book. This book takes time to ex­plain the story be­hind this pro­ject, the un­der­ly­ing con­cepts and will hand-hold the read­ers through build­ing their very own web ra­dio. Li­quid­soap is an ex­tremely flex­ible tool. A lot of it stems from the func­tional OCam­l-in­spired pro­gram­ming/­con­fig­ur­a­tion lan­guage that li­quid­soap comes with. This can seem daunt­ing at first1 but the re­sources are of high qual­ity and copy-­past­ing snip­pets from the book might be just enough to achieve a ser­vice­able res­ult for many.

Side note: it is a lucky ac­ci­dent that I man­aged to dis­cover li­quid­soap dur­ing my re­search. I’m pretty sure that none of the keyword com­bin­a­tions I tried would coax the search en­gines to re­turn a link to this pro­ject or any re­source ref­er­en­cing this pro­ject even re­motely. Hope­fully this post has suf­fi­cient con­cen­tra­tion of keywords to help with the dis­cov­er­ab­il­ity of this pro­ject.

Build­ing a web ra­dio with li­quid­soap


def beets(id) =
  queue = ref([])

  def next()
    while list.is_empty(!queue) do
      album = string.trim("beet random -a -p"))
      queue := playlist.files(album)
    song = list.hd(!queue)
    queue :=!queue)

  request.dynamic(id=id, retry_delay=1., next)

I’m us­ing beets to tag and man­age my lib­rary, so it was a pleas­ant sur­prise when I found a snip­pet of code sug­gest­ing use of beets as a playl­ist driver. For the time be­ing I have chosen to keep this simple and have beets pick ran­dom al­bums (this is all I really need any­how, though I already have ideas on how I would im­prove this.)

radio = beets("random");
radio = buffer(buffer=1., radio)

default_replaygain = lin_of_dB(-6.);
normalized = amplify(id="replaygain", override="replaygain_album_gain", default_replaygain, radio)
radio = mksafe(normalized)

The snip­pet to ap­ply re­play­gain volume nor­mal­iz­a­tion comes from the book, as do the “dir­ect­ives” to out­put the au­dio streams (I ad­ded opus just be­cause I could! Might come handy if I find my­self in a band­width con­strained en­vir­on­ment.)

flac = %flac(samplerate=48000)
output.harbor(flac, port=2048, mount="flac", radio)

opus = %opus(samplerate=48000)
output.harbor(opus, port=2048, mount="opus", radio)

These three snip­pets com­bined make up the en­tire con­fig­ur­a­tion file I fed to li­quid­soap for the first it­er­a­tion. Things seemed out to be work­ing great at first. I could open the streams in Fire­fox and they were play­ing mu­sic. Prob­lems did­n’t take long to sur­face, though. For ex­ample, mpv and half of other play­ers I tried would bail or hang when poin­ted at the FLAC stream.

Blindly copy­ing snip­pets of code led me to a ma­jor over­sight. Un­able to fig­ure out the prob­lem, I even re­sor­ted to re­im­ple­ment­ing this en­tire web ra­dio thing with GStreamer (which is a can of worms of its own, but that’s a story for an­other time.) See, the book has this to say about %flac:

The FLAC en­cod­ing format comes in two fla­vors:

  • %flac is the nat­ive flac form­at, use­ful for file out­put but not for stream­ing pur­pose,
  • %ogg(%flac) is the Og­g/­flac form­at, which can be used to broad­cast data with Icecast.

Note that con­trar­ily to most other co­decs, the two are not ex­actly the same.

I had missed this note en­tirely and only real­ized this was the case when GStreamer flipped out at me for try­ing to com­bine flacenc with shout2send without an oggmux in between. Welp!

-flac = %flac(samplerate=48000)
+flac = %ogg(%flac(samplerate=48000))

Now that the streams are work­ing, I could listen to my mu­sic from any­where. All that’s left is to turn in for the day.

A pro­verb along the lines of “trouble does­n’t travel alone” seems aptly fit­ting here: I found the ra­dio dead si­lent next morn­ing. A SIG­SEGV had taken it down. It turned out that the ocaml-opus and ocaml-flac bind­ings to the en­coders that liquidsoap uses by de­fault were mis­hand­ling my me­dia in some way or an­other. While these is­sues seem scary, the book will make it pretty clear that liquidsoap is ac­tu­ally very ag­nostic to the en­cod­ing and de­cod­ing im­ple­ment­a­tions used, as long as the pre­requis­ite de­pend­en­cies are in­stalled. In par­tic­u­lar it sup­ports two other in­dustry-stand­ard me­dia toolkits – ffm­peg and GStreamer – out of the box, and both are pretty easy to switch to. I chose ffmpeg some­what ar­bit­rar­ily…

flac = %ffmpeg(format="ogg", %audio(codec="flac", channels=2, ar=48000))
output.icecast(flac, host="::1", port=2049, password="x", format="application/ogg", mount="flac", radio)

opus = %ffmpeg(format="ogg", %audio(codec="libopus", channels=2, ar=48000, b="256k"))
output.icecast(opus, host="::1", port=2049, password="x", format="application/ogg", mount="opus", radio)

…and my ra­dio has been rock­-stable since. At the time of writ­ing, this ra­dio has been run­ning for the past 3 weeks without a hitch.

Fu­ture dir­ec­tions

There seems to be a lot of value hav­ing ac­cess to the me­dia player at a level as low as this. In the past I found my­self want­ing a bet­ter shuffle al­gorithm at times. One that would pri­or­it­ize more re­cently ad­ded al­bums, for ex­ample. Now that liquidsoap just in­vokes a com­mand to fig­ure out the next al­bum to play, there’s noth­ing stop­ping me from im­ple­ment­ing ar­bit­rar­ily com­plex lo­gic to fig­ure out what the most ap­pro­pri­ate next al­bum to play is.

Li­quid­soap also provides a lot of con­trol of track trans­itions. Rather than un­con­di­tion­ally play­ing tracks in se­quence, it is now feas­ible for me to ex­plore DJ-­style auto-­mix al­gorithms and any­thing of sim­ilar sorts. This is some­thing I def­in­itely would­n’t have bothered with when lim­ited by a local me­dia play­er. With a ra­dio the res­ults of such ex­plor­a­tions would be uni­ver­sally avail­able across all devices I might want to listen mu­sic on. I prob­ably still won’t ever get to this side pro­ject, but hey, op­tions.

Li­quid­soap also al­lows ex­pos­ing a con­trol web in­ter­face and API en­d­poo­ints. These can be used to en­able ar­bit­rar­ily com­plic­ated ex­ternal con­trol of the ra­dio. For ex­ample, it might make sense to add a method to skip tracks (something most me­dia play­ers come with out of the box), en­queue a you­tube/­sound­cloud/band­camp track, al­bum or per­haps everything match­ing a spe­cific beets query (something av­er­age me­dia play­ers most def­in­itely do not sup­port).

Ul­ti­mately, I’m really happy with li­quid­soap so far and I can really re­com­mend try­ing it out as well if a web­ra­dio (even if a per­sonal one) seems like some­thing you’d like to have.

  1. Li­quid­soap def­in­itely seemed in­tim­id­at­ing to me at first, des­pite the fact that I had been hack­ing on an­other OCaml pro­ject just a week be­fore.↩︎