#!/usr/bin/env bash # # A microphone in another room, as a local audio source on this desktop. # # remote-mic.sh start # remote-mic.sh stop # remote-mic.sh status # # Called by the mic-follow agent's start_command/stop_command hooks; `` is the # room, and its parameters come from ~/.config/mic-follow/remote-.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 # # **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 " >&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 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 " >&2 exit 2 ;; esac