Ein kombinierter Audio-Ausgang mit PipeWire

Veröffentlicht von
Table of Contents

Es gibt diese Momente im Linux-Leben, in denen man denkt:
"Warum kann ich nicht einfach gleichzeitig über Headset und Monitor hören?"

Die Antwort lautet: Es geht.
Man muss PipeWire nur ein bisschen überreden.

Das Problem: Mehrere Ausgänge, ein Sound

Typisches Setup:

  • USB-Headset für Meetings und Games
  • Lautsprecher eines HDMI-Monitor
  • vielleicht noch Bluetooth Lautsprecher

Standardmäßig darf immer nur ein Sink (also ein Audio Ausgabegerät) das Standardgerät sein. Was wir aber wollen: Einen kombinierten virtuellen Sink, damit der Ton gleichzeitig an mehrere echte Ausgänge verteilt wird.

PipeWire kann das mit "combine-stream"

PipeWire bringt ein Modul mit, das genau dafür gemacht wurde: libpipewire-module-combine-stream. Dieses Modul erzeugt einen neuen virtuellen Sink, das intern mehrere echte Sinks ansteuert.
Wichtig dabei:

  • combine.mode = sink ==> Wir erstellen ein neues virtuelles Audio Ausgabegerät
  • combine.latency-compensate = true ==> Latenzunterschiede (USB vs. HDMI vs. Bluetooth) werden ausgeglichen (soweit möglich)
  • audio.position = [ FL FR ] ==> Stereo Ausgang

Stereo der einfachheit halber. Die meisten Headsets und HDMI-Ausgänge laufen ohnehin nur in 2.0.

Node-Namen herausfinden

Für die eigentliche Konfiguration werden die internen node.name Bezeichnungen der Sinks, also der Audio Ausgänge, benötigt:

pw-link -o | grep -i output
alsa_output.usb-SteelSeries_Arctis_Nova_4X-00.analog-stereo:monitor_FL
alsa_output.usb-SteelSeries_Arctis_Nova_4X-00.analog-stereo:monitor_FR
alsa_output.usb-Generic_USB_Audio-00.HiFi__SPDIF__sink:monitor_FL
alsa_output.usb-Generic_USB_Audio-00.HiFi__SPDIF__sink:monitor_FR
alsa_output.usb-Generic_USB_Audio-00.HiFi__Speaker__sink:monitor_FL
alsa_output.usb-Generic_USB_Audio-00.HiFi__Speaker__sink:monitor_FR
alsa_output.usb-Generic_USB_Audio-00.HiFi__Headphones__sink:monitor_FL
alsa_output.usb-Generic_USB_Audio-00.HiFi__Headphones__sink:monitor_FR
alsa_output.pci-0000_01_00.1.hdmi-stereo:monitor_FL
alsa_output.pci-0000_01_00.1.hdmi-stereo:monitor_FR
bluez_output.4C_1B_86_70_93_63.1:monitor_FL
bluez_output.4C_1B_86_70_93_63.1:monitor_FR

Konfiguration anlegen

Zuerst das Konfigurationsverzeichnis erzeugen:

mkdir -p $XDG_CONFIG_HOME/pipewire/pipewire.conf.d/
nano $XDG_CONFIG_HOME/pipewire/pipewire.conf.d/add-combined-sink.conf

Falls $XDG_CONFIG_HOME nicht gesetzt ist, ist es üblicherweise ~/.config.

Beispiel: Monitor + USB-Headset

context.modules = [
  { name = libpipewire-module-combine-stream
    args = {
      combine.mode = sink
      node.name = "combined_output_2"
      node.description = "Combined Output (Monitor / Headset)"

      # gleicht Latenzdifferenzen aus (HDMI vs. USB vs. BT)
      # Perfekte Synchronität ist physikalisch aber schwer erreichbar
      combine.latency-compensate = true

      # Kombi-Sink ist Stereo
      combine.props = { audio.position = [ FL FR ] }

      stream.rules = [
        # USB-Headset (SteelSeries)
        { matches = [
            { media.class = "Audio/Sink"
              node.name = "alsa_output.usb-SteelSeries_Arctis_Nova_4X-00.analog-stereo" }
          ]
          actions = { create-stream = {
            combine.audio.position = [ FL FR ]
            audio.position = [ FL FR ]
          } }
        },

        # HDMI-Monitor (NVidia / Stereo)
        { matches = [
            { media.class = "Audio/Sink"
              node.name = "alsa_output.pci-0000_01_00.1.hdmi-stereo" }
          ]
          actions = { create-stream = {
            combine.audio.position = [ FL FR ]
            audio.position = [ FL FR ]
          } }
        }
      ]
    }
  }
]

Neustart nicht vergessen

systemctl --user restart pipewire.service

Danach erscheint ein neues Ausgabegerät mit Namen "Combined Output (Monitor / Headset)" in den Audioeinstellungen.

Erweiterung: Bluetooth

Im nächsten Beispiel werden die beiden obigen Ausgänge um einen zusätzlichen Bluetooth Ausgang erweitert:

context.modules = [
  { name = libpipewire-module-combine-stream
    args = {
      combine.mode = sink
      node.name = "combined_output_3"
      node.description = "Combined Output (Monitor / Headset / Bluetooth)"

      # gleicht Latenzdifferenzen aus (HDMI vs. USB vs. BT)
      # Perfekte Synchronität ist physikalisch aber schwer erreichbar
      combine.latency-compensate = true

      # Kombi-Sink ist Stereo
      combine.props = { audio.position = [ FL FR ] }

      stream.rules = [

        # USB-Headset (SteelSeries)
        { matches = [
            { media.class = "Audio/Sink"
              node.name = "alsa_output.usb-SteelSeries_Arctis_Nova_4X-00.analog-stereo" }
          ]
          actions = { create-stream = {
            combine.audio.position = [ FL FR ]
            audio.position = [ FL FR ]
          } }
        },

        # HDMI-Monitor (NVidia / Stereo)
        { matches = [
            { media.class = "Audio/Sink"
              node.name = "alsa_output.pci-0000_01_00.1.hdmi-stereo" }
          ]
          actions = { create-stream = {
            combine.audio.position = [ FL FR ]
            audio.position = [ FL FR ]
          } }
        },

        # Bluetooth-Ausgabe
        { matches = [
            { media.class = "Audio/Sink"
              node.name = "bluez_output.4C_1B_86_70_93_63.1" }
          ]
          actions = { create-stream = {
            combine.audio.position = [ FL FR ]
            audio.position = [ FL FR ]
            # optional: etwas größere Blockgröße hilft manchen BT-Stacks
            # 10,7ms
            # node.latency = "512/48000"
            # 1s
            node.latency = "48000/48000"
          } }
        }
      ]
    }
  }
]

Im node.name lässt sich auch RegEx verwenden.

node.name = ~"bluez_output\\..*"

Das bedeutet: Alle BlueZ-Ausgänge werden automatisch angesteuert.
Praktisch, wenn der Kopfhörername wechselt oder man einfach mehrere Bluetooth Geräte hat.

Und jetzt Audio Session Management

Bis hierhin haben wir einen neuen Audio-Sink erzeugt. Aber wer entscheidet eigentlich, welche Anwendung welchen Ausgang nutzt? Hier kommt WirePlumber ins Spiel.
PipeWire selbst ist "nur" der Medien-Server. Er verwaltet Nodes, Streams, Ports und Verbindungen.
Aber er entscheidet nicht aktiv:

  • Welche Anwendung bekommt welchen Ausgang?
  • Was passiert, wenn ein neues Gerät eingesteckt wird?
  • Was passiert, wenn ein Gerät verschwindet?

Diese Logik übernimmt der Session Manager. In modernen Distributionen übernimmt das WirePlumber.

Was macht WirePlumber konkret?

WirePlumber beobachtet permanent:

  • Neue Streams (z. B. wenn Firefox Ton abspielt)
  • Neue Geräte (z. B. wenn ein Bluetooth-Headset verbunden wird)
  • Entfernte Geräte
  • Manuelle Routing-Änderungen durch den Benutzer

Er trifft daraufhin Entscheidungen. Beispiel:

  1. Du startest ein Spiel
  2. Das Spiel erzeugt einen Audio-Stream
  3. WirePlumber wählt einen geeigneten Sink
  4. Du ziehst den Stream manuell auf "combined_output"
  5. WirePlumber merkt sich diese Entscheidung

Beim nächsten Start desselben Spiels wird wieder dieser Sink genutzt.

Wo speichert WirePlumber das?

Die Zustandsdaten liegen typischerweise unter:

~/.local/state/wireplumber/

Dort speichert WirePlumber:

  • Zuordnungen von Streams zu Sinks
  • Default-Geräte
  • manuelle Routing-Änderungen
  • Metadaten über Geräte

Löscht man dieses Verzeichnis, "vergisst" es alle bisherigen Zuordnungen.

Wichtige Notiz

Änderungen, die in der Desktopumgebung vorgenommen werden, also:

  • Standard-Ausgabegerät wechseln
  • Lautstärke einzelner Anwendungen ändern
  • Streams manuell auf andere Ausgänge ziehen

werden in der Regel ebenfalls unter ~/.local/state/wireplumber/ gespeichert. Das Desktop-Soundpanel spricht über PipeWire mit WirePlumber, und WirePlumber schreibt den Zustand persistent weg.

Wie erkennt WirePlumber eine Anwendung?

Nicht am Fenstertitel und auch nicht am sichtbaren Programmnamen, sondern an Stream-Properties wie:

  • application.name
  • application.process.binary
  • media.role

Deshalb kann es Unterschiede geben zwischen:

  • Firefox (native)
  • Firefox (Flatpak)
  • Chromium

Aus Sicht von WirePlumber sind das unterschiedliche Identities.

Eigene Routing-Regeln definieren

WirePlumber erlaubt es, eigene Regeln zu definieren.
Beispiele für sinnvolle Policies:

  • Alles mit media.role = Communication ==> Headset
  • Alles mit media.role = Game ==> Combined Sink
  • Alles mit application.name = VLC ==> HDMI

Neuere Versionen von WirePlumber (0.5+) nutzen eine deklarative Konfiguration in ~/.config/wireplumber/. Damit wird Audio-Routing proaktiv steuerbar.

Zusammenspiel mit dem Combined Sink

Technisch betrachtet ist der Combined Sink für WirePlumber einfach ein weiterer Audio-Knoten. Das bedeutet:

  • Er kann Standard-Sink sein
  • Er kann Ziel für einzelne Anwendungen sein
  • Er kann Teil eigener Routing-Regeln werden

Fazit

Mit "wenigen" Zeilen Konfiguration bekommt man:

  • parallele Audioausgabe auf mehreren Geräten
  • Latenzkompensation
  • persistentes Routing pro Anwendung
  • vollständige Kontrolle über Audio-Policies

PipeWire kümmert sich um die Signalverarbeitung. WirePlumber kümmert sich um die Entscheidungen.

Kommentar hinterlassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert