It started with a pretty simple requirement: I just want to know which sound card is which.

Background about the setup

I sometimes play around with amateur radios. Very often I connect them to computers to play around. E.g. JS8Call, FT8, SSTV, AX.25, and some other things.

This normally works very well. I just connect radio control over a serial port, and the audio using a cheap USB audio dongle. Sometimes the radio has USB support and delivers both a serial control port and an audio interface over the same cable.

The problem

So what if I connect two radios at the same time? How do I know which sound card, and which serial port, is which?

Both serial ports (/dev/ttyUSB<n>) and audio device numbers and names depend on the order that the devices were detected, or plugged in, which is not stable.

The fix for serial ports

Serial ports are relatively easy. You just tell udev to create some consistent symlinks based on the serial number of the USB device.

For example here’s the setup for a raspberry pi that sees various radios at various times (with some serial numbers obscured) added as /etc/udev/rules.d/99-myserial.rules.

ACTION=="add", KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="IC-7300 0301XXXX", SYMLINK+="usb/ic7300"
ACTION=="add", KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="IC-9700 1300XXXX A", SYMLINK+="usb/ic9700-A"
ACTION=="add", KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="IC-9700 1300XXXX B", SYMLINK+="usb/ic9700-B"
ACTION=="add", KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="AK06XXXX", SYMLINK+="usb/kx2"
ACTION=="add", KERNEL=="ttyACM*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a7", SYMLINK+="usb/gps"

But now on to the tricky part: audio device names.

Audio device names

ALSA

Linux has a long history of audio APIs. Like OSS, ALSA, PulseAudio, Jack, and now PipeWire, to name a few.

It’s a big mess, and they don’t seem to have any consistent name at all. Not across unplug/replug, and not between each other.

From what I can gather this is the situation:

When detected every audio device shows up in /dev/snd/ as “a few” devices, with sequential numbers in them. E.g. plugging in the first radio into my raspberry pi creates /dev/snd/controlC2, /dev/snd/pcmC2D0c, and /dev/snd/pcmC2D0p. This is the device used for mixer, capture, and playback, respectively.

That’s ALSA cards. They can be listed in a more friendly manner with aplay -l or arecord -l and will be numbered in the same way. The /dev/snd/pcmC2D0p card (on the playback side) shows up like this:

$ aplay -l
[…]
card 2: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

But “card 2” is not a playback device. No that’s just a card.

aplay -l is good for a human to check what sound cards are there for playback, but is not the name used for playback itself.

The cards used for playback includes some more settings. I believe this is so that an application can just open a named playback device, and get 7.1 surround sound, as opposed to another for 2.1 surround.

To see all of these devices you can run aplay -L. These names are not particularly helpful. For example:

$ aplay -L | grep -A 2 sysdefault
sysdefault:CARD=b1
    bcm2835 HDMI 1, bcm2835 HDMI 1
    Default Audio Device
--
sysdefault:CARD=Headphones
    bcm2835 Headphones, bcm2835 Headphones
    Default Audio Device
--
sysdefault:CARD=CODEC
    USB Audio CODEC, USB Audio
    Default Audio Device

I’ve found that the plughw: device tends to work best. Of course it’s not the only syntax that can be used. plughw:2,0 can also work great for ALSA card 2, subdevice 0.

You can create new card “aliases” using symlinks:

$ cd /dev/snd
$ sudo ln -s pcmC2D0p pcmC11D0p
$ sudo ln -s controlC2 controlC11
$ aplay -l
[…]
card 2: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
card 11: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

But it doesn’t create uniquely named playback devices:

$ aplay -L | grep plughw
plughw:CARD=b1,DEV=0
plughw:CARD=Headphones,DEV=0
plughw:CARD=CODEC,DEV=0
plughw:CARD=CODEC,DEV=0

For devices that allow us to provide the playback device as a string (e.g. direwolf, gnuradio) we can reference this new alias uniquely as plughw:11,0.

It’s still not consistently named, though. If you plug in two identical USB sound cards (or radios have the same chip built in), then (in my case) the first will be named CODEC, and the second CODEC_1.

If both are plugged in / get power at the same time (which is the case for me, since they share a power supply), then which is which is randomized by a race condition.

PulseAudio

On top of ALSA PulseAudio adds another layer. PulseAudio is one of the buggiest parts of a modern Linux system, and regularly causes breakage, or consumes whole modern CPU cores to do what was easily done on quarter century old hardware without breaking a sweat.

Luckily PipeWire is replacing PulseAudio (and Jack), while remaining API-compatible, so at least that part should improve over the next few years as this pile is phased out.

But it also doesn’t change much else. PipeWire is still a layer on top of ALSA, and has the same problems I’m trying to fix in this article.

Anyway. PulseAudio takes the ALSA card and exposes it as a PulseAudio source & sink. The card configuration can be dumped with:

$ pacmd dump | grep 'device_id="2"'
load-module module-alsa-card device_id="2" name="usb-Burr-Brown_from_TI_USB_Audio_CODEC-00" card_name="alsa_card.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00" namereg_fail=false tsched=no fixed_latency_range=no ignore_dB=no deferred_volume=yes use_ucm=yes card_properties="module-udev-detect.discovered=1"
$ pacmd dump | grep usb-Burr-Brown_from_TI_USB_Audio_CODEC-00
load-module module-alsa-card device_id="2" name="usb-Burr-Brown_from_TI_USB_Audio_CODEC-00" card_name="alsa_card.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00" namereg_fail=false tsched=no fixed_latency_range=no ignore_dB=no deferred_volume=yes use_ucm=yes card_properties="module-udev-detect.discovered=1"
[…]
set-default-sink alsa_output.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00.analog-stereo
set-default-source alsa_input.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00.analog-stereo

Here we see the full PulseAudio name for the stereo input and output for the card. So yay, another name.

So which devices are actually used by apps?

Some app (e.g. wsjtx, js8call) seem to list both ALSA devices and PulseAudio devices, whereas others (e.g. qsstv) list only ALSA devices, even though it lets you choose between playback directly via ALSA or via PulseAudio.

Ugh. Presumably there are also apps out there that’ll only allow using the PulseAudio name. So turns out I can get away without a consistent PulseAudio name for now, though.

So how do we name them consistently?

With no serial number to make udev decisions on, there are only two options left:

  1. Make decision based on which USB port it’s connected to, and always plug into the same port.
  2. If anything else is plugged into that port, and that other thing has a serial number, then you can write a script that snoops this information and feeds back the relevant setting back into udev.

I went with (1), but it should not be hard to use the udev PROGRAM and %c option to shell out to a script that does (2). For now I’m leaving it as an exercise for the reader.

Then when our code can find the devices, and tell them apart, the second question is what we do with it. What names do we want consistent?

Do we want ALSA card number to be consistent?

Then we’ll need a SYMLINK+=snd/%c setting in udev.

Consistent ALSA card number allows consistent plughw:11,0.

There’s some incorrect documentation out there. For example these instructions say that you can set a NAME based on your script. But nope, can’t do that. Symlink yes, but name no.

E.g. /etc/udev/rules.d/99-myrules.rules this should work:

KERNEL=="controlC[0-9]*", DRIVERS=="usb", PROGRAM="/usr/local/bin/alsa_name.py %k", SYMLINK+="snd/%c"
KERNEL=="hwC[D0-9]*", DRIVERS=="usb", PROGRAM="/usr/local/bin/alsa_name.py %k", SYMLINK+="snd/%c"
KERNEL=="midiC[D0-9]*", DRIVERS=="usb", PROGRAM="/usr/local/bin/alsa_name.py %k", SYMLINK+="snd/%c"
KERNEL=="pcmC[D0-9cp]*", DRIVERS=="usb", PROGRAM="/usr/local/bin/alsa_name.py %k", SYMLINK+="snd/%c"

You can then have your script check all sorts of things coming is as environment variables, and adjust the name to a new number, thus creating the symlinks created manually, above.

A simple script only checking the USB port path can look like:

#!/bin/bash
NAME="$1"
if echo "$DEVPATH" | grep -q 1-1.4; then
  NAME="$(echo "$NAME" | sed -r 's/(.*)C([0-9]+)(.*)/\1C11\3/')"
fi
if echo "$DEVPATH" | grep -q 1-1.3; then
  NAME="$(echo "$NAME" | sed -r 's/(.*)C([0-9]+)(.*)/\1C12\3/')"
fi
exec echo "snd/$NAME"

What’s left as an exercise to the reader here is to compare $DEVPATH with anything else coming from the same device. That way when you connect a radio that presents both a serial port and an audio card, you can take the serial number from the serial port and use it to decide what to name (number) the audio card.

After adding/changing rules, you shouldn’t need to run anything since udev is supposed to watch for new rules, but sometimes you do need to run sudo udevadm control --reload-rules. Then you can run sudo udevadm trigger to supposedly apply the config.

But really what you need to do is unplug and re-plug the USB cable, since running these triggers doesn’t actually work right, and can leave things in a broken state.

If you did it right then you should now see your card duplicated under a second number when you run aplay -l, just like the manual symlinks above.

Do we want consistent PulseAudio devices?

Once you have a consistent ALSA card number, you can create consistent PulseAudio playback/capture devices (aka sink/source to PulseAudio, because why even have common terminology?) from ALSA devices by running various pacmd commands, like:

N=11
DEV="radio-7300"
pacmd load-module module-alsa-card \
    device_id="${N}" name="${DEV}" \
    card_name="alsa_card.platform-${DEV}_audio" \
    namereg_fail=false tsched=no fixed_latency_range=no \
    ignore_dB=no deferred_volume=yes use_ucm=yes \
    card_properties="module-udev-detect.discovered=1"
pacmd suspend-sink alsa_output.${DEV}.analog-stereo no
pacmd suspend-source alsa_input.${DEV}.analog-stereo no

Do we want QT dropdowns to contain our ALSA device?

Since some applications (e.g. qsstv) don’t list PulseAudio source/sinks, we may have to create consistent ALSA device too.

Neither consistent ALSA number nor PulseAudio name will help you, since the dropdowns in qsstv, js8call, and wsjtx don’t allow you to enter whatever you want. They want ALSA devices in the form plughw:CARD=CODEC,DEV=0. By ID (CODEC), not by number (11).

In udev you can not only set ALSA symlinks, but also other attributes. This CODEC both audio cards have is a problem, so let’s override it based on where it’s plugged in.

So in a fantastic display of burying the lede, this (and only this) is what I put in my udev rules to get consistent ALSA text and QT UI dropdown support for consistent device names:

SUBSYSTEM=="sound",KERNELS=="1-1.4.4:1.0",ATTR{id}="CODEC_7300"
SUBSYSTEM=="sound",KERNELS=="1-1.3.4:1.0",ATTR{id}="CODEC_9700"

You can get the KERNELS path using udevadm info -ap /sys/class/sound/controlC2 | grep KERNELS.

After you’ve done that you should see:

$ aplay -l
[…]
card 2: CODEC_9700 [USB Audio CODEC], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
card 3: CODEC_7300 [USB Audio CODEC], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
$ aplay -L | grep plughw.*CODEC
plughw:CARD=CODEC_9700,DEV=0
plughw:CARD=CODEC_7300,DEV=0

I still don’t have a consistent PulseAudio source/sink pair, but so far I’ve not needed it. If that changes I can create it by setting a consistent ALSA number and then creating the PulseAudio device as described above.

Further work not covered

I believe that you can create ALSA devices similar to how you create PulseAudio ones using .asoundrc or /etc/alsa/conf.d/. You may have to generate configs at plug-in time using udev, though.

But I got what I wanted, so I’m out. It’s a god damn mess.