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
- Solution must be flexible, streaming services should be easily swappable.
- Volume control must be working from devices.
- Streaming services must pass 100% volume to CamillaDSP at all times.
- Max volume is out of scope, this is set on the amplifier.
- Working amplifier idle power saving.
Here is my current solution
- All streaming services output audio to a ALSA Loopback device.
- CamillaDSP captures the ALSA Loopback audio, outputs to S/PDIF.
- Volume control is passed using a separate ALSA Dummy Mixer.
- Streaming services controls the mixer.
- CamillaDSP syncs the volume from the mixer to internal volume control.
- The
dac-power.pyscript monitors ALSA Loopback status files for RUNNING state- Controls UCA202 power.
- Manages CamillaDSP systemd service.
Here is a HLD diagram for the current solution
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};
- Here we use the ALSA Loopback playback channel at
hw:Loopback,0,0. - The output rate, format and channels are the same as the maximum on the Behringer UCA202. This is done to avoid resampling in our audio chain.
- And the mixer is our dummy mixer, which is tied to the Loopback card.
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
- Finds the device path for Behringer UCA202 based on USB VID/PID
USB_VIDandUSB_PIDcould be swapped out, might work with others out of the box.
- Finds and monitors file-paths for all ALSA Loopback device status files for
RUNNINGstate - Controls UCA202 power using bind/unbind.
- Starts and stops services, as they are dependent on UCA202 device.
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