#!/bin/bash

# Copyright 2020-2022 eomanis
#
# This file is part of pulse-autoconf.
#
# pulse-autoconf is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3 as
# published by the Free Software Foundation.
#
# pulse-autoconf is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pulse-autoconf.  If not, see <http://www.gnu.org/licenses/>.

# TODO Boolean option whether to move streams that are using the
#      fallback devices on startup to the new fallbacks
# TODO Stop abusing the cookie as PulseAudio server instance ID, this
#      might not work if the server is running with
#      module-native-protocol-unix option "auth-cookie-enabled=0"
# TODO Implement trigger mechanism to replace periodic polling of the
#      PulseAudio server; maybe research into "pactl subscribe"
# TODO Manual page
# TODO Preset "EchoCancellationPlacebo": Instead of remapping dummy
#      devices, directly create the dummy devices as "sink_main" or
#      "source_main"
# TODO Echo cancellation master finding: Implement prefix "function"
#      that retrieves the master from a user-defined bash function
# TODO Persist the most-recently-run version of pulse-autoconf for
#      more precise automatic forward-migration of configuration files,
#      maybe in ~/.cache/pulse-autoconf/previous-version.conf
# TODO Add functionality that attempts to keep the volume of a sink or
#      source at a pre-defined level, to combat dumbass Chromium-based
#      applications that cannot be taught to leave the microphone's
#      recording volume alone, possibly also dependent on the current
#      preset
#      May also be used to always set the volume of sink_main of preset
#      EchoCancellationWithSourcesMix to 100%
# TODO Automatic virtual device blacklisting for echo cancellation
#      device finding

set -o nounset
set -o noclobber
set -o errexit
shopt -qs inherit_errexit

# Semantic versioning
declare -r versionMajor=1
declare -r versionMinor=10
declare -r versionPatch=2
declare -r versionLabel=""

# Prints the version string to STDOUT
getVersion () {

	echo -n "${versionMajor}.${versionMinor}.${versionPatch}"
	test "$versionLabel" != "" && echo "-$versionLabel" || echo ""
}

# printMsgError message
#
# Prints the given message to STDERR, prefixed with "ERROR  "
printMsgError () {
	echo "ERROR " "$@" >&2
}

# printMsgWarning message
#
# Prints the given message to STDERR, prefixed with " WARN  "
printMsgWarning () {
	echo " WARN " "$@" >&2
}

# printMsgInfo message
#
# Prints the given message to STDERR, prefixed with " INFO  "
printMsgInfo () {
	echo " INFO " "$@" >&2
}

# printMsgDebug message
#
# If $verbose is "true", prints the given message to STDERR, prefixed with
# "DEBUG  "
# If $verbose is anything else, does nothing
printMsgDebug () {
	if test "$verbose" = 'true'; then echo "DEBUG " "$@" >&2; else true; fi
}

# Prints some concise usage information to STDOUT
printUsageInfo () {

	echo -n \
"Usage:
  pulse-autoconf
  pulse-autoconf --help
  pulse-autoconf --version
  pulse-autoconf edit-config [customEditor] [customEditorArgument]...
  pulse-autoconf set-preset preset|-
  pulse-autoconf reload-config [graceful]
  pulse-autoconf wake-up [graceful]
  pulse-autoconf send-signal signal [graceful]
  pulse-autoconf list-sinks-and-sources [showMonitors] [sleepTime]
  pulse-autoconf interactive-loopback source sink [sink]...
"
}

# Prints a short help message / summary to STDOUT
printHelpMessage () {

	echo -n "pulse-autoconf "; getVersion
	echo "PulseAudio server dynamic configuration daemon"
	echo ""
	printUsageInfo
	echo ""
	echo -n \
"Monitors a running PulseAudio server instance and ensures that a
certain configuration is in place.

For example, makes sure that echo cancellation is always active between
a dynamically determined master sink and master source, and that the
virtual echo cancellation devices are set as fallback sink/source.
"
}

# isCommandType type command
#
# Returns with code 0 if the given command exists and is of the given type
# Types are, for example
#  - file     (regular executable)
#  - builtin  (shell builtin)
#  - function (shell function)
isCommandType () {
	local commandType="$1"; shift
	local command="$1"; shift
	local commandTypeActual

	if ! commandTypeActual="$(type -t "$command")"; then return 1; fi
	if ! test "$commandTypeActual" = "$commandType"; then return 1; fi
	return 0
}

# Returns the paths to all existing valid system-level configuration
# files, in ascending order of priority
#
# The paths are returned in a newline-separated list
getConfigurationFilesSystem () {
	getConfigurationFiles "/usr/lib" "/etc" "/run"
}

# Returns the paths to all existing valid user-level configuration
# files, in ascending order of priority
#
# The paths are returned in a newline-separated list
getConfigurationFilesUser () {
	getConfigurationFiles ~/".config"
}

# getConfigurationFiles baseDir [otherBaseDir...]
#
# Returns the paths to all existing valid configuration files, in
# ascending order of priority, in the given directories
#
# The paths are returned in a newline-separated list
# The directories must be given without trailing slash
getConfigurationFiles () {
	local suffix=".conf"
	local pathPrefix
	local configDir
	local configFile
	local baseDir

	while test $# -gt 0; do
		baseDir="$1"; shift
		pathPrefix="${baseDir}/pulse-autoconf/pulse-autoconf"
		configDir="${pathPrefix}.d"
		configFile="${pathPrefix}$suffix"

		if test -d "$configDir"; then
			find "$configDir" -mindepth 1 -maxdepth 1 -type f -name "*$suffix" -print | sort
		fi
		if test -f "$configFile"; then
			echo "$configFile"
		fi
	done
}

# printTemplateConfigurationFile
#
# Prints an initial configuration file to STDOUT
printTemplateConfigurationFile () {

	echo "# Configuration file for pulse-autoconf
# ======================================================================

# Created by pulse-autoconf $(getVersion)

"
	# shellcheck disable=SC2028  # echo won't expand escape sequences such as
	# \t or \n
	# No expansion should be done on this multi-line string whatsoever
	# It does not contain \t or \n
	# It does contain double-backslashes, and they are meant to be printed
	# as-is
	echo -n '# Options for action "edit-config"
# ----------------------------------------------------------------------

# Action "edit-config": The default text editor executable to use, with
# arguments
#editorCustomWithArgs=(gedit --)


# Preset selection
# ----------------------------------------------------------------------

# The desired preset, i.e. the configuration that should be maintained
# in the PulseAudio server
# Uncomment the preset you wish to use
# Be aware that if you use the "set-preset" action then you need not
# bother uncommenting a preset here because "set-preset" takes
# precedence
# Default: "EchoCancellation"
#preset="EchoCancellation"
#preset="EchoCancellationWithSourcesMix"
#preset="EchoCancellationPlacebo"
#preset="None"


# Echo cancellation options
# ----------------------------------------------------------------------

# The parameters that should be used for module-echo-cancel
#ecParams=()
#ecParams+=(aec_method=webrtc)
#ecParams+=(use_master_format=1)
#ecParams+=(aec_args="analog_gain_control=0\\ digital_gain_control=1\\ experimental_agc=1\\ noise_suppression=1\\ voice_detection=1\\ extended_filter=1")
# Uncomment this line if the virtual echo cancellation devices use a
# lower sample rate than your audio hardware, e.g. only 32000 Hz instead
# of 44100 Hz or 48000 Hz:
#ecParams+=(rate=48000)

# Echo cancellation master finding: Patterns for device names in
# descending order of priority
# Patterns have the format "prefix:string"
# Available pattern prefixes:
#   "exact"         - Name is this exact string
#   "notexact"      - Name is not this exact string
#   "startswith"    - Name starts with this string
#   "notstartswith" - Name does not start with this string
#   "endswith"      - Name ends with this string
#   "notendswith"   - Name does not end with this string
#   "grep"          - Name matches "grep --regexp string"
#   "notgrep"       - Name matches "grep --invert-match --regexp string"
#   "egrep"         - Name matches "grep --extended-regexp --regexp string"
#   "notegrep"      - Name matches "grep --extended-regexp --invert-match --regexp string"
# To match any device you can use "startswith:"
#ecSinkMasters=()
#ecSinkMasters+=("startswith:") # Any sink
#ecSourceMasters=()
#ecSourceMasters+=("notendswith:.monitor") # Exclude monitor sources
# Example for a pattern that matches a source device having "Webcam"
# or "webcam" in its name:
#ecSourceMasters+=("grep:[Ww]ebcam")

# Echo cancellation master finding: Whether to prefer newer devices over
# older devices
# If true, newer devices are tested for a pattern match before older
# devices, i.e. a newly plugged-in device that matches a pattern
# replaces an existing echo cancellation master
# If false, older devices are tested before newer devices, i.e. an
# existing device is kept as master even if an eligible new device is
# plugged in
# Default: false
#ecSinkMastersPreferNewer=true
#ecSourceMastersPreferNewer=true


# Loopback device options
# ----------------------------------------------------------------------

# The parameters that should be used for module-loopback
# These are the defaults
#loopbackParams=()
#loopbackParams+=(latency_msec=60)
#loopbackParams+=(max_latency_msec=100)
#loopbackParams+=(adjust_time=6)


# Workarounds to apply after suspend-resume
# ----------------------------------------------------------------------

# Suspend-resume appears to sometimes impair PulseAudio functionality
# Issues that have been observed after resume:
#  - Vastly increased latency of loopbacks
#  - Reduced efficacy of echo cancellation
# By default, after resume pulse-autoconf unloads and re-applies all
# loopbacks of the current preset, which takes care of the first issue
# If you observe the second issue or both of them, uncomment this
# method, which instead causes the current preset to be unloaded
# completely and then re-applied:
#handleResume () {
#	handleRequestReloadPreset
#}


# Other options
# ----------------------------------------------------------------------

# Uncomment for verbose output
#verbose=true

# For further options have a look at this function in the source code of
# pulse-autoconf:
# setDefaultSettings () {
#     (...)
# }
'
}

# Sources all existing configuration files, also re-populates the
# $configFilesMonitored associative array
loadSettingsFromConfigFiles () {
	local -i exitCode
	local configFile

	reloadConfig=false

	# Source the system-level configuration files
	while read -rs configFile; do
		printMsgInfo "Sourcing configuration file \"$configFile\""
		# shellcheck source=/dev/null
		source -- "$configFile" && exitCode=$? || exitCode=$?
		if test "$exitCode" -ne 0; then
			printMsgError "Could not source configuration file \"$configFile\""
			exit "$exitCode"
		fi
	done < <(getConfigurationFilesSystem)

	# Source the user-level configuration files (these are monitored for
	# changes)
	# Set all entries in the "monitored configuration files" map to
	# "File not found"
	for configFile in "${!configFilesMonitored[@]}"; do
		configFilesMonitored["$configFile"]=""
	done
	while read -rs configFile; do
		printMsgInfo "Sourcing configuration file \"$configFile\""
		# shellcheck source=/dev/null
		source -- "$configFile" && exitCode=$? || exitCode=$?
		if test "$exitCode" -ne 0; then
			printMsgError "Could not source configuration file \"$configFile\""
			exit "$exitCode"
		fi
		configFilesMonitored["$configFile"]="$(getFileStatus "$configFile")"
	done < <(getConfigurationFilesUser)
}

# getFileStatus pathToFile
#
# Prints a status string for the given file composed of the file's size
# in bytes and its modification time in seconds since Epoch, separated
# by a single blank
# Prints nothing if the file does not exist
getFileStatus () {
	local file="$1"; shift
	local fileSizeBytes
	local fileModTime

	if test -e "$file"; then
		if ! fileSizeBytes="$(getFileSizeBytes "$file")" 2> /dev/null; then
			fileSizeBytes=""
		fi
		if ! fileModTime="$(getFileModTimeSecsSinceEpoch "$file")" 2> /dev/null; then
			fileModTime=""
		fi
		echo "$fileSizeBytes $fileModTime"
	fi
}

# getFileSizeBytes pathToFile
#
# Prints the given file's size, in bytes
getFileSizeBytes () {
	LC_ALL=C stat -c '%s' "$1"
}

# getFileModTimeSecsSinceEpoch pathToFile
#
# Prints the given file's modification time, in seconds since Epoch,
# with greatest possible decimal precision, using the dot "." as decimal
# separator
getFileModTimeSecsSinceEpoch () {

	# Force locale "C" to make stat use the dot as decimal separator
	LC_ALL=C stat -c '%.Y' "$1"
}

# Validates the global settings; exits with code 1 if pulse-autoconf
# cannot run with the current settings
validateSettings () {

	if ! test "$verbose" = true && ! test "$verbose" = false; then
		printMsgWarning "\$verbose must be either \"true\" or \"false\", was \"$verbose\", using \"false\" instead"
		verbose=false
	fi

	if ! test "$ecUseDummySource" = true && ! test "$ecUseDummySource" = false; then
		printMsgWarning "\$ecUseDummySource must be either \"true\" or \"false\", was \"$ecUseDummySource\", using \"true\" instead"
		ecUseDummySource=true
	fi

	if ! test "$ecUseDummySink" = true && ! test "$ecUseDummySink" = false; then
		printMsgWarning "\$ecUseDummySink must be either \"true\" or \"false\", was \"$ecUseDummySink\", using \"true\" instead"
		ecUseDummySink=true
	fi

	if ! test "$ecSinkMastersPreferNewer" = true && ! test "$ecSinkMastersPreferNewer" = false; then
		printMsgWarning "\$ecSinkMastersPreferNewer must be either \"true\" or \"false\", was \"$ecSinkMastersPreferNewer\", using \"true\" instead"
		ecSinkMastersPreferNewer=true
	fi

	if ! test "$ecSourceMastersPreferNewer" = true && ! test "$ecSourceMastersPreferNewer" = false; then
		printMsgWarning "\$ecSourceMastersPreferNewer must be either \"true\" or \"false\", was \"$ecSourceMastersPreferNewer\", using \"true\" instead"
		ecSourceMastersPreferNewer=true
	fi

	if test "$sleepTime" = "" \
		|| ! echo "$sleepTime" | grep --quiet --extended-regexp --line-regexp --regexp='[0-9]*([.][0-9]+)?'; then
		printMsgWarning "Invalid \$sleepTime value \"$sleepTime\"; must be a duration in seconds with period as decimal separator, without unit (e.g. \"5\" or \"4.5\"), using \"5\" instead"
		sleepTime="5"
	fi

	# Validate the configured preset
	if ! isKnownPreset "$preset"; then
		printMsgWarning "Unknown preset \"$preset\", using preset \"None\" instead"
		preset="None"
		printMsgInfo "Available presets: $(printKnownPresets)"
	fi

	# Calculate the maximum duration that a single main loop iteration may run
	# without triggering handleResume()
	handleResumeTimeoutMillis="$(getHandleResumeTimeoutMillis "$sleepTime")"

	# Find out which "column" command line application is present
	columnProgramVariant="$(getColumnProgramVariant)"
	printMsgDebug "Found \"$columnProgramVariant\" variant of the \"column\" application"

	# Pre-load the map of monitored configuration files with the default
	# user-level configuration file and with the "set-preset" configuration
	# file, so that they are picked up if they are created during runtime
	if test -z ${configFilesMonitored["$actEditConfigConfigFile"]+x}; then
		configFilesMonitored["$actEditConfigConfigFile"]=""
	fi
	if test -z ${configFilesMonitored["$actSetPresetConfigFile"]+x}; then
		configFilesMonitored["$actSetPresetConfigFile"]=""
	fi

	# Convert the maximum time span from system startup during which to apply
	# the initial backoff to milliseconds
	initialBackoffMaxTimeMillis="$(getMillisFromSeconds "$initialBackoffMaxTime")"
}

# isKnownPreset preset
#
# Returns with code 0 if the given preset is known/valid, and with code 1
# if it isn't
isKnownPreset () {
	local presetToTest="$1"; shift
	local presetFunction="setup$presetToTest"

	if test "$presetToTest" = ''; then
		return 1
	fi
	isCommandType function "$presetFunction"
}

# printKnownPresets
#
# Prints a list of all known/valid presets, separated by commas
printKnownPresets () {
	local firstPreset=true
	local validPreset

	while read -rs validPreset; do

		if $firstPreset; then
			echo -n "\"$validPreset\""
		else
			echo -n ", \"$validPreset\""
		fi
		firstPreset=false
	done < <(declare -F | sed -nre 's/^declare -f //;s/^setup(.+)$/\1/p')
	echo ""
}

# getReloadTimeoutMillis sleepTimeSeconds
#
# Derives from the given sleep time in seconds the maximum duration, in
# milliseconds, that a single main loop iteration may run without triggering
# handleResume()
# The sleep time must be something like "5" or "4.5"
getHandleResumeTimeoutMillis () {
	local sleepTime="$1"; shift
	local sleepTimeMillis

	sleepTimeMillis="$(getMillisFromSeconds "$sleepTime")"
	echo $(( sleepTimeMillis + 4000 ))
}

# getMillisFromSeconds timeInSeconds
#
# Converts the given time span in seconds to an integer value in milliseconds
# The given time span may have decimals; if it does, the decimal separator
# must be the period
# Examples for a valid time span in seconds: "5", "4.5", "0.6"
# If the time span is the empty string returns an empty string
getMillisFromSeconds () {

	if test "$1" != ""; then
		echo "scale=0; ( $1 * 1000 ) / 1" | bc --quiet
	fi
}

# (Re)loads the configuration and validates the resulting settings
reloadConfig() {

	setDefaultSettings
	loadSettingsFromConfigFiles
	validateSettings
}

# Returns with code 0 if the size or modification time of any of the
# files in $configFilesMonitored (associative array) have changed since
# the last call of reloadConfig()
isUserConfigurationModified () {
	local configFile

	for configFile in "${!configFilesMonitored[@]}"; do
		if test "${configFilesMonitored["$configFile"]}" != "$(getFileStatus "$configFile")"; then
			return 0
		fi
	done
	return 1
}

# Returns with code 0 if the PulseAudio server needs to be set up again
# Calls preset-specific code if the preset implements it
#
# The preset-specific code is always called, even if the decision
# whether to reload the preset is pre-determined by e.g. $reloadPreset
isSetupRequired () {
	local instanceIdNew
	local presetFunction
	local -i returnCode=1

	# New PulseAudio instance?
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" != "$instanceId"; then
		returnCode=0
	fi

	# Preset re-application has been requested from elsewhere?
	if $reloadPreset; then
		returnCode=0
	fi

	# User-level configuration has been modified?
	if isUserConfigurationModified; then
		printMsgDebug "Configuration change detected, reloading configuration and re-applying preset"
		reloadConfig=true
		returnCode=0
	fi

	# Preset's isSetupRequired function (if implemented) requests preset
	# reload?
	presetFunction="isSetupRequired$preset"
	if isCommandType function "$presetFunction"; then
		if "$presetFunction"; then
			returnCode=0
		fi
	fi

	return "$returnCode"
}

# Applies a preset to the PulseAudio server while putting any loaded
# modules' IDs into the global $loadedModules array
# Returns with the preset function's return code
setup () {
	local presetFunction
	local -i presetFunctionReturnCode

	printMsgDebug "Setup"

	# Clear some global flags whose state is invalidated by this method
	reloadLoopbacks=false
	reloadPreset=false

	# Reload the configuration from the configuration files if requested
	if $reloadConfig; then
		reloadConfig=false
		reloadConfig
	fi

	# Try to acquire the lock for the PulseAudio server instance
	if ! getInstanceLock; then
		if ! $instanceLockFailed; then
			printMsgWarning "Another pulse-autoconf instance seems to be managing the PulseAudio server, not applying preset \"$preset\""
		fi
		instanceLockFailed=true
		return 1
	fi
	instanceLockFailed=false

	# Apply the configured preset to the running PulseAudio server
	printMsgInfo "Applying preset \"$preset\""
	presetFunction="setup$preset"
	"$presetFunction" && presetFunctionReturnCode="$?" || presetFunctionReturnCode="$?"
	if test "$presetFunctionReturnCode" != 0 ; then
		printMsgWarning "Failed to apply preset \"$preset\""
	fi
	return "$presetFunctionReturnCode"
}

# Unloads the modules loaded by the preset in reverse order of loading
# Also handles some prep work for a subsequent preset application, such
# as storing the IDs of streams that are using the fallback devices
teardown () {
	local -i index
	local instanceIdNew

	printMsgDebug "Teardown"

	# Store the IDs of the streams that are currently using the fallback
	# sink or source
	storeStreamsOnFallbackDevices

	# Unload the loaded modules if required
	unloadModules

	if ! releaseInstanceLock; then
		printMsgWarning "Failed to release the lock for the PulseAudio server instance"
	fi
}

# loadModule arguments...
#
# Performs a call of
#   pactl load-module <arguments...>
# and adds the returned module instance ID to the global modulesLoaded
# array
loadModule () {
	local moduleId
	local -i exitCode

	printMsgDebug "Loading module: pactl load-module $*"
	#$verbose && read -sp "Enter to continue... " >&2; echo "" >&2
	moduleId="$(pactl load-module "$@")" && exitCode=$? || exitCode=$?
	if test "$exitCode" -eq 0; then
		modulesLoaded+=("$moduleId")
	else
		printMsgWarning "A \"pactl load-module\" call failed with exit code $exitCode. Further arguments: $*"
	fi
	return $exitCode
}

# Unloads the modules loaded by the preset in reverse order of loading
# The loaded modules' IDs are read from the $loadedModules array
# Also updates $instanceId
unloadModules () {
	local instanceIdNew
	local -i index

	# Ensure that the PulseAudio server instance is the same from
	# setup()
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" = "$instanceId"; then
		# Unload all loaded modules in reverse order of loading
		for (( index = ${#modulesLoaded[@]} - 1; index >= 0; index-- )); do
			# If unloading a module fails, still attempt to unload the
			# rest of them
			# Also suppress the error message "Failure: No such entity"
			# when attempting to unload stuff that does not exist
			# anymore
			pactl unload-module "${modulesLoaded[$index]}" 2> /dev/null || true
			unset "modulesLoaded[$index]"
		done
	else
		# Different PulseAudio instance, do not attempt to unload any modules
		printMsgDebug "Instance ID changed from \"$instanceId\" to \"$instanceIdNew\""
		if test "$instanceId" != ""; then
			printMsgWarning "PulseAudio restart detected, not unloading modules"
		fi
		modulesLoaded=()
	fi

	# Store the PulseAudio server's instance ID
	instanceId="$instanceIdNew"
}

# Unloads and re-loads any trailing loopbacks currently present in
# $modulesLoaded
# Also sets the $reloadLoopbacks flag to false
reloadLoopbacks () {
	local instanceIdNew
	local -i index
	local moduleType
	local loopbacksUnloadedParams=()

	# Clear the $reloadLoopbacks flag if it has been set
	reloadLoopbacks=false
	# Ensure that the PulseAudio server instance is the same from
	# setup()
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" = "$instanceId"; then
		# Unload all trailing loaded loopbacks in reverse order of loading
		for (( index = ${#modulesLoaded[@]} - 1; index >= 0; index-- )); do
			moduleType="$(getModuleType "${modulesLoaded[$index]}")"
			if test "$moduleType" != "module-loopback"; then
				break
			fi
			loopbacksUnloadedParams+=("$(getModuleParams "${modulesLoaded[$index]}")")
			pactl unload-module "${modulesLoaded[$index]}" 2> /dev/null || true
			unset "modulesLoaded[$index]"
		done
		# Restore the unloaded loopbacks in reverse order of unloading
		for (( index = ${#loopbacksUnloadedParams[@]} - 1; index >= 0; index-- )); do
			loadModule module-loopback "${loopbacksUnloadedParams[$index]}"
		done
	fi
}

# getModuleType id
#
# Returns the type of the loaded PulseAudio module that has the given ID
# For example, if there is a loopback active with ID 12, returns
# "module-loopback" for a given ID of 12
# Returns with return code 1 if there is no module that has the given ID
getModuleType () {
	local -i moduleId="$1"; shift
	local result

	result="$(pactl list short modules | sed -nre 's/^'"$moduleId"'\t([^\t]+).*$/\1/p')"
	if test "" = "$result"; then
		return 1
	fi
	echo "$result"
}

# getModuleParams id
#
# Returns the parameters of the loaded PulseAudio module that has the given ID
# For example, if there is a loopback active with ID 12, returns something like
# "source=src_ec sink=sink_mix" for a given ID of 12
# An empty string is a valid result; some modules do not have parameters
# Returns with return code 1 if there is no module that has the given ID
getModuleParams () {
	local -i moduleId="$1"; shift
	local result

	result="$(pactl list short modules | sed -nre 's/^'"$moduleId"'\t.*$/\0/p')"
	if test "" = "$result"; then
		return 1
	fi
	echo "$result" | sed -nre 's/^[^\t]+\t[^\t]+\t([^\t]+).*$/\1/p'
}

# createDummySinkIfRequired sinkName
#
# If the given sink name is the name of the dummy sink, and if the
# dummy sink does not exist yet, creates the dummy sink
createDummySinkIfRequired () {
	local sinkName="$1"; shift

	if test "$sinkName" = "${sinkDummy[0]}"; then
		createNullSinkIfRequired "${sinkDummy[0]}" "${sinkDummy[1]}"
	fi
}

# createDummySourceIfRequired sourceName
#
# If the given source name is the name of the dummy source, and if the
# dummy source does not exist yet, creates the dummy source
createDummySourceIfRequired () {
	local sourceName="$1"; shift

	if test "$sourceName" = "${sourceDummy[0]}"; then
		createNullSourceIfRequired "${sourceDummy[0]}" "${sourceDummy[1]}"
	fi
}

# createNullSinkIfRequired sinkName sinkDescription
#
# Creates a null sink having the given name and description if no sink
# with that name exists yet
createNullSinkIfRequired () {
	local sinkName="$1"; shift
	local sinkDescription="$1"; shift

	if ! getDevice sinks "" false "exact:$sinkName" &> /dev/null; then
		printMsgDebug "Creating null sink \"$sinkName\""
		if ! loadModule module-null-sink "${nullSinkParams[@]}" sink_name="$sinkName" sink_properties="device.description=$sinkDescription"; then
			printMsgWarning "Failed to create null sink \"$sinkName\""
			return 1
		fi
	fi
}

# createNullSourceIfRequired sourceName sourceDescription
#
# Creates a null source having the given name if no source with that
# name exists yet
createNullSourceIfRequired () {
	local sourceName="$1"; shift
	local sourceDescription="$1"; shift

	if ! getDevice sources "" false "exact:$sourceName" &> /dev/null; then
		printMsgDebug "Creating null source \"$sourceName\""
		if ! loadModule module-null-source "${nullSourceParams[@]}" source_name="$sourceName" description="$sourceDescription"; then
			printMsgWarning "Failed to create null source \"$sourceName\""
			return 1
		fi
	fi
}

# Returns the currently running PulseAudio server instance's instance ID
getInstanceId () {
	LC_ALL=C pactl info | sed -nre 's/^Cookie: (.*)$/\1/p'
}

# Returns the current fallback sink (a.k.a. default sink)
getFallbackSink () {
	LC_ALL=C pactl info | sed -nre 's/^Default Sink: (.*)$/\1/p'
}

# Returns the current fallback source (a.k.a. default source)
getFallbackSource () {
	LC_ALL=C pactl info | sed -nre 's/^Default Source: (.*)$/\1/p'
}

# Stores the IDs of the streams that are currently playing to the
# fallback sink or recording from the fallback source into the global
# array variables
#   streamsPlayingToFallbackSink,
#   streamsRecordingFromFallbackSource,
# respectively
storeStreamsOnFallbackDevices () {
	local fallbackSink
	local fallbackSource
	local streamPlayingToFallbackSink
	local streamRecordingFromFallbackSource

	# Clear stream stores
	streamsPlayingToFallbackSink=()
	streamsRecordingFromFallbackSource=()

	# Store streams that are playing to the fallback sink or recording
	# from the fallback source
	if ! $stopped; then
		# Store streams playing to the fallback sink
		fallbackSink="$(getFallbackSink)"
		while read -rs streamPlayingToFallbackSink; do
			streamsPlayingToFallbackSink+=("$streamPlayingToFallbackSink")
		done < <(getStreamIds sink-inputs "$fallbackSink")
		printMsgDebug "IDs of streams playing to \"$fallbackSink\": ${streamsPlayingToFallbackSink[*]}"
		# Store streams recording from the fallback source
		fallbackSource="$(getFallbackSource)"
		while read -rs streamRecordingFromFallbackSource; do
			streamsRecordingFromFallbackSource+=("$streamRecordingFromFallbackSource")
		done < <(getStreamIds source-outputs "$fallbackSource")
		printMsgDebug "IDs of streams recording from \"$fallbackSource\": ${streamsRecordingFromFallbackSource[*]}"
	fi
}

# Moves the streams whose IDs are stored in the global array variables
#   streamsPlayingToFallbackSink
#   streamsRecordingFromFallbackSource,
# to the current fallback sink or source, respectively
restoreStreamsOnFallbackDevices () {
	local fallbackSink
	local fallbackSource
	local moveToFallbackSink
	local moveToFallbackSource

	# Restore playback streams to the fallback sink
	fallbackSink="$(getFallbackSink)"
	for moveToFallbackSink in "${streamsPlayingToFallbackSink[@]}"; do
		! streamExists sink-inputs "$moveToFallbackSink" && continue
		printMsgDebug "Moving playback stream with ID \"$moveToFallbackSink\" to sink \"$fallbackSink\""
		# Silently ignore failures caused by attempts to move special
		# recording streams such as peak detectors
		pactl move-sink-input "$moveToFallbackSink" "$fallbackSink" 2> /dev/null || true
	done
	# Restore recording streams to the fallback source
	fallbackSource="$(getFallbackSource)"
	for moveToFallbackSource in "${streamsRecordingFromFallbackSource[@]}"; do
		! streamExists source-outputs "$moveToFallbackSource" && continue
		printMsgDebug "Moving recording stream with ID \"$moveToFallbackSource\" to source \"$fallbackSource\""
		pactl move-source-output "$moveToFallbackSource" "$fallbackSource" 2> /dev/null || true
	done
}

# getNewlineList [arguments...]
#
# Prints all arguments to STDOUT, each argument terminated with a line
# feed
getNewlineList () {

	while test $# -gt 0; do
		echo "$1"; shift
	done
}

# Returns the first available sink that matches a pattern from the
# ecSinkMasters array, which is the sink that should be used as
# sink_master= when setting up echo cancellation
getEcSinkMaster () {

	getFirstAvailableDevice sinks \
		"$(getNewlineList "${ecSinkMastersIgnore[@]}" "${ecSinkMastersIgnorePreset[@]}")" \
		"$ecSinkMastersPreferNewer" "${ecSinkMasters[@]}" && return 0

	# No sink master found: Return the dummy sink if it is enabled
	# It will be automatically created if required
	if $ecUseDummySink; then
		echo "${sinkDummy[0]}"
		return 0
	fi
	return 1
}

# getEcSourceMaster ignoredSink
#
# Returns the first available source that is NOT the monitor of the
# given sink, and that matches a pattern from the ecSourceMasters array,
# which is the source that should be used as source_master= when setting
# up echo cancellation
getEcSourceMaster () {
	local ignoredSink="$1"; shift
	local ignoredSinkMonitorIfPresent=()

	if test "$ignoredSink" != ""; then
		ignoredSinkMonitorIfPresent+=("$ignoredSink".monitor)
	fi

	getFirstAvailableDevice sources \
		"$(getNewlineList "${ecSourceMastersIgnore[@]}" "${ecSourceMastersIgnorePreset[@]}" "${ignoredSinkMonitorIfPresent[@]}")" \
		"$ecSourceMastersPreferNewer" "${ecSourceMasters[@]}" && return 0

	# No source master found: Return the dummy source if it is enabled
	# It will be automatically created if required
	if $ecUseDummySource; then
		echo "${sourceDummy[0]}"
		return 0
	fi
	return 1
}

# getFirstAvailableDevice deviceType ignoredDevices preferNewer [pattern]...
#
# For each pattern, attempts to find a PulseAudio sink/source that
# matches the pattern
# On the first successful match, prints the PulseAudio sink/source to
# STDOUT and returns with code 0
# If none of the given patterns match a sink or source prints nothing
# and returns with code 1
#
# The deviceType argument must be one of "sinks", "sources"
# The ignoredDevices argument may contain the (exact) names of devices
# that should be ignored, separated by line breaks; if no device should
# be ignored, this argument should be the empty string
# It is used when e.g. determining an echo cancellation master source,
# to deny the monitor of an already-determined echo cancellation master
# sink, and also to exclude virtual devices created by the presets
# The preferNewer argument controls the order in which the devices are
# matched against the patterns, if it is "true" then newer devices will
# be matched before older devices
getFirstAvailableDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; shift
	local preferNewer="$1"; shift
	local pattern

	while test $# -gt 0; do
		pattern="$1"; shift

		if getDevice "$type" "$ignoredDevices" "$preferNewer" "$pattern"; then
			return 0
		fi
	done
	return 1
}

# getDevice deviceType ignoredDevices preferNewer pattern
#
# If a sink or source matching the given pattern exists, prints the
# first such sink/source's name to STDOUT and returns with code 0
#
# The deviceType argument must be one of "sinks", "sources"
# The ignoredDevices argument may contain the (exact) names of devices
# that should be ignored, separated by line breaks; if no device should
# be ignored, this argument should be the empty string
# The preferNewer argument controls the order in which the devices are
# matched against the patterns, if it is "true" then newer devices will
# be matched before older devices
getDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; shift
	local preferNewer; if test "$1" = "true"; then preferNewer="true"; else preferNewer="false"; fi; shift
	local pattern="$1"; shift
	local IFS=$'\t'$'\n'
	local prefix
	local payload
	local deviceId
	local deviceName
	local deviceOther

	prefix="$(echo "$pattern" | sed -nre 's/^([a-z]+:).*$/\1/p')"
	payload="${pattern:${#prefix}}"
	#printMsgDebug "pattern=\"$pattern\", prefix=\"$prefix\", payload=\"$payload\""
	#printMsgDebug "Ignoring $(echo "$ignoredDevices" | wc -l) device(s)"

	while read -rs deviceId deviceName deviceOther; do

		# Ignore devices in the ignoreDevices list
		echo "$ignoredDevices" | grep --quiet --line-regexp --fixed-strings --regexp "$deviceName" && continue

		if test "$prefix" = "exact:"; then
			test "$deviceName" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notexact:"; then
			test "$deviceName" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "startswith:"; then
			test "${deviceName: 0: ${#payload}}" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notstartswith:"; then
			test "${deviceName: 0: ${#payload}}" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "endswith:"; then
			test "${deviceName: $(( ${#deviceName} - ${#payload} )): ${#deviceName}}" = "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "notendswith:"; then
			test "${deviceName: $(( ${#deviceName} - ${#payload} )): ${#deviceName}}" != "$payload" && { echo "$deviceName"; return 0; }
		elif test "$prefix" = "grep:"; then
			echo "$deviceName" | grep --regexp "${payload}" && return 0
		elif test "$prefix" = "notgrep:"; then
			echo "$deviceName" | grep --invert-match --regexp "${payload}" && return 0
		elif test "$prefix" = "egrep:"; then
			echo "$deviceName" | grep --extended-regexp --regexp "${payload}" && return 0
		elif test "$prefix" = "notegrep:"; then
			echo "$deviceName" | grep --extended-regexp --invert-match --regexp "${payload}" && return 0
		else
			printMsgWarning "Unknown device name pattern prefix \"$prefix\", must be one of \"exact:\", \"notexact:\", \"startswith:\", \"notstartswith:\", \"endswith:\", \"notendswith:\", \"grep:\", \"notgrep:\", \"egrep:\", \"notegrep:\": \"$pattern\""
		fi
	done < <(pactl list short "$type" | { if $preferNewer; then tac; else cat; fi; })
	return 1
}

# streamExists streamType streamId
#
# Returns with code 0 if there is a stream of the given type and having
# the given stream ID
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
streamExists () {
	local type="$1"; shift
	local id="$1"; shift
	local IFS=$'\t'$'\n'
	local streamId
	local streamOther

	while read -rs streamId streamOther; do
		if test "$streamId" = "$id"; then
			return 0
		fi
	done < <(pactl list short "$type")
	return 1
}

# getStreamIds streamType deviceName
#
# Returns the IDs of the source-outputs or sink-inputs that use the
# sink/source with the given name, in a newline-separated list
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
getStreamIds () {
	local type="$1"; shift
	local name="$1"; shift
	local IFS=$'\t'$'\n'
	local deviceType
	local deviceId
	local streamId
	local streamDeviceId
	local streamOther

	# Get the device's ID
	# "sink-inputs" -> "sinks", "source-outputs" -> "sources"
	if test "sink-inputs" = "$type"; then
		deviceType="sinks"
	elif test "source-outputs" = "$type"; then
		deviceType="sources"
	else
		printMsgError "Unknown stream type \"${type}\", must be either \"sink-inputs\" or \"source-outputs\""
		return 1
	fi
	deviceId="$(getDeviceId "$deviceType" "$name")"
	#printMsgDebug "ID of sink/source \"$name\" is \"$deviceId\""

	# shellcheck disable=SC2034  # Unused variable "streamOther"
	# required to separate trailing data from variable "streamDeviceId"
	while read -rs streamId streamDeviceId streamOther; do
		#printMsgDebug "streamId=\"$streamId\", streamDeviceId=\"$streamDeviceId\", streamOther=\"$streamOther\""
		if test "$streamDeviceId" = "$deviceId"; then
			echo "$streamId"
		fi
	done < <(pactl list short "$type")
}

# getDeviceId deviceType deviceName
#
# Returns the ID of the sink or source that has the given name
#
# The deviceType argument must be one of "sinks", "sources"
getDeviceId () {
	local type="$1"; shift
	local name="$1"; shift
	local IFS=$'\t'$'\n'
	local deviceId
	local deviceName
	local deviceOther

	# shellcheck disable=SC2034  # Unused variable "deviceOther"
	# required to separate trailing data from variable "deviceName"
	while read -rs deviceId deviceName deviceOther; do
		if test "$deviceName" = "$name"; then
			echo "$deviceId"
			return 0
		fi
	done < <(pactl list short "$type")
	return 1
}

# Causes the next main loop iteration to be commenced immediately
# Interrupts a running pause() call, and disables pause() until the end of the
# next main loop iteration has been reached
resumeMainLoop () {
	local sleepPidCopy="$sleepPid"

	resumeMainLoop=true
	if test "$sleepPidCopy" != ""; then
		kill -s TERM "$sleepPidCopy" 2> /dev/null || true
	fi
}

# pause duration
#
# Enters a sleep-wait call for the given duration
# Does nothing if $resumeMainLoop=true or if an empty string is given as
# duration
# The duration argument, if not empty, is passed to the sleep call as-is
# Can be interrupted by calling resumeMainLoop()
pause () {
	local duration="$1"; shift

	if ! $resumeMainLoop && test "$duration" != ""; then
		sleep "$duration" &
		sleepPid=$!
		wait $sleepPid || true
		sleepPid=""
	fi
}

# Sets the "stop" flag and immediately resumes the main loop if it is
# waiting on its "sleep" call
handleRequestStop () {

	printMsgInfo "Terminating main loop"
	stopped=true
	resumeMainLoop
}

# Handler for signal USR1
handleSignalUsr1 () {
	handleRequestReloadConfig
}

# Handler for signal USR2
handleSignalUsr2 () {
	handleRequestResumeMainLoop
}

# Causes the application to perform a main loop iteration immediately
handleRequestResumeMainLoop () {

	printMsgDebug "Resuming main loop"
	resumeMainLoop
}

# Unloads and re-applies the current preset's loopbacks
handleRequestReloadLoopbacks () {

	printMsgDebug "Re-applying loopbacks"
	reloadLoopbacks=true
	resumeMainLoop
}
# Unloads and re-applies the current preset
#
# Immediately resumes the main loop if it is waiting on its "sleep"
# call, tears down the current preset and then re-applies it
handleRequestReloadPreset () {

	printMsgDebug "Re-applying preset"
	reloadPreset=true
	resumeMainLoop
}

# Reloads the application configuration and re-applies the preset
#
# Immediately resumes the main loop if it is waiting on its "sleep"
# call, tears down the current preset, reloads the complete
# configuration and then re-applies the (now possibly different) preset
handleRequestReloadConfig () {

	printMsgDebug "Reloading configuration and re-applying preset"
	reloadConfig=true
	reloadPreset=true
	resumeMainLoop
}

# The code that should be run after the system resumes from standby
# May be overridden in a configuration file
# Suggestions:
#   true                         - Do nothing
#   handleRequestReloadLoopbacks - Unload and re-apply the current preset's
#                                  loopbacks
#   handleRequestReloadPreset    - Unload and re-apply the current preset
#   handleRequestReloadConfig    - Unload the current preset, reload the
#                                  configuration, then re-apply the preset
#   handleResumeDefault          - Restore the default behavior
# If you experience poor echo cancellation efficacy after suspend try
# handleRequestReloadPreset
handleResume () {

	# Call the default implementation
	handleResumeDefault
}

# The default code that should be run after the system resumes from standby
handleResumeDefault () {

	# On some systems, after suspend-resume the loopbacks installed by a preset
	# have an unreasonably large delay
	# To mitigate this, unload and re-apply these loopbacks
	# This is uncritical for applications as it does not affect any sinks or
	# sources
	handleRequestReloadLoopbacks
}

# Calls the teardown function before termination
handleExit () {

	# Set those two flags to true
	# Possibly not required, but doesn't hurt to do so
	stopped=true
	resumeMainLoop=true

	teardown
}

# Attempts to acquire the pulse-autoconf instance lock for the current
# PulseAudio server instance
# Only a single pulse-autoconf may be messing with a PulseAudio server
# at once
# Returns with code 0 if the lock has been acquired
getInstanceLock() {
	local pid=$$
	local pidFileNew
	local pidFromFile

	# Get the current PID file
	if ! pidFileNew="$(getPidFile)" || test "$pidFileNew" = ""; then
		printMsgError "Unable to determine the current PID file's path"
		return 1
	fi
	if test "$pidFile" != "" && test "$pidFileNew" != "$pidFile"; then
		# We appear to have the lock on an obsolete PID file
		# Maybe the PulseAudio server has been restarted, which causes
		# the file's name to change
		# Release the obsolete lock
		releaseInstanceLock || true
	fi

	# Unset the current PID file, so that we do not think we have the
	# lock if anything goes wrong
	pidFile=""

	# Try to obtain the lock
	if writePidFile "$pid" "$pidFileNew"; then
		# PID file did not exist and has been written successfully
		pidFile="$pidFileNew"
		return 0
	elif pidFromFile="$(getPidFromFile "$pidFileNew")"; then
		# PID file exists
		if test "$pidFromFile" = "$pid"; then
			# This is our PID file, we already have the lock
			pidFile="$pidFileNew"
			return 0
		else
			# Other PID or bullshit in file
			if kill -0 -- "$pidFromFile" 2> /dev/null; then
				# There is a process with the PID in this file: Somebody
				# else has the lock
				return 1
			else
				# No process found that uses the PID from the file
				printMsgWarning "Deleting stale PID file containing PID \"$pidFromFile\": \"$pidFileNew\""
				if test -f "$pidFileNew"; then
					rm -f "$pidFileNew" 2> /dev/null || true
				fi
				if writePidFile "$pid" "$pidFileNew"; then
					pidFile="$pidFileNew"
					return 0
				else
					return 1
				fi
			fi
		fi
	else
		# Something else
		#  - File could not be read
		#  - Race where the file has been deleted while this function
		#    was running
		return 1
	fi
}

# Releases the lock for the current PulseAudio server instance if it is
# held
# Returns with code 0 if this pulse-autoconf instance does not hold the
# lock afterwards
releaseInstanceLock () {
	local pid=$$
	local pidFromFile

	if test "$pidFile" = ""; then
		# We do not have the lock
		return 0
	fi

	# Try to release the lock
	if ! test -e "$pidFile"; then
		# PID file does not exist, nobody has the lock
		pidFile=""
		return 0
	elif pidFromFile="$(getPidFromFile "$pidFile")"; then
		# PID file exists
		if test "$pidFromFile" = "$pid"; then
			# This is our PID file, we have the lock and can release it
			rm -f "$pidFile"
			pidFile=""
			return 0
		else
			# Somebody else has the lock
			pidFile=""
			return 0
		fi
	else
		# Something else
		#  - File could not be read
		#  - Race where the file has been deleted while this function
		#    was running
		# Whatever it is, assume we do not have the lock anymore
		pidFile=""
		return 0
	fi
}

# Prints the path of the file that pulse-autoconf should check for when
# determining whether there are other pulse-autoconf instances that are
# using the same PulseAudio server instance
getPidFile () {

	# Figure out the path to the PID file
	if ! pidFileDir="$(getPidFileDir)" || test "$pidFileDir" = ""; then
		printMsgError "Unable to determine the PID file parent directory"
		return 1
	fi
	if ! pidFileName="$(getPidFileName)" || test "$pidFileName" = ""; then
		printMsgError "Unable to determine the PID file's name"
		return 1
	fi
	echo "$pidFileDir"/"$pidFileName"
}


# Prints the path to the directory where pulse-autoconf should check for
# other instances using the same PulseAudio instance
getPidFileDir () {
	local baseDir

	if ! test -z ${XDG_RUNTIME_DIR+x} && test -d "$XDG_RUNTIME_DIR"; then
		echo "$XDG_RUNTIME_DIR"/pulse-autoconf
		return 0
	fi

	if baseDir="/run/user/$(id -u)" && test -d "$baseDir"; then
		echo "$baseDir"/pulse-autoconf
		return 0
	fi

	# TODO Use a session-independent directory instead?
	# $XDG_RUNTIME_DIR is a session-specific directory, but
	# pulse-autoconf's single-instance scope is the PulseAudio server
	# instance; the session does not matter
	# TODO Implement more fallbacks?

	return 1
}

# Prints the name of the file in getPidFileDir() that pulse-autoconf
# should check for when determining whether there are other
# pulse-autoconf instances that are using the same PulseAudio server
# instance
getPidFileName () {
	getInstanceId | sed -re 's/[^a-zA-Z0-9]/_/g'
}

# getPidFromFile path
#
# Prints the first max. 40 characters of the given file's first text
# text line to STDOUT, terminated by a line break
# Non-digit characters are replaced by underscores
# Prints nothing and returns with code 1 if the file does not exist
# Prints nothing and returns with code 0 if the file is empty
getPidFromFile () {
	local path="$1"; shift
	local pid

	if ! test -f "$path"; then
		return 1
	fi
	while read -rsn 40 pid || test "$pid" != ""; do
		break
	done < <(cat "$path")
	if ! test -z ${pid+x}; then
		echo "$pid" | sed -re 's/[^0-9]/_/g'
	fi
}

# writePidFile pid filePath
#
# Writes the given PID followed by a line break to the given file if no
# such file exists
# Returns with code 0 if the file has been written
# ASSUMES THAT THE SHELL OPTION "noclobber" IS SET!
writePidFile () {
	local pid="$1"; shift
	local path="$1"; shift
	local parentPath

	# This test is technically not required as the "noclobber" shell
	# option atomically prevents an existing lock file from being
	# overwritten
	# Its sole reason for existence is that, in the majority of cases,
	# it prevents the shell's error message about an attempt to clobber
	# an existing file, which can not easily be silenced
	if test -e "$path"; then
		return 1
	fi

	parentPath="$(dirname "$path")"
	mkdir --parents "$parentPath"
	echo "$pid" > "$path" 2> /dev/null
}

# Returns the current point in time, in milliseconds since UNIX epoch
getNowEpochMillis () {
	date '+%s%3N'
}

# getFunctionSuffix actionName
#
# Converts the given action name to an action function suffix
# E.g. converts "interactive-loopback" to "InteractiveLoopback"
# If a function suffix is given, the function suffix is returned as-is
getFunctionSuffix () {
	local actionName="$1"; shift
	local -i index
	local currentChar
	local uppercaseNextChar
	local result=""

	# Input validation
	if test ${#actionName} -gt 180 \
		|| ! test "$(echo "$actionName" | wc -l)" -eq 1 \
		|| ! echo "$actionName" | grep --quiet --extended-regexp --line-regexp --regexp='[-a-zA-Z]*'; then
		# Unreasonably many characters / multiple text lines / invalid characters
		return 1
	fi

	uppercaseNextChar=true
	for (( index = 0; index < ${#actionName}; index++ )); do
		currentChar="${actionName:$index:1}"
		if test "$currentChar" = '-'; then
			uppercaseNextChar=true
			currentChar=""
		elif "$uppercaseNextChar"; then
			uppercaseNextChar=false
			currentChar="${currentChar^^}"
		fi
		result="${result}$currentChar"
	done
	echo "$result"
}

# getActionName functionSuffix
#
# Converts the given function suffix to an action name
# E.g. converts "InteractiveLoopback" to "interactive-loopback"
# If an action name is given, the action name is returned as-is
getActionName () {
	local functionSuffix="$1"; shift
	local -i index
	local currentChar
	local result=""

	# Input validation
	if test ${#functionSuffix} -gt 160 \
		|| ! test "$(echo "$functionSuffix" | wc -l)" -eq 1 \
		|| ! echo "$functionSuffix" | grep --quiet --extended-regexp --line-regexp --regexp='[-a-zA-Z]*'; then
		# Unreasonably many characters / multiple text lines / invalid characters
		return 1
	fi

	for (( index = 0; index < ${#functionSuffix}; index++ )); do
		currentChar="${functionSuffix:$index:1}"
		if test "$currentChar" = "${currentChar^^}"; then
			if test "$result" != "" && test "${result:$(( ${#result} - 1 ))}" != "-"; then
				result="${result}-"
			fi
			currentChar="${currentChar,,}"
		fi
		result="${result}$currentChar"
	done
	echo "$result"
}

# Writes the system uptime in milliseconds to STDOUT
getUptimeMillis () {
	local uptimeSeconds

	uptimeSeconds="$(sed -nre 's/^([0-9.]+) .*$/\1/p' < /proc/uptime)"
	getMillisFromSeconds "$uptimeSeconds"
}

# Pauses for a short time if pulse-autoconf was started during a small time
# window after system startup
# Part of a workaround for what is believed to be an ALSA glitch, see
# documentation for $initialBackoffSleepTime in setDefaultSettings()
initialBackoff () {
	local uptimeMillis

	if test "$initialBackoffSleepTime" != ""; then
		uptimeMillis="$(getUptimeMillis)"
		if test "$initialBackoffMaxTimeMillis" = "" || test "$uptimeMillis" -lt "$initialBackoffMaxTimeMillis"; then
			printMsgInfo "Waiting a bit to give ALSA time to probe for device profiles"
			pause "$initialBackoffSleepTime"
		fi
	fi
}


# Special actions
# ----------------------------------------------------------------------

# runActionEditConfig [customEditor] [customEditorArgument]...
#
# Opens the default user-level configuration file in a text editor
# Creates a new configuration file if it does not exist yet
runActionEditConfig () {
	local editorWithArgs=()
	local editorFallback

	# Find the text editor executable and its arguments
	# Custom editor with arguments supplied as action arguments
	if test ${#editorWithArgs[@]} -eq 0 && test $# -gt 0; then
		while test $# -gt 0; do
			editorWithArgs+=("$1"); shift
		done
		if type "${editorWithArgs[0]}" &> /dev/null; then
			printMsgInfo "Using editor \"${editorWithArgs[0]}\" from command line to edit configuration file \"$actEditConfigConfigFile\""
		else
			printMsgWarning "Text editor \"${editorWithArgs[0]}\" from command line does not exist or is not executable"
			editorWithArgs=()
		fi
	fi

	# Custom editor with arguments from configuration
	if test ${#editorWithArgs[@]} -eq 0 && test ${#editorCustomWithArgs[@]} -ge 1; then
		editorWithArgs=("${editorCustomWithArgs[@]}")
		if type "${editorWithArgs[0]}" &> /dev/null; then
			printMsgInfo "Using editor \"${editorWithArgs[0]}\" from configuration to edit configuration file \"$actEditConfigConfigFile\""
		else
			printMsgWarning "Text editor \"${editorWithArgs[0]}\" from configuration does not exist or is not executable"
			editorWithArgs=()
		fi
	fi

	# Default command line text editor from $EDITOR
	if test ${#editorWithArgs[@]} -eq 0 &&  test ! -z ${EDITOR+x}; then
		editorWithArgs=("$EDITOR")
		if type "${editorWithArgs[0]}" &> /dev/null; then
			printMsgInfo "Using editor \"${editorWithArgs[0]}\" from \$EDITOR to edit configuration file \"$actEditConfigConfigFile\""
		else
			printMsgWarning "Text editor \"${editorWithArgs[0]}\" from \$EDITOR does not exist or is not executable"
			editorWithArgs=()
		fi
	fi

	# Hard-coded list of fallback text editor executables
	if test ${#editorWithArgs[@]} -eq 0; then
		# Scan $editorFallbacks for an available text editor
		for editorFallback in "${editorFallbacks[@]}"; do
			if type "$editorFallback" &> /dev/null; then
				editorWithArgs=("$editorFallback")
				printMsgInfo "Using editor \"${editorWithArgs[0]}\" from \$editorFallbacks to edit configuration file \"$actEditConfigConfigFile\""
				break
			fi
		done
		if test ${#editorWithArgs[@]} -eq 0; then
			printMsgError "None of the editors in \$editorFallbacks is available, please specify a valid text editor executable"
			return 1
		fi
	fi

	# If applicable, move a default user level configuration file that was
	# created by a version earlier than pulse-autoconf 1.8.0 to the correct
	# location
	migratePre180DefaultConfigFile

	# Create a configuration file if it does not exist
	if ! test -e "$actEditConfigConfigFile"; then
		printMsgInfo "Configuration file does not exist, creating it"
		# Create the configuration file's parent directories if they do
		# not exist
		mkdir --parents "$(dirname "$actEditConfigConfigFile")"
		printTemplateConfigurationFile > "$actEditConfigConfigFile"
	fi

	# Launch the text editor with the arguments and the configuration
	# file's path
	printMsgDebug "Launching text editor: \"${editorWithArgs[*]}\" \"$actEditConfigConfigFile\""
	"${editorWithArgs[@]}" "$actEditConfigConfigFile"
}

# Moves a pre-1.8.0 default user level configuration file to the correct
# location if required
migratePre180DefaultConfigFile () {
	local createdByVersion
	local createdByVersionMajor
	local createdByVersionMinor

	if test -z ${migrateConfigPre180+x} || test "$migrateConfigPre180" != "true"; then
		return 0 # Automatic configuration file migration has been disabled
	fi
	# Prerequisites: The current default configuration file must not exist,
	# and the legacy file must exist
	if ! test -e "$actEditConfigConfigFile" && test -f ~/.config/pulse-autoconf/pulse-autoconf.conf; then
		# Further requirement: The legacy file must have been created by a
		# pulse-autoconf version less than 1.8.0
		# Attempt to extract the version number from the
		# "Created by pulse-autoconf x.y.z" text line from the legacy config file
		createdByVersion="$(head --lines=4 -- - < ~/.config/pulse-autoconf/pulse-autoconf.conf | tail --lines=1 -- - \
			| sed -nre 's/^# Created by pulse-autoconf ([0-9]+[.][0-9]+[.][0-9]+)(-[a-z]+[.][0-9]+){0,1}$/\1\2/p')"
		# Attempt to extract the major and minor version
		createdByVersionMajor="$(echo "$createdByVersion" | sed -nre 's/^([0-9]+)[.]([0-9]+)[.]([0-9]+).*$/\1/p' )"
		createdByVersionMinor="$(echo "$createdByVersion" | sed -nre 's/^([0-9]+)[.]([0-9]+)[.]([0-9]+).*$/\2/p' )"
		# If the version is less than 1.8.0, move the legacy file to the new
		# location
		if test "$createdByVersionMajor" != "" && test "$createdByVersionMinor" != "" \
			&& {
				   { test "$createdByVersionMajor" = "0"; } \
				|| { test "$createdByVersionMajor" = "1" && test "$createdByVersionMinor" -le "7"; }
			}; then
			printMsgInfo "Moving default configuration file created by previous version \"$createdByVersion\" to new location \"$actEditConfigConfigFile\""
			mkdir --parents "$(dirname "$actEditConfigConfigFile")"
			mv --no-target-directory -- ~/.config/pulse-autoconf/pulse-autoconf.conf "$actEditConfigConfigFile"
		fi
	fi
}

# runActionSetPreset preset|-
#
# Writes/replaces/deletes an action-dedicated configuration file that contains
# a single "preset=" setting
# The "preset" argument is the preset that should be set, or the reserved
# value "-" (i.e. the minus character)
# If the preset is "-", then the dedicated configuration file is deleted
# Unless configured otherwise, this dedicated configuration file takes
# precedence over the default configuration file that is edited by action
# "edit-config"
# If the dedicated file to be written already contains the setting for the
# requested preset, does nothing
runActionSetPreset () {
	local configFileTmp="${actSetPresetConfigFile}.tmp"
	local presetToSet
	local presetCommandLine

	# Parse arguments
	if test $# -ne 1; then
		printMsgError "Single argument required: preset|- (the preset that should be set, or \"-\" to unset the preset that has been set by this action)"
		return 1
	fi
	presetToSet="$1"; shift

	if test "$presetToSet" = '-'; then
		if test -e "$actSetPresetConfigFile"; then
			printMsgInfo "Deleting configuration file \"$actSetPresetConfigFile\""
			rm --force "$actSetPresetConfigFile"
			runActionReloadConfig true
		else
			printMsgInfo "Configuration file \"$actSetPresetConfigFile\" does not exist, doing nothing"
		fi
	else
		if ! isKnownPreset "$presetToSet"; then
			printMsgError "Unknown preset \"$presetToSet\", doing nothing"
			printMsgInfo "Available presets: $(printKnownPresets)"
			return 1
		fi
		presetCommandLine="preset='$presetToSet'"
		if test -e "$actSetPresetConfigFile" && test "$presetCommandLine" = "$(tail --lines=1 -- - < "$actSetPresetConfigFile" )"; then
			printMsgInfo "Preset \"$presetToSet\" appears to be already set in configuration file \"$actSetPresetConfigFile\", doing nothing"
			return 0
		fi
		if test -e "$configFileTmp"; then
			printMsgWarning "Temporary configuration file already exists, deleting it: \"$configFileTmp\""
			rm --force "$configFileTmp"
		fi
		printMsgInfo "Writing configuration file with preset \"$presetToSet\": \"$actSetPresetConfigFile\""
		mkdir --parents "$(dirname "$configFileTmp")"
		{
			printActionSetPresetConfigFileHeader
			echo "$presetCommandLine"
		} > "$configFileTmp"
		mv --force "$configFileTmp" "$actSetPresetConfigFile"
		runActionReloadConfig true
	fi
}

# printActionSetPresetConfigFileHeader
#
# Prints the configuration file header for the file created by the action
# "set-preset" to STDOUT
printActionSetPresetConfigFileHeader () {
	echo "# Configuration file for pulse-autoconf
# ======================================================================

# Created by pulse-autoconf $(getVersion)
# Created by action \"set-preset\"
"
}

# runActionReloadConfig [graceful]
#
# Tells a running pulse-autoconf instance to reload its configuration and
# re-apply its preset by sending it a USR1 signal
# If the "graceful" argument is not given or is "false", failure to find a
# running pulse-autoconf instance causes an error
# If "graceful" is "true", failure to find a running instance is ignored
runActionReloadConfig () {

	if test $# -gt 1; then
		printMsgError "Action \"reload-config\" requires at most a single argument: [graceful]"
		return 1
	fi
	sendSignalToRunningInstance "USR1" "$@"
}

# runActionWakeUp [graceful]
#
# Tells a running pulse-autoconf instance to immediately perform the next main
# loop iteration, i.e. to wake up do whatever needs to be done, by sending it
# a USR2 signal
# If the "graceful" argument is not given or is "false", failure to find a
# running pulse-autoconf instance causes an error
# If "graceful" is "true", failure to find a running instance is ignored
runActionWakeUp() {

	if test $# -gt 1; then
		printMsgError "Action \"wake-up\" requires at most a single argument: [graceful]"
		return 1
	fi
	sendSignalToRunningInstance "USR2" "$@"
}

# runActionSendSignal signal [graceful]
#
# Sends the given signal to the currently running pulse-autoconf instance that
# owns the instance lock
# The signal is passed to the "kill" command with "kill -s signal"
# If the "graceful" argument is not given or is "false", failure to find a
# pulse-autoconf instance causes an error
# If "graceful" is "true", failure to find a running instance is ignored
runActionSendSignal () {

	if test $# -eq 0; then
		printMsgError "Action \"send-signal\" requires at least a single argument, the signal that should be sent"
		return 1
	fi
	if test $# -gt 2; then
		printMsgError "Action \"send-signal\" requires at most two arguments: signal [graceful]"
		return 1
	fi
	sendSignalToRunningInstance "$@"
}

# sendSignalToRunningInstance signal [graceful]
#
# Sends the given signal to the currently running pulse-autoconf instance that
# owns the instance lock
# The signal is passed to the "kill" command with "kill -s signal"
# If the "graceful" argument is not given or is "false", failure to find a
# pulse-autoconf instance causes an error
# If "graceful" is "true", failure to find a running instance is ignored
sendSignalToRunningInstance () {
	local signal="$1"; shift
	local graceful=false
	local pidFile
	local pid=""

	if test $# -gt 0; then
		if ! test "$1" = true && ! test "$1" = false; then
			printMsgError "Argument \"graceful\" must be either \"true\" or \"false\", was \"$1\""
			return 1
		fi
		graceful="$1"; shift
	fi
	pidFile="$(getPidFile)"
	if test -f "$pidFile" && pid="$(getPidFromFile "$pidFile")" && test "$pid" != ""; then
		printMsgInfo "Sending signal \"$signal\" to pulse-autoconf instance with process ID $pid"
		if ! kill -s "$signal" "$pid"; then
			printMsgError "Failed to send signal \"$signal\" to pulse-autoconf instance with process ID $pid"
			return 1
		fi
	else
		if ! $graceful; then
			printMsgError "Could not find a running instance of pulse-autoconf"
			return 1
		else
			printMsgInfo "No running instance of pulse-autoconf found, signal \"$signal\" was not sent"
		fi
	fi
}

# runActionListSinksAndSources [showMonitors] [sleepTime]
#
# Prints a table-style listing of the sinks and sources that are
# currently present in the PulseAudio server to STDOUT
#
# showMonitors can be "true" or "false" and controls whether monitor
# sources are shown, default (if not given) is "false"
#
# sleepTime can be anything that is understood by the "sleep" command;
# if given, and if it is not the empty string, then this action will
# enter an infinite clear-and-print loop using this sleep time
runActionListSinksAndSources () {
	local showMonitors=false
	local sleepTime=""
	local sinksAndSourcesPrevious=""
	local sinksAndSources

	if test $# -gt 0; then
		showMonitors="$1"; shift
	fi
	if test $# -gt 0; then
		sleepTime="$1"; shift
	fi

	printMsgInfo "Listing sinks and sources"
	if test "$sleepTime" != ""; then
		while true; do
			if ! sinksAndSources="$(listSinksAndSources "$showMonitors")"; then
				return 1
			fi
			if test "$sinksAndSources" != "$sinksAndSourcesPrevious"; then
				sinksAndSourcesPrevious="$sinksAndSources"
				clear
				echo "$sinksAndSources"
			fi
			if ! sleep "$sleepTime"; then
				# Prevent the loop from running without sleep time if a
				# bad sleepTime argument has been given
				sleep "2s"
				# Trigger a redraw, so that the warning printed by sleep
				# does not accumulate in the terminal
				sinksAndSourcesPrevious=""
			fi
		done
	else
		listSinksAndSources "$showMonitors"
	fi
}

# listSinksAndSources [showMonitors]
#
# Prints a table-style listing of the sinks and sources that are
# currently present in the PulseAudio server to STDOUT
#
# showMonitors can be "true" or "false" and controls whether monitor
# sources are shown, default (if not given) is "false"
listSinksAndSources () {
	local showMonitors=false
	local fallbackSinkKeyword
	local fallbackSourceKeyword

	if test $# -gt 0; then
		if ! test "$1" = true && ! test "$1" = false; then
			printMsgError "Argument \"showMonitors\" must be either \"true\" or \"false\", was \"$1\""
			return 1
		fi
		showMonitors="$1"; shift
	fi
	# Retrieve the current fallback sink and source, and craft them into
	# keywords that can be used in a basic sed expression, i.e. escape
	# a bunch of special characters
	# After that, prepend and append a tabulator
	fallbackSinkKeyword="$(getFallbackSink | sed -e 's/[]\/$*.^[]/\\&/g')"
	fallbackSinkKeyword="	$fallbackSinkKeyword	"
	fallbackSourceKeyword="$(getFallbackSource | sed -e 's/[]\/$*.^[]/\\&/g')"
	fallbackSourceKeyword="	$fallbackSourceKeyword	"
	{	echo "ID	F	Sink	Driver	Sample Specification	State"
		echo "--	-	------------------------	------------------	--------------------	---------"
		# The first sed invocation inserts a new column in front of the
		# Name column, and the second sed invocation inserts an asterisk
		# in that new column if the line contains the default sink
		pactl list short sinks \
			| sed -re 's/^([^\t]*)\t/\1\t\t/' \
			| sed -e "s/$fallbackSinkKeyword/*$fallbackSinkKeyword/"
		echo ""
		if $showMonitors; then
			echo "ID	F	Source	Driver	Sample Specification	State"
			echo "--	-	------------------------	------------------	--------------------	---------"
			pactl list short sources \
				| sed -re 's/^([^\t]*)\t/\1\t\t/' \
				| sed -e "s/$fallbackSourceKeyword/*$fallbackSourceKeyword/"
		else
			echo "ID	F	Source (monitors hidden)	Driver	Sample Specification	State"
			echo "--	-	------------------------	------------------	--------------------	---------"
			pactl list short sources | grep --invert-match ".monitor	" \
				| sed -re 's/^([^\t]*)\t/\1\t\t/' \
				| sed -e "s/$fallbackSourceKeyword/*$fallbackSourceKeyword/"
		fi
	} | listSinksAndSourcesColumn
}

# Invokes the "column" command line application for the
# "list-sinks-and-sources" action
# Uses different arguments, depending on which variant is present (util-linux
# or BSD).
listSinksAndSourcesColumn () {

	if test "$columnProgramVariant" = "util-linux"; then
		# Util-linux variant
		column --table --separator "	" \
			--table-columns "ID,F,Name,Driver,Sample Specification,State" \
			--table-right "ID" \
			--table-truncate "Name" \
			--table-empty-lines \
			--table-noheadings \
			--output-width "$(tput cols)"
	else
		# BSD variant
		column -t -s "	" -e -n -c "$(tput cols)"
	fi
}

# Determines which "column" command line application is present on the system
# If it is the util-linux variant, prints "util-linux"
# If it is the BSD variant, prints "bsd"
getColumnProgramVariant () {

	if echo "a	b	c" | column --table --separator "	" \
			--table-columns "ColA,ColB,ColC" \
			--table-right "ColA" \
			--table-truncate "ColB" \
			--table-empty-lines \
			--table-noheadings \
			--output-width 80 &> /dev/null; then
		# Invocation with util-linux arguments successful: Must be the
		# util-linux variant (used by e.g. Arch Linux and Ubuntu >= 22.04)
		echo "util-linux"
	else
		# Invocation with util-linux arguments failed: Assume it is the BSD
		# variant (used by e.g. Ubuntu <= 20.04)
		echo "bsd"
	fi
}

# runActionInteractiveLoopback source sink [sink]...
#
# Starts an interactive command line session where the user can start
# and stop a loopback from the given source to any of the given sinks
runActionInteractiveLoopback () {
	local source
	local sinks=()
	local userSelection=""
	local promptMsgLevel
	local promptAction
	local promptMessage
	local -i sinkIndexPrevious
	local -i sinkIndex
	local -i sinkIndexTmp

	# Make sure we have at least the source and a single sink
	if test $# -le 1; then
		printMsgError "At least two arguments required: source sink [sink]..."
		return 1
	fi

	# Read the arguments
	source="$1"; shift
	while test $# -gt 0; do
		sinks+=("$1"); shift
	done

	# Validate source and sinks and warn if any of them are not
	# available
	if ! getDevice sources "" false "exact:$source" 1> /dev/null; then
		printMsgWarning "Source \"$source\" not found, recording from it might fail"
	fi
	sinkIndexTmp=0
	while test "$sinkIndexTmp" -lt ${#sinks[@]}; do
		if ! getDevice sinks "" false "exact:${sinks[$sinkIndexTmp]}" 1> /dev/null; then
			printMsgWarning "Sink \"${sinks[$sinkIndexTmp]}\" not found, playing to it might fail"
		fi
		sinkIndexTmp=$(( sinkIndexTmp + 1 ))
	done
	unset sinkIndexTmp

	# Register trap that calls unloadModules() on termination
	trap unloadModules EXIT

	# Initial sink is the first sink
	sinkIndex=0
	# Initial action is to print the help
	userSelection="h"

	while true; do
		sinkIndexPrevious="$sinkIndex"
		promptMsgLevel=" INFO"
		unset promptAction
		promptMessage=""

		if test "$userSelection" = ""; then
			# Enter only: Print current status
			promptAction="Status"
			if test "${#modulesLoaded[@]}" -ge 1; then
				promptMessage="Currently playing to \"${sinks[$sinkIndex]}\", [m]ute to stop, [h]elp or [?] to show controls"
			else
				promptMessage="Current sink is \"${sinks[$sinkIndex]}\", [u]nmute to start loopback, [h]elp or [?] to show controls"
			fi

		elif test "$userSelection" = "h" || test "$userSelection" = "?"; then
			# [h]elp or [?]: Print source and sinks, along with controls
			promptAction="Help"
			echo "$promptMsgLevel  [${promptAction}] Interactive loopback: Audio source is \"$source\"" >&2
			sinkIndexTmp=0
			while test "$sinkIndexTmp" -lt ${#sinks[@]}; do
				echo "$promptMsgLevel  [${promptAction}] Controls: [$(( sinkIndexTmp + 1 ))] or substring to use sink \"${sinks[$sinkIndexTmp]}\"" >&2
				sinkIndexTmp=$(( sinkIndexTmp + 1 ))
			done
			unset sinkIndexTmp
			echo "$promptMsgLevel  [${promptAction}] Controls: [n]ext sink, [p]revious sink, [m]ute, [u]nmute, [?h]elp, [q]uit" >&2
			if test "${#modulesLoaded[@]}" -ge 1; then
				promptMessage="Currently playing to \"${sinks[$sinkIndex]}\", [m]ute to stop"
			else
				promptMessage="Current sink is \"${sinks[$sinkIndex]}\", [u]nmute to start loopback"
			fi

		elif test "$userSelection" = "m"; then
			# "m" / "mute": Unload the loopback
			promptAction="Mute"
			if test "${#modulesLoaded[@]}" -ge 1; then
				setStandaloneLoopback "$source" "" || true
				promptMessage="Stopped playing to \"${sinks[$sinkIndex]}\", [u]nmute to resume"
			else
				promptMessage="Already muted, [u]nmute to start loopback to \"${sinks[$sinkIndex]}\""
			fi

		elif test "$userSelection" = "u"; then
			# "u" / "unmute": Set up the loopback
			promptAction="Unmute"
			if test "${#modulesLoaded[@]}" -eq 0; then
				if setStandaloneLoopback "$source" "${sinks[$sinkIndex]}"; then
					promptMessage="Now playing to \"${sinks[$sinkIndex]}\", [m]ute to stop"
				else
					promptMsgLevel=" WARN"
					promptMessage="Could not start loopback to \"${sinks[$sinkIndex]}\", [u]nmute to try again"
				fi
			else
				promptMessage="Already playing to \"${sinks[$sinkIndex]}\", [m]ute to stop"
			fi

		elif test "$userSelection" = "q"; then
			# "q" / "quit": Return immediately
			# The trap function will unload any running loopbacks
			return 0

		else
			if test "$userSelection" = "n"; then
				# "n" / "next": Switch to next sink
				promptAction="Next"
				sinkIndex="$(incrDecrIndex ${#sinks[@]} "$sinkIndex" true)"

			elif test "$userSelection" = "p"; then
				# "p" / "previous": Switch to the previous sink
				promptAction="Previous"
				sinkIndex="$(incrDecrIndex ${#sinks[@]} "$sinkIndex" false)"

			elif echo "$userSelection" | grep --quiet --extended-regexp --line-regexp --regexp '[1-9][0-9]*' \
				&& test "$userSelection" -ge 1 && test "$userSelection" -le ${#sinks[@]}; then
				# 1-based sink index: Switch to that sink
				promptAction="Index"
				sinkIndex=$(( userSelection - 1 ))

			else
				# Something else: Attempt to select the first sink that
				# contains the entered string as substring
				if sinkIndexTmp="$(findIndexBySubstring "$userSelection" "${sinks[@]}")"; then
					promptAction="Substring"
					sinkIndex="$sinkIndexTmp"
				else
					# Unknown input: Tell the user to get their shit
					# together
					promptMsgLevel=" WARN"
					promptMessage="Unknown selection \"$userSelection\", try [h]elp or [?] to show controls"
				fi
			fi

			# Handle sink switch if required
			# Do nothing if a prompt message has already been set
			if test "$promptMessage" = ""; then
				if test "${#modulesLoaded[@]}" -ge 1; then
					if test "$sinkIndex" -ne "$sinkIndexPrevious"; then
						if setStandaloneLoopback "$source" "${sinks[$sinkIndex]}"; then
							promptMessage="Now playing to \"${sinks[$sinkIndex]}\""
						else
							promptMsgLevel=" WARN"
							promptMessage="Could not start loopback to \"${sinks[$sinkIndex]}\", [u]nmute to try again"
						fi
					else
						promptMessage="Already playing to \"${sinks[$sinkIndex]}\", [m]ute to stop"
					fi
				else
					# If muted just prompt "Switched to xyz"
					if test "$sinkIndex" -ne "$sinkIndexPrevious"; then
						promptMessage="Switched to sink \"${sinks[$sinkIndex]}\", [u]nmute to start loopback"
					else
						promptMessage="Current sink already is \"${sinks[$sinkIndex]}\", [u]nmute to start loopback"
					fi
				fi
			fi
		fi

		# Display the user input prompt
		read -rp "$promptMsgLevel  ${promptAction+[$promptAction] }${promptMessage} > " userSelection >&2
	done
}

# incrDecrIndex arraySize index [increment]
#
# Calculates an incremented or decremented array index
# Does not wrap around, e.g. an attempt to increment (arraySize - 1)
# returns (arraySize - 1), and an attempt to decrement 0 returns 0
# The "increment" argument, if given, must be "true" or "false"; "true"
# to increment by 1 and "false" to decrement by 1
# Default (if not given or an unknown value) is to increment by 1
incrDecrIndex () {
	local -i arraySize="$1"; shift
	local -i index="$1"; shift
	local increment=true

	if test $# -gt 0 && test "$1" = false; then
		increment=false; shift
	fi

	if $increment; then
		index=$(( index + 1 ))
		if test "$index" -ge "$arraySize"; then
			index=$(( arraySize - 1 ))
		fi
	else
		index=$(( index - 1 ))
		if test "$index" -lt 0; then
			index=0
		fi
	fi
	echo "$index"
}

# findIndexBySubstring substring [string]...
#
# Prints the 0-based index of the first [string]... argument that
# contains the given substring
# If none of them do, prints nothing and returns with code 1
findIndexBySubstring () {
	local substring="$1"; shift
	local index=0
	local string

	while test $# -gt 0; do
		string="$1"; shift
		if echo "$string" | grep --quiet --fixed-strings --regexp "$substring" 1> /dev/null; then
			echo "$index"
			return 0
		fi
		index=$(( index + 1 ))
	done
	return 1
}

# setStandaloneLoopback source sink
#
# Unloads all modules that are present in $modulesLoaded, then
# sets up a loopback from source to sink
# If the sink is the empty string then only unloads all modules
setStandaloneLoopback () {
	local source="$1"; shift
	local sink="$1"; shift

	# Unload all modules
	unloadModules

	# If no sink has been specified then that's it
	if test "$sink" = ""; then
		return 0
	fi

	# Set up the new loopback
	loadModule module-loopback "${loopbackParams[@]}" source="$source" sink="$sink"
}

# runActionListDependencies [one-line|multi-line|detailed] program [programArgument]...
#
# Looks up the packages providing the programs required by pulse-autoconf and
# prints them to STDOUT, in alphabetically ascending order
# The program and its arguments must be something that looks up the owning
# package of an executable program file, which is given as an absolute path
# Examples:
#  - Arch Linux:       pacman --query --quiet --owns
#  - Debian or Ubuntu: dpkg --search
runActionListDependencies () {
	local outputStyle
	local program
	local programFile
	local package
	local package2
	local -A packagesWithPrograms
	local packagesAlphabetical=()
	local subsequentPackage

	# Basic arguments validation
	if test $# -le 1; then
		printMsgError "Two or more arguments required: \"one-line\" or \"multi-line\" or \"detailed\" followed by the program and its arguments that returns the owning package of an executable"
		return 1
	fi

	# Read and validate the output style
	outputStyle="$1"; shift
	if test "$outputStyle" != "one-line" \
		&& test "$outputStyle" != "multi-line" \
		&& test "$outputStyle" != "detailed"; then
		printMsgError "Invalid output style \"$outputStyle\", must be one of: \"one-line\", \"multi-line\", \"detailed\""
		return 1
	fi

	# Determine the packages providing the required programs
	printMsgInfo "Looking up packages that provide required programs with \"$*\""
	for program in "${requiredPrograms[@]}"; do

		if ! isCommandType file "$program"; then
			continue # Not a regular executable: Assume it is a shell builtin and skip it
		fi
		programFile="$(type -p -- "$program")"
		if package="$("$@" "$programFile")"; then
			# Extract the first word from the first line
			package="$(echo "$package" | head -n 1 | sed -nre 's/^([-_a-zA-Z0-9]+).*$/\1/p')"
			if test "$package" = ""; then
				# Package lookup output could not be parsed: Assign program to
				# a reserved fake package
				package="Package lookup failed"
			fi
		else
			# Package lookup failed: Assign program to a reserved fake package
			package="Package lookup failed"
		fi
		# Put the found package into the associative array as key and add the
		# program it provides to the value(s)
		if test -z ${packagesWithPrograms["$package"]+x}; then
			packagesWithPrograms["$package"]="$program"
		else
			packagesWithPrograms["$package"]="${packagesWithPrograms["$package"]}, $program"
		fi
	done

	# Create a list of the found packages in alphabetical order
	while read -rs package; do
		if test "$package" = "Package lookup failed"; then continue; fi # Not that one
		packagesAlphabetical+=("$package")
	done < <(for package2 in "${!packagesWithPrograms[@]}"; do echo "$package2"; done | sort)

	# Warn about failed lookups
	if ! test -z ${packagesWithPrograms["Package lookup failed"]+x}; then
		printMsgWarning "Failed to look up the packages providing these program(s): ${packagesWithPrograms["Package lookup failed"]}"
	fi

	# Print the packages and which programs they provide, in alphabetical order
	subsequentPackage=false
	for package in "${packagesAlphabetical[@]}"; do
		if test "$outputStyle" = "one-line"; then
			$subsequentPackage && echo -n " $package" || echo -n "$package"
		elif test "$outputStyle" = "multi-line"; then
			echo "$package"
		elif test "$outputStyle" = "detailed"; then
			echo "Package \"$package\" provides these program(s): ${packagesWithPrograms["$package"]}"
		else
			printMsgError "Invalid output style \"$outputStyle\", must be one of: \"one-line\", \"multi-line\", \"detailed\""
			return 1
		fi
		subsequentPackage=true
	done
	if $subsequentPackage && test "$outputStyle" = "one-line"; then
		echo ""
	fi

	# Indicate package lookup failures with a return code of 2
	if test -z ${packagesWithPrograms["Package lookup failed"]+x}; then return 0; else return 2; fi
}


# Built-in preset: EchoCancellation
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellation preset
# Also updates the global variables $ecSinkMaster and $ecSourceMaster,
# which are read by the EchoCancellation preset
isSetupRequiredEchoCancellation () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor )

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# isModuleMissing [moduleId]...
#
# Tests if the given module IDs are present in the PulseAudio server
# Returns with 0 if one or more IDs are missing
isModuleMissing () {
	local allModules
	local loadedModule

	allModules="$(pactl list short | sed -nre 's/^([0-9]+)\t.*$/\1/p')"
	while test $# -gt 0; do
		loadedModule="$1"; shift
		if ! echo "$allModules" | grep  --quiet --line-regexp --fixed-strings --regexp "$loadedModule"; then
			return 0
		fi
	done
	return 1
}

# Updates the global variables $ecSinkMaster and $ecSourceMaster, which
# are read by the EchoCancellation preset
# Returns with code 0 if any of the variables have changed
getNewEchoCancellationMasters () {
	local ecSinkMasterNew
	local ecSourceMasterNew
	local -i returnCode=1

	# Determine the new echo cancellation sink and source masters
	if ! ecSinkMasterNew="$(getEcSinkMaster)"; then
		ecSinkMasterNew=""
	fi
	if ! ecSourceMasterNew="$(getEcSourceMaster "$ecSinkMasterNew")"; then
		ecSourceMasterNew=""
	fi

	# Check whether the new masters differ from the ones from the
	# previous call of this method
	if test "$ecSinkMasterNew" != "$ecSinkMaster"; then
		printMsgDebug "Echo cancellation sink master changed from \"$ecSinkMaster\" to \"$ecSinkMasterNew\""
		ecSinkMaster="$ecSinkMasterNew"
		returnCode=0
	fi
	if test "$ecSourceMasterNew" != "$ecSourceMaster"; then
		printMsgDebug "Echo cancellation source master changed from \"$ecSourceMaster\" to \"$ecSourceMasterNew\""
		ecSourceMaster="$ecSourceMasterNew"
		returnCode=0
	fi

	return "$returnCode"
}

# setupEchoCancellation
#
# Preset that maintains echo cancellation between a master source and
# sink
setupEchoCancellation () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		printMsgInfo "Could not find a sink master, not setting up echo cancellation"
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		printMsgInfo "Could not find a source master, not setting up echo cancellation"
		return 0
	fi
	printMsgInfo "Echo cancellation sink master is \"$ecSinkMaster\""
	printMsgInfo "Echo cancellation source master is \"$ecSourceMaster\""

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Set up echo cancellation between the master sink and source
	printMsgDebug "Setting up echo cancellation"
	loadModule module-echo-cancel "${ecParams[@]}" \
		  sink_master="$ecSinkMaster"     sink_name="${sinkMain[0]}"     sink_properties="device.description=${sinkMain[1]}" \
		source_master="$ecSourceMaster" source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new virtual echo cancellation sink and source as fallbacks
	printMsgDebug "Setting fallback devices"
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: EchoCancellationWithSourcesMix
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellationWithSourcesMix
# preset
isSetupRequiredEchoCancellationWithSourcesMix () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSinkMastersIgnorePreset+=("${sinkEffects[0]}")
	ecSinkMastersIgnorePreset+=("${sinkMix[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sourceEc[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor)
	ecSourceMastersIgnorePreset+=("${sinkEffects[0]}".monitor)
	ecSourceMastersIgnorePreset+=("${sinkMix[0]}".monitor)

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# setupEchoCancellationWithSourcesMix
#
# Preset that maintains echo cancellation between a master source and
# sink, and that provides a way to mix arbitrary sound effects into the
# fallback source's audio via a special virtual sink "sink_fx"
# Intended for streaming setups, or if you just want to annoy the hell
# out of the other participants of a voice chat or video call by playing
# obnoxious sound effects or music on your microphone stream
setupEchoCancellationWithSourcesMix () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		printMsgInfo "Could not find a sink master, not setting up echo cancellation"
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		printMsgInfo "Could not find a source master, not setting up echo cancellation"
		return 0
	fi
	printMsgInfo "Echo cancellation sink master is \"$ecSinkMaster\""
	printMsgInfo "Echo cancellation source master is \"$ecSourceMaster\""

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Set up echo cancellation between the master sink and source
	printMsgDebug "Setting up echo cancellation"
	loadModule module-echo-cancel "${ecParams[@]}" \
		  sink_master="$ecSinkMaster"     sink_name="${sinkMain[0]}"   sink_properties="device.description=${sinkMain[1]}" \
		source_master="$ecSourceMaster" source_name="${sourceEc[0]}" source_properties="device.description=${sourceEc[1]}"

	# Create virtual output devices
	printMsgDebug "Creating virtual output devices"
	loadModule module-null-sink "${nullSinkParams[@]}" sink_name="${sinkEffects[0]}" sink_properties="device.description=${sinkEffects[1]}"
	loadModule module-null-sink "${nullSinkParams[@]}" sink_name="${sinkMix[0]}"     sink_properties="device.description=${sinkMix[1]}"

	# Create remaps
	printMsgDebug "Creating remaps"
	loadModule module-remap-source "${remapSourceParams[@]}" master="${sinkMix[0]}".monitor \
		source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new fallbacks before creating the loopbacks
	# A case has been reported where creating the loopbacks and then
	# setting the fallbacks made the loopback devices change their sink
	# to the main sink, messing up the whole setup
	printMsgDebug "Setting fallback devices"
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Create loopbacks
	printMsgDebug "Creating loopbacks"
	loadModule module-loopback "${loopbackParams[@]}" source="${sourceEc[0]}"            sink="${sinkMix[0]}"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffects[0]}.monitor" sink="${sinkMix[0]}"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffects[0]}.monitor" sink="${sinkMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: EchoCancellationPlacebo
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the EchoCancellationPlacebo preset
isSetupRequiredEchoCancellationPlacebo () {

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecSinkMastersIgnorePreset=()
	ecSinkMastersIgnorePreset+=("${sinkMain[0]}")
	ecSinkMastersIgnorePreset+=("${sinkEffects[0]}")
	ecSourceMastersIgnorePreset=()
	ecSourceMastersIgnorePreset+=("${sourceMain[0]}")
	ecSourceMastersIgnorePreset+=("${sinkMain[0]}".monitor )
	ecSourceMastersIgnorePreset+=("${sinkEffects[0]}".monitor)

	# Determine the new echo cancellation sink and source masters
	if getNewEchoCancellationMasters; then
		return 0;
	fi

	# Make sure all modules loaded by the preset are still present
	isModuleMissing "${modulesLoaded[@]}"
}

# Preset that chooses a master sink and source and renames/remaps them
# to $sinkMain and $sourceMain, respectively
#
# Mimics the result of the EchoCancellation preset minus the actual echo
# cancellation, for when no echo cancellation is desired yet the virtual
# master devices should remain available for applications
setupEchoCancellationPlacebo () {

	# Validate the echo cancellation sink and source masters
	if test "$ecSinkMaster" = ""; then
		printMsgInfo "Could not find a sink master, not creating placebo devices"
		return 0
	fi
	if test "$ecSourceMaster" = ""; then
		printMsgInfo "Could not find a source master, not creating placebo devices"
		return 0
	fi
	printMsgInfo "Placebo sink master is \"$ecSinkMaster\""
	printMsgInfo "Placebo source master is \"$ecSourceMaster\""

	# Create the dummy source and dummy sink if required
	createDummySinkIfRequired "$ecSinkMaster"
	createDummySourceIfRequired "$ecSourceMaster"

	# Create remaps
	printMsgDebug "Creating remaps"
	loadModule module-remap-sink "${remapSinkParams[@]}" master="$ecSinkMaster" \
		sink_name="${sinkMain[0]}" sink_properties="device.description=${sinkMain[1]}"
	loadModule module-remap-sink "${remapSinkParams[@]}" master="$ecSinkMaster" \
		sink_name="${sinkEffects[0]}" sink_properties="device.description=${sinkEffects[1]}"
	loadModule module-remap-source "${remapSourceParams[@]}" master="$ecSourceMaster" \
		source_name="${sourceMain[0]}" source_properties="device.description=${sourceMain[1]}"

	# Set the new fallbacks
	printMsgDebug "Setting fallback devices"
	pactl set-default-sink   "${sinkMain[0]}"
	pactl set-default-source "${sourceMain[0]}"

	# Restore streams to the fallback sink and source
	restoreStreamsOnFallbackDevices
}


# Built-in preset: None
# ----------------------------------------------------------------------

# Custom isSetupRequired code for the None preset
isSetupRequiredNone () {

	# This preset does nothing, so it does not care about anything
	# happening in the PulseAudio server
	return 1
}

# Preset that does nothing, intended to "switch off" pulse-autoconf
setupNone () {

	# TODO This is only - maybe - relevant when pulse-autoconf has
	# transitioned from another preset to None
	# But is it really required? Likely PulseAudio has already moved
	# streams that were using the previous preset's virtual devices to
	# the new fallback devices
	restoreStreamsOnFallbackDevices
}


# Sets/restores the default settings
#
# For more in-depth documentation about some settings look at the auto-
# generated template configuration file, or directly at the
# printTemplateConfigurationFile() function
setDefaultSettings () {

	# The desired preset, i.e. the configuration that should be
	# maintained in the PulseAudio server
	preset="EchoCancellation"

	# Echo cancellation master finding: Whether to prefer newer devices
	# over older devices
	ecSinkMastersPreferNewer=false
	ecSourceMastersPreferNewer=false

	# Echo cancellation: The parameters that should be used for
	# module-echo-cancel
	ecParams=()
	ecParams+=(aec_method=webrtc)
	ecParams+=(use_master_format=1)
	ecParams+=(aec_args="analog_gain_control=0\\ digital_gain_control=1\\ experimental_agc=1\\ noise_suppression=1\\ voice_detection=1\\ extended_filter=1")

	# Loopbacks: The parameters that should be used for module-loopback
	loopbackParams=()
	loopbackParams+=(latency_msec=60)
	loopbackParams+=(max_latency_msec=100)
	loopbackParams+=(adjust_time=6)

	# Echo cancellation master finding: Patterns for device names in
	# descending order of priority
	ecSinkMasters=()
	ecSinkMasters+=("startswith:") # Any sink
	ecSourceMasters=()
	ecSourceMasters+=("notendswith:.monitor") # Exclude monitor sources

	# Echo cancellation: Whether to create and use a dummy sink as sink
	# master if no real sink master can be found
	# Yes, by default PulseAudio loads its "module-always-sink" module,
	# which automatically creates an "auto_null" sink if no sinks are
	# present, but we cannot use it, because it disappears again as soon
	# as a preset creates a virtual sink
	ecUseDummySink=true

	# Echo cancellation: Whether to create and use a dummy source as
	# source master if no real source master can be found
	ecUseDummySource=true

	# Null sinks: The parameters that should be used for
	# module-null-sink
	nullSinkParams=()

	# Null sources: The parameters that should be used for
	# module-null-source
	nullSourceParams=()

	# Sink remaps: The parameters that should be used for
	# module-remap-sink
	remapSinkParams=()

	# Source remaps: The parameters that should be used for
	# module-remap-source
	remapSourceParams=()

	# Names and descriptions that should be used for virtual devices
	# Names may not contain blanks
	# Descriptions may contain blanks, but quoting is fickle
	# If a description contains blanks you need to
	#  - Enclose the entire description in double quotes (")
	#  - Escape each blank with a leading backslash (\)
	sinkMain=(    'sink_main'  '"Main\ sink\ (play\ everything\ here)"'       ) # The primary sink
	sinkDummy=(   'sink_dummy' '"Dummy\ sink\ (do\ not\ use)"'                ) # The dummy sink
	# shellcheck disable=SC2034  # Unused variable "sinkEc"
	sinkEc=(      'sink_ec'    '"Echo-cancelled\ sink\ (do\ not\ use)"'       ) # The echo-cancelled sink
	sinkEffects=( 'sink_fx'    '"Effects\ sink\ (play\ shared\ music\ here)"' ) # The audio effects sink
	sinkMix=(     'sink_mix'   '"Mixing\ sink\ (do\ not\ use)"'               ) # The mixing sink
	sourceMain=(  'src_main'   '"Main\ source\ (record\ from\ here)"'         ) # The primary source
	sourceDummy=( 'src_dummy'  '"Dummy\ source\ (do\ not\ use)"'              ) # The dummy source
	sourceEc=(    'src_ec'     '"Echo-cancelled\ source\ (do\ not\ use)"'     ) # The echo-cancelled source

	# Echo cancellation master finding: Exact names of devices that
	# should *never* be considered as echo cancellation masters
	ecSinkMastersIgnore=()
	ecSinkMastersIgnore+=("${sinkDummy[0]}")           # The dummy sink
	ecSinkMastersIgnore+=("auto_null")                 # The sink of module-always-sink
	ecSourceMastersIgnore=()
	ecSourceMastersIgnore+=("${sourceDummy[0]}")       # The dummy source
	ecSourceMastersIgnore+=("${sinkDummy[0]}".monitor) # The monitor of the dummy sink
	ecSourceMastersIgnore+=("auto_null".monitor)       # The monitor of module-always-sink's sink

	# How long the main loop should sleep, in seconds, without unit, before
	# polling the PulseAudio server again
	# If a decimal value is given, the decimal separator must be a period
	# Examples: "5", "4.5"
	sleepTime="5"

	# The time span, in seconds, without unit, starting at system startup,
	# during which pulse-autoconf should wait a short time before using
	# PulseAudio/pactl
	# If a decimal value is given, the decimal separator must be a period
	# Examples: "5", "4.5"
	# May be the empty string, in which case pulse-autoconf will always wait,
	# no matter how long ago the system was started
	# Part of a workaround for what is believed to be an ALSA glitch, see
	# documentation for $initialBackoffSleepTime below
	initialBackoffMaxTime="60"

	# How long, after pulse-autoconf has been started, pulse-autoconf should
	# wait before attempting to use PulseAudio/pactl
	# Only used if pulse-autoconf has been started within a small time window
	# directly after system startup
	# Must be something that is understood by the "sleep" program
	# May be the empty string if pulse-autoconf should not wait
	# Is a workaround for what may be an ALSA glitch that may occur if
	# PulseAudio is used immediately after system startup
	# Apparently starting PulseAudio or using some of its functionality with
	# "pactl" early after system startup can cause ALSA to fail to correctly
	# detect available audio device profiles, leading to missing profiles
	# On systems that boot up quickly and automatically start a graphical
	# user session this glitch may be exposed when pulse-autoconf is
	# auto-started with the user session
	initialBackoffSleepTime="2s"

	# How long, after the system has resumed from suspend, pulse-autoconf
	# should wait before running the handleResume() function
	# Must be something that is understood by the "sleep" program
	# May be the empty string if pulse-autoconf should not wait
	sleepTimeResume="1s"

	# Special action "edit-config": Text editor with arguments to use if no
	# editor is supplied with action arguments
	editorCustomWithArgs=()

	# Special action "edit-config": Which configuration file should be edited,
	# i.e. which is the default user-level configuration file
	actEditConfigConfigFile=~/.config/pulse-autoconf/pulse-autoconf.d/50-edit-config.conf

	# Special action "edit-config": Fallback text editors to try in order of
	# declaration if no editor has been specified, $editorCustomWithArgs is
	# empty and $EDITOR is not set
	editorFallbacks=()
	editorFallbacks+=(nano)
	editorFallbacks+=(vim)
	editorFallbacks+=(emacs)
	editorFallbacks+=(pico)
	editorFallbacks+=(vi)

	# Special action "set-preset": Which configuration file should be replaced
	# when setting the preset
	actSetPresetConfigFile=~/.config/pulse-autoconf/pulse-autoconf.d/90-set-preset.conf

	# Whether to print DEBUG messages to STDERR
	verbose=false

	# Migration from previous versions:
	# Starting with pulse-autoconf 1.8.0 the default user-level configuration
	# file has moved to a new location
	# If certain prerequisites are met, automatically move the configuration
	# file from the old to the new location
	migrateConfigPre180=true
}


# Declare global statics
# ----------------------------------------------------------------------

# The required command line programs / shell builtins
requiredPrograms=()
requiredPrograms+=(bash) # Included for action "list-dependencies"
requiredPrograms+=(bc)
requiredPrograms+=(cat)
requiredPrograms+=(column)
requiredPrograms+=(find)
requiredPrograms+=(grep)
requiredPrograms+=(id)
requiredPrograms+=(kill)
requiredPrograms+=(pactl)
requiredPrograms+=(sed)
requiredPrograms+=(stat)
requiredPrograms+=(tac)
requiredPrograms+=(tr)


# Basic startup checks
# ----------------------------------------------------------------------

# Make sure the required programs are available
allRequiredProgramsPresent=true
requiredProgram=""
for requiredProgram in "${requiredPrograms[@]}"; do
	if ! type "$requiredProgram" &> /dev/null; then
		allRequiredProgramsPresent=false
		printMsgError "Required program \"$requiredProgram\" is not available"
	fi
done
$allRequiredProgramsPresent || exit 1
unset requiredProgram
unset allRequiredProgramsPresent


# Global state variables
# ----------------------------------------------------------------------

# A string that identifies the PulseAudio instance, to recognize new
# instances
instanceId=""

# The user-level configuration files that are currently effective
# Used to check whether they have been modified
# Key is the file path, value is file size and modification time,
# separated by a space
declare -A configFilesMonitored

# Modules loaded by the presets (their IDs), are unloaded by teardown()
modulesLoaded=()

# Echo cancellation: The dynamically determined device that is used as
# sink master
ecSinkMaster=""
# Echo cancellation: The dynamically determined device that is used as
# source master
ecSourceMaster=""
# Exact names of devices that the current preset does not want to be
# chosen as echo cancellation masters, such as the virtual devices
# created by the echo cancellation module itself, along with other
# virtual devices created by the preset
ecSinkMastersIgnorePreset=()
ecSourceMastersIgnorePreset=()

# Streams that are recording from the fallback/default source, or
# playing to the fallback/default sink
# teardown() populates these arrays before it unloads the modules
# The setupPreset() functions may (and likely should) restore the
# streams to the new fallback devices by calling
# restoreStreamsOnFallbackDevices()
streamsPlayingToFallbackSink=()
streamsRecordingFromFallbackSource=()

# PID of the main loop's sleep process (set to the empty string while
# not in use)
sleepPid=""

# The path of the PID file for which the lock is currently being held
# (set to the empty string while not in possession of a lock)
pidFile=""

# If this is true, the main loop will skip sleeping during its next iteration
# Is reset to false by the main loop
resumeMainLoop=false

# True to leave the main loop and terminate
stopped=false

# If this is true, then the main loop will unload and re-apply all
# trailing loopbacks of the currently active preset on its next iteration
reloadLoopbacks=false

# If this is true, then the next call of isSetupRequired() will return
# with true
reloadPreset=false

# If this is true, then the next call of setup() will reload the
# configuration from the configuration files
reloadConfig=false

# If this is true, then the most recent call of getInstanceLock()
# returned with a non-zero return code
instanceLockFailed=false

# The maximum duration that a single main loop iteration may run
# without triggering handleResume()
declare -i handleResumeTimeoutMillis

# Point in time when the main loop most recently entered its sleep section
# after doing its tasks (epoch milliseconds)
declare -i mainLoopLastEndEpochMillis

# Time span in milliseconds since $mainLoopLastEndEpochMillis; used to
# determine whether handleResume() should be called
declare -i millisSinceMainLoopLastEnd

# Which kind of "column" command line application is available, e.g. the one
# from util-linux (e.g. Arch Linux, Ubuntu >= 22.04) or the limited one from
# BSD (e.g. Ubuntu <= 20.04)
columnProgramVariant=""

# Time span from system startup during which to apply the initial backoff, in
# milliseconds
# May be the empty string, in which case the initial backoff is always applied
# Part of a workaround for what is believed to be an ALSA glitch, see
# documentation for $initialBackoffSleepTime in setDefaultSettings()
initialBackoffMaxTimeMillis=""


# Handler for special single argument "--help"
# ----------------------------------------------------------------------

if test $# -eq 1 && test "$1" = "--help"; then
	printHelpMessage
	exit 0
fi


# Handler for special single argument "--version"
# ----------------------------------------------------------------------

if test $# -eq 1 && test "$1" = "--version"; then
	getVersion
	exit 0
fi


# Apply/load and validate the settings
# ----------------------------------------------------------------------

reloadConfig


# Print an overview
# ----------------------------------------------------------------------

echo -n " INFO  This is pulse-autoconf " >&2; getVersion >&2
printMsgDebug "Verbose output is enabled"


# Handle special action if present
# ----------------------------------------------------------------------

# Run a special action instead of the regular daemon if such an action
# is given as first argument
if test $# -gt 0; then
	actionName="$1"; shift
	declare functionSuffix
	declare actionFunction

	if functionSuffix="$(getFunctionSuffix "$actionName")" \
		&& test "$functionSuffix" != '' \
		&& actionFunction="runAction$functionSuffix" \
		&& isCommandType function "$actionFunction"; then
		# Normalize the action name for use in messages
		actionName="$(getActionName "$functionSuffix")"
		printMsgDebug "Running action \"$actionName\" with $# argument(s)"
		"$actionFunction" "$@" && actionFunctionReturnCode="$?" || actionFunctionReturnCode="$?"
		if test "$actionFunctionReturnCode" != 0 ; then
			printMsgWarning "Action \"$actionName\" returned with code $actionFunctionReturnCode"
		fi
		exit "$actionFunctionReturnCode"
	else
		echo -n "ERROR  Unknown action \"$actionName\", must be one of:" >&2
		# Print all available actions
		while read -rs functionSuffix; do
			# Hide special action "list-dependencies" as it is only of interest to packagers
			if test "$functionSuffix" = "ListDependencies"; then continue; fi
			echo -n " \"" >&2; getActionName "$functionSuffix" | tr -d '\n' >&2; echo -n "\"" >&2
		done < <(declare -F | sed -nre 's/^declare -f //;s/^runAction(.+)$/\1/p')
		echo "" >&2
		printUsageInfo >&2
		exit 1
	fi
fi


# Execute startup delay if required
# ----------------------------------------------------------------------

# Workaround for ALSA sometimes not detecting audio device profiles correctly,
# see declaration of $initialBackoffSleepTime in setDefaultSettings() for
# details
initialBackoff


# Install signal/exit handlers
# ----------------------------------------------------------------------

trap handleExit EXIT
trap handleRequestStop TERM INT QUIT HUP
trap handleSignalUsr1 USR1
trap handleSignalUsr2 USR2


# Enter the main loop
# ----------------------------------------------------------------------

# Initialize that one to something sensible
mainLoopLastEndEpochMillis="$(getNowEpochMillis)"

while ! $stopped; do

	#printMsgDebug "---- Start of main loop iteration ----"
	resumeMainLoop=false
	if isSetupRequired; then
		teardown
		setup || reloadPreset=true
	elif test "${#modulesLoaded[@]}" -ge 1; then
		millisSinceMainLoopLastEnd=$(( $(getNowEpochMillis) - mainLoopLastEndEpochMillis ))
		if test "$millisSinceMainLoopLastEnd" -gt "$handleResumeTimeoutMillis"; then
			printMsgInfo "Resume from suspend detected"
			pause "${sleepTimeResume}" # Back off for a bit to let things settle down after resume
			handleResume || true
		fi
	fi
	if $reloadLoopbacks; then
		reloadLoopbacks
	fi
	mainLoopLastEndEpochMillis="$(getNowEpochMillis)"
	pause "${sleepTime}"
done