167 lines
7.0 KiB
Bash
Executable File
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
|