#!/bin/sh # # DESCRIPTION: # dmenu-based audio helper # # TODO # - auto-select the currently active profile set -e [ -n "$DEBUG" ] && set -x # check requirements for tool in jq swq pactl perl pw-dump; do if ! command -v "$tool" >/dev/null; then printf "%s: %s not installed but required\n" "$(basename "$0")" "$tool" >&2 exit 1 fi done DEFAULT_PROFILE="output:analog-stereo+input:analog-stereo" # DEFAULT_PROFILE="pro-audio" HDMI_PROFILE="output:hdmi-stereo" _menu() { [ -n "${mode}" ] && prompt="soundswitch (${mode})" || prompt="soundswitch" export MENU_PROMPT=${MENU_PROMPT:-${prompt}} menu -l10 --ifne --accept-single } mode= output= input= mute= volume_down= volume_up= usage() { printf "usage: %s [-hIOgfpHRmMvV]\n" "$(basename "$0")" printf "\n" printf " -O \t(manipulate pulseaudio sinks)\n" printf " -I \t(manipulate pulseaudio sources)\n" printf " -g \t(set default sink and switch global output)\n" printf " -f \t(switch focused container output)\n" printf " -p \t(switch card profile)\n" printf " -H \t(switch card to HDMI profile)\n" printf " -R \t(switch card to default profile)\n" printf "\n" printf " -m \t(mute default sink)\n" printf " -M \t(mute default source)\n" printf " -v \t(reduce sink volume by 5%%)\n" printf " -V \t(increase sink volume by 5%%)\n" printf "\n" printf "Needs swq(1) to work.\n" exit 2 } _args="$*" while getopts hIOgfpHRmMvVBj name; do case ${name} in j) mode=json ;; g) mode=global ;; f) mode=focused ;; p) mode=profile output=1 input=1 ;; H) mode=hdmi output=1 input=1 ;; R) mode=reset ;; B) mode=mute ;; O) output=1 ;; I) input=1 ;; # quick actions, to make move to pipewire easier m) mode=none output=1 mute=1 ;; M) mode=none input=1 mute=1 ;; v) mode=none output=1 volume_down=1 ;; V) mode=none output=1 volume_up=1 ;; h | ?) echo "$*" usage ;; esac done shift $((OPTIND - 1)) # ask mode _mode_global="󰣨 global" _mode_focused="󰻿 focused" _mode_profile_card="󱂇 card profile" _mode_profile_hdmi="󰡁 hdmi profile" _mode_profile_reset="󰕍 reset profile" _mode_mute="󰂛 mute" if [ -z "$mode" ]; then printf "$_mode_global\n$_mode_focused\n$_mode_profile_card\n$_mode_profile_hdmi\n$_mode_profile_reset\n$_mode_mute\n" | _menu | { read -r line case ${line} in "$_mode_global") exec "$0" -g "$*" ;; "$_mode_focused") exec "$0" -f "$*" ;; "$_mode_profile_card") exec "$0" -p "$*" ;; "$_mode_profile_hdmi") exec "$0" -H "$*" ;; "$_mode_profile_reset") exec "$0" -R "$*" ;; "$_mode_mute") exec "$0" -B "$*" ;; *) usage ;; esac } exit $? fi _mode_output="󰴸 output" _mode_input="󰍬 input" if [ -n "$mode" ]; then # ask target? if [ -z "$output" ] && [ -z "$input" ]; then printf "$_mode_output\n$_mode_input\n" | _menu | { read -r line case ${line} in "$_mode_output") exec "$0" -O $_args ;; "$_mode_input") exec "$0" -I $_args ;; *) usage ;; esac } exit $? fi fi # process quick actions first [ -n "$output" ] && [ -n "$mute" ] && exec pactl set-sink-mute '@DEFAULT_SINK@' toggle [ -n "$input" ] && [ -n "$mute" ] && exec pactl set-source-mute '@DEFAULT_SINK@' toggle [ -n "$volume_down" ] && exec pactl set-sink-volume '@DEFAULT_SINK@' '-5%' [ -n "$volume_up" ] && exec pactl set-sink-volume '@DEFAULT_SINK@' '+5%' [ -n "$output" ] && keyword="sink" && what="sink-input" [ -n "$input" ] && keyword="source" && what="source-output" # returns the sink_id default() { key=$1 case "$1" in sink) key="audio.default.sink" ;; source) key="audio.default.source" ;; esac pw-dump | jq -r --arg key "$key" '.[]|select(.type=="PipeWire:Interface:Metadata")|.metadata[]|select(.key==$key)|.value.name' } # __identifier: compacted json containing index and description # e.g. {"i":60,"d":"Built-in Audio Analog Stereo"} __identifier_list() { if [ "$1" = "card" ]; then pactl --format=json list "${1}s" | jq -c '.[]|{i:.index,d:.properties["api.alsa.card.name"]}' else pactl --format=json list "${1}s" | jq -c '.[]|{i:.index,d:.description}' fi } __identifier_index() { if [ -z "$1" ]; then jq -r '.i' else printf "%s" "$1" | __identifier_index fi } __identifier_desc() { if [ -z "$1" ]; then jq -r '.d' else printf "%s" "$1" | __identifier_desc fi } __identifier_fmt() { jq -r '[.i,.d]|@csv' | tr -d '"' | column -s ',' -t } __identifier_fmt_parse() { fmt=$(cat) i=$(printf "%s" "$fmt" | tr -s ' ' | cut -d' ' -f1) d=$(printf "%s" "$fmt" | tr -s ' ' | cut -d' ' -f2-) jq -cn --arg i "$i" --arg d "$d" '{i:$i,d:$d}' } selects() { __identifier_list "$1" | __identifier_fmt | _menu | __identifier_fmt_parse } notif() { topic=$1 mesg=$2 urgency=normal [ -n "$3" ] && urgency=critical notify-send -i /usr/share/icons/mdi/scalable/cast-audio.svg -u ${urgency} -a soundswitch "soundswitch: ${topic}" "${mesg}" } case "${mode}" in json) selects "${keyword}" ;; global) target=$(selects "${keyword}") if [ -z "${target}" ]; then notif "focused" "no target ${what}" error exit 1 fi notif "global" "switch to $(__identifier_desc "${target}")" # set new default pactl "set-default-${keyword}" "$(__identifier_index "${target}")" # move all attached to the new target pactl list "${what}s" short | cut -f1 | xargs -i pactl "move-${what}" "{}" "$(__identifier_index "${target}")" exit $? ;; focused) # parse pid of currently focused container # then get the sink-inputs for it and parse the sink-input-id notif "not implemented, do we need it?" error exit 1 focused_binary=$(swq current con | jq -r '.pid' | xargs ps -o comm=) what_id=$(printf "%s\n" "${focused_binary}" | xargs pactl list "${what}s" | tr '\n' '\r' | perl -pe 's/[^\r]*index: ([0-9]+).+?application\.process\.binary = "([^\r]+)"\r.+?(?=index:|$)/\2=\1\r/g' | tr '\r' '\n' | grep "^{}" | cut -d'=' -f2) if [ -z "${what_id}" ]; then notif "focused" "no ${what}" error exit 1 fi # select where to switch to target=$(selects "${keyword}") if [ -z "${target}" ]; then exit 1 fi notif "${focused_binary}" "switch to ${target}" pactl move-sink-input "${what_id}" "${target}" exit $? ;; profile) notif "not implemented, do we need it?" "" error exit 1 target_card=$(selects "card") if [ -z "${target_card}" ]; then exit 1 fi # [^\r]#([0-9]+).*?Name: ([^\r]+).*?Profiles:\r(.*?)Active.*?(?=Card #|$) # target_profile=$(pactl list cards | # tr '\n' '\r' | # perl -pe 's/[^\r]#([0-9]+).*?Name: ([^\r]+).*?Profiles:\r(.*?)Active.*?(?=Card #|$)/\2~\3`/g' | # tr -d '\t' | # tr '`' '\n' | # grep "${target_card}" | # cut -d'~' -f2- | # tr '\r' '\n' | # xargs -L1 | # _menu | # perl -pe 's/^\s*([^\s]+):\s.*$/\1/') # if [ -z "${target_profile}" ]; then # exit 1 # fi # # notif "profile" "switch ${target_card} to ${target_profile} profile" # pactl set-card-profile "${target_card}" "${target_profile}" # exit $? ;; hdmi) notif "not implemented, do we need it?" "" error exit 1 target_card=$(selects "card") if [ -z "${target_card}" ]; then exit 1 fi target_profile=$(pactl list cards | tr '\n' '\r' | perl -pe 's/[^\r]#([0-9]+).*?Name: ([^\r]+).*?Profiles:\r(.*?)Active.*?(?=Card #|$)/\2~\3`/g' | tr -d '\t' | tr '`' '\n' | grep "${target_card}" | cut -d'~' -f2- | tr '\r' '\n' | grep -i "hdmi" | grep -i -v "input" | xargs -L1 | _menu | perl -pe 's/^\s*([^\s]+):\s.*$/\1/') if [ -z "${target_profile}" ]; then target_profile=${HDMI_PROFILE} fi notif "hdmi" "switch ${target_card} to HDMI profile\n${target_profile}" pactl set-card-profile "${target_card}" "${target_profile}" exit $? ;; reset) notif "not implemented, do we need it?" "" error exit 1 target_card=$(selects "card") if [ -z "${target_card}" ]; then exit 1 fi notif "reset" "switch ${target_card} to default profile" pactl set-card-profile "${target_card}" ${DEFAULT_PROFILE} exit $? ;; mute) notif "not implemented, do we need it?" "" error exit 1 _data=$(pw-dump -N | jq -r '.. | select(.type?=="PipeWire:Interface:Node" and .info.props."media.class"?=="Stream/Output/Audio" and .info.props."stream.is-live"?==true) | @text') _id=$(printf "${_data}" | jq -r '. | [ .info.props."application.name", .info.props."application.process.id", .id ] | @tsv' | awk '{ system("swaymsg -t get_tree | jq -r \".. | select(.pid?==" $2 ") | .name\" | grep -v Picture-in-Picture | tr -d \"\\n\""); printf " [%s]", $3 }' | _menu | perl -pe 's/^.*\[([0-9]+)\]$/\1/') if [ -z "${_id}" ]; then exit 1 fi _oid=$(printf "${_data}" | jq -r --argjson id "${_id}" '.. | select(.id?==$id) | .info.props."object.id"') _target_state=$(printf "${_data}" | jq -r --argjson id "${_id}" '.. | select(.id?==$id) | .info.params.Props[0] | .mute = (.mute | not) | @text') pw-cli set-param "${_oid}" Props "${_target_state}" exit $? ;; esac