// devices.js: the holding screen's scripted half (spec §3.3 "The // holding screen", §9 "The scriptless floor"). Reveals the device // pickers and camera preview the server hid, enumerates cameras, // microphones and (where the browser supports routing audio output) // speakers, remembers the chosen device ids and the mute/camera-off // defaults in localStorage, and hands the join button off to the room // — or, when this session already has the room open in another tab // (join.html's own server-rendered notice), to the takeover confirm // page instead, because a fresh stream at the room would 409. // // Nothing here reaches the server: preferences live only in this // browser, and no device label or id ever rides in a request. The audio // test (§15.20) keeps the same promise — its recording is played back // from memory and dropped, and nothing it hears is sent anywhere. (() => { 'use strict'; const root = document.getElementById('devices'); if (!root) return; const STORAGE_KEY = 'meet.devices'; const $ = (id) => document.getElementById(id); const preview = $('preview'); const pickers = $('pickers'); const cameraRow = $('camera-row'); const micRow = $('mic-row'); const speakerRow = $('speaker-row'); 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'); const cameraState = $('toggle-camera-state'); const joinButton = $('join'); const status = $('status'); // §15.20's audio test. Optional as far as this file is concerned, like // every other element it reaches for by id. const testBox = $('audio-test'); const testMic = $('test-mic'); const micLevel = $('mic-level'); const micResult = $('mic-result'); const testSpeaker = $('test-speaker'); const speakerLevel = $('speaker-level'); const speakerResult = $('speaker-result'); const testLoop = $('test-loopback'); const loopLevel = $('loopback-level'); const loopResult = $('loopback-result'); const testAudio = $('test-audio'); const AudioCtx = window.AudioContext || window.webkitAudioContext; // The microphone test's two thresholds. Speech a person can hear // themselves make reaches -45 dBFS RMS in any window worth the name, // even through a laptop's microphone at arm's length; five seconds // without it is a microphone that is not hearing the room. const MIC_WORKING_DB = -45; const MIC_SILENT_AFTER = 5000; // How long the microphone test listens before it lets go of the // microphone again, and how long the playback test records. const MIC_TEST_FOR = 12000; const RECORD_FOR = 5000; let stream = null; // Bumped on every startPreview() call and captured at its start; a // call whose generation no longer matches when its getUserMedia // settles was superseded by a later device change (a quick switch // between two selects) and must not touch the shared `stream` or // the UI — it stops whatever tracks it was handed and gets out of // the way instead of leaking them or clobbering the newer attempt. let generation = 0; const prefs = loadPrefs(); // §15.20: the audio test that is running, as {kind, stop}, or null. // One at a time: they share the microphone and the speaker. let audioTest = null; // What the tests found, for the browser gate (window.meetDevices). // Levels, verdicts and a byte count — never the audio. const tested = { running: '', mic: { loudestDb: -Infinity, verdict: '' }, speaker: { played: false, peak: 0 }, loopback: { phase: '', bytes: 0, peak: 0 }, }; // --- preferences: read and written to localStorage only --- function loadPrefs() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch { return {}; } } function savePrefs() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch { // Private browsing, or the quota is full: the choice just won't // be remembered next time. Nothing here is required to join. } } // 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 — // on a Mac it is Apple's own system Voice Isolation, reached through // the browser. It is only ever asked for *off*, and never on. Asked // on, it runs on top of the noise suppression above rather than // instead of it, and two suppressors chewing one voice is not twice // as clean: it is a growl with the keystrokes still coming through, // which is what a call on 2026-09-17 came back as. The pipeline above // is every browser's own default and is the one this app asks for. // // Off it still belongs here, because someone may have turned Voice // Isolation on for the whole machine and studio audio has to be able // to say no to it. 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, }; if (!processed) constraints.voiceIsolation = false; 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; } // --- mute and camera-off defaults: applied to the live tracks when --- // --- there is a stream, always reflected on the toggle buttons --- // --- even when there isn't one, so the choice still holds at join. --- // // The buttons keep one fixed accessible name each, from aria-label // ("Microphone", "Camera"): a button whose name changes on every // click reads, to a screen reader tracking it, as a different // control each time. The state moves three ways and never by colour // — aria-pressed for assistive technology, a word in the button for // everyone, and calls.css's inset outline as a third cue. function applyToggles() { if (stream) { for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted; for (const track of stream.getVideoTracks()) track.enabled = !prefs.cameraOff; } muteButton.setAttribute('aria-pressed', String(!!prefs.muted)); cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff)); if (muteState) muteState.textContent = prefs.muted ? 'Muted' : 'On'; if (cameraState) cameraState.textContent = prefs.cameraOff ? 'Off' : 'On'; } function stopPreview() { // The audio test is testing this stream's microphone, and whatever // replaces the stream is a different one. stopAudioTest(); if (!stream) return; for (const track of stream.getTracks()) track.stop(); stream = null; } // --- wiring that needs neither media support nor a stream --- muteButton.addEventListener('click', () => { prefs.muted = !prefs.muted; savePrefs(); applyToggles(); }); cameraButton.addEventListener('click', () => { prefs.cameraOff = !prefs.cameraOff; 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 || ''; // Belt and braces: only ever navigate to a path this page itself // rendered, never to whatever a mangled attribute happened to hold. if (href.startsWith('/')) location.assign(href); }); window.addEventListener('pagehide', stopPreview); // Coming back through the back/forward cache restores the DOM but not // the tracks pagehide stopped, so the preview is asked for again. window.addEventListener('pageshow', (e) => { if (!e.persisted || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; // startPreview is async and its result is not awaited here, so its // rejection needs somewhere to land: an unhandled one is invisible // to the person and to the console alike. startPreview().catch((err) => { console.error('meet: preview after a bfcache return', err); }); }); applyToggles(); root.hidden = false; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { // An insecure context or a browser old enough to lack the API: // the pickers and preview have nothing to show, so they stay // hidden rather than sitting there empty and inert. Join still // works — the room is built to allow joining with no local media. preview.hidden = true; pickers.hidden = true; say('This browser cannot reach a camera or microphone here. You can still join with neither.'); joinButton.disabled = false; return; } // --- the preview stream --- async function startPreview() { const myGen = ++generation; stopPreview(); say('Asking for your camera and microphone…'); const constraints = { audio: audioConstraints(prefs), video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true, }; let result; try { result = await navigator.mediaDevices.getUserMedia(constraints); } catch (err) { if (myGen !== generation) return; // superseded; its outcome no longer matters if ((err.name === 'OverconstrainedError' || err.name === 'NotFoundError') && (prefs.camera || prefs.mic)) { // A remembered device is gone — unplugged, or no longer // matching. err.constraint (typically "deviceId") names the // constraint that failed but not which of camera/mic it // belonged to, and dropping the camera first would quietly // reset a working camera choice whenever it was the // microphone that went. So ask the question directly: try the // remembered camera with an unconstrained microphone. If that // works it was the microphone; if it fails the same way it was // the camera. return await disambiguate(myGen); } preview.srcObject = null; say(errorSentence(err)); applyToggles(); joinButton.disabled = false; await fillPickers().catch(() => {}); return; } if (myGen !== generation) { // A newer request already won; this one's tracks are unwanted. for (const track of result.getTracks()) track.stop(); return; } stream = result; preview.srcObject = stream; applyToggles(); joinButton.disabled = false; await fillPickers().catch(() => {}); say(rawNotice()); } // disambiguate settles which remembered device id is stale, then // deletes exactly that one and starts over. It runs at most once per // failure, and a generation that has been superseded meanwhile drops // whatever it was handed rather than acting on it. async function disambiguate(myGen) { if (prefs.camera && prefs.mic) { try { const probe = await navigator.mediaDevices.getUserMedia({ audio: true, video: { deviceId: { exact: prefs.camera } }, }); for (const track of probe.getTracks()) track.stop(); if (myGen !== generation) return; delete prefs.mic; } catch { if (myGen !== generation) return; delete prefs.camera; } } else if (prefs.camera) { delete prefs.camera; } else { delete prefs.mic; } savePrefs(); return startPreview(); } function errorSentence(err) { if (err.name === 'NotAllowedError') { return 'Calls needs permission to use your camera and microphone. You can still join with neither.'; } if (err.name === 'NotReadableError') { return 'Your camera or microphone is already in use by another app. You can still join with neither.'; } return 'Calls could not reach a camera or microphone. You can still join with neither.'; } // --- pickers: (re)built from enumerateDevices, which only carries --- // --- real labels once permission has been granted, which is why --- // --- this always runs after a getUserMedia attempt, never before. --- async function fillPickers() { let devices; try { devices = await navigator.mediaDevices.enumerateDevices(); } catch { // Treated as "no devices found" below, which hides every row // rather than leaving stale or half-filled selects on screen. 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', prefs.camera || currentDeviceId('video')); const micCount = fillOne(pickMic, 'audioinput', prefs.mic || currentDeviceId('audio')); const hasSinkId = typeof HTMLMediaElement.prototype.setSinkId === 'function'; const speakerCount = hasSinkId ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0; // A field with nothing to choose from is not help, it's clutter — // hidden rather than shown empty. Re-evaluated on every call, so a // device unplugged or plugged in mid-call (devicechange) is // 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; } function labelFor(kind, count) { return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count; } function currentDeviceId(kind) { const track = stream && stream.getTracks().find((t) => t.kind === kind); return track ? track.getSettings().deviceId : ''; } // --- wiring that needs media support --- pickCamera.addEventListener('change', () => { prefs.camera = pickCamera.value; savePrefs(); startPreview(); }); pickMic.addEventListener('change', () => { prefs.mic = pickMic.value; savePrefs(); startPreview(); }); pickSpeaker.addEventListener('change', () => { prefs.speaker = pickSpeaker.value; savePrefs(); }); navigator.mediaDevices.addEventListener?.('devicechange', () => { fillPickers().catch(() => {}); }); // --- the audio test (§15.20) --- // // Three questions a person about to join cannot otherwise answer until // somebody on the call says "I can't hear you": is my microphone // hearing anything, is my speaker playing anything, and what do I // sound like with the processing the call will use. The meters here // are Web Audio, which the room's are not: every test starts from a // click, so an AudioContext made in its handler is running, where one // made without a gesture would start suspended and read nothing. // // The page's CSP has no media-src, so default-src 'self' refuses a // blob: URL: the recording is never given one. It is decoded in memory // and played through Web Audio into a MediaStream, and the