Make a private web-radio with LiquidSoap 2.2x

Liquidsoap logo

Make a private web-radio with LiquidSoap 2.2x

Today there are so many streaming services that you can use. But they can be costly, especially if you go with lossless streaming. If you have a large musical collection, wouldn’t it be great if you could make your own private web-radio with it?

Granted, there are service like Roon that can do that. But I personally never enjoyed Roon radio as it always ends up playing something unexpected. So I decided that I should try making my own private web-radio.

This article does not cover the legal requirements about making a publicly accessible web-radio in your country of residence and I strongly advice that you read those before you do so.

I have all my music on my NAS and I wanted a stream that plays randomly among multiple playlists. I also have a VPS, you can find some pretty cheap ones for as low as 3 to 5 euros per month. No need for too much RAM or vCPUs, although I advise having at least 2 GB of RAM.

So to resume what I had at the beginning of my project:

  1. The sender: it’s my NAS. All my music is on it. I have it in FLAC format so I can stream lossless music. The NAS is currently running Linux. Although I could totally host an Icecast instance on my NAS, I needed to manage a dynhost solution and this has proven to be unreliable so I opted to send the stream to an external server, that I call the receiver.
  2. The receiver: a little inexpensive VPS, running Linux as well.
  3. I think this is an important prerequisite as well : a fast internet connection ideally at least 100 Mbits so that it won’t slow down your other activites.

I tried many solutions:

  • Streaming music from Mixxx to an Icecast instance on my server
  • Streaming music from Liquidsoap to an Icecast instance on my server
  • Streaming music from Liquidsoap to another Liquidsoap instance using Harbor

But those solutions were very unstable. I needed the following constraints to be respected:

  • The whole system should be stable, tolerant to internet cuts, self restartable. And I cannot emphasize enough how important this is. When you decide to
  • Ideally the sender would send only one lossless stream to the server that will then transcode to different format on the fly. This is to save CPU on my NAS (or it would starts to be noisy) and also bandwidth (send one stream uses less bandwidth than four)
  • It should not use too much resources both on the sender and the receiver

So here is what worked best for me:

On the sender, I have a Liquidsoap instance. It’s reading my playlists, each playlist has a weight so that I can control how often a song from a particular playlist will be played.

On the receiver, I have another Liquidsoap instance, and an Icecast instance. That Liquidsoap instance receives the audio stream, transcodes it to several formats on the fly, and sends the streams to the Icecast instance.

I chose to use the SRT transport between the two Liquidsoap instances for it’s stability and reliability as I had some resources issues with Harbor, another way to transmit the audio data.

OPTIONAL. A web-radio is usually coupled to a website, so for educational purpose I kept some optional parts that I highlighted. The sender will make some requests on the server to update metadata on the Liquidsoap instance, because metadata is currently not updated when streaming FLAC data. Also to update what’s playing now on a website. I also have a system that let me skip the track currently playing, get the current song time and metadata, However this article will focus more on getting the stream from a point A to a point B, to an Icecast instance. So feel free to remove any optional part. If you need to know, I have an express + socket.io server on both the sender and the receiver that let me communicate from the website to the sender, and vice versa. Interaction with a website could be another article.

The usernames, hosts, ports and passwords have been changed so you should replace with what works for you.

I used the rolling release of Liquidsoap 2.2.0 at the time of writing, so make sure that you use at least this version or ulterior.

Here is the script that I use on the sender:

#!/usr/bin/liquidsoap

#settings
settings.sandbox.set(true)
settings.sandbox.network.set(true)
settings.sandbox.shell.set(true)
settings.sandbox.shell.path.set("/bin/bash")


# Log dir
log.file.path.set("/home/user/radio.log")
log.level.set(4)


# (optional) This function is called when
# a new metadata block is passed in
# the stream.
def apply_metadata(m) =

    log("calling apply_metadata")

    artist = url.encode(m["artist"])
    album  = url.encode(m["album"])
    title  = url.encode(m["title"])

    if(artist == "" and album == "" and title == "") then
        log("Artist, album and title all empty!")
    else
        query = "artist=#{artist}&album=#{album}&title=#{title}"
        icy   = "#{artist}+-+#{title}"
        log("query for metadata server: #{query}")
        log("icy for icecast metadata: #{icy}")

        # Update website currently playing
        command = process.quote.command( args=["-m", "2", "https://server.com/update_metadata?#{query}"], "curl" )
        process.run(timeout=4.0, command)

        # Update website history
        command = process.quote.command( args=["-m", "2", "https://server.com/update_history?#{query}"], "curl" )
        process.run(timeout=4.0, command)
        
        # Update mp3 stream metadata
        command = process.quote.command( args=["-m", "2", "-u", "admin:hackme", "https://server.com:8888/admin/metadata?mount=%2Fstream.mp3&mode=updinfo&charset=UTF-8&song=#{icy}"], "curl" )
        process.run(timeout=4.0, command)

        # Update ogg stream metadata
        command = process.quote.command( args=["-m", "2", "-u", "admin:hackme", "https://server.com:8888/admin/metadata?mount=%2Fstream.ogg&mode=updinfo&charset=UTF-8&song=#{icy}"], "curl" )
        process.run(timeout=4.0, command)

        # flac and opus do not support updating metadata
    end
end

# (optional) runs on new metadata on a different thread
def apply_metadata_thread(m) =
    thread.run({apply_metadata(m)})
end

# Music
playlist_ambient   = playlist(id="radio_ambient"  , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_ambient.pls")
playlist_chillout  = playlist(id="radio_chillout" , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_chillout.pls")
playlist_dnb       = playlist(id="radio_dnb"      , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_dnb.pls")
playlist_dub       = playlist(id="radio_dub"      , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_dub.pls")
playlist_dubstep   = playlist(id="radio_dubstep"  , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_dubstep.pls")
playlist_electro   = playlist(id="radio_electro"  , mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_electro.pls")
playlist_futurepop = playlist(id="radio_futurepop", mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_futurepop.pls")
playlist_nightcity = playlist(id="radio_nightcity", mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_nightcity.pls")
playlist_synthwave = playlist(id="radio_synthwave", mode="randomize", reload=3600, reload_mode="seconds", "/home/user/radio_synthwave.pls")
playlist_mix = random(
    id="playlist_mix",
    transitions=[],
    transition_length=0.0,
    weights = [3, 15, 18, 2, 1, 6, 21, 3, 75],
    [
        playlist_ambient,
        playlist_chillout,
        playlist_dnb,
        playlist_dub,
        playlist_dubstep,
        playlist_electro,
        playlist_futurepop,
        playlist_nightcity,
        playlist_synthwave
    ]
)

# If something goes wrong, we'll play this
security=single(
    id="security_single",
    "/home/user/default.wav"
)

# create the radio stream
radio_stream=blank.skip(
    id="radio_stream_blank_skipper",
    # "radio_to_stereo is deprecated in 2.2.0, use stereo instead"
    stereo(
        id="radio_stream_audio_to_stereo",
        blank.eat(
            id="playlist_mix_blank_eater",
            max_blank=5.0,
            playlist_mix
        )
    )
)

# And finally the security
radio=fallback.skip(
    radio_stream,
    fallback=security
)

# (optional) action on metadata
# execute on another thread to avoid disconnecting
radio.on_metadata(apply_metadata_thread)

# add blank so that the stream never stops
safe_radio=mksafe(
    id="safe_radio_mksafe",
    buffer(
        id="safe_radio_buffer",
        add(
            id="safe_radio_add",
            normalize=false,
            [
                blank(
                    id="safe_radio_blank"
                ),
                radio
            ]
        )
    )
)

# encode to PCM S16LE
rawpcmstream = ffmpeg.encode.audio(
    id="rawpcm_stream",
    %ffmpeg(
        %audio(
            codec="pcm_s16le",
            ac=2,
            ar=48000
        )
    ),
    safe_radio
)

# send the stream to a server
output.srt(
    id="output_srt_myradio",
    %ffmpeg(
        format="s16le",
        %audio.copy
    ),
    host="server.com",
    port=9999,
    passphrase="hackme",
    rawpcmstream
)

# (optional) called when accessing the HTTP "/skip" API endpoint
def try_skip(request, response) =
    log("Got a request on path #{request.path}, protocol version: #{request.http_version}, \
           method: #{request.method}, headers: #{request.headers}, query: #{request.query}, \
           body: #{request.body()}")
    
    # skip to next track
    log("Skipping to next track!")
    source.skip(playlist_mix)

    # write response for debug
    response.status_code(200)
    response.status_message("OK")
    response.content_type("application/json")
    response.http_version("1.1")
    response.json({status = "success"})
end

# (optional) called when accessing the HTTP "/time" API endpoint
def try_time(request, response) =
    log("Got a request on path #{request.path}, protocol version: #{request.http_version}, \
           method: #{request.method}, headers: #{request.headers}, query: #{request.query}, \
           body: #{request.body()}")
    
    # get current track time information
    log("Getting current track time information")
    duration=radio.duration()
    elapsed=radio.elapsed()
    remaining=radio.remaining()

    # write response for debug
    response.status_code(200)
    response.status_message("OK")
    response.content_type("application/json")
    response.http_version("1.1")
    response.json({
        duration=duration,
        elapsed=elapsed,
        remaining=remaining
    })
end

# (optional) called when accessing the HTTP "/meta" API endpoint
def try_meta(request, response) =
    log("Got a request on path #{request.path}, protocol version: #{request.http_version}, \
           method: #{request.method}, headers: #{request.headers}, query: #{request.query}, \
           body: #{request.body()}")
    
    # get current track time information
    log("Getting current track time information")
    meta=radio.last_metadata()
    #apply_metadata(meta)
    if( null.defined(meta) ) then
        log( "not null" )
        apply_metadata_thread(meta ?? [])
    else
        log( "null" )
    end

    # write response for debug
    response.status_code(200)
    response.status_message("OK")
    response.content_type("application/json")
    response.http_version("1.1")
    response.json({status = "success"})
end

# (optional) register HTTP "/skip" API endpoint
harbor.http.register(
    port=5678,
    method="GET",
    "/skip",
    try_skip
)

# (optional) register HTTP "/time" API endpoint
harbor.http.register(
    port=5677,
    method="GET",
    "/time",
    try_time
)

# (optional) register HTTP "/meta" API endpoint
harbor.http.register(
    port=5676,
    method="GET",
    "/meta",
    try_meta
)

Then here is the Liquidsoap script on the receiver end:

#!/usr/bin/liquidsoap

home_path="/home/user"
log_path="#{home_path}/radio.log"
default_wav_path="#{home_path}/default.wav"
harbor_port=4444
harbor_password="hackme"
srt_port=9999
srt_password="hackme"
radio_name="My Awesome Web Radio"
radio_desc="Description of my Awesome Web Radio"
radio_genre="electronic"
radio_host="server.com"
radio_url="https://#{radio_host}"
icecast_host="localhost"
icecast_port=8887
icecast_password="hackme"

settings.harbor.bind_addrs.set(["0.0.0.0"])

log.file.path.set(log_path)
log.level.set(4)

# configure security input
security = single(
    id="security_single",
    default_wav_path
)

# (optional) configure live harbor input
raw_harbor_live=input.harbor(
    id="raw_harbor_live",
    port=harbor_port,
    #transport=harbor_transport,
    password=harbor_password,
    buffer=2.0,
    replay_metadata=true,
    metadata_charset="UTF-8",
    icy=true,
    icy_metadata_charset="UTF-8",
    "/live"
)

# process live harbor input
live_harbor=blank.strip(
    id="live_harbor_blank_stripper",
    stereo(
        id="live_harbor_stereo",
        raw_harbor_live
    )
)

# configure SRT input
raw_srt_input=input.srt(
    content_type="application/ffmpeg;format=s16le,channels=2,sample_rate=48000",
    id="input_srt_master_stream",
    passphrase=srt_password,
    enforced_encryption=true,
    port=srt_port
)

# process SRT input
input_srt=blank.strip(
    id="input_srt_blank_stripper",
    stereo(
        id="input_srt_stereo",
        raw_srt_input
    )
)

# main source switcher
source_switcher=fallback(
    id="source_switcher",
    track_sensitive=false,
    [
        # (optional) comment "live_harbor," line if you don't need live override
        live_harbor,
        input_srt,
        security
    ]
)

# function that makes a safe radio
def saferadio(radio, n) =
    mksafe(
        id="radio_mksafe_#{n}",
        radio
    )
end

radio_ogg=saferadio(
    source_switcher,
    "ogg"
)
radio_opus=saferadio(
    source_switcher,
    "opus"
)
radio_mp3=saferadio(
    source_switcher,
    "mp3"
)
radio_flac=saferadio(
    source_switcher,
    "flac"
)

output.icecast(
    id="output_icecast_ogg",
    %ffmpeg(
        format="ogg",
        %audio(
            codec="libvorbis",
            global_quality="9"
        )
    ),
    radio_ogg,
    host=icecast_host,
    port=icecast_port,
    password=icecast_password,
    mount="/stream.ogg",
    name="#{radio_name} (OGG)",
    genre=radio_genre,
    description=radio_desc,
    url="#{radio_url}/stream.ogg",
    send_icy_metadata=true,
    encoding="UTF-8",
    format="audio/ogg",
    start=true
)

output.icecast(
    id="output_icecast_opus",
    %ffmpeg(
        format="opus",
        %audio(
            codec="libopus",
            b="327680",
            ar="48000"
        )
    ),
    radio_opus,
    host=icecast_host,
    port=icecast_port,
    password=icecast_password,
    mount="/stream.opus",
    name="#{radio_name} (OPUS)",
    genre=radio_genre,
    description=radio_desc,
    url="#{radio_url}/stream.opus",
    send_icy_metadata=true,
    encoding="UTF-8",
    format="audio/ogg",
    start=true
)

output.icecast(
    id="output_icecast_mp3",
    %ffmpeg(
        format="mp3",
        %audio(
            codec="libmp3lame",
            b="320k"
        )
    ),
    radio_mp3,
    host=icecast_host,
    port=icecast_port,
    password=icecast_password,
    mount="/stream.mp3",
    name="#{radio_name} (MP3)",
    genre=radio_genre,
    description=radio_desc,
    url="#{radio_url}/stream.mp3",
    send_icy_metadata=true,
    encoding="UTF-8",
    format="audio/mpeg",
    start=true
)

output.icecast(
    id="output_icecast_flac",
    %ffmpeg(
       format="ogg",
       %audio(
           codec="flac",
           ar=48000,
           ac=2,
           compression_level=8
       )
    ),
    radio_flac,
    host=icecast_host,
    port=icecast_port,
    password=icecast_password,
    mount="/stream.flac",
    name="#{radio_name} (FLAC)",
    genre=radio_genre,
    description=radio_desc,
    url="#{radio_url}/stream.flac",
    send_icy_metadata=true,
    encoding="UTF-8",
    format="audio/ogg",
    start=true
)

Of course you need an Icecast instance. Here is my config, stripped of all the comments:

<icecast>
    <location>Earth</location>
    <admin>admin@server.com</admin>
    <limits>
        <clients>100</clients>
        <sources>10</sources>
        <queue-size>524288</queue-size>
        <client-timeout>30</client-timeout>
        <header-timeout>15</header-timeout>
        <source-timeout>10</source-timeout>
        <burst-on-connect>1</burst-on-connect>
        <burst-size>65535</burst-size>
    </limits>

    <authentication>
        <source-password>hackme</source-password>
        <relay-password>hackme</relay-password>
        <admin-user>admin</admin-user>
        <admin-password>hackme</admin-password>
    </authentication>

    <hostname>server.com</hostname>

    <listen-socket>
        <port>8887</port>
    </listen-socket>

    <listen-socket>
        <port>8888</port>
        <ssl>1</ssl>
    </listen-socket>

    <http-headers>
        <header name="Access-Control-Allow-Origin" value="*" />
    </http-headers>

    <relays-on-demand>1</relays-on-demand>

    <mount type="default">
        <public>0</public>
    </mount>

    <mount type="normal">
        <mount-name>/live</mount-name>
        <public>0</public>
        <hidden>1</hidden>
    </mount>
    <mount type="normal">
        <mount-name>/stream.ogg</mount-name>
        <public>0</public>
        <stream-name>My Awesome Web Radio (OGG)</stream-name>
        <stream-description>Description of my Awesome Web Radio</stream-description>
        <stream-url>https://server.com/stream.ogg</stream-url>
    </mount>
    <mount type="normal">
        <mount-name>/stream.opus</mount-name>
        <public>0</public>
        <stream-name>My Awesome Web Radio (OPUS)</stream-name>
        <stream-description>Description of my Awesome Web Radio</stream-description>
        <stream-url>https://server.com/stream.opus</stream-url>
    </mount>
    <mount type="normal">
        <mount-name>/stream.mp3</mount-name>
        <public>0</public>
        <stream-name>My Awesome Web Radio (MP3)</stream-name>
        <stream-description>Description of my Awesome Web Radio</stream-description>
        <stream-url>https://server.com/stream.mp3</stream-url>
    </mount>
    <mount type="normal">
        <mount-name>/stream.flac</mount-name>
        <public>0</public>
        <stream-name>My Awesome Web Radio (FLAC)</stream-name>
        <stream-description>Description of my Awesome Web Radio</stream-description>
        <stream-url>https://server.com/stream.flac</stream-url>
    </mount>

    <fileserve>1</fileserve>

    <paths>
        <basedir>/usr/share/icecast2</basedir>

        <logdir>/var/log/icecast2</logdir>
        <webroot>/usr/share/icecast2/web</webroot>
        <adminroot>/usr/share/icecast2/admin</adminroot>
        <ssl-certificate>/etc/icecast2/bundle.pem</ssl-certificate>
    </paths>

    <logging>
        <accesslog>access.log</accesslog>
        <errorlog>error.log</errorlog>
        <loglevel>3</loglevel>
        <logsize>10000</logsize>
    </logging>

    <security>
        <chroot>0</chroot>
    </security>
</icecast>

And if you want to make a reverse proxy on a stream with an Nginx server, to access the streams without ports numbers for example, here is how you do it:

server {
    listen 443 ssl http2;

    ...

    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    proxy_read_timeout 24h;
    fastcgi_read_timeout 24h;
    chunked_transfer_encoding off;

   location /stream.ogg {
        proxy_pass          http://localhost:8887/stream.ogg;
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto http;
        proxy_set_header    Cache-Control     no-cache;
        proxy_read_timeout 24h;
        proxy_pass_request_headers on;
        proxy_set_header Access-Control-Allow-Origin *;
        proxy_set_header Range bytes=0-;
        proxy_buffering off;
        tcp_nodelay on;
        types        { }
        default_type audio/ogg;
    }
    location /stream.opus {
        proxy_pass          http://localhost:8887/stream.opus;
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto http;
        proxy_set_header    Cache-Control     no-cache;
        proxy_read_timeout 24h;
        proxy_pass_request_headers on;
        proxy_set_header Access-Control-Allow-Origin *;
        proxy_set_header Range bytes=0-;
        proxy_buffering off;
        tcp_nodelay on;
        types        { }
        default_type audio/ogg;
    }
    location /stream.mp3 {
        proxy_pass          http://localhost:8887/stream.mp3;
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto http;
        proxy_set_header    Cache-Control     no-cache;
        proxy_read_timeout 24h;
        proxy_pass_request_headers on;
        proxy_set_header Access-Control-Allow-Origin *;
        proxy_set_header Range bytes=0-;
        proxy_buffering off;
        tcp_nodelay on;
        types        { }
        default_type audio/mpeg;
    }
    location /stream.flac {
        proxy_pass          http://localhost:8887/stream.flac;
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto http;
        proxy_set_header    Cache-Control     no-cache;
        proxy_read_timeout 24h;
        proxy_pass_request_headers on;
        proxy_set_header Access-Control-Allow-Origin *;
        proxy_set_header Range bytes=0-;
        proxy_buffering off;
        tcp_nodelay on;
        types        { }
        default_type audio/ogg;
    }

    ...
    
}

Once you got all that set up and running, there are a few things that you need to consider:

  1. You should run the Liquidsoap scripts in something like Screen or Tmux
  2. You should find a way to restart the scripts automatically when the server restarts

Usually I proceed like this on a Linux server:

Root can run a script when the server starts. For example, place this in /root/.bash_profile:

/root/start_tmux_sessions.sh

Content of /root/start_tmux_sessions.sh:

#!/usr/bin/env bash
su -c "~/start_tmux.sh" - user

Content of /home/user/start_tmux.sh:

#!/usr/bin/env bash
if tmux has-session -t liquidsoap > /dev/null 2>&1; then
    echo "tmux session 'liquidsoap' already exists"
else
    echo "Starting tmux session 'liquidsoap'..."
    tmux new-session -d -s liquidsoap "cd /home/user/radio/ && /home/user/radio/startliquidsoap.sh"
    tmux new-window -d -t liquidsoap
fi

Content of /home/user/radio/startliquidsoap.sh:

#!/usr/bin/env bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd $SCRIPT_DIR
liquidsoap ./radio.liq

Hopefully you can adapt all this to your needs, I hope that all this will be useful to someone!

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.