Convincing S/PDIF to put my amplifier to sleep


I recently got a brand new HIFI setup in my living room, Argon Audio SA1 MK2 paired with a set of Dali Kupid. I wanted something compact, but with decent sound, and I’m pretty happy with how the combo turned out.

One of the core features that the amplifier swayed me with, was the auto power sense feature. If there is no audio on any of the inputs, the amplifier shuts down after 15 minutes, if one of the inputs gets a signal, the amplifier turns back on.

This feature is a nice companion for my streaming box, the lesser known Raspberry Pi 4B. Which currently runs shairport-sync for airplay and jellyfin-mpv-shim for “play on”.

The amplifier has a decent built-in DAC. So my plan was to add a optical S/PDIF sound-card for digital signal transport to the amplifier.

And, oh boy did I get sucked into a rabbit hole. Forums will discuss clocking, synchronization, interference, “clean signal” and whole bunch of audiophile mumbo-jumbo marketing swaying you to buy a expensive HAT…

So, I splurged and bought a Behringer UCA202, USB, optical S/PDIF cheap and worked flawlessly.

There is just one issue. The UCA202 keeps the amplifier powered on even when audio is not playing, either by a digital carrier signal, the clock pulse or something else.

But, I found a workaround to suspend the UCA202

1echo 1-1.4 | tee /sys/bus/usb/drivers/usb/unbind

This basically software “ejects” the USB audio interface at the kernel driver level, the device is still powered, but the status light and S/PDIF port is turned off.

To turn it back on, we rebind the device

1echo 1-1.4 | tee /sys/bus/usb/drivers/usb/bind

The UCA202 device status light is on again, same with optical S/PDIF, nice.

But doing this manually each time is a pain, so let’s automate it somehow. Oh, right. I also want to further complicate things by adding CamillaDSP for software EQ and pass volume controls from shairport-sync.

Taming that optical S/PDIF

Here are my requirements

Here is my current solution

Here is a HLD diagram for the current solution

diagram

ALSA Loopback

Let’s enable the ALSA Loopback feature, you can read more about it here

1sudo modprobe snd-aloop

Now make it persistent across reboots

1echo "snd-aloop" | sudo tee /etc/modules-load.d/snd-aloop.conf

We can now list our hardware devices, loopback should be present

 1$ aplay -l
 2**** List of PLAYBACK Hardware Devices ****
 3card 0: Loopback [Loopback], device 0: Loopback PCM [Loopback PCM]
 4  Subdevices: 8/8
 5  Subdevice #0: subdevice #0
 6  Subdevice #1: subdevice #1
 7  Subdevice #2: subdevice #2
 8  Subdevice #3: subdevice #3
 9  Subdevice #4: subdevice #4
10  Subdevice #5: subdevice #5
11  Subdevice #6: subdevice #6
12  Subdevice #7: subdevice #7
13card 0: Loopback [Loopback], device 1: Loopback PCM [Loopback PCM]
14  Subdevices: 8/8
15  Subdevice #0: subdevice #0
16  Subdevice #1: subdevice #1
17  Subdevice #2: subdevice #2
18  Subdevice #3: subdevice #3
19  Subdevice #4: subdevice #4
20  Subdevice #5: subdevice #5
21  Subdevice #6: subdevice #6
22  Subdevice #7: subdevice #7

We can check if there is music playing in one of the loopback devices by checking the content of the following file

1$ cat /proc/asound/card0/pcm1p/sub0/status
2closed

Nothing is playing, so the file returns closed. Let’s play something and then check the output

 1$ aplay -D hw:Loopback,1 /usr/share/sounds/alsa/Front_Center.wav &; cat /proc/asound/card0/pcm1p/sub0/status
 2state: RUNNING
 3owner_pid   : 23729
 4trigger_time: 171189.822203143
 5tstamp      : 0.000000000
 6delay       : 20544
 7avail       : 3456
 8avail_max   : 5952
 9-----
10hw_ptr      : 39744
11appl_ptr    : 60288

And it goes back to closed when there is no audio playing.

By monitoring this file for RUNNING and closed state, we can avoid vendor lock-in and create something flexible which just monitors ALSA for audio playback.

ALSA Dummy Mixer

We want to avoid streaming services to use software volume inside the audio chain, only CamillaDSP should be allowed to adjust volume. These services should always output 100% volume, and volume adjustment should be passed to CamillaDSP somehow.

For that, we can create a “dummy” ALSA mixer. Which means a soft_vol mixer that is not connected to any sound cards, and simply stores the mixer volume. Create a /etc/asound.conf file

 1# /etc/asound.conf
 2pcm.loop_softvol {
 3    type softvol
 4    slave {
 5        pcm "null" # This makes it a dummy mixer
 6    }
 7    control {
 8        name "Loopback Playback Volume" # This name will appear in alsamixer
 9        card 0
10    }
11}

Reboot to ensure that the file is read by ALSA.

Now we need to start the mixer, as a mixer in ALSA is not present until some audio has been played on the device. We can just play some test sound for starting the mixer

1$ aplay -D loop_softvol /usr/share/sounds/alsa/Front_Center.wav
2Playing WAVE '/usr/share/sounds/alsa/Front_Center.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Mono

Now the mixer should be present

1$ amixer
2Simple mixer control 'Loopback',0
3  Capabilities: pvolume
4  Playback channels: Front Left - Front Right
5  Limits: Playback 0 - 255
6  Mono:
7  Front Left: Playback 225 [88%] [-6.00dB]
8  Front Right: Playback 223 [87%] [-6.40dB]

The mixer will be present until the next reboot, so we need to add a oneshot systemd service that plays a sound after booting

 1# /etc/systemd/system/alsa-softvol-init.service
 2
 3[Unit]
 4Description=Initialize ALSA Softvol Mixer
 5After=sound.target
 6Before=multi-user.target
 7
 8[Service]
 9Type=oneshot
10ExecStart=/usr/bin/aplay -D loop_softvol /usr/share/sounds/alsa/Front_Center.wav
11RemainAfterExit=yes
12
13[Install]
14WantedBy=multi-user.target

Test the service first, then enable it

1sudo systemctl daemon-reload
2sudo systemctl start alsa-softvol-init.service
3systemctl status alsa-softvol-init.service
4sudo systemctl enable alsa-softvol-init.service

Now we should see the mixer after reboot.

shairport-sync

This post uses shairport-sync, but really any service could be used as long as it can output to an ALSA device and has a feature for specifying a ALSA mixer for hardware volume control.

For installation, I use docker

 1# compose.yml
 2name: shairport
 3
 4services:
 5  shairport-sync:
 6    container_name: shairport
 7    image: mikebrady/shairport-sync:latest
 8    network_mode: host
 9    restart: unless-stopped
10    devices:
11      - "/dev/snd"
12    cap_add:
13      - SYS_NICE
14    volumes:
15      - ./settings.conf:/etc/shairport-sync.conf

And this is the settings.conf file

 1# settings.conf
 2general = {
 3  name = "Dali Kupid";
 4  interpolation = "soxr";
 5  volume_control_profile = "dasl_tapered";
 6  default_airplay_volume = -12.5;
 7};
 8
 9alsa = {
10  output_device = "hw:Loopback,0,0";
11  output_rate = 48000;
12  output_format = "S16_LE";
13  output_channels = 2;
14  mixer_device = "hw:Loopback";
15  mixer_control_name = "Loopback";
16};

Now we got the input sorted out, it’s time for the output.

CamillaDSP

CamillaDSP is a audio processing tool, and can do a lot of cool things like active crossovers, room correction and advanced audio filtering. I use room correction, some filters for making the speakers warmer and loudness as I most often listen at lower volumes.

We are now going to install CamillaDSP and CamillaGUI-backend.

As I run this on a Raspberry Pi, I’m going to build this with a optimized rust flag for NEON, which allows us to use hardware acceleration for audio processing.

Install dependencies

1sudo apt install pkg-config \
2	libasound2-dev \
3	openssl \
4	libssl-dev
5
6# rustc and cargo
7curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Clone and checkout latest release

1git clone https://github.com/HEnquist/camilladsp.git
2cd camilladsp
3git checkout v4.1.3

Build camilladsp with the NEON flag, then move the to bin

1RUSTFLAGS='-C target-feature=+neon -C target-cpu=native' cargo build --release
2sudo mv ./target/release/camilladsp /usr/local/bin/camilladsp

Now you should be able to run camilladsp --version, testing camilladsp requires specifying a config, so let’s create a basic one in /etc/camilladsp/config.yaml

 1# /etc/camilladsp/config.yaml
 2devices:
 3  capture:
 4    channels: 2
 5    device: hw:CARD=Loopback,DEV=1
 6    format: S16_LE
 7    labels: null
 8    link_mute_control: Loopback Playback Volume
 9    link_volume_control: Loopback Playback Volume
10    type: Alsa
11  chunksize: 512
12  multithreaded: true
13  playback:
14    channels: 2
15    device: iec958:CARD=CODEC,DEV=0
16    format: S16_LE
17    type: Alsa
18  samplerate: 48000
19  volume_limit: 0
20  worker_threads: 4
21title: Dali Kupid

Now we can start camilladsp

1camilladsp /etc/camilladsp/config.yaml

At this point, you now got both input, output and volume sync working. Go ahead and try playing something, adjust volume with alsamixer.

Let’s make this into a systemd service, first let’s create a non-root user

1sudo useradd -r -s /usr/sbin/nologin camilladsp
2sudo usermod -aG audio camilladsp
3sudo mkdir /etc/camilladsp/configs /etc/camilladsp/coeffs
4sudo chown -R camilladsp:camilladsp /etc/camilladsp/
5sudo chmod 760 /etc/camilladsp/
6sudo chmod 660 /etc/camilladsp/config.yaml

Now we can add a systemd service

 1# /usr/lib/systemd/system/camilladsp.service
 2[Unit]
 3Description=CamillaDSP
 4After=sound.target
 5StartLimitIntervalSec=30
 6StartLimitBurst=3
 7
 8[Service]
 9Type=simple
10User=camilladsp
11Group=audio
12
13WorkingDirectory=/etc/camilladsp
14ExecStart=/usr/bin/camilladsp \
15    -s /etc/camilladsp/statefile.yml \
16    -w \
17    -g-40 \
18    -o /etc/camilladsp/camilladsp.log \
19    -p 1234 \
20    /etc/camilladsp/config.yaml
21
22Restart=on-failure
23RestartSec=5
24
25StandardOutput=journal
26StandardError=journal
27SyslogIdentifier=camilladsp
28
29# Required for CPU Scheduling
30AmbientCapabilities=CAP_SYS_NICE
31CapabilityBoundingSet=CAP_SYS_NICE
32
33CPUSchedulingPolicy=fifo
34CPUSchedulingPriority=65
35
36[Install]
37WantedBy=default.target

Start the service, check the logs

1sudo systemctl daemon-reload
2sudo systemctl start camilladsp.service
3systemctl status camilladsp.service

Do not enable the service, we will use a script later to manage the service status

CamillaGUI-Backend

The GUI for CamillaDSP is a python bundle, we’ll install the latest release at this time

1wget https://github.com/HEnquist/camillagui-backend/releases/download/v4.1.0/bundle_linux_aarch64.tar.gz -O /tmp/bundle_linux_aarch64.tar.gz
2tar xvf /tmp/bundle_linux_aarch64.tar.gz
3sudo mv /tmp/camillagui_backend /opt/camillagui_backend

Now we need to edit the camillagui config to use the /etc directory

 1# /opt/camillagui_backend/_internal/config/camillagui.yml
 2---
 3camilla_host: "127.0.0.1"
 4camilla_port: 1234
 5bind_address: "0.0.0.0"
 6port: 5005
 7ssl_certificate: null
 8ssl_private_key: null
 9gui_config_file: null
10config_dir: "/etc/camilladsp/configs"
11coeff_dir: "/etc/camilladsp/coeffs"
12default_config: "/etc/camilladsp/default_config.yml"
13statefile_path: "/etc/camilladsp/statefile.yml"
14log_file: "/etc/camilladsp/camilladsp.log"
15on_set_active_config: null
16on_get_active_config: null
17supported_capture_types: null
18supported_playback_types: null

Now we can add a systemd service

 1# /usr/lib/systemd/system/camillagui.service
 2[Unit]
 3Description=CamillaDSP Backend and GUI
 4After=default.target
 5
 6[Service]
 7Type=simple
 8User=camilladsp
 9Group=camilladsp
10
11ExecStart=/opt/camillagui_backend/camillagui_backend
12
13Restart=always
14RestartSec=5
15
16[Install]
17WantedBy=default.target

Start the service, check the logs

1sudo systemctl daemon-reload
2sudo systemctl start camillagui.service
3systemctl status camillagui.service

The CamillaGUI should now be reachable on port 5005

Power Management Python script

This script does the following

Copy this python script to /usr/local/bin/dac-power.py

  1#!/usr/bin/env python3
  2import time
  3import os
  4import glob
  5import re
  6import logging
  7import sys
  8import json
  9from datetime import datetime, UTC
 10
 11USB_VID = "08bb"
 12USB_PID = "2902"
 13BIND_PATH = "/sys/bus/usb/drivers/usb"
 14LAST_DEV_FILE = "/run/dac-power-usb-dev"
 15SYSTEMD_SERVICE = "camilladsp.service camillagui.service"
 16OFF_DELAY = 300
 17
 18class JSONFormatter(logging.Formatter):
 19    def formatTime(self, record, datefmt=None):
 20        from datetime import datetime, UTC
 21        return datetime.fromtimestamp(record.created, UTC).isoformat()
 22
 23    def format(self, record):
 24        log_record = {
 25            "timestamp": self.formatTime(record),
 26            "level": record.levelname,
 27            "message": record.getMessage(),
 28            "logger": record.name,
 29        }
 30
 31        if record.exc_info:
 32            log_record["err"] = self.formatException(record.exc_info)
 33
 34        base = logging.LogRecord(None, None, "", 0, "", (), None).__dict__
 35
 36        for key, value in record.__dict__.items():
 37            if key not in base and not key.startswith("_"):
 38                log_record[key] = value
 39
 40        return json.dumps(log_record, separators=(",", ":"))
 41
 42# Logger setup
 43
 44
 45
 46 = logging.getLogger("dacpower")
 47logger.setLevel(logging.INFO)
 48logger.propagate = False
 49
 50if not logger.handlers:
 51    handler = logging.StreamHandler(sys.stdout)
 52    handler.setFormatter(JSONFormatter())
 53    logger.addHandler(handler)
 54
 55def find_usb_dev():
 56    for vendor_path in glob.glob("/sys/bus/usb/devices/*/idVendor"):
 57        dev_dir = os.path.dirname(vendor_path)
 58        try:
 59            vid = open(f"{dev_dir}/idVendor").read().strip()
 60            pid = open(f"{dev_dir}/idProduct").read().strip()
 61            if vid == USB_VID and pid == USB_PID:
 62                return os.path.basename(dev_dir)
 63        except OSError:
 64            pass
 65    return None
 66
 67def find_loopback_status_files():
 68    with open("/proc/asound/cards") as f:
 69        for line in f:
 70            if "Loopback" in line:
 71                match = re.match(r"\s*(\d+)", line)
 72                if match:
 73                    card_index = match.group(1)
 74                    break
 75        else:
 76            logger.error("loopback card not found")
 77            exit(1)
 78            return []
 79
 80    status_files = []
 81    for pcm in glob.glob(f"/proc/asound/card{card_index}/pcm*p"):
 82        status_files += glob.glob(f"{pcm}/sub*/status")
 83
 84    logger.debug("using status files", extra={"files": status_files})
 85    return status_files
 86
 87def is_running(status_files):
 88    for f in status_files:
 89        try:
 90            if "RUNNING" in open(f).read():
 91                return True
 92        except OSError:
 93            pass
 94    return False
 95
 96def usb_on():
 97    dev = find_usb_dev()
 98    if not dev:
 99        try:
100            dev = open(LAST_DEV_FILE).read().strip()
101        except OSError:
102            logger.warning("UCA202 not found")
103            return
104    logger.info("powering DAC on using bind", extra={"device": dev})
105    with open(LAST_DEV_FILE, "w") as f:
106        f.write(dev)
107    if not os.path.exists(f"{BIND_PATH}/{dev}"):
108        with open(f"{BIND_PATH}/bind", "w") as f:
109            f.write(dev)
110    time.sleep(0.3)
111    os.system(f"systemctl start {SYSTEMD_SERVICE}")
112
113def usb_off():
114    os.system(f"systemctl stop {SYSTEMD_SERVICE}")
115    time.sleep(0.3)
116    dev = find_usb_dev()
117    if not dev:
118        logger.warning("UCA202 not found, already unbound?")
119        return
120    logger.info("powering DAC down using unbind", extra={"device": dev})
121    with open(LAST_DEV_FILE, "w") as f:
122        f.write(dev)
123    if os.path.exists(f"{BIND_PATH}/{dev}"):
124        with open(f"{BIND_PATH}/unbind", "w") as f:
125            f.write(dev)
126
127def reset_to_known_state():
128    logger.info("reset dsp to off state")
129    os.system(f"systemctl stop {SYSTEMD_SERVICE}")
130    time.sleep(0.3)
131
132    logger.info("reset dac to off state")
133    dev = find_usb_dev()
134    if dev and os.path.exists(f"{BIND_PATH}/{dev}"):
135        logger.info("unbinding device at startup", extra={"device": dev})
136        with open(f"{BIND_PATH}/unbind", "w") as f:
137            f.write(dev)
138    logger.info("startup reset complete")
139
140def main():
141    status_files = find_loopback_status_files()
142    if not status_files:
143        logger.error("no loopback PCM status files found")
144        exit(1)
145
146    last_running = 0.0
147    last_heartbeat = 0
148    is_on = False
149
150    reset_to_known_state()
151    logger.info("started ALSA loopback monitoring")
152
153    while True:
154        now = time.time()
155        if is_running(status_files):
156            last_running = now
157            if not is_on:
158                usb_on()
159                is_on = True
160        elif is_on and (now - last_running > OFF_DELAY):
161            usb_off()
162            is_on = False
163        time.sleep(0.3)
164
165if __name__ == "__main__":
166    main()

Create and enable a systemd service

 1# /etc/systemd/system/dac-power.service
 2[Unit]
 3Description=ALSA Activity DAC Power Control
 4After=sound.target
 5Wants=multi-user.target
 6
 7[Service]
 8Type=simple
 9ExecStart=/usr/bin/python3 /usr/local/bin/dac-power.py
10
11# We monitor /proc/asound and sysfs, so root is needed
12User=root
13
14StandardOutput=journal
15StandardError=journal
16
17Restart=always
18RestartSec=5
19
20[Install]
21WantedBy=multi-user.target

← Back