SmartestHome/mic-follow/remote-mic.sh

167 lines
7.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# A microphone in another room, as a local audio source on this desktop.
#
# remote-mic.sh start <name>
# remote-mic.sh stop <name>
# remote-mic.sh status <name>
#
# Called by the mic-follow agent's start_command/stop_command hooks; `<name>` is the
# room, and its parameters come from ~/.config/mic-follow/remote-<name>.env, generated
# from CoreSystemConfig.json.
#
# HOW IT WORKS, and why this shape:
#
# panel: pw-record (its microphone) ──ssh──> desktop: pw-cat --playback
# into a null sink
# │
# module-remap-source ──────────┘
# presents it as a REAL source named <source_name>
#
# **SSH, not an audio protocol.** RTP and PipeWire's pulse-tunnel are both lower
# latency and both need a new listening service on the panel, an ACL, and config on two
# machines that has to agree. The panel already runs sshd as its documented admin path
# and already trusts this desktop's key, so the transport comes with authentication and
# encryption already solved, and nothing new listens on the network. For a smoke-break
# voice call, ~40 ms of extra buffering is not the constraint; a hot microphone in an
# empty room is.
#
# **Audio only exists while this runs.** No daemon, no always-on stream: `start` opens
# one SSH session, `stop` closes it, and the panel's microphone is not being read at
# any other time. That is a property of the transport rather than a promise in a
# config file, which is the reason to prefer it.
#
# **Why a null sink plus module-remap-source**, rather than just using the sink's
# monitor: a monitor is not a real source, applications treat it as "record what the
# desktop is playing", and mic-follow's own audio layer refuses to select one on
# purpose. remap-source turns it into an ordinary microphone with a name and a
# description, which is what Discord's device list needs to show.
#
# UNVERIFIED: this has never run against the real panel. The module names and the
# pw-record/pw-cat invocations are written from documentation. `status` exists to make
# checking it a one-liner.
set -uo pipefail
ACTION="${1:-}"
NAME="${2:-}"
[[ -n "$ACTION" && -n "$NAME" ]] || { echo "usage: remote-mic.sh <start|stop|status> <name>" >&2; exit 2; }
CONF="${MIC_FOLLOW_REMOTE_DIR:-$HOME/.config/mic-follow}/remote-${NAME}.env"
RUN_DIR="${XDG_RUNTIME_DIR:-/tmp}/mic-follow"
PID_FILE="${RUN_DIR}/${NAME}.pid"
MODULES_FILE="${RUN_DIR}/${NAME}.modules"
[[ -r "$CONF" ]] || { echo "remote-mic: no config at $CONF" >&2; exit 2; }
# shellcheck disable=SC1090
. "$CONF"
REMOTE_HOST="${REMOTE_HOST:-}"
REMOTE_USER="${REMOTE_USER:-kiosk}"
REMOTE_MIC="${REMOTE_MIC:-}" # empty = whatever the panel's default input is
SOURCE_NAME="${SOURCE_NAME:-micfollow_${NAME}}"
SOURCE_DESCRIPTION="${SOURCE_DESCRIPTION:-${NAME} microphone}"
RATE="${RATE:-48000}"
CHANNELS="${CHANNELS:-1}"
LATENCY_MS="${LATENCY_MS:-40}"
SSH_KEY="${SSH_KEY:-}"
mkdir -p "$RUN_DIR"
is_running() {
[[ -f "$PID_FILE" ]] || return 1
local pid; pid="$(cat "$PID_FILE" 2>/dev/null || true)"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}
case "$ACTION" in
status)
if is_running; then
echo "running (pid $(cat "$PID_FILE"))"
pactl list short sources 2>/dev/null | grep -F "$SOURCE_NAME" || echo " WARNING: the source is not registered"
exit 0
fi
echo "stopped"
exit 1
;;
start)
[[ -n "$REMOTE_HOST" ]] || { echo "remote-mic: REMOTE_HOST is not set in $CONF" >&2; exit 2; }
if is_running; then
echo "remote-mic: ${NAME} is already running"
exit 0
fi
command -v pactl >/dev/null || { echo "remote-mic: pactl is not installed" >&2; exit 2; }
command -v pw-cat >/dev/null || { echo "remote-mic: pw-cat is not installed (pipewire-bin)" >&2; exit 2; }
# The sink and the source it is remapped into. Recorded so `stop` unloads exactly
# what `start` loaded, rather than pattern-matching modules somebody else may own.
: > "$MODULES_FILE"
SINK_MODULE="$(pactl load-module module-null-sink \
sink_name="${SOURCE_NAME}_sink" \
sink_properties="device.description='${SOURCE_DESCRIPTION} (transport)'" 2>/dev/null)" || {
echo "remote-mic: could not create the null sink" >&2; exit 1; }
echo "$SINK_MODULE" >> "$MODULES_FILE"
SOURCE_MODULE="$(pactl load-module module-remap-source \
master="${SOURCE_NAME}_sink.monitor" \
source_name="${SOURCE_NAME}" \
source_properties="device.description='${SOURCE_DESCRIPTION}'" 2>/dev/null)" || {
echo "remote-mic: could not create the remapped source" >&2
pactl unload-module "$SINK_MODULE" 2>/dev/null
exit 1; }
echo "$SOURCE_MODULE" >> "$MODULES_FILE"
SSH_ARGS=(-o BatchMode=yes -o ConnectTimeout=5 -o ServerAliveInterval=5
-o ServerAliveCountMax=2 -o StrictHostKeyChecking=accept-new)
[[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
# The panel end. --target is left off entirely when REMOTE_MIC is empty, so the
# panel's own default input is used and nothing here has to know its device names.
REMOTE_CMD="pw-record --rate ${RATE} --channels ${CHANNELS} --format s16 --latency ${LATENCY_MS}ms"
[[ -n "$REMOTE_MIC" ]] && REMOTE_CMD="${REMOTE_CMD} --target '${REMOTE_MIC}'"
REMOTE_CMD="${REMOTE_CMD} -"
# setsid so the pipe survives the hook's own shell exiting, and so `stop` can kill
# the whole thing by process group rather than chasing two processes.
setsid bash -c "ssh ${SSH_ARGS[*]} '${REMOTE_USER}@${REMOTE_HOST}' \"${REMOTE_CMD}\" \
| pw-cat --playback --rate ${RATE} --channels ${CHANNELS} --format s16 \
--latency ${LATENCY_MS}ms --target '${SOURCE_NAME}_sink' -" \
</dev/null >/dev/null 2>&1 &
echo $! > "$PID_FILE"
sleep 0.5
if ! is_running; then
echo "remote-mic: the stream died immediately — check: ssh ${REMOTE_USER}@${REMOTE_HOST} pw-record --help" >&2
"$0" stop "$NAME" >/dev/null 2>&1
exit 1
fi
echo "remote-mic: ${NAME} up as source '${SOURCE_NAME}'"
;;
stop)
# Kill the transport FIRST, then tear down the local plumbing: the order that
# guarantees the panel stops being recorded even if unloading a module fails.
if [[ -f "$PID_FILE" ]]; then
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "$pid" ]]; then
kill -TERM -- "-${pid}" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
sleep 0.3
kill -KILL -- "-${pid}" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
if [[ -f "$MODULES_FILE" ]]; then
# Reverse order: the remapped source depends on the sink.
tac "$MODULES_FILE" | while read -r module; do
[[ -n "$module" ]] && pactl unload-module "$module" 2>/dev/null
done
rm -f "$MODULES_FILE"
fi
echo "remote-mic: ${NAME} stopped"
;;
*)
echo "usage: remote-mic.sh <start|stop|status> <name>" >&2
exit 2
;;
esac