#!/usr/bin/env bash # Sets up the sound device HyperHDR will listen to, and optionally installs the # receiver as a service. # # HyperHDR's sound-reactive effects read a local capture device. This script # creates one that is fed by the TV: # # TV ──RTP──> lgtv-audiocap-receiver.py ──> loopback playback # │ # HyperHDR <─────┘ loopback capture # # Two ways to make that loopback: # # alsa snd-aloop, a kernel module that pairs a playback device with a # capture device. HyperHDR enumerates ALSA devices, so it sees this # one directly. This is the default and the one to prefer. # pulse A null sink whose monitor is the capture side. Only useful if # HyperHDR is reaching audio through the PulseAudio ALSA plugin. # # sudo ./install-loopback.sh # snd-aloop, no service # sudo ./install-loopback.sh --install-service # ... and run at boot # sudo ./install-loopback.sh --method pulse # sudo ./install-loopback.sh --uninstall set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" METHOD=alsa NAME=lgtv-audio PORT=5004 RATE=48000 CHANNELS=2 CARD_INDEX=10 PREFIX=/usr/local SERVICE_USER="" INSTALL_SERVICE=0 UNINSTALL=0 RULES_FILE=/etc/udev/rules.d/89-lgtv-audiocap-loopback.rules MODPROBE_FILE=/etc/modprobe.d/lgtv-audiocap-loopback.conf MODULES_FILE=/etc/modules-load.d/lgtv-audiocap-loopback.conf UNIT_FILE=/etc/systemd/system/lgtv-audiocap.service usage() { # The header comment is the help text, up to the first line of code. awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" cat <&2; exit 1; } need_root() { [ "$(id -u)" -eq 0 ] || die "run this with sudo" } while [ $# -gt 0 ]; do case "$1" in --method) METHOD="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; --rate) RATE="$2"; shift 2 ;; --channels) CHANNELS="$2"; shift 2 ;; --card-index) CARD_INDEX="$2"; shift 2 ;; --prefix) PREFIX="$2"; shift 2 ;; --user) SERVICE_USER="$2"; shift 2 ;; --install-service) INSTALL_SERVICE=1; shift ;; --uninstall) UNINSTALL=1; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown option $1 (try --help)" ;; esac done case "$METHOD" in alsa|pulse) ;; *) die "--method must be alsa or pulse" ;; esac # The user whose PulseAudio session we touch, and who the service runs as. if [ -z "$SERVICE_USER" ]; then SERVICE_USER="${SUDO_USER:-$(id -un)}" fi # --------------------------------------------------------------------------- uninstall() { need_root step "Removing the ALSA loopback" rm -f "$MODPROBE_FILE" "$MODULES_FILE" "$RULES_FILE" modprobe -r snd-aloop 2>/dev/null || say "snd-aloop is in use; it will go at the next reboot" step "Removing the service" if [ -f "$UNIT_FILE" ]; then systemctl disable --now lgtv-audiocap.service 2>/dev/null || true rm -f "$UNIT_FILE" systemctl daemon-reload fi rm -f "$PREFIX/bin/lgtv-audiocap-receiver.py" step "PulseAudio" say "If you used --method pulse, remove the module-null-sink line from" say " ~/.config/pulse/default.pa (user $SERVICE_USER)" say "and unload it now with: pactl unload-module module-null-sink" say "" say "Done." } setup_alsa() { need_root step "Loading snd-aloop" cat > "$MODPROBE_FILE" < "$MODULES_FILE" if ! lsmod 2>/dev/null | grep -q '^snd_aloop'; then modprobe snd-aloop || die "could not load snd-aloop; is alsa-utils / the kernel module package installed?" else say "snd-aloop already loaded (reboot to pick up the new options)" fi # PulseAudio grabs every card it finds. Left alone it opens the loopback, # which is at best a wasted device and at worst a fight over the substream. if command -v pulseaudio >/dev/null 2>&1 || command -v pipewire >/dev/null 2>&1; then step "Hiding the loopback from PulseAudio/PipeWire" cat > "$RULES_FILE" <<'EOF' # LG TV Audio Cap: the loopback belongs to the receiver and HyperHDR, not to # the desktop sound server. ATTRS{id}=="Loopback", ENV{PULSE_IGNORE}="1", ENV{ACP_IGNORE}="1" EOF udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=sound 2>/dev/null || true fi PLAY_DEVICE="hw:Loopback,0,0" CAPTURE_DEVICE="hw:Loopback,1,0" step "Checking the loopback" if aplay -l 2>/dev/null | grep -q 'Loopback'; then aplay -l | grep -i loopback | sed 's/^/ /' else say " aplay does not list the Loopback card yet; a reboot will fix it" fi } setup_pulse() { step "Creating the null sink" local as_user=(sudo -u "$SERVICE_USER") [ "$(id -un)" = "$SERVICE_USER" ] && as_user=() if ! "${as_user[@]}" pactl info >/dev/null 2>&1; then die "no PulseAudio/PipeWire session for user $SERVICE_USER" fi if "${as_user[@]}" pactl list short sinks | grep -q "^[0-9]*[[:space:]]*$NAME"; then say "sink $NAME already exists" else "${as_user[@]}" pactl load-module module-null-sink \ sink_name="$NAME" \ sink_properties="device.description='LG TV Audio Cap'" \ rate="$RATE" channels="$CHANNELS" >/dev/null say "created sink $NAME" fi local conf="/home/$SERVICE_USER/.config/pulse/default.pa" [ -d "/home/$SERVICE_USER" ] || conf="" if [ -n "$conf" ]; then mkdir -p "$(dirname "$conf")" if [ ! -f "$conf" ]; then printf '.include /etc/pulse/default.pa\n' > "$conf" fi if ! grep -q "sink_name=$NAME" "$conf"; then cat >> "$conf" </dev/null || true say "persisted in $conf" fi fi PLAY_DEVICE="$NAME" CAPTURE_DEVICE="$NAME.monitor" } install_service() { need_root step "Installing the receiver" install -Dm755 "$HERE/lgtv-audiocap-receiver.py" "$PREFIX/bin/lgtv-audiocap-receiver.py" say "installed $PREFIX/bin/lgtv-audiocap-receiver.py" local output device if [ "$METHOD" = alsa ]; then output=aplay device="$PLAY_DEVICE" else output=pacat device="$PLAY_DEVICE" fi cat > "$UNIT_FILE" < HyperHDR audio (RTP/L16) > on Receiver address: this machine's IP UDP port: $PORT In HyperHDR: Sound capture (or the LED device's music effect) > input device $CAPTURE_DEVICE then pick a music effect such as "Waves" or "Spectrum". If the receiver is not running as a service, start it by hand: $HERE/lgtv-audiocap-receiver.py --port $PORT --rate $RATE \\ --channels $CHANNELS --output $( [ "$METHOD" = alsa ] && echo aplay || echo pacat ) \\ --device $PLAY_DEVICE Check that audio is arriving at all: $HERE/lgtv-audiocap-receiver.py --port $PORT --output - | \\ aplay -f S16_LE -r $RATE -c $CHANNELS - EOF } # --------------------------------------------------------------------------- if [ "$UNINSTALL" -eq 1 ]; then uninstall exit 0 fi say "LG TV Audio Cap — host setup" say "method: $METHOD, user: $SERVICE_USER, port: $PORT, format: $RATE Hz x $CHANNELS" if [ "$METHOD" = alsa ]; then setup_alsa else setup_pulse fi if [ "$INSTALL_SERVICE" -eq 1 ]; then install_service fi summary