Created
March 19, 2026 10:48
-
-
Save jonbesga/20e85af2e5427f2ef736b0d98b380dbc to your computer and use it in GitHub Desktop.
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 | |
| STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/virtual-mic-toggle" | |
| STATE_FILE="$STATE_DIR/state.env" | |
| VIRTUAL_SINK_NAME="virtual_sink" | |
| VIRTUAL_SINK_DESC="VirtualSink" | |
| VIRTUAL_MIC_NAME="virtual_mic" | |
| VIRTUAL_MIC_DESC="VirtualMic" | |
| DUAL_SINK_NAME="dual_sink" | |
| DUAL_SINK_DESC="DualSink" | |
| mkdir -p "$STATE_DIR" | |
| require_sink_exists() { | |
| local name="$1" | |
| pactl list short sinks | awk '{print $2}' | grep -Fxq "$name" | |
| } | |
| require_source_exists() { | |
| local name="$1" | |
| pactl list short sources | awk '{print $2}' | grep -Fxq "$name" | |
| } | |
| get_sink_id_by_name() { | |
| local name="$1" | |
| pactl list short sinks | awk -v target="$name" '$2 == target { print $1; exit }' | |
| } | |
| status() { | |
| if [[ -f "$STATE_FILE" ]]; then | |
| echo "on" | |
| else | |
| echo "off" | |
| fi | |
| } | |
| enable() { | |
| if [[ -f "$STATE_FILE" ]]; then | |
| echo "Virtual mic is already ON" | |
| exit 0 | |
| fi | |
| local real_sink | |
| real_sink="$(pactl get-default-sink)" | |
| local sink_module_id | |
| sink_module_id="$( | |
| pactl load-module module-null-sink \ | |
| sink_name="$VIRTUAL_SINK_NAME" \ | |
| sink_properties="device.description=$VIRTUAL_SINK_DESC" | |
| )" | |
| require_sink_exists "$VIRTUAL_SINK_NAME" || { | |
| echo "Failed to create $VIRTUAL_SINK_NAME" | |
| exit 1 | |
| } | |
| local mic_module_id | |
| mic_module_id="$( | |
| pactl load-module module-remap-source \ | |
| master="${VIRTUAL_SINK_NAME}.monitor" \ | |
| source_name="$VIRTUAL_MIC_NAME" \ | |
| source_properties="device.description=$VIRTUAL_MIC_DESC" | |
| )" | |
| require_source_exists "$VIRTUAL_MIC_NAME" || { | |
| echo "Failed to create $VIRTUAL_MIC_NAME" | |
| pactl unload-module "$sink_module_id" 2>/dev/null || true | |
| exit 1 | |
| } | |
| local dual_module_id | |
| dual_module_id="$( | |
| pactl load-module module-combine-sink \ | |
| sink_name="$DUAL_SINK_NAME" \ | |
| sink_properties="device.description=$DUAL_SINK_DESC" \ | |
| slaves="$real_sink,$VIRTUAL_SINK_NAME" | |
| )" | |
| require_sink_exists "$DUAL_SINK_NAME" || { | |
| echo "Failed to create $DUAL_SINK_NAME" | |
| pactl unload-module "$dual_module_id" 2>/dev/null || true | |
| pactl unload-module "$mic_module_id" 2>/dev/null || true | |
| pactl unload-module "$sink_module_id" 2>/dev/null || true | |
| exit 1 | |
| } | |
| cat >"$STATE_FILE" <<EOF | |
| REAL_SINK=$real_sink | |
| SINK_MODULE_ID=$sink_module_id | |
| MIC_MODULE_ID=$mic_module_id | |
| DUAL_MODULE_ID=$dual_module_id | |
| EOF | |
| echo "Virtual mic enabled" | |
| echo "Move app streams to '$DUAL_SINK_NAME'" | |
| echo "Select '$VIRTUAL_MIC_NAME' or '$VIRTUAL_MIC_DESC' as microphone in Meet/Jitsi" | |
| } | |
| disable() { | |
| if [[ ! -f "$STATE_FILE" ]]; then | |
| echo "Virtual mic is already OFF" | |
| exit 0 | |
| fi | |
| # shellcheck disable=SC1090 | |
| source "$STATE_FILE" | |
| pactl unload-module "${DUAL_MODULE_ID:-}" 2>/dev/null || true | |
| pactl unload-module "${MIC_MODULE_ID:-}" 2>/dev/null || true | |
| pactl unload-module "${SINK_MODULE_ID:-}" 2>/dev/null || true | |
| rm -f "$STATE_FILE" | |
| echo "Virtual mic disabled" | |
| } | |
| toggle() { | |
| if [[ -f "$STATE_FILE" ]]; then | |
| disable | |
| else | |
| enable | |
| fi | |
| } | |
| list_apps() { | |
| declare -A sink_names | |
| while IFS=$'\t' read -r id name _; do | |
| sink_names["$id"]="$name" | |
| done < <(pactl list short sinks) | |
| printf "%-6s %-25s %-25s %s\n" "ID" "APP" "MEDIA" "SINK" | |
| while IFS='|' read -r id app media sink_id; do | |
| [[ -z "$id" ]] && continue | |
| [[ "$media" == "dual_sink output" ]] && continue | |
| [[ "$media" == "VirtualSink" ]] && continue | |
| printf "%-6s %-25s %-25s %s\n" \ | |
| "$id" "$app" "$media" "${sink_names[$sink_id]:-$sink_id}" | |
| done < <( | |
| pactl list sink-inputs | awk ' | |
| /^Sink Input #[0-9]+/ { | |
| id=$3 | |
| sub("#","",id) | |
| app_name="-" | |
| media_name="-" | |
| sink="-" | |
| } | |
| /application.name = / { | |
| app_name=$0 | |
| sub(/.*application.name = "/, "", app_name) | |
| sub(/"$/, "", app_name) | |
| } | |
| /media.name = / { | |
| media_name=$0 | |
| sub(/.*media.name = "/, "", media_name) | |
| sub(/"$/, "", media_name) | |
| } | |
| /Sink: / { | |
| sink=$2 | |
| } | |
| /^$/ { | |
| if (id != "") { | |
| print id "|" app_name "|" media_name "|" sink | |
| id="" | |
| } | |
| } | |
| END { | |
| if (id != "") { | |
| print id "|" app_name "|" media_name "|" sink | |
| } | |
| } | |
| ' | |
| ) | |
| } | |
| move_app() { | |
| [[ -f "$STATE_FILE" ]] || { | |
| echo "Virtual mic is OFF" | |
| exit 1 | |
| } | |
| require_sink_exists "$DUAL_SINK_NAME" || { | |
| echo "$DUAL_SINK_NAME does not exist" | |
| exit 1 | |
| } | |
| if [[ $# -ne 1 ]]; then | |
| echo "Usage: $0 move-app <sink-input-id>" | |
| exit 1 | |
| fi | |
| pactl move-sink-input "$1" "$DUAL_SINK_NAME" | |
| echo "Moved sink-input $1 to $DUAL_SINK_NAME" | |
| } | |
| move_name() { | |
| [[ -f "$STATE_FILE" ]] || { | |
| echo "Virtual mic is OFF" | |
| exit 1 | |
| } | |
| require_sink_exists "$DUAL_SINK_NAME" || { | |
| echo "$DUAL_SINK_NAME does not exist" | |
| exit 1 | |
| } | |
| if [[ $# -lt 1 ]]; then | |
| echo "Usage: $0 move-name <text>" | |
| exit 1 | |
| fi | |
| local query="$*" | |
| local ids | |
| ids="$( | |
| pactl list sink-inputs | awk -v q="$query" ' | |
| BEGIN { IGNORECASE=1 } | |
| /^Sink Input #[0-9]+/ { | |
| id=$3 | |
| sub("#","",id) | |
| app_name="" | |
| media_name="" | |
| } | |
| /application.name = / { | |
| app_name=$0 | |
| sub(/.*application.name = "/, "", app_name) | |
| sub(/"$/, "", app_name) | |
| } | |
| /media.name = / { | |
| media_name=$0 | |
| sub(/.*media.name = "/, "", media_name) | |
| sub(/"$/, "", media_name) | |
| } | |
| /^$/ { | |
| text = app_name " " media_name | |
| if (id != "" && text ~ q && media_name != "dual_sink output") { | |
| print id | |
| } | |
| id="" | |
| app_name="" | |
| media_name="" | |
| } | |
| END { | |
| text = app_name " " media_name | |
| if (id != "" && text ~ q && media_name != "dual_sink output") { | |
| print id | |
| } | |
| } | |
| ' | |
| )" | |
| [[ -n "$ids" ]] || { | |
| echo "No matching app found" | |
| exit 1 | |
| } | |
| while read -r id; do | |
| [[ -n "$id" ]] || continue | |
| pactl move-sink-input "$id" "$DUAL_SINK_NAME" | |
| echo "Moved sink-input $id to $DUAL_SINK_NAME" | |
| done <<<"$ids" | |
| } | |
| restore_app() { | |
| [[ -f "$STATE_FILE" ]] || { | |
| echo "Virtual mic is OFF" | |
| exit 1 | |
| } | |
| # shellcheck disable=SC1090 | |
| source "$STATE_FILE" | |
| if [[ $# -ne 1 ]]; then | |
| echo "Usage: $0 restore-app <sink-input-id>" | |
| exit 1 | |
| fi | |
| pactl move-sink-input "$1" "$REAL_SINK" | |
| echo "Moved sink-input $1 back to $REAL_SINK" | |
| } | |
| show_sinks() { | |
| pactl list short sinks | |
| } | |
| show_sources() { | |
| pactl list short sources | |
| } | |
| case "${1:-toggle}" in | |
| on | enable) | |
| enable | |
| ;; | |
| off | disable) | |
| disable | |
| ;; | |
| toggle) | |
| toggle | |
| ;; | |
| status) | |
| status | |
| ;; | |
| list-apps) | |
| list_apps | |
| ;; | |
| move-app) | |
| shift | |
| move_app "$@" | |
| ;; | |
| move-name) | |
| shift | |
| move_name "$@" | |
| ;; | |
| restore-app) | |
| shift | |
| restore_app "$@" | |
| ;; | |
| sinks) | |
| show_sinks | |
| ;; | |
| sources) | |
| show_sources | |
| ;; | |
| *) | |
| echo "Usage: $0 [on|off|toggle|status|list-apps|move-app <id>|move-name <text>|restore-app <id>|sinks|sources]" | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment