#!/bin/dash
# set default soundcard for user
#
# Copyright (C) James Budiono 2013, 2016, 2018, 2019, 2020, 2021, 2023, 2024
# License: GPL Version 3 or later
#
# Version 2 (Jan 2016): re-written. Supports mix-out.
# Version 3 (Jul 2016): supports stereo swap
# Version 4 (Oct 2018): support bluez-alsa
# Version 5 (Jan 2020): fix issues with non-English locale, and non-root for bluetooth.
# Version 6 (Feb 2020): remove remap-oss, it never works
# Version 7 (Oct 2020): Support bmix from bluez-alsa
# Version 8 (Oct 2021): Implement ALSA Loop sound server (aloopd)
# Version 9 (Mar 2023): JACK output is now unconditional so we can use it with aloopd
# Version 10 (Jul 2023): Implement support for PulseAudio
# Version 11 (Feb 2024): Implement support for Pipewire
# Version 12 (Apr 2025): Support custom jack output ports
#
# $1-special command
#
# Note: Loopback allocation
# 0,1   - free (reserved for bluealsa-dsnoop)
# 2,3,4 - free
# 4 - netsource/netsink for jackp/jacka
# 5 - bmixd -> see /etc/default/bmix
# 6 - aloopd
# 7 - mixout

### configuration
APPTITLE="Set Default Soundcard"
ALSADEV_PREFIX=${ALSADEV_PREFIX:-fd}
ASOUNDRC=${ASOUNDRC:-$HOME/.asoundrc}	# it can easily be /etc/asound.conf for system-wide settings
SPOT_HOME=$(awk -F: '$1=="spot" {print $6}' /etc/passwd)

### run-time variables
DMIX_IPC_KEY=$(od -An -tu4 -N3 /dev/urandom)
DSNOOP_IPC_KEY=$(od -An -tu4 -N3 /dev/urandom)
MIXOUT_IPC_KEY=$(od -An -tu4 -N3 /dev/urandom)
ALOOPD_IPC_KEY=$(od -An -tu4 -N3 /dev/urandom)
JACK_OUT_LEFT=${JACK_OUT_LEFT:-default}
JACK_OUT_RIGHT=${JACK_OUT_RIGHT:-default}
JACK_INP_LEFT=${JACK_INP_LEFT:-default}
JACK_INP_RIGHT=${JACK_INP_RIGHT:-default}
SOUNDCARDS=
BT_DEVICES=

### output: SOUNDCARDS, BT_DEVICES, DMIX_IPC_KEY, DSNOOP_IPC_KEY
detect_hardware() {
	SOUNDCARDS=$(LANG=C aplay -l | awk '$1~/card/ && $0!~/Loopback/ { print "\"" $0 "\" " "\"" $0 "\"" }')

	SOUNDCARDS="$SOUNDCARDS aloop \"ALSA Loop sound server (aloopd)\""
	if pidof jackd > /dev/null; then
		SOUNDCARDS="$SOUNDCARDS jack \"ALSA -> JACK bridge\""
	fi
	if pidof pulseaudio > /dev/null; then
		SOUNDCARDS="$SOUNDCARDS pulse \"ALSA -> PulseAudio bridge\""
	fi
	if pidof pipewire > /dev/null; then
		SOUNDCARDS="$SOUNDCARDS pipewire \"ALSA -> Pipewire bridge\""
	fi		
	if type bmixd.bash > /dev/null; then
		SOUNDCARDS="$SOUNDCARDS bmix \"Bluetooth audio using blue-alsa bmixd\""
	fi
	if pidof bluetoothd > /dev/null && BT_DEVICES=$(bt-device -l);
	then
		BT_DEVICES="$(echo "$BT_DEVICES" | sed '1d; s|.*(\(.*\))|"\1" "&"|')"
		SOUNDCARDS="$SOUNDCARDS bluetooth \"Bluetooth devices\""
		#Nice but does not work with non-root
		#BT_DEVICES=$(ls -d /var/lib/bluetooth/*/* | sed 's|/var/lib/bluetooth/.*/||; /:/!d')
		#BT_DEVICES=$(for p in $BT_DEVICES; do
		#awk -v mac=$p -F= '/^Name=/ { print "\""mac"\"", "\""$2, "(" mac ")\"" }' /var/lib/bluetooth/*/$p/info
		#done)
	fi
}

########## UI helpers #########

# $1-info
infobox() {
	if [ $DISPLAY ]; then
		Xdialog --title "$APPTITLE" --infobox "$1" 0 0 10000
	else
		dialog --backtitle "$APPTITLE" --infobox "$1" 5 50
	fi
}

# $1-text, $2-choices, output: stdout
choices() {
	if [ $DISPLAY ]; then
		eval Xdialog --title \"$APPTITLE\" --stdout --no-tags --menubox \""$1"\" 20 100 5 $2
	else 
		eval dialog --backtitle \"$APPTITLE\" --stdout --no-tags --menu \""$1"\" 0 0 0 $2
	fi
}

# $1-text, $2-choices, output: stdout
multi_choices() {
	if [ $DISPLAY ]; then
		eval Xdialog --title \"$APPTITLE\" --stdout --no-tags --separator \" \" --checklist \""$1"\" 20 100 5 $2
	else
		eval dialog --title \"$APPTITLE\" --stdout --no-tags --separator \" \" --checklist \""$1"\" 0 0 0 $2
	fi
}

# $1-text
yesno() {
	if [ $DISPLAY ]; then
		Xdialog --title "$APPTITLE" --yesno "$1" 0 0
	else
		dialog --title "$APPTITLE" --yesno "$1" 0 0
	fi
}

# $1-text
die() {
	infobox "$1"
	exit
}

die_unchanged() {
	die "Operation cancelled. Nothing was changed."
}

# $1-option, $* options
has_option() {
	local p inp=$1
	shift
	for p; do
		case $p in
			$inp) return 0
		esac	
	done
	return 1
}

# $1-text $2-cmd $3,$4...-all other parameters
run_as_root() {
	local prompt="$1"
	shift
	if [ $(id -u) -eq 0 ]; then
		"$@"
	else
		if [ "$DISPLAY" ]; then
			gtksu "$prompt" "$@"
		else
			su -c "$*"
		fi
	fi	
}



############## UI #############

### output: CARD
choose_card() {
	CARD=$(choices "Choose card to be made as default" "$SOUNDCARDS")
}

### input: CARD, output: HW, HWMIXER (asoundrc hardware device for HW/HWMIXER)
get_card_hardware() {
	case "$CARD" in
		cancel) ;; # do nothing if cancelled
		bluetooth) 
			if BT_DEV=$(choices "Choose bluetooth devices" "$BT_DEVICES"); then
				#HW="{ type bluetooth device ${BT_DEV%% *} profile \"auto\" }" ;;
				HW="bluealsa"
				HWMIXER="bluealsa"
				HWDEFAULTS="
defaults.bluealsa.device $BT_DEV
defaults.bluealsa.delay 10000
"
			else
				return 1
			fi
		;;

		aloop)
			HW="\"hw:Loopback,0,6\""
			HWMIXER="{ type hw card Loopback }"
		;;
		
		jack)
			HW="jack"
			HWMIXER=""
		;;

		pulse)
			HW="pulse"
			HWMIXER="pulse"
		;;

		pipewire)
			HW="pipewire"
			HWMIXER="{ type pipewire }"
		;;
		
		bmix)
			HW="bluetooth"
			HWMIXER="bluealsa"
		;;
		
		*)	HW=$(echo $CARD | sed 's/card \([0-9]*\):.*device \([0-9]*\):.*/{ type hw card \1 device \2  }/')
			HWMIXER=$(echo $CARD | sed 's/card \([0-9]*\):.*/{ type hw card \1 }/')
		;;
	esac	
}

### input: CARD, output: OPTIONS, JACK_INP/OUT_LEFT/RIGHT
# options are: plug equal softvol preamp dmix dsnoop force48k force44k vdownmix
choose_card_option() {
	local p jack_ports

	# prepare available options
	AVAIL_OPTIONS="plug     \"Sample rate converter\" on \
				   equal    \"Equaliser\" off \
				   softvol  \"Software volume control\" off \
				   preamp   \"Software volume control with 20dB preamp\" off"
	if [ "$CARD" != bluetooth -a "$CARD" != jack -a "$CARD" != pulse -a "$CARD" != pipewire -a "$CARD" != "bmix" ]; then
		AVAIL_OPTIONS="$AVAIL_OPTIONS \
					   dmix     \"Multiple applications can output at the same time\" on \
					   dsnoop   \"Multiple applications can record at the same time\" off \
					   stereoswap \"Swap stereo channel\" off \
					   vdownmix \"Downmix multi-channel to stereo (will disable equaliser)\" off"
	fi
	AVAIL_OPTIONS="$AVAIL_OPTIONS \
				   force48k \"Force hardware output rate at 48 kHz\" off \
				   force44k \"Force hardware output rate at 44.1 kHz\" off \
				   mixout \"Enable mixout (pcm.mixout)\" off \
				   custom_jack_output \"Custom JACK output\" off"

	# choose options
	if OPTIONS=$(multi_choices "Use the following plugins" "$AVAIL_OPTIONS"); then
		case "$OPTIONS" in
			*custom_jack_output*) # define JACK output ports
				jack_ports="default default"
				while read p; do
					jack_ports="$jack_ports '$p' '$p' "
				done << EOF
$(jack_lsp)
EOF
				JACK_OUT_LEFT=$(choices "Choose Jack output LEFT" "$jack_ports") &&
				JACK_OUT_RIGHT=$(choices "Choose Jack output RIGHT" "$jack_ports") &&
				JACK_INP_LEFT=$(choices "Choose Jack input LEFT" "$jack_ports") &&
				JACK_INP_RIGHT=$(choices "Choose Jack input RIGHT" "$jack_ports")
		esac
	fi
}

############## Process #############

# input: HW, OPTIONS output=PIPELINE
build_pipeline() {
	# pipeline can't be built arbitrarily, some devices must be used before others
	# scan options to determine pipeline to build
	PIPELINE=$(awk -v v="$OPTIONS" 'BEGIN {
		split(v,o)
		for (p in o) opt[o[p]]=1

		# preamp and softvol is mutually exclusive
		if (opt["preamp"]) opt["softvol"]=0 # preamp includes softvol
		# mixout and equal is mutually exclusive
		if (opt["mixout"]) opt["equal"]=0 # assume mixout more important		
		
		# these are listed in the order the must be chained.
		pipeline=""
		if (opt["plug"])    pipeline = pipeline " plug"		
		if (opt["dmix"] || opt["dsnoop"]) pipeline = pipeline " asym"

		if (opt["equal"])    pipeline = pipeline " equal"
		
		if (opt["softvol"]) pipeline = pipeline " softvol"
		if (opt["preamp"])  pipeline = pipeline " preamp"

		if (opt["vdownmix"]) pipeline = pipeline " vdownmix"				
		if (opt["mixout"])  pipeline = pipeline " mixout"

		if (opt["dmix"])    pipeline = pipeline " dmix"
		if (opt["dsnoop"])  pipeline = pipeline " dsnoop"
		print pipeline " hw" # lastly do hw
	}')
}

# input: PIPELINE, HW, HWMIXER, HWDEFAULTS
output_conf() {
	# firstly - output HW & HWMIXER
	{		
		set -- $PIPELINE
		echo "pcm.!default" $ALSADEV_PREFIX$1
		[ "$HWDEFAULTS" ] && echo "$HWDEFAULTS"
		[ "$HWMIXER" ] && echo "ctl.!default $HWMIXER"

		while [ $1 ]; do
			case $1 in

				hw)
					echo "pcm.$ALSADEV_PREFIX$1 $HW"
				;;
				
				plug)
cat << EOF
pcm.$ALSADEV_PREFIX$1 {
	type plug
	slave {
		pcm $ALSADEV_PREFIX$2
EOF
has_option force48k $OPTIONS && echo "		rate 48000"
has_option force44k $OPTIONS && echo "		rate 44100"
echo "	}"
echo "}"
				;;

				asym)
					capture_pcm=${ALSADEV_PREFIX}hw
					case "$PIPELINE" in
						*dsnoop*) capture_pcm=${ALSADEV_PREFIX}dsnoop ;;
					esac
cat << EOF
pcm.$ALSADEV_PREFIX$1 { 
		type asym 
		playback.pcm {
			@func getenv
			vars [ ALSA_PCMOUT ]
			default $ALSADEV_PREFIX$2
		}
		capture.pcm {
			@func getenv
			vars [ ALSA_PCMIN ]
			default $capture_pcm
		}
}
EOF
				;;

				vdownmix)
					echo "pcm.$ALSADEV_PREFIX$1 { type vdownmix slave.pcm $ALSADEV_PREFIX$2 }"
				;;

				equal)
					echo "pcm.$ALSADEV_PREFIX$1 { type equal slave.pcm plug:$ALSADEV_PREFIX$2 } " 
					echo "ctl.equal { type equal }"
				;;

				softvol|preamp)
cat << EOF
pcm.$ALSADEV_PREFIX$1 {
    type            softvol
    slave.pcm       $ALSADEV_PREFIX$2
    control.name    "SoftPCM"
    control.card    0
EOF
if [ $1 = preamp ]; then
cat << EOF
	min_dB -5.0
	max_dB 20.0
	resolution 6
EOF
fi
echo "}"
				;;

				mixout)
					playback_pcm="hw:Loopback,0,7"
					case "$PIPELINE" in
						*dmix*) playback_pcm=${ALSADEV_PREFIX}mixoutdmix
cat << EOF
pcm.${ALSADEV_PREFIX}mixoutdmix {
	type dmix 
	ipc_key $MIXOUT_IPC_KEY
	ipc_gid audio
	ipc_perm 0660 
	slave {
		pcm "hw:Loopback,0,7"
		period_time 0
		period_size 2048
		buffer_size 32768
	}
}
EOF
						;;
					esac
cat << EOF
pcm.$ALSADEV_PREFIX$1 {
	type plug
	route_policy "duplicate"
	slave.pcm {
		type multi
		slaves.a.pcm $ALSADEV_PREFIX$2
		slaves.a.channels 2
		slaves.b.pcm "$playback_pcm"
		slaves.b.channels 2
		bindings.0.slave a
		bindings.0.channel 0
		bindings.1.slave a
		bindings.1.channel 1
		bindings.2.slave b
		bindings.2.channel 0
		bindings.3.slave b
		bindings.3.channel 1
	}
}
pcm.mixout "plughw:Loopback,1,7"
EOF
				;;
						
				dsnoop)
cat << EOF
pcm.$ALSADEV_PREFIX$1 {
	type dsnoop
	ipc_key $DSNOOP_IPC_KEY
	ipc_gid audio
	ipc_perm 0660
	slave {
		pcm ${ALSADEV_PREFIX}hw
EOF
has_option force48k $OPTIONS && echo "		rate 48000"
has_option force44k $OPTIONS && echo "		rate 44100"
cat << EOF
	}
}
EOF
				;;

				dmix)
cat << EOF
pcm.$ALSADEV_PREFIX$1 { 
	type dmix 
	ipc_key $DMIX_IPC_KEY
	ipc_gid audio
	ipc_perm 0660 
	slave {
		pcm ${ALSADEV_PREFIX}hw
		period_time 0
		period_size 2048
		buffer_size 32768
EOF
[ "$CARD" = "aloop" ] && echo "		format S16_LE"
[ "$CARD" = "aloop" ] && echo "		channels 2"
has_option force48k $OPTIONS && echo "		rate 48000"
has_option force44k $OPTIONS && echo "		rate 44100"
echo "	}"
has_option stereoswap $OPTIONS && cat << EOF
	bindings {
		0 1
		1 0
	}
EOF
echo "}"
				;;

			esac
			shift
		done

	# add additional parts required by certain cards
	case "$CARD" in
		aloop)
cat << EOF
pcm.aloopd {
	type plug
	slave {
		pcm "hw:Loopback,1,6"
		rate 48000
		format S16_LE
	}
}
pcm.aloopdshared {
	type plug
	slave.pcm aloopdsnoop
}
pcm.aloopdsnoop {
	type dsnoop
	ipc_key $ALOOPD_IPC_KEY
	ipc_gid audio
	ipc_perm 0660
	slave {
		pcm "hw:Loopback,1,6"
		rate 48000
		format S16_LE
	}
}
EOF
		;;

	esac

	# unconditional useful stuff
[ "$JACK_OUT_LEFT"  = "default" ] && JACK_OUT_LEFT="system:playback_1"
[ "$JACK_OUT_RIGHT" = "default" ] && JACK_OUT_RIGHT="system:playback_2"
[ "$JACK_INP_LEFT"  = "default" ] && JACK_INP_LEFT="system:capture_2"
[ "$JACK_INP_RIGHT" = "default" ] && JACK_INP_RIGHT="system:capture_2"
cat << EOF

# ===== Manual override section =======
# for use by jack (output of jack_lsp)
pcm.jack {
     type jack
     playback_ports {
         0 "$JACK_OUT_LEFT"
         1 "$JACK_OUT_RIGHT"
     }
     capture_ports {
         0 "$JACK_INP_LEFT"
         1 "$JACK_INP_RIGHT"
     }
}
# for use pulseaudio
pcm.pulse {
	type pulse
}
# for use pulseaudio
pcm.pipewire {
	type pipewire
}
# for manual use with plug: overrides
pcm.vdownmix 
{
	@args [ SLAVE ]
	@args.SLAVE {
		type string
	}
	type vdownmix
	slave.pcm \$SLAVE
}
# for a2dp-alsa (manual edits) - deprecated, replaced by bluealsa
pcm.a2dpfifo {
	type rate
	slave {
		pcm {
			type file
			slave.pcm "null"
			file "/tmp/a2dp.fifo"		
		}
		rate 44100
	}
}
EOF
	} > $ASOUNDRC

	# update spot too
	if [ $(id -u) -eq 0 ]; then
		case $ASOUNDRC in
			*/.asoundrc) # if per-user settings
				cp $ASOUNDRC $SPOT_HOME # whatever we do for root we also do for spot
				chown spot:spot $SPOT_HOME/${ASOUNDRC##*/}
		esac
	fi
}

# other final stuff todo
any_other_business() {
	# offer to enable radeon hdmi if output is via HDMI and card is radeon
	if lsmod | grep -q radeon && echo "$CARD" | grep -q HDMI; then
		run_as_root "Enable radeon audio over HDMI" "$0" enable-radeon-audio
	fi
	
	# ask if want to re-map OSS if it is currently loaded
	case $CARD in
		jack|pulse|pipewire) ;; # do nothing
		bluetooth)
			bt-device -c $BT_DEV  # connect to device
		;;
		bmix)
			infobox "Remember to start \"bmixd\" service if it's not already running."
			if ! lsmod | grep -q snd_aloop; then
				run_as_root "Load ALSA Loopback kernel module" "$0" load-snd-aloop
			fi
		;;
		aloop)
			if ! lsmod | grep -q snd_aloop; then
				run_as_root "Load ALSA Loopback kernel module" "$0" load-snd-aloop
			fi
			if type aloopdcfg.sh > /dev/null && 
			   yesno "Launch aloopdcfg to configure outputs?";
			then
				aloopdcfg.sh
			else
				infobox "Remember to start \"aloopd\" service if it's not already running."
			fi
		;;
	esac

	# mixout requires snd-aloop too
	if has_option mixout $OPTIONS; then
		if ! lsmod | grep -q snd_aloop; then
			run_as_root "Load ALSA Loopback kernel module" "$0" load-snd-aloop
		fi	
	fi
}

### must run as root, $1-special command, $2-more params for special commands
special_command() {
	case $1 in
		enable-radeon-audio)
			if yesno "Enable sound over Radeon HDMI?"; then
				echo "options radeon audio=1" > /etc/modprobe.d/radeon-audio.conf
			else
				rm /etc/modprobe.d/radeon-audio.conf
			fi
			exit
		;;

		load-snd-aloop)
			modprobe snd-aloop
			exit
		;;
	esac
}

########### main ############
special_command "$@" # check for special commands first
detect_hardware      # various run-time vars
choose_card || die_unchanged         # returns CARD
get_card_hardware || die_unchanged   # returns HW, HWMIXER
choose_card_option || die_unchanged  # returns OPTIONS
build_pipeline  # HW, HWMIXER, OPTIONS
output_conf     # PIPELINE, HW, HWMIXER
any_other_business # CARD, HW
