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