Liquidsoap 1.3.0 : Liquidsoap Workshop, part II

Building an advanced stream: file-based sources

The purpose of this part is to document and illustrate the creation of an advanced stream using static files.

In order to be self-contained, we will use here only pure liquidsoap scripting functionalities. However, all the parts that use pre-defined functions can be implemented using external scripts, which is the most common practice, and has proved to be very convenient in order to integrate your liquidsoap stream into the framework that you use to manage your radio.

Preliminaries

In order to make things more clear and modular, we will separate the code in two parts:

The scripts here should be tested using the following command line:

liquidsoap /path/to/radio.liq

Thus, we do not define here daemonized script. In order to make things work smoothly, you should put the following lines at the beginning of radio.liq:

set("log.file",false)
set("log.stdout",true)
set("log.level",3)
Grab the code!

Finally, we add the following line at the beginning of radio.liq, in order to load our pre-defined functions:

%include "/path/to/library.liq"

We will use the telnet server to interact with the radio. Thus, we enable the telnet server by adding the following line in radio.liq:

set("server.telnet",true)
Grab the code!

An initial model

In this part, we describe the initial stream that we want. We start with a simple stream that contains songs from a static playlist, with some jingles, with 3 songs for one jingle, and output the result to an icecast server. This is described by the following graph:

Initial stream model

This very simple stream is defined by the following content in radio.liq:

# The file source
songs = playlist("/path/to/some/files/")

# The jingle source
jingles = playlist("/path/to/some/jingles")

# We combine the sources and play 
# one single every 3 songs:
s = rotate(weights=[1,3], [jingles, songs])

# We output the stream to an icecast
# server, in ogg/vorbis format.
output.icecast(%vorbis,id="icecast",
               fallible=true,mount="my_radio.ogg", 
               host="my_server", password="hack_me_not",
               s)
Grab the code!

For now, library.liq does not contain any code so we only do:

touch /path/to/library.liq

Now, we extend this initial stream with some advanced features:

Notify when a song is played

Once the stream is started, we may want to be able to keep track of the songs that are played, for instance to display this information on a website. One nice way to do this is to call a function every time that a new track is passed to the output, which will inform the user of which tracks are played and when. This can be done using the on_metadata operator.

First, we define a function that is called every time a new metadata is seen in the stream. This is a function of type (metadata)->unit, i.e. a function that receives the metadata as argument and returns nothing. The metadata type is actually [(string*string)], i.e. a list of elements of the form ("label","value").

Thus, we add the following in library.liq:

# This function is called when
# a new metadata block is passed in
# the stream.
def apply_metadata(m) =
  title = m["title"]
  artist = m["artist"]
  print("Now playing: #{title} by #{artist}")
end
Grab the code!

Note: the string "foo #{bla}" can also be written "foo " ^ bla and is the string “foo bar”, if bla is the string "bar".

Now, we apply the on_metadata operator with this function just before passing the final source to the output, so we write in radio.liq, before the output line:

s = on_metadata(apply_metadata,s)
Grab the code!

Solutions:

Custom scheduling

Another issue with the above stream is the fact that jingles have a strict frequency of one jingle every 3 songs. In a lot of cases, you may want more flexibility and have full-features scheduling of your songs. The best approach in this case is to externalize this operation by creating a scheduler with the language/framework of your choice and integrating it with liquidsoap using request.dynamic.

request.dynamic takes a function of type ()->request, i.e. a function with no arguments that returns a new request to queue and create a source with it. Every time that liquidsoap needs to prepare a new file, it will execute the function and use its result.

Requests in liquidsoap are created with the function request.create, which takes an URIs of the form:

protocol:arguments

where protocol: is optional is arguments is the URI of a local file. For instance, ftp://server.net/path/to/file.mp3 is a requests using the ftp protocol, which is resolved using wget (if present in the system).

We are going to use request.dynamic to merge both the songs and jingles sources into one source and let our external scheduler decides when to play a jingle or a song. However, we will need later to know if we are currently playing a song or a jingle.

For these reasons, we will be using the annotate: protocol. This protocol can be used to pass additional metadata along with the metadata of the file. Here, we will pass a metadata labeled "type", with value "song" if the track is a song or "jingle" otherwise.

In the context of this simple presentation, we will write a dummy script. Thus, we create a file "/tmp/request" that contains a line of the form:

annotate:type="song":/path/to/song.mp3

And, we add in library.liq:

# Our custom request function
def get_request() = 
  # Get the URI
  uri = list.hd(get_process_lines("cat /tmp/request"))
  # Create a request
  request.create(uri)
end
Grab the code!

Now, we replace the lines defining songs, files and the line using the rotate operator in radio.liq with the following code:

s = request.dynamic(id="s",get_request)
Grab the code!

annotate:type="jingle":/path/to/jingle.mp3

Solutions:

Custom metadata

We have just seen how it is possible to use the annotate: protocol to pass custom metadata to any request. Additionally, it is also possible to rewrite your stream's metadata on the fly, using the on_metadata operator.

This operator takes a function of the type metadata->metadata, i.e. a function that takes the current metadata and returns some metadata. Thus, when map_metadata sees a new metadata in the stream, it calls this function and, by default, updates the metadata with the values returned by the function.

Here, we use this operator to customize the title metadata with the name of our radio. First, we create a file "/tmp/metadata" containing:

My Awesome Liquidsoap Radio!

Then, in library.liq, we add the following function:

# This function updates the title metadata with
# the content of "/tmp/metadata"
def update_title(m) = 
  # The title metadata
  title = m["title"]
  # Our addition
  content = list.hd(get_process_lines("cat /tmp/metadata"))
  
  # If title is empty
  if title == "" then
    [("title",content)]
  # Otherwise
  else
    [("title","#{title} on #{content}")]
  end
end
Grab the code!

Finally, we apply map_metadata to the source, just after the request.dynamic definition in radio.liq:

s = map_metadata(update_title,s)
Grab the code!

Solutions:

Infallible sources

It is reasonable, for a radio, to expect that a stream is always available for broadcasting. However, problems may happen (and always do at some point). Thus, we need to offer an alternative for the case where nothing is available.

Instead of using mksafe, which streams blank audio when this happens, we use a custom sound file. For instance, this sound file may contain a sentence like “Hello, this is radio FOO! We are currently having some technical difficulties but we'll be back soon so stay tuned!”.

We do that here using the say: protocol, which creates a speech synthesis of the given sentence. Otherwise, you may record a (more serious) file and pass it to the single operator...

First, we add the following in library.liq

# This function turns a fallible
# source into an infallible source
# by playing a static single when
# the original song is not available
def my_safe(s) =
  # We assume that festival is installed and
  # functional in liquidsoap
  security = single("say:Hello, this is radio FOO! \
                     We are currently having some \
                     technical difficulties but we'll \
                     be back soon so stay tuned!")

  # We return a fallback where the original
  # source has priority over the security
  # single. We set track_sensitive to false
  # to return immediately to the original source
  # when it becomes available again.
  fallback(track_sensitive=false,[s,security])
end
Grab the code!

Then, we add the following line in radio.liq, just before the output line:

s = my_safe(s)
Grab the code!

And we also remove the fallible=true from the parameters of output.icecast.

Solutions:

Multiple outputs

We may as well output the stream to several targets, for instance to different icecast mount points with different formats. Therefore, we define a custom output function that defines all these outputs.

We add the following in library.liq:

# A function that contains all the output
# we want to create with the final stream
def outputs(s) =
  # First, we partially apply output.icecast
  # with common parameters. The resulting function
  # is stored in a new definition of output.icecast,
  # but this could be my_icecast or anything.
  output.icecast = output.icecast(host="my_server", 
                                  password="hack_me_not")

  # An output in ogg/vorbis to the "my_radio.ogg"
  # mountpoint:
  output.icecast(%vorbis, mount="my_radio.ogg",s)
  
  # An output in mp3 at 128kbits to the "my_radio"
  # mountpoint:
  output.icecast(%mp3(bitrate=128), mount="my_radio",s)

  # An output in ogg/flac to the "my_radio-flac.ogg"
  # mountpoint:
  output.icecast(%ogg(%flac), mount="my_radio-flac.ogg",s)

  # An output in AAC+ at 32 kbits to the "my_radio.aac"
  # mountpoint
  output.icecast(%fdkaac(bitrate=32), mount="my_radio.aac",s)
end
Grab the code!

And we replace the output line in radio.liq by:

outputs(s)
Grab the code!

Note liquidsoap may fail with the following error:

Connection failed: 403, too many sources connected (HTTP/1.0)!

In this case, you should check the maximum number of sources that your icecast server accepts.

Solutions:

More advanced functions!

Now that we have a controllable initial radio, we extend our initial scripts to add advanced features. The following graph illustrates what we are going to add:

Advanced stream model

Replaygain

The replaygain support is achieved in liquidsoap in two steps:

The "replay_gain" metadata can be passed manually or computed by liquidsoap. Liquidsoap comes with a script that can extract the replaygain information from ogg/vorbis, mp3 and FLAC files. This is a very convenient script but it generate a high CPU usage which can be bad for real-time streaming. In some situations, you may compute beforehand this value and pass it manually using the annotate protocol.

If you cannot compute the value beforehand, liquidsoap comes with two ways to extract the replaygain information

The most simple solution, in our case, is to change the requests passed to request.dynamic to something of the form:

annotate:type="song":replay_gain:URI

However, in order to illustrate a bit more the functionalities of liquidsoap we present another solution. The method we propose here consists in using map_metadata, which we have already seen to update the metadata with a "replay_gain" metadata when we see the "type" metadata with the value "song". Thus, we add the following function in library.liq:

# This function takes a metadata,
# check if it is of type "file"
# and add the replay_gain metadata in
# this case
def add_replaygain(m) = 
  # Get the type
  type = m["type"]
  # The replaygain script is located there
  script = "#{configure.libdir}/extract-replaygain"
  # The file name is contained in this value
  filename = m["filename"]

  # If type = "song", proceed:
  if type == "song" then
    info = list.hd(get_process_lines("#{script} #{filename}")) 
    [("replay_gain",info)]
  # Otherwise add nothing
  else
    []
  end
end
Grab the code!

And, we add the following line in radio.liq after the request.dynamic line:

s = map_metadata(add_replaygain,s)
Grab the code!

Finally, we add the amplify operator. We set the default amplification to 1., i.e. no amplification, and tell the operator to update this value with the content of the "replay_gain" metadata. Thus, only the tracks which have this metadata will be modified.

We add the following in radio.liq, after the line we just inserted:

s = amplify(override="replay_gain",1.,s)
Grab the code!

Note we can also apply amplify only to songs, before the switch operator

Note in this case, the replay_gain metadata is not added during the request resolution. Thus, it is not visible in the request.metadata. However, you should be able to find another command that displays it!

Solutions:

Smart crossfade

The smart_crossfade is a crossfade operator that decides the crossfading to apply depending on the volume and metadata of the old and new track.

It is defined using a generic smart_cross operator, that takes a function of type (float, float, metadata, metadata, source, source) -> source, i.e. a function that take the volume level (in decibels) of, respectively, the old and new tracks, the metadata of, resp. the old and new tracks and, finally, the old and new tracks, and returns the new source with the required transition.

We give here a simple custom implementation of our crossfade. What we do is:

We identify the type of each track by reading the "type" metadata we have added when creating the request.dynamic source.

A typical smart_crossfade operator is defined in utils.liq but you may do much more things with a little bit of imagination.

Here, we add the following in library.liq:

# Our custom crossfade that 
# only crossfade between tracks
def my_crossfade(s) = 
  # Our transition function
  def f(_,_, old_m, new_m, old, new) = 
    # If none of old and new have "type" metadata
    # with value "jingles", we crossfade the source:
    if old_m["type"] != "jingle" and new_m["type"] != "jingle" then
      add([fade.initial(new), fade.final(old)])
    else
      sequence([old,new])
    end
 end
 # Now, we apply smart_cross with this function:
 smart_cross(f,s)
end
Grab the code!

Finally, we add the following line in radio.liq, just after the amplify operator:

s = my_crossfade(s)
Grab the code!

Solutions:

Smooth_add

Finally, we add another nice feature: a jingle that is played on top of the current stream. We use the smooth_add operator, which is also defined in utils.liq. This operator takes a normal source and a special jingle source. Every time that a new track is available in the special source, it fades out the volume of the normal source, plays the track from the special source on top of the current track of the normal source, and then fades back in the volume of the normal source when the track is finished.

Typically, you use for the special source a request.queue where you push a new jingle every time you want to use this feature.

We modify radio.liq and add the following line just before my_safe:

# A special source
special = request.queue(id="special")
# Smooth_add the special source
s = smooth_add(normal=s,special=special)
Grab the code!

Solutions:

What about DJs?

We present now another important part of an advanced stream: the addition of a live stream in order to allow DJs to broadcast their shows.

We are going to add the following features:

Live inputs

The live inputs in liquidsoap are of two types:

We focus here on the first type, and more precisely on input.harbor. When using this operator in your script, the running instance will be able to receive data coming from icecast and shoutcast source clients. Then, your DJs can broadcast a live stream using their favorite software. Liquidsoap supports most of the usual data formats, when enabled as encoder:

You may also communicate data between two liquidsoap instance, one using output.icecast to send data and the other one input.harbor to receive it. In this case, you wan also use the WAVE or FLAC format to send lossless data.

We add a live source in radio.liq, anywhere before the outputs:

live = input.harbor("live")
Grab the code!

Note "live" is the name of the mountpoint that will be associated to this source. The default parameters for the port, user and password are contained in the following settings:

set("harbor.password","hackme")
set("harbor.port",8005)
set("harbor.username","source")
Grab the code!

We want the live source to be played as soon as it becomes available. Thus, we use a fallback to combine it with the file-based source, and add the following code after my_safe in radio.liq:

s = fallback(track_sensitive=false, [live,s])
Grab the code!

Note the track_sensitive=false parameter tells liquidsoap to switch immediately to live when it becomes available instead of waiting for the end of the track currently played by s.

Solutions:

Enabling shoutcast clients

By default, shoutcast source clients are not supported. You can enable them by adding the following settings:

set("harbor.icy",true)
Grab the code!

Note ICY is the technical name of the original shoutcast source protocol.

Additionally, the shoutcast source protocol does not support the notion of mountpoint: all the sources try to connect to the same "/" mountpoint. However, you can emulate this in liquidsoap by using different harbor sources on different port.

For instance, if we replace the definition of live in radio.liq with the following:

live1 = input.harbor(port=9000,"/")
live2 = input.harbor(port=7000,"/")
Grab the code!

And the fallback line with:

s = fallback(track_sensitive=false, [live1,live2,s]) 
Grab the code!

Then your a DJ should be able to send data using the port 9000 and another one using the port 7000, and the one connecting on port 9000 may be played in priority if the two are connected at the same time.

Solutions:

A nice transition!

Now that our radio support live shows, we deal with another issue: when switching to the live show, the current song is cut at the point where it is and the audio content switches over to the live data without any transition, which is not very nice for the listeners. Further, when switching back to the file-based source at the end of the live, the source resumes in the middle of the song that was last played..

In this part, we define a transition for switching from file to live, which fades the current song out and superposes a jingle before starting the live show. We use the transition parameter of the fallback operators.

This parameter contains functions of the type: source * source -> source, i.e. functions that take two sources as arguments, the old and new source, and returned a source that is the result of the desired transition. Finally, when defined as:

fallback(transition=[f,g], [s1, s2])
Grab the code!

f is called when switching to s1 (and g when switching to s2).

We also use source.skip, which skips the file currently being played, in order to play a fresh file when switching back to the file-based source.

First, we add the following code in library.liq:

# Define a transition that fades out the
# old source, adds a single, and then 
# plays the new source
def to_live(jingle,old,new) = 
  # Fade out old source
  old = fade.final(old)
  # Superpose the jingle
  s = add([jingle,old])
  # Compose this in sequence with
  # the new source
  sequence([s,new])
end

# A transition when switching back to files:
def to_file(old,new) =
  # We skip the file
  # currently in new
  # in order to being with
  # a fresh file
  source.skip(new) 
  sequence([old,new])
end
Grab the code!

Note source.skip may cause troubles if the file source does not prepare a new track quickly enough. In this case, you may add conservative=true to the parameters of the request.dynamic source.

Then, we add the following code in radio.liq, where we defined the fallback between the two live sources and the file-based source:

# The transition to live1
jingle1 = single("say:And now, we present the awesome show number one!!")
to_live1 = to_live(jingle1)

# Transition to live2
jingle2 = single("say:Welcome guys, this is show two on My Awesome Radio!")
to_live2 = to_live(jingle2)

# Combine lives and files:
s = fallback(track_sensitive=false,
             transitions=[to_live1, to_live2, to_file],
             [live1, live2, s]) 
Grab the code!

Solutions:

Custom logins

Another powerful feature of input.harbor is the possibility to define a custom authentication. For instance, imagine that DJ Alice may connect to the live1 source only between 20h and 21h, which is the time of her shows, with the password "rabbit", while DJ Bob may connect to the live2 source between 18h and 20h with password "foo".

This can be implemented using the auth parameter of input.harbor. This parameter is a function of type: string * string -> bool, i.e. a function that takes a pair (user,password) and returns true if the connection should be granted.

You may use this, for instance, with an external script and integrate harbor and DJ authentication into the framework of your choice. Here we illustrate this functionality with a custom functions. Thus, we add the following in library.liq:

# Our custom authentication
# Note: the ICY protocol 
# does not have any username and for 
# icecast, it is "source" most of the time
# thus, we discard it
def harbor_auth(port,_,password) = 
  # Alice connects on port 9000 between 20h and 21h
  # with password "rabbit"
  (port == 9000 and 20h-21h and password == "rabbit")
    or
  # Bob connection on port 7000 between 18h and 20h
  # with password "foo"
  (port == 7000 and 18h-20h and password == "foo")
end
Grab the code!

And we use it by replacing the live1 and live2 definitions by:

# Authentication for live 1:
auth1 = harbor_auth(9000)
live1 = input.harbor(port=9000,auth=auth1,"/")

# Authentication for live 2:
auth2 = harbor_auth(7000)
live2 = input.harbor(port=7000,auth=auth2,"/")
Grab the code!

No solution here :-)