How to get sys-audio working in other VMs using an audio jack?

I’m relatively new to QubesOS and I’m trying to get the audio to work in multiple VMs. I have followed two guides posted on the forum, one using a fedora VM and another using a debian-11-minimal VM:

In each of these instances I managed to get audio to play in the sys-audio VM, however I can’t seem to get it to connect to any of the other VMs. I can’t help but wonder whether this is not a bug, but a feature, since I can only ever play the audio in one VM that has to be set up as in the guides.

I’m using audio jack headphones and I can’t see them in the device/input list, except for dom0:mic. I’m not really interested in getting audio input working, since I have no use for it at the time, but it would be great to get the audio output working so I can watch videos or listen to music while I’m doing other stuff. Right now I’m just downloading the media content, loading it onto sys-audio and doing it that way, but it just seems less than ideal.

The one part of the first tutorial using the fedora VM that I did not understand is configuring the policy, since I don’t know how to patch the source code. I tried the “recommended way” and “non recommended way” nonetheless, but I didn’t have any success with either route.

I’m wondering what I’m doing wrong trying to get audio input from the other VMs. Has anyone else faced this issue? If so how did you solve it?

Any help is greatly appreciated.

The first guide worked for me. I guess you’ve made an error somewhere while following the guide.

You need to open pulseaudio Volume Control (pavucontrol) of your sys-audio, not dom0.

So here are all of my actions:

  • Created template VM based on fedora-39-xfce called ‘audio-template’
  • Created AppVM using audio-template called ‘audio-app’
  • Checked “Disposable template” in ‘audio-app’
  • Created DisposableVM using ‘audio-app’ as a template called ‘sys-audio’
  • Opened terminal for ‘audio-template’
  • Installed packages: sudo dnf install -y pipewire-qubes qubes-audio-daemon pavucontrol qubes-core-admin-client qubes-usb-proxy alsa-utils
  • Installed systray: sudo dnf install -y pasystray
  • Opened 'sys-audio settings:
    • Unchecked “Include in memory balancing”
    • Changed Virtualization Mode from PVH to HVM
    • Applied changes
    • Add Audio device to “Devices always connected to this qube”
    • Configured Audio device to have “no strict reset” option
    • Saved settings
  • Executed command in dom0 to make ‘sys-audio’ the default audiovm: qubes-prefs default_audiovm sys-audio
  • Opened terminal for ‘sys-audio’
  • Executed command to test sound: aplay /usr/share/sounds/alsa/Noise.wav
  • Sound worked successfully
  • Went to ‘sys-audio’ settings>services
  • Added custom service called ‘audiovm’ and saved
  • Going the “NON RECOMMENDED WAY” (I don’t know how to patch source code)
  • Copied the code into a file and transferred the file to dom0
  • Moved file to /etc/qubes/policy.d/50-sys-audio.policy
  • Restarted ‘sys-audio’
  • Tested sound once more and it works
  • Executed command in dom0 to make ‘sys-audio’ the default audiovm: qubes-prefs default_audiovm sys-audio
  • Launched two qubes: debian 11 and fedora 39
  • Tested audio in debian 11
Playing WAVE '/usr/share/sounds/alsa/Noise.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Mono
  • No sound was audible
  • Tested audio in fedora 39
Playing WAVE '/usr/share/sounds/alsa/Noise.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Mono
aplay: set_params:1456: Unable to install hw params:
ACCESS:  RW_INTERLEAVED
FORMAT:  S16_LE
SUBFORMAT:  STD
SAMPLE_BITS: 16
FRAME_BITS: 16
CHANNELS: 1
RATE: 48000
PERIOD_TIME: 125000
PERIOD_SIZE: 6000
PERIOD_BYTES: 12000
PERIODS: 4
BUFFER_TIME: 500000
BUFFER_SIZE: 24000
BUFFER_BYTES: 48000
TICK_TIME: [0 0]
  • No sound was audible

After opening PulseAudio Volume Control for ‘sys-audio’ I see:

Playback

  • System Sounds

Recording:

  • No application is currently recording audio

Output Devices:

  • Built-in Audio Analog Stereo
  • Port: Headphones (plugged in)

Input Devices:

  • Built-in Audio Analog Stereo
  • Port: Analog Input

Configuration

  • Built-in Audio
  • Profile: Analog Stereo Duplex

I’m not sure what I’m doing wrong or whether I misunderstood some step in the guide.

Do you have Qubes OS 4.1 or Qubes OS 4.2?
Debian 11 should be unsupported on Qubes OS 4.2. And I’m not sure about this guide working on Qubes OS 4.1.

I’m currently running Qubes OS 4.2.1 (R4.2)

Edit: In my second post where I outlined the steps, I’m following this guide: Audio qube

I’ve tried with fedora-38-xfce and "RECOMMENDED WAY”, maybe there is an issue with fedora-39 or “NON RECOMMENDED WAY”.

Just for clarification, both of the above commands will set the default audiovm only for new VMs you would create. Not for the existing ones. Have you changed the audiovm for the existing ones with something like:

qvm-prefs debian-11 audiovm sys-audio

Sound won’t work for qubes that were running before sys-audio start, not the ones that were created before changing default_audiovm to sys-audio.
Also dynamic audiovm switching is not supported for now so changing audiovm of a running qube won’t work:

But you can still make it work manually:

I tried the recommended way after realizing that patching it wasn’t that difficult… I have the same issue unfortunately. For Fedora I realized that I have to open up PulseAudio Volume Control to get rid of the error message. Both debian and fedora show me this message:

Playing WAVE '/usr/share/sounds/alsa/Noise.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Mono

The problem is that it’s simply inaudible in any VM except for the sys-audio VM.

@alimirjamali I tried that too by creating a new VM. Tried rebooting sys-audio and rebooting any of the other VMs.

Looking at qvm-start-daemon --all --watch, it shows that the VMs are connected to sys-audio:

personal: AUDIO connected to sys-audio. Skipping.
work: AUDIO connected to sys-audio. Skipping.
whonix: AUDIO connected to sys-audio. Skipping.
my-box: AUDIO connected to sys-audio. Skipping.

Open the dom0 terminal, execute this command:

journalctl -f -n0

Try to start some qube.
Check the messages related to sys-audio policy.
Maybe there will be some errors.

I’ve just tried to create audio qube using debian-12-minimal template with "RECOMMENDED WAY” and it worked for me as well.
I’ve installed these packages:

apt install -y pipewire-qubes qubes-audio-daemon pavucontrol qubes-core-admin-client qubes-usb-proxy alsa-utils blueman bluez firmware-linux pasystray libspa-0.2-bluetooth

And patched this file:

/usr/lib/python3/dist-packages/qubesadmin/tools/qvm_start_daemon.py
Patch
--- qvm_start_daemon.py.orig	2024-05-03 12:56:17.535996438 +0000
+++ qvm_start_daemon.py	2024-05-03 13:04:34.394034525 +0000
@@ -322,7 +322,7 @@
 class DAEMONLauncher:
     """Launch GUI/AUDIO daemon for VMs"""
 
-    def __init__(self, app: qubesadmin.app.QubesBase, vm_names=None, kde=False):
+    def __init__(self, app: qubesadmin.app.QubesBase, services, vm_names=None, kde=False):
         """ Initialize DAEMONLauncher.
 
         :param app: :py:class:`qubesadmin.Qubes` instance
@@ -333,6 +333,7 @@
         self.started_processes = {}
         self.vm_names = vm_names
         self.kde = kde
+        self.services = services
 
         # cache XID values when the VM was still running -
         # for cleanup purpose
@@ -491,6 +492,9 @@
         :param monitor_layout: monitor layout to send; if None, fetch it from
             local X server.
         """
+        if not "guivm" in self.services:
+            return
+
         guid_cmd = self.common_guid_args(vm)
         if self.kde:
             guid_cmd.extend(self.kde_guid_args(vm))
@@ -519,6 +523,9 @@
 
         This function is a coroutine.
         """
+        if not "guivm" in self.services:
+            return
+
         want_stubdom = force
         if not want_stubdom and \
                 vm.features.check_with_template('gui-emulated', False):
@@ -547,6 +554,9 @@
 
         :param vm: VM for which start AUDIO daemon
         """
+        if not "audiovm" in self.services:
+            return
+
         pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name]
         vm.log.info('Starting AUDIO')
 
@@ -562,6 +572,9 @@
             one for target AppVM is running.
         :param monitor_layout: monitor layout configuration
         """
+        if not "guivm" in self.services:
+            return
+
         guivm = getattr(vm, 'guivm', None)
         if guivm != vm.app.local_name:
             vm.log.info('GUI connected to {}. Skipping.'.format(guivm))
@@ -583,6 +596,9 @@
 
         :param vm: VM for which AUDIO daemon should be started
         """
+        if not "audiovm" in self.services:
+            return
+
         audiovm = getattr(vm, 'audiovm', None)
         if audiovm != vm.app.local_name:
             vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm))
@@ -601,6 +617,9 @@
         if not self.is_watched(vm):
             return
 
+        if not "guivm" in self.services:
+            return
+
         try:
             if getattr(vm, 'guivm', None) != vm.app.local_name:
                 return
@@ -622,28 +641,32 @@
 
         self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid
 
-        try:
-            if getattr(vm, 'guivm', None) == vm.app.local_name and \
-                    vm.features.check_with_template('gui', True) and \
-                    kwargs.get('start_guid', 'True') == 'True':
-                asyncio.ensure_future(self.start_gui_for_vm(vm))
-        except qubesadmin.exc.QubesException as e:
-            vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
-
-        try:
-            if getattr(vm, 'audiovm', None) == vm.app.local_name and \
-                    vm.features.check_with_template('audio', True) and \
-                    kwargs.get('start_audio', 'True') == 'True':
-                asyncio.ensure_future(self.start_audio_for_vm(vm))
-        except qubesadmin.exc.QubesException as e:
-            vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
+        if "guivm" in self.services:
+            try:
+                if getattr(vm, 'guivm', None) == vm.app.local_name and \
+                        vm.features.check_with_template('gui', True) and \
+                        kwargs.get('start_guid', 'True') == 'True':
+                    asyncio.ensure_future(self.start_gui_for_vm(vm))
+            except qubesadmin.exc.QubesException as e:
+                vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
 
+        if "audiovm" in self.services:
+            try:
+                if getattr(vm, 'audiovm', None) == vm.app.local_name and \
+                        vm.features.check_with_template('audio', True) and \
+                        kwargs.get('start_audio', 'True') == 'True':
+                    asyncio.ensure_future(self.start_audio_for_vm(vm))
+            except qubesadmin.exc.QubesException as e:
+                vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
     def on_connection_established(self, _subject, _event, **_kwargs):
         """Handler of 'connection-established' event, used to launch GUI/AUDIO
         daemon for domains started before this tool. """
 
-        monitor_layout = get_monitor_layout()
         self.app.domains.clear_cache()
+
+        if "guivm" in self.services:
+            monitor_layout = get_monitor_layout()
+
         for vm in self.app.domains:
             if vm.klass == 'AdminVM':
                 continue
@@ -653,17 +676,23 @@
 
             power_state = vm.get_power_state()
             if power_state == 'Running':
-                asyncio.ensure_future(
-                    self.start_gui(vm, monitor_layout=monitor_layout))
-                asyncio.ensure_future(self.start_audio(vm))
+
+                if "guivm" in self.services:
+                    asyncio.ensure_future(
+                        self.start_gui(vm, monitor_layout=monitor_layout))
+
+                if "audiovm" in self.services:
+                    asyncio.ensure_future(self.start_audio(vm))
+
                 self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid
             elif power_state == 'Transient':
                 # it is still starting, we'll get 'domain-start'
                 # event when fully started
-                if vm.virt_mode == 'hvm':
+                if vm.virt_mode == 'hvm' and "guivm" in self.services:
                     asyncio.ensure_future(
                         self.start_gui_for_stubdomain(vm))
 
+
     def on_domain_stopped(self, vm, _event, **_kwargs):
         """Handler of 'domain-stopped' event, cleans up"""
 
@@ -678,8 +707,19 @@
             return
         if xid != -1:
             self.cleanup_guid(xid)
+            self.cleanup_pacat_process(xid)
         if stubdom_xid != -1:
             self.cleanup_guid(stubdom_xid)
+            self.cleanup_pacat_process(stubdom_xid)
+
+    def cleanup_pacat_process(self, xid):
+        try:
+            with open(self.pacat_pidfile(xid)) as f:
+                pid = int(f.readline())
+                os.kill(pid, signal.SIGTERM)
+                print(f"Sent SIGTERM signal to pacat-simple-vchan process {pid}")
+        except OSError:
+            print(f"Failed to send SIGTERM signal for the pacat-simple-vchan with xid of {xid}")
 
     def cleanup_guid(self, xid):
         """
@@ -743,8 +783,9 @@
     only_if_service_enabled = ['guivm', 'audiovm']
     enabled_services = [service for service in only_if_service_enabled if
                         os.path.exists('/var/run/qubes-service/%s' % service)]
-    if not enabled_services and '--force' not in sys.argv and \
-            not os.path.exists('/etc/qubes-release'):
+    if os.path.exists('/etc/qubes-release'):
+        enabled_services = only_if_service_enabled
+    if not enabled_services and '--force' not in sys.argv:
         print(parser.format_help())
         return
     args = parser.parse_args(args)
@@ -758,7 +799,8 @@
     launcher = DAEMONLauncher(
         args.app,
         vm_names=vm_names,
-        kde=args.kde)
+        kde=args.kde,
+        services=enabled_services)
 
     if args.watch:
         fd = os.open(args.pidfile,
@@ -780,8 +822,14 @@
             lock_f.flush()
             lock_f.truncate()
             loop = asyncio.get_event_loop()
-            # pylint: disable=no-member
-            events = qubesadmin.events.EventsDispatcher(args.app)
+
+            if "guivm" in enabled_services:
+                # pylint: disable=no-member
+                events = qubesadmin.events.EventsDispatcher(args.app)
+            else:
+                # pylint: disable=no-member
+                events = qubesadmin.events.EventsDispatcher(args.app,enable_cache=False)
+
             # pylint: enable=no-member
             launcher.register_events(events)
 
@@ -794,18 +842,20 @@
             loop.add_signal_handler(signal.SIGHUP,
                                     launcher.send_monitor_layout_all)
 
-            conn = xcffib.connect()
-            x_watcher = XWatcher(conn, args.app)
-            x_fd = conn.get_file_descriptor()
-            loop.add_reader(x_fd, x_watcher.event_reader,
-                            events_listener.cancel)
-            x_watcher.update_keyboard_layout()
+            if "guivm" in enabled_services:
+                conn = xcffib.connect()
+                x_watcher = XWatcher(conn, args.app)
+                x_fd = conn.get_file_descriptor()
+                loop.add_reader(x_fd, x_watcher.event_reader,
+                                events_listener.cancel)
+                x_watcher.update_keyboard_layout()
 
             try:
                 loop.run_until_complete(events_listener)
             except asyncio.CancelledError:
                 pass
-            loop.remove_reader(x_fd)
+            if "guivm" in enabled_services:
+                loop.remove_reader(x_fd)
             loop.stop()
             loop.run_forever()
             loop.close()