From 0cc492c1ce16e953c27ff4e7dcc724220bf2783c Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Fri, 7 Aug 2026 18:19:56 +0200 Subject: [PATCH] Add streaming audio routing script --- README.md | 107 +++++++ streaming-audio.sh | 731 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 README.md create mode 100755 streaming-audio.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..a74b9f2 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Streaming Audio + +`streaming-audio.sh` richtet ein persönliches Streaming-Audiorouting für Fedora, +PipeWire, OBS und Discord ein. Das Skript trennt Spiel- und Systemton, Discord, +Firebot und Soundboard in eigene virtuelle Ausgänge. + +## Signalwege + +| Virtuelles Gerät | Verwendung | Kopfhörer | OBS | Discord | +| --- | --- | --- | --- | --- | +| `game_audio` | Spiel- und Systemton | ja | ja | nein | +| `discord_sink` | Stimmen aus Discord | ja | ja | nein | +| `firebot_sounds` | Firebot und Browser | ja | ja | nein | +| `soundboard_sounds` | Soundboard und Stream Deck | ja | ja | ja, leiser | +| `discord_micmix` | Gefiltertes OBS-Mikrofon und Soundboard-Kopie | nein | nein | ja | + +Das echte Mikrofon wird zuerst in OBS bearbeitet. Das OBS-Audiomonitoring leitet +die gefilterte Stimme anschließend an `discord_micmix` und damit an das virtuelle +Eingabegerät `Discord-Mikrofon` weiter. + +## Voraussetzungen + +- Fedora oder ein vergleichbares System mit PipeWire und WirePlumber +- `bash`, `pactl`, `pw-link`, `systemctl`, `awk` und `grep` +- OBS Studio und Discord + +Die Gerätenamen und Routing-Regeln sind auf das ursprüngliche System abgestimmt. +Vor einer Verwendung auf einem anderen Rechner müssen insbesondere `MIC_RE`, +`LINE_OUT_RE` und gegebenenfalls die Erkennung der Anwendungen im Skript angepasst +werden. + +## Installation + +```bash +git clone https://git.hnz.io/hnzio/streaming-audio.git ~/Projekte/streaming-audio +ln -s ~/Projekte/streaming-audio/streaming-audio.sh ~/bin/streaming-audio.sh +~/bin/streaming-audio.sh install +``` + +`install` schreibt die PipeWire-Konfiguration nach +`~/.config/pipewire/pipewire.conf.d/10-streaming-sinks.conf` und startet die +PipeWire-Benutzerdienste neu. Laufende Audioverbindungen werden dabei kurz +unterbrochen. Danach sollten OBS, Discord, Firebot und das Soundboard neu gestartet +werden. + +## Verwendung + +```bash +~/bin/streaming-audio.sh install +~/bin/streaming-audio.sh route +~/bin/streaming-audio.sh status +~/bin/streaming-audio.sh cleanup +~/bin/streaming-audio.sh restore-mic +``` + +- `install` bereinigt alte Instanzen und legt die virtuellen Geräte dauerhaft an. +- `route` verschiebt aktive Anwendungen und stellt die PipeWire-Verbindungen her. +- `status` zeigt Geräte, Audiostreams, Module und relevante Verbindungen an. +- `cleanup` entfernt alte manuelle Module und deaktiviert doppelte Konfigurationen. +- `restore-mic` setzt das erkannte ZOOM/H6 wieder als Standardmikrofon. + +`route` sollte ausgeführt werden, wenn OBS, Discord und die zu routenden Anwendungen +bereits laufen oder gerade Ton ausgeben. Spiele sollten danach gestartet werden, +damit sie den virtuellen Standardausgang `game_audio` verwenden. + +## OBS und Discord + +In OBS unter **Einstellungen > Audio > Erweitert**: + +- Audiomonitoring-Gerät: `DiscordMicMix` + +In den erweiterten Audioeigenschaften: + +- Mikrofon: `Monitor und Ausgabe` +- Soundboard und alle anderen Quellen: Monitoring aus + +In Discord: + +- Eingabegerät: `Discord-Mikrofon` + +`DiscordMicMix` darf nicht als eigene OBS-Quelle aufgenommen werden. Auch der +physische Line-Out sollte nicht zusätzlich in OBS aufgenommen werden, da sonst +doppelte Signale oder Hall entstehen können. + +## Pegel anpassen + +Die Pegel und die Loopback-Latenz lassen sich über Umgebungsvariablen ändern: + +```bash +DISCORD_MIC_VOLUME=110% \ +DISCORD_SOUNDBOARD_VOLUME=70% \ +DISCORD_SOUNDBOARD_LATENCY_MS=40 \ + ~/bin/streaming-audio.sh route +``` + +Standardwerte sind `115%`, `75%` und `30` Millisekunden. + +## Hinweise zu `cleanup` + +Das Skript sucht in `~/.config/pipewire/pipewire.conf.d` nach anderen +Konfigurationen, die Namen seiner virtuellen Geräte enthalten. Solche Dateien +werden nicht gelöscht, sondern mit dem Zusatz `.disabled-by-streaming-audio` +umbenannt. Falls dieser Name bereits existiert, wird eine freie nummerierte Variante +verwendet. + +Vor dem ersten Einsatz auf einem anderen System empfiehlt es sich, die vorhandenen +PipeWire-Konfigurationen zu sichern und die Trefferbedingungen im Skript zu prüfen. diff --git a/streaming-audio.sh b/streaming-audio.sh new file mode 100755 index 0000000..6dcec5c --- /dev/null +++ b/streaming-audio.sh @@ -0,0 +1,731 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ------------------------------------------------------------ +# Streaming-Audio-Setup für Fedora / PipeWire / OBS / Discord +# ------------------------------------------------------------ +# +# install = virtuelle PipeWire-Geräte dauerhaft anlegen +# cleanup = alte manuelle null-sink-Module und doppelte alte Configs entfernen/umbenennen +# route = Standardmikrofon setzen, aktive Apps verschieben und Links setzen +# status = Diagnose anzeigen +# restore-mic = echtes ZOOM/H6 wieder als System-Standardmikrofon setzen +# +# Ziel: +# - Mikrofon -> OBS-Filter -> DiscordMicMix -> Discord-Mikrofon -> Discord +# - Soundboard/Stream-Deck -> SoundboardSounds -> Line-Out + OBS +# - SoundboardSounds -> eigener 75-%-Loopback -> DiscordMicMix +# - Firebot/Chromium -> FirebotSounds -> Line-Out + OBS +# - Firefox -> FirebotSounds -> Line-Out + OBS +# - GeForce NOW -> GameAudio -> Line-Out + OBS +# - Discord-Leute -> Discord -> Line-Out + OBS +# - Spiel/Systemsound -> GameAudio -> Line-Out + OBS +# +# Wichtig: +# - In OBS muss als Audiomonitoring-Gerät "DiscordMicMix" eingestellt sein. +# - Beim Mikrofon in OBS muss "Monitor und Ausgabe" aktiviert sein. +# - Beim Soundboard und allen anderen OBS-Quellen bleibt das Monitoring aus. +# - Das echte Mikrofon wird NICHT direkt mit DiscordMicMix verbunden, damit +# Discord nicht gleichzeitig die rohe und die gefilterte Stimme erhält. +# - Firebot geht NICHT in Discord. +# - GameAudio geht NICHT in Discord. +# - DiscordMicMix wird NICHT in OBS aufgenommen, sonst hast du Hall. +# - Starship/Matisse wird NICHT in OBS aufgenommen, sonst kommen Signale doppelt. + +MIC_RE='Handy Recorder|ZOOM|H6' +LINE_OUT_RE='Starship|Matisse|alsa_output.pci-0000_0b_00.4' + +DISCORD_SINK='discord_sink' +DISCORD_MICMIX='discord_micmix' +DISCORD_MIC_SOURCE='discord_microphone' +DISCORD_MIC_DESCRIPTION='Discord-Mikrofon' +FIREBOT_SINK='firebot_sounds' +SOUNDBOARD_SINK='soundboard_sounds' +GAME_SINK='game_audio' + +# Nur echte Discord/WebRTC-Prozesse erfassen. Ein bloßes "Discord" würde auch +# OBS-Quellen wie "Discord-Sound" oder Streams auf discord_micmix erwischen. +DISCORD_APP_RE='application.name = "(WEBRTC VoiceEngine|Discord|discord)"|application.process.binary = "(Discord|discord)"' + +# Nur die Discord-Kopien werden verändert. OBS-Stream und Kopfhörer bleiben gleich. +DISCORD_MIC_VOLUME="${DISCORD_MIC_VOLUME:-115%}" +DISCORD_SOUNDBOARD_VOLUME="${DISCORD_SOUNDBOARD_VOLUME:-75%}" +DISCORD_SOUNDBOARD_LATENCY_MS="${DISCORD_SOUNDBOARD_LATENCY_MS:-30}" + +CONFIG_DIR="$HOME/.config/pipewire/pipewire.conf.d" +CONFIG_FILE="$CONFIG_DIR/10-streaming-sinks.conf" + +die() { + echo "Fehler: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "$1 fehlt. Installiere es bitte zuerst." +} + +show_header() { + echo + echo "== $1 ==" +} + +cleanup_old_modules() { + need_cmd pactl + + show_header "Entferne alte manuelle null-sink-Module dieses Setups" + + local ids + ids="$( + pactl list short modules | awk ' + /module-null-sink/ && ($0 ~ /discord_sink|discord_micmix|firebot_sounds|soundboard_sounds|game_audio|DiscordMicMix|FirebotSounds|SoundboardSounds|GameAudio/) { + print $1 + } + ' + )" + + if [[ -z "${ids}" ]]; then + echo "Keine alten manuellen module-null-sink-Instanzen gefunden." + return 0 + fi + + while read -r id; do + [[ -n "$id" ]] || continue + echo "Entlade altes Modul: $id" + pactl unload-module "$id" 2>/dev/null || true + done <<< "$ids" + + echo "Module-Cleanup fertig." +} + +cleanup_soundboard_discord_loopbacks() { + need_cmd pactl + + local ids + ids="$( + pactl list short modules | awk -v source="${SOUNDBOARD_SINK}.monitor" -v sink="$DISCORD_MICMIX" ' + $2 == "module-loopback" && index($0, "source=" source) && index($0, "sink=" sink) { + print $1 + } + ' + )" + + while read -r id; do + [[ -n "$id" ]] || continue + echo "Entlade alten Soundboard-Discord-Loopback: $id" + pactl unload-module "$id" 2>/dev/null || true + done <<< "$ids" +} + +cleanup_discord_microphone_modules() { + need_cmd pactl + + local ids + ids="$( + pactl list short modules | awk -v source_name="$DISCORD_MIC_SOURCE" ' + $2 == "module-remap-source" && index($0, "source_name=" source_name) { + print $1 + } + ' + )" + + while read -r id; do + [[ -n "$id" ]] || continue + echo "Entlade altes virtuelles Discord-Mikrofon: $id" + pactl unload-module "$id" 2>/dev/null || true + done <<< "$ids" +} + +cleanup_old_configs() { + mkdir -p "$CONFIG_DIR" + + show_header "Prüfe alte doppelte PipeWire-Configs" + + shopt -s nullglob + for file in "$CONFIG_DIR"/*.conf; do + [[ "$file" == "$CONFIG_FILE" ]] && continue + + if grep -qE 'discord_sink|discord_micmix|firebot_sounds|soundboard_sounds|game_audio' "$file" 2>/dev/null; then + local disabled="${file}.disabled-by-streaming-audio" + local suffix=1 + + while [[ -e "$disabled" ]]; do + disabled="${file}.disabled-by-streaming-audio.${suffix}" + ((suffix += 1)) + done + + echo "Deaktiviere alte Config: $file" + mv "$file" "$disabled" + echo "Umbenannt nach: $disabled" + fi + done + shopt -u nullglob +} + +install_sinks() { + need_cmd systemctl + need_cmd pactl + + cleanup_old_modules + cleanup_soundboard_discord_loopbacks + cleanup_discord_microphone_modules + cleanup_old_configs + + show_header "Schreibe PipeWire-Config" + + mkdir -p "$CONFIG_DIR" + + cat > "$CONFIG_FILE" <<'EOF' +context.objects = [ + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "discord_sink" + node.description = "Discord" + media.class = "Audio/Sink" + audio.position = [ FL FR ] + } + } + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "discord_micmix" + node.description = "DiscordMicMix" + media.class = "Audio/Sink" + audio.position = [ FL FR ] + } + } + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "firebot_sounds" + node.description = "FirebotSounds" + media.class = "Audio/Sink" + audio.position = [ FL FR ] + } + } + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "soundboard_sounds" + node.description = "SoundboardSounds" + media.class = "Audio/Sink" + audio.position = [ FL FR ] + } + } + { + factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "game_audio" + node.description = "GameAudio" + media.class = "Audio/Sink" + audio.position = [ FL FR ] + } + } +] +EOF + + echo "PipeWire-Config geschrieben: $CONFIG_FILE" + + show_header "Starte PipeWire neu" + systemctl --user restart pipewire pipewire-pulse wireplumber + + show_header "Virtuelle Sinks" + pactl list short sinks | grep -E 'discord|firebot|soundboard|game' || true + + show_header "Virtuelle Sources/Monitore" + pactl list short sources | grep -E 'discord|firebot|soundboard|game' || true + + echo + echo "Install fertig. Discord, OBS, Firebot und Soundboard danach bitte neu starten." +} + +find_output_port() { + local node_re="$1" + local port_re="$2" + + pw-link -o | grep -Ei "$node_re" | grep -Ei "$port_re" | head -n1 || true +} + +find_input_port() { + local node_re="$1" + local port_re="$2" + + pw-link -i | grep -Ei "$node_re" | grep -Ei "$port_re" | head -n1 || true +} + +safe_link() { + local out="$1" + local in="$2" + + if [[ -z "$out" || -z "$in" ]]; then + return 0 + fi + + pw-link "$out" "$in" 2>/dev/null || true +} + +safe_unlink() { + local out="$1" + local in="$2" + + if [[ -z "$out" || -z "$in" ]]; then + return 0 + fi + + pw-link -d "$out" "$in" 2>/dev/null || true +} + +link_lr() { + local from_node="$1" + local from_kind="$2" + local to_node="$3" + local to_kind="$4" + + local out_l out_r in_l in_r + + out_l="$(find_output_port "$from_node" "${from_kind}.*(FL|front-left|L$|left)")" + out_r="$(find_output_port "$from_node" "${from_kind}.*(FR|front-right|R$|right)")" + + in_l="$(find_input_port "$to_node" "${to_kind}.*(FL|front-left|L$|left)")" + in_r="$(find_input_port "$to_node" "${to_kind}.*(FR|front-right|R$|right)")" + + if [[ -z "$out_l" && -z "$out_r" ]]; then + echo "Warnung: Keine Outputs gefunden für: $from_node / $from_kind" + return 0 + fi + + if [[ -z "$in_l" && -z "$in_r" ]]; then + echo "Warnung: Keine Inputs gefunden für: $to_node / $to_kind" + return 0 + fi + + safe_link "$out_l" "$in_l" + safe_link "$out_r" "$in_r" + + echo "Link: $from_node/$from_kind → $to_node/$to_kind" +} + +unlink_lr() { + local from_node="$1" + local from_kind="$2" + local to_node="$3" + local to_kind="$4" + + local out_l out_r in_l in_r + + out_l="$(find_output_port "$from_node" "${from_kind}.*(FL|front-left|L$|left)")" + out_r="$(find_output_port "$from_node" "${from_kind}.*(FR|front-right|R$|right)")" + + in_l="$(find_input_port "$to_node" "${to_kind}.*(FL|front-left|L$|left)")" + in_r="$(find_input_port "$to_node" "${to_kind}.*(FR|front-right|R$|right)")" + + safe_unlink "$out_l" "$in_l" + safe_unlink "$out_r" "$in_r" +} + +move_sink_inputs_matching() { + local match_re="$1" + local target_sink="$2" + + local ids + ids="$( + pactl list sink-inputs | awk -v RS='' -v re="$match_re" ' + $0 ~ re { + if (match($0, /#[0-9]+/)) { + s=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", s) + print s + } + } + ' + )" + + if [[ -z "$ids" ]]; then + return 0 + fi + + while read -r id; do + [[ -n "$id" ]] || continue + echo "Verschiebe Sink-Input #$id → $target_sink" + pactl move-sink-input "$id" "$target_sink" 2>/dev/null || true + done <<< "$ids" +} + +move_source_outputs_matching() { + local match_re="$1" + local target_source="$2" + + local ids + ids="$( + pactl list source-outputs | awk -v RS='' -v re="$match_re" ' + $0 ~ re { + if (match($0, /#[0-9]+/)) { + s=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", s) + print s + } + } + ' + )" + + if [[ -z "$ids" ]]; then + return 0 + fi + + while read -r id; do + [[ -n "$id" ]] || continue + echo "Verschiebe Source-Output #$id → $target_source" + pactl move-source-output "$id" "$target_source" 2>/dev/null || true + done <<< "$ids" +} + +check_required_sinks() { + pactl list short sinks | grep -q "$DISCORD_SINK" || die "$DISCORD_SINK nicht gefunden. Erst ausführen: $0 install" + pactl list short sinks | grep -q "$DISCORD_MICMIX" || die "$DISCORD_MICMIX nicht gefunden. Erst ausführen: $0 install" + pactl list short sinks | grep -q "$FIREBOT_SINK" || die "$FIREBOT_SINK nicht gefunden. Erst ausführen: $0 install" + pactl list short sinks | grep -q "$SOUNDBOARD_SINK" || die "$SOUNDBOARD_SINK nicht gefunden. Erst ausführen: $0 install" + pactl list short sinks | grep -q "$GAME_SINK" || die "$GAME_SINK nicht gefunden. Erst ausführen: $0 install" +} + +create_discord_microphone() { + local module_id + + if pactl list short sources | awk '{print $2}' | grep -Fxq "$DISCORD_MIC_SOURCE"; then + echo "Virtuelles Mikrofon bereits vorhanden: $DISCORD_MIC_DESCRIPTION ($DISCORD_MIC_SOURCE)" + return 0 + fi + + cleanup_discord_microphone_modules + + module_id="$( + pactl load-module module-remap-source \ + master="${DISCORD_MICMIX}.monitor" \ + source_name="$DISCORD_MIC_SOURCE" \ + source_properties="device.description=$DISCORD_MIC_DESCRIPTION" + )" || die "Virtuelles Discord-Mikrofon konnte nicht erstellt werden." + + pactl list short sources | awk '{print $2}' | grep -Fxq "$DISCORD_MIC_SOURCE" || { + pactl unload-module "$module_id" 2>/dev/null || true + die "$DISCORD_MIC_SOURCE wurde nach dem Laden nicht gefunden." + } + + echo "Virtuelles Mikrofon bereit: $DISCORD_MIC_DESCRIPTION ($DISCORD_MIC_SOURCE)" +} + +set_discord_microphone_as_default_source() { + pactl set-default-source "$DISCORD_MIC_SOURCE" + echo "System-Standardmikrofon ist jetzt: $DISCORD_MIC_SOURCE" + echo "Programme mit fest ausgewähltem Mikrofon bleiben davon unberührt." +} + +find_real_mic_source() { + pactl list sources | awk -v RS='' -v re="$MIC_RE" ' + BEGIN { IGNORECASE=1 } + $0 ~ re { + if (match($0, /Name: [^\n]+/)) { + name=substr($0, RSTART + 6, RLENGTH - 6) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) + print name + exit + } + } + ' +} + +restore_real_mic_as_default() { + need_cmd pactl + + local mic_source + mic_source="$(find_real_mic_source)" + [[ -n "$mic_source" ]] || die "Echtes ZOOM/H6-Mikrofon nicht gefunden." + + pactl set-default-source "$mic_source" + echo "System-Standardmikrofon ist wieder: $mic_source" + echo "Discord danach vollständig neu starten oder dort ein Gerät fest auswählen." +} + +sink_index_by_name() { + local sink_name="$1" + pactl list short sinks | awk -v name="$sink_name" '$2 == name { print $1; exit }' +} + +source_index_by_name() { + local source_name="$1" + pactl list short sources | awk -v name="$source_name" '$2 == name { print $1; exit }' +} + +discord_capture_ids() { + LC_ALL=C pactl list source-outputs | awk -v RS='' -v re="$DISCORD_APP_RE" ' + $0 ~ re { + if (match($0, /Source Output #[0-9]+/)) { + id=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", id) + print id + } + } + ' +} + +route_and_verify_discord_capture() { + local target_index ids id actual_index + target_index="$(source_index_by_name "$DISCORD_MIC_SOURCE")" + [[ -n "$target_index" ]] || die "Source-Index für $DISCORD_MIC_SOURCE nicht gefunden." + + ids="$(discord_capture_ids)" + if [[ -z "$ids" ]]; then + echo "Hinweis: Kein aktiver Discord-Mikrofonstream gefunden." + echo "Discord verwendet beim nächsten Verbindungsaufbau das Standardmikrofon $DISCORD_MIC_SOURCE." + return 0 + fi + + while read -r id; do + [[ -n "$id" ]] || continue + pactl move-source-output "$id" "$DISCORD_MIC_SOURCE" || \ + die "Discord-Aufnahmestrom #$id konnte nicht verschoben werden." + + actual_index="$(pactl list short source-outputs | awk -v id="$id" '$1 == id { print $2; exit }')" + [[ "$actual_index" == "$target_index" ]] || \ + die "Discord-Aufnahmestrom #$id nutzt Source $actual_index statt $target_index ($DISCORD_MIC_SOURCE)." + + echo "Bestätigt: Discord-Aufnahmestrom #$id -> $DISCORD_MIC_SOURCE (Source $target_index)" + done <<< "$ids" +} + +find_obs_mic_monitor_input() { + LC_ALL=C pactl list sink-inputs | awk -v RS='' ' + $0 ~ /application.name = "OBS"/ && + $0 ~ /media.name = "(Mikrofon|Microphone)"/ { + if (match($0, /Sink Input #[0-9]+/)) { + id=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", id) + print id + exit + } + } + ' +} + +set_obs_mic_discord_volume() { + local sink_index mic_input + sink_index="$(sink_index_by_name "$DISCORD_MICMIX")" + [[ -n "$sink_index" ]] || die "Sink-Index für $DISCORD_MICMIX nicht gefunden." + + mic_input="$(find_obs_mic_monitor_input)" + if [[ -z "$mic_input" ]]; then + echo "Warnung: OBS-Monitoringstream 'Mikrofon' nicht gefunden." + echo "In OBS beim Mikrofon 'Monitor und Ausgabe' aktivieren und $0 route erneut ausführen." + return 0 + fi + + # OBS kann einen bereits laufenden Monitoringstream noch auf dem früheren + # Gerät halten. Deshalb wird er hier ausdrücklich zum MicMix verschoben. + pactl move-sink-input "$mic_input" "$DISCORD_MICMIX" + pactl set-sink-input-volume "$mic_input" "$DISCORD_MIC_VOLUME" + echo "OBS-Mikrofon für Discord: Sink-Input #$mic_input -> $DISCORD_MICMIX mit $DISCORD_MIC_VOLUME" +} + +create_soundboard_discord_loopback() { + local module_id sink_input_id + + cleanup_soundboard_discord_loopbacks + + module_id="$( + pactl load-module module-loopback \ + source="${SOUNDBOARD_SINK}.monitor" \ + sink="$DISCORD_MICMIX" \ + latency_msec="$DISCORD_SOUNDBOARD_LATENCY_MS" \ + source_dont_move=true \ + sink_dont_move=true \ + remix=false + )" || die "Soundboard-Discord-Loopback konnte nicht erstellt werden." + + sink_input_id="$( + LC_ALL=C pactl list sink-inputs | awk -v RS='' -v module_id="$module_id" ' + ($0 ~ ("Owner Module: " module_id "([[:space:]]|$)")) || + ($0 ~ ("Module: " module_id "([[:space:]]|$)")) { + if (match($0, /Sink Input #[0-9]+/)) { + id=substr($0, RSTART, RLENGTH) + gsub(/[^0-9]/, "", id) + print id + exit + } + } + ' + )" + + if [[ -z "$sink_input_id" ]]; then + pactl unload-module "$module_id" 2>/dev/null || true + die "Lautstärkeregler des Soundboard-Discord-Loopbacks nicht gefunden." + fi + + pactl set-sink-input-volume "$sink_input_id" "$DISCORD_SOUNDBOARD_VOLUME" + echo "Soundboard nur für Discord: Sink-Input #$sink_input_id -> $DISCORD_SOUNDBOARD_VOLUME" +} + +remove_forbidden_links() { + show_header "Entferne verbotene alte Links" + + # Firebot darf NICHT in Discord. + unlink_lr "$FIREBOT_SINK" 'monitor' "$DISCORD_MICMIX" 'playback' + + # GameAudio darf NICHT in Discord. + unlink_lr "$GAME_SINK" 'monitor' "$DISCORD_MICMIX" 'playback' + + # Discord-Ausgabe darf NICHT zurück in Discord. + unlink_lr "$DISCORD_SINK" 'monitor' "$DISCORD_MICMIX" 'playback' + + # Keine direkte Soundboard-Verbindung: Discord bekommt eine separat + # regelbare Kopie über module-loopback. + unlink_lr "$SOUNDBOARD_SINK" 'monitor' "$DISCORD_MICMIX" 'playback' + + # Das echte Mikrofon darf NICHT direkt in DiscordMicMix landen. + # Die gefilterte Stimme kommt stattdessen über das OBS-Audiomonitoring. + unlink_lr "$MIC_RE" 'capture' "$DISCORD_MICMIX" 'playback' + + # Der fertige MicMix soll nicht auf den Line-Out zurück, sonst hörst du dich selbst. + unlink_lr "$DISCORD_MICMIX" 'monitor' "$LINE_OUT_RE" 'playback' + + echo "Verbotene Links entfernt, falls vorhanden." +} + +route_audio() { + need_cmd pactl + need_cmd pw-link + + show_header "Prüfe virtuelle Sinks" + check_required_sinks + + show_header "Setze GameAudio als Standardausgabe" + pactl set-default-sink "$GAME_SINK" 2>/dev/null || true + echo "Standardausgabe ist jetzt: $GAME_SINK" + echo "Wichtig: Spiele nach diesem Schritt starten, damit sie automatisch auf GameAudio landen." + + show_header "Erzeuge Discord-Mikrofon" + create_discord_microphone + set_discord_microphone_as_default_source + + remove_forbidden_links + + show_header "Aktive App-Streams verschieben" + + # Discord-Ausgabe der anderen Leute -> Discord + move_sink_inputs_matching "$DISCORD_APP_RE" "$DISCORD_SINK" + + # Discord-Mikrofoneingang -> echtes virtuelles Discord-Mikrofon. + # Die Funktion prüft anschließend, ob die Verschiebung wirklich gehalten hat. + route_and_verify_discord_capture + + # Repariert eine OBS-Quelle, die von einer älteren Skriptversion wegen ihres + # Namens "Discord-Sound" fälschlich auf den MicMix verschoben worden sein kann. + move_source_outputs_matching 'media.name = "Discord-Sound"' "${DISCORD_SINK}.monitor" + + # Firebot läuft bei dir über Chromium. + # Das betrifft aktive Chromium-Audiostreams. Also während/einmal nach einem Firebot-Sound ausführen. + # Absichtlich KEIN allgemeines "media.name = Playback": Das würde auch fremde Apps erwischen. + move_sink_inputs_matching 'application.name = "Chromium"|node.name = "Chromium"' "$FIREBOT_SINK" + + # Soundboard/Stream-Deck-Sounds liefen bei dir bisher als python3 [Audio Stream]. + # Das betrifft aktive Python-Audiostreams. Wenn dein Soundboard anders heißt, später im status prüfen. + move_sink_inputs_matching 'application.name = "python3"|node.name = "python3"|python3|Audio Stream' "$SOUNDBOARD_SINK" + + # Firefox soll gemeinsam mit den Browser-/Firebot-Sounds laufen. + move_sink_inputs_matching 'application.name = "Firefox"|node.name = "Firefox"|application.process.binary = "firefox"|Firefox|firefox' "$FIREBOT_SINK" + + # GeForce NOW ist technisch Chromium-basiert, soll aber als Spiel auf GameAudio landen. + # Diese Regel steht bewusst zuletzt und hat damit Vorrang vor der Chromium-Regel. + move_sink_inputs_matching 'application.name = "GeForceNOW"|node.name = "GeForceNOW"|application.process.binary = "GeForceNOW"|GeForceNOW|GeForce NOW' "$GAME_SINK" + + show_header "PipeWire-Links setzen" + + # Soundboard -> Line-Out/Kopfhörer + link_lr "$SOUNDBOARD_SINK" 'monitor' "$LINE_OUT_RE" 'playback' + + # Firebot -> Line-Out/Kopfhörer + link_lr "$FIREBOT_SINK" 'monitor' "$LINE_OUT_RE" 'playback' + + # Discord-Ausgabe -> Line-Out/Kopfhörer + link_lr "$DISCORD_SINK" 'monitor' "$LINE_OUT_RE" 'playback' + + # GameAudio -> Line-Out/Kopfhörer + link_lr "$GAME_SINK" 'monitor' "$LINE_OUT_RE" 'playback' + + show_header "Discord-Pegel setzen" + create_soundboard_discord_loopback + set_obs_mic_discord_volume + + show_header "Fertig" + echo "Wenn Chromium/Discord/Soundboard gerade keinen aktiven Stream hatten:" + echo "1. Discord-Sprachkanal betreten" + echo "2. Firebot-Sound abspielen" + echo "3. Soundboard-Sound abspielen" + echo "4. Falls benötigt: Firefox- und GeForce-NOW-Ton starten" + echo "5. erneut ausführen: $0 route" + echo + echo "OBS-Konfiguration für Discord:" + echo "- Einstellungen -> Audio -> Erweitert -> Audiomonitoring-Gerät: DiscordMicMix" + echo "- Erweiterte Audioeigenschaften -> Mikrofon -> Audioüberwachung: Monitor und Ausgabe" + echo "- Soundboard und alle anderen OBS-Quellen beim Monitoring ausgeschaltet lassen." + echo "- Discord-Pegel: Mikrofon $DISCORD_MIC_VOLUME, Soundboard $DISCORD_SOUNDBOARD_VOLUME" + echo "- In Discord als Eingabegerät: $DISCORD_MIC_DESCRIPTION" + echo + echo "Rückweg zum echten Mikrofon: $0 restore-mic" +} + +status_audio() { + show_header "Standardgeräte" + echo "Ausgabe: $(pactl get-default-sink 2>/dev/null || true)" + echo "Mikrofon: $(pactl get-default-source 2>/dev/null || true)" + + show_header "Sinks" + pactl list short sinks | grep -E 'discord|firebot|soundboard|game|alsa_output|ZOOM|Logitech|Starship|Matisse' || true + + show_header "Sources" + pactl list short sources | grep -E 'discord|firebot|soundboard|game|alsa_input|ZOOM|Logitech|Handy|monitor' || true + + show_header "Module dieses Setups" + pactl list short modules | grep -E 'null-sink|discord|firebot|soundboard|game|DiscordMicMix|FirebotSounds|SoundboardSounds|GameAudio' || true + + show_header "Sink Inputs / Wiedergabe" + pactl list sink-inputs | grep -E 'Sink Input|#[0-9]+|application.name|application.process.binary|media.name|node.name|Sink:|Chromium|Firefox|firefox|GeForceNOW|GeForce NOW|Discord|WEBRTC|OBS|python3|Audio Stream|Steam|gamescope|wine|proton' || true + + show_header "Source Outputs / Aufnahme" + pactl list source-outputs | grep -E 'Source Output|#[0-9]+|application.name|media.name|node.name|Source:|Discord|WEBRTC|OBS' || true + + show_header "pw-link relevante Ports" + pw-link -io | grep -Ei 'discord|firebot|soundboard|game|chromium|firefox|geforcenow|geforce now|python3|handy|zoom|h6|starship|matisse' || true +} + +case "${1:-}" in + install) + install_sinks + ;; + cleanup) + cleanup_old_modules + cleanup_soundboard_discord_loopbacks + cleanup_discord_microphone_modules + cleanup_old_configs + ;; + route) + route_audio + ;; + status) + status_audio + ;; + restore-mic) + restore_real_mic_as_default + ;; + *) + echo "Nutzung:" + echo " $0 install # alte Instanzen/Configs bereinigen + virtuelle Geräte dauerhaft anlegen" + echo " $0 cleanup # alte manuelle Instanzen/Configs dieses Setups entfernen" + echo " $0 route # Discord/Firebot/Soundboard/Mikro/GameAudio routen" + echo " $0 status # Diagnose anzeigen" + echo " $0 restore-mic # ZOOM/H6 wieder als System-Standardmikrofon setzen" + exit 1 + ;; +esac