#!/bin/bash # # i3-volume # # Volume control and volume notifications. # # Dependencies: # awk (POSIX compatible) # pulseaudio-utils - if using PulseAudio # alsa-utils - if using amixer # # Copyright (c) 2016 Beau Hastings. All rights reserved. # License: GNU General Public License v2 # # Author: Beau Hastings # URL: https://github.com/hastinbe/i3-volume # Wiki: https://github.com/hastinbe/i3-volume/wiki/ define_helpers() { empty() { [[ -z $1 ]] } not_empty() { [[ -n $1 ]] } isset() { [[ -v $1 ]] } command_exists() { command -v "$1" >/dev/null 2>&1; } error() { echo "$COLOR_RED$*$COLOR_RESET" } has_color() { (( $(tput colors 2>/dev/null || echo 0) >= 8 )) && [ -t 1 ] } # Converts milliseconds to seconds with rounding up # # Arguments: # milliseconds (integer) An integer in milliseconds ms_to_secs() { echo $(( ($1 + (1000 - 1)) / 1000 )) } is_command_hookable() { ! [[ ${POST_HOOK_EXEMPT_COMMANDS[*]} =~ $1 ]] } has_capability() { [[ "${NOTIFY_CAPS[*]}" =~ $1 ]] } max() { echo $(( $1 > $2 ? $1 : $2 )) } } define_notify() { # Display a notification indicating muted or current volume. notify_volume() { local -r vol=$(get_volume) local icon if is_muted; then text="Volume muted" if $USE_FULLCOLOR_ICONS; then icon=${ICONS[0]} else icon=${ICONS_SYMBOLIC[0]} fi else printf -v text "Volume %3s%%" "$vol" icon=$(get_volume_icon "$vol") if $SHOW_VOLUME_PROGRESS; then local -r progress=$(progress_bar "$vol") text="$text $progress" fi fi case "$NOTIFICATION_METHOD" in xosd ) notify_volume_xosd "$vol" "$text" ;; herbe ) notify_volume_herbe "$text" ;; volnoti ) notify_volume_volnoti "$vol" ;; kosd ) notify_volume_kosd "$vol" ;; dunst ) notify_volume_libnotify "$vol" "$icon" "$text" ;; notify-osd ) notify_volume_libnotify "$vol" "$icon" "$text" ;; libnotify ) notify_volume_libnotify "$vol" "$icon" "$text" ;; haskell-notification-daemon) notify_volume_libnotify "$vol" "$icon" "$text" ;; * ) notify_volume_libnotify "$vol" "$icon" "$text" ;; esac } list_notification_methods() { awk -W posix 'match($0,/ notify_volume_([[:alnum:]]+)/) {print substr($0, 19, RLENGTH-18)}' "${BASH_SOURCE[0]}" || exit "$EX_USAGE" exit "$EX_OK" } setup_notification_icons() { if not_empty "$SYMBOLIC_ICON_SUFFIX"; then apply_symbolic_icon_suffix fi } show_volume_notification() { $DISPLAY_NOTIFICATIONS || return if empty "$NOTIFICATION_METHOD"; then load_notify_server_info NOTIFICATION_METHOD=$NOTIFY_SERVER fi setup_notification_icons notify_volume } # Loads notification system information via DBus load_notify_server_info() { command_exists dbus-send || return IFS=$'\t' read -r NOTIFY_SERVER _ _ _ < <(dbus-send --print-reply --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.GetServerInformation | awk 'BEGIN { ORS="\t" }; match($0, /^ string ".*"/) {print substr($0, RSTART+11, RLENGTH-12)}') } # Load notification system capabilities via DBus load_notify_server_caps() { command_exists dbus-send || return IFS= read -r -d '' -a NOTIFY_CAPS < <(dbus-send --print-reply=literal --dest="${DBUS_NAME}" "${DBUS_PATH}" "${DBUS_IFAC_FDN}.GetCapabilities" | awk 'RS=" " { if (NR > 2) print $1 }') } # Send notifcation for libnotify-compatible notification daemons. # # Arguments: # Volume (integer) An integer indicating the volume. # Icon (string) Icon to display. # Text (string) Notification text. notify_volume_libnotify() { local -r vol=$1 local -r icon=$2 local -r text=${*:3} local -a args=( -t "$EXPIRES" ) local -a hints=( # Replaces previous notification in some notification servers string:synchronous:volume # Replaces previous notification in NotifyOSD string:x-canonical-private-synchronous:volume ) # If we're not drawing our own progress bar, allow the notification daemon to draw its own (if supported) if ! $SHOW_VOLUME_PROGRESS; then hints+=(int:value:"$vol") fi (( ${#NOTIFY_CAPS[@]} < 1 )) && load_notify_server_caps if has_capability icon-static; then args+=(-i "$icon") # haskell-notification-daemon (aka deadd-notification-center) does not support -i|--icon hints+=(string:image-path:"$icon") fi if $PLAY_SOUND && has_capability sound; then hints+=(string:sound-name:audio-volume-change) fi if ! isset NO_NOTIFY_COLOR && [[ $NOTIFICATION_METHOD == "dunst" ]]; then if is_muted; then hints+=(string:fgcolor:"$COLOR_MUTED") else hints+=(string:fgcolor:"$(volume_color "$vol")") fi fi if $USE_DUNSTIFY; then args+=(-r 1000) # Transient notifications will bypass the idle_threshold setting. # Should be boolean, but Notify-OSD doesn't support boolean yet. Dunst checks # for int and bool with transient so lets play nice with both servers. hints+=(int:transient:1) read -ra hints <<< "${hints[@]/#/-h }" "${DUNSTIFY_PATH:+${DUNSTIFY_PATH%/}/}dunstify" "${hints[@]}" "${args[@]}" "$text" elif isset USE_NOTIFY_SEND_PY; then # Replaces previous notification, but leaves itself running in the bg to work args+=(--replaces-process volume) # By-pass the server's persistence capability, if it should exist hints+=(boolean:transient:true) "${NOTIFY_SEND_PATH:+${NOTIFY_SEND_PATH%/}/}notify-send.py" --hint "${hints[@]}" "${args[@]}" "$text" & else read -ra hints <<< "${hints[@]/#/-h }" "${NOTIFY_SEND_PATH:+${NOTIFY_SEND_PATH%/}/}notify-send" "${hints[@]}" "${args[@]}" "$text" fi } # Send notification to XOSD. # # Arguments: # Volume (integer) An integer indicating the volume. # Text (string) Notification text. notify_volume_xosd() { local -r vol=$1 local -r text=${*:2} local -r delay=$(ms_to_secs "$EXPIRES") local percentage if is_muted; then color=$COLOR_MUTED percentage=0 else color=$(volume_color "$vol") percentage=$vol fi "${XOSD_PATH:+${XOSD_PATH%/}/}osd_cat" --align center -b percentage -P "$percentage" -d "$delay" -p top -A center -c "$color" -T "$text" -O 2 -u "$COLOR_XOSD_OUTLINE" & disown } # Send notification to herbe. # # Arguments: # Text (string) Notification text. # # Note: a patch with a notify-send script for herbe, not in the current version at this # time but would make this irrelevant. See https://github.com/dudik/herbe/pull/10 notify_volume_herbe() { local -r text=$* # Dismiss existing/pending notifications to prevent queuing pkill -SIGUSR1 herbe "${HERBE_PATH:+${HERBE_PATH%/}/}herbe" "$text" & disown } # Send notification to volnoti. # # Arguments: # Volume (integer) An integer indicating the volume. notify_volume_volnoti() { local -r vol=$1 if is_muted; then "${VOLNOTI_PATH:+${VOLNOTI_PATH%/}/}volnoti-show" -m "$vol" else "${VOLNOTI_PATH:+${VOLNOTI_PATH%/}/}volnoti-show" "$vol" fi } # Send notification to KOSD. # # Arguments: # Volume (integer) An integer indicating the volume. notify_volume_kosd() { local -r vol=$1 if is_muted; then qdbus org.kde.kded /modules/kosd showVolume "$vol" 1 else qdbus org.kde.kded /modules/kosd showVolume "$vol" 0 fi } } define_output_formats() { # Outputs the current volume in the default format. output_volume_default() { if is_muted; then echo MUTE else echo "$(get_volume)%" fi } # Outputs the current volume using a custom format string. # # Format options: # %v = volume percentage or "MUTE" when muted # %s = sink name (PulseAudio only) # %c = card (alsamixer only) # %m = mixer (alsamixer only) # %p = volume progress bar # %i = volume icon output_volume_custom() { local -r format=$* local -r vol=$(get_volume) local string if is_muted; then string=${format//\%v/MUTE} else string=${format//\%v/$vol%} fi string=${string//\%s/$SINK} string=${string//\%c/$CARD} string=${string//\%m/$MIXER} string=${string//\%p/$(progress_bar "$vol")} string=${string//\%i/$(get_volume_emoji "$vol")} echo -ne "$string" } # Outputs the current volume for i3blocks. output_volume_i3blocks() { local short_text local full_text if is_muted; then short_text="MUTE\n" full_text="MUTE\n" else local -r vol=$(get_volume) local -r color=$(volume_color "$vol") short_text="${vol}%\n" full_text="${vol}%\n" if isset MAX_VOLUME && (( vol > MAX_VOLUME )); then EXITCODE=$EX_URGENT fi fi echo -ne "$full_text$short_text" } # Outputs the current volume for xob. output_volume_xob() { local -ir vol=$(get_volume) if is_muted; then echo "${vol}!" else echo "$vol" fi } } define_commands() { # Increase volume relative to current volume. # # Arguments: # Step (integer) Percentage to increase by. # Max Volume (optional) (integer|percentage) Maximum volume limit. increase_volume() { local step=${1:?$(error 'Step is required')} local -r max_volume=$2 if not_empty "$max_volume"; then local -r vol=$(get_volume) if (( vol + step > max_volume )); then # Instead of doing nothing, step to max_volume step=$( max "0" "$(( max_volume - vol ))" ) fi fi if $USE_AMIXER; then amixer_increase_volume "$CARD" "$step" else pa_increase_volume "$SINK" "$step" fi } # Decrease volume relative to current volume. # # Arguments: # Step (integer) Percentage to decrease by. decrease_volume() { local -r step=${1:?$(error 'Step is required')} if $USE_AMIXER; then amixer_decrease_volume "$CARD" "$step" else pa_decrease_volume "$SINK" "$step" fi } # Set volume. # # Arguments: # Volume (integer|linear factor|percentage|decibel) # Max Volume (optional) (integer|percentage) Maximum volume limit. set_volume() { local -r vol=${1:?$(error 'Volume is required')} local -r max_volume=$2 if not_empty "$max_volume" && (( vol > max_volume )); then return fi if $USE_AMIXER; then amixer_set_volume "${vol}%" "$CARD" else pa_set_volume "$SINK" "${vol}%" fi } toggle_mute() { if $USE_AMIXER; then amixer_toggle_mute "$CARD" else pa_toggle_mute "$SINK" fi } # Outputs the current volume. # # Arguments # Output method (string) Method to use to output volume. output_volume() { local -r for=${1:?$(error 'Output method is required')} case "$for" in i3blocks ) output_volume_i3blocks ;; xob ) output_volume_xob ;; default ) output_volume_default ;; * ) output_volume_custom "$*" ;; esac } list_output_formats() { awk -W posix 'match($0,/ output_volume_([[:alnum:]]+)/) {print substr($0, 19, RLENGTH-18)}' "${BASH_SOURCE[0]}" || exit "$EX_USAGE" exit "$EX_OK" } usage() { cat <<- EOF 1>&2 ${COLOR_YELLOW}Usage:${COLOR_RESET} $0 [] [] Control volume and related notifications. ${COLOR_YELLOW}Commands:${COLOR_RESET} ${COLOR_GREEN}up ${COLOR_RESET} increase volume ${COLOR_GREEN}down ${COLOR_RESET} decrease volume ${COLOR_GREEN}set ${COLOR_RESET} set volume ${COLOR_GREEN}mute${COLOR_RESET} toggle mute ${COLOR_GREEN}listen${COLOR_RESET} listen for changes to a PulseAudio sink ${COLOR_GREEN}output ${COLOR_RESET} output volume in a supported format custom format substitutions: %v = volume %s = sink name (PulseAudio only) %c = card (alsamixer only) %m = mixer (alsamixer only) %p = volume progress bar %i = volume icon/emoji examples: "Volume is %v" = Volume is 50% "%i %v %p \n" = 奔 50% ██████████ ${COLOR_GREEN}outputs${COLOR_RESET} show available output formats ${COLOR_GREEN}notifications${COLOR_RESET} show available notification methods ${COLOR_GREEN}help${COLOR_RESET} display help ${COLOR_YELLOW}Options:${COLOR_RESET} ${COLOR_GREEN}-a${COLOR_RESET} use amixer ${COLOR_GREEN}-n${COLOR_RESET} enable notifications ${COLOR_GREEN}-C${COLOR_RESET} use libcanberra for playing event sounds ${COLOR_GREEN}-P${COLOR_RESET} play sound for volume changes ${COLOR_GREEN}-j ${COLOR_RESET} specify custom volume emojis as a comma separated list ${COLOR_GREEN}-t ${COLOR_RESET} process name of status bar (${COLOR_MAGENTA}requires -u${COLOR_RESET}) ${COLOR_GREEN}-u ${COLOR_RESET} signal to update status bar (${COLOR_MAGENTA}requires -t${COLOR_RESET}) ${COLOR_GREEN}-x ${COLOR_RESET} maximum volume ${COLOR_GREEN}-X ${COLOR_RESET} maximum amplification; if supported (${COLOR_MAGENTA}default: 2${COLOR_RESET}) ${COLOR_GREEN}-h${COLOR_RESET} display help ${COLOR_YELLOW}amixer Options:${COLOR_RESET} ${COLOR_GREEN}-c ${COLOR_RESET} card number to control ${COLOR_GREEN}-m ${COLOR_RESET} set mixer (${COLOR_MAGENTA}default: Master${COLOR_RESET}) ${COLOR_YELLOW}PulseAudio Options:${COLOR_RESET} ${COLOR_GREEN}-s ${COLOR_RESET} symbolic name of sink ${COLOR_YELLOW}Notification Options:${COLOR_RESET} ${COLOR_GREEN}-N ${COLOR_RESET} notification method (${COLOR_MAGENTA}default: libnotify${COLOR_RESET}) ${COLOR_GREEN}-p${COLOR_RESET} enable progress bar ${COLOR_GREEN}-e ${COLOR_RESET} expiration time of notifications in ms ${COLOR_GREEN}-l${COLOR_RESET} use fullcolor instead of symbolic icons ${COLOR_GREEN}-S ${COLOR_RESET} append suffix to symbolic icon names ${COLOR_GREEN}-y${COLOR_RESET} use dunstify (${COLOR_MAGENTA}default: notify-send${COLOR_RESET}) ${COLOR_YELLOW}Environment Variables:${COLOR_RESET} ${COLOR_CYAN}XOSD_PATH${COLOR_RESET} path to osd_cat ${COLOR_CYAN}HERBE_PATH${COLOR_RESET} path to herbe ${COLOR_CYAN}VOLNOTI_PATH${COLOR_RESET} path to volnoti-show ${COLOR_CYAN}DUNSTIFY_PATH${COLOR_RESET} path to dunstify ${COLOR_CYAN}CANBERRA_PATH${COLOR_RESET} path to canberra-gtk-play ${COLOR_CYAN}NOTIFY_SEND_PATH${COLOR_RESET} path to notify-send or notify-send.py ${COLOR_CYAN}USE_NOTIFY_SEND_PY${COLOR_RESET} flag to use notify-send.py instead of notify-send ${COLOR_CYAN}NO_NOTIFY_COLOR${COLOR_RESET} flag to disable colors in notifications EOF exit "$EX_USAGE" } } # Get the volume as a percentage. get_volume() { if $USE_AMIXER; then amixer_get_volume "$CARD" "$MIXER" else pa_get_volume "$SINK" fi } is_muted() { if $USE_AMIXER; then amixer_is_muted "$CARD" return $? else pa_is_muted "$SINK" return $? fi } # Gets an icon for the provided volume. # # Arguments: # Volume (integer) An integer indicating the volume. # # Returns: # The volume icon name. get_volume_icon() { local -r vol=${1:?$(error 'Volume is required')} local icon if $USE_FULLCOLOR_ICONS; then if (( vol >= 70 )); then icon=${ICONS[1]} elif (( vol >= 40 )); then icon=${ICONS[3]} elif (( vol > 0 )); then icon=${ICONS[2]} else icon=${ICONS[2]} fi else # Get overamplified icon if available, otherwise default to high volume icon if (( vol > 100 )); then icon=${ICONS_SYMBOLIC[4]:-${ICONS_SYMBOLIC[1]}} elif (( vol >= 70 )); then icon=${ICONS_SYMBOLIC[1]} elif (( vol >= 40 )); then icon=${ICONS_SYMBOLIC[3]} elif (( vol > 0 )); then icon=${ICONS_SYMBOLIC[2]} else icon=${ICONS_SYMBOLIC[2]} fi fi echo "$icon" } # Gets an emoji for the provided volume. # # Arguments: # Volume (integer) An integer indicating the volume. # # Returns: # The volume emoji. get_volume_emoji() { local -r vol=${1:?$(error 'Volume is required')} local icon if is_muted; then icon=${ICONS_EMOJI[0]} else if (( vol >= 70 )); then icon=${ICONS_EMOJI[1]} elif (( vol >= 40 )); then icon=${ICONS_EMOJI[3]} elif (( vol > 0 )); then icon=${ICONS_EMOJI[2]} else icon=${ICONS_EMOJI[2]} fi fi echo "$icon" } # Updates the status line. # # Arguments: # signal (string) The signal used to update the status line. # proc (string) The name of the status line process. update_statusline() { local -r signal=${1:?$(error 'Signal is required')} local -r proc=${2:?$(error 'Process name is required')} pkill "-$signal" "$proc" } # Generates a progress bar for the provided value. # # Arguments: # Percentage (integer) Percentage of progress. # Maximum (integer) Maximum percentage. (default: 100) # Divisor (integer) For calculating the ratio of blocks to progress (default: 5) # # Returns: # The progress bar. progress_bar() { local -r percent=${1:?$(error 'Percentage is required')} local -r max_percent=${2:-100} local -r divisor=${3:-5} local -r progress=$(( (percent > max_percent ? max_percent : percent) / divisor )) printf -v bar "%*s" $progress "" echo "${bar// /█}" } apply_symbolic_icon_suffix() { for i in "${!ICONS_SYMBOLIC[@]}"; do ICONS_SYMBOLIC[$i]="${ICONS_SYMBOLIC[$i]}${SYMBOLIC_ICON_SUFFIX}" done } # Get color for the given volume # # Arguments: # $1 - The volume volume_color() { local -ir vol=${1:?$(error 'A volume is required')} if $USE_AMIXER; then amixer_volume_color "$vol" else pa_volume_color "$vol" fi } # Updates the status bars # # Returns # 0 when no problem occurred # 1 when one $of signal or $statusline are set but not both update_statusbar() { if not_empty "$SIGNAL"; then if empty "$STATUSLINE"; then return 1 fi update_statusline "$SIGNAL" "$STATUSLINE" else if not_empty "$STATUSLINE"; then return 1 fi fi return 0 } setup_audio() { if $USE_AMIXER; then setup_amixer else setup_pulseaudio fi } # All PulseAudio functions are defined here define_pulseaudio_functions() { # Executes `pactl list sinks` or return its output if called previously pa_list_sinks() { if $OPT_LISTEN || empty "$PA_LIST_SINKS"; then PA_LIST_SINKS=$(pactl list sinks) fi echo "$PA_LIST_SINKS" } pa_invalidate_cache() { unset PA_LIST_SINKS } pa_default_sink_name() { pactl info | awk -F': ' '/^Default Sink: /{print $2}' } # Get the index of a sink name. # # Arguments # Sink name (string) Symbolic name of sink. pa_get_sink_index() { local -r sink=${1:?$(error 'Sink name is required')} pa_list_sinks | \ awk -W posix '/^Sink #/{gsub("#", ""); idx = $2} /^[ \t]+Name: / {insink = $2 == "'"$sink"'"; if (insink) { print idx }; exit}' } # Get the volume as a percentage. # # Arguments # Sink name (string) Symbolic name of sink. pa_get_volume() { local -r sink=${1:?$(error 'Sink name is required')} pa_list_sinks | \ awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"} /^[ \t]+Volume: / && insink {gsub("%,?", ""); print $5; exit}' } # Get the max volume as a percentage. # # Arguments # Sink name (string) Symbolic name of sink. pa_get_base_volume() { local -r sink=${1:?$(error 'Sink name is required')} pa_list_sinks | \ awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"} /^[ \t]+Base Volume: / && insink {gsub("%", ""); print $5; exit}' } # Increase volume relative to current volume using pulseaudio. # # Arguments: # Sink name (string) Symbolic name of sink. # Step (integer) Percentage to increase by. pa_increase_volume() { local -r sink=$1 local -r step=${2:=-5} pa_set_volume "$sink" "+${step}%" } # Decrease volume relative to current volume using pulseaudio. # # Arguments: # Sink name (string) Symbolic name of sink. # Step (integer|percentage) Percentage to decrease by. pa_decrease_volume() { local -r sink=$1 local -r step=${2:=-5} pa_set_volume "$sink" "-${step}%" } # Set volume using pulseaudio. # # Arguments: # Sink name (string) Symbolic name of sink. # Volume (integer|linear factor|percentage|decibel) pa_set_volume() { local -r sink=${1:?$(error 'Sink name is required')} local -r vol=${2:?$(error 'Volume is required')} pa_invalidate_cache pactl set-sink-volume "$sink" "$vol" } # Toggle mute using pulseaudio. # # Arguments: # Sink name (string) Symbolic name of sink. pa_toggle_mute() { local -r sink=${1:?$(error 'Sink name is required')} pa_invalidate_cache pactl set-sink-mute "$sink" toggle } # Check if sink is muted. # # Arguments: # Sink name (string) Symbolic name of sink. # # Returns: # 0 when true, 1 when false. pa_is_muted() { local -r sink=${1:?$(error 'Sink name is required')} pa_list_sinks | \ awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"} /^[ \t]+Mute: / && insink && $2 ~ /^yes$/ { exitcode=1 }; END { exit !exitcode }' } # Get the flags of the PulseAudio sink. # # Arguments # Sink name (string) Symbolic name of sink. pa_get_sink_flags() { local -r sink=${1:?$(error 'Sink name is required')} pa_list_sinks | \ awk -W posix '/^[ \t]+Name: / {insink = $2 == "'"$sink"'"} /^[ \t]+Flags: / && insink { for(i = 2; i <= NF; ++i) printf $i FS; exit}' } # Get color for the given volume for PulseAudio # # Arguments: # $1 - The volume pa_volume_color() { local -ir vol=${1:?$(error 'A volume is required')} if (( vol >= PA_VOLUME_MUTED && vol < PA_BASE_VOLUME )); then echo "$COLOR_MUTED_TO_BASE" elif (( vol >= PA_BASE_VOLUME && vol <= PA_VOLUME_NORM )); then echo "$COLOR_BASE_TO_NORM" elif (( vol > PA_VOLUME_NORM && vol <= MAX_VOLUME )); then echo "$COLOR_NORM_TO_MAX" else echo "$COLOR_OTHER" fi } # Listens for PulseAudio events # # Arguments: # Output (optional) (string) An output mode. When set, outputs volume in the output mode format. listen() { local -r output=$* local -r index=$(pa_get_sink_index "$SINK") # Output volume so statusbars have something to display before any event occurs not_empty "$output" && output_volume "$output" while IFS= read -r; do show_volume_notification update_statusbar play_volume_changed not_empty "$output" && output_volume "$output" done < <(pactl subscribe | stdbuf -oL grep -e "Event 'change' on sink #$index") } # Play a sound file. # # Arguments: # Sound file (string) pa_play() { local -r file=$1 paplay -d "$SINK" "$file" & } } # Register PulseAudio related functions and settings setup_pulseaudio() { define_pulseaudio_functions PA_LIST_SINKS=$(pactl list sinks) || exit 1 if empty "$SINK"; then SINK="$(pa_default_sink_name)" fi # Determine a max volume when it's not specified if ! isset MAX_VOLUME; then read -ra SINK_FLAGS < <(pa_get_sink_flags "$SINK") PA_BASE_VOLUME=$(pa_get_base_volume "$SINK") # Does the sink support digital (software) amplification? if [[ "${SINK_FLAGS[*]}" =~ "DECIBEL_VOLUME" ]]; then MAX_VOLUME=$((PA_VOLUME_NORM * MAX_AMPLIFICATION)) else MAX_VOLUME=$PA_VOLUME_NORM fi fi } # All amixer functions are defined here define_amixer_functions() { # Get the volume as a percentage. # # Arguments # Card (integer) Card number to control. # Mixer (string) Name of the mixer. amixer_get_volume() { local -r card=$1 local -r mixer=${2:-Master} amixer ${card:+-c "$card" --} sget "$mixer" | \ awk -W posix -F'[][]' '/dB/ { gsub("%", ""); print $2 }' } # Increase volume relative to current volume using amixer. # # Arguments: # Card (integer) Card number to control. # Step (integer) Percentage to increase by. amixer_increase_volume() { local -r card=$1 local -r step=${2:=-5} amixer_set_volume "${step}%+" "$card" } # Decrease volume relative to current volume using amixer. # # Arguments: # Card (integer) Card number to control. # Step (integer) Percentage to decrease by. amixer_decrease_volume() { local -r card=$1 local -r step=${2:=-5} amixer_set_volume "${step}%-" "$card" } # Set volume using amixer. # # Arguments: # Volume (integer|linear factor|percentage|decibel) # Card (optional) (integer) Card number to control. amixer_set_volume() { local -r vol=${1:?$(error 'Volume is required')} local -r card=$2 amixer -q ${card:+-c "$card" --} set "$MIXER" "$vol" } # Toggle mute using amixer. # # Arguments: # Card (integer) Card number to control. amixer_toggle_mute() { local -r card=$1 amixer -q ${card:+-c "$card" --} set "$MIXER" toggle } # Check if card is muted. # # Arguments: # Card (optional) (integer) Card number to control. # # Returns: # 0 when true, 1 when false. amixer_is_muted() { local -r card=$1 amixer ${card:+-c "$card" --} sget "$MIXER" | \ awk -W posix -F'[][]' '/dB/ && $6 ~ /^off$/ { exitcode=1 }; END { exit !exitcode }' } # Get color for the given volume for amixer # # Arguments: # $1 - The volume amixer_volume_color() { local -ir vol=${1:?$(error 'A volume is required')} if (( vol >= 0 && vol < 100 )); then echo "$COLOR_MUTED_TO_BASE" elif (( vol == 100 )); then echo "$COLOR_BASE_TO_NORM" elif (( vol > 100 && vol <= MAX_VOLUME )); then echo "$COLOR_NORM_TO_MAX" else echo "$COLOR_OTHER" fi } # Play a sound file. # # Arguments: # Sound file (string) amixer_play() { local -r file=$1 aplay -q "$file" & } } # Register amixer related functions and settings setup_amixer() { define_amixer_functions } setup_color() { if has_color; then COLOR_RESET=$'\033[0m' COLOR_RED=$'\033[0;31m' COLOR_GREEN=$'\033[0;32m' COLOR_YELLOW=$'\033[0;33m' COLOR_MAGENTA=$'\033[0;35m' COLOR_CYAN=$'\033[0;36m' fi } # Rearrange all options to place flags first # Author: greycat # URL: https://mywiki.wooledge.org/ComplexOptionParsing arrange_opts() { local flags args optstr=$1 shift while (($#)); do case $1 in --) args+=("$@") break; ;; -*) flags+=("$1") if [[ $optstr == *"${1: -1}:"* ]]; then flags+=("$2") shift fi ;; *) args+=("$1") ;; esac shift done OPTARR=("${flags[@]}" "${args[@]}") } parse_opts() { local optstring=:ac:Ce:hj:lm:nN:pPs:S:t:u:x:X:y arrange_opts "$optstring" "$@" set -- "${OPTARR[@]}" OPTIND=1 while getopts "$optstring" opt; do case "$opt" in a ) USE_AMIXER=true ;; c ) CARD=$OPTARG ;; C ) USE_CANBERRA=true ;; e ) EXPIRES=$OPTARG ;; j ) IFS=, read -ra ICONS_EMOJI <<< "$OPTARG" ;; l ) USE_FULLCOLOR_ICONS=true ;; m ) MIXER=${OPTARG@Q} ;; n ) DISPLAY_NOTIFICATIONS=true ;; N ) NOTIFICATION_METHOD=$OPTARG ;; p ) SHOW_VOLUME_PROGRESS=true ;; P ) PLAY_SOUND=true ;; s ) SINK=$OPTARG ;; S ) SYMBOLIC_ICON_SUFFIX=$OPTARG ;; t ) STATUSLINE=$OPTARG ;; u ) SIGNAL=$OPTARG ;; x ) MAX_VOLUME=$OPTARG ;; X ) MAX_AMPLIFICATION=$OPTARG ;; y ) USE_DUNSTIFY=true ;; h | *) usage ;; esac done read -ra CMDARGS <<< "${OPTARR[@]:$((OPTIND-1))}" } exec_command() { IFS=' ' read -ra ARGS <<< "$1" set -- "${ARGS[@]}" COMMAND=${1:?$(error 'A command is required')} shift case "$COMMAND" in up|raise|increase) case "$#" in 1) ;; *) usage ;; esac increase_volume "$1" "$MAX_VOLUME" ;; down|lower|decrease) case "$#" in 1) ;; *) usage ;; esac decrease_volume "$1" ;; set) case "$#" in 1) ;; *) usage ;; esac case "$1" in +*) increase_volume "${1:1}" "$MAX_VOLUME" ;; -*) decrease_volume "${1:1}" ;; *) set_volume "$1" "$MAX_VOLUME" ;; esac ;; mute) toggle_mute ;; listen) listen "$*" ;; output) case "$#" in 0) usage ;; esac output_volume "$*" exit "${EXITCODE:-$EX_OK}" ;; outputs) list_output_formats ;; notifications) list_notification_methods ;; *) usage ;; esac } play_volume_changed() { $PLAY_SOUND || return # Sound can be handled by the notification method if $DISPLAY_NOTIFICATIONS && has_capability sound; then return fi if $USE_CANBERRA; then ca_play "$SOUND_VOLUME_CHANGED" "Volume Changed" else if $USE_AMIXER; then amixer_play "$SOUND_VOLUME_CHANGED" else pa_play "$SOUND_VOLUME_CHANGED" fi fi } ca_play() { local -r file=$1 desc=$2 if [[ -f $file ]]; then "${CANBERRA_PATH:+${CANBERRA_PATH%/}/}canberra-gtk-play" -f "$file" -d "$desc" else "${CANBERRA_PATH:+${CANBERRA_PATH%/}/}canberra-gtk-play" -i "audio-volume-change" -d "$desc" fi } post_command_hook() { if is_command_hookable "$COMMAND"; then show_volume_notification play_volume_changed update_statusbar || usage fi } main() { # Getopt parsing variables declare OPTIND declare -a OPTARR CMDARGS ########################################################### # Non-command line option variables ########################################################### # Commands which will not use post_command_hook(), usually because # they handle notifications and/or statusbar updates manually declare -a POST_HOOK_EXEMPT_COMMANDS=( listen ) # Exit codes declare -ir \ EX_OK=0 \ EX_URGENT=33 \ EX_USAGE=64 # Main program exit code declare -i EXITCODE=$EX_OK # Standard notification icons. Usually full color # Note: order matters; muted, high, low, medium, and optionally overamplified declare -a ICONS=( audio-volume-muted audio-volume-high audio-volume-low audio-volume-medium ) # Symbolic notification icons. Usually low color or monochrome # Note: order matters; muted, high, low, medium, and optionally overamplified declare -a ICONS_SYMBOLIC=( audio-volume-muted-symbolic audio-volume-high-symbolic audio-volume-low-symbolic audio-volume-medium-symbolic ## Only exists in some icon sets # audio-volume-overamplified-symbolic ) # Emoji-based icons. declare -a ICONS_EMOJI=( ﱝ 墳 奄 奔 ) # Volume changed sound. declare SOUND_VOLUME_CHANGED=${SOUND_VOLUME_CHANGED:-/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga} # DBUS constants declare -r \ DBUS_NAME=org.freedesktop.Notifications \ DBUS_PATH=/org/freedesktop/Notifications \ DBUS_IFAC_FDN=org.freedesktop.Notifications # Notification server information declare \ NOTIFY_SERVER # NOTIFY_VENDOR \ # NOTIFY_VERSION \ # NOTIFY_SPEC_VERSION # Notification capabilities declare -a NOTIFY_CAPS=() # PulseAudio sink flags declare -a SINK_FLAGS=() # PulseAudio volume variables and constants. # Note: unlike in PA, PA_VOLUME_* here are percentages instead of integers declare -i PA_BASE_VOLUME=100 declare -ir \ PA_VOLUME_NORM=100 \ PA_VOLUME_MUTED=0 # Cached output of `pactl list sinks`; so we don't have to call it each time we need it declare PA_LIST_SINKS # Output volume colors declare -r \ COLOR_MUTED=${COLOR_MUTED:-#FFFF00} \ COLOR_MUTED_TO_BASE=${COLOR_MUTED_TO_BASE:-#00FF00} \ COLOR_BASE_TO_NORM=${COLOR_BASE_TO_NORM:-#FFFF00} \ COLOR_NORM_TO_MAX=${COLOR_NORM_TO_MAX:-#FF0000} \ COLOR_OTHER=${COLOR_OTHER:-#FFFFFF} \ COLOR_XOSD_OUTLINE=${COLOR_XOSD_OUTLINE:-#222222} declare \ COLOR_RESET \ COLOR_RED \ COLOR_GREEN \ COLOR_YELLOW \ COLOR_MAGENTA \ COLOR_CYAN ########################################################### # Command line option variables ########################################################### declare -l NOTIFICATION_METHOD declare \ COMMAND \ DISPLAY_NOTIFICATIONS=false \ SHOW_VOLUME_PROGRESS=false \ USE_AMIXER=false \ USE_DUNSTIFY=false \ USE_FULLCOLOR_ICONS=false \ CARD \ MIXER=Master \ SIGNAL \ SINK \ STATUSLINE \ SYMBOLIC_ICON_SUFFIX \ NOTIFICATION_METHOD \ PLAY_SOUND=false \ USE_CANBERRA=false declare -i \ EXPIRES=1500 \ MAX_VOLUME \ MAX_AMPLIFICATION=2 define_helpers define_notify define_output_formats define_commands setup_color parse_opts "$@" # Requires options to be parsed first setup_audio exec_command "${CMDARGS[*]}" && post_command_hook exit ${EXITCODE:-$EX_OK} } main "$@"