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