Remote microphone: the Loggia panel becomes an input on the desktop

The headphones have no microphone, so the follow-me switch needs somewhere to
switch TO. The Loggia has no mic you can plug into the desktop, but it does
have a machine with one — the touch panel already in that room.

remote-mic.sh makes that panel's microphone appear on the desktop as an
ordinary source:

  panel: pw-record ──ssh──> desktop: pw-cat into a null sink
                                          └─ module-remap-source ─> micfollow_<room>

SSH RATHER THAN AN AUDIO PROTOCOL. RTP and PipeWire's pulse-tunnel are both
lower latency, and both want 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
arrives with authentication and encryption already solved and nothing new
listening on the network. For a smoke-break voice call, 40ms of buffering is
not the constraint — a hot microphone in an empty room is. If the latency ever
matters, the null-sink half stays and only the transport changes.

And audio exists ONLY while the stream runs: no daemon, one SSH session,
`stop` closes it. That is a property of the transport rather than a promise in
a config file, which is the reason to prefer it.

A null sink plus module-remap-source, not the sink's monitor: a monitor is not
a real source, applications treat it as "record what the desktop is playing",
and this component's own audio layer refuses to select one on purpose.

The panel says so, in the room. hosts/touch-panel/ gains mic-in-use and a red
MIC LIVE badge on the dock, lit whenever ANYTHING is capturing that panel's
microphone — this feature or not. A room mic somebody elsewhere can open has to
be visible to the person standing in front of it; Home Assistant knowing is not
the same as them knowing. It reads PipeWire's actual capture streams rather
than trusting who asked, ignores monitor streams, and without jq it falls back
to over-reporting, which is the right direction to be wrong in for a warning
light. pipewire-bin and pulseaudio-utils join that image for it.

Fixed while building it: the agent looked for a source BEFORE running the start
hook, so a remote microphone — which does not exist as a PipeWire node until
its stream is up — could never be selected. It fell back to the desk every
time, correct by the letter of the code and useless in practice. Hooks now run
first, the source is waited for (source_timeout_seconds, default 6), and a
failed start has its stop hook run before falling back rather than leaving a
half-started stream behind.

Configuration is a `remote` block on the source instead of a source pattern;
the generator writes both hooks and the transport's parameters, because "stop
the stream" is a safety property and not something to rely on somebody having
typed correctly. Tests pin exactly that: every remote source produces both
hooks, a local one is left alone, and mic-in-use's monitor exclusion is checked
against fixtures. 24 tests now.

The one command that decides whether any of this works, and it needs the real
machines: ssh <user>@<host> pw-record --help

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
main
Amir Alexander Abdelbaki 2026-08-31 13:55:31 +02:00
parent cebdc9529b
commit 864b5d1719
15 changed files with 557 additions and 44 deletions

View File

@ -237,13 +237,22 @@
"_on_unknown_room": "hold | desk. What happens in a room with no microphone configured. 'hold' keeps the last one (you might be walking through); 'desk' is the honest one (nothing in that room can hear you).", "_on_unknown_room": "hold | desk. What happens in a room with no microphone configured. 'hold' keeps the last one (you might be walking through); 'desk' is the honest one (nothing in that room can hear you).",
"on_unknown_room": "hold", "on_unknown_room": "hold",
"reconcile_seconds": 10, "reconcile_seconds": 10,
"_sources": "One per room this client can be heard in. start_command/stop_command are hooks for a microphone that is not simply plugged into this machine — a network mic stream, say — and the STOP hook is the important one: a room microphone that keeps streaming after the switch left it is a hot mic in somebody's flat.", "_sources": "One per room this client can be heard in. A source is either LOCAL (a microphone plugged into this machine — a wireless clip-on receiver, say: give `source` a pattern and nothing else) or REMOTE (a microphone on another machine, streamed here — give it a `remote` block and mic-follow generates the rest). start_command/stop_command are the escape hatch for anything neither of those covers; the STOP hook is the important one, because a room microphone still streaming after the switch left it is a hot mic in somebody's flat.",
"_remote": "A microphone on another machine in the household — for the Loggia, the touch panel that is already in there. Audio travels over SSH (that panel already runs sshd as its admin path and already trusts this desktop's key), so there is no new listening service, no ACL to maintain, and audio exists ONLY while the stream is running — a property of the transport rather than a promise. host/user are that machine; mic_source is a pattern for ITS microphone, empty meaning its own default; latency_ms buys smoothness over wifi at the cost of delay. See mic-follow/README.md section 6.",
"sources": [ "sources": [
{ {
"room": "loggia", "room": "loggia",
"source": "DJI MIC MINI", "source": "micfollow_loggia",
"start_command": "", "description": "Loggia panel mic",
"stop_command": "" "remote": {
"host": "touch-panel-loggia",
"user": "kiosk",
"mic_source": "",
"latency_ms": 40,
"rate": 48000,
"channels": 1,
"ssh_key": ""
}
} }
] ]
} }

View File

@ -97,7 +97,7 @@ stream-dock/ Desk-side MiraBox N4 Pro (10 keys, 4 encoders) driv
## Status ## Status
- [x] Project plan drafted - [x] Project plan drafted
- [ ] **Follow-me microphone** (`mic-follow/`) — a voice call that survives walking out of the room: the person leaves their desk, the locator says which room they are in, and their machine's live microphone switches to one that can hear them there, then back to the studio mic when they sit down. **Written per client**, so a second person with a second desktop is one more entry in `CoreSystemConfig.json`. Home Assistant decides *where the person is*; the desktop agent only knows *how to change the input* — and it both sets the default source and **moves the already-running capture streams** of the configured applications, because anyone who owns a studio mic has picked it explicitly in Discord and an explicitly-picked device does not follow the default. **One rule makes it predictable: off means the desk mic** — the switch being off is an active guarantee, reachable from the dock, the watch, or MQTT, and honoured on shutdown too. Three surfaces, one entity to read: `sensor.mic_follow_<node>_status`, whose state is the *name of the live microphone*, shown in large type on a **Stream Dock key** (websocket-driven, nothing polls) and on a new **Pebble toggles screen** (long-press Select), which reaches Home Assistant through a narrow **allowlist** in `identity` (`/toggles`) rather than putting an HA admin token on a phone. Tested: 17 fixture cases over source selection (a monitor source can never be picked — that would transmit what the desktop is *playing*), a JS↔C round trip for the new watch message, and the generated HA package parsing for one client and several. **Untested:** every command that changes state, and the thing most likely to bite — whether BLE presence reports rooms fast and accurately enough to be worth wiring to a microphone at all, which has never been measured in this flat (`mic-follow/README.md` §3, §7) - [ ] **Follow-me microphone** (`mic-follow/`) — a voice call that survives walking out of the room: the person leaves their desk, the locator says which room they are in, and their machine's live microphone switches to one that can hear them there, then back to the studio mic when they sit down. **Written per client**, so a second person with a second desktop is one more entry in `CoreSystemConfig.json`. Home Assistant decides *where the person is*; the desktop agent only knows *how to change the input* — and it both sets the default source and **moves the already-running capture streams** of the configured applications, because anyone who owns a studio mic has picked it explicitly in Discord and an explicitly-picked device does not follow the default. **One rule makes it predictable: off means the desk mic** — the switch being off is an active guarantee, reachable from the dock, the watch, or MQTT, and honoured on shutdown too. Three surfaces, one entity to read: `sensor.mic_follow_<node>_status`, whose state is the *name of the live microphone*, shown in large type on a **Stream Dock key** (websocket-driven, nothing polls) and on a new **Pebble toggles screen** (long-press Select), which reaches Home Assistant through a narrow **allowlist** in `identity` (`/toggles`) rather than putting an HA admin token on a phone. Tested: 17 fixture cases over source selection (a monitor source can never be picked — that would transmit what the desktop is *playing*), a JS↔C round trip for the new watch message, and the generated HA package parsing for one client and several. **The Loggia case is a remote microphone**: the touch panel already in that room becomes an audio source on the desktop over **SSH** (`pw-record` there, `pw-cat` into a null sink and `module-remap-source` here) — chosen over RTP or a pulse-tunnel because that panel already runs sshd as its admin path, so the transport arrives with auth and encryption solved and **nothing new listens on the network**; audio exists only while the one SSH session does, which is a property of the transport rather than a promise. The panel now shows a red **MIC LIVE** badge on its dock whenever anything is capturing its microphone, because a room mic somebody elsewhere can open has to be visible to the person standing in front of it. **Untested:** the SSH audio pipe end to end, every command that changes state, and the thing most likely to bite — whether BLE presence reports rooms fast and accurately enough to be worth wiring to a microphone at all, which has never been measured in this flat (`mic-follow/README.md` §3, §6, §7)
- [ ] **Stream Dock lighting controls** (`stream-dock/`) — four rotary encoders on a desk-side **MiraBox N4 Pro** (the xVSDinside-branded one; Ajazz AKP05 family) as R / G / B / brightness for one room, through **OpenDeck**. **No new plugin was written, deliberately**: [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) already does HA-over-websocket with encoder actions, and [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) already teaches OpenDeck this non-Elgato hardware — what was missing was the configuration between them, which is what this directory is. The one thing neither plugin can do is **relative colour**: an encoder emits "three ticks clockwise" and HA has `brightness_step_pct` but no equivalent for a colour channel, so the dock sends only *which channel, how many ticks* and `ha-package/stream_dock.yaml` does the read-clamp-write against the lamp's current `rgb_color`. Rings 13 glow their own channel's value in their own colour and ring 4 glows in **what the room is actually emitting** (rgb scaled by brightness); off the dock's lighting layer they fall back to the desktop's own `#E40046`/`#5018DD`/`#F50505` chase and Home Assistant stops being polled at all. **Nothing has touched hardware.** The ring colours are computed, debounced and written correctly — verified against a stub HA — but the akp05 plugin reads `leds.toml` only at startup and holds the USB device open, so the last hop is an `apply_command` hook that ships empty; the real fix is a file-watch upstream. The layer gate ships answering *"I cannot tell"* rather than guessing OpenDeck's undocumented profile-state schema, and `{{ticks}}` is the first thing to test before binding four dials — see `stream-dock/README.md` §57 - [ ] **Stream Dock lighting controls** (`stream-dock/`) — four rotary encoders on a desk-side **MiraBox N4 Pro** (the xVSDinside-branded one; Ajazz AKP05 family) as R / G / B / brightness for one room, through **OpenDeck**. **No new plugin was written, deliberately**: [streamdeck-homeassistant](https://github.com/cgiesche/streamdeck-homeassistant) already does HA-over-websocket with encoder actions, and [opendeck-akp05](https://github.com/aroaxinping/opendeck-akp05) already teaches OpenDeck this non-Elgato hardware — what was missing was the configuration between them, which is what this directory is. The one thing neither plugin can do is **relative colour**: an encoder emits "three ticks clockwise" and HA has `brightness_step_pct` but no equivalent for a colour channel, so the dock sends only *which channel, how many ticks* and `ha-package/stream_dock.yaml` does the read-clamp-write against the lamp's current `rgb_color`. Rings 13 glow their own channel's value in their own colour and ring 4 glows in **what the room is actually emitting** (rgb scaled by brightness); off the dock's lighting layer they fall back to the desktop's own `#E40046`/`#5018DD`/`#F50505` chase and Home Assistant stops being polled at all. **Nothing has touched hardware.** The ring colours are computed, debounced and written correctly — verified against a stub HA — but the akp05 plugin reads `leds.toml` only at startup and holds the USB device open, so the last hop is an `apply_command` hook that ships empty; the real fix is a file-watch upstream. The layer gate ships answering *"I cannot tell"* rather than guessing OpenDeck's undocumented profile-state schema, and `{{ticks}}` is the first thing to test before binding four dials — see `stream-dock/README.md` §57
- [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy) - [x] Container host setup script v1 (HA, Mosquitto, Zigbee2MQTT USB, Frigate, Grocy)
- [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack - [x] Node-RED + monitoring (Netdata) + dashboard (Homepage) + ntfy + Portainer added to compose stack

View File

@ -191,6 +191,23 @@ Wayland one:
Which audio device the call actually uses is Discord's own setting, on-device, once — Which audio device the call actually uses is Discord's own setting, on-device, once —
this image does not manage it, and `touchpanel-agent` has no entity for it. this image does not manage it, and `touchpanel-agent` has no entity for it.
## This panel as a remote microphone
The Loggia panel doubles as a microphone for a desktop in another room
(`../../mic-follow/`): that machine opens an SSH session and runs `pw-record` here, so
somebody on the balcony stays in a voice call running on their PC. Nothing listens for
it — it is an ordinary SSH command over the admin path this image already has — and
audio is captured only while that session is open.
Because it is a microphone in a shared flat that somebody elsewhere can open, the dock
carries a red **MIC LIVE** badge, driven by `mic-in-use` (polled every 2s). It lights
whenever *anything* is capturing this panel's microphone, not only when mic-follow is:
an indicator whose job is to be believed should not depend on knowing who asked.
Monitor streams — something recording what the panel is *playing* — do not light it.
The image ships `pipewire-bin` (for `pw-record`) and `pulseaudio-utils` (for `pactl`)
for this. Neither is needed by anything else on the panel.
## On-screen keyboard — no auto-show, by design ## On-screen keyboard — no auto-show, by design
`configs/keyboard/toggle-keyboard` toggles `wvkbd` on and off. There is deliberately `configs/keyboard/toggle-keyboard` toggles `wvkbd` on and off. There is deliberately

View File

@ -48,3 +48,25 @@ $accent: #6ea8fe;
background-color: rgba(255, 255, 255, 0.12); background-color: rgba(255, 255, 255, 0.12);
margin: 8px 4px; margin: 8px 4px;
} }
/* The remote-microphone indicator. Red, because there is exactly one thing on this
dock that means "somebody in another room can hear you" and it should not have to
compete with the app buttons for attention. */
.mic-badge {
background-color: #7a1020;
border-radius: 8px;
padding: 0 12px;
margin-left: 8px;
}
.mic-dot {
color: #ff4d5e;
font-size: 16px;
}
.mic-text {
color: #ffd7dc;
font-size: 13px;
font-weight: bold;
}

View File

@ -12,6 +12,13 @@
(defpoll active_workspace :interval "1s" :initial "2:home" (defpoll active_workspace :interval "1s" :initial "2:home"
"swaymsg -t get_workspaces | jq -r '.[] | select(.focused) | .name' 2>/dev/null || echo 2:home") "swaymsg -t get_workspaces | jq -r '.[] | select(.focused) | .name' 2>/dev/null || echo 2:home")
;; Is this panel's microphone being recorded right now — by anything, including a
;; desktop in another room using it as a remote microphone (mic-follow/). A room
;; microphone that can be opened remotely has to say so IN THE ROOM; Home Assistant
;; knowing is not the same as the person standing here knowing.
(defpoll mic_live :interval "2s" :initial "0"
"mic-in-use 2>/dev/null || echo 0")
(defwidget dock-btn [ws icon label] (defwidget dock-btn [ws icon label]
(button :class {active_workspace == ws ? "dock-btn dock-btn-active" : "dock-btn"} (button :class {active_workspace == ws ? "dock-btn dock-btn-active" : "dock-btn"}
:onclick "swaymsg workspace ${ws}" :onclick "swaymsg workspace ${ws}"
@ -29,7 +36,12 @@
(button :class "dock-btn" :onclick "toggle-keyboard" (button :class "dock-btn" :onclick "toggle-keyboard"
(box :orientation "vertical" :space-evenly false :spacing 2 (box :orientation "vertical" :space-evenly false :spacing 2
(label :class "dock-icon" :text "⌨") (label :class "dock-icon" :text "⌨")
(label :class "dock-label" :text "Keyboard"))))) (label :class "dock-label" :text "Keyboard")))
;; Not a button: nothing to press, it is a statement of fact. It takes no space
;; when the microphone is idle.
(box :class "mic-badge" :visible {mic_live == "1"} :space-evenly false :spacing 4
(label :class "mic-dot" :text "●")
(label :class "mic-text" :text "MIC LIVE"))))
;; :exclusive true reserves this strip so it is never overlapped by, and never steals ;; :exclusive true reserves this strip so it is never overlapped by, and never steals
;; area from behind, the app on screen — unlike the thin client's overlay widgets, ;; area from behind, the app on screen — unlike the thin client's overlay widgets,

View File

@ -0,0 +1,47 @@
#!/bin/sh
# Is somebody recording this panel's microphone right now? Prints "1" or "0".
#
# Installed to /usr/local/bin/mic-in-use and polled by the touch dock
# (configs/eww/eww.yuck), which turns a "1" into a red LIVE badge.
#
# WHY THIS EXISTS: this panel can be used as a remote microphone by a desktop in
# another room (mic-follow/). That is a deliberate, useful feature, and it is also a
# microphone in a shared flat that somebody elsewhere can open. Such a thing has to be
# visible IN THE ROOM, not only in Home Assistant — anyone standing in the Loggia
# should be able to see that the panel is listening without knowing mic-follow exists.
#
# It reports what is actually capturing, from PipeWire, rather than trusting anything
# about who asked: a stray process recording for its own reasons lights the badge too,
# which is correct for an indicator whose whole job is to be believed.
#
# Monitor streams — something recording what the panel is PLAYING, like a visualiser —
# are not the microphone and must not light it up.
set -eu
command -v pactl >/dev/null 2>&1 || { echo 0; exit 0; }
if command -v jq >/dev/null 2>&1; then
sources="$(pactl -f json list sources 2>/dev/null || echo '[]')"
outputs="$(pactl -f json list source-outputs 2>/dev/null || echo '[]')"
jq -n --argjson s "$sources" --argjson o "$outputs" '
# The indexes of every monitor source, by either of the two ways one identifies
# itself.
[ $s[]
| select(((.properties["device.class"] // "") | ascii_downcase) == "monitor"
or (.name | endswith(".monitor")))
| .index ] as $monitors
| [ $o[] | select([.source] | inside($monitors) | not) ] | length > 0
| if . then 1 else 0 end
' 2>/dev/null || echo 0
exit 0
fi
# No jq: fall back to counting capture streams without being able to tell a monitor
# apart. That OVER-reports, which is the right direction to be wrong in for a warning
# light — a badge that is on too often gets questioned, one that is off too often gets
# trusted wrongly.
if pactl list source-outputs 2>/dev/null | grep -q "^Source Output #"; then
echo 1
else
echo 0
fi

View File

@ -18,6 +18,13 @@ pipewire
pipewire-pulse pipewire-pulse
wireplumber wireplumber
alsa-utils alsa-utils
# pw-record / pw-cat / pw-cli. Needed because this panel can act as a REMOTE
# MICROPHONE for a desktop in another room (mic-follow/): the desktop opens an SSH
# session and runs pw-record here. Nothing listens for that — it is an ordinary SSH
# command — and audio is only ever captured while that session is open.
pipewire-bin
# pactl, for checking what is capturing locally (mic-in-use, below).
pulseaudio-utils
# --- Spotify GUI client and Steam-Link-style apps come from Flathub, not apt # --- Spotify GUI client and Steam-Link-style apps come from Flathub, not apt
# (see 0300-flatpak-spotify.hook.chroot) --- # (see 0300-flatpak-spotify.hook.chroot) ---

View File

@ -103,15 +103,67 @@ on/off/toggle, and an id that is not listed is a 404. Paste the line from
5. Paste `generated/identity-toggles.env` into `identity.env`, restart identity. 5. Paste `generated/identity-toggles.env` into `identity.env`, restart identity.
6. Bind the dock key from `generated/dock-bindings.md`. 6. Bind the dock key from `generated/dock-bindings.md`.
## 6. A remote microphone that is not plugged into this machine ## 6. A remote microphone — the Loggia case
`start_command` / `stop_command` on a source are the hook for that — bringing up a The Loggia has no microphone you can plug into the desktop, but it does have a machine
network mic stream, for instance, when the switch moves to that room. with one: the touch panel that is already in there. `remote-mic.sh` makes that panel's
microphone appear on the desktop as an ordinary audio source, which the switch then
selects like any other.
**The stop hook is the important one.** A room microphone that keeps streaming after the ```
switch has left it is a hot mic in somebody's flat. The agent runs the stop hook on panel: pw-record (its mic) ──ssh──> desktop: pw-cat --playback
every transition away and again on shutdown, and the validator warns about a into a null sink
`start_command` with no matching `stop_command`.
module-remap-source ──────────┘
presented as a real source named micfollow_<room>
```
Configure it with a `remote` block on the source instead of a `source` pattern, and
the generator writes both hooks and the transport's parameters:
```jsonc
{ "room": "loggia",
"description": "Loggia panel mic",
"remote": { "host": "touch-panel-loggia", "user": "kiosk", "latency_ms": 40 } }
```
**Why SSH and 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 arrives with authentication and
encryption already solved and nothing new listening on the network. For a smoke-break
voice call, 40 ms of buffering is not the constraint — a hot microphone in an empty room
is. If you later want the latency, the null-sink half stays and only the transport
changes.
**Audio exists only while the stream runs.** There is no daemon: `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. The agent runs the stop hook on every transition away, when
a start fails, and on shutdown.
**And the panel says so, in the room.** `hosts/touch-panel/` now ships `mic-in-use` and
a red **MIC LIVE** badge on the touch dock, lit whenever anything is capturing that
panel's microphone — this feature or not. A room microphone somebody elsewhere can open
has to be visible to the person standing in front of it, and Home Assistant knowing is
not the same as them knowing. It reads PipeWire's actual capture streams rather than
trusting who asked, ignores monitor streams (recording what the panel is *playing* is
not the microphone), and without `jq` it falls back to over-reporting — the right
direction to be wrong in for a warning light.
**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 this component's own audio layer refuses to select one on
purpose (`test_selection.py`). remap-source turns it into an ordinary microphone with a
name and a description, which is what Discord's device list needs.
**The one command that decides whether any of this works:**
```
ssh <user>@<host> pw-record --help
```
That is the whole transport. If it works, the microphone works.
## 7. What is tested, and what is not ## 7. What is tested, and what is not
@ -125,8 +177,15 @@ Tested here, and it runs anywhere:
- `pebble-presence/test/run-tests.sh` — the toggles line is packed by the real JS and - `pebble-presence/test/run-tests.sh` — the toggles line is packed by the real JS and
parsed by the real C, so those two implementations of one format cannot drift. parsed by the real C, so those two implementations of one format cannot drift.
- The generated HA package parses as YAML, for one client and for several. - The generated HA package parses as YAML, for one client and for several.
- The remote-source generator: every `remote` block produces BOTH hooks, a local source
is left alone, and the transport parameters land in the env file. The stop hook is a
safety property, so it is pinned by a test rather than by having been written once.
- `mic-in-use`'s monitor-exclusion logic, against fixtures: a microphone capture lights
the badge, a monitor capture does not, nothing recording does not.
**Not tested, because it needs the actual machines:** every command that changes state **Not tested, because it needs the actual machines:** the SSH audio pipe end to end
(`pw-record` on the panel, `pw-cat` here, and whether the latency is pleasant over that
wifi link), every command that changes state
(`pactl set-default-source`, `move-source-output`), the pactl JSON shapes the fixtures (`pactl set-default-source`, `move-source-output`), the pactl JSON shapes the fixtures
imitate, the MQTT discovery payloads against a real Home Assistant, and — the one most imitate, the MQTT discovery payloads against a real Home Assistant, and — the one most
likely to bite — whether the presence entity reports rooms quickly and accurately likely to bite — whether the presence entity reports rooms quickly and accurately

View File

@ -64,6 +64,10 @@ class Client:
self.desk_source = config["desk_source"] self.desk_source = config["desk_source"]
self.move_streams = list(config.get("move_streams") or []) self.move_streams = list(config.get("move_streams") or [])
self.reconcile_seconds = float(config.get("reconcile_seconds", 10)) self.reconcile_seconds = float(config.get("reconcile_seconds", 10))
# How long a source with a start hook gets to show up before it counts as
# absent. An SSH tunnel over the LAN is up well inside this; the timeout is
# what stops a dead panel from hanging the switch.
self.source_timeout = float(config.get("source_timeout_seconds", 6))
# option name -> {source, start_command, stop_command} # option name -> {source, start_command, stop_command}
self.sources: dict[str, dict] = { self.sources: dict[str, dict] = {
DESK: {"source": self.desk_source, "start_command": "", "stop_command": ""} DESK: {"source": self.desk_source, "start_command": "", "stop_command": ""}
@ -160,34 +164,41 @@ class Client:
log.warning("%s hook could not run: %s", label, exc) log.warning("%s hook could not run: %s", label, exc)
def apply(self, option: str) -> str: def apply(self, option: str) -> str:
"""Make `option` the live input. Returns the option actually applied — which is """Make `option` the live input. Returns the option actually applied.
`desk` whenever the requested one cannot be found, because silence is a worse
answer than the wrong room's microphone only until you remember that the desk ORDER MATTERS HERE, and it was wrong the first time: a remote microphone does
mic is the one the person is not standing in front of. Falling back loudly and not EXIST as a PipeWire source until its start hook has run and the stream is
predictably beats leaving Discord holding a device that has gone away.""" up. Looking for the source first meant a network mic could never be selected
it fell back to the desk every time, correctly by the letter of the code and
uselessly in practice. So: hooks first, then wait for the source to appear,
then fall back only if it genuinely never does.
"""
wanted = self.sources.get(option) wanted = self.sources.get(option)
if wanted is None: if wanted is None:
log.warning("unknown input %r — falling back to %s", option, DESK) log.warning("unknown input %r — falling back to %s", option, DESK)
option, wanted = DESK, self.sources[DESK] option, wanted = DESK, self.sources[DESK]
sources = audio_sources.list_sources()
target = audio_sources.select_source(sources, wanted["source"])
if target is None and option != DESK:
log.warning("no audio source matching %r on this machine — falling back to %s",
wanted["source"], DESK)
option, wanted = DESK, self.sources[DESK]
target = audio_sources.select_source(sources, wanted["source"])
if target is None:
log.error("no audio source matching %r either — leaving the input alone",
wanted["source"])
return self.active
if option != self.active: if option != self.active:
previous = self.sources.get(self.active) previous = self.sources.get(self.active)
if previous: if previous:
self._run_hook(previous.get("stop_command", ""), f"{self.active} stop") self._run_hook(previous.get("stop_command", ""), f"{self.active} stop")
self._run_hook(wanted.get("start_command", ""), f"{option} start") self._run_hook(wanted.get("start_command", ""), f"{option} start")
target = self._await_source(wanted["source"],
timeout=self.source_timeout if wanted.get("start_command") else 0)
if target is None and option != DESK:
log.warning("no audio source matching %r appeared — falling back to %s",
wanted["source"], DESK)
# Tear down whatever the failed start hook left running before leaving it
# behind: a half-started remote microphone is the hot-mic case.
self._run_hook(wanted.get("stop_command", ""), f"{option} stop (failed)")
option, wanted = DESK, self.sources[DESK]
target = self._await_source(wanted["source"], timeout=0)
if target is None:
log.error("no audio source matching %r either — leaving the input alone",
wanted["source"])
return self.active
audio_sources.set_default_source(target) audio_sources.set_default_source(target)
# And move what is already recording: an application that picked a specific # And move what is already recording: an application that picked a specific
# microphone in its own settings — which anyone with a studio mic has — does # microphone in its own settings — which anyone with a studio mic has — does
@ -202,6 +213,20 @@ class Client:
log.info("input is now %s (%s)", option, target.description) log.info("input is now %s (%s)", option, target.description)
return option return option
def _await_source(self, pattern: str, timeout: float) -> audio_sources.Source | None:
"""Look for a source, giving a just-started one time to register.
A local device is found on the first pass and this costs nothing. A tunnelled
one takes a moment: the hook returns as soon as the transport is up, and the
node appears in PipeWire slightly after that.
"""
deadline = time.monotonic() + max(0.0, timeout)
while True:
found = audio_sources.select_source(audio_sources.list_sources(), pattern)
if found is not None or time.monotonic() >= deadline:
return found
time.sleep(0.25)
def make_mqtt_client(client_id: str) -> mqtt.Client: def make_mqtt_client(client_id: str) -> mqtt.Client:
# Same shim as every other agent here: paho 2.x wants an explicit callback API # Same shim as every other agent here: paho 2.x wants an explicit callback API

View File

@ -48,6 +48,59 @@ def entity_ids(node_id: str) -> dict[str, str]:
} }
def remote_source_name(source: dict) -> str:
"""A remote microphone's PipeWire source name. Generated rather than configured:
it is created by remote-mic.sh on this desktop, so nobody has to go and look it up
on a machine where it does not exist yet."""
return str(source.get("source") or "").strip() or f"micfollow_{source['room']}"
def build_remote_env(source: dict, repo_root: Path) -> str:
"""One remote microphone's parameters, read by remote-mic.sh."""
remote = source.get("remote") or {}
name = remote_source_name(source)
description = source.get("description") or f"{room_label(source['room'])} microphone"
return "\n".join([
f"# Generated by mic-follow/generate.py for the '{source['room']}' microphone.",
"# Read by mic-follow/remote-mic.sh. Regenerate rather than editing.",
f"REMOTE_HOST={remote.get('host', '')}",
f"REMOTE_USER={remote.get('user', 'kiosk')}",
f"REMOTE_MIC={remote.get('mic_source', '')}",
f"SOURCE_NAME={name}",
f"SOURCE_DESCRIPTION={description}",
f"RATE={remote.get('rate', 48000)}",
f"CHANNELS={remote.get('channels', 1)}",
f"LATENCY_MS={remote.get('latency_ms', 40)}",
f"SSH_KEY={remote.get('ssh_key', '')}",
"",
])
def source_entry(source: dict, repo_root: Path) -> dict:
"""One entry of the agent's `sources` list.
A remote microphone's hooks are generated, not typed: the whole point of the
`remote` block is that "start the stream" and much more importantly "stop the
stream" are not things a person should be relying on themselves to have written
correctly in a config file.
"""
if source.get("remote"):
room = source["room"]
script = repo_root / "mic-follow" / "remote-mic.sh"
return {
"room": room,
"source": remote_source_name(source),
"start_command": f"{script} start {room}",
"stop_command": f"{script} stop {room}",
}
return {
"room": source["room"],
"source": source["source"],
"start_command": source.get("start_command", ""),
"stop_command": source.get("stop_command", ""),
}
def build_client_config(client: dict, cfg: dict) -> dict: def build_client_config(client: dict, cfg: dict) -> dict:
prefix = cfg["network"]["subnet_prefix"] prefix = cfg["network"]["subnet_prefix"]
container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}" container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
@ -65,15 +118,8 @@ def build_client_config(client: dict, cfg: dict) -> dict:
"desk_source": client["desk_source"], "desk_source": client["desk_source"],
"move_streams": list(client.get("move_streams") or []), "move_streams": list(client.get("move_streams") or []),
"reconcile_seconds": client.get("reconcile_seconds", 10), "reconcile_seconds": client.get("reconcile_seconds", 10),
"sources": [ "source_timeout_seconds": client.get("source_timeout_seconds", 6),
{ "sources": [source_entry(source, REPO) for source in client.get("sources") or []],
"room": source["room"],
"source": source["source"],
"start_command": source.get("start_command", ""),
"stop_command": source.get("stop_command", ""),
}
for source in client.get("sources") or []
],
} }
@ -294,6 +340,10 @@ def main(argv: list[str]) -> int:
config_file.write_text(json.dumps(build_client_config(client, cfg), indent=2) + "\n") config_file.write_text(json.dumps(build_client_config(client, cfg), indent=2) + "\n")
config_file.chmod(0o600) config_file.chmod(0o600)
(client_dir / f"mic-follow-{node_id}.service").write_text(build_unit(client)) (client_dir / f"mic-follow-{node_id}.service").write_text(build_unit(client))
for source in client.get("sources") or []:
if source.get("remote"):
(client_dir / f"remote-{source['room']}.env").write_text(
build_remote_env(source, REPO))
package_dir = out_dir / "ha-package" package_dir = out_dir / "ha-package"
package_dir.mkdir(parents=True, exist_ok=True) package_dir.mkdir(parents=True, exist_ok=True)
@ -320,7 +370,9 @@ def main(argv: list[str]) -> int:
print(f"wrote {out_dir}/ for {len(clients)} client(s):") print(f"wrote {out_dir}/ for {len(clients)} client(s):")
for client in clients: for client in clients:
print(f" {client['node_id']}/client.json, {client['node_id']}/mic-follow-{client['node_id']}.service") remotes = [s["room"] for s in client.get("sources") or [] if s.get("remote")]
extra = f", remote-*.env for {', '.join(remotes)}" if remotes else ""
print(f" {client['node_id']}/client.json, {client['node_id']}/mic-follow-{client['node_id']}.service{extra}")
print(" ha-package/mic_follow.yaml") print(" ha-package/mic_follow.yaml")
print(" dock-bindings.md") print(" dock-bindings.md")
print(" identity-toggles.json + identity-toggles.env (paste into identity.env)") print(" identity-toggles.json + identity-toggles.env (paste into identity.env)")

166
mic-follow/remote-mic.sh Executable file
View File

@ -0,0 +1,166 @@
#!/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

View File

@ -40,6 +40,14 @@ fi
core_log "Installing" core_log "Installing"
mkdir -p "$HOME/.config/mic-follow" "$HOME/.config/systemd/user" mkdir -p "$HOME/.config/mic-follow" "$HOME/.config/systemd/user"
install -m 600 "${GEN}/client.json" "$HOME/.config/mic-follow/client.json" install -m 600 "${GEN}/client.json" "$HOME/.config/mic-follow/client.json"
# Any remote microphones this client can switch to. Each one names a machine and an
# SSH user; remote-mic.sh reads them by room name.
shopt -s nullglob
for env_file in "${GEN}"/remote-*.env; do
install -m 600 "$env_file" "$HOME/.config/mic-follow/$(basename "$env_file")"
echo " remote microphone: $(basename "$env_file" .env | sed 's/^remote-//')"
done
shopt -u nullglob
install -m 644 "${GEN}/mic-follow-${NODE_ID}.service" \ install -m 644 "${GEN}/mic-follow-${NODE_ID}.service" \
"$HOME/.config/systemd/user/mic-follow.service" "$HOME/.config/systemd/user/mic-follow.service"
@ -67,6 +75,11 @@ Left to do, and none of it is on this machine:
[ ] Stream Dock: bind the toggle key as described in [ ] Stream Dock: bind the toggle key as described in
${DIR}/generated/dock-bindings.md ${DIR}/generated/dock-bindings.md
[ ] For each remote microphone, check the SSH path this desktop will use:
ssh <user>@<host> pw-record --help
That one command is the whole transport. If it works, the microphone works;
if it does not, no amount of Home Assistant configuration will help.
[ ] Check the presence entity actually reports area_ids: [ ] Check the presence entity actually reports area_ids:
Developer Tools -> States -> the presence_entity for this client. Developer Tools -> States -> the presence_entity for this client.
If its state is a friendly room name rather than an area_id, the automation If its state is a friendly room name rather than an area_id, the automation

View File

@ -20,6 +20,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import audio_sources as audio import audio_sources as audio
import generate
SOURCES = [ SOURCES = [
{ {
@ -137,5 +138,63 @@ class StreamMoveTests(unittest.TestCase):
self.assertEqual([s.index for s in moved], [12]) self.assertEqual([s.index for s in moved], [12])
class RemoteSourceTests(unittest.TestCase):
"""The generated hooks for a microphone on another machine.
These are worth pinning because the STOP hook is a safety property: a room
microphone still streaming after the switch left it is a hot mic in a shared flat,
and "the generator emitted a stop command" is exactly the kind of thing that
silently stops being true.
"""
def setUp(self):
self.remote = {
"room": "loggia",
"description": "Loggia panel mic",
"remote": {"host": "touch-panel-loggia", "user": "kiosk", "latency_ms": 40},
}
self.local = {"room": "kitchen", "source": "DJI MIC MINI"}
def test_remote_source_gets_a_generated_name(self):
self.assertEqual(generate.remote_source_name(self.remote), "micfollow_loggia")
def test_an_explicit_name_wins(self):
named = dict(self.remote, source="my_own_name")
self.assertEqual(generate.remote_source_name(named), "my_own_name")
def test_remote_source_gets_both_hooks(self):
entry = generate.source_entry(self.remote, Path("/repo"))
self.assertEqual(entry["source"], "micfollow_loggia")
self.assertIn("remote-mic.sh start loggia", entry["start_command"])
self.assertIn("remote-mic.sh stop loggia", entry["stop_command"])
def test_every_remote_source_has_a_stop_hook(self):
for host in ("panel", "other-machine"):
entry = generate.source_entry(
{"room": "x", "remote": {"host": host}}, Path("/repo"))
self.assertTrue(entry["stop_command"], "a remote source without a stop hook")
def test_local_source_is_left_alone(self):
entry = generate.source_entry(self.local, Path("/repo"))
self.assertEqual(entry["source"], "DJI MIC MINI")
self.assertEqual(entry["start_command"], "")
self.assertEqual(entry["stop_command"], "")
def test_env_carries_the_transport_parameters(self):
env = generate.build_remote_env(self.remote, Path("/repo"))
self.assertIn("REMOTE_HOST=touch-panel-loggia", env)
self.assertIn("REMOTE_USER=kiosk", env)
self.assertIn("SOURCE_NAME=micfollow_loggia", env)
self.assertIn("LATENCY_MS=40", env)
# Empty means "the panel's own default input", which is what lets this work
# without knowing that machine's device names.
self.assertIn("REMOTE_MIC=", env)
def test_room_labels_are_short_and_readable(self):
self.assertEqual(generate.room_label("loggia"), "Loggia")
self.assertEqual(generate.room_label("living_room"), "Living room")
self.assertEqual(generate.room_label("desk"), "Desk")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -164,6 +164,7 @@ install -m 0755 "${CONFIGS_DIR}/sway/ha-kiosk" "$INCLUDES/usr/loca
install -m 0755 "${CONFIGS_DIR}/spotify/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch" install -m 0755 "${CONFIGS_DIR}/spotify/spotify-launch" "$INCLUDES/usr/local/bin/spotify-launch"
install -m 0755 "${CONFIGS_DIR}/discord/discord-launch" "$INCLUDES/usr/local/bin/discord-launch" install -m 0755 "${CONFIGS_DIR}/discord/discord-launch" "$INCLUDES/usr/local/bin/discord-launch"
install -m 0755 "${CONFIGS_DIR}/keyboard/toggle-keyboard" "$INCLUDES/usr/local/bin/toggle-keyboard" install -m 0755 "${CONFIGS_DIR}/keyboard/toggle-keyboard" "$INCLUDES/usr/local/bin/toggle-keyboard"
install -m 0755 "${CONFIGS_DIR}/mic/mic-in-use" "$INCLUDES/usr/local/bin/mic-in-use"
# Touchscreen misclassification override (inert 0000:0000 template until the real # Touchscreen misclassification override (inert 0000:0000 template until the real
# hardware's vendor/product ID is filled in — see that file's own setup steps). # hardware's vendor/product ID is filled in — see that file's own setup steps).
@ -314,6 +315,9 @@ else
echo " Touch dock : bottom bar (eww) — Spotify / Home / Web / Keyboard buttons" echo " Touch dock : bottom bar (eww) — Spotify / Home / Web / Keyboard buttons"
fi fi
echo " On-screen keyboard: wvkbd, toggled from the dock's Keyboard button" echo " On-screen keyboard: wvkbd, toggled from the dock's Keyboard button"
echo " Microphone badge : a red LIVE badge on the dock whenever anything is recording"
echo " this panel's mic — including a desktop in another room using"
echo " it as a remote microphone (mic-follow/)"
echo " Touchscreen input : native wl_touch preferred; degrades to a usable" echo " Touchscreen input : native wl_touch preferred; degrades to a usable"
echo " single-touch/click-drag pointer if the hardware reports" echo " single-touch/click-drag pointer if the hardware reports"
echo " as an emulated-mouse HID device instead — see README.md" echo " as an emulated-mouse HID device instead — see README.md"

View File

@ -848,11 +848,32 @@ def validate_mic_follow(cfg: dict, rep: Report) -> None:
f"'{room}' is also this client's own room, so following the " f"'{room}' is also this client's own room, so following the "
"person there switches away from the desk mic while they sit " "person there switches away from the desk mic while they sit "
"at the desk") "at the desk")
if not str(source.get("source") or "").strip(): remote = source.get("remote")
if remote is not None and not isinstance(remote, dict):
rep.error(f"{source_where}.remote", "must be an object")
remote = None
if remote:
if not str(remote.get("host") or "").strip():
rep.error(f"{source_where}.remote.host",
"missing — this is the machine the microphone is plugged into")
if source.get("start_command") or source.get("stop_command"):
rep.warn(f"{source_where}.remote",
"a remote block AND explicit hooks — the generated hooks win, "
"and yours are ignored. Drop one of them")
latency = remote.get("latency_ms", 40)
if not isinstance(latency, (int, float)) or isinstance(latency, bool) or not 5 <= latency <= 500:
rep.error(f"{source_where}.remote.latency_ms",
f"must be 5-500, got {latency!r}")
channels = remote.get("channels", 1)
if channels not in (1, 2):
rep.error(f"{source_where}.remote.channels", f"must be 1 or 2, got {channels!r}")
elif not str(source.get("source") or "").strip():
# A local source has to be named; a remote one gets a generated name.
rep.error(f"{source_where}.source", rep.error(f"{source_where}.source",
"missing — run mic-follow/desktop_agent.py --list-sources on that " "missing — run mic-follow/desktop_agent.py --list-sources on that "
"machine for the real strings") "machine for the real strings, or give this source a `remote` block "
if source.get("start_command") and not source.get("stop_command"): "if the microphone is on another machine")
if not remote and source.get("start_command") and not source.get("stop_command"):
rep.warn(f"{source_where}.stop_command", rep.warn(f"{source_where}.stop_command",
"a start_command with no stop_command leaves whatever it started " "a start_command with no stop_command leaves whatever it started "
"running after the switch moves away — for a network microphone " "running after the switch moves away — for a network microphone "