ENOSUCHBLOG

Programming, philosophy, pedaling.


Software-defined (Internet) radio with Liquidsoap

Jun 27, 2023     Tags: howto, music    


This is going to be another short “how-to” blog post on music management, this time on declarative Internet radio streaming with Liquidsoap. I couldn’t find a ton of great examples of Liquidsoap online while defining my own radio stream (besides the project’s own excellent docs), so I figured I’d write one.

Background

I have a friend who runs an Internet radio server; I do a (mostly) weekly show on it.

I’ve done Internet radio before (and ran my own personal server for a few years), and wasn’t particularly happy with the tooling or broadcasting flows: most flows were either extremely inflexible (fixed playlists) or brittle (local loopbacks that sink to a tool like butt, requiring error-prone fiddling with my local sound settings).

In contrast to both of those, I wanted something that could:

These properties brought me to Liquidsoap1.

Liquidsoap: a Swiss Army knife for Internet radio

Liquidsoap is, among other things, an entire programming language dedicated to describing and composing audio streams2.

Conceptually, Liquidsoap turns inputs (a streaming playlist, live microphone input, periodic jingles and announcements, &c) into a stream generator, which is then sunk into outputs (the local audio output, an MP3 backup, an Icecast server, &c).

This is all done with strongly typed ML-ish3 scripts, like the following:

1
2
3
4
5
6
7
8
9
10
source = mksafe(playlist("radio.m3u", mode="normal"))

output.icecast(
  %mp3(bitrate=320, samplerate=44100, stereo=true),
  mount="/stream",
  host="server.example.com",
  port=8000,
  password="hunter2",
  description="my first radio stream",
  source)

The above is a very basic example: all it does it take an M3U-formatted playlist and stream it to an Icecast server.

One of Liquidsoap’s key niceties is infallibility: the language won’t let you define a stream generator that will fail to provide input when the sink needs it. That’s what the mksafe function in the example above does: it converts a fallible source (a playlist) into an infallible one by ensuring that radio silence is streamed if the underlying playlist is malformed or can’t be streamed in time.

We can demonstrate by trying to remove the mksafe and observing the typechecking error Liquidsoap gives us:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# this version may be pretty old;
# see https://www.liquidsoap.info/doc-dev/install.html
sudo apt install -y liquidsoap

liquidsoap 'source = playlist("radio.m3u", mode="normal")

output.icecast(
  %mp3(bitrate=320, samplerate=44100, stereo=true),
  mount="/stream",
  host="server.example.com",
  port=8000,
  password="hunter2",
  description="my first radio stream",
  source)'

At line 0, char 9-45:

Error 7: Invalid value:
That source is fallible

With these building blocks (fallible and infallible sources, stream generators, and outputs) we can begin to do more complex things, like defining crossfades between tracks:

1
2
3
source = playlist("radio.m3u", mode="normal")
source = crossfade(duration=2.0, source)
source = mksafe(source)

…or adding an additional input source (a microphone), and mixing it in with a fade in/out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# music source: playlist
music = mksafe(playlist("radio.m3u", mode="normal"))

# microphone source: amplify to 130% and strip blanks > 0.75 secs
mic = input.alsa(bufferize=false)
mic = amplify(1.3, mic)
mic = blank.strip(max_blank=0.75, mic)

def f(a, b)
  add(normalize=false, [fade.out(a), fade.in(b)])
end

# fade in and out of the mic/music sources
mix = fallback(
  track_sensitive=false,
  transition_length=0.75,
  transitions=[f, f],
  [mic, music])

Liquidsoap also has a mature callback system, allowing us to define behaviors that should occur e.g. whenever the stream’s metadata changes:

1
2
3
# scrobble the stream's metadata whenever it changes (i.e., when a
# new song begins)
music.on_metadata(fun(m) -> lastfm.submit(user="***", password="***", m))

These examples are just the tip of the iceberg; the Core API and Extra API documentation contains far more, including all kinds of neat signals processing and advanced filtering functionality that I haven’t needed (yet).

Typing it all together

Here is what my (admittedly hacky) Liquidsoap-defined Internet radio looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/liquidsoap -v

log.stdout.set(true)

test = getenv("TEST") != ""
mount = if test then "/test" else "/stream" end
date = argv(default=time.string("%Y-%m-%d"), 1)

# Do your own thing here.
radio_password = process.read("kbs2 pass badradio.biz")

# Do your own thing here.
lastfm_username = "yossarian_flew"
lastfm_password = process.read("kbs2 pass last.fm")

# retrieve the playlist
playlist_name = date ^ ".m3u"
playlist_path = "/tmp/" ^ playlist_name

print("writing temp playlist to: " ^ playlist_path)

if process.test("ruby gimme-playlist " ^ date ^ " > " ^ playlist_path) then
  log.important("gimme-playlist: saved to " ^ playlist_path)

  def cleanup()
    file.remove(playlist_path)
  end

  on_shutdown(cleanup)
else
  print("gimme-playlist failed for " ^ date)
  exit(1)
end

music = mksafe(crossfade(duration=2.0, playlist(playlist_path, mode="normal")))

# TODO: Use lastfm.submit.full instead, once it works with crossfades.
# See: https://github.com/savonet/liquidsoap/issues/3172
# music = lastfm.submit.full(user=lastfm_username, password=lastfm_password, music)
music.on_metadata(fun(m) -> lastfm.submit(user=lastfm_username, password=lastfm_password, m))

# microphone source: amplify to 130% and strip blanks > 0.75 secs
mic = input.alsa(bufferize=false)
mic = amplify(1.3, mic)
mic = blank.strip(max_blank=0.75, mic)

def f(a, b)
  add(normalize=false, [fade.out(a), fade.in(b)])
end

# fade in and out of the mic/music sources
mix = fallback(
  track_sensitive=false,
  transition_length=0.75,
  transitions=[f, f],
  [mic, music])

output.alsa(bufferize=false, mix)

output.icecast(
  %mp3(bitrate=320, samplerate=44100, stereo=true),
  mount=mount,
  host="server.badradio.biz",
  port=8000,
  password=radio_password,
  url="https://yossarian.net/junk/badradio/" ^ date,
  description="the rummage bin",
  mix)

Starting the stream is as simple as:

1
2
3
4
5
# `radio.liq` is the source file
liquidsoap radio.liq -- "2023-06-26"

# stream to the test endpoint instead
TEST=1 liquidsoap radio.liq -- "2023-06-26"

This does a few things automatically for me:

All told, Liquidsoap has made a normally stressful and unappealing task into a genuinely fun one: it’s allowed me to spend less time thinking about ways in which my stream can break catastrophically, and more time actually planning my shows. It’s also given me plenty of ideas for future improvements, including:


  1. More accurately, they brought me back to Liquidsoap: I had discovered it years ago, but had completely forgotten about it except as “interesting looking stream management tool.” I had to search for it online to find it again, which was surprisingly hard to do. 

  2. It can apparently also do video, but I haven’t tried that. 

  3. Liquidsoap itself is written in OCaml; the Liquidsoap language is (to my eyes) somewhere between OCaml and Python. 


Discussions: Mastodon Reddit