Monday, 23 February 2026

Reliably recording Internet radio programs

In a previous blog, I've explained how to use mplayer to dump Internet radio programs to a file.

In practice the recording sometimes terminated before the time was up, which could be due to the server breaking the connection, Internet glitches and so forth. Interactive station tuners will attempt to reconnect. We want to do the same with our mplayer scripts.

Here is a solution I came up with, which I will explain below the code. Some code before the main code has been elided. Assume that the bash script is called with 3 arguments, the duration, the destination file, and the streaming URL.

#!/bin/bash

pipe="/run/user/$(id -u)/mplayerpipe$$"
READER_PID=''
MIN_DURATION=30
STRETCH_TIME=15         # seems to lose time when a new segment is started

cleanup() {
       rm -f "$pipe"
       # don't use builtin kill so that we can send to process group
       [[ -n "$READER_PID" ]] && echo "Killing group $READER_PID" && /usr/bin/kill -TERM -- "$READER_PID" $(pgrep -f "cat $pipe")
}

if [[ "$1" -lt "$MIN_DURATION" ]]
then
       echo Minimum duration "$MIN_DURATION" seconds
       exit 1
fi
trap cleanup EXIT SIGINT SIGTERM
mkfifo -m 600 "$pipe"
coproc READER { exec bash -c "while [[ -p '$pipe' ]]; do cat '$pipe'; echo 'Restart reader' 1>&2; done" > "$2"; }
now=$(date +%s)
later=$((now + $1))
left=$((later - now))
while [[ "$left" -ge "$MIN_DURATION" ]]
do
       # echo and sleep for testing
       echo mplayer -prefer-ipv4 -noconsolecontrols -slave -vo null -vc null -endpos "$left" -dumpaudio -dumpfile "$pipe" -really-quiet -loop 0 "$stream"
       # sleep 10
       mplayer -prefer-ipv4 -noconsolecontrols -slave -vo null -vc null -endpos "$left" -dumpaudio -dumpfile "$pipe" -really-quiet -loop 0 "$stream"
       now=$(date +%s)
       left=$((later - now + STRETCH_TIME))
done
sleep 2

The idea is we establish a named pipe in the standard location /run/user/<uid>/and mplayer writes to this pipe. At the same time we start a coprocess which is a bash inline script calling cat repeatedly to append to the destination file. Coprocesses are explained in the bash man page. Here we call it READER and its pid is put in READER_PID by convention. Note that we exec the bash coprocess so that the pid is that of the code fragment, not the bash parent.

When mplayer terminates prematurely, the amount of time left to go is computed and it's restarted with that duration. The cat process will receive EOF, and new one is started to continue to read from the pipe.

We need to clean up the named pipe at the end of the program so we install a signal handler cleanup() to do that. It removes the named pipe, and kills the group pid, which the kill command says can be done by prepending - to the pid (making it negative). Note that for this behaviour we use the kill command in /usr/bin/kill, not the bash builtin. However I couldn't get the group kill to work so I settled for using pgrep to find the cat process to get the pid.

You may ask why not try to trick mplayer into appending to a file by >> output redirection, then passing /dev/stdout as the dumpfile argument. It doesn't work. /dev/stdout is a symlink to the real file and mplayer will open it in overwrite not append mode.

After I wrote that script, I decided to try another tack. The script relies on Linux named pipes, so isn't cross-platform.

Here is the second attempt, which is a Python program. I'll show only the main routine, the needed auxiliary definitions and functions can be inferred.

def main():
   """ Main program """

   mplayer_cmd = sys.argv.copy()
   mplayer_cmd[0] = MPLAYER

   # find index of -dumpfile argument
   dumpfile_pos = find_index_of("-dumpfile", mplayer_cmd)
   if dumpfile_pos < 0:
       sys.exit("-dumpfile argument not found")
   dumpfile_name = mplayer_cmd[dumpfile_pos]
   insert_pos = dot_index(dumpfile_name)
   base = dumpfile_name[0:insert_pos]
   tail = dumpfile_name[insert_pos:]
   seq = 'aa'
   mplayer_cmd[dumpfile_pos] = first_name = base + seq + tail

   # find index of -endpos argument
   end_pos = find_index_of("-endpos", mplayer_cmd)
   if end_pos < 0:
       sys.exit("-endpos argument not found")
   if not mplayer_cmd[end_pos].isdigit():
       sys.exit("End posiiton not a number")

   left = int(mplayer_cmd[end_pos])  # get original endpos
   now = time.time()
   later = now + left                  # when we should stop
   # substitute endpos
   mplayer_cmd[end_pos] = str(left)
   while left >= MIN_DURATION:
       print(f'{" ".join(mplayer_cmd)}')
       before = time.time()
       # for debugging just sleep
       if TESTING:
           time.sleep(10)
       else:
           result = subprocess.run(mplayer_cmd, check=False)
           if DEBUG:
               print(result)

       if seq == 'zz':
           print("Can't handle more than 676 segments")
           break

       now = time.time()
       if bad_session(mplayer_cmd[dumpfile_pos], seq, before, now):
           continue

       # set up for next session
       seq = next_segment(seq)
       # substitute dumpfile
       mplayer_cmd[dumpfile_pos] = dump_name(base, seq, tail)
       # substitute endpos
       left = round(later - now) + STRETCH_TIME
       mplayer_cmd[end_pos] = str(left)

   # gather all the segments
   # rename first segment
   if DEBUG:
       print(f'{first_name} -> {dumpfile_name}')
   try:
       os.replace(first_name, dumpfile_name)
   except FileNotFoundError:
       print(f'Warning: {first_name} not found')
   # append remaining segments
   if seq > 'ab':
       append_segments(base, seq, tail, dumpfile_name)


if __name__ == '__main__':
   main()
We write a series of files called <basename>aa.aac to <basename>zz.aac and at the end of the program we rename the first one <basename>.aac and append all the others to it. Up to 676 segments are allowed. Hopefully you will not have so many connection breaks. All the string manipulations are to find the endpos and dumpfile arguments in the original invocation and replace those with new values for each run of mplayer. Thus this script which I called mplayer_retry is a plugin replacement for the original mplayer invocation.

This script has the advantage of not requiring more than standard file operations, nor named pipes, so is cross-platform.

Unfortunately it seems that in practice, some seconds of program are lost when the connection breaks. Only thing for it is to complain to the service provider to have more a robust streaming service. 

Monday, 3 November 2025

E Unibus Unix

Today I was reminded of what I think might have been a fortune cookie from Bell Labs Unix: E Unibus Unix. Unibus was the backplane bus of the DEC PDP-11 series of minicomputers which was the main platform for Unix development for many years.

It's of course a play on the motto E pluribus unum (Out of many, one) which is on the great seal of the USA. We won't go into if that motto is still valid these days. 

Thursday, 2 January 2025

Installing mplayer on dietpi on Raspberry Pi

In a previous article, I explained how to use mplayer to record Internet radio broadcast non-interactively.

I got it working on my workhorse PC, but it suffered from using 100% of one core as mentioned in that article. I have 12 cores so this wasn't a disaster. But I thought I could shove the job to a less important computer and also have a backup means of recording. I have a very old Raspberry Pi 2B which was idle.

I tried installing the latest Raspberry Pi OS, but I couldn't write the entire image on the micro SD card. I think the USB to micro SD adaptor got too hot and caused sector errors towards the end. Maybe I should have limited the writing rate. Anyway I decided to use a lighter RPi distro: dietpi.

This installed and booted up fine. I had an issue with the default NTP server until I specified a regional pool. Then I encountered a series of problems:

Apt repos need to be signed

It couldn't update from the default Debian Bookworm archive because the signing key wasn't present. Normally this is provided by the distro but since this is dietpi they only provided keys for the repos they used. Or they provided an old key.

Cut to the chase: Install all the relevant Bookworm repos, you can find a definitive list of them at at various sites. If possible use a local mirror for the repos. Don't forget bookworm-security, but this will come from security.debian.org, not a mirror.

When you do an apt update it will complain about various unsigned repos. Note down the key fingerprints for the next stage.

Apt-key is deprecated

Ignore any tutorials that talk about using apt-key to install the required keys. For security reasons, apt-key is deprecated. The new way of doing it is:

Download the GPG keys for all the repos missing keys. You'll need to find a suitable keyserver with the Debian keys.

Feed each through gpg to dearmor the keys and write the output to a suitably named file in /etc/apt/trusted.gpg.d/ Here's what I have:

root@DietPi:/etc/apt/trusted.gpg.d# ls
bookworm-security.gpg       debian-bookworm-archive.gpg  dietpi.asc
bullseye-security.gpg       debian-bookworm-stable.gpg   raspberrypi-archive-stable.gpg
deb-multimedia-keyring.asc  debian-bullseye-archive.gpg  raspbian-archive-keyring.gpg

I haven't given the details to avoid duplication and because I could have forgotten some bits. You can find them in up-to-date tutorials.

You need Deb multimedia

Mplayer uses some codecs that are not supplied in Debian, so you have to get them from Deb Multimedia. Use a mirror if you can. You need to install the signing key for this in the same manner shown above.

Finally install mplayer

Do an apt update and then apt install mplayer. If you have any issues at the update step, fix those. I came across issues like mirrors no longer existing, or didn't specify their Debian repo domain in their list of alternate domain names which caused failure on verification.

Also any other packages with problems could block the installation. For example I had issues with libgomp1 where it had a spurious dependency. I actually hacked /var/lib/dpkg/status with a text editor to bypass this.

What made this exercise worthwhile

When I use mplayer on the RPi to record an Internet radio station I was surprised to find that it didn't eat up 100% of a core like on my workstation. So I ran a strace on mplayer on my workstation and saw that it was looping on reading file descriptor 0 (stdin). Recalling that mplayer reads single keystrokes from the controlling terminal to control the playback, I reasoned that it must be doing that in non-interactive mode and looping on failure. So I found the -noconsolecontrols and -slave options to mplayer and adding these to the command made the CPU usage normal again.

Recording Internet radio with mplayer

It's not widely known, but mplayer can be used to listen to Internet radio stations.

If you just want to listen, my recommendation is to install pyradio which is a curses based command player. For a widget I can recommend radiotray-ng. For Plasma desktops there is plasma5-radiotray. They use mplayer and other programs like vlc to do the heavy lifting.

But the subject of this blog article is recording, and I usually do this from a cronjob or crontab entry for unattended recording of periodic programs. Cutting to the chase, this is the command line you need, with explanations below.

mplayer -prefer-ipv4 -noconsolecontrols -slave -vo null -vc null -endpos "$1" -dumpaudio -dumpfile "$2" -really-quiet -profile pyradio "$stream"

-prefer-ipv4 is because I have a DNS client that returns IPv6 entries but I have only IPv4 connectivity

-noconsolecontrols -slave prevent mplayer from reading for commands and polling for single keypresses for commands. I think only the first is needed, but the second can't hurt. In non-interactive mode, there is no terminal and mplayer goes into a busy loop, using up 100% of a CPU core

-vo null -vc null disable the video output and codecs

-endpos is followed by the number of seconds to record. It's the first argument to the shell script this command is in

-dumpaudio -dumpfile are followed by the file to write the raw audio data to, typically it's AAC format. It's the second argument to the shell script

-really-quiet suppresses pretty much all messages

-profile pyradio specifies a profile in ~/.mplayer/config. It consists of this stanza:

[pyradio]
softvol=1
softvol-max=300
volstep=1
volume=80

"$stream" is the URL the station broadcasts on. A site like https://streamurl.link/ could be useful for finding this for the station you are interested in.

Some stations use a playlist URL, in which case "$stream" should be replaced by -playlist "$playlist"

I've found that typically the audio data is about 8 kB/s for AAC.

Wednesday, 26 June 2024

An interesting anomaly in my car player re MP3 and AAC

I discovered by accident that my 10-year old car's entertainment system can accept .aac suffixed files on USB flash memory sticks to play. But when I tried to play an ISO9660 data CD containing AAC files instead of MP3 files, it said it could not find any MP3 files on the CD.

Since it's the same entertainment unit which also accepts input from Bluetooth, and analog AUX 3.5 mm stereo jack for a total of 4 input sources, it seems strange that it can handle AAC files, but only from the USB flash memory.

I tried naming the files suffixed as .m4a. No joy, still could not find any MP3 files on the CD.

Ok, I'll try to fool it. I renamed the AAC files to have .mp3 suffix. Now it doesn't complain that there are no MP3 files, but regards them as invalid, skipping through them without playing.

From this I infer that there are at least two decoder paths, the one for the CD drive that can only play MP3 files, and the one for the USB flash memory that can play both MP3 and AAC.

Incidentally I think this might be the last car player I own that will play CDs. For my next car I'll probably play from USB flash memory, or from my phone via Bluetooth. These days when you mention CDs to people below a certain age, they go: what?

Saturday, 6 January 2024

Clever function names

It came back to me today that in the language that influenced Python, ABC, developed at the CWI to teach programming, there were 2 string operations described as behead and curtail. I suppose the person who thought up the verbs was chuffed. They were probably too bloody for general consumption so these days in programming languages other verbs are used to describe the operations, or means like string slicing are used.

Sunday, 5 November 2023

Excluding devices from pipewire control

In my previous blog post I described how I used pipewire to add a bluetooth dongle to the audio outputs of my computer.

Unfortunately activating pipewire had the result of it taking control of all the sound interfaces on my computer. This caused a problem with a line-in port which I use for recording audio at scheduled times. It turns out that now and then pipewire will reset the port for maximum gain, wrecking the recording.

I searched for how to exclude this port from pipewire's influence and this was the best answer. I discovered the PCI bus id of the sound card (actually onboard sound device) in question and wrote an override script just like the one described with my card's id. Unfortunately the whole device has to be disabled, not just the line-in port, but that's ok for me. I still have an HDMI port which I can connect to my amplifier instead of using the analog line-out port. So thanks to the author of that ZenLinux blog for the insight. It's a pity such a simple task has to be so complicated, maybe future wireplumber developments will make this simpler.