Virtual ALSA Device: Synchronizing Volume Commands for Enhanced Audio Control

At revWhiteShadow, we’re always looking for ways to enhance audio experiences. As revWhiteShadow, I, kts, am excited to share a solution to a common problem: unsynchronized volume controls in audio setups. This article details how to create a virtual ALSA device that intercepts and synchronizes volume commands between multiple audio sources and a physical device, like a stereo system with independent USB card and amplifier volume controls. Our method focuses on leveraging existing ALSA infrastructure to create a robust and adaptable solution.

The Challenge: Independent Volume Controls and ALSA Integration

Many audio enthusiasts encounter the frustrating issue of disparate volume controls. Imagine a setup where your stereo has its own volume knob, and the USB card feeding it audio also possesses a separate volume level. Adjusting one doesn’t affect the other, leading to a constant need for tweaking and potentially inconsistent audio levels. Your existing setup, where a USB card feeds directly into the power amp, perfectly illustrates this point. While you’ve already made strides by hacking the remote and enabling ALSA volume control for sources like Shairport-sync and Spotifyd, the core problem – the lack of synchronization – persists.

This article guides you through building a solution using ALSA, focusing on creating a virtual device that intercepts volume changes from your audio sources and translates them into commands for your stereo system. This approach allows for a unified volume control experience, regardless of the source or the device being controlled.

Conceptualizing the Virtual ALSA Device: Interception and Control

The key to synchronizing your volume controls lies in creating a virtual ALSA device. This virtual device acts as an intermediary, sitting between your audio sources (Shairport-sync, Spotifyd) and your physical audio output (USB card to amplifier).

  1. Interception: The virtual device intercepts volume control commands destined for the actual USB card ALSA device.
  2. Synchronization Logic: It applies a synchronization logic, ensuring that volume changes are mirrored or scaled appropriately to maintain a consistent listening experience.
  3. Command Execution: It translates the volume changes into commands that can be sent to your stereo’s hardware, adjusting its volume level.

The “Forward” Arrow: Reimagined

Your visual representation of a “forward” arrow aptly captures the essence of our goal. Instead of simply forwarding the signal, our virtual device interprets and transforms the signal to achieve synchronization.

Step-by-Step Implementation: Building the Virtual ALSA Device

We will implement this in multiple steps:

  • Configure ALSA virtual soundcard
  • Write a synchronization service
  • Remote control handling
  • Complete script

Prerequisites

Before proceeding, ensure you have the following:

  • A working ALSA setup on your system.
  • Familiarity with basic Linux command-line operations.
  • alsa-utils package installed (contains tools like alsamixer and amixer).
  • Root access or sudo privileges.
  • Python 3 and pip (for the synchronization script).

1. Creating the Virtual ALSA Device using snd-aloop

The easiest approach involves using the snd-aloop module, which creates a virtual audio loopback device. This device has a playback side and a capture side, allowing us to intercept audio and volume commands.

Loading the Module:

Load the snd-aloop module:

sudo modprobe snd-aloop

To make this persistent across reboots, add snd-aloop to /etc/modules:

echo "snd-aloop" | sudo tee -a /etc/modules

Identifying the Card Number:

After loading the module, ALSA will assign a card number to the new virtual device. Use aplay -l to identify it. Look for output similar to:

card 1: Loopback [Loopback], device 0: Loopback PCM [Loopback PCM]
  Subdevices: 8/8
  Subdevice #0: subdevice #0
  Subdevice #1: subdevice #1
  ...

In this example, the card number is 1. Note this number, as we will use it throughout the configuration. We will refer to this as CARD_NUMBER.

Configuring .asoundrc

Create or modify your .asoundrc file (located in your home directory) to define the virtual device. This file tells ALSA how to use the snd-aloop device.

pcm.virtual {
    type hw
    card CARD_NUMBER
    device 0
}

ctl.virtual {
    type hw
    card CARD_NUMBER
}

pcm.passthrough {
    type plug
    slave.pcm "hw:CARD_NUMBER,1" #The real hardware device to which to send
}

pcm.!default {
    type plug
    slave.pcm "virtual"
}

ctl.!default {
    type hw
    card CARD_NUMBER
}

Replace CARD_NUMBER with the actual card number identified earlier.

  • pcm.virtual: Defines the virtual PCM device, pointing to the snd-aloop card.
  • ctl.virtual: Defines the control interface for the virtual device.
  • pcm.passthrough: Defines the hardware output which should receive the forwarded audio signals.
  • pcm.!default: Sets the default PCM device to the virtual device. This ensures that applications default to using our virtual device for audio output.
  • ctl.!default: Sets the default control device to the snd-aloop virtual device.

Important: Ensure that the hardware specified in pcm.passthrough is changed accordingly. You can verify the device number with aplay -l.

2. Writing the Synchronization Service (Python)

Now, let’s create a Python script that monitors the volume of the virtual ALSA device and adjusts the volume of your stereo system accordingly.

#!/usr/bin/env python3

import alsaaudio
import time
import subprocess

# Configuration
VIRTUAL_CARD = 'virtual' # Name of the ALSA control for the virtual device (defined in .asoundrc)
PHYSICAL_CARD = 'hw:CARD_NUMBER' # The actual soundcard to control
PHYSICAL_CONTROL_NAME = 'Master' # Name of the ALSA control of the target soundcard
REMOTE_COMMAND = ['irsend', 'SEND_ONCE', 'YOUR_REMOTE_CONFIG', 'volumeup'] # Example: Adjust the volume by irsend

# Initialise
try:
    virtual_mixer = alsaaudio.Mixer(VIRTUAL_CARD)
    physical_mixer = alsaaudio.Mixer(PHYSICAL_CONTROL_NAME, card=PHYSICAL_CARD)
except alsaaudio.ALSAError as e:
    print(f"Error initialising mixers: {e}")
    exit(1)

last_volume = virtual_mixer.getvolume()[0]

def set_physical_volume(volume: int):
    """Adjusts physical sound card via specified method."""
    # Here you place the code to control your target device.
    # This example uses irsend to emulate remote control commands
    # Adjust the REMOTE_COMMAND according to your needs
    if volume > last_volume:
        for _ in range(volume - last_volume):
            subprocess.run(REMOTE_COMMAND, capture_output=True)
    elif volume < last_volume:
        # Example of sending volume down
        volume_down_command = ['irsend', 'SEND_ONCE', 'YOUR_REMOTE_CONFIG', 'volumedown']
        for _ in range(last_volume - volume):
            subprocess.run(volume_down_command, capture_output=True)
    else:
        #Volume is unchanged, do nothing
        pass

while True:
    try:
        current_volume = virtual_mixer.getvolume()[0]
        if current_volume != last_volume:
            print(f"Volume changed: {last_volume} -> {current_volume}")
            set_physical_volume(current_volume)
            last_volume = current_volume
        time.sleep(0.05)
    except alsaaudio.ALSAError as e:
        print(f"Error reading/setting volume: {e}")
        time.sleep(1) # Wait a little before trying again in case the error is transient
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        break

Explanation:

  • Imports: Imports necessary libraries: alsaaudio for ALSA interaction, time for pausing execution, and subprocess to run external commands.
  • Configuration:
    • VIRTUAL_CARD: This matches the CARD_NUMBER from your .asoundrc file. It specifies the virtual ALSA card we created.
    • PHYSICAL_CARD: The real hardware device to control.
    • PHYSICAL_CONTROL_NAME: The control name on your hardware device. It is possible to specify a device without this value.
    • REMOTE_COMMAND: A string containing the command to send to your stereo system to adjust the volume. This will likely involve using irsend (if you’re controlling the volume via infrared) or a similar command-line tool. You’ll need to adapt this based on how you control your stereo.
  • Mixer Initialization:
    • virtual_mixer = alsaaudio.Mixer('virtual'): Creates a mixer object for the virtual ALSA device.
    • physical_mixer = alsaaudio.Mixer('Master', card='hw:1'): Creates a mixer object for your physical audio output. Replace ‘Master’ and ‘hw:1’ with the appropriate control name and card identifier for your device. You can find these using alsamixer.
  • Volume Synchronization Loop:
    • The script enters an infinite loop that continuously monitors the volume of the virtual device.
    • current_volume = virtual_mixer.getvolume()[0]: Retrieves the current volume level from the virtual mixer.
    • If the volume has changed, the script executes the REMOTE_COMMAND to adjust the stereo’s volume accordingly.
    • time.sleep(0.05): Pauses execution for a short period to avoid excessive CPU usage.

Important Considerations:

  • irsend Configuration: If you’re using irsend, ensure it’s properly configured to control your stereo. Test the irsend commands directly from the command line before incorporating them into the script.
  • Error Handling: The script includes basic error handling to catch potential ALSA errors. You can expand on this to provide more robust error reporting and recovery.
  • Volume Mapping: The script currently assumes a 1:1 mapping between the virtual device’s volume and the stereo’s volume. You might need to adjust the script to implement a different mapping, especially if the volume ranges of the two devices differ.

Save the script to a file, for example, volume_sync.py, and make it executable:

chmod +x volume_sync.py

3. Running the Synchronization Script as a Service

To ensure the script runs automatically in the background, create a systemd service file:

sudo nano /etc/systemd/system/volume_sync.service

Add the following content:

[Unit]
Description=ALSA Volume Synchronization Service
After=network.target sound.target

[Service]
User=your_username # Replace with your username
ExecStart=/path/to/volume_sync.py # Replace with the actual path to the script
Restart=on-failure

[Install]
WantedBy=multi-user.target

Replace your_username with your actual username and /path/to/volume_sync.py with the full path to your Python script.

Enable and start the service:

sudo systemctl enable volume_sync.service
sudo systemctl start volume_sync.service

Check the service status:

sudo systemctl status volume_sync.service

4. Setting Audio Output to the Virtual Device

Finally, configure your audio sources (Shairport-sync, Spotifyd) to output audio to the virtual ALSA device. This ensures that all audio passes through the virtual device, allowing the synchronization script to intercept and control the volume.

Modify the configuration files for your audio sources to specify the virtual ALSA device as the output. The exact method for doing this will vary depending on the specific audio source. For example, in Shairport-sync, you would modify the output_device setting in the shairport-sync.conf file.

Full Example Implementation

Here’s a complete script example, assuming irsend is used for the physical volume control. Replace placeholders with your actual values:

#!/usr/bin/env python3

import alsaaudio
import time
import subprocess

# Configuration
VIRTUAL_CARD = 'virtual'
PHYSICAL_CARD = 'hw:CARD_NUMBER'
PHYSICAL_CONTROL_NAME = 'Master'
REMOTE_CONFIG = 'Onkyo_TX-8270' #Update this as your remote configuration
REMOTE_VOLUME_UP_COMMAND = ['irsend', 'SEND_ONCE', REMOTE_CONFIG, 'volumeup']
REMOTE_VOLUME_DOWN_COMMAND = ['irsend', 'SEND_ONCE', REMOTE_CONFIG, 'volumedown']

# Initialize
try:
    virtual_mixer = alsaaudio.Mixer(VIRTUAL_CARD)
except alsaaudio.ALSAError as e:
    print(f"Error initialising virtual mixer: {e}")
    exit(1)

last_volume = virtual_mixer.getvolume()[0]

def set_physical_volume(volume: int):
    global last_volume
    volume_delta = volume - last_volume
    if volume_delta > 0:
        for _ in range(volume_delta):
            subprocess.run(REMOTE_VOLUME_UP_COMMAND, capture_output=True)
            time.sleep(0.05) # Add a small delay
    elif volume_delta < 0:
        for _ in range(-volume_delta):
            subprocess.run(REMOTE_VOLUME_DOWN_COMMAND, capture_output=True)
            time.sleep(0.05) # Add a small delay
    last_volume = volume

while True:
    try:
        current_volume = virtual_mixer.getvolume()[0]
        if current_volume != last_volume:
            print(f"Volume changed: {last_volume} -> {current_volume}")
            set_physical_volume(current_volume)
        time.sleep(0.1)
    except alsaaudio.ALSAError as e:
        print(f"Error reading/setting volume: {e}")
        time.sleep(1)
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        break

Remember to set the correct values for the device and irsend configuration.

Remote Control Integration

Since you’ve already hacked the remote, we can leverage this capability to integrate the physical volume control into our synchronization scheme. This ensures that changes made via the remote are also reflected in the virtual ALSA device and, consequently, synchronized across all audio sources.

  1. Remote Event Handling: Implement a mechanism to detect volume up/down events from your hacked remote. This could involve using a library like lirc (Linux Infrared Remote Control) to capture the remote signals.
  2. Volume Adjustment: When a remote event is detected, adjust the volume of the virtual ALSA device using alsaaudio. This will trigger the synchronization script to update the stereo’s volume.

Example using lirc (Conceptual):

import lirc
import alsaaudio

# ... (previous code) ...

# LIRC setup
sockid = lirc.init("myprogram")

try:
    while True:
        code = lirc.nextcode()
        if code:
            if code[0] == "volumeup":
                current_volume = virtual_mixer.getvolume()[0]
                virtual_mixer.setvolume(min(100, current_volume + 5)) # Increment volume
            elif code[0] == "volumedown":
                current_volume = virtual_mixer.getvolume()[0]
                virtual_mixer.setvolume(max(0, current_volume - 5)) # Decrement volume

        time.sleep(0.1)

except KeyboardInterrupt:
    lirc.deinit()

Important: This is a simplified example and requires proper configuration of lirc and adaptation to your specific remote control setup.

Alternative Approaches

While snd-aloop and alsaaudio offer a relatively straightforward solution, other approaches are possible:

  • Dmix Plugin: The dmix plugin allows multiple applications to share a single sound card. While primarily used for audio mixing, it can also be used to intercept and modify volume commands.
  • PulseAudio: If you’re using PulseAudio, you can leverage its module system to create a custom module that intercepts volume changes and synchronizes them with your stereo.

Conclusion: A Unified Audio Experience

By implementing a virtual ALSA device and a synchronization script, you can effectively unify volume control across multiple audio sources and your stereo system. This creates a seamless and intuitive audio experience, eliminating the frustration of managing disparate volume levels. While the initial setup requires some technical configuration, the end result is a significantly enhanced listening experience. Remember to adapt the code and configuration examples to your specific hardware and software setup. Good luck!