oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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