Last active
August 9, 2025 22:16
-
-
Save codrcodz/51abeac01dbc4b0e74ab87241fe48bf2 to your computer and use it in GitHub Desktop.
Audio Combiner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [Desktop Entry] | |
| Type=Application | |
| Name=Refresh Combined Audio | |
| Comment=Rebuild Combined Output + Combined Input from current devices | |
| Exec=/bin/bash -lc '~/.local/bin/audio-combine.sh' | |
| Icon=audio-card | |
| Terminal=false | |
| Categories=AudioVideo; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [Unit] | |
| Description=Auto-detect and build Combined Output/Input | |
| After=pipewire-pulse.service bluetooth.target | |
| Wants=bluetooth.target | |
| [Service] | |
| Type=oneshot | |
| ExecStart=%h/.local/bin/audio-combine.sh | |
| RemainAfterExit=yes | |
| Restart=on-failure | |
| RestartSec=10 | |
| [Install] | |
| WantedBy=default.target |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| ac.log() { | |
| echo "[audio] $*" >&2 | |
| } | |
| ac.unloadPrevious() { | |
| # Aggressively remove any modules we own; loop until none remain | |
| local did_any="" | |
| while :; do | |
| did_any="" | |
| while IFS=$'\t' read -r id mod args; do | |
| case "$mod" in | |
| module-combine-sink) | |
| # Our sink by internal name or by description label | |
| if [[ "$args" == *"sink_name=combined_output"* || "$args" == *"device.description=Combined Output"* || "$args" == *"device.description=Combined Output (Speakers)"* ]]; then | |
| pactl unload-module "$id" && did_any="yes" | |
| fi | |
| ;; | |
| module-null-sink) | |
| # Our input bus null-sink | |
| if [[ "$args" == *"sink_name=combined_input"* || "$args" == *"device.description=Combined Input"* || "$args" == *"device.description=Combined Input (Bus)"* ]]; then | |
| pactl unload-module "$id" && did_any="yes" | |
| fi | |
| ;; | |
| module-loopback) | |
| # Any loopback feeding our input bus | |
| if [[ "$args" == *"sink=combined_input"* ]]; then | |
| pactl unload-module "$id" && did_any="yes" | |
| fi | |
| ;; | |
| module-virtual-source|module-remap-source) | |
| # Our virtual mic | |
| if [[ "$args" == *"source_name=combined_input_mic"* || "$args" == *"device.description=Combined Input (Virtual Mic)"* ]]; then | |
| pactl unload-module "$id" && did_any="yes" | |
| fi | |
| ;; | |
| esac | |
| done < <(pactl list modules short || true) | |
| [[ -z "$did_any" ]] && break | |
| done | |
| } | |
| ac.getSinkNames() { | |
| pactl list short sinks | awk '{print $2}' | |
| } | |
| ac.getSourceNames() { | |
| pactl list short sources | awk '{print $2}' | |
| } | |
| ac.createCombinedOutput() { | |
| local sinks=("$@") | |
| if [[ ${#sinks[@]} -lt 2 ]]; then | |
| ac.log "Need at least two sinks to combine, found: ${sinks[*]}" | |
| return 1 | |
| fi | |
| ac.log "Creating Combined Output from: ${sinks[*]}" | |
| pactl load-module module-combine-sink sink_name=combined_output sink_properties=device.description="Combined Output" slaves="$(IFS=,; echo "${sinks[*]}")" >/dev/null | |
| } | |
| ac.createCombinedInput() { | |
| local sources=("$@") | |
| if [[ ${#sources[@]} -lt 2 ]]; then | |
| ac.log "Need at least two sources to combine, found: ${sources[*]}" | |
| return 1 | |
| fi | |
| ac.log "Creating/refreshing Combined Input bus (sources detected: ${#sources[@]})" | |
| pactl load-module module-null-sink sink_name=combined_input sink_properties=device.description="Combined Input" >/dev/null | |
| for src in "${sources[@]}"; do | |
| pactl load-module module-loopback source="$src" sink=combined_input latency_msec=10 >/dev/null | |
| done | |
| pactl load-module module-remap-source master=combined_input.monitor source_name=combined_input_mic source_properties=device.description="Combined Input (Virtual Mic)" >/dev/null | |
| } | |
| main() { | |
| ac.unloadPrevious | |
| mapfile -t sinks < <(ac.getSinkNames | grep -v combined_ || true) | |
| mapfile -t sources < <(ac.getSourceNames | grep -v combined_ || true) | |
| if [[ ${#sinks[@]} -ge 2 ]]; then | |
| ac.createCombinedOutput "${sinks[@]}" | |
| else | |
| ac.log "Skipping combined output creation (not enough sinks)" | |
| fi | |
| if [[ ${#sources[@]} -ge 2 ]]; then | |
| ac.createCombinedInput "${sources[@]}" | |
| else | |
| ac.log "Skipping combined input creation (not enough sources)" | |
| fi | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment