| 1 | #!/bin/sh |
| 2 | # Requires: rofi, wireplumber (wpctl) |
| 3 | # Audio menu: volume, mute, and which sink the sound comes out of. This is |
| 4 | # the rofi replacement for the gum-based audio-menu/audio-output pair, so it |
| 5 | # no longer needs a terminal window to run in. |
| 6 | set -u |
| 7 | |
| 8 | STEP=${ROFI_AUDIO_STEP:-5} |
| 9 | NOTIFY_ID=7303 |
| 10 | |
| 11 | notify() { |
| 12 | if command -v dunstify >/dev/null 2>&1; then |
| 13 | dunstify -r "$NOTIFY_ID" "Audio" "$1" |
| 14 | else |
| 15 | notify-send "Audio" "$1" |
| 16 | fi |
| 17 | } |
| 18 | |
| 19 | # wpctl has no machine-readable listing, so carve the Sinks block out of |
| 20 | # `status`. Output: "<*|space>\t<id>\t<name>". |
| 21 | sinks() { |
| 22 | wpctl status 2>/dev/null | awk ' |
| 23 | /^Audio/ { audio = 1; next } |
| 24 | /^[A-Z]/ { audio = 0 } |
| 25 | audio && /Sinks:/ && !/endpoints:/ { insec = 1; next } |
| 26 | audio && /Devices:|Sources:|Streams:|endpoints:|Filters:/ { insec = 0; next } |
| 27 | insec { |
| 28 | line = $0 |
| 29 | prefix = line; sub(/[0-9].*$/, "", prefix) |
| 30 | def = (prefix ~ /\*/) ? "*" : " " |
| 31 | sub(/^[^0-9]*/, "", line) |
| 32 | if (line !~ /^[0-9]+\./) next |
| 33 | id = line; sub(/\..*$/, "", id) |
| 34 | name = line; sub(/^[0-9]+\.[[:space:]]*/, "", name) |
| 35 | sub(/[[:space:]]*\[vol:.*$/, "", name) |
| 36 | sub(/[[:space:]]+$/, "", name) |
| 37 | print def "\t" id "\t" name |
| 38 | } |
| 39 | ' |
| 40 | } |
| 41 | |
| 42 | # @DEFAULT_AUDIO_SINK@ always resolves to whatever is current, so the volume |
| 43 | # actions never need to know which sink that is. |
| 44 | volume() { wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null; } |
| 45 | |
| 46 | vol_percent() { volume | awk '{ printf "%d", $2 * 100 }'; } |
| 47 | |
| 48 | # The icon tracks the level the same way the status bar blocks do, so a |
| 49 | # notification reads the same whether it came from here or from a media key. |
| 50 | vol_icon() { |
| 51 | case "$(volume)" in |
| 52 | *MUTED*) printf '' ;; |
| 53 | *) v=$(vol_percent) |
| 54 | if [ "$v" -eq 0 ]; then printf '' |
| 55 | elif [ "$v" -lt 34 ]; then printf '' |
| 56 | elif [ "$v" -lt 67 ]; then printf '' |
| 57 | else printf '' |
| 58 | fi ;; |
| 59 | esac |
| 60 | } |
| 61 | |
| 62 | report() { notify "$(vol_icon) $(vol_percent)%"; } |
| 63 | |
| 64 | SINKS=$(sinks) |
| 65 | [ -n "$SINKS" ] || { notify "No sinks found — is pipewire running?"; exit 1; } |
| 66 | |
| 67 | CURRENT=$(printf '%s\n' "$SINKS" | awk -F'\t' '$1 == "*" { print $3; exit }') |
| 68 | MIC=$(wpctl get-volume @DEFAULT_AUDIO_SOURCE@ 2>/dev/null | grep -q MUTED && echo "muted" || echo "live") |
| 69 | |
| 70 | ACTIONS=$(printf ' volume up (+%s%%)\n volume down (-%s%%)\n toggle mute\n toggle mic (currently %s)\n automatic output\n' "$STEP" "$STEP" "$MIC") |
| 71 | |
| 72 | # Sinks are listed by name with their wpctl id hidden behind two spaces, the |
| 73 | # same split the actions use, so one case statement handles both. |
| 74 | SINKLIST=$(printf '%s\n' "$SINKS" | awk -F'\t' '{ |
| 75 | icon = ($1 == "*") ? "" : "" |
| 76 | printf "%s %s\t%s\n", icon, $3, $2 |
| 77 | }') |
| 78 | |
| 79 | CHOICE=$(printf '%s\n%s\n' "$ACTIONS" "$(printf '%s\n' "$SINKLIST" | cut -f1)" | |
| 80 | rofi -dmenu -i -no-custom -l 10 -p "audio" \ |
| 81 | -mesg "$(vol_icon) $(vol_percent)% — ${CURRENT:-no default sink}") || exit 0 |
| 82 | [ -n "$CHOICE" ] || exit 0 |
| 83 | |
| 84 | case "$CHOICE" in |
| 85 | *"volume up"*) |
| 86 | # The cap keeps a stray keystroke from pushing the sink into the |
| 87 | # clipping range above 100%. |
| 88 | wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ "$STEP%+" |
| 89 | report ;; |
| 90 | *"volume down"*) |
| 91 | wpctl set-volume @DEFAULT_AUDIO_SINK@ "$STEP%-" |
| 92 | report ;; |
| 93 | *"toggle mute"*) |
| 94 | wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle |
| 95 | report ;; |
| 96 | *"toggle mic"*) |
| 97 | wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle |
| 98 | wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED && |
| 99 | notify " microphone muted" || notify " microphone live" ;; |
| 100 | *"automatic output"*) |
| 101 | wpctl clear-default |
| 102 | notify " output: automatic (wireplumber picks)" ;; |
| 103 | *) |
| 104 | ID=$(printf '%s\n' "$SINKLIST" | awk -F'\t' -v c="$CHOICE" '$1 == c { print $2; exit }') |
| 105 | [ -n "$ID" ] || exit 0 |
| 106 | wpctl set-default "$ID" |
| 107 | notify " output: ${CHOICE##* }" ;; |
| 108 | esac |