Choose a camera, microphone or speaker during a call
The holding screen picked the devices and was the only place that could: a microphone chosen wrongly, unplugged, or wanted for a headset halfway through cost the person the call to change. The room now opens the same three pickers behind a Devices button, and carries the studio audio switch beside them. The swap rests on replaceTrack, which changes what a sender is sending without touching the m-line it sends on — no offer, no answer, no rollback, and nothing §5.1's perfect negotiation has to hear about. The one case that renegotiates is a browser that joined with no camera at all: createPC already left a recvonly transceiver for the kind, so the m-line exists in the right place and only its direction turns round, deferred to the connection's own quiet moment the way a share transceiver is. Switches are serialised. Two clicks are two replaceTrack loops over the same senders, and run at once what every peer receives is decided by whichever device opened fastest; queued, the last click wins. The generation counter is per kind, so changing the camera cannot cancel a microphone change that has not settled. A run superseded before it opens anything puts nothing back; a run superseded after its sender loop keeps its track, because stopping it there would stop the call's own media until a newer choice arrived — which it may never do. Studio audio also becomes movable: the ceiling now follows the preference rather than the connection, so flipping it mid-call reaches the connections that already exist and flipping it back lets them go. A re-open that fails puts the preference and the checkbox back rather than claiming raw capture the call is not on, and it no longer pins prefs.mic for someone who was following the system default. repairCamera stands down when a switch took its sender while it was awaiting: the track it captured is the one that switch is retiring, and putting it back would undo the switch and freeze that peer on a track that has ended. Gates: make ci, and make browser with a new `switching` scenario — the panel opens, both kinds are switched with the far side watched across each, then three switches are fired at once and the media is watched again. Two node tests hold devices.js and room.js to being mirrors of each other, and cover the invariants the browser gate cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 files changed,
+1208
−7
Makefile+2 −0internal/meet/static/devices.js+90 −2internal/meet/static/meet.css+28 −0internal/meet/static/room.js+577 −3internal/meet/templates/join.html+8 −0internal/meet/templates/room.html+22 −0internal/meettest/browser_test.go+1 −0internal/meettest/holding_test.go+40 −0internal/meettest/room_test.go+47 −0test/browser/audio.test.mjs+130 −0test/browser/call.mjs+107 −0test/browser/devices.test.mjs+116 −0test/browser/room-lane.test.mjs+40 −2
CI passed — run details
diff --git a/Makefile b/Makefile| index 71c7adc..ebf9b77 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -149,6 +149,8 @@ browser: |
| node --test test/browser/room-share.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| node --test test/browser/media.test.mjs |
| + node --test test/browser/audio.test.mjs |
| + node --test test/browser/devices.test.mjs |
| npm install --no-save playwright@1.62.1 |
| npx playwright install chromium |
| go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/ |
diff --git a/internal/meet/static/devices.js b/internal/meet/static/devices.js| index 40ad506..f824cc4 100644 |
| --- a/internal/meet/static/devices.js |
| +++ b/internal/meet/static/devices.js |
| @@ -25,6 +25,12 @@ |
| const pickCamera = $('pick-camera'); |
| const pickMic = $('pick-mic'); |
| const pickSpeaker = $('pick-speaker'); |
| + const rawRow = $('raw-row'); |
| + // field-check renders a real checkbox inside its label and gives it no |
| + // id of its own, so it is reached through the row the page wraps it in |
| + // rather than by an id this file would have to agree with twice. |
| + const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| + const rawHelp = $('raw-help'); |
| const muteButton = $('toggle-mute'); |
| const cameraButton = $('toggle-camera'); |
| const muteState = $('toggle-mute-state'); |
| @@ -61,6 +67,70 @@ |
| } |
| } |
| + // audioConstraints builds the microphone half of a getUserMedia |
| + // request out of the stored preferences. It is a pure function of |
| + // `prefs` so the gate can lift it out of both files and check the two |
| + // copies still agree (test/browser/audio.test.mjs). room.js carries |
| + // it verbatim, because the room asks for a stream of its own at join |
| + // — the holding screen's is stopped by then — and a choice that held |
| + // on one page and not the other would be worse than no choice. |
| + // |
| + // The three flags are the browser's voice pipeline: echo |
| + // cancellation, noise suppression and automatic gain. Every browser |
| + // turns all three on for a microphone unless told otherwise, which is |
| + // right for a laptop on a kitchen table and wrong for an interface in |
| + // a room someone has treated — gain riding a held note, noise |
| + // suppression taking a cello for a fan. `rawAudio` is the person |
| + // saying which of those two rooms they are in. |
| + // |
| + // They are asked for as ideals and never as `exact`. A browser that |
| + // cannot turn its processing off should hand over a processed |
| + // microphone, not refuse getUserMedia and cost the person their |
| + // voice — the same rule the device ladder is built on. It is also why |
| + // the holding screen checks afterwards what it was actually given |
| + // (rawHonoured) instead of trusting that it got what it asked for. |
| + // |
| + // voiceIsolation is the newer, heavier filter in Safari and Chrome, |
| + // and it is not everywhere. An unknown constraint name in this basic, |
| + // non-exact form is dropped rather than refused, so naming it costs |
| + // nothing in a browser that has never heard of it. |
| + function audioConstraints(prefs) { |
| + const processed = !prefs.rawAudio; |
| + const constraints = { |
| + echoCancellation: processed, |
| + noiseSuppression: processed, |
| + autoGainControl: processed, |
| + voiceIsolation: processed, |
| + }; |
| + if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; |
| + return constraints; |
| + } |
| + |
| + // rawHonoured answers whether the stream really did come back |
| + // unprocessed: true, false, or null when the browser reports none of |
| + // the settings and there is nothing honest to say. getSettings is the |
| + // only place the answer lives, because audioConstraints asks as an |
| + // ideal and an ideal may simply be ignored. Someone who turned the |
| + // processing off for an interface deserves to be told it did not take, |
| + // rather than spending the call hunting for the filter in their own |
| + // gear. |
| + function rawHonoured(stream) { |
| + const track = stream && stream.getAudioTracks()[0]; |
| + if (!track || typeof track.getSettings !== 'function') return null; |
| + const settings = track.getSettings(); |
| + const reported = ['echoCancellation', 'noiseSuppression', 'autoGainControl'] |
| + .filter((key) => typeof settings[key] === 'boolean'); |
| + if (!reported.length) return null; |
| + return reported.every((key) => settings[key] === false); |
| + } |
| + |
| + // rawNotice is the sentence startPreview ends on: empty when there is |
| + // nothing to report, which is the normal case both ways round. |
| + function rawNotice() { |
| + if (!prefs.rawAudio || rawHonoured(stream) !== false) return ''; |
| + return 'This browser kept its own echo cancellation and noise suppression on, so studio audio had no effect here.'; |
| + } |
| + |
| function say(text) { |
| status.textContent = text; |
| } |
| @@ -104,6 +174,22 @@ |
| savePrefs(); |
| applyToggles(); |
| }); |
| + // Studio audio is a capture setting and not a call control, so it is a |
| + // checkbox in the pickers rather than a fourth button in the row of |
| + // toggles: it changes what the microphone sends, not whether it is |
| + // sending. Changing it re-asks for the stream, because the processing |
| + // is chosen when the track is opened and not afterwards. |
| + if (rawInput) { |
| + if (rawHelp) rawInput.setAttribute('aria-describedby', 'raw-help'); |
| + rawInput.checked = !!prefs.rawAudio; |
| + rawInput.addEventListener('change', () => { |
| + prefs.rawAudio = rawInput.checked; |
| + savePrefs(); |
| + startPreview().catch((err) => { |
| + console.error('meet: preview after a studio audio change', err); |
| + }); |
| + }); |
| + } |
| joinButton.addEventListener('click', () => { |
| stopPreview(); |
| const href = root.dataset.joinHref || ''; |
| @@ -146,7 +232,7 @@ |
| stopPreview(); |
| say('Asking for your camera and microphone…'); |
| const constraints = { |
| - audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true, |
| + audio: audioConstraints(prefs), |
| video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true, |
| }; |
| let result; |
| @@ -183,7 +269,7 @@ |
| applyToggles(); |
| joinButton.disabled = false; |
| await fillPickers().catch(() => {}); |
| - say(''); |
| + say(rawNotice()); |
| } |
| // disambiguate settles which remembered device id is stale, then |
| @@ -260,6 +346,8 @@ |
| // reflected immediately. |
| cameraRow.hidden = cameraCount === 0; |
| micRow.hidden = micCount === 0; |
| + // No microphone is no processing to choose about. |
| + if (rawRow) rawRow.hidden = micCount === 0; |
| speakerRow.hidden = !hasSinkId || speakerCount === 0; |
| } |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| index 6b4fa68..75b22ea 100644 |
| --- a/internal/meet/static/meet.css |
| +++ b/internal/meet/static/meet.css |
| @@ -114,6 +114,22 @@ |
| min-height: 1.2em; |
| } |
| +/* Studio audio takes a line of its own below the device selects: it |
| + carries a sentence of explanation, and in the pickers' flex row that |
| + sentence would otherwise read as a caption to whichever select it |
| + happened to land beside. flex-basis and not display, so the hidden |
| + attribute still hides the row — see the note at the top of this file. */ |
| +.meet-raw-row { |
| + flex-basis: 100%; |
| +} |
| + |
| +.meet-raw-help { |
| + margin-block: var(--rst-sp-1) 0; |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + max-inline-size: 42rem; |
| +} |
| + |
| .meet-tile-name > span:first-child { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| @@ -182,6 +198,18 @@ |
| margin-inline-start: var(--rst-sp-1); |
| } |
| +/* The device panel's own live region (a camera that would not open, a |
| + microphone another app is holding). Never colour alone: the sentence |
| + is the whole signal and the weight is a second cue on top of it. No |
| + display of its own, so the hidden attribute still hides it — see the |
| + note at the top of this file. */ |
| +.meet-device-note { |
| + color: var(--rst-text); |
| + font-weight: 600; |
| + flex-basis: 100%; |
| + margin-block: var(--rst-sp-2) 0; |
| +} |
| + |
| /* The relay warning (spec §5.3 step 1) sits under the status line in a |
| live region of its own. Never colour alone: the sentence is the whole |
| signal and the weight is a second cue on top of it. No display of its |
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| index 234d5f8..71e11b8 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -67,6 +67,21 @@ |
| // The chat panel's root (§6). chat.js owns everything inside it; this |
| // file owns the wire under it and the moment it becomes usable. |
| const chatEl = $('chat'); |
| + // The device panel and its controls. Every one of these is optional |
| + // as far as this file is concerned — the room renders without them in |
| + // a browser that cannot enumerate devices, and switchDevice and |
| + // fillDevices both check before they reach for one. |
| + const devicesToggle = $('devices-toggle'); |
| + const devicesPanel = $('room-devices'); |
| + const deviceNote = $('device-note'); |
| + const pickCamera = $('pick-camera'); |
| + const pickMic = $('pick-mic'); |
| + const pickSpeaker = $('pick-speaker'); |
| + const cameraRow = $('camera-row'); |
| + const micRow = $('mic-row'); |
| + const speakerRow = $('speaker-row'); |
| + const rawRow = $('raw-row'); |
| + const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| const DEVICE_KEY = 'meet.devices'; |
| const BACKOFF_MIN = 500; |
| @@ -180,6 +195,12 @@ |
| // Reading any advance at all as health let such a tile reset the |
| // budget below for ever and never earn its reset. |
| const RENDER_MOVED = 0.25; |
| + // The ceiling studio audio asks for, in bits per second. Opus is |
| + // transparent for speech well below it; the number is picked so a |
| + // microphone someone turned the processing off for is not then |
| + // squeezed down a pipe built for speech. It is a ceiling and not a |
| + // floor — the encoder still spends only what the link will carry. |
| + const RAW_AUDIO_BITRATE = 128000; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| @@ -315,6 +336,96 @@ |
| // Local media (§3.3's stored choices, §5.1's tracks) |
| // --------------------------------------------------------------- |
| + // audioConstraints builds the microphone half of a getUserMedia |
| + // request out of the stored preferences. It is a pure function of |
| + // `prefs` so the gate can lift it out of both files and check the two |
| + // copies still agree (test/browser/audio.test.mjs). room.js carries |
| + // it verbatim, because the room asks for a stream of its own at join |
| + // — the holding screen's is stopped by then — and a choice that held |
| + // on one page and not the other would be worse than no choice. |
| + // |
| + // The three flags are the browser's voice pipeline: echo |
| + // cancellation, noise suppression and automatic gain. Every browser |
| + // turns all three on for a microphone unless told otherwise, which is |
| + // right for a laptop on a kitchen table and wrong for an interface in |
| + // a room someone has treated — gain riding a held note, noise |
| + // suppression taking a cello for a fan. `rawAudio` is the person |
| + // saying which of those two rooms they are in. |
| + // |
| + // They are asked for as ideals and never as `exact`. A browser that |
| + // cannot turn its processing off should hand over a processed |
| + // microphone, not refuse getUserMedia and cost the person their |
| + // voice — the same rule the device ladder is built on. It is also why |
| + // the holding screen checks afterwards what it was actually given |
| + // (rawHonoured) instead of trusting that it got what it asked for. |
| + // |
| + // voiceIsolation is the newer, heavier filter in Safari and Chrome, |
| + // and it is not everywhere. An unknown constraint name in this basic, |
| + // non-exact form is dropped rather than refused, so naming it costs |
| + // nothing in a browser that has never heard of it. |
| + function audioConstraints(prefs) { |
| + const processed = !prefs.rawAudio; |
| + const constraints = { |
| + echoCancellation: processed, |
| + noiseSuppression: processed, |
| + autoGainControl: processed, |
| + voiceIsolation: processed, |
| + }; |
| + if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; |
| + return constraints; |
| + } |
| + |
| + // tuneAudioSender raises the audio ceiling on one connection, and is |
| + // the other half of what studio audio promises: turning the filters |
| + // off buys nothing if the result then goes out through Opus at its |
| + // default of roughly 32 kbit/s mono, which is a codec a good |
| + // microphone is heard *through* rather than heard. |
| + // |
| + // It is a sender-side change, so it needs no SDP of our own and no |
| + // renegotiation, and §5.1's perfect negotiation above never learns it |
| + // happened. Stereo is deliberately not attempted: it lives in the |
| + // fmtp line, and reaching it would mean building offers by hand |
| + // instead of the argumentless setLocalDescription §5.1 is written on |
| + // — a large risk to the one part of this file that must not wobble, |
| + // bought for a microphone that is almost always mono anyway. |
| + // |
| + // Every step is optional in some browser that is otherwise fine, so |
| + // the whole thing is best effort: a browser that will not take the |
| + // parameters carries the call at its own bitrate rather than losing |
| + // the connection over it. The encodings array is read right after |
| + // addTrack, which is where Chromium has already built it; a browser |
| + // that fills it in later simply keeps its default here, and that is a |
| + // quieter microphone and not a broken one. |
| + function tuneAudioSender(pc) { |
| + let sender; |
| + try { |
| + sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio'); |
| + } catch { |
| + return; |
| + } |
| + if (!sender || typeof sender.getParameters !== 'function') return; |
| + let params; |
| + try { |
| + params = sender.getParameters(); |
| + } catch { |
| + return; |
| + } |
| + // A sender the browser has not built encodings for yet is not one to |
| + // invent an encodings array for: that is a different request, and |
| + // not the one this function is making. |
| + if (!params.encodings || !params.encodings.length) return; |
| + // Both directions. Studio audio turned off mid-call has to give the |
| + // ceiling back, and `undefined` — not zero, which is a request to |
| + // send nothing — is how the API spells "the encoder's own default". |
| + if (prefs.rawAudio) params.encodings[0].maxBitrate = RAW_AUDIO_BITRATE; |
| + else delete params.encodings[0].maxBitrate; |
| + Promise.resolve().then(() => sender.setParameters(params)).catch(() => { |
| + // Chromium refuses parameters that did not come from the most |
| + // recent getParameters, which a renegotiation in flight can cause. |
| + // The call is unharmed, and the next connection gets its own try. |
| + }); |
| + } |
| + |
| // startLocal walks down a ladder: the remembered devices, then the |
| // browser's defaults, then audio alone. A camera that is missing or |
| // in use should not cost the call its microphone as well, and a |
| @@ -327,10 +438,16 @@ |
| } |
| const exact = (id) => ({ deviceId: { exact: id } }); |
| const remembered = !!(prefs.mic || prefs.camera); |
| + // The processing choice rides every rung. Falling back to the |
| + // browser's default *device* is not a reason to put the voice |
| + // pipeline back on someone who asked for it off, so only the |
| + // remembered device id is dropped on the way down — by |
| + // forgetDevices, which touches mic and camera and never rawAudio. |
| + const raw = { rawAudio: prefs.rawAudio }; |
| const attempts = [ |
| - { audio: prefs.mic ? exact(prefs.mic) : true, video: prefs.camera ? exact(prefs.camera) : true }, |
| - { audio: true, video: true }, |
| - { audio: true, video: false }, |
| + { audio: audioConstraints(prefs), video: prefs.camera ? exact(prefs.camera) : true }, |
| + { audio: audioConstraints(raw), video: true }, |
| + { audio: audioConstraints(raw), video: false }, |
| ]; |
| for (let i = 0; i < attempts.length; i++) { |
| try { |
| @@ -385,6 +502,323 @@ |
| local = null; |
| } |
| + // --------------------------------------------------------------- |
| + // Changing a device mid-call (§3.3's stored choice, made movable) |
| + // --------------------------------------------------------------- |
| + // |
| + // The holding screen picks the devices; until now that was the only |
| + // place they could be picked, and a microphone chosen wrongly — or |
| + // unplugged, or wanted for a headset halfway through — cost the |
| + // person the call to change. Everything below is that choice, applied |
| + // to a call already running. |
| + // |
| + // The whole thing rests on replaceTrack, which swaps the media a |
| + // sender is sending without touching the m-line it sends on. That is |
| + // the part §5.1 never has to hear about: no offer, no answer, no |
| + // rollback, and no chance of the glare the negotiation above spends |
| + // 300 ms avoiding. The one case that does renegotiate is named at |
| + // sendTrack, and it is a direction change on an m-line that already |
| + // exists rather than a new one. |
| + |
| + // Two clicks on a picker are two switches, and they must not run at |
| + // once. devices.js can settle that with a generation counter alone |
| + // because the only thing it is racing for is one <video>; here the |
| + // loser would be racing for what every sender in the mesh is holding, |
| + // and interleaved replaceTracks leave that decided by whichever |
| + // getUserMedia happened to settle last. |
| + // |
| + // So they are serialised, and the counter only decides which queued |
| + // run is still wanted: three clicks queue three runs, the first two |
| + // find themselves superseded before their turn and return without |
| + // opening anything, and the third does the work. One switch is in |
| + // flight at a time and the last click wins, which is what clicking |
| + // means. |
| + // One counter per kind, not one between them: a camera and a |
| + // microphone are two independent choices, and a shared counter made |
| + // changing the camera silently cancel a microphone change whose |
| + // getUserMedia had not settled — the select snapping back with |
| + // nothing said about why. |
| + const switching = { audio: 0, video: 0 }; |
| + let switchQueue = Promise.resolve(); |
| + |
| + // senderFor finds the transceiver carrying one kind for this call — |
| + // the camera or the microphone, never the screen. entry.shareTx is |
| + // video too, and replacing *its* track with a camera would put the |
| + // camera on the m-line the far side has already labelled a screen |
| + // (§5.4), which is the one mistake here that would look like a bug in |
| + // someone else's browser. |
| + function senderFor(entry, kind) { |
| + let transceivers; |
| + try { |
| + transceivers = entry.pc.getTransceivers(); |
| + } catch { |
| + return null; |
| + } |
| + for (const tx of transceivers) { |
| + if (tx === entry.shareTx || tx.stopped) continue; |
| + // receiver.track is present on every transceiver and carries the |
| + // kind even when nothing is being sent yet, which is exactly the |
| + // recvonly case this has to find. |
| + const known = (tx.sender && tx.sender.track) || (tx.receiver && tx.receiver.track); |
| + if (known && known.kind === kind) return tx; |
| + } |
| + return null; |
| + } |
| + |
| + // sendTrack puts one track on one connection. |
| + // |
| + // The replaceTrack is the whole job when this browser was already |
| + // sending that kind. When it was not — it came down startLocal's |
| + // ladder with no camera, or joined with nothing at all — createPC |
| + // left a `recvonly` transceiver for the kind, and that m-line is |
| + // already in the right place in the order. Turning it round is a |
| + // direction change on an existing m-line, so no m-line is added and |
| + // none moves: the hazard addShareTo carries (an unassociated |
| + // transceiver claiming the m-line a remote offer meant for something |
| + // else) is not in this path at all. It still waits for `stable`, |
| + // because a direction changed mid-negotiation is a change the offer |
| + // in flight does not carry, and the deferred ones are picked up by |
| + // the connection's own signalingstatechange. |
| + async function sendTrack(entry, kind, track) { |
| + const tx = senderFor(entry, kind); |
| + if (!tx || !tx.sender || typeof tx.sender.replaceTrack !== 'function') return; |
| + try { |
| + await tx.sender.replaceTrack(track); |
| + } catch { |
| + // A connection closing under us, or a browser refusing the track. |
| + // Every other peer still gets it, and this one is the reset path's |
| + // problem rather than this function's. |
| + return; |
| + } |
| + // classifyTrack reads entry.sentVideo to tell an arriving camera |
| + // from an arriving screen, and a browser that starts sending video |
| + // for the first time here has just changed that answer. |
| + if (kind === 'video') entry.sentVideo = true; |
| + if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') return; |
| + if (entry.pc.signalingState !== 'stable') { |
| + entry.upgrades.add(kind); |
| + return; |
| + } |
| + try { |
| + tx.direction = 'sendrecv'; |
| + } catch { |
| + // Older browsers make direction read-only. The track is on the |
| + // sender either way; it simply is not sent until the next full |
| + // renegotiation builds the m-line sending. |
| + } |
| + } |
| + |
| + // upgradeWhenQuiet runs the direction changes sendTrack could not make |
| + // because the connection was mid-negotiation. Called from the same |
| + // signalingstatechange that adds a deferred share transceiver. |
| + function upgradeWhenQuiet(entry) { |
| + if (!entry.upgrades.size || entry.pc.signalingState !== 'stable') return; |
| + for (const kind of Array.from(entry.upgrades)) { |
| + entry.upgrades.delete(kind); |
| + const tx = senderFor(entry, kind); |
| + if (!tx || !tx.sender || !tx.sender.track) continue; |
| + if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') continue; |
| + try { |
| + tx.direction = 'sendrecv'; |
| + } catch { |
| + // As above: nothing to do about it, and nothing broken by it. |
| + } |
| + } |
| + } |
| + |
| + // switchDevice is the gesture: one kind, one device id, applied to |
| + // every connection and then to the local stream. |
| + // |
| + // Order matters twice here. The new track is disabled before it is |
| + // anywhere near a sender, because a track that goes live for even one |
| + // frame while the person is muted has broken the only promise the |
| + // mute button makes. And the old track is stopped only once the new |
| + // one is on every sender: stopping it first would take the camera |
| + // light off a beat early and send black frames in the gap. |
| + function switchDevice(kind, deviceId) { |
| + const myGen = ++switching[kind]; |
| + const run = switchQueue.then(() => applySwitch(kind, deviceId, myGen)); |
| + // The queue must not break on a failure, or one rejected switch |
| + // would swallow every switch after it. The caller still sees the |
| + // rejection through `run`. |
| + switchQueue = run.catch(() => {}); |
| + return run; |
| + } |
| + |
| + // Returns true when the call is actually on the new device, so a |
| + // caller that changed a preference to ask for the switch can put it |
| + // back when the switch did not happen. |
| + async function applySwitch(kind, deviceId, myGen) { |
| + // Superseded before this run's turn came round, or the call ended |
| + // while it waited. Nothing has been opened yet, so there is nothing |
| + // to put back. |
| + if (myGen !== switching[kind] || state.stopped) return false; |
| + const wanted = kind === 'audio' |
| + ? { audio: audioConstraints(Object.assign({}, prefs, { mic: deviceId })) } |
| + : { video: deviceId ? { deviceId: { exact: deviceId } } : true }; |
| + let fresh; |
| + try { |
| + fresh = await navigator.mediaDevices.getUserMedia(wanted); |
| + } catch (err) { |
| + if (myGen !== switching[kind]) return false; |
| + note(deviceError(kind, err)); |
| + // The choice is not saved: the pickers are put back to the device |
| + // actually in use, so the select never claims a device the call is |
| + // not on. |
| + fillDevices().catch(() => {}); |
| + return false; |
| + } |
| + const track = fresh.getTracks()[0]; |
| + if (myGen !== switching[kind] || !track || state.stopped) { |
| + for (const t of fresh.getTracks()) t.stop(); |
| + return false; |
| + } |
| + track.enabled = kind === 'audio' ? !prefs.muted : !prefs.cameraOff; |
| + for (const entry of pcs.values()) { |
| + // Sequential and not Promise.all: replaceTrack is cheap, the mesh |
| + // is four, and a failure on one connection should not abandon the |
| + // rest through a rejected race. |
| + await sendTrack(entry, kind, track); |
| + } |
| + // No *generation* check here, and that is deliberate. From the loop |
| + // above onwards this run owns what every sender is holding, so |
| + // stopping the track to defer to a newer choice would stop the |
| + // media the call is sending — until that newer choice arrives, which |
| + // it may never do if its own getUserMedia fails, leaving every peer |
| + // watching a track that ended. The queued run replaces the senders |
| + // and `local` together when its turn comes; until then this is the |
| + // truth and is recorded as such. |
| + // |
| + // The call *ending* is the other thing entirely, and does need |
| + // checking: terminal() leaves the page alive, so without this the |
| + // line below would build a fresh MediaStream around a camera that |
| + // was opened for a call that is already over, and nothing would |
| + // ever stop it. A light left on after the meeting ended is the one |
| + // way this file can be rude. |
| + if (state.stopped) { |
| + track.stop(); |
| + return false; |
| + } |
| + if (!local) local = new MediaStream(); |
| + for (const old of kind === 'audio' ? local.getAudioTracks() : local.getVideoTracks()) { |
| + local.removeTrack(old); |
| + old.stop(); |
| + } |
| + local.addTrack(track); |
| + // A track that ends on its own — the device unplugged mid-call — |
| + // should leave the pickers telling the truth about what is left. |
| + track.addEventListener('ended', () => { |
| + fillDevices().catch(() => {}); |
| + }); |
| + // An id that names nothing is the browser's default, and the |
| + // absence of a remembered device is how the rest of this file and |
| + // devices.js both spell that. |
| + const key = kind === 'audio' ? 'mic' : 'camera'; |
| + if (deviceId) prefs[key] = deviceId; |
| + else delete prefs[key]; |
| + savePrefs(); |
| + note(''); |
| + applyToggles(); |
| + renderTiles(); |
| + // The audio ceiling follows the choice and not the connection: a |
| + // studio switch flipped mid-call has to reach the connections that |
| + // already exist, and flipped back has to let them go again. |
| + // tuneAudioSender reads the preference itself, so this is the same |
| + // call in both directions. |
| + if (kind === 'audio') { |
| + for (const entry of pcs.values()) tuneAudioSender(entry.pc); |
| + } |
| + await fillDevices().catch(() => {}); |
| + return true; |
| + } |
| + |
| + // deviceError is the sentence for a device that would not open. The |
| + // names are getUserMedia's own; the wording says what the person can |
| + // do about it, and never that the call is over, because it is not — |
| + // the old device is still on every sender. |
| + function deviceError(kind, err) { |
| + const thing = kind === 'audio' ? 'microphone' : 'camera'; |
| + if (err && err.name === 'NotAllowedError') { |
| + return 'Meet needs permission to use that ' + thing + '. The call is still on the previous one.'; |
| + } |
| + if (err && err.name === 'NotReadableError') { |
| + return 'That ' + thing + ' is in use by another app. The call is still on the previous one.'; |
| + } |
| + return 'That ' + thing + ' could not be opened. The call is still on the previous one.'; |
| + } |
| + |
| + // note writes the device panel's own live region. It is not `say`: |
| + // renderStatus owns #status and rewrites it on every render, so a |
| + // sentence put there would be gone by the next roster. |
| + function note(text) { |
| + if (!deviceNote) return; |
| + deviceNote.textContent = text; |
| + deviceNote.hidden = !text; |
| + } |
| + |
| + // fillDevices is devices.js's fillPickers, for the room. The two are |
| + // deliberate mirrors — same ids, same rules, same reason for each — |
| + // and test/browser/devices.test.mjs holds them to it. Labels only |
| + // exist once permission has been granted, which in the room it always |
| + // has by the time this runs. |
| + async function fillDevices() { |
| + if (!pickCamera || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return; |
| + let devices; |
| + try { |
| + devices = await navigator.mediaDevices.enumerateDevices(); |
| + } catch { |
| + devices = []; |
| + } |
| + const fillOne = (select, kind, chosen) => { |
| + select.replaceChildren(); |
| + let count = 0; |
| + for (const device of devices) { |
| + if (device.kind !== kind) continue; |
| + count++; |
| + const option = document.createElement('option'); |
| + option.value = device.deviceId; |
| + option.textContent = device.label || labelFor(kind, count); |
| + if (device.deviceId === chosen) option.selected = true; |
| + select.append(option); |
| + } |
| + return count; |
| + }; |
| + const cameraCount = fillOne(pickCamera, 'videoinput', liveDeviceId('video') || prefs.camera); |
| + const micCount = fillOne(pickMic, 'audioinput', liveDeviceId('audio') || prefs.mic); |
| + const speakerCount = HAS_SINK_ID ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0; |
| + // A field with nothing to choose from is clutter, not help. Unlike |
| + // the holding screen, the camera and microphone rows can be empty |
| + // here too — a call joined with neither is a call this file is |
| + // built to allow. |
| + if (cameraRow) cameraRow.hidden = cameraCount === 0; |
| + if (micRow) micRow.hidden = micCount === 0; |
| + if (speakerRow) speakerRow.hidden = !HAS_SINK_ID || speakerCount === 0; |
| + if (rawRow) rawRow.hidden = micCount === 0; |
| + } |
| + |
| + function labelFor(kind, count) { |
| + return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count; |
| + } |
| + |
| + // liveDeviceId is what the call is *actually* on, which is the honest |
| + // thing for a select to be showing: prefs can name a device that |
| + // startLocal's ladder never managed to open. |
| + function liveDeviceId(kind) { |
| + const track = local && local.getTracks().find((t) => t.kind === kind && t.readyState === 'live'); |
| + if (!track || typeof track.getSettings !== 'function') return ''; |
| + return track.getSettings().deviceId || ''; |
| + } |
| + |
| + // applySinkAll re-routes every remote tile after a speaker change. |
| + // applySink does one element and is what a new tile gets; this is the |
| + // same thing for the tiles that already exist. |
| + function applySinkAll() { |
| + for (const [key, t] of tiles) { |
| + if (t.self || key === 'self') continue; |
| + applySink(t.video); |
| + } |
| + } |
| + |
| // --------------------------------------------------------------- |
| // The stream (§4.1, §4.5) |
| // --------------------------------------------------------------- |
| @@ -1106,6 +1540,9 @@ |
| // m-line it built for nobody. classifyTrack's direction rule turns |
| // on which of the two is true. |
| sentVideo: false, |
| + // Kinds whose transceiver switchDevice turned round while the |
| + // connection was mid-negotiation, waiting for it to settle. |
| + upgrades: new Set(), |
| sharing: false, |
| share: null, |
| // The mid the peer's screen arrives on, so the getStats fallback |
| @@ -1164,6 +1601,7 @@ |
| // rollback — and then the screen is being sent on the m-line the |
| // far side means for something else. |
| addShareTo(entry); |
| + upgradeWhenQuiet(entry); |
| adoptWhenQuiet(id, entry); |
| // And it is when an ask the negotiation rounds ran out on gets |
| // its turn (Codex S4-8): a connection that was mid-negotiation is |
| @@ -1195,6 +1633,7 @@ |
| // here, or a device that went away — is not worth the connection. |
| // The recvonly transceivers below cover what it would have been. |
| } |
| + tuneAudioSender(pc); |
| for (const kind of ['video', 'audio']) { |
| if (hasLiveTrack(kind)) continue; |
| try { |
| @@ -3517,6 +3956,14 @@ |
| // enabled — the same rule the two device toggles follow when there |
| // is no track for them to move. |
| if (shareButton) shareButton.disabled = !on || !HAS_DISPLAY_MEDIA; |
| + // The device panel goes the same way. A terminal screen's pickers |
| + // are inert anyway — applySwitch refuses once state.stopped — but a |
| + // live-looking select that silently does nothing is the worse half |
| + // of that, so they are disabled and say so. |
| + if (devicesToggle) devicesToggle.disabled = !on; |
| + for (const el of [pickCamera, pickMic, pickSpeaker, rawInput]) { |
| + if (el) el.disabled = !on; |
| + } |
| } |
| // --------------------------------------------------------------- |
| @@ -3857,6 +4304,98 @@ |
| applyToggles(); |
| }); |
| + // The device panel. The button is hidden by the server and revealed |
| + // only where there is something behind it: a browser with no |
| + // enumerateDevices has no list to draw and no switch to offer, and a |
| + // control that does nothing is worse than no control. |
| + if (devicesToggle && devicesPanel && navigator.mediaDevices && |
| + navigator.mediaDevices.enumerateDevices) { |
| + devicesToggle.hidden = false; |
| + devicesToggle.addEventListener('click', () => { |
| + const open = devicesPanel.hidden; |
| + devicesPanel.hidden = !open; |
| + devicesToggle.setAttribute('aria-expanded', String(open)); |
| + // Filled on opening rather than at load: the list is only worth |
| + // having when it is about to be looked at, and a device plugged |
| + // in during the call is then always in the one that is shown. |
| + if (open) { |
| + fillDevices().catch((err) => { |
| + console.error('meet: devices', err); |
| + }); |
| + if (pickCamera && cameraRow && !cameraRow.hidden) pickCamera.focus(); |
| + } |
| + }); |
| + if (rawInput) { |
| + if (deviceNote || $('raw-help')) rawInput.setAttribute('aria-describedby', 'raw-help'); |
| + rawInput.checked = !!prefs.rawAudio; |
| + } |
| + navigator.mediaDevices.addEventListener?.('devicechange', () => { |
| + if (!devicesPanel.hidden) fillDevices().catch(() => {}); |
| + }); |
| + // Filled once now, as well as on every open. Opening reveals the |
| + // panel synchronously and fills it a microtask later, so without |
| + // this the first open is a flash of three empty selects — and the |
| + // focus below would be deciding on rows whose hidden state nothing |
| + // had set yet. |
| + fillDevices().catch(() => {}); |
| + } |
| + |
| + // The three pickers and the studio switch. Each one is a getUserMedia |
| + // and a replaceTrack per connection (switchDevice), except the |
| + // speaker, which never touches the call at all: it is where this |
| + // browser plays what it has already received. |
| + if (pickCamera) { |
| + pickCamera.addEventListener('change', () => { |
| + switchDevice('video', pickCamera.value).catch((err) => { |
| + console.error('meet: camera', err); |
| + }); |
| + }); |
| + } |
| + if (pickMic) { |
| + pickMic.addEventListener('change', () => { |
| + switchDevice('audio', pickMic.value).catch((err) => { |
| + console.error('meet: microphone', err); |
| + }); |
| + }); |
| + } |
| + if (pickSpeaker) { |
| + pickSpeaker.addEventListener('change', () => { |
| + prefs.speaker = pickSpeaker.value; |
| + savePrefs(); |
| + applySinkAll(); |
| + }); |
| + } |
| + if (rawInput) { |
| + // Studio audio is chosen when the track is opened and cannot be |
| + // changed on a track already running, so this re-opens the |
| + // microphone that is already in use — the same switch as picking it |
| + // again from the list, with a different set of constraints behind |
| + // it. |
| + rawInput.addEventListener('change', () => { |
| + const was = !!prefs.rawAudio; |
| + prefs.rawAudio = rawInput.checked; |
| + // The remembered microphone and not the live one. Passing the id |
| + // of whatever happens to be open would write it into prefs.mic |
| + // and pin someone who had been following the system default to |
| + // one device, for ever, because they touched an audio setting. |
| + switchDevice('audio', prefs.mic || '').then((ok) => { |
| + if (ok) { |
| + savePrefs(); |
| + return; |
| + } |
| + // The microphone would not re-open, so the call is still on the |
| + // processed track and saying otherwise — in the checkbox or in |
| + // localStorage — would be a lie the next call would inherit. |
| + prefs.rawAudio = was; |
| + rawInput.checked = was; |
| + }).catch((err) => { |
| + prefs.rawAudio = was; |
| + rawInput.checked = was; |
| + console.error('meet: studio audio', err); |
| + }); |
| + }); |
| + } |
| + |
| // §5.4's gesture. The handler is not async: a rejection escaping one |
| // goes where neither a person nor a gate could see it, so the work is |
| // a named function with a catch of its own — the same shape as the |
| @@ -4152,8 +4691,23 @@ |
| const sender = tx && tx.sender; |
| const track = sender && sender.track; |
| if (!sender || !track || typeof sender.replaceTrack !== 'function') return; |
| + // Which camera the call is on, captured before the awaits below. |
| + // Asking the sender afterwards would be the more obvious check and |
| + // is the weaker one: it reads a browser's own bookkeeping to infer |
| + // whether something else has been here, where this asks the |
| + // question directly. |
| + const generation = switching.video; |
| try { |
| await sender.replaceTrack(null); |
| + // A device switch that started in this gap owns this sender now, |
| + // and the track captured above is the one it is retiring — it |
| + // stops it. Putting it back would undo the switch and freeze this |
| + // peer on a track that has ended, which is a worse fault than the |
| + // stall being repaired. The switch sets its own track on every |
| + // sender, so leaving is safe; and if that switch then fails, the |
| + // stall detector is still watching and asks again on the next |
| + // poll. |
| + if (switching.video !== generation || track.readyState === 'ended') return; |
| await sender.replaceTrack(track); |
| } catch { |
| // A connection that is closing. The reset is behind this anyway. |
| @@ -4386,6 +4940,26 @@ |
| } |
| window.meet = { |
| + // local() is what this tab is actually sending: one entry per track |
| + // on the local stream, with its kind and readyState. It is the fact |
| + // a device switch has to be judged on and that the DOM cannot |
| + // answer — a self tile looks exactly the same whichever microphone |
| + // is behind it. |
| + // |
| + // No device id and no label. devices.js's promise is that neither |
| + // ever leaves this browser, and a test hook is not an exception |
| + // worth carving into it. Whether a device was *chosen* is a |
| + // boolean, which is all a gate needs to tell a switch from a |
| + // default. |
| + local() { |
| + if (!local) return []; |
| + return local.getTracks().map((t) => ({ |
| + kind: t.kind, |
| + state: t.readyState, |
| + enabled: !!t.enabled, |
| + chosen: !!(typeof t.getSettings === 'function' && t.getSettings().deviceId), |
| + })); |
| + }, |
| // media() is the identity reading the fix wave's second round asked |
| // for: which track each tile is bound to, which tracks its element |
| // is actually holding, and what the connection's own receivers say |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index 63cfc31..d79a0b0 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -17,6 +17,14 @@ |
| <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div> |
| <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div> |
| <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| + {{/* Studio audio: the unprocessed capture option. field-check is the |
| + vendored toggle (AGENTS.md's field rule), and the sentence under |
| + it is wired to the input with aria-describedby by devices.js, |
| + because field-check gives its checkbox no id to write here. The |
| + row starts hidden like the speaker row: devices.js reveals it |
| + once it knows there is a microphone to apply it to. */}} |
| + <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}} |
| + <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div> |
| </div> |
| <div class="meet-controls"> |
| <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| index 5f4b1cf..9ab33b0 100644 |
| --- a/internal/meet/templates/room.html |
| +++ b/internal/meet/templates/room.html |
| @@ -18,8 +18,30 @@ |
| <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="share" rst-btn aria-pressed="false">Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button> |
| + <button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>Devices</button> |
| <button type="button" id="leave" rst-btn="danger">Leave</button> |
| </div> |
| + {{/* The device panel, closed until asked for. A call's own screen is |
| + the video and the four controls; three selects permanently under |
| + them would be furniture for the whole call to pay for so that |
| + someone can change a microphone once. room.js reveals the button |
| + — the panel is useless in a browser that cannot enumerate |
| + devices — and fills the selects from the same enumeration the |
| + holding screen uses. The ids are devices.js's own on purpose: |
| + the two fill routines are mirrors, and a reader comparing them |
| + should not also have to translate names. */}} |
| + <div id="room-devices" class="meet-pickers" hidden> |
| + <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div> |
| + <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div> |
| + <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| + <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}} |
| + <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div> |
| + {{/* Device messages get a live region of their own, like the relay |
| + warning: renderStatus owns #status and rewrites it on every |
| + render, so a sentence about a camera that would not open has |
| + to live somewhere that is not about to be overwritten. */}} |
| + <p id="device-note" class="meet-device-note" role="status" hidden></p> |
| + </div> |
| {{/* The chat panel (spec §6): one partial, one script, one |
| stylesheet, none of which know anything about Meet — see the |
| comment in partials/chat.html. chat.js loads before room.js in |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index edaee28..255821a 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -82,6 +82,7 @@ const ( |
| var scenarios = []string{ |
| "devices", |
| "call", |
| + "switching", |
| "share", |
| "mutualshare", |
| "laterolls", |
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go| index cf3f17d..2ba52c0 100644 |
| --- a/internal/meettest/holding_test.go |
| +++ b/internal/meettest/holding_test.go |
| @@ -163,3 +163,43 @@ func TestHoldingPageIs404ForEndedMeetings(t *testing.T) { |
| t.Fatalf("ended: %d", rec.Code) |
| } |
| } |
| + |
| +// Studio audio is the unprocessed capture option (the microphone asked |
| +// for with echo cancellation, noise suppression and automatic gain all |
| +// off, and a higher Opus ceiling to send it through). Three facts about |
| +// the markup are worth holding here rather than in a browser, because |
| +// devices.js only ever moves the checkbox's `checked` and the row's |
| +// `hidden` and would go on "working" with any of them broken: |
| +// |
| +// - it is the vendored field-check and not a hand-rolled checkbox, |
| +// which AGENTS.md's field rule requires — rst-switch is that |
| +// partial's own attribute and nothing else in these pages emits it; |
| +// - the row starts hidden, like the speaker row, so a scriptless |
| +// browser is not shown a control that cannot do anything; |
| +// - the help sentence carries the id devices.js wires to the input |
| +// with aria-describedby, so a rename here that left the script |
| +// alone would silently unwire the explanation. |
| +func TestHoldingPageOffersStudioAudioAsAVendoredField(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid + "/join").Body.String() |
| + for _, want := range []string{ |
| + `<div id="raw-row" class="meet-raw-row" hidden>`, |
| + `<label rst-switch>`, |
| + `name="raw-audio"`, |
| + `id="raw-help"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("holding page lacks %q for studio audio: %s", want, body) |
| + } |
| + } |
| + // The sentence has to say what is being turned off. "Studio audio" |
| + // alone is a name, not an explanation, and someone reaching for it |
| + // on a laptop should be told why not before they hear the echo. |
| + for _, want := range []string{"echo cancellation", "noise suppression", "automatic gain"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("studio audio's help sentence does not mention %q: %s", want, body) |
| + } |
| + } |
| +} |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| index 1998811..a54d44c 100644 |
| --- a/internal/meettest/room_test.go |
| +++ b/internal/meettest/room_test.go |
| @@ -602,3 +602,50 @@ func TestNativeRoomInfoUsesRoomAuthority(t *testing.T) { |
| t.Fatal("metadata leaked another meeting") |
| } |
| } |
| + |
| +// The room's device panel (mid-call switching). The server renders it |
| +// closed and renders the button that opens it hidden: room.js reveals |
| +// the button only in a browser that can enumerate devices, so a |
| +// scriptless page — and a browser with no enumeration — shows neither a |
| +// panel nor a control that would do nothing. The panel reuses the |
| +// holding screen's ids on purpose, because the two fill routines are |
| +// mirrors of each other and test/browser/devices.test.mjs holds them to |
| +// it; asserting them here is what stops a rename on one page from |
| +// quietly unwiring the other. |
| +func TestRoomRendersItsDevicePanelClosed(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid).Body.String() |
| + for _, want := range []string{ |
| + `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>`, |
| + `<div id="room-devices" class="meet-pickers" hidden>`, |
| + `id="pick-camera"`, |
| + `id="pick-mic"`, |
| + `id="pick-speaker"`, |
| + `id="device-note"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("room page lacks %q for the device panel: %s", want, body) |
| + } |
| + } |
| +} |
| + |
| +// The device panel's own live region is not #status. renderStatus owns |
| +// #status and rewrites it on every roster, so a sentence about a camera |
| +// that would not open would be gone within the tick — which is exactly |
| +// the moment someone is reading it. |
| +func TestRoomDeviceNoteIsNotTheStatusLine(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid).Body.String() |
| + note := strings.Index(body, `id="device-note"`) |
| + status := strings.Index(body, `id="status"`) |
| + if note < 0 || status < 0 { |
| + t.Fatalf("room page is missing one of the two regions: note=%d status=%d", note, status) |
| + } |
| + if note == status { |
| + t.Fatal("the device note and the status line are the same element") |
| + } |
| +} |
diff --git a/test/browser/audio.test.mjs b/test/browser/audio.test.mjs| new file mode 100644 |
| index 0000000..fe83d1e |
| --- /dev/null |
| +++ b/test/browser/audio.test.mjs |
| @@ -0,0 +1,130 @@ |
| +// audio.test.mjs — the studio audio choice, gated without a browser. |
| +// |
| +// node --test test/browser/audio.test.mjs |
| +// |
| +// `audioConstraints` decides what the microphone is asked for, and it |
| +// is written twice on purpose: the holding screen opens a stream and |
| +// stops it, and the room opens its own at join, so devices.js and |
| +// room.js each carry a copy. Duplication between those two files is |
| +// this app's existing shape (forgetDevices is the same arrangement), |
| +// and the cost of it is drift — a choice that holds on the holding |
| +// screen, is silently dropped at join, and leaves someone listening to |
| +// noise suppression they turned off one page earlier. |
| +// |
| +// So the first test here is not about constraints at all. It is that |
| +// the two copies are still the same source. The rest is lifted out of |
| +// devices.js and run, the way negotiation.test.mjs runs offerCollision, |
| +// so what is under test is the shipped function and not a copy that can |
| +// go stale quietly. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url)); |
| +const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url)); |
| + |
| +// lift returns the source of one `function name(...) {...}` by matching |
| +// braces from its declaration — negotiation.test.mjs's helper, and it |
| +// fails loudly on a rename rather than passing on a stale duplicate. |
| +function lift(source, name, where) { |
| + let start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); |
| + if (source.slice(start - 6, start) === 'async ') start -= 6; |
| + let depth = 0; |
| + for (let i = source.indexOf('{', start); i < source.length; i++) { |
| + if (source[i] === '{') depth += 1; |
| + else if (source[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return source.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name + ' out of ' + where); |
| +} |
| + |
| +const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| +const roomSrc = readFileSync(roomJS, 'utf8'); |
| + |
| +const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js'); |
| +const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js'); |
| + |
| +test('devices.js and room.js still ask for the microphone the same way', () => { |
| + // If this fails, one of the two was edited and the other was not. Copy |
| + // the change across rather than relaxing the assertion: the point of |
| + // the pair is that the holding screen's choice survives the join. |
| + assert.equal(fromDevices, fromRoom); |
| +}); |
| + |
| +// eslint-disable-next-line no-new-func |
| +const audioConstraints = new Function(fromDevices + '; return audioConstraints;')(); |
| + |
| +test('the voice pipeline is on unless it is turned off', () => { |
| + const c = audioConstraints({}); |
| + assert.equal(c.echoCancellation, true); |
| + assert.equal(c.noiseSuppression, true); |
| + assert.equal(c.autoGainControl, true); |
| + assert.equal(c.voiceIsolation, true); |
| +}); |
| + |
| +test('studio audio turns all of it off', () => { |
| + const c = audioConstraints({ rawAudio: true }); |
| + assert.equal(c.echoCancellation, false); |
| + assert.equal(c.noiseSuppression, false); |
| + assert.equal(c.autoGainControl, false); |
| + assert.equal(c.voiceIsolation, false); |
| +}); |
| + |
| +test('the processing is asked for as an ideal and never as exact', () => { |
| + // The safety property, and the reason rawHonoured exists. `exact` here |
| + // would mean a browser that cannot turn its filters off refuses |
| + // getUserMedia outright — costing someone their microphone over a |
| + // preference, which is the opposite of what the ladder in either file |
| + // is built to do. Booleans are ideals; objects are not. |
| + for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) { |
| + const c = audioConstraints(prefs); |
| + for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) { |
| + assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key])); |
| + } |
| + } |
| +}); |
| + |
| +test('a remembered microphone is still asked for exactly', () => { |
| + // The device id is the one thing that *is* exact: a remembered device |
| + // that has gone should fail so the caller can notice and forget it, |
| + // which is what disambiguate and forgetDevices are for. |
| + const c = audioConstraints({ mic: 'mic-1' }); |
| + assert.deepEqual(c.deviceId, { exact: 'mic-1' }); |
| +}); |
| + |
| +test('no remembered microphone names no device at all', () => { |
| + assert.equal('deviceId' in audioConstraints({}), false); |
| +}); |
| + |
| +test('studio audio and a remembered microphone are independent', () => { |
| + const c = audioConstraints({ mic: 'mic-1', rawAudio: true }); |
| + assert.deepEqual(c.deviceId, { exact: 'mic-1' }); |
| + assert.equal(c.noiseSuppression, false); |
| +}); |
| + |
| +test('the room lifts the audio ceiling, and only for studio audio', () => { |
| + // Turning the filters off buys nothing if Opus then runs at its |
| + // default of roughly 32 kbit/s, so the two ship together. The number |
| + // is read out of room.js rather than restated here. |
| + const m = /const RAW_AUDIO_BITRATE = (\d+);/.exec(roomSrc); |
| + assert.notEqual(m, null, 'RAW_AUDIO_BITRATE is not a number constant in room.js any more'); |
| + assert.ok(Number(m[1]) >= 64000, 'a ceiling below 64 kbit/s is not worth the switch'); |
| + |
| + // The ceiling follows the preference, not the connection. tuneAudioSender |
| + // runs for every connection and decides inside itself, so studio audio |
| + // flipped mid-call reaches the connections that already exist — and |
| + // flipped back lets them go again. Gating the *call* on prefs.rawAudio |
| + // was the half-feature: it only ever raised, and only on connections |
| + // built after the switch. |
| + const tune = /function tuneAudioSender\(pc\) \{[\s\S]*?\n \}/.exec(roomSrc); |
| + assert.notEqual(tune, null, 'tuneAudioSender is not in room.js any more'); |
| + assert.match(tune[0], /if \(prefs\.rawAudio\)/, 'tuneAudioSender must read the preference itself'); |
| + assert.match(tune[0], /delete params\.encodings\[0\]\.maxBitrate/, |
| + 'tuneAudioSender must give the ceiling back when studio audio goes off'); |
| + assert.match(roomSrc, /for \(const entry of pcs\.values\(\)\) tuneAudioSender\(entry\.pc\);/, |
| + 'an audio switch must re-tune the connections that already exist'); |
| +}); |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 6aa3d61..c12ef24 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -1193,6 +1193,113 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // Changing a device without leaving the call. The panel opens, the |
| + // microphone and then the camera are chosen again from the list, and |
| + // the far side's media is watched across both. |
| + // |
| + // Picking the same device again is the point rather than a weakness |
| + // of the test. What is under test is the swap — getUserMedia, a |
| + // replaceTrack on every connection's sender, the local stream rebuilt |
| + // under the self tile — and none of that cares whether the id it is |
| + // handed differs from the one already in use. Chromium's fake capture |
| + // offers one camera and one microphone, so a "different" device here |
| + // would be a second fake and not a second assertion. |
| + // |
| + // The assertion that matters is the negative one: a swap must not |
| + // cost the call anything. replaceTrack does not renegotiate, so a |
| + // regression that reached for addTrack, or that stopped the old track |
| + // before the new one was on the senders, shows up here as the far |
| + // side's frames stopping. |
| + async switching() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + |
| + // room.js reveals the button only where there is an enumeration |
| + // behind it, so its being visible is itself the feature detection |
| + // working. |
| + const shown = await until(async () => await pageA.locator('#devices-toggle').isVisible(), 10000); |
| + check(shown, 'the Devices button is revealed in the room'); |
| + await pageA.click('#devices-toggle'); |
| + check(await pageA.locator('#room-devices').isVisible(), 'the device panel opens'); |
| + check(await pageA.locator('#devices-toggle').getAttribute('aria-expanded') === 'true', |
| + 'the Devices button reports itself expanded'); |
| + |
| + const mics = await until(async () => { |
| + const n = await pageA.locator('#pick-mic option').count(); |
| + return n > 0 ? n : false; |
| + }, 10000); |
| + check(mics, 'the room lists at least one microphone (got ' + mics + ')'); |
| + const cameras = await pageA.locator('#pick-camera option').count(); |
| + check(cameras > 0, 'the room lists at least one camera (got ' + cameras + ')'); |
| + |
| + const sides = [ |
| + { page: pageB, label: 'member B', keys: [pair.a.peer] }, |
| + { page: pageA, label: 'member A', keys: [pair.b.peer] }, |
| + ]; |
| + for (const [selector, what] of [['#pick-mic', 'microphone'], ['#pick-camera', 'camera']]) { |
| + const value = await pageA.locator(selector + ' option').first().getAttribute('value'); |
| + await pageA.selectOption(selector, value); |
| + // The swap is asynchronous behind the change event: one |
| + // getUserMedia and a replaceTrack per connection. Nothing in the |
| + // page announces it finishing, so what is waited for is the thing |
| + // that must be true afterwards — a live local track of that kind |
| + // — and then the media is watched over a duration. |
| + const live = await until(async () => await pageA.evaluate((kind) => { |
| + const tracks = (window.meet && window.meet.local && window.meet.local()) || []; |
| + const of = tracks.filter((t) => t.kind === kind); |
| + return of.length === 1 && of[0].state === 'live' && of[0].chosen; |
| + }, what === 'microphone' ? 'audio' : 'video'), 15000); |
| + check(live, 'member A holds exactly one live ' + what + ' track after the switch'); |
| + |
| + check((await pageA.locator('#device-note').textContent() || '').trim() === '', |
| + 'switching the ' + what + ' reported no trouble (' + |
| + (await pageA.locator('#device-note').textContent()) + ')'); |
| + |
| + await watchMedia(sides, 'after the ' + what + ' was switched: '); |
| + } |
| + |
| + // Three changes with nothing awaited between them, which is one |
| + // person clicking through a list rather than choosing from it. Each |
| + // is a getUserMedia and a replaceTrack across the mesh, and run at |
| + // once they would be two loops writing to the same senders — what |
| + // every peer then receives decided by whichever device opened |
| + // fastest. Serialised, the last click wins and the media never |
| + // stops; the failure this catches is the middle run stopping a |
| + // track the senders were still holding, which reads at the far end |
| + // as the call simply ending. |
| + const camera = await pageA.locator('#pick-camera option').first().getAttribute('value'); |
| + await Promise.all([ |
| + pageA.selectOption('#pick-camera', camera), |
| + pageA.selectOption('#pick-camera', camera), |
| + pageA.selectOption('#pick-camera', camera), |
| + ]); |
| + const settled = await until(async () => await pageA.evaluate(() => { |
| + const tracks = (window.meet && window.meet.local && window.meet.local()) || []; |
| + const video = tracks.filter((t) => t.kind === 'video'); |
| + return video.length === 1 && video[0].state === 'live'; |
| + }), 20000); |
| + check(settled, 'member A holds exactly one live camera after three switches at once — ' + |
| + JSON.stringify(await pageA.evaluate(() => window.meet.local()))); |
| + await watchMedia(sides, 'after three switches at once: '); |
| + |
| + check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| + check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // §5.4 and §13's slice-4 sentence: "the higher-id context shares its |
| // screen and the lower receives a second video track, then stops, the |
| // received track ends, the share tile is removed and the camera track |
diff --git a/test/browser/devices.test.mjs b/test/browser/devices.test.mjs| new file mode 100644 |
| index 0000000..6b6c4bc |
| --- /dev/null |
| +++ b/test/browser/devices.test.mjs |
| @@ -0,0 +1,116 @@ |
| +// devices.test.mjs — the two device pickers, held to each other. |
| +// |
| +// node --test test/browser/devices.test.mjs |
| +// |
| +// The holding screen (devices.js) and the room (room.js) both enumerate |
| +// devices and fill the same three selects, and they are separate |
| +// routines because they want different things from the answer: the |
| +// holding screen is choosing what to open, the room is showing what is |
| +// already open. What they must not differ on is the rules — which row |
| +// is hidden when, and what an unlabelled device is called — because a |
| +// person who saw "Microphone 2" on one page and something else on the |
| +// next would reasonably think they had changed device when they had |
| +// not. |
| +// |
| +// This is the cheap half of that, checked without a browser. The |
| +// expensive half is the `switching` scenario in call.mjs, which does a |
| +// real swap on a real call. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url)); |
| +const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url)); |
| + |
| +function lift(source, name, where) { |
| + let start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); |
| + if (source.slice(start - 6, start) === 'async ') start -= 6; |
| + let depth = 0; |
| + for (let i = source.indexOf('{', start); i < source.length; i++) { |
| + if (source[i] === '{') depth += 1; |
| + else if (source[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return source.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name + ' out of ' + where); |
| +} |
| + |
| +const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| +const roomSrc = readFileSync(roomJS, 'utf8'); |
| + |
| +test('both pages name an unlabelled device the same way', () => { |
| + assert.equal(lift(devicesSrc, 'labelFor', 'devices.js'), lift(roomSrc, 'labelFor', 'room.js')); |
| +}); |
| + |
| +// eslint-disable-next-line no-new-func |
| +const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')(); |
| + |
| +test('an unlabelled device is numbered, not blank', () => { |
| + // enumerateDevices returns empty labels until permission has been |
| + // granted, and on some browsers for some devices even after. A select |
| + // full of blank options is a control nobody can use. |
| + assert.equal(labelFor('videoinput', 1), 'Camera 1'); |
| + assert.equal(labelFor('audioinput', 2), 'Microphone 2'); |
| +}); |
| + |
| +test('both pages hide a row with nothing in it', () => { |
| + // A field with no options is clutter, not help — the rule the holding |
| + // screen has always followed, and the room now has to as well. |
| + for (const [src, where] of [[devicesSrc, 'devices.js'], [roomSrc, 'room.js']]) { |
| + for (const row of ['cameraRow', 'micRow']) { |
| + assert.match(src, new RegExp(row + '\\.hidden = '), |
| + where + ' never hides ' + row); |
| + } |
| + } |
| +}); |
| + |
| +test('both pages gate the speaker row on setSinkId', () => { |
| + // A speaker picker in a browser that cannot route output is a control |
| + // that does nothing, which is the one thing worse than no control. |
| + assert.match(devicesSrc, /speakerRow\.hidden = !hasSinkId \|\| speakerCount === 0;/); |
| + assert.match(roomSrc, /speakerRow\.hidden = !HAS_SINK_ID \|\| speakerCount === 0;/); |
| +}); |
| + |
| +test('the room only ever swaps a track, never adds an m-line', () => { |
| + // The whole reason a mid-call switch is safe is that replaceTrack does |
| + // not renegotiate. A regression that reached for addTrack here would |
| + // put an offer in flight on every device change, straight into the |
| + // glare §5.1 spends 300 ms avoiding — and it would pass every test |
| + // that only looked at the local stream. |
| + const sendTrack = lift(roomSrc, 'sendTrack', 'room.js'); |
| + assert.match(sendTrack, /replaceTrack\(track\)/); |
| + assert.equal(/addTrack|addTransceiver/.test(sendTrack), false, |
| + 'sendTrack must not add tracks or transceivers — replaceTrack is what makes this free'); |
| +}); |
| + |
| +test('switches are serialised, so two clicks cannot interleave', () => { |
| + // Two switches running at once are two replaceTrack loops over the |
| + // same senders, and what every peer ends up receiving is then decided |
| + // by whichever getUserMedia happened to settle last. The queue is what |
| + // makes the last click win instead of the fastest device. |
| + const src = lift(roomSrc, 'switchDevice', 'room.js'); |
| + assert.match(src, /switchQueue\s*=/, 'switchDevice must chain onto the queue, not run immediately'); |
| + assert.match(src, /switchQueue\.then\(/, 'switchDevice must wait for the switch before it'); |
| + // A rejected switch must not take every later switch with it. |
| + assert.match(src, /\.catch\(/); |
| +}); |
| + |
| +test('a switch superseded before it opens anything puts nothing back', () => { |
| + // The three generation checks, and the one place there deliberately |
| + // is not one. Before getUserMedia there is nothing to tidy; between |
| + // getUserMedia and the sender loop the track is this run's alone and |
| + // is stopped; *after* the sender loop it belongs to every sender in |
| + // the mesh, and stopping it there would stop the call's own media |
| + // until a newer switch arrived — which it might never do. |
| + const src = lift(roomSrc, 'applySwitch', 'room.js'); |
| + const guards = src.match(/myGen !== switching/g) || []; |
| + assert.equal(guards.length, 3, 'applySwitch checks its generation ' + guards.length + ' times, expected exactly 3'); |
| + assert.match(src, /for \(const t of fresh\.getTracks\(\)\) t\.stop\(\);/, |
| + 'the superseded path must stop the tracks getUserMedia handed it'); |
| + const afterLoop = src.slice(src.indexOf('await sendTrack(')); |
| + assert.equal(/myGen !== switching/.test(afterLoop), false, |
| + 'there must be no generation check after the sender loop — see the comment there'); |
| +}); |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index dc234e2..68dd7fe 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -1228,12 +1228,19 @@ test('a muted or ended track is nobody here to repair', () => { |
| const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS'); |
| const CAMERA_REPAIRS = pin('CAMERA_REPAIRS'); |
| +// `switching` is the room's per-kind device-switch counter, injected |
| +// rather than stubbed inside the box: repairCamera reads it to find out |
| +// whether a device switch took ownership of the sender while it was |
| +// awaiting, and a test that could not move it could not exercise the |
| +// one case where the repair must stand down. |
| +const switching = { audio: 0, video: 0 }; |
| + |
| const cameraBox = new Function( |
| - 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', |
| + 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', 'switching', |
| [lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') + |
| '\nreturn { judgeCamera, cameraReport, repairCamera };', |
| ); |
| -const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS); |
| +const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching); |
| // A camera transceiver as the watch sees one, and the record of what |
| // was done to its sender. |
| @@ -1379,6 +1386,37 @@ test('the repair takes the track off its sender and puts it back', async () => { |
| 'the encoder is rebuilt by removing the track and restoring it, in that order'); |
| }); |
| +test('a repair overtaken by a device switch leaves the new track alone', async () => { |
| + // The gap between the two replaceTrack calls is the only place a |
| + // device switch can do damage here: the track captured on the way in |
| + // is the one that switch is retiring, and putting it back would undo |
| + // the switch and leave every peer watching a track that has ended. |
| + // The stall that prompted this repair is still being watched, so |
| + // standing down costs a poll and not a camera. |
| + const c = cameraTx(); |
| + const replace = c.tx.sender.replaceTrack; |
| + c.tx.sender.replaceTrack = async (t) => { |
| + const out = await replace(t); |
| + if (t === null) switching.video += 1; |
| + return out; |
| + }; |
| + await cam.repairCamera(c.tx); |
| + assert.deepEqual(c.calls, ['null'], 'the repair stops once a switch owns the sender'); |
| + switching.video = 0; |
| +}); |
| + |
| +test('a repair does not restore a track that has ended under it', async () => { |
| + const c = cameraTx(); |
| + const replace = c.tx.sender.replaceTrack; |
| + c.tx.sender.replaceTrack = async (t) => { |
| + const out = await replace(t); |
| + if (t === null) c.track.readyState = 'ended'; |
| + return out; |
| + }; |
| + await cam.repairCamera(c.tx); |
| + assert.deepEqual(c.calls, ['null'], 'an ended track is not worth putting back'); |
| +}); |
| + |
| test('a sender that cannot replace its track is left alone', async () => { |
| const c = cameraTx({ noReplace: true }); |
| await assert.doesNotReject(() => cam.repairCamera(c.tx)); |