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.
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 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).
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:
TEST=1
while invoking
the script to switch to a testing endpoint.kbs2
(my password manager)
to retrieve the appropriate credentials as required.gimme-playlist
)
that talks to my Navidrome instance and generates an M3U-formatted playlist.
It also ensures that the playlist is cleaned up automatically, via
the on_shutdown
hook.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:
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. ↩
It can apparently also do video, but I haven’t tried that. ↩
Liquidsoap itself is written in OCaml; the Liquidsoap language is (to my eyes) somewhere between OCaml and Python. ↩