193 lines
11 KiB
Markdown
193 lines
11 KiB
Markdown
# 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 into `generated/dock-bindings.md`; leave the key's
|
|
title empty and let the state be the whole label, because `Loggia` in large type is
|
|
readable across a room and `Follow-me mic` over a small `Loggia` is not.
|
|
- **Pebble watchapp** — long-press Select on the plan screen. The screen shows the live
|
|
microphone in large type, `FOLLOWING` or `DESK ONLY` as 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
|
|
|
|
1. Fill in `mic_follow` in `CoreSystemConfig.json`. Get the `desk_source` and each
|
|
`source` string by running `mic-follow/desktop_agent.py --list-sources` on the
|
|
machine in question — any part of a name or description matches.
|
|
2. `tools/validate-config.py`.
|
|
3. On each client machine: `mic-follow/setup-client.sh <node_id>`.
|
|
4. Put `generated/ha-package/mic_follow.yaml` in HA's `packages/` (the same directory
|
|
`stream-dock` uses) and reload YAML.
|
|
5. Paste `generated/identity-toggles.env` into `identity.env`, restart identity.
|
|
6. 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:
|
|
|
|
```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
|
|
|
|
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 `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:** 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).
|