├── .github └── workflows │ └── ci.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── pulseaudio-control ├── screenshots └── example.png └── tests.bats /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: master 7 | 8 | jobs: 9 | linter: 10 | name: ShellCheck 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: ShellCheck 15 | uses: ludeeus/action-shellcheck@1.0.0 16 | 17 | tests: 18 | name: Tests 19 | runs-on: ubuntu-20.04 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Install dependencies 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install bats pulseaudio 26 | - name: Tests with Bats 27 | run: bats tests.bats 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian 2 | 3 | RUN apt-get update && apt-get -y install bats pulseaudio psmisc procps 4 | 5 | COPY ./pulseaudio-control ./tests.bats / 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mario 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PulseAudio Control 2 | 3 | A feature-full volume control module for PulseAudio. Also known as Pavolume. Main features: 4 | 5 | * Increase/Decrease and Mute the default audio node (can be sink or source). 6 | * Switch between nodes easily. You can also blacklist useless devices. 7 | * Optionally enable notifications and OSD messages. 8 | * Works as a shortcut to pavucontrol or your favorite audio manager tool. 9 | * Highly customizable: check the [Usage](#usage) section for details. 10 | 11 | ![example](screenshots/example.png) 12 | 13 | 14 | ## Installation 15 | 16 | ### Arch 17 | 18 | Install [`pulseaudio-control`](https://aur.archlinux.org/packages/pulseaudio-control/) from the AUR with your preferred method, for example: 19 | ``` 20 | $ yay -S pulseaudio-control 21 | ``` 22 | 23 | ### Other Linux 24 | 25 | Download the [bash script](https://github.com/marioortizmanero/polybar-pulseaudio-control/blob/master/pulseaudio-control) from this repository, or extract it from [the latest release](https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/latest), and put it somewhere in your `$PATH`. 26 | 27 | #### Dependencies 28 | 29 | [`pulseaudio`](https://www.freedesktop.org/wiki/Software/PulseAudio/) with `pactl` in your `$PATH`. You might want to have [`pavucontrol`](https://freedesktop.org/software/pulseaudio/pavucontrol/) installed to easily control pulseaudio with a GUI. The script can send notifications if enabled, for which you'll need a notification daemon like [`dunst`](https://github.com/dunst-project/dunst). 30 | 31 | This script works with PipeWire as well, as long as your system has something like [`pipewire-pulse`](https://archlinux.org/packages/extra/x86_64/pipewire-pulse/). 32 | 33 | To be able to switch the default sinks from this script you might need to disable stream target device restore by editing the corresponding line in `/etc/pulse/default.pa` to: 34 | 35 | ``` 36 | load-module module-stream-restore restore_device=false 37 | ``` 38 | 39 | At a minimum, bash version 4 is required to run the script. You can check your bash version by running `bash --version`. 40 | 41 | 42 | ## Usage 43 | 44 | `pulseaudio-control` is expected to be invoked from a [polybar](//github.com/polybar/polybar) module: 45 | ```ini 46 | [module/pulseaudio-control] 47 | type = custom/script 48 | exec = pulseaudio-control [option...] 49 | ``` 50 | 51 | where `action`, and (optionally) `option`s are as specified in `pulseaudio-control help`: 52 | 53 | ``` 54 | Usage: ./pulseaudio-control [OPTION...] ACTION 55 | 56 | Terminology: A node represents either a sink (output) or source (input). 57 | 58 | Options: 59 | --autosync | --no-autosync 60 | Whether to maintain same volume for all programs. 61 | Default: "no" 62 | --color-muted 63 | Color in which to format when muted. 64 | Pass empty string to disable. 65 | Default: "6b6b6b" 66 | --notifications | --no-notifications 67 | Whether to show notifications when changing nodes. 68 | Default: "no" 69 | --osd | --no-osd 70 | Whether to display KDE's OSD message. 71 | Default: "no" 72 | --icon-muted 73 | Icon to use when muted. 74 | Default: none 75 | --icon-node 76 | Icon to use for node. 77 | Default: none 78 | --format 79 | Use a format string to control the output. 80 | Remember to pass this argument wrapped in single quotes (`'`) instead 81 | of double quotes (`"`) to avoid your shell from evaluating the 82 | variables early. 83 | Available variables: 84 | * $VOL_ICON 85 | * $VOL_LEVEL 86 | * $ICON_NODE 87 | * $NODE_NICKNAME 88 | * $IS_MUTED (yes/no) 89 | Default: '$VOL_ICON ${VOL_LEVEL}% $ICON_NODE $NODE_NICKNAME' 90 | --icons-volume [,...] 91 | Icons for volume, from lower to higher. 92 | Default: none 93 | --node-type 94 | Whether to consider PulseAudio sinks (output) or sources (input). 95 | All the operations of pulseaudio-control will apply to one of the two. 96 | Pass `input` for the sources, e.g. a microphone. 97 | Pass `output` for the sinks, e.g. speakers, headphones. 98 | Default: "output" 99 | --volume-max 100 | Maximum volume to which to allow increasing. 101 | Default: "130" 102 | --volume-step 103 | Step size when inc/decrementing volume. 104 | Default: "2" 105 | --node-blacklist [,...] 106 | Nodes to ignore when switching. You can use globs. Don't forget to 107 | quote the string when using globs, to avoid unwanted shell glob 108 | extension. 109 | Default: none 110 | --node-nicknames-from 111 | pactl property to use for node names, unless overridden by 112 | --node-nickname. Its possible values are listed under the 'Properties' 113 | key in the output of `pactl list sinks` and `pactl list sources`. 114 | Default: none 115 | --node-nickname : 116 | Nickname to assign to given node name, taking priority over 117 | --node-nicknames-from. May be given multiple times, and 'name' is 118 | exactly as listed in the output of `pactl list sinks short | cut -f2` 119 | and `pactl list sources short | cut -f2`. 120 | Note that you can also specify a port name for the node with 121 | `/`. 122 | It is also possible to use glob matching to match node and port names. 123 | Exact matches are prioritized. Don't forget to quote the string when 124 | using globs, to avoid unwanted shell glob extension. 125 | Default: none 126 | --listen-timeout-secs 127 | The listen command updates the output as soon as it receives an event 128 | from PulseAudio. However, events are often accompanied by many other 129 | useless ones, which may result in unnecessary consecutive output 130 | updates. This script buffers the following events until a timeout is 131 | reached to avoid this scenario, which lessens the CPU load on events. 132 | However, this may result in noticeable latency when performing many 133 | actions quickly (e.g., updating the volume with the mouse wheel). You 134 | can specify what timeout to use to control the responsiveness, in 135 | seconds. 136 | Default: "0.05" 137 | 138 | Actions: 139 | help display this message and exit 140 | output print the PulseAudio status once 141 | listen listen for changes in PulseAudio to automatically update 142 | this script's output 143 | up, down increase or decrease the default node's volume 144 | mute, unmute mute or unmute the default node's audio 145 | togmute switch between muted and unmuted 146 | next-node switch to the next available node 147 | sync synchronize all the output streams volume to be the same as 148 | the current node's volume 149 | 150 | Author: 151 | Mario Ortiz Manero 152 | More info on GitHub: 153 | https://github.com/marioortizmanero/polybar-pulseaudio-control 154 | ``` 155 | 156 | See the [Module](#module) section for an example, or the [Useful icons](#useful-icons) section for some packs of icons. 157 | 158 | 159 | ## Module 160 | 161 | The example from the screenshot can: 162 | 163 | * Increase and decrease the volume on mousewheel scroll 164 | * Mute the audio on left click 165 | * Switch between devices on mousewheel click 166 | * Open `pavucontrol` on right click 167 | 168 | ```ini 169 | [module/pulseaudio-control-output] 170 | type = custom/script 171 | tail = true 172 | format-underline = ${colors.cyan} 173 | label-padding = 2 174 | label-foreground = ${colors.foreground} 175 | 176 | # Icons mixed from Font Awesome 5 and Material Icons 177 | # You can copy-paste your options for each possible action, which is more 178 | # trouble-free but repetitive, or apply only the relevant ones (for example 179 | # --node-blacklist is only needed for next-node). 180 | exec = pulseaudio-control --icons-volume " , " --icon-muted " " --node-nicknames-from "device.description" --node-nickname "alsa_output.pci-0000_00_1b.0.analog-stereo: Speakers" --node-nickname "alsa_output.usb-Kingston_HyperX_Virtual_Surround_Sound_00000000-00.analog-stereo: Headphones" listen 181 | click-right = exec pavucontrol & 182 | click-left = pulseaudio-control togmute 183 | click-middle = pulseaudio-control --node-blacklist "alsa_output.pci-0000_01_00.1.hdmi-stereo-extra2" next-node 184 | scroll-up = pulseaudio-control --volume-max 130 up 185 | scroll-down = pulseaudio-control --volume-max 130 down 186 | 187 | [module/pulseaudio-control-input] 188 | type = custom/script 189 | tail = true 190 | format-underline = ${colors.cyan} 191 | label-padding = 2 192 | label-foreground = ${colors.foreground} 193 | 194 | # Use --node-blacklist to remove the unwanted PulseAudio .monitor that are child of sinks 195 | exec = pulseaudio-control --node-type input --icons-volume "" --icon-muted "" --node-nickname "alsa_output.pci-0000_0c_00.3.analog-stereo: Webcam" --node-nickname "alsa_output.usb-Kingston_HyperX_Virtual_Surround_Sound_00000000-00.analog-stereo: Headphones" --node-blacklist "*.monitor" listen 196 | click-right = exec pavucontrol & 197 | click-left = pulseaudio-control --node-type input togmute 198 | click-middle = pulseaudio-control --node-type input next-node 199 | scroll-up = pulseaudio-control --node-type input --volume-max 130 up 200 | scroll-down = pulseaudio-control --node-type input --volume-max 130 down 201 | ``` 202 | 203 | ## Useful icons 204 | 205 | Here's a list with some icons from different fonts you can copy-paste. Most have a space afterwards so that the module has a bit of spacing. They may appear bugged on your browser if the font isn't available there. Please add yours if they aren't in the list. 206 | 207 | | Font name | Volumes | Muted | Output icons | Input icons | 208 | | ----------------------------------------------- | :-------------: | :--------------: | :------------------------: | :---------: | 209 | | [FontAwesome](https://fontawesome.com) | `" , , "` | `" "` or `" "` | `" "` or `" "` or `` | `", "` | 210 | | [Material](https://material.io/resources/icons) | `" , , "` | `" "` or `"󰍭 "` | `" "` or `" "` or `" "` | `"󰍬, 󰍮"` | 211 | | Emoji | `"🔈 ,🔉 ,🔊 "` | `"🔇 "` | `"🔈 "` or `"🎧 "` | `"🎙️ "` | 212 | | Emoji v2 | `"🕨 ,🕩 ,🕪 "` | `"🔇 "` | `"🕨 "` or `"🎧 "` | `"🎤 "` | 213 | 214 | Most of these can be used after downloading a [Nerd Font](https://www.nerdfonts.com/) and including it in your [Polybar config](https://github.com/polybar/polybar/wiki/Fonts). For example: 215 | 216 | ```ini 217 | font-X = Font Awesome 5 Free: style=Solid: pixelsize=11 218 | font-Y = Font Awesome 5 Brands: pixelsize=11 219 | font-Z = Material Icons: style=Regular: pixelsize=13; 2 220 | ``` 221 | 222 | ## FAQ 223 | 224 | ### Can I use this with a status bar other than Polybar? 225 | 226 | The only part of this script that's tied to Polybar is the color formatting. 227 | When muted, the dimmed color will probably not work. Please [let us know in this 228 | issue](https://github.com/marioortizmanero/polybar-pulseaudio-control/issues/36) 229 | if you want support for a new status bar. I'd strongly recommend you to open a 230 | PR yourself, as it should be relatively easy! 231 | 232 | ### Does this work with PipeWire? 233 | 234 | Yes! You only need to install the pulseaudio client on your machine. On Arch 235 | Linux, that's 236 | [`pipewire-pulse`](https://archlinux.org/packages/extra/x86_64/pipewire-pulse/), 237 | for example. 238 | 239 | It won't work with other audio servers like JACK, though. 240 | 241 | ### This script uses too much CPU 242 | 243 | We use the `pactl subscribe` command to get notified of new events that may 244 | occur in order to refresh the output. However, the command often outputs *a lot* 245 | of events for a simple action, like increasing the volume. Instead of refreshing 246 | for every single line it prints, we: 247 | 248 | 1. Wait for one event 249 | 2. Update the output first 250 | 3. Continue to listen for events until a timeout ends, or until we reach a large 251 | enough number of them 252 | 4. Update the output again 253 | 5. Go back to step 1 254 | 255 | This way, the first event will update quickly, and the following ones, which are 256 | most likely unnecessary, will be ignored until some time passes. This reduces 257 | the CPU usage, but it's not really perfect, as everyone percieves latency 258 | differently, and it depends on the use-case. 259 | 260 | The timer can be configured with `--listen-timeout-secs`, which has a default 261 | value of `0.05` (50 ms). If you want less CPU usage, i.e., ignore more duplicate 262 | events, you can bump it to, for example, `0.1` (100 ms). Or for faster refreshes 263 | when performing multiple actions quickly, e.g., updating the volume with your 264 | mousewheel, you can even use a smaller value. 265 | 266 | ### This script feels laggy when performing multiple actions quickly 267 | 268 | Please refer to the previous question, as you can fix this by setting a smaller 269 | refresh delay with `--listen-timeout-secs`. 270 | 271 | ## Sources 272 | 273 | Part of the script and of this README's info was taken from [customlinux.blogspot.com](http://customlinux.blogspot.com/2013/02/pavolumesh-control-active-sink-volume.html), the creator. It was later adapted to fit polybar. It is also mixed with [the ArcoLinux version](https://github.com/arcolinux/arcolinux-polybar/blob/master/etc/skel/.config/polybar/scripts/pavolume.sh), which implemented the `listen` action to use less resources. 274 | 275 | ## Development 276 | 277 | Any PRs and issues are welcome! The tests can be ran with `bats tests.bats`, preferably with the Dockerfile in this repository. 278 | -------------------------------------------------------------------------------- /pulseaudio-control: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ################################################################## 4 | # Polybar Pulseaudio Control # 5 | # https://github.com/marioortizmanero/polybar-pulseaudio-control # 6 | ################################################################## 7 | 8 | # Deprecated values, to be removed in a next release. This is kept around to 9 | # be displayed for users using it in custom FORMAT 10 | # shellcheck disable=SC2034 11 | ICON_SINK="Replaced by ICON_NODE, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" 12 | SINK_NICKNAME="Replaced by NODE_NICKNAME, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" 13 | 14 | # Defaults for configurable values, expected to be set by command-line arguments 15 | AUTOSYNC="no" 16 | COLOR_MUTED="%{F#6b6b6b}" 17 | ICON_MUTED= 18 | ICON_NODE= 19 | NODE_TYPE="output" 20 | NOTIFICATIONS="no" 21 | OSD="no" 22 | NODE_NICKNAMES_PROP= 23 | VOLUME_STEP=2 24 | VOLUME_MAX=130 25 | LISTEN_TIMEOUT=0.05 26 | # shellcheck disable=SC2016 27 | FORMAT='$VOL_ICON ${VOL_LEVEL}% $ICON_NODE $NODE_NICKNAME' 28 | declare -A NODE_NICKNAMES 29 | declare -a ICONS_VOLUME 30 | declare -a NODE_BLACKLIST 31 | 32 | # Special variable: within the script, pactl, grep, and awk commands are used 33 | # on sinks or sources, depending on NODE_TYPE. 34 | # 35 | # The commands are almost always the same, except for the sink/source part. 36 | # In order to reduce duplication, this variable is used for commands that behave 37 | # the same, regardless of the NODE_TYPE. 38 | # 39 | # Having only the "radix" (ink/ource) and omitting the first letter enables us 40 | # to use that single variable: 41 | # 42 | # S-ink , s-ink , s-ink -s, S-ink -s 43 | # S-ource, s-ource, s-ource-s, S-ource-s 44 | SINK_OR_SOURCE="ink" 45 | 46 | # Environment & global constants for the script 47 | export LC_ALL=C # Some calls depend on English outputs of pactl 48 | END_COLOR="%{F-}" # For Polybar colors 49 | 50 | 51 | # Saves the currently default node into a variable named `curNode`. It will 52 | # return an error code when pulseaudio isn't running. 53 | function getCurNode() { 54 | if ! pactl info &>/dev/null; then return 1; fi 55 | local curNodeName 56 | 57 | curNodeName=$(pactl info | awk "/Default S${SINK_OR_SOURCE}: / {print \$3}") 58 | curNode=$(pactl list "s${SINK_OR_SOURCE}s" | grep -B 4 -E "Name: $curNodeName\$" | sed -nE "s/^S${SINK_OR_SOURCE} #([0-9]+)$/\1/p") 59 | } 60 | 61 | 62 | # Saves the node passed by parameter's volume into a variable named `VOL_LEVEL`. 63 | function getCurVol() { 64 | VOL_LEVEL=$(pactl list "s${SINK_OR_SOURCE}s" | grep -A 15 -E "^S${SINK_OR_SOURCE} #$1\$" | grep 'Volume:' | grep -E -v 'Base Volume:' | awk -F : '{print $3; exit}' | grep -o -P '.{0,3}%' | sed 's/.$//' | tr -d ' ') 65 | } 66 | 67 | 68 | # Saves the name of the node passed by parameter into a variable named 69 | # `nodeName`. 70 | function getNodeName() { 71 | nodeName=$(pactl list "s${SINK_OR_SOURCE}s" short | awk -v sink="$1" "{ if (\$1 == sink) {print \$2} }") 72 | portName=$(pactl list "s${SINK_OR_SOURCE}s" | grep -e "S${SINK_OR_SOURCE} #" -e 'Active Port: ' | sed -n "/^S${SINK_OR_SOURCE} #$1\$/,+1p" | awk -F ": " '/Active Port: / {print $2}') 73 | } 74 | 75 | 76 | # Saves the name to be displayed for the node passed by parameter into a 77 | # variable called `NODE_NICKNAME`. 78 | # If a mapping for the node name exists, that is used. Otherwise, the string 79 | # "Node #" is used. 80 | function getNickname() { 81 | getNodeName "$1" 82 | unset NODE_NICKNAME 83 | 84 | if [ -n "$nodeName" ] && [ -n "$portName" ] && [ -n "${NODE_NICKNAMES[$nodeName/$portName]}" ]; then 85 | NODE_NICKNAME="${NODE_NICKNAMES[$nodeName/$portName]}" 86 | elif [ -n "$nodeName" ] && [ -n "${NODE_NICKNAMES[$nodeName]}" ]; then 87 | NODE_NICKNAME="${NODE_NICKNAMES[$nodeName]}" 88 | elif [ -n "$nodeName" ]; then 89 | # No exact match could be found, try a Glob Match 90 | for glob in "${!NODE_NICKNAMES[@]}"; do 91 | # shellcheck disable=SC2053 # Disable Shellcheck warning for Glob-Matching 92 | if [[ "$nodeName/$portName" == $glob ]] || [[ "$nodeName" == $glob ]]; then 93 | NODE_NICKNAME="${NODE_NICKNAMES[$glob]}" 94 | # Cache that result for next time 95 | NODE_NICKNAMES["$nodeName"]="$NODE_NICKNAME" 96 | break 97 | fi 98 | done 99 | fi 100 | 101 | if [ -z "$NODE_NICKNAME" ] && [ -n "$nodeName" ] && [ -n "$NODE_NICKNAMES_PROP" ]; then 102 | getNicknameFromProp "$NODE_NICKNAMES_PROP" "$nodeName" 103 | # Cache that result for next time 104 | NODE_NICKNAMES["$nodeName"]="$NODE_NICKNAME" 105 | elif [ -z "$NODE_NICKNAME" ]; then 106 | NODE_NICKNAME="S${SINK_OR_SOURCE} #$1" 107 | fi 108 | } 109 | 110 | # Gets node nickname based on a given property. 111 | function getNicknameFromProp() { 112 | local nickname_prop="$1" 113 | local for_name="$2" 114 | 115 | NODE_NICKNAME= 116 | while read -r property value; do 117 | case "$property" in 118 | Name:) 119 | node_name="$value" 120 | unset node_desc 121 | ;; 122 | "$nickname_prop") 123 | if [ "$node_name" != "$for_name" ]; then 124 | continue 125 | fi 126 | NODE_NICKNAME="${value:3:-1}" 127 | break 128 | ;; 129 | esac 130 | done < <(pactl list "s${SINK_OR_SOURCE}s") 131 | } 132 | 133 | # Saves the status of the node passed by parameter into a variable named 134 | # `IS_MUTED`. 135 | function getIsMuted() { 136 | IS_MUTED=$(pactl list "s${SINK_OR_SOURCE}s" | grep -E "^S${SINK_OR_SOURCE} #$1\$" -A 15 | awk '/Mute: / {print $2}') 137 | } 138 | 139 | 140 | # Saves all the sink inputs of the sink passed by parameter into a string 141 | # named `sinkInputs`. 142 | function getSinkInputs() { 143 | sinkInputs=$(pactl list sink-inputs | grep -B 4 "Sink: $1" | sed -nE "s/^Sink Input #([0-9]+)\$/\1/p") 144 | } 145 | 146 | 147 | # Saves all the source outputs of the source passed by parameter into a string 148 | # named `sourceOutputs`. 149 | function getSourceOutputs() { 150 | sourceOutputs=$(pactl list source-outputs | grep -B 4 "Source: $1" | sed -nE "s/^Source Output #([0-9]+)\$/\1/p") 151 | } 152 | 153 | 154 | function volUp() { 155 | # Obtaining the current volume from pulseaudio into $VOL_LEVEL. 156 | if ! getCurNode; then 157 | echo "PulseAudio not running" 158 | return 1 159 | fi 160 | getCurVol "$curNode" 161 | local maxLimit=$((VOLUME_MAX - VOLUME_STEP)) 162 | 163 | # Checking the volume upper bounds so that if VOLUME_MAX was 100% and the 164 | # increase percentage was 3%, a 99% volume would top at 100% instead 165 | # of 102%. If the volume is above the maximum limit, nothing is done. 166 | if [ "$VOL_LEVEL" -le "$VOLUME_MAX" ] && [ "$VOL_LEVEL" -ge "$maxLimit" ]; then 167 | pactl "set-s${SINK_OR_SOURCE}-volume" "$curNode" "$VOLUME_MAX%" 168 | elif [ "$VOL_LEVEL" -lt "$maxLimit" ]; then 169 | pactl "set-s${SINK_OR_SOURCE}-volume" "$curNode" "+$VOLUME_STEP%" 170 | fi 171 | 172 | if [ $OSD = "yes" ]; then showOSD "$curNode"; fi 173 | if [ $AUTOSYNC = "yes" ]; then volSync; fi 174 | } 175 | 176 | 177 | function volDown() { 178 | # Pactl already handles the volume lower bounds so that negative values 179 | # are ignored. 180 | if ! getCurNode; then 181 | echo "PulseAudio not running" 182 | return 1 183 | fi 184 | pactl "set-s${SINK_OR_SOURCE}-volume" "$curNode" "-$VOLUME_STEP%" 185 | 186 | if [ $OSD = "yes" ]; then showOSD "$curNode"; fi 187 | if [ $AUTOSYNC = "yes" ]; then volSync; fi 188 | } 189 | 190 | 191 | function volSync() { 192 | # This will only be called if $AUTOSYNC is `yes`. 193 | 194 | if ! getCurNode; then 195 | echo "PulseAudio not running" 196 | return 1 197 | fi 198 | 199 | getCurVol "$curNode" 200 | 201 | if [[ "$NODE_TYPE" = "output" ]]; then 202 | getSinkInputs "$curNode" 203 | 204 | # Every output found in the active sink has their volume set to the 205 | # current one. 206 | for each in $sinkInputs; do 207 | pactl "set-sink-input-volume" "$each" "$VOL_LEVEL%" 208 | done 209 | else 210 | getSourceOutputs "$curNode" 211 | 212 | # Every input found in the active source has their volume set to the 213 | # current one. 214 | for each in $sourceOutputs; do 215 | pactl "set-source-output-volume" "$each" "$VOL_LEVEL%" 216 | done 217 | fi 218 | } 219 | 220 | 221 | function volMute() { 222 | # Switch to mute/unmute the volume with pactl. 223 | if ! getCurNode; then 224 | echo "PulseAudio not running" 225 | return 1 226 | fi 227 | if [ "$1" = "toggle" ]; then 228 | getIsMuted "$curNode" 229 | if [ "$IS_MUTED" = "yes" ]; then 230 | pactl "set-s${SINK_OR_SOURCE}-mute" "$curNode" "no" 231 | else 232 | pactl "set-s${SINK_OR_SOURCE}-mute" "$curNode" "yes" 233 | fi 234 | elif [ "$1" = "mute" ]; then 235 | pactl "set-s${SINK_OR_SOURCE}-mute" "$curNode" "yes" 236 | elif [ "$1" = "unmute" ]; then 237 | pactl "set-s${SINK_OR_SOURCE}-mute" "$curNode" "no" 238 | fi 239 | 240 | if [ $OSD = "yes" ]; then showOSD "$curNode"; fi 241 | } 242 | 243 | 244 | function nextNode() { 245 | # The final nodes list, removing the blacklisted ones from the list of 246 | # currently available nodes. 247 | if ! getCurNode; then 248 | echo "PulseAudio not running" 249 | return 1 250 | fi 251 | 252 | # Obtaining a tuple of node indexes after removing the blacklisted devices 253 | # with their name. 254 | nodes=() 255 | local i=0 256 | while read -r line; do 257 | index=$(echo "$line" | cut -f1) 258 | name=$(echo "$line" | cut -f2) 259 | 260 | # If it's in the blacklist, continue the main loop. Otherwise, add 261 | # it to the list. 262 | for node in "${NODE_BLACKLIST[@]}"; do 263 | # shellcheck disable=SC2053 # Disable Shellcheck warning for Glob-Matching 264 | if [[ "$name" == $node ]]; then 265 | continue 2 266 | fi 267 | done 268 | 269 | nodes[i]="$index" 270 | i=$((i + 1)) 271 | done < <(pactl list short "s${SINK_OR_SOURCE}s" | sort -n) 272 | 273 | # If the resulting list is empty, nothing is done 274 | if [ ${#nodes[@]} -eq 0 ]; then return; fi 275 | 276 | # If the current node is greater or equal than last one, pick the first 277 | # node in the list. Otherwise just pick the next node avaliable. 278 | local newNode 279 | if [ "$curNode" -ge "${nodes[-1]}" ]; then 280 | newNode=${nodes[0]} 281 | else 282 | for node in "${nodes[@]}"; do 283 | if [ "$curNode" -lt "$node" ]; then 284 | newNode=$node 285 | break 286 | fi 287 | done 288 | fi 289 | 290 | # The new node is set 291 | pactl "set-default-s${SINK_OR_SOURCE}" "$newNode" 292 | 293 | # Move all audio threads to new node 294 | local inputs 295 | 296 | if [[ "$NODE_TYPE" = "output" ]]; then 297 | inputs="$(pactl list short sink-inputs | cut -f 1)" 298 | for i in $inputs; do 299 | pactl move-sink-input "$i" "$newNode" 300 | done 301 | else 302 | outputs="$(pactl list short source-outputs | cut -f 1)" 303 | for i in $outputs; do 304 | pactl move-source-output "$i" "$newNode" 305 | done 306 | fi 307 | 308 | if [ $NOTIFICATIONS = "yes" ]; then 309 | getNickname "$newNode" 310 | 311 | if command -v dunstify &>/dev/null; then 312 | notify="dunstify --replace 201839192" 313 | else 314 | notify="notify-send" 315 | fi 316 | $notify "PulseAudio" "Changed $NODE_TYPE to $NODE_NICKNAME" --icon=audio-headphones-symbolic & 317 | fi 318 | } 319 | 320 | 321 | # This function assumes that PulseAudio is already running. It only supports 322 | # KDE OSDs for now. It will show a system message with the status of the 323 | # node passed by parameter, or the currently active one by default. 324 | function showOSD() { 325 | if [ -z "$1" ]; then 326 | curNode="$1" 327 | else 328 | getCurNode 329 | fi 330 | getCurVol "$curNode" 331 | getIsMuted "$curNode" 332 | qdbus org.kde.kded /modules/kosd showVolume "$VOL_LEVEL" "$IS_MUTED" 333 | } 334 | 335 | 336 | function listen() { 337 | # If this is the first time start by printing the current state. Otherwise, 338 | # directly wait for events. This is to prevent the module being empty until 339 | # an event occurs. 340 | output 341 | 342 | # Listen for changes and immediately create new output for the bar. 343 | # This is faster than having the script on an interval. 344 | pactl subscribe 2>/dev/null | grep --line-buffered -e "on \(card\|s${SINK_OR_SOURCE}\|server\)" | { 345 | while read -r; do 346 | # Output the new state 347 | output 348 | 349 | # Read all stdin to flush unwanted pending events, i.e. if there are 350 | # 15 events at the same time (100ms window), output is only called 351 | # twice. 352 | read -r -d '' -t "$LISTEN_TIMEOUT" -n 10000 353 | 354 | # After the 100ms waiting time, output again the state, as it may 355 | # have changed if the user did an action during the 100ms window. 356 | output 357 | done 358 | } 359 | } 360 | 361 | 362 | function output() { 363 | if ! getCurNode; then 364 | echo "PulseAudio not running" 365 | return 1 366 | fi 367 | getCurVol "$curNode" 368 | getIsMuted "$curNode" 369 | 370 | # Fixed volume icons over max volume 371 | local iconsLen=${#ICONS_VOLUME[@]} 372 | if [ "$iconsLen" -ne 0 ]; then 373 | local volSplit=$((VOLUME_MAX / iconsLen)) 374 | for i in $(seq 1 "$iconsLen"); do 375 | if [ $((i * volSplit)) -ge "$VOL_LEVEL" ]; then 376 | VOL_ICON="${ICONS_VOLUME[$((i-1))]}" 377 | break 378 | fi 379 | done 380 | else 381 | VOL_ICON="" 382 | fi 383 | 384 | getNickname "$curNode" 385 | 386 | # Showing the formatted message 387 | if [ "$IS_MUTED" = "yes" ]; then 388 | # shellcheck disable=SC2034 389 | VOL_ICON=$ICON_MUTED 390 | content="$(eval echo "$FORMAT")" 391 | if [ -n "$COLOR_MUTED" ]; then 392 | echo "${COLOR_MUTED}${content}${END_COLOR}" 393 | else 394 | echo "$content" 395 | fi 396 | else 397 | eval echo "$FORMAT" 398 | fi 399 | } 400 | 401 | 402 | function usage() { 403 | echo "\ 404 | Usage: $0 [OPTION...] ACTION 405 | 406 | Terminology: A node represents either a sink (output) or source (input). 407 | 408 | Options: 409 | --autosync | --no-autosync 410 | Whether to maintain same volume for all programs. 411 | Default: \"$AUTOSYNC\" 412 | --color-muted 413 | Color in which to format when muted. 414 | Pass empty string to disable. 415 | Default: \"${COLOR_MUTED:4:-1}\" 416 | --notifications | --no-notifications 417 | Whether to show notifications when changing nodes. 418 | Default: \"$NOTIFICATIONS\" 419 | --osd | --no-osd 420 | Whether to display KDE's OSD message. 421 | Default: \"$OSD\" 422 | --icon-muted 423 | Icon to use when muted. 424 | Default: none 425 | --icon-node 426 | Icon to use for node. 427 | Default: none 428 | --format 429 | Use a format string to control the output. 430 | Remember to pass this argument wrapped in single quotes (\`'\`) instead 431 | of double quotes (\`\"\`) to avoid your shell from evaluating the 432 | variables early. 433 | Available variables: 434 | * \$VOL_ICON 435 | * \$VOL_LEVEL 436 | * \$ICON_NODE 437 | * \$NODE_NICKNAME 438 | * \$IS_MUTED (yes/no) 439 | Default: '$FORMAT' 440 | --icons-volume [,...] 441 | Icons for volume, from lower to higher. 442 | Default: none 443 | --node-type 444 | Whether to consider PulseAudio sinks (output) or sources (input). 445 | All the operations of pulseaudio-control will apply to one of the two. 446 | Pass \`input\` for the sources, e.g. a microphone. 447 | Pass \`output\` for the sinks, e.g. speakers, headphones. 448 | Default: \"$NODE_TYPE\" 449 | --volume-max 450 | Maximum volume to which to allow increasing. 451 | Default: \"$VOLUME_MAX\" 452 | --volume-step 453 | Step size when inc/decrementing volume. 454 | Default: \"$VOLUME_STEP\" 455 | --node-blacklist [,...] 456 | Nodes to ignore when switching. You can use globs. Don't forget to 457 | quote the string when using globs, to avoid unwanted shell glob 458 | extension. 459 | Default: none 460 | --node-nicknames-from 461 | pactl property to use for node names, unless overridden by 462 | --node-nickname. Its possible values are listed under the 'Properties' 463 | key in the output of \`pactl list sinks\` and \`pactl list sources\`. 464 | Default: none 465 | --node-nickname : 466 | Nickname to assign to given node name, taking priority over 467 | --node-nicknames-from. May be given multiple times, and 'name' is 468 | exactly as listed in the output of \`pactl list sinks short | cut -f2\` 469 | and \`pactl list sources short | cut -f2\`. 470 | Note that you can also specify a port name for the node with 471 | \`/\`. 472 | It is also possible to use glob matching to match node and port names. 473 | Exact matches are prioritized. Don't forget to quote the string when 474 | using globs, to avoid unwanted shell glob extension. 475 | Default: none 476 | --listen-timeout-secs 477 | The listen command updates the output as soon as it receives an event 478 | from PulseAudio. However, events are often accompanied by many other 479 | useless ones, which may result in unnecessary consecutive output 480 | updates. This script buffers the following events until a timeout is 481 | reached to avoid this scenario, which lessens the CPU load on events. 482 | However, this may result in noticeable latency when performing many 483 | actions quickly (e.g., updating the volume with the mouse wheel). You 484 | can specify what timeout to use to control the responsiveness, in 485 | seconds. 486 | Default: \"$LISTEN_TIMEOUT\" 487 | 488 | Actions: 489 | help display this message and exit 490 | output print the PulseAudio status once 491 | listen listen for changes in PulseAudio to automatically update 492 | this script's output 493 | up, down increase or decrease the default node's volume 494 | mute, unmute mute or unmute the default node's audio 495 | togmute switch between muted and unmuted 496 | next-node switch to the next available node 497 | sync synchronize all the output streams volume to be the same as 498 | the current node's volume 499 | 500 | Author: 501 | Mario Ortiz Manero 502 | More info on GitHub: 503 | https://github.com/marioortizmanero/polybar-pulseaudio-control" 504 | } 505 | 506 | # Obtains the value for an option and returns 1 if no shift is needed. 507 | function getOptVal() { 508 | if [[ "$1" = *=* ]]; then 509 | val="${1//*=/}" 510 | return 1 511 | fi 512 | 513 | val="$2" 514 | } 515 | 516 | # Parsing the options from the arguments 517 | while [[ "$1" = --* ]]; do 518 | unset arg 519 | unset val 520 | 521 | arg="$1" 522 | case "$arg" in 523 | --autosync) 524 | AUTOSYNC=yes 525 | ;; 526 | --no-autosync) 527 | AUTOSYNC=no 528 | ;; 529 | --color-muted|--colour-muted) 530 | if getOptVal "$@"; then shift; fi 531 | if [ -n "$val" ]; then 532 | COLOR_MUTED="%{F#$val}" 533 | else 534 | COLOR_MUTED="" 535 | fi 536 | ;; 537 | --notifications) 538 | NOTIFICATIONS=yes 539 | ;; 540 | --no-notifications) 541 | NOTIFICATIONS=no 542 | ;; 543 | --osd) 544 | OSD=yes 545 | ;; 546 | --no-osd) 547 | OSD=no 548 | ;; 549 | --icon-muted) 550 | if getOptVal "$@"; then shift; fi 551 | ICON_MUTED="$val" 552 | ;; 553 | --icon-node) 554 | if getOptVal "$@"; then shift; fi 555 | # shellcheck disable=SC2034 556 | ICON_NODE="$val" 557 | ;; 558 | --icons-volume) 559 | if getOptVal "$@"; then shift; fi 560 | IFS=, read -r -a ICONS_VOLUME <<< "${val//[[:space:]]/}" 561 | ;; 562 | --volume-max) 563 | if getOptVal "$@"; then shift; fi 564 | VOLUME_MAX="$val" 565 | ;; 566 | --volume-step) 567 | if getOptVal "$@"; then shift; fi 568 | VOLUME_STEP="$val" 569 | ;; 570 | --node-blacklist) 571 | if getOptVal "$@"; then shift; fi 572 | IFS=, read -r -a NODE_BLACKLIST <<< "${val//[[:space:]]/}" 573 | ;; 574 | --node-nicknames-from) 575 | if getOptVal "$@"; then shift; fi 576 | NODE_NICKNAMES_PROP="$val" 577 | ;; 578 | --node-nickname) 579 | if getOptVal "$@"; then shift; fi 580 | NODE_NICKNAMES["${val//:*/}"]="${val//*:}" 581 | ;; 582 | --format) 583 | if getOptVal "$@"; then shift; fi 584 | FORMAT="$val" 585 | ;; 586 | --node-type) 587 | if getOptVal "$@"; then shift; fi 588 | if [[ "$val" != "output" && "$val" != "input" ]]; then 589 | echo "node-type must be 'output' or 'input', got '$val'" >&2 590 | exit 1 591 | fi 592 | NODE_TYPE="$val" 593 | SINK_OR_SOURCE=$([ "$NODE_TYPE" == "output" ] && echo "ink" || echo "ource") 594 | ;; 595 | --listen-timeout-secs) 596 | if getOptVal "$@"; then shift; fi 597 | LISTEN_TIMEOUT="$val" 598 | ;; 599 | # Deprecated options, to be removed in a next release 600 | --icon-sink) 601 | echo "Replaced by --icon-node, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" >&2 602 | exit 1 603 | ;; 604 | --sink-blacklist) 605 | echo "Replaced by --node-blacklist, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" >&2 606 | exit 1 607 | ;; 608 | --sink-nicknames-from) 609 | echo "Replaced by --node-nicknames-from, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" >&2 610 | exit 1 611 | ;; 612 | --sink-nickname) 613 | echo "Replaced by --node-nickname, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" >&2 614 | exit 1 615 | ;; 616 | # Undocumented because the `help` action already exists, but makes the 617 | # help message more accessible. 618 | --help) 619 | usage 620 | exit 0 621 | ;; 622 | *) 623 | echo "Unrecognised option: $arg" >&2 624 | exit 1 625 | ;; 626 | esac 627 | shift 628 | done 629 | 630 | # Parsing the action from the arguments 631 | case "$1" in 632 | up) 633 | volUp 634 | ;; 635 | down) 636 | volDown 637 | ;; 638 | togmute) 639 | volMute toggle 640 | ;; 641 | mute) 642 | volMute mute 643 | ;; 644 | unmute) 645 | volMute unmute 646 | ;; 647 | sync) 648 | volSync 649 | ;; 650 | listen) 651 | listen 652 | ;; 653 | next-node) 654 | nextNode 655 | ;; 656 | output) 657 | output 658 | ;; 659 | help) 660 | usage 661 | ;; 662 | # Deprecated action, to be removed in a next release 663 | next-sink) 664 | echo "Replaced by next-node, see https://github.com/marioortizmanero/polybar-pulseaudio-control/releases/tag/v3.0.0" >&2 665 | exit 1 666 | ;; 667 | "") 668 | echo "No action specified. Run \`$0 help\` for more information." >&2 669 | ;; 670 | *) 671 | echo "Unrecognised action: $1" >&2 672 | exit 1 673 | ;; 674 | esac 675 | -------------------------------------------------------------------------------- /screenshots/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marioortizmanero/polybar-pulseaudio-control/ed03a1b85dd0e92f85bc7446b78e010b36be4606/screenshots/example.png -------------------------------------------------------------------------------- /tests.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | # vim: filetype=sh 3 | # 4 | # Polybar PulseAudio Control - tests.bats 5 | # 6 | # Simple test script to make sure the most basic functions in this script 7 | # always work as intended. The tests will modify the system's PulseAudio 8 | # setup until it's restarted, so either do that after running the test, or 9 | # launch the tests inside a container (see the Dockerfile in the main 10 | # repository). 11 | # 12 | # The tests can be run with BATS. See the README.md for more info. 13 | 14 | function restartPulseaudio() { 15 | if pulseaudio --check; then 16 | echo "Killing PulseAudio" 17 | killall pulseaudio 18 | fi 19 | 20 | # Starting and killing PulseAudio is performed asynchronously, so this 21 | # makes sure the requested state is real. 22 | while pgrep pulseaudio &>/dev/null; do :; done 23 | echo "Starting PulseAudio" 24 | pulseaudio --start -D 25 | while ! pgrep pulseaudio &>/dev/null; do :; done 26 | } 27 | 28 | 29 | # Loading the script and starting pulseaudio if it isn't already 30 | function setup() { 31 | restartPulseaudio 32 | echo "Loading script" 33 | source pulseaudio-control output &>/dev/null 34 | } 35 | 36 | 37 | @test "nextNode()" { 38 | # This test will only work if there is currently only one sink. It's 39 | # kind of hardcoded to avoid excessive complexity. 40 | numSinks=$(pactl list short sinks | wc -l) 41 | if [ "$numSinks" -ne 1 ]; then 42 | skip 43 | fi 44 | 45 | # Testing sink swapping with 8 sinks 46 | for i in {1..15}; do 47 | pactl load-module module-null-sink sink_name="null-sink-$i" 48 | done 49 | pactl set-default-sink 1 50 | 51 | # The blacklist has valid and invalid sinks. The switching will be in 52 | # the same order as the array. 53 | # This test assumes that `getCurNode` works properly, and tries it 50 54 | # times. The sink with ID zero must always be ignored, because it's 55 | # reserved to special sinks, and it might cause issues in the test. 56 | NODE_BLACKLIST=( 57 | "null-sink-0" 58 | "null-sink-8" 59 | "null-sink-4" 60 | "null-sink-2" 61 | "null-sink-2" 62 | "null-sink-doesntexist" 63 | "null-sink-300" 64 | "null-sink-noexist" 65 | "null-sink-13" 66 | "null-sink-10" 67 | ) 68 | local order=(1 3 5 6 7 9 11 12 14 15) 69 | for i in {1..50}; do 70 | nextNode 71 | getCurNode 72 | echo "Real sink is $curNode, expected ${order[$((i % ${#order[@]}))]} at iteration $i" 73 | [ "$curNode" -eq "${order[$((i % ${#order[@]}))]}" ] 74 | done 75 | } 76 | 77 | 78 | @test "volUp()" { 79 | # Increases the volume from zero to a set maximum step by step, making 80 | # sure that the results are expected. 81 | VOLUME_MAX=350 82 | VOLUME_STEP=5 83 | local vol=0 84 | getCurNode 85 | pactl set-sink-volume "$curNode" "$vol%" 86 | for i in {1..100}; do 87 | volUp 88 | getCurVol "$curNode" 89 | if [ "$vol" -lt $VOLUME_MAX ]; then 90 | vol=$((vol + VOLUME_STEP)) 91 | fi 92 | echo "Real volume is $VOL_LEVEL, expected $vol" 93 | [ "$VOL_LEVEL" -eq $vol ] 94 | done 95 | } 96 | 97 | 98 | @test "volDown()" { 99 | # Decreases the volume to 0 step by step, making sure that the results 100 | # are expected. 101 | VOLUME_MAX=350 102 | VOLUME_STEP=5 103 | # It shouldn't matter that the current volume exceeds the maximum volume 104 | local vol=375 105 | getCurNode 106 | pactl set-sink-volume "$curNode" "$vol%" 107 | for i in {1..100}; do 108 | volDown 109 | getCurVol "$curNode" 110 | if [ "$vol" -gt 0 ]; then 111 | vol=$((vol - VOLUME_STEP)) 112 | fi 113 | echo "Real volume is $VOL_LEVEL, expected $vol" 114 | [ "$VOL_LEVEL" -eq $vol ] 115 | done 116 | } 117 | 118 | 119 | @test "volMute()" { 120 | # Very simple tests to make sure that volume muting works. The sink starts 121 | # muted, and its state is changed to check that the function doesn't fail. 122 | # First of all, the toggle mode is tested. 123 | getCurNode 124 | local expected="no" 125 | pactl set-sink-mute "$curNode" "$expected" 126 | for i in {1..50}; do 127 | volMute toggle 128 | getIsMuted "$curNode" 129 | if [ "$expected" = "no" ]; then expected="yes"; else expected="no"; fi 130 | echo "Real status is '$IS_MUTED', expected '$expected'" 131 | [ "$IS_MUTED" = "$expected" ] 132 | done 133 | 134 | # Testing that muting once or more results in a muted sink 135 | volMute mute 136 | getIsMuted "$curNode" 137 | [ "$IS_MUTED" = "yes" ] 138 | volMute mute 139 | getIsMuted "$curNode" 140 | [ "$IS_MUTED" = "yes" ] 141 | volMute mute 142 | getIsMuted "$curNode" 143 | [ "$IS_MUTED" = "yes" ] 144 | 145 | # Same for unmuting 146 | volMute unmute 147 | getIsMuted "$curNode" 148 | [ "$IS_MUTED" = "no" ] 149 | volMute unmute 150 | getIsMuted "$curNode" 151 | [ "$IS_MUTED" = "no" ] 152 | volMute unmute 153 | getIsMuted "$curNode" 154 | [ "$IS_MUTED" = "no" ] 155 | } 156 | 157 | 158 | @test "getNickname()" { 159 | # The already existing sinks will be ignored. 160 | offset=$(pactl list short sinks | wc -l) 161 | 162 | # Testing sink nicknames with 10 null sinks. Only a few of them will 163 | # have a name in the nickname map. 164 | for i in {0..9}; do 165 | pactl load-module module-null-sink sink_name="null-sink-$i" 166 | done 167 | 168 | unset NODE_NICKNAMES 169 | declare -A NODE_NICKNAMES 170 | # Checking with an empty map. 171 | for i in {0..9}; do 172 | getNickname "$((i + offset))" 173 | [ "$NODE_NICKNAME" = "Sink #$((i + offset))" ] 174 | done 175 | 176 | # Populating part of the map. 177 | NODE_NICKNAMES["does-not-exist"]="null" 178 | for i in {0..4}; do 179 | NODE_NICKNAMES["null-sink-$i"]="Null Sink $i" 180 | getNickname "$((i + offset))" 181 | [ "$NODE_NICKNAME" = "Null Sink $i" ] 182 | done 183 | for i in {5..9}; do 184 | getNickname "$((i + offset))" 185 | [ "$NODE_NICKNAME" = "Sink #$((i + offset))" ] 186 | done 187 | 188 | # Testing empty $nodeName. 189 | # Observed to happen when a sink is removed (e.g. Bluetooth disconnect) 190 | # possibly only with unlucky timing of when `getNodeName` runs. cf. #41 191 | function getNodeName() { 192 | nodeName='' 193 | } 194 | getNickname "$((10 + offset))" # beyond what exists 195 | [ "$NODE_NICKNAME" = "Sink #$((10 + offset))" ] 196 | } 197 | --------------------------------------------------------------------------------