#!/bin/bash

# Copyright 2020 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 Parse arguments such as --version, --help, --verbose, --preset
# TODO On setup(), source configuration files again if they have changed
# TODO Boolean option whether to move streams using the fallback devices
#      on startup
# TODO Only apply echo cancellation if there is a recording stream
# TODO Main loop sleep: If the sleep call fails and the loop resumes
#      immediately (e.g. because of a bad $sleepTime), perform a
#      fallback sleep
# TODO Find out if pactl list short <type> has a consistent ordering;
#      if not, impose a custom order
# 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 When restoring streams to the fallback source and sink, do not
#      attempt to move special streams such as peak detectors
# TODO Implement single-instancing, e.g. with a user-level PID file
# TODO Implement trigger mechanism to replace periodic polling of the
#      PulseAudio server
# TODO systemd unit file for systemd --user operation
# TODO Manual page

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

# Semantic versioning
declare -r versionMajor=0
declare -r versionMinor=0
declare -r versionPatch=4
declare -r versionLabel=""

# Prints the version string to STDOUT
getVersion () {
	echo -n "${versionMajor}.${versionMinor}.${versionPatch}"
	test "$versionLabel" != "" && echo "-$versionLabel" || echo ""
}

# isFunction command
#
# Returns with code 0 if the given command refers to a function
# (and not, for example, to a shell builtin or an executable)
isFunction () {
	local command="$1"; shift
	local commandType

	! commandType="$(type -t "$command")" && return 1
	! test "function" = "$commandType" && return 1
	return 0
}

# Returns the paths to all existing valid configuration files, in
# ascending order of priority
#
# The paths are returned in a newline-separated list
getConfigurationFiles () {
	local suffix=".conf"
	local baseDir
	local pathPrefix
	local configDir
	local configFile

	for baseDir in "/usr/lib" "/etc" "/run" ~/".config"; do
		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
}

# Sources all existing configuration files
loadSettingsFromConfigFiles () {
	local -i exitCode
	local configFile

	reloadConfig=false
	while read -rs configFile; do
		echo " INFO  Sourcing configuration file \"$configFile\"" >&2
		# shellcheck source=/dev/null
		source -- "$configFile" && exitCode=$? || exitCode=$?
		if test "$exitCode" -ne 0; then
			echo "ERROR  Could not source configuration file \"$configFile\"" >&2
			exit "$exitCode"
		fi
	done < <(getConfigurationFiles)
}

# Validates the global settings; exits with code 1 if validation failed
validateSettings () {

	if ! test "$verbose" = true && ! test "$verbose" = false; then
		echo "ERROR  \$verbose must be either \"true\" or \"false\", was \"$verbose\"" >&2
		exit 1
	fi
	if ! test "$ecUseDummySource" = true && ! test "$ecUseDummySource" = false; then
		echo "ERROR  \$ecUseDummySource must be either \"true\" or \"false\", was \"$ecUseDummySource\"" >&2
		exit 1
	fi
}

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

	setDefaultSettings
	loadSettingsFromConfigFiles
	validateSettings
}

# Returns with code 0 if the PulseAudio server needs to be set up again
# Calls preset-specific code if the preset implements it
isSetupRequired () {
	local instanceIdNew
	local presetFunction

	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" != "$instanceId"; then
		return 0
	fi

	if $reloadPreset; then
		reloadPreset=false
		return 0
	fi

	# Call the preset's isSetupRequired function if implemented
	presetFunction="isSetupRequired$preset"
	if isFunction "$presetFunction"; then
		if "$presetFunction"; then
			return 0
		fi
	fi

	return 1
}

# 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

	$verbose && echo "DEBUG  Setup" >&2

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

	# Store the PulseAudio server's instance ID
	instanceId="$(getInstanceId)"

	# Validate the configured preset
	presetFunction="setup$preset"
	if ! isFunction "$presetFunction"; then
		local validPreset
		echo -n "ERROR  Unknown preset \"$preset\", available presets: " >&2
		while read -rs validPreset; do
			echo -n " \"$validPreset\"" >&2
		done < <(declare -F | sed -nre 's/^declare -f //;s/^setup(.+)$/\1/p')
		echo >&2
		exit 1
	fi

	# Apply the configured preset to the running PulseAudio server
	echo " INFO  Applying preset \"$preset\"" >&2
	"$presetFunction" && presetFunctionReturnCode="$?" || presetFunctionReturnCode="$?"
	if test "$presetFunctionReturnCode" != 0 ; then
		echo " WARN  Failed to apply preset \"$preset\"" >&2
	fi
	return "$presetFunctionReturnCode"
}

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

	$verbose && echo "DEBUG  Teardown" >&2

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

	# Ensure that the PulseAudio server instance is the same from
	# setup()
	instanceIdNew="$(getInstanceId)"
	if test "$instanceIdNew" != "$instanceId"; then
		$verbose && echo "DEBUG  Instance ID changed from \"$instanceId\" to \"$instanceIdNew\"" >&2
		if test "$instanceId" != ""; then
			echo " WARN  PulseAudio restart detected, not unloading modules" >&2
		fi
		for (( index = ${#modulesLoaded[@]} - 1; index >= 0; index-- )); do
			unset "modulesLoaded[$index]"
		done
		return 0
	fi

	# 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
		pactl unload-module "${modulesLoaded[$index]}" || true
		unset "modulesLoaded[$index]"
	done
}

# 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

	moduleId="$(pactl load-module "$@")" && exitCode=$? || exitCode=$?
	if test "$exitCode" -eq 0; then
		modulesLoaded+=( "$moduleId" )
	else
		echo " WARN  A \"pactl load-module\" call failed with exit code $exitCode. Further arguments: $*" >&2
	fi
	return $exitCode
}

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

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

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

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

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

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

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

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

# 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 ignoredSinkMonitor="$1".monitor; shift
	local dummySourceIfUsed=()

	# If the echo cancellation dummy source is enabled, add it as final
	# fallback option so that it gets picked up even if no pattern
	# matches it
	if $ecUseDummySource; then
		dummySourceIfUsed+=("exact:$sourceDummyName")
	fi
	getFirstAvailableDevice sources "$(getNewlineList "$ignoredSinkMonitor" "${ecMastersIgnore[@]}")" "${ecSourceMasters[@]}" "${dummySourceIfUsed[@]}"
}

# getFirstAvailableDevice deviceType ignoredDevices [pattern]...
#
# The deviceType argument must be one of "sources", "sinks"
# 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
# For each pattern, attempts to find a PulseAudio source/sink that
# matches the pattern
# On the first successful match, prints the PulseAudio source/sink to
# STDOUT and returns with code 0
# If none of the given patterns match a source or sink prints nothing
# and returns with code 1
getFirstAvailableDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; shift
	local pattern

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

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

# getDevice deviceType ignoredDevices pattern
#
# The deviceType argument must be one of "sources", "sinks"
# 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
# If a source or sink matching the given pattern exists, prints the
# first such source/sink's name to STDOUT and returns with code 0
getDevice () {
	local type="$1"; shift
	local ignoredDevices="$1"; 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}}"
	#$verbose && echo "DEBUG  pattern=\"$pattern\", prefix=\"$prefix\", payload=\"$payload\"" >&2
	#$verbose && echo "DEBUG  Ignoring $(echo "$ignoredDevices" | wc -l) device(s)" >&2

	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" = "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
			echo " WARN  Unknown device name pattern prefix \"$prefix\", must be one of \"exact:\", \"notexact:\", \"startswith:\", \"grep:\", \"notgrep:\", \"egrep:\", \"notegrep:\": \"$pattern\"" >&2
		fi
	done < <( pactl list short "$type" )
	return 1
}

# streamExists streamType streamId
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
# Returns with code 0 if there is a stream of the given type and having
# the given stream ID
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
#
# The streamType argument must be one of "sink-inputs", "source-outputs"
# Returns the IDs of the source-outputs or sink-inputs that use the
# source/sink with the given name, in a newline-separated list
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
	# "source-outputs" -> "sources", "sink-inputs" -> "sinks"
	#  ^^^^^1       2                 ^^^1      2  (capturing groups)
	deviceType="$(echo "$type" | sed -re 's/^(.+)-[^-]+([^-])$/\1\2/')"
	deviceId="$(getDeviceId "$deviceType" "$name")"
	#$verbose && echo "DEBUG  ID of source/sink \"$name\" is \"$deviceId\"" >&2

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

# getDeviceId deviceType deviceName
#
# The deviceType argument must be one of "sources", "sinks"
# Returns the ID of the source or sink that has the given name
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
}

# Immediately resumes the main loop if it is waiting on its "sleep" call
resumeMainLoop () {
	local sleepPidCopy="$sleepPid"

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

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

	echo " INFO  Terminating main loop" >&2
	stopped=true
	resumeMainLoop
}

# Immediately resumes the main loop if it is waiting on its "sleep" call
# (handler/trap function for SIGHUP)
handleSigHup () {

	$verbose && echo "DEBUG  Received SIGHUP, reloading configuration and re-applying preset" >&2
	reloadPreset=true
	reloadConfig=true
	resumeMainLoop
}

# Sets the "stop" flag and then calls the teardown function
handleExit () {

	stopped=true
	teardown
}


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

# Custom isSetupRequired code for the EchoCancellation preset
isSetupRequiredEchoCancellation () {
	local ecSourceMasterNew
	local ecSinkMasterNew

	ecSinkMasterNew="$(getEcSinkMaster)"
	if test "$ecSinkMasterNew" != "$ecSinkMaster"; then
		$verbose && echo "DEBUG  Echo cancellation sink master changed to \"$ecSinkMasterNew\"" >&2
		return 0
	fi
	ecSourceMasterNew="$(getEcSourceMaster "$ecSinkMasterNew")"
	if test "$ecSourceMasterNew" != "$ecSourceMaster"; then
		$verbose && echo "DEBUG  Echo cancellation source master changed to \"$ecSourceMasterNew\"" >&2
		return 0
	fi
	return 1
}

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

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecMastersIgnore=()
	ecMastersIgnore+=( "$sinkMainName" "$sinkMainName".monitor )
	ecMastersIgnore+=( "$sourceMainName" )

	# Determine the sink master
	if ! ecSinkMaster="$(getEcSinkMaster)" || test "$ecSinkMaster" = ""; then
		echo " WARN  Failed to find an echo cancellation master sink, cannot set up echo cancellation" >&2
		return 1
	fi
	echo " INFO  Echo cancellation sink master is \"$ecSinkMaster\"" >&2

	# Determine the source master
	if ! ecSourceMaster="$(getEcSourceMaster "$ecSinkMaster")" || test "$ecSourceMaster" = ""; then
		# No source master found: Use a dummy source if the respective
		# option is set
		if $ecUseDummySource ; then
			# Only create the dummy source if it does not exist already
			if ! getDevice sources "" "exact:$sourceDummyName" &> /dev/null; then
				$verbose && echo "DEBUG  Creating dummy source \"$sourceDummyName\"" >&2
				if ! loadModule module-null-source source_name="$sourceDummyName" description="$sourceDummyName"; then
					echo " WARN  Failed to create dummy source \"$sourceDummyName\", cannot set up echo cancellation" >&2
					return 1
				fi
			fi
			ecSourceMaster="$sourceDummyName"
		fi
	fi
	if test "$ecSourceMaster" = ""; then
		echo " INFO  Could not find a source master, not setting up echo cancellation" >&2
		return 0
	fi

	echo " INFO  Echo cancellation source master is \"$ecSourceMaster\"" >&2

	# Set up echo cancellation between the master source and sink
	$verbose && echo "DEBUG  Setting up echo cancellation" >&2
	loadModule module-echo-cancel "${ecParams[@]}" \
		source_master="$ecSourceMaster" source_name="$sourceMainName" source_properties="device.description=$sourceMainName" \
		  sink_master="$ecSinkMaster"     sink_name="$sinkMainName"     sink_properties="device.description=$sinkMainName"

	# Set the new virtual echo cancellation source and sink as fallbacks
	pactl set-default-source "$sourceMainName"
	pactl set-default-sink   "$sinkMainName"

	# Restore streams to the fallback source and sink
	restoreStreamsOnFallbackDevices
}


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

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

	# Same as what EchoCancellation does
	isSetupRequiredEchoCancellation
}

# 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 () {
	local sinkMixName="sink_mix"
	local -i exitCode

	# Blacklist this preset's virtual devices for the echo cancellation
	# master device finding logic
	ecMastersIgnore=()
	ecMastersIgnore+=( "$sinkMainName"    "$sinkMainName".monitor )
	#ecMastersIgnore+=( "$sourceMainName" )
	ecMastersIgnore+=( "$sourceEcName" )
	ecMastersIgnore+=( "$sinkEffectsName" "$sinkEffectsName".monitor )
	ecMastersIgnore+=( "$sinkMixName"     "$sinkMixName".monitor )

	# Determine the sink master
	if ! ecSinkMaster="$(getEcSinkMaster)" || test "$ecSinkMaster" = ""; then
		echo " WARN  Failed to find an echo cancellation master sink, cannot set up echo cancellation" >&2
		return 1
	fi
	echo " INFO  Echo cancellation sink master is \"$ecSinkMaster\"" >&2

	# Determine the source master
	if ! ecSourceMaster="$(getEcSourceMaster "$ecSinkMaster")" || test "$ecSourceMaster" = ""; then
		# No source master found: Use a dummy source if the respective
		# option is set
		if $ecUseDummySource ; then
			# Only create the dummy source if it does not exist already
			if ! getDevice sources "" "exact:$sourceDummyName" &> /dev/null; then
				$verbose && echo "DEBUG  Creating dummy source \"$sourceDummyName\"" >&2
				if ! loadModule module-null-source source_name="$sourceDummyName" description="$sourceDummyName"; then
					echo " WARN  Failed to create dummy source \"$sourceDummyName\", cannot set up echo cancellation" >&2
					return 1
				fi
			fi
			ecSourceMaster="$sourceDummyName"
		fi
	fi
	if test "$ecSourceMaster" = ""; then
		echo " INFO  Could not find a source master, not setting up echo cancellation" >&2
		return 0
	fi

	echo " INFO  Echo cancellation source master is \"$ecSourceMaster\"" >&2

	# Set up echo cancellation between the master source and sink
	$verbose && echo "DEBUG  Setting up echo cancellation" >&2
	loadModule module-echo-cancel "${ecParams[@]}" \
		source_master="$ecSourceMaster" source_name="$sourceEcName" source_properties="device.description=$sourceEcName" \
		  sink_master="$ecSinkMaster"     sink_name="$sinkMainName"   sink_properties="device.description=$sinkMainName"

	# Create virtual output devices
	$verbose && echo "DEBUG  Creating virtual output devices" >&2
	loadModule module-null-sink sink_name="$sinkEffectsName" sink_properties="device.description=$sinkEffectsName"
	loadModule module-null-sink sink_name="$sinkMixName"     sink_properties="device.description=$sinkMixName"

	# Create loopbacks
	$verbose && echo "DEBUG  Creating loopbacks" >&2
	loadModule module-loopback "${loopbackParams[@]}" source="$sourceEcName"              sink="$sinkMixName"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffectsName}.monitor" sink="$sinkMixName"
	loadModule module-loopback "${loopbackParams[@]}" source="${sinkEffectsName}.monitor" sink="$sinkMainName"

	# Set the new fallbacks
	# TODO Find a way to set $sourceMainName as alias for
	#      sink_mix.monitor
	pactl set-default-source "$sinkMixName".monitor
	pactl set-default-sink   "$sinkMainName"

	# Restore streams to the fallback source and sink
	restoreStreamsOnFallbackDevices
}

# Sets/restores the default settings
setDefaultSettings () {

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

	# Which names to use for certain devices
	sourceMainName="src_main"   # The resulting primary source
	sinkMainName="sink_main"    # The resulting primary sink
	sourceDummyName="src_dummy" # The dummy source
	sinkDummyName="sink_dummy"  # The dummy sink
	sourceEcName="src_ec"       # The echo-cancelled source
	# shellcheck disable=SC2034  # Unused variable "sinkEcName"
	sinkEcName="sink_ec"        # The echo-cancelled sink
	sinkEffectsName="sink_fx"   # The audio effects sink
	sinkMixName="sink_mix"      # The mixing sink

	# 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
	#   "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:"
	ecSourceMasters=()
	ecSourceMasters+=("notexact:$sourceDummyName")
	ecSinkMasters=()
	ecSinkMasters+=("notexact:$sinkDummyName")

	# 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")

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

	# Loopbacks: The parameters that should be used for module-loopback
	loopbackParams=()
	loopbackParams+=(latency_msec=40)
	loopbackParams+=(adjust_time=4)

	# How long the main loop should sleep before polling the PulseAudio
	# server again
	sleepTime="5s"

	# Whether to print DEBUG messages to STDERR
	verbose=false
}


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

# Make sure the required programs are available
allRequiredProgramsPresent=true
type find &> /dev/null  || { echo "ERROR  Required program \"find\" is not available" >&2;  allRequiredProgramsPresent=false; }
type grep &> /dev/null  || { echo "ERROR  Required program \"grep\" is not available" >&2;  allRequiredProgramsPresent=false; }
type pactl &> /dev/null || { echo "ERROR  Required program \"pactl\" is not available" >&2; allRequiredProgramsPresent=false; }
type sed &> /dev/null   || { echo "ERROR  Required program \"sed\" is not available" >&2;   allRequiredProgramsPresent=false; }
$allRequiredProgramsPresent || exit 1
unset allRequiredProgramsPresent


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

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

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

# Echo cancellation: The dynamically determined device that is used as
# source master
ecSourceMaster=""
# Echo cancellation: The dynamically determined device that is used as
# sink master
ecSinkMaster=""
# Devices that should not be used 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
ecMastersIgnore=()

# 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, and
# setup() restores the streams to the new fallback device after it has
# applied the preset
streamsRecordingFromFallbackSource=()
streamsPlayingToFallbackSink=()

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

# True to leave the main loop and terminate
stopped=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


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

trap handleExit EXIT
trap handleSigHup HUP
trap stop TERM INT QUIT


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

reloadConfig


# Print an overview and enter the main loop
# ----------------------------------------------------------------------

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

while ! $stopped; do
	if isSetupRequired; then
		# The " || true" part is only needed for teardown() when
		# $verbose is false
		# TODO Find out why, when $verbose is false, the script exits
		# after the teardown() call, *even though teardown() returns
		# with code 0*, unless " || true" is used. Wat
		# I should get off my ass and learn a sane scripting language
		# such as Python
		teardown || true
		setup || true
	fi
	if ! $stopped && ! $reloadPreset && ! $reloadConfig; then
		sleep "$sleepTime" & sleepPid=$!
		wait $sleepPid || true
		sleepPid=""
	fi
done