| 1 | // devices.js: the holding screen's scripted half (spec §3.3 "The |
| 2 | // holding screen", §9 "The scriptless floor"). Reveals the device |
| 3 | // pickers and camera preview the server hid, enumerates cameras, |
| 4 | // microphones and (where the browser supports routing audio output) |
| 5 | // speakers, remembers the chosen device ids and the mute/camera-off |
| 6 | // defaults in localStorage, and hands the join button off to the room |
| 7 | // — or, when this session already has the room open in another tab |
| 8 | // (join.html's own server-rendered notice), to the takeover confirm |
| 9 | // page instead, because a fresh stream at the room would 409. |
| 10 | // |
| 11 | // Nothing here reaches the server: preferences live only in this |
| 12 | // browser, and no device label or id ever rides in a request. The audio |
| 13 | // test (§15.20) keeps the same promise — its recording is played back |
| 14 | // from memory and dropped, and nothing it hears is sent anywhere. |
| 15 | (() => { |
| 16 | 'use strict'; |
| 17 | const root = document.getElementById('devices'); |
| 18 | if (!root) return; |
| 19 | |
| 20 | const STORAGE_KEY = 'meet.devices'; |
| 21 | const $ = (id) => document.getElementById(id); |
| 22 | const preview = $('preview'); |
| 23 | const pickers = $('pickers'); |
| 24 | const cameraRow = $('camera-row'); |
| 25 | const micRow = $('mic-row'); |
| 26 | const speakerRow = $('speaker-row'); |
| 27 | const pickCamera = $('pick-camera'); |
| 28 | const pickMic = $('pick-mic'); |
| 29 | const pickSpeaker = $('pick-speaker'); |
| 30 | const rawRow = $('raw-row'); |
| 31 | // field-check renders a real checkbox inside its label and gives it no |
| 32 | // id of its own, so it is reached through the row the page wraps it in |
| 33 | // rather than by an id this file would have to agree with twice. |
| 34 | const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| 35 | const rawHelp = $('raw-help'); |
| 36 | const muteButton = $('toggle-mute'); |
| 37 | const cameraButton = $('toggle-camera'); |
| 38 | const muteState = $('toggle-mute-state'); |
| 39 | const cameraState = $('toggle-camera-state'); |
| 40 | const joinButton = $('join'); |
| 41 | const status = $('status'); |
| 42 | // §15.20's audio test. Optional as far as this file is concerned, like |
| 43 | // every other element it reaches for by id. |
| 44 | const testBox = $('audio-test'); |
| 45 | const testMic = $('test-mic'); |
| 46 | const micLevel = $('mic-level'); |
| 47 | const micResult = $('mic-result'); |
| 48 | const testSpeaker = $('test-speaker'); |
| 49 | const speakerLevel = $('speaker-level'); |
| 50 | const speakerResult = $('speaker-result'); |
| 51 | const testLoop = $('test-loopback'); |
| 52 | const loopLevel = $('loopback-level'); |
| 53 | const loopResult = $('loopback-result'); |
| 54 | const testAudio = $('test-audio'); |
| 55 | const AudioCtx = window.AudioContext || window.webkitAudioContext; |
| 56 | |
| 57 | // The microphone test's two thresholds. Speech a person can hear |
| 58 | // themselves make reaches -45 dBFS RMS in any window worth the name, |
| 59 | // even through a laptop's microphone at arm's length; five seconds |
| 60 | // without it is a microphone that is not hearing the room. |
| 61 | const MIC_WORKING_DB = -45; |
| 62 | const MIC_SILENT_AFTER = 5000; |
| 63 | // How long the microphone test listens before it lets go of the |
| 64 | // microphone again, and how long the playback test records. |
| 65 | const MIC_TEST_FOR = 12000; |
| 66 | const RECORD_FOR = 5000; |
| 67 | |
| 68 | let stream = null; |
| 69 | // Bumped on every startPreview() call and captured at its start; a |
| 70 | // call whose generation no longer matches when its getUserMedia |
| 71 | // settles was superseded by a later device change (a quick switch |
| 72 | // between two selects) and must not touch the shared `stream` or |
| 73 | // the UI — it stops whatever tracks it was handed and gets out of |
| 74 | // the way instead of leaking them or clobbering the newer attempt. |
| 75 | let generation = 0; |
| 76 | const prefs = loadPrefs(); |
| 77 | // §15.20: the audio test that is running, as {kind, stop}, or null. |
| 78 | // One at a time: they share the microphone and the speaker. |
| 79 | let audioTest = null; |
| 80 | // What the tests found, for the browser gate (window.meetDevices). |
| 81 | // Levels, verdicts and a byte count — never the audio. |
| 82 | const tested = { |
| 83 | running: '', |
| 84 | mic: { loudestDb: -Infinity, verdict: '' }, |
| 85 | speaker: { played: false, peak: 0 }, |
| 86 | loopback: { phase: '', bytes: 0, peak: 0 }, |
| 87 | }; |
| 88 | |
| 89 | // --- preferences: read and written to localStorage only --- |
| 90 | |
| 91 | function loadPrefs() { |
| 92 | try { |
| 93 | return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; |
| 94 | } catch { |
| 95 | return {}; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | function savePrefs() { |
| 100 | try { |
| 101 | localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); |
| 102 | } catch { |
| 103 | // Private browsing, or the quota is full: the choice just won't |
| 104 | // be remembered next time. Nothing here is required to join. |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // audioConstraints builds the microphone half of a getUserMedia |
| 109 | // request out of the stored preferences. It is a pure function of |
| 110 | // `prefs` so the gate can lift it out of both files and check the two |
| 111 | // copies still agree (test/browser/audio.test.mjs). room.js carries |
| 112 | // it verbatim, because the room asks for a stream of its own at join |
| 113 | // — the holding screen's is stopped by then — and a choice that held |
| 114 | // on one page and not the other would be worse than no choice. |
| 115 | // |
| 116 | // The three flags are the browser's voice pipeline: echo |
| 117 | // cancellation, noise suppression and automatic gain. Every browser |
| 118 | // turns all three on for a microphone unless told otherwise, which is |
| 119 | // right for a laptop on a kitchen table and wrong for an interface in |
| 120 | // a room someone has treated — gain riding a held note, noise |
| 121 | // suppression taking a cello for a fan. `rawAudio` is the person |
| 122 | // saying which of those two rooms they are in. |
| 123 | // |
| 124 | // They are asked for as ideals and never as `exact`. A browser that |
| 125 | // cannot turn its processing off should hand over a processed |
| 126 | // microphone, not refuse getUserMedia and cost the person their |
| 127 | // voice — the same rule the device ladder is built on. It is also why |
| 128 | // the holding screen checks afterwards what it was actually given |
| 129 | // (rawHonoured) instead of trusting that it got what it asked for. |
| 130 | // |
| 131 | // voiceIsolation is the newer, heavier filter in Safari and Chrome — |
| 132 | // on a Mac it is Apple's own system Voice Isolation, reached through |
| 133 | // the browser. It is only ever asked for *off*, and never on. Asked |
| 134 | // on, it runs on top of the noise suppression above rather than |
| 135 | // instead of it, and two suppressors chewing one voice is not twice |
| 136 | // as clean: it is a growl with the keystrokes still coming through, |
| 137 | // which is what a call on 2026-09-17 came back as. The pipeline above |
| 138 | // is every browser's own default and is the one this app asks for. |
| 139 | // |
| 140 | // Off it still belongs here, because someone may have turned Voice |
| 141 | // Isolation on for the whole machine and studio audio has to be able |
| 142 | // to say no to it. An unknown constraint name in this basic, |
| 143 | // non-exact form is dropped rather than refused, so naming it costs |
| 144 | // nothing in a browser that has never heard of it. |
| 145 | function audioConstraints(prefs) { |
| 146 | const processed = !prefs.rawAudio; |
| 147 | const constraints = { |
| 148 | echoCancellation: processed, |
| 149 | noiseSuppression: processed, |
| 150 | autoGainControl: processed, |
| 151 | }; |
| 152 | if (!processed) constraints.voiceIsolation = false; |
| 153 | if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; |
| 154 | return constraints; |
| 155 | } |
| 156 | |
| 157 | // rawHonoured answers whether the stream really did come back |
| 158 | // unprocessed: true, false, or null when the browser reports none of |
| 159 | // the settings and there is nothing honest to say. getSettings is the |
| 160 | // only place the answer lives, because audioConstraints asks as an |
| 161 | // ideal and an ideal may simply be ignored. Someone who turned the |
| 162 | // processing off for an interface deserves to be told it did not take, |
| 163 | // rather than spending the call hunting for the filter in their own |
| 164 | // gear. |
| 165 | function rawHonoured(stream) { |
| 166 | const track = stream && stream.getAudioTracks()[0]; |
| 167 | if (!track || typeof track.getSettings !== 'function') return null; |
| 168 | const settings = track.getSettings(); |
| 169 | const reported = ['echoCancellation', 'noiseSuppression', 'autoGainControl'] |
| 170 | .filter((key) => typeof settings[key] === 'boolean'); |
| 171 | if (!reported.length) return null; |
| 172 | return reported.every((key) => settings[key] === false); |
| 173 | } |
| 174 | |
| 175 | // rawNotice is the sentence startPreview ends on: empty when there is |
| 176 | // nothing to report, which is the normal case both ways round. |
| 177 | function rawNotice() { |
| 178 | if (!prefs.rawAudio || rawHonoured(stream) !== false) return ''; |
| 179 | return 'This browser kept its own echo cancellation and noise suppression on, so studio audio had no effect here.'; |
| 180 | } |
| 181 | |
| 182 | function say(text) { |
| 183 | status.textContent = text; |
| 184 | } |
| 185 | |
| 186 | // --- mute and camera-off defaults: applied to the live tracks when --- |
| 187 | // --- there is a stream, always reflected on the toggle buttons --- |
| 188 | // --- even when there isn't one, so the choice still holds at join. --- |
| 189 | // |
| 190 | // The buttons keep one fixed accessible name each, from aria-label |
| 191 | // ("Microphone", "Camera"): a button whose name changes on every |
| 192 | // click reads, to a screen reader tracking it, as a different |
| 193 | // control each time. The state moves three ways and never by colour |
| 194 | // — aria-pressed for assistive technology, a word in the button for |
| 195 | // everyone, and calls.css's inset outline as a third cue. |
| 196 | function applyToggles() { |
| 197 | if (stream) { |
| 198 | for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted; |
| 199 | for (const track of stream.getVideoTracks()) track.enabled = !prefs.cameraOff; |
| 200 | } |
| 201 | muteButton.setAttribute('aria-pressed', String(!!prefs.muted)); |
| 202 | cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff)); |
| 203 | if (muteState) muteState.textContent = prefs.muted ? 'Muted' : 'On'; |
| 204 | if (cameraState) cameraState.textContent = prefs.cameraOff ? 'Off' : 'On'; |
| 205 | } |
| 206 | |
| 207 | function stopPreview() { |
| 208 | // The audio test is testing this stream's microphone, and whatever |
| 209 | // replaces the stream is a different one. |
| 210 | stopAudioTest(); |
| 211 | if (!stream) return; |
| 212 | for (const track of stream.getTracks()) track.stop(); |
| 213 | stream = null; |
| 214 | } |
| 215 | |
| 216 | // --- wiring that needs neither media support nor a stream --- |
| 217 | |
| 218 | muteButton.addEventListener('click', () => { |
| 219 | prefs.muted = !prefs.muted; |
| 220 | savePrefs(); |
| 221 | applyToggles(); |
| 222 | }); |
| 223 | cameraButton.addEventListener('click', () => { |
| 224 | prefs.cameraOff = !prefs.cameraOff; |
| 225 | savePrefs(); |
| 226 | applyToggles(); |
| 227 | }); |
| 228 | // Studio audio is a capture setting and not a call control, so it is a |
| 229 | // checkbox in the pickers rather than a fourth button in the row of |
| 230 | // toggles: it changes what the microphone sends, not whether it is |
| 231 | // sending. Changing it re-asks for the stream, because the processing |
| 232 | // is chosen when the track is opened and not afterwards. |
| 233 | if (rawInput) { |
| 234 | if (rawHelp) rawInput.setAttribute('aria-describedby', 'raw-help'); |
| 235 | rawInput.checked = !!prefs.rawAudio; |
| 236 | rawInput.addEventListener('change', () => { |
| 237 | prefs.rawAudio = rawInput.checked; |
| 238 | savePrefs(); |
| 239 | startPreview().catch((err) => { |
| 240 | console.error('meet: preview after a studio audio change', err); |
| 241 | }); |
| 242 | }); |
| 243 | } |
| 244 | joinButton.addEventListener('click', () => { |
| 245 | stopPreview(); |
| 246 | const href = root.dataset.joinHref || ''; |
| 247 | // Belt and braces: only ever navigate to a path this page itself |
| 248 | // rendered, never to whatever a mangled attribute happened to hold. |
| 249 | if (href.startsWith('/')) location.assign(href); |
| 250 | }); |
| 251 | window.addEventListener('pagehide', stopPreview); |
| 252 | // Coming back through the back/forward cache restores the DOM but not |
| 253 | // the tracks pagehide stopped, so the preview is asked for again. |
| 254 | window.addEventListener('pageshow', (e) => { |
| 255 | if (!e.persisted || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; |
| 256 | // startPreview is async and its result is not awaited here, so its |
| 257 | // rejection needs somewhere to land: an unhandled one is invisible |
| 258 | // to the person and to the console alike. |
| 259 | startPreview().catch((err) => { |
| 260 | console.error('meet: preview after a bfcache return', err); |
| 261 | }); |
| 262 | }); |
| 263 | |
| 264 | applyToggles(); |
| 265 | root.hidden = false; |
| 266 | |
| 267 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
| 268 | // An insecure context or a browser old enough to lack the API: |
| 269 | // the pickers and preview have nothing to show, so they stay |
| 270 | // hidden rather than sitting there empty and inert. Join still |
| 271 | // works — the room is built to allow joining with no local media. |
| 272 | preview.hidden = true; |
| 273 | pickers.hidden = true; |
| 274 | say('This browser cannot reach a camera or microphone here. You can still join with neither.'); |
| 275 | joinButton.disabled = false; |
| 276 | return; |
| 277 | } |
| 278 | |
| 279 | // --- the preview stream --- |
| 280 | |
| 281 | async function startPreview() { |
| 282 | const myGen = ++generation; |
| 283 | stopPreview(); |
| 284 | say('Asking for your camera and microphone…'); |
| 285 | const constraints = { |
| 286 | audio: audioConstraints(prefs), |
| 287 | video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true, |
| 288 | }; |
| 289 | let result; |
| 290 | try { |
| 291 | result = await navigator.mediaDevices.getUserMedia(constraints); |
| 292 | } catch (err) { |
| 293 | if (myGen !== generation) return; // superseded; its outcome no longer matters |
| 294 | if ((err.name === 'OverconstrainedError' || err.name === 'NotFoundError') && (prefs.camera || prefs.mic)) { |
| 295 | // A remembered device is gone — unplugged, or no longer |
| 296 | // matching. err.constraint (typically "deviceId") names the |
| 297 | // constraint that failed but not which of camera/mic it |
| 298 | // belonged to, and dropping the camera first would quietly |
| 299 | // reset a working camera choice whenever it was the |
| 300 | // microphone that went. So ask the question directly: try the |
| 301 | // remembered camera with an unconstrained microphone. If that |
| 302 | // works it was the microphone; if it fails the same way it was |
| 303 | // the camera. |
| 304 | return await disambiguate(myGen); |
| 305 | } |
| 306 | preview.srcObject = null; |
| 307 | say(errorSentence(err)); |
| 308 | applyToggles(); |
| 309 | joinButton.disabled = false; |
| 310 | await fillPickers().catch(() => {}); |
| 311 | return; |
| 312 | } |
| 313 | if (myGen !== generation) { |
| 314 | // A newer request already won; this one's tracks are unwanted. |
| 315 | for (const track of result.getTracks()) track.stop(); |
| 316 | return; |
| 317 | } |
| 318 | stream = result; |
| 319 | preview.srcObject = stream; |
| 320 | applyToggles(); |
| 321 | joinButton.disabled = false; |
| 322 | await fillPickers().catch(() => {}); |
| 323 | say(rawNotice()); |
| 324 | } |
| 325 | |
| 326 | // disambiguate settles which remembered device id is stale, then |
| 327 | // deletes exactly that one and starts over. It runs at most once per |
| 328 | // failure, and a generation that has been superseded meanwhile drops |
| 329 | // whatever it was handed rather than acting on it. |
| 330 | async function disambiguate(myGen) { |
| 331 | if (prefs.camera && prefs.mic) { |
| 332 | try { |
| 333 | const probe = await navigator.mediaDevices.getUserMedia({ |
| 334 | audio: true, |
| 335 | video: { deviceId: { exact: prefs.camera } }, |
| 336 | }); |
| 337 | for (const track of probe.getTracks()) track.stop(); |
| 338 | if (myGen !== generation) return; |
| 339 | delete prefs.mic; |
| 340 | } catch { |
| 341 | if (myGen !== generation) return; |
| 342 | delete prefs.camera; |
| 343 | } |
| 344 | } else if (prefs.camera) { |
| 345 | delete prefs.camera; |
| 346 | } else { |
| 347 | delete prefs.mic; |
| 348 | } |
| 349 | savePrefs(); |
| 350 | return startPreview(); |
| 351 | } |
| 352 | |
| 353 | function errorSentence(err) { |
| 354 | if (err.name === 'NotAllowedError') { |
| 355 | return 'Calls needs permission to use your camera and microphone. You can still join with neither.'; |
| 356 | } |
| 357 | if (err.name === 'NotReadableError') { |
| 358 | return 'Your camera or microphone is already in use by another app. You can still join with neither.'; |
| 359 | } |
| 360 | return 'Calls could not reach a camera or microphone. You can still join with neither.'; |
| 361 | } |
| 362 | |
| 363 | // --- pickers: (re)built from enumerateDevices, which only carries --- |
| 364 | // --- real labels once permission has been granted, which is why --- |
| 365 | // --- this always runs after a getUserMedia attempt, never before. --- |
| 366 | |
| 367 | async function fillPickers() { |
| 368 | let devices; |
| 369 | try { |
| 370 | devices = await navigator.mediaDevices.enumerateDevices(); |
| 371 | } catch { |
| 372 | // Treated as "no devices found" below, which hides every row |
| 373 | // rather than leaving stale or half-filled selects on screen. |
| 374 | devices = []; |
| 375 | } |
| 376 | const fillOne = (select, kind, chosen) => { |
| 377 | select.replaceChildren(); |
| 378 | let count = 0; |
| 379 | for (const device of devices) { |
| 380 | if (device.kind !== kind) continue; |
| 381 | count++; |
| 382 | const option = document.createElement('option'); |
| 383 | option.value = device.deviceId; |
| 384 | option.textContent = device.label || labelFor(kind, count); |
| 385 | if (device.deviceId === chosen) option.selected = true; |
| 386 | select.append(option); |
| 387 | } |
| 388 | return count; |
| 389 | }; |
| 390 | const cameraCount = fillOne(pickCamera, 'videoinput', prefs.camera || currentDeviceId('video')); |
| 391 | const micCount = fillOne(pickMic, 'audioinput', prefs.mic || currentDeviceId('audio')); |
| 392 | const hasSinkId = typeof HTMLMediaElement.prototype.setSinkId === 'function'; |
| 393 | const speakerCount = hasSinkId ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0; |
| 394 | // A field with nothing to choose from is not help, it's clutter — |
| 395 | // hidden rather than shown empty. Re-evaluated on every call, so a |
| 396 | // device unplugged or plugged in mid-call (devicechange) is |
| 397 | // reflected immediately. |
| 398 | cameraRow.hidden = cameraCount === 0; |
| 399 | micRow.hidden = micCount === 0; |
| 400 | // No microphone is no processing to choose about. |
| 401 | if (rawRow) rawRow.hidden = micCount === 0; |
| 402 | speakerRow.hidden = !hasSinkId || speakerCount === 0; |
| 403 | } |
| 404 | |
| 405 | function labelFor(kind, count) { |
| 406 | return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count; |
| 407 | } |
| 408 | |
| 409 | function currentDeviceId(kind) { |
| 410 | const track = stream && stream.getTracks().find((t) => t.kind === kind); |
| 411 | return track ? track.getSettings().deviceId : ''; |
| 412 | } |
| 413 | |
| 414 | // --- wiring that needs media support --- |
| 415 | |
| 416 | pickCamera.addEventListener('change', () => { |
| 417 | prefs.camera = pickCamera.value; |
| 418 | savePrefs(); |
| 419 | startPreview(); |
| 420 | }); |
| 421 | pickMic.addEventListener('change', () => { |
| 422 | prefs.mic = pickMic.value; |
| 423 | savePrefs(); |
| 424 | startPreview(); |
| 425 | }); |
| 426 | pickSpeaker.addEventListener('change', () => { |
| 427 | prefs.speaker = pickSpeaker.value; |
| 428 | savePrefs(); |
| 429 | }); |
| 430 | navigator.mediaDevices.addEventListener?.('devicechange', () => { |
| 431 | fillPickers().catch(() => {}); |
| 432 | }); |
| 433 | |
| 434 | // --- the audio test (§15.20) --- |
| 435 | // |
| 436 | // Three questions a person about to join cannot otherwise answer until |
| 437 | // somebody on the call says "I can't hear you": is my microphone |
| 438 | // hearing anything, is my speaker playing anything, and what do I |
| 439 | // sound like with the processing the call will use. The meters here |
| 440 | // are Web Audio, which the room's are not: every test starts from a |
| 441 | // click, so an AudioContext made in its handler is running, where one |
| 442 | // made without a gesture would start suspended and read nothing. |
| 443 | // |
| 444 | // The page's CSP has no media-src, so default-src 'self' refuses a |
| 445 | // blob: URL: the recording is never given one. It is decoded in memory |
| 446 | // and played through Web Audio into a MediaStream, and the <audio> |
| 447 | // element takes that as srcObject — which is also how the chosen |
| 448 | // speaker is honoured, with setSinkId, exactly as the room plays a call. |
| 449 | |
| 450 | // rmsDb is the loudness of one window of samples, in dBFS. Pure, and |
| 451 | // lifted by name in the node test. |
| 452 | function rmsDb(samples) { |
| 453 | let sum = 0; |
| 454 | for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]; |
| 455 | const rms = samples.length ? Math.sqrt(sum / samples.length) : 0; |
| 456 | return rms > 0 ? 20 * Math.log10(rms) : -Infinity; |
| 457 | } |
| 458 | |
| 459 | // meterFill puts a loudness on a meter running from -60 dBFS (empty) |
| 460 | // to 0 dBFS (full). Pure, and lifted by name in the node test. |
| 461 | function meterFill(db) { |
| 462 | if (!(db > -60)) return 0; |
| 463 | return Math.min(1, (db + 60) / 60); |
| 464 | } |
| 465 | |
| 466 | // micVerdict is the microphone test's answer so far: 'working' once |
| 467 | // speech-level sound has been heard, 'silent' once MIC_SILENT_AFTER has |
| 468 | // passed without it, and '' while it is still listening. Working wins |
| 469 | // whenever it comes — somebody who only started talking at six seconds |
| 470 | // has a microphone that works. Pure, and lifted by name in the node |
| 471 | // test. |
| 472 | function micVerdict(elapsed, loudestDb) { |
| 473 | if (loudestDb >= MIC_WORKING_DB) return 'working'; |
| 474 | if (elapsed >= MIC_SILENT_AFTER) return 'silent'; |
| 475 | return ''; |
| 476 | } |
| 477 | |
| 478 | // micSentence and the two after it are the tests' words, from the |
| 479 | // verdict and the device's own label. Labels are other software's |
| 480 | // text and go in with textContent, like every name on these pages. |
| 481 | function micSentence(verdict, label) { |
| 482 | if (verdict === 'working') return 'Your microphone is working.'; |
| 483 | if (verdict === 'silent') { |
| 484 | return 'We can\u2019t hear anything from ' + label + '. Check that it is the microphone you ' + |
| 485 | 'mean to use, that it is not muted on the device itself, and the input your computer\u2019s ' + |
| 486 | 'sound settings are using.'; |
| 487 | } |
| 488 | return 'Listening to ' + label + '. Say something.'; |
| 489 | } |
| 490 | |
| 491 | function chosenLabel(select, fallback) { |
| 492 | const option = select && select.selectedOptions && select.selectedOptions[0]; |
| 493 | const row = select && select.closest('[id$="-row"]'); |
| 494 | if (option && option.textContent && !(row && row.hidden)) return option.textContent; |
| 495 | return fallback; |
| 496 | } |
| 497 | |
| 498 | function stopAudioTest() { |
| 499 | const running = audioTest; |
| 500 | audioTest = null; |
| 501 | tested.running = ''; |
| 502 | if (running) running.stop(); |
| 503 | } |
| 504 | |
| 505 | // testTrack is the microphone to test: a clone of the preview's own |
| 506 | // track, so it is the device the room will open with the processing |
| 507 | // the room will ask for (audioConstraints above), switched on even if |
| 508 | // the Microphone toggle says muted — the test is heard by nobody else. |
| 509 | // With no preview microphone it asks for one the same way. |
| 510 | async function testTrack() { |
| 511 | const live = stream && stream.getAudioTracks().find((t) => t.readyState === 'live'); |
| 512 | if (live) { |
| 513 | const copy = live.clone(); |
| 514 | copy.enabled = true; |
| 515 | return copy; |
| 516 | } |
| 517 | try { |
| 518 | const own = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints(prefs) }); |
| 519 | return own.getAudioTracks()[0] || null; |
| 520 | } catch { |
| 521 | return null; |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | // playThrough plays a Web Audio stream on the chosen speaker, and |
| 526 | // answers whether the browser agreed to. |
| 527 | async function playThrough(out) { |
| 528 | testAudio.srcObject = out; |
| 529 | if (prefs.speaker && typeof testAudio.setSinkId === 'function') { |
| 530 | try { |
| 531 | await testAudio.setSinkId(prefs.speaker); |
| 532 | } catch { |
| 533 | // The remembered output is gone: the default carries the test, |
| 534 | // as it would carry the call. |
| 535 | } |
| 536 | } |
| 537 | try { |
| 538 | await testAudio.play(); |
| 539 | return true; |
| 540 | } catch { |
| 541 | return false; |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | // metering runs one analyser onto one <meter> ten times a second, and |
| 546 | // hands each reading to `each` as well. |
| 547 | function metering(analyser, meter, each) { |
| 548 | const buf = new Float32Array(analyser.fftSize); |
| 549 | return setInterval(() => { |
| 550 | analyser.getFloatTimeDomainData(buf); |
| 551 | const db = rmsDb(buf); |
| 552 | if (meter) meter.value = meterFill(db); |
| 553 | if (each) each(db); |
| 554 | }, 100); |
| 555 | } |
| 556 | |
| 557 | // session starts one test: stops whatever was running, and gives the |
| 558 | // new one a context and a list of things to undo when it ends. |
| 559 | function session(kind) { |
| 560 | stopAudioTest(); |
| 561 | const undo = []; |
| 562 | const run = { |
| 563 | kind, |
| 564 | ctx: new AudioCtx(), |
| 565 | over: false, |
| 566 | undo, |
| 567 | stop() { |
| 568 | run.over = true; |
| 569 | for (const fn of undo.reverse()) { |
| 570 | try { |
| 571 | fn(); |
| 572 | } catch { |
| 573 | // Already undone. |
| 574 | } |
| 575 | } |
| 576 | run.ctx.close().catch(() => {}); |
| 577 | }, |
| 578 | }; |
| 579 | audioTest = run; |
| 580 | tested.running = kind; |
| 581 | return run; |
| 582 | } |
| 583 | |
| 584 | function finish(run) { |
| 585 | if (audioTest === run) stopAudioTest(); |
| 586 | } |
| 587 | |
| 588 | async function testMicrophone() { |
| 589 | const run = session('mic'); |
| 590 | tested.mic = { loudestDb: -Infinity, verdict: '' }; |
| 591 | const track = await testTrack(); |
| 592 | if (run.over) { |
| 593 | if (track) track.stop(); |
| 594 | return; |
| 595 | } |
| 596 | if (!track) { |
| 597 | micResult.textContent = 'Calls could not reach a microphone.'; |
| 598 | finish(run); |
| 599 | return; |
| 600 | } |
| 601 | run.undo.push(() => track.stop(), () => { micLevel.value = 0; }); |
| 602 | const label = chosenLabel(pickMic, track.label || 'your microphone'); |
| 603 | const analyser = run.ctx.createAnalyser(); |
| 604 | analyser.fftSize = 2048; |
| 605 | run.ctx.createMediaStreamSource(new MediaStream([track])).connect(analyser); |
| 606 | const started = Date.now(); |
| 607 | let said = null; |
| 608 | const say = () => { |
| 609 | const verdict = micVerdict(Date.now() - started, tested.mic.loudestDb); |
| 610 | tested.mic.verdict = verdict; |
| 611 | if (verdict !== said) { |
| 612 | said = verdict; |
| 613 | micResult.textContent = micSentence(verdict, label); |
| 614 | } |
| 615 | }; |
| 616 | say(); |
| 617 | const timer = metering(analyser, micLevel, (db) => { |
| 618 | tested.mic.loudestDb = Math.max(tested.mic.loudestDb, db); |
| 619 | say(); |
| 620 | if (Date.now() - started >= MIC_TEST_FOR) finish(run); |
| 621 | }); |
| 622 | run.undo.push(() => clearInterval(timer)); |
| 623 | } |
| 624 | |
| 625 | // chime is the test sound: three short rising notes, quiet enough not |
| 626 | // to startle anybody wearing headphones. |
| 627 | function chime(ctx, into) { |
| 628 | const t0 = ctx.currentTime + 0.05; |
| 629 | [[660, 0], [880, 0.3], [990, 0.6]].forEach(([freq, at]) => { |
| 630 | const osc = ctx.createOscillator(); |
| 631 | osc.frequency.value = freq; |
| 632 | const gain = ctx.createGain(); |
| 633 | gain.gain.setValueAtTime(0.0001, t0 + at); |
| 634 | gain.gain.exponentialRampToValueAtTime(0.25, t0 + at + 0.02); |
| 635 | gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + 0.5); |
| 636 | osc.connect(gain).connect(into); |
| 637 | osc.start(t0 + at); |
| 638 | osc.stop(t0 + at + 0.55); |
| 639 | }); |
| 640 | return 1300; |
| 641 | } |
| 642 | |
| 643 | async function testSpeakers() { |
| 644 | const run = session('speaker'); |
| 645 | tested.speaker = { played: false, peak: 0 }; |
| 646 | const label = chosenLabel(pickSpeaker, 'your speakers'); |
| 647 | const analyser = run.ctx.createAnalyser(); |
| 648 | analyser.fftSize = 2048; |
| 649 | const out = run.ctx.createMediaStreamDestination(); |
| 650 | analyser.connect(out); |
| 651 | const lasts = chime(run.ctx, analyser); |
| 652 | run.undo.push(() => { |
| 653 | testAudio.pause(); |
| 654 | testAudio.srcObject = null; |
| 655 | speakerLevel.value = 0; |
| 656 | }); |
| 657 | if (!(await playThrough(out.stream)) || run.over) { |
| 658 | if (!run.over) speakerResult.textContent = 'This browser would not play the test sound.'; |
| 659 | finish(run); |
| 660 | return; |
| 661 | } |
| 662 | tested.speaker.played = true; |
| 663 | speakerResult.textContent = 'Playing a test sound through ' + label + '.'; |
| 664 | const timer = metering(analyser, speakerLevel, (db) => { |
| 665 | tested.speaker.peak = Math.max(tested.speaker.peak, meterFill(db)); |
| 666 | }); |
| 667 | run.undo.push(() => clearInterval(timer)); |
| 668 | setTimeout(() => { |
| 669 | if (run.over) return; |
| 670 | speakerResult.textContent = 'Played a test sound through ' + label + '. If you heard nothing, ' + |
| 671 | 'choose another speaker above or turn the volume up.'; |
| 672 | finish(run); |
| 673 | }, lasts); |
| 674 | } |
| 675 | |
| 676 | // hearYourself records RECORD_FOR of the processed microphone and |
| 677 | // plays it straight back. MediaRecorder encodes it much as the call |
| 678 | // will, which is the point; the Blob it makes is read into memory, |
| 679 | // decoded, played, and let go — never given a URL, never uploaded. |
| 680 | async function hearYourself() { |
| 681 | const run = session('loopback'); |
| 682 | tested.loopback = { phase: 'starting', bytes: 0, peak: 0 }; |
| 683 | if (typeof MediaRecorder !== 'function') { |
| 684 | loopResult.textContent = 'This browser cannot record, so it cannot play you back.'; |
| 685 | tested.loopback.phase = 'failed'; |
| 686 | finish(run); |
| 687 | return; |
| 688 | } |
| 689 | const track = await testTrack(); |
| 690 | if (run.over) { |
| 691 | if (track) track.stop(); |
| 692 | return; |
| 693 | } |
| 694 | if (!track) { |
| 695 | loopResult.textContent = 'Calls could not reach a microphone.'; |
| 696 | tested.loopback.phase = 'failed'; |
| 697 | finish(run); |
| 698 | return; |
| 699 | } |
| 700 | run.undo.push(() => track.stop(), () => { |
| 701 | testAudio.pause(); |
| 702 | testAudio.srcObject = null; |
| 703 | loopLevel.value = 0; |
| 704 | }); |
| 705 | const analyser = run.ctx.createAnalyser(); |
| 706 | analyser.fftSize = 2048; |
| 707 | const recording = new MediaStream([track]); |
| 708 | run.ctx.createMediaStreamSource(recording).connect(analyser); |
| 709 | const timer = metering(analyser, loopLevel, (db) => { |
| 710 | tested.loopback.peak = Math.max(tested.loopback.peak, meterFill(db)); |
| 711 | }); |
| 712 | run.undo.push(() => clearInterval(timer)); |
| 713 | let chunks = []; |
| 714 | let recorder; |
| 715 | try { |
| 716 | recorder = new MediaRecorder(recording); |
| 717 | } catch { |
| 718 | loopResult.textContent = 'This browser cannot record, so it cannot play you back.'; |
| 719 | tested.loopback.phase = 'failed'; |
| 720 | finish(run); |
| 721 | return; |
| 722 | } |
| 723 | recorder.addEventListener('dataavailable', (e) => { |
| 724 | if (e.data && e.data.size) chunks.push(e.data); |
| 725 | }); |
| 726 | const stopped = new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true })); |
| 727 | run.undo.push(() => { |
| 728 | if (recorder.state !== 'inactive') recorder.stop(); |
| 729 | chunks = []; |
| 730 | }); |
| 731 | recorder.start(); |
| 732 | tested.loopback.phase = 'recording'; |
| 733 | loopResult.textContent = 'Recording for five seconds. Say something.'; |
| 734 | await new Promise((resolve) => setTimeout(resolve, RECORD_FOR)); |
| 735 | if (run.over) return; |
| 736 | recorder.stop(); |
| 737 | await stopped; |
| 738 | track.stop(); |
| 739 | if (run.over) return; |
| 740 | let buffer; |
| 741 | try { |
| 742 | const blob = new Blob(chunks, { type: recorder.mimeType || '' }); |
| 743 | chunks = []; |
| 744 | tested.loopback.bytes = blob.size; |
| 745 | buffer = await run.ctx.decodeAudioData(await blob.arrayBuffer()); |
| 746 | } catch { |
| 747 | buffer = null; |
| 748 | } |
| 749 | if (run.over) return; |
| 750 | if (!buffer) { |
| 751 | loopResult.textContent = 'This browser could not play the recording back.'; |
| 752 | tested.loopback.phase = 'failed'; |
| 753 | finish(run); |
| 754 | return; |
| 755 | } |
| 756 | const source = run.ctx.createBufferSource(); |
| 757 | source.buffer = buffer; |
| 758 | buffer = null; |
| 759 | const out = run.ctx.createMediaStreamDestination(); |
| 760 | source.connect(analyser); |
| 761 | analyser.connect(out); |
| 762 | run.undo.push(() => { |
| 763 | source.onended = null; |
| 764 | try { |
| 765 | source.stop(); |
| 766 | } catch { |
| 767 | // Never started, or already over. |
| 768 | } |
| 769 | source.disconnect(); |
| 770 | }); |
| 771 | const label = chosenLabel(pickSpeaker, 'your speakers'); |
| 772 | if (!(await playThrough(out.stream)) || run.over) { |
| 773 | if (!run.over) { |
| 774 | loopResult.textContent = 'This browser would not play the recording back.'; |
| 775 | tested.loopback.phase = 'failed'; |
| 776 | } |
| 777 | finish(run); |
| 778 | return; |
| 779 | } |
| 780 | source.onended = () => { |
| 781 | if (run.over) return; |
| 782 | tested.loopback.phase = 'played'; |
| 783 | loopResult.textContent = 'That is how you sound to the others. The recording has been thrown away.'; |
| 784 | finish(run); |
| 785 | }; |
| 786 | source.start(); |
| 787 | tested.loopback.phase = 'playing'; |
| 788 | loopResult.textContent = 'Playing it back through ' + label + '.'; |
| 789 | } |
| 790 | |
| 791 | // The test is in the page from the first paint (join.html), so that |
| 792 | // nothing moves when this script arrives; it goes only where there is |
| 793 | // no Web Audio to run it with. |
| 794 | if (testBox && !(AudioCtx && testMic && testSpeaker && testLoop && testAudio)) testBox.hidden = true; |
| 795 | if (testBox && AudioCtx && testMic && testSpeaker && testLoop && testAudio) { |
| 796 | testBox.hidden = false; |
| 797 | const go = (fn, what) => () => { |
| 798 | fn().catch((err) => { |
| 799 | console.error('meet: audio test, ' + what, err); |
| 800 | stopAudioTest(); |
| 801 | }); |
| 802 | }; |
| 803 | testMic.addEventListener('click', go(testMicrophone, 'microphone')); |
| 804 | testSpeaker.addEventListener('click', go(testSpeakers, 'speaker')); |
| 805 | testLoop.addEventListener('click', go(hearYourself, 'playback')); |
| 806 | // Closing the disclosure is putting the test away. |
| 807 | testBox.addEventListener('toggle', () => { |
| 808 | if (!testBox.open) stopAudioTest(); |
| 809 | }); |
| 810 | } |
| 811 | // For the browser gate: what the tests found, as numbers and words. |
| 812 | window.meetDevices = { |
| 813 | audioTest() { |
| 814 | return JSON.parse(JSON.stringify(tested)); |
| 815 | }, |
| 816 | }; |
| 817 | |
| 818 | // A tab loaded in the background is not somewhere a permission |
| 819 | // prompt should appear unannounced; the request waits for the tab |
| 820 | // to actually become visible, once, and proceeds normally from |
| 821 | // there. |
| 822 | if (document.visibilityState === 'visible') { |
| 823 | startPreview(); |
| 824 | } else { |
| 825 | document.addEventListener('visibilitychange', function onVisible() { |
| 826 | if (document.visibilityState !== 'visible') return; |
| 827 | document.removeEventListener('visibilitychange', onVisible); |
| 828 | startPreview(); |
| 829 | }); |
| 830 | } |
| 831 | })(); |
| 832 | |