#!/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