| index 73ca423..3771e9b 100644 |
| --- a/internal/calls/static/devices.js |
| +++ b/internal/calls/static/devices.js |
| @@ -9,7 +9,9 @@ |
| // 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. |
| +// 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'); |
| @@ -37,6 +39,31 @@ |
| 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 |
| @@ -47,6 +74,17 @@ |
| // 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 --- |
| |
| @@ -167,6 +205,9 @@ |
| } |
| |
| 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; |
| @@ -390,6 +431,386 @@ |
| 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 <audio> |
| + // element takes that as srcObject — which is also how the chosen |
| + // speaker is honoured, with setSinkId, exactly as the room plays a call. |
| + |
| + // rmsDb is the loudness of one window of samples, in dBFS. Pure, and |
| + // lifted by name in the node test. |
| + function rmsDb(samples) { |
| + let sum = 0; |
| + for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]; |
| + const rms = samples.length ? Math.sqrt(sum / samples.length) : 0; |
| + return rms > 0 ? 20 * Math.log10(rms) : -Infinity; |
| + } |
| + |
| + // meterFill puts a loudness on a meter running from -60 dBFS (empty) |
| + // to 0 dBFS (full). Pure, and lifted by name in the node test. |
| + function meterFill(db) { |
| + if (!(db > -60)) return 0; |
| + return Math.min(1, (db + 60) / 60); |
| + } |
| + |
| + // micVerdict is the microphone test's answer so far: 'working' once |
| + // speech-level sound has been heard, 'silent' once MIC_SILENT_AFTER has |
| + // passed without it, and '' while it is still listening. Working wins |
| + // whenever it comes — somebody who only started talking at six seconds |
| + // has a microphone that works. Pure, and lifted by name in the node |
| + // test. |
| + function micVerdict(elapsed, loudestDb) { |
| + if (loudestDb >= MIC_WORKING_DB) return 'working'; |
| + if (elapsed >= MIC_SILENT_AFTER) return 'silent'; |
| + return ''; |
| + } |
| + |
| + // micSentence and the two after it are the tests' words, from the |
| + // verdict and the device's own label. Labels are other software's |
| + // text and go in with textContent, like every name on these pages. |
| + function micSentence(verdict, label) { |
| + if (verdict === 'working') return 'Your microphone is working.'; |
| + if (verdict === 'silent') { |
| + return 'We can\u2019t hear anything from ' + label + '. Check that it is the microphone you ' + |
| + 'mean to use, that it is not muted on the device itself, and the input your computer\u2019s ' + |
| + 'sound settings are using.'; |
| + } |
| + return 'Listening to ' + label + '. Say something.'; |
| + } |
| + |
| + function chosenLabel(select, fallback) { |
| + const option = select && select.selectedOptions && select.selectedOptions[0]; |
| + const row = select && select.closest('[id$="-row"]'); |
| + if (option && option.textContent && !(row && row.hidden)) return option.textContent; |
| + return fallback; |
| + } |
| + |
| + function stopAudioTest() { |
| + const running = audioTest; |
| + audioTest = null; |
| + tested.running = ''; |
| + if (running) running.stop(); |
| + } |
| + |
| + // testTrack is the microphone to test: a clone of the preview's own |
| + // track, so it is the device the room will open with the processing |
| + // the room will ask for (audioConstraints above), switched on even if |
| + // the Microphone toggle says muted — the test is heard by nobody else. |
| + // With no preview microphone it asks for one the same way. |
| + async function testTrack() { |
| + const live = stream && stream.getAudioTracks().find((t) => t.readyState === 'live'); |
| + if (live) { |
| + const copy = live.clone(); |
| + copy.enabled = true; |
| + return copy; |
| + } |
| + try { |
| + const own = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints(prefs) }); |
| + return own.getAudioTracks()[0] || null; |
| + } catch { |
| + return null; |
| + } |
| + } |
| + |
| + // playThrough plays a Web Audio stream on the chosen speaker, and |
| + // answers whether the browser agreed to. |
| + async function playThrough(out) { |
| + testAudio.srcObject = out; |
| + if (prefs.speaker && typeof testAudio.setSinkId === 'function') { |
| + try { |
| + await testAudio.setSinkId(prefs.speaker); |
| + } catch { |
| + // The remembered output is gone: the default carries the test, |
| + // as it would carry the call. |
| + } |
| + } |
| + try { |
| + await testAudio.play(); |
| + return true; |
| + } catch { |
| + return false; |
| + } |
| + } |
| + |
| + // metering runs one analyser onto one <meter> ten times a second, and |
| + // hands each reading to `each` as well. |
| + function metering(analyser, meter, each) { |
| + const buf = new Float32Array(analyser.fftSize); |
| + return setInterval(() => { |
| + analyser.getFloatTimeDomainData(buf); |
| + const db = rmsDb(buf); |
| + if (meter) meter.value = meterFill(db); |
| + if (each) each(db); |
| + }, 100); |
| + } |
| + |
| + // session starts one test: stops whatever was running, and gives the |
| + // new one a context and a list of things to undo when it ends. |
| + function session(kind) { |
| + stopAudioTest(); |
| + const undo = []; |
| + const run = { |
| + kind, |
| + ctx: new AudioCtx(), |
| + over: false, |
| + undo, |
| + stop() { |
| + run.over = true; |
| + for (const fn of undo.reverse()) { |
| + try { |
| + fn(); |
| + } catch { |
| + // Already undone. |
| + } |
| + } |
| + run.ctx.close().catch(() => {}); |
| + }, |
| + }; |
| + audioTest = run; |
| + tested.running = kind; |
| + return run; |
| + } |
| + |
| + function finish(run) { |
| + if (audioTest === run) stopAudioTest(); |
| + } |
| + |
| + async function testMicrophone() { |
| + const run = session('mic'); |
| + tested.mic = { loudestDb: -Infinity, verdict: '' }; |
| + const track = await testTrack(); |
| + if (run.over) { |
| + if (track) track.stop(); |
| + return; |
| + } |
| + if (!track) { |
| + micResult.textContent = 'Calls could not reach a microphone.'; |
| + finish(run); |
| + return; |
| + } |
| + run.undo.push(() => track.stop(), () => { micLevel.value = 0; }); |
| + const label = chosenLabel(pickMic, track.label || 'your microphone'); |
| + const analyser = run.ctx.createAnalyser(); |
| + analyser.fftSize = 2048; |
| + run.ctx.createMediaStreamSource(new MediaStream([track])).connect(analyser); |
| + const started = Date.now(); |
| + let said = null; |
| + const say = () => { |
| + const verdict = micVerdict(Date.now() - started, tested.mic.loudestDb); |
| + tested.mic.verdict = verdict; |
| + if (verdict !== said) { |
| + said = verdict; |
| + micResult.textContent = micSentence(verdict, label); |
| + } |
| + }; |
| + say(); |
| + const timer = metering(analyser, micLevel, (db) => { |
| + tested.mic.loudestDb = Math.max(tested.mic.loudestDb, db); |
| + say(); |
| + if (Date.now() - started >= MIC_TEST_FOR) finish(run); |
| + }); |
| + run.undo.push(() => clearInterval(timer)); |
| + } |
| + |
| + // chime is the test sound: three short rising notes, quiet enough not |
| + // to startle anybody wearing headphones. |
| + function chime(ctx, into) { |
| + const t0 = ctx.currentTime + 0.05; |
| + [[660, 0], [880, 0.3], [990, 0.6]].forEach(([freq, at]) => { |
| + const osc = ctx.createOscillator(); |
| + osc.frequency.value = freq; |
| + const gain = ctx.createGain(); |
| + gain.gain.setValueAtTime(0.0001, t0 + at); |
| + gain.gain.exponentialRampToValueAtTime(0.25, t0 + at + 0.02); |
| + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + 0.5); |
| + osc.connect(gain).connect(into); |
| + osc.start(t0 + at); |
| + osc.stop(t0 + at + 0.55); |
| + }); |
| + return 1300; |
| + } |
| + |
| + async function testSpeakers() { |
| + const run = session('speaker'); |
| + tested.speaker = { played: false, peak: 0 }; |
| + const label = chosenLabel(pickSpeaker, 'your speakers'); |
| + const analyser = run.ctx.createAnalyser(); |
| + analyser.fftSize = 2048; |
| + const out = run.ctx.createMediaStreamDestination(); |
| + analyser.connect(out); |
| + const lasts = chime(run.ctx, analyser); |
| + run.undo.push(() => { |
| + testAudio.pause(); |
| + testAudio.srcObject = null; |
| + speakerLevel.value = 0; |
| + }); |
| + if (!(await playThrough(out.stream)) || run.over) { |
| + if (!run.over) speakerResult.textContent = 'This browser would not play the test sound.'; |
| + finish(run); |
| + return; |
| + } |
| + tested.speaker.played = true; |
| + speakerResult.textContent = 'Playing a test sound through ' + label + '.'; |
| + const timer = metering(analyser, speakerLevel, (db) => { |
| + tested.speaker.peak = Math.max(tested.speaker.peak, meterFill(db)); |
| + }); |
| + run.undo.push(() => clearInterval(timer)); |
| + setTimeout(() => { |
| + if (run.over) return; |
| + speakerResult.textContent = 'Played a test sound through ' + label + '. If you heard nothing, ' + |
| + 'choose another speaker above or turn the volume up.'; |
| + finish(run); |
| + }, lasts); |
| + } |
| + |
| + // hearYourself records RECORD_FOR of the processed microphone and |
| + // plays it straight back. MediaRecorder encodes it much as the call |
| + // will, which is the point; the Blob it makes is read into memory, |
| + // decoded, played, and let go — never given a URL, never uploaded. |
| + async function hearYourself() { |
| + const run = session('loopback'); |
| + tested.loopback = { phase: 'starting', bytes: 0, peak: 0 }; |
| + if (typeof MediaRecorder !== 'function') { |
| + loopResult.textContent = 'This browser cannot record, so it cannot play you back.'; |
| + tested.loopback.phase = 'failed'; |
| + finish(run); |
| + return; |
| + } |
| + const track = await testTrack(); |
| + if (run.over) { |
| + if (track) track.stop(); |
| + return; |
| + } |
| + if (!track) { |
| + loopResult.textContent = 'Calls could not reach a microphone.'; |
| + tested.loopback.phase = 'failed'; |
| + finish(run); |
| + return; |
| + } |
| + run.undo.push(() => track.stop(), () => { |
| + testAudio.pause(); |
| + testAudio.srcObject = null; |
| + loopLevel.value = 0; |
| + }); |
| + const analyser = run.ctx.createAnalyser(); |
| + analyser.fftSize = 2048; |
| + const recording = new MediaStream([track]); |
| + run.ctx.createMediaStreamSource(recording).connect(analyser); |
| + const timer = metering(analyser, loopLevel, (db) => { |
| + tested.loopback.peak = Math.max(tested.loopback.peak, meterFill(db)); |
| + }); |
| + run.undo.push(() => clearInterval(timer)); |
| + let chunks = []; |
| + let recorder; |
| + try { |
| + recorder = new MediaRecorder(recording); |
| + } catch { |
| + loopResult.textContent = 'This browser cannot record, so it cannot play you back.'; |
| + tested.loopback.phase = 'failed'; |
| + finish(run); |
| + return; |
| + } |
| + recorder.addEventListener('dataavailable', (e) => { |
| + if (e.data && e.data.size) chunks.push(e.data); |
| + }); |
| + const stopped = new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true })); |
| + run.undo.push(() => { |
| + if (recorder.state !== 'inactive') recorder.stop(); |
| + chunks = []; |
| + }); |
| + recorder.start(); |
| + tested.loopback.phase = 'recording'; |
| + loopResult.textContent = 'Recording for five seconds. Say something.'; |
| + await new Promise((resolve) => setTimeout(resolve, RECORD_FOR)); |
| + if (run.over) return; |
| + recorder.stop(); |
| + await stopped; |
| + track.stop(); |
| + if (run.over) return; |
| + let buffer; |
| + try { |
| + const blob = new Blob(chunks, { type: recorder.mimeType || '' }); |
| + chunks = []; |
| + tested.loopback.bytes = blob.size; |
| + buffer = await run.ctx.decodeAudioData(await blob.arrayBuffer()); |
| + } catch { |
| + buffer = null; |
| + } |
| + if (run.over) return; |
| + if (!buffer) { |
| + loopResult.textContent = 'This browser could not play the recording back.'; |
| + tested.loopback.phase = 'failed'; |
| + finish(run); |
| + return; |
| + } |
| + const source = run.ctx.createBufferSource(); |
| + source.buffer = buffer; |
| + buffer = null; |
| + const out = run.ctx.createMediaStreamDestination(); |
| + source.connect(analyser); |
| + analyser.connect(out); |
| + run.undo.push(() => { |
| + source.onended = null; |
| + try { |
| + source.stop(); |
| + } catch { |
| + // Never started, or already over. |
| + } |
| + source.disconnect(); |
| + }); |
| + const label = chosenLabel(pickSpeaker, 'your speakers'); |
| + if (!(await playThrough(out.stream)) || run.over) { |
| + if (!run.over) { |
| + loopResult.textContent = 'This browser would not play the recording back.'; |
| + tested.loopback.phase = 'failed'; |
| + } |
| + finish(run); |
| + return; |
| + } |
| + source.onended = () => { |
| + if (run.over) return; |
| + tested.loopback.phase = 'played'; |
| + loopResult.textContent = 'That is how you sound to the others. The recording has been thrown away.'; |
| + finish(run); |
| + }; |
| + source.start(); |
| + tested.loopback.phase = 'playing'; |
| + loopResult.textContent = 'Playing it back through ' + label + '.'; |
| + } |
| + |
| + if (testBox && AudioCtx && testMic && testSpeaker && testLoop && testAudio) { |
| + testBox.hidden = false; |
| + const go = (fn, what) => () => { |
| + fn().catch((err) => { |
| + console.error('meet: audio test, ' + what, err); |
| + stopAudioTest(); |
| + }); |
| + }; |
| + testMic.addEventListener('click', go(testMicrophone, 'microphone')); |
| + testSpeaker.addEventListener('click', go(testSpeakers, 'speaker')); |
| + testLoop.addEventListener('click', go(hearYourself, 'playback')); |
| + // Closing the disclosure is putting the test away. |
| + testBox.addEventListener('toggle', () => { |
| + if (!testBox.open) stopAudioTest(); |
| + }); |
| + } |
| + // For the browser gate: what the tests found, as numbers and words. |
| + window.meetDevices = { |
| + audioTest() { |
| + return JSON.parse(JSON.stringify(tested)); |
| + }, |
| + }; |
| + |
| // A tab loaded in the background is not somewhere a permission |
| // prompt should appear unannounced; the request waits for the tab |
| // to actually become visible, once, and proceeds normally from |