Let the candidate switch microphones mid-interview

Merged
GI
1d ago
+2174-19

Once the interview started, the page had no way to move off the microphone the preflight opened, so a headset plugged in afterwards went unused while the laptop microphone kept hearing Jim through the speakers, and the only fix was to end the interview and start over. A select beside the Mic button now lists the inputs once the room is joined, and picking one moves the published audio to it without reconnecting. Chrome's default and Windows' communications aliases are left out of the list, as in #411's preflight picker, so picking the alias of the input in use cannot reopen it. Every real input keeps its own row, including several on one audio interface that share a groupId, and the select stays hidden unless there are at least two rows. When the input in use matches no row, such as a system default with a group of its own, the select names it instead of reading as if no microphone were open. The note under the controls is announced from a status region that is always present, since one hidden until filled is skipped by screen readers, and when unplugging leaves one input the select hands focus to the Mic button before it hides.

This covers the interview after it starts and nothing before it. Choosing devices in the preflight is #206, which #411 added; this change leaves the preflight alone and stores no device preference. Rebased onto #411, its selectMicrophone in web/devices.js now builds its constraints with the same helper the switch opens its input with, both capture through the one getUserMedia call, and the header of its web/audio-input.js names web/mic-switch.js as the mid-interview counterpart.

The new input is opened through the existing request path in web/devices.js, so the getUserMedia allowlist in tests/browser/meet-tab-audio.test.js still finds exactly one capture site, and it is handed to the published track with replaceTrack(track, { userProvidedTrack: true }). LiveKit's switchActiveDevice was the shorter route, but it reopens the device inside the vendored client and leaves the page's own stream, which the integrity heartbeat, the track-state poll and the turn ring read, holding the stopped track. A switch that fails hands the sender back to the input still working; when nothing can be sent, the page says Jim cannot hear the candidate, keeps the select open, and retries when a device is plugged in.

A switch records a MICROPHONE_SWITCHED integrity event (reason=chosen or reason=lost, then the device label) and no MICROPHONE_STOPPED. sanitize_integrity_event lists the new type in the same change, because the agent drops an unlisted type before it checks the chain, so every later event is refused as out of sequence. The wire fixture gains one generated row at the end, so the earlier hashes are unchanged and browser_generated_integrity_events_all_verify_in_the_agent fails if the type is dropped from the allowlist. A failed switch that has to drop an input that was still working records MICROPHONE_STOPPED with reason=switch_failed, since stopping it fires no ended. docs/integrity-evidence.md describes the event and both failure cases. While a switch holds LiveKit's mute flag, LiveKit has disabled the input still in the stream, so for that stretch the heartbeat and the 1s state poll report the Mic button's state rather than the track's, and a candidate who never pressed Mic is not recorded as muted.

The Mic button still mutes the track only, and also reaches the input a switch is still handing to the sender, which is in neither the stream nor the publication yet. The publication's own mute flag is a server-side mute: the SFU stops forwarding the track, so the agent would receive no frames rather than the silence that ends a spoken turn. The switch therefore raises that flag only across replaceTrack, since LiveKit enables a track it is handed from it, and does so whatever the Mic state, because the candidate can press Mic while the replace is pending; it lowers it afterwards with the Mic state read at that point, including when LiveKit raised it itself after a loss. LiveKit enables the track and announces unmuted in one synchronous step when the flag comes down, and a listener on that event disables a muted candidate's input again before anything else runs, so it is never live.

Validation: tests/browser/mic-switch.test.js drives the switch against fake tracks and a fake LiveKit publication and drives the page's device listing through loadInterview; tests/browser/devices.test.js covers the exact-device and default requests. Each guarded behavior was broken in turn and a test failed. node scripts/gen-wire-fixtures.mjs --check, rustfmt --check and Prettier pass. cargo test, clippy, ESLint and the Playwright lanes run in CI only, since this checkout lacks the vendored files; the browser tests that fail locally for that reason fail identically on a clean main. The screenshots below come from that flow in Chromium with its fake media devices (--use-fake-device-for-media-stream) and a LiveKit room that connects nowhere, the same setup tests/browser/restore-starter.test.js uses; the device names are Chromium's own. Not checked: a real browser with two physical microphones.

Screenshots of the interview controls after joining. Before, on main:

before-dock (1)

After, on joining, with the input in use named in the select:
after-1-dock (1)

After picking another input:
after-2-switched (1)

After picking an input that refuses to open:
after-3-refused (1)