Commit Graph

2 Commits (864b5d1719b527e3743b73fa79c07bac4382330a)

Author SHA1 Message Date
Amir Alexander Abdelbaki 864b5d1719 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
2026-08-31 13:55:31 +02:00
Amir Alexander Abdelbaki cebdc9529b Follow-me microphone: the mic switches to the room you walked into
A voice call that survives a smoke break. The person leaves their desk, the
locator says which room they are in, their machine's live microphone switches
to one that can hear them there, and back to the studio mic when they sit down
again. Written per CLIENT, so a second person with a second desktop is one
more entry in CoreSystemConfig.json and nothing else changes.

ONE RULE MAKES IT PREDICTABLE: 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 — reachable from the dock, the watch or MQTT, honoured when
the agent shuts down, and independent of whether presence is right or the
remote machine is up. Being live on the wrong mic is the failure you notice in
front of five other people, so the safe state has to be the cheap one.

Home Assistant decides where the person is; the desktop agent only knows how
to change the input. That split is the same one every other agent here uses —
the inbound control surface is MQTT discovery entities and nothing else, and
no presence logic runs on a desktop.

The agent sets the default source AND moves the already-running capture
streams of the configured applications. Anyone who owns a studio mic has
picked it explicitly in Discord, and an explicitly-picked device does not
follow the default; without the second half the switch would appear to do
nothing in the one application it exists for.

Three surfaces, one entity to read. sensor.mic_follow_<node>_status has as its
STATE the name of the microphone that is live right now — "Desk", "Loggia" —
so nothing has to reimplement the same three-way template:

  Stream Dock  a key showing that sensor, calling switch.toggle. The HA plugin
               subscribes to the websocket, so it updates on state change
               rather than on a timer, including when the watch moved it.
               Leave the key title empty: "Loggia" in large type is readable
               across a room, "Follow-me mic" over a small "Loggia" is not.
  Pebble       a new toggles screen, long-press Select on the plan. The live
               microphone in large type, FOLLOWING / DESK ONLY as a coloured
               pill, Select flips it, Up/Down cycles clients.
  HA           the switch and the select, like anything else.

The watch reaches HA through a new allowlist in identity (/toggles), not
directly: 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 into a
watchapp's settings. Only switch.* entities that are named in
TOGGLE_ALLOWLIST_JSON, only on/off/toggle, anything else is a 404 — and each
entry may name a detail_entity whose state is served alongside, which is how
the watch displays the live microphone instead of deriving it. Every response
re-reads the state rather than assuming it: "I sent the command" is not the
same fact as "the switch is on".

start_command/stop_command on a source are the hook for a microphone that is
not simply plugged into the machine. The STOP hook is the important one: a room
microphone still streaming after the switch left it is a hot mic in somebody's
flat. It runs on every transition away and on shutdown, and the validator warns
about a start with no stop.

Tested, and it runs anywhere: 17 fixture cases over source selection — a
monitor source 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 move. The new watch message is
round-tripped through the real JS packer and the real C parser, including the
two cases that decode as plausible garbage otherwise: a value containing the
field separator, and a record truncated mid-way. The generated HA package
parses as YAML for one client and for several.

Untested, and it needs the actual machines: every command that changes state
(pactl set-default-source, move-source-output), the pactl JSON shapes the
fixtures imitate, the discovery payloads against a real HA — and the one most
likely to bite, whether BLE presence reports rooms fast and accurately enough
to be worth wiring to a microphone at all. Room-level presence has never been
measured in this flat. RuView can say a room is occupied but not by whom, and
Frigate recognises faces at the door, not per room, so this rides on BLE with
both of those as corroboration. mic-follow/README.md sections 3 and 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7yp4NcmX4zqja1MKRjeWJ
2026-08-31 13:41:33 +02:00