11 KiB
mic-follow — the microphone follows the person
A voice call that survives walking out of the room. The person leaves their desk, the locator says which room they are in now, and their machine's live microphone switches to one that can hear them there — then back to the good desk mic when they sit down.
Built for the specific case of a smoke break on the Loggia during a game, and written
per client so a second person with a second desktop is one more entry in
CoreSystemConfig.json and nothing else.
1. The one rule
Off means the desk mic.
The Follow-me switch being off is not "ignore me", it is an active guarantee that the machine is on its own microphone. Being live on the wrong mic is the failure you notice in front of five other people, so the safe state is reachable by one tap on the dock, one button on a watch, or one MQTT message — and it does not depend on presence being right, on the remote machine being up, or on the agent having seen a recent update. The agent also returns to the desk mic when it shuts down.
2. Who decides what
locator (BLE via identity; RuView/Frigate corroborate — see §3)
│ presence_entity's state is an area_id
▼
Home Assistant ── generated automation ──> select.mic_follow_<node>_input
│ (only while switch.mic_follow_<node>_armed is on)
▼ MQTT
desktop_agent.py ──> pactl: set the default source, and MOVE the running
capture streams of the configured applications
Home Assistant decides where the person is and therefore which mic should be live. The agent knows how to change the input on this machine and nothing about presence, people or rooms beyond the names in its own config. Same rule as every other agent in this repo: the inbound control surface is MQTT discovery entities and nothing else.
Moving the streams matters as much as setting the default. Changing the default
source only affects applications that asked for "default", and anyone who owns a studio
mic has picked it explicitly in Discord. move_streams names the applications whose
already-running capture stream gets moved too.
3. What the locator can and cannot tell you
presence_entity must be an entity whose state is an HA area_id
(docs/rooms-and-endpoints.md). Getting one is the part of this that is not built here:
| Source | Gives | Usable to drive this? |
|---|---|---|
BLE / Bermuda, surfaced by identity's /presence |
a person, resolved to a room | Yes — the only identity-bearing room-level source in this household |
| RuView (CSI radar) | a room is occupied, by somebody | No on its own — it cannot say who. Good for corroboration |
| Frigate | a recognised face at the door | No — it is a peephole camera, not per-room |
So this rides on BLE, with everything else as confirmation. That matters because
room-level presence has never been measured in this flat — the repo says so
elsewhere and it is still true. Before trusting it with your microphone, watch the
entity in Developer Tools while you walk to the Loggia and back. If it lags by a minute
or reports a friendly room name instead of an area_id, the automation will never fire
and the mic will never move.
dwell_seconds (default 20) is the guard against a twitchy locator: a walk past a door
should not move your microphone. return_dwell_seconds (default 5) is deliberately
shorter — sitting down should give you the good mic back before you say anything into
it.
4. Three ways to flip it, one place to read it
Every surface shows the same entity: sensor.mic_follow_<node>_status, whose state is
the name of the microphone that is live right now — Desk, Loggia. That sensor
exists so no surface has to reimplement the same three-way template, and so the answer
to "what am I being heard through" is one string anything can print.
- Stream Dock key — displays that sensor and calls
switch.toggle. The Home Assistant plugin subscribes to HA's websocket, so the key updates when the state changes rather than on a timer: nothing polls, and the key is right within a moment of the mic actually moving, including when it was the watch or the automation that moved it. Bindings are generated intogenerated/dock-bindings.md; leave the key's title empty and let the state be the whole label, becauseLoggiain large type is readable across a room andFollow-me micover a smallLoggiais not. - Pebble watchapp — long-press Select on the plan screen. The screen shows the live
microphone in large type,
FOLLOWINGorDESK ONLYas a coloured pill, and Select flips it. Up/Down cycles clients if there is more than one. - Home Assistant — the switch and the select, like anything else.
The watch goes through identity's /toggles, not straight at Home Assistant: identity
already holds an HA token and the phone already holds identity's, so one button on a
wrist does not put an HA admin token in a watchapp's settings. It is an allowlist,
not a proxy — only switch.* entities that are named in TOGGLE_ALLOWLIST_JSON, only
on/off/toggle, and an id that is not listed is a 404. Paste the line from
generated/identity-toggles.env into identity.env or the watch sees nothing.
5. Setting it up
- Fill in
mic_followinCoreSystemConfig.json. Get thedesk_sourceand eachsourcestring by runningmic-follow/desktop_agent.py --list-sourceson the machine in question — any part of a name or description matches. tools/validate-config.py.- On each client machine:
mic-follow/setup-client.sh <node_id>. - Put
generated/ha-package/mic_follow.yamlin HA'spackages/(the same directorystream-dockuses) and reload YAML. - Paste
generated/identity-toggles.envintoidentity.env, restart identity. - Bind the dock key from
generated/dock-bindings.md.
6. A remote microphone — the Loggia case
The Loggia has no microphone you can plug into the desktop, but it does have a machine
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.
panel: pw-record (its mic) ──ssh──> desktop: pw-cat --playback
into a null sink
│
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:
{ "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
Tested here, and it runs anywhere:
test_selection.py— 17 cases over the audio layer's parsing and selection: monitor sources can never be selected (picking one transmits what the desktop is playing, the worst outcome available), exact names beat substrings, an ambiguous pattern resolves the same way after a reboot instead of coin-flipping, and only the configured applications' streams get moved.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.- The generated HA package parses as YAML, for one client and for several.
- The remote-source generator: every
remoteblock 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: 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
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
enough to be worth wiring to a microphone at all (§3).