oficina / meet Public

Calls web: an audio test on the holding screen

Spec §15.20. A "Test audio" disclosure beside the device choices: a
microphone meter on a clone of the preview's own track (so the same
device and processing the room will use) that says "Your microphone is
working" or "We can't hear anything from <mic>"; a test chime through
the chosen speaker via setSinkId; and "Hear yourself as others will",
five seconds through MediaRecorder played straight back.

The page's CSP refuses blob: media, so the recording is never given a
URL: it is decoded in memory and played through Web Audio into a
MediaStream the <audio> element takes as srcObject, then dropped.
Nothing the test hears leaves the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4873145142ebee7a251b255101add2901b8e3683 parent 888b735
3 files changed, +514 −1
  • internal/calls/static/devices.js +422 −1
  • internal/calls/templates/join.html +29 −0
  • test/browser/devices.test.mjs +63 −0
diff --git a/internal/calls/static/devices.js b/internal/calls/static/devices.js
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
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html
index d87afc2..491ff7a 100644
--- a/internal/calls/templates/join.html
+++ b/internal/calls/templates/join.html
@@ -30,6 +30,35 @@
<div id="raw-row" class="calls-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
<p id="raw-help" class="calls-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div>
</div>
+ {{/* Spec §15.20: the audio test, beside the choices it tests. A
+ native disclosure, so it opens and closes with no script of its
+ own; devices.js reveals it only where there is Web Audio to run
+ it with, like the rest of #devices it is never seen without
+ script. Each result is said in words in its own status line, and
+ the meters are native <meter>s with names of their own. Nothing
+ here is sent anywhere: the recording is played back from memory
+ and dropped. */}}
+ <details id="audio-test" class="calls-audio-test" hidden>
+ <summary>Test audio</summary>
+ <p class="calls-raw-help">Everything here stays in this browser. Nothing you say is sent anywhere.</p>
+ <div class="calls-audio-row">
+ <button type="button" id="test-mic" rst-btn>Test microphone</button>
+ <meter id="mic-level" min="0" max="1" value="0" aria-label="Microphone level"></meter>
+ <p id="mic-result" class="calls-audio-result" role="status"></p>
+ </div>
+ <div class="calls-audio-row">
+ <button type="button" id="test-speaker" rst-btn>Play a test sound</button>
+ <meter id="speaker-level" min="0" max="1" value="0" aria-label="Test sound level"></meter>
+ <p id="speaker-result" class="calls-audio-result" role="status"></p>
+ </div>
+ <div class="calls-audio-row">
+ <button type="button" id="test-loopback" rst-btn aria-describedby="loopback-help">Hear yourself as others will</button>
+ <meter id="loopback-level" min="0" max="1" value="0" aria-label="Recording and playback level"></meter>
+ <p id="loopback-help" class="calls-raw-help">Records five seconds from your microphone, with the same settings the call will use, and plays it back to you here. The recording never leaves this browser and is thrown away once it has played.</p>
+ <p id="loopback-result" class="calls-audio-result" role="status"></p>
+ </div>
+ <audio id="test-audio" hidden></audio>
+ </details>
<div class="calls-controls" estilo-call-actions>
<button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
<button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
diff --git a/test/browser/devices.test.mjs b/test/browser/devices.test.mjs
index dcf8873..fff56e6 100644
--- a/test/browser/devices.test.mjs
+++ b/test/browser/devices.test.mjs
@@ -114,3 +114,66 @@ test('a switch superseded before it opens anything puts nothing back', () => {
assert.equal(/myGen !== switching/.test(afterLoop), false,
'there must be no generation check after the sender loop — see the comment there');
});
+
+// §15.20: the holding screen's audio test. The decisions are pure and
+// lifted by name, with the thresholds lifted alongside them, so what is
+// tested is what ships.
+function liftConst(source, name) {
+ const m = source.match(new RegExp('const ' + name + ' = (-?[0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in devices.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+
+const audioTestConsts = ['MIC_WORKING_DB', 'MIC_SILENT_AFTER'].map((n) => liftConst(devicesSrc, n)).join('\n');
+const liftAudio = (name) =>
+ // eslint-disable-next-line no-new-func
+ new Function(audioTestConsts + '\n' + lift(devicesSrc, name, 'devices.js') + '; return ' + name + ';')();
+
+const rmsDb = liftAudio('rmsDb');
+const meterFill = liftAudio('meterFill');
+const micVerdict = liftAudio('micVerdict');
+const micSentence = liftAudio('micSentence');
+
+test('rmsDb measures a window in dBFS', () => {
+ assert.equal(rmsDb(new Float32Array(128)), -Infinity, 'digital silence');
+ assert.equal(rmsDb([]), -Infinity);
+ const full = new Float32Array(128).fill(1);
+ assert.ok(Math.abs(rmsDb(full)) < 1e-9, 'a full-scale square wave is 0 dBFS');
+ const sine = Float32Array.from({ length: 4800 }, (_, i) => 0.1 * Math.sin((2 * Math.PI * 440 * i) / 48000));
+ assert.ok(Math.abs(rmsDb(sine) - (20 * Math.log10(0.1 / Math.SQRT2))) < 0.05, String(rmsDb(sine)));
+});
+
+test('meterFill runs from -60 dBFS empty to 0 dBFS full', () => {
+ assert.equal(meterFill(-Infinity), 0);
+ assert.equal(meterFill(NaN), 0);
+ assert.equal(meterFill(-60), 0);
+ assert.equal(meterFill(-30), 0.5);
+ assert.equal(meterFill(0), 1);
+ assert.equal(meterFill(6), 1);
+});
+
+test('micVerdict: working at speech level whenever it comes, silent after five quiet seconds', () => {
+ assert.equal(micVerdict(0, -Infinity), '', 'still listening');
+ assert.equal(micVerdict(4999, -60), '', 'room noise is not a verdict yet');
+ assert.equal(micVerdict(5000, -60), 'silent');
+ assert.equal(micVerdict(5000, -45.01), 'silent', 'just under speech level is still nothing heard');
+ assert.equal(micVerdict(100, -45), 'working');
+ assert.equal(micVerdict(9000, -20), 'working', 'someone who started talking late has a microphone that works');
+});
+
+test('the microphone test says what it found, naming the microphone', () => {
+ assert.equal(micSentence('working', 'Studio Mic'), 'Your microphone is working.');
+ assert.match(micSentence('silent', 'Studio Mic'), /^We can’t hear anything from Studio Mic\./);
+ assert.match(micSentence('', 'Studio Mic'), /^Listening to Studio Mic\./);
+});
+
+test('the audio test never gives its recording a URL or sends it anywhere', () => {
+ // The page's CSP refuses blob: media, and the promise at the top of
+ // devices.js is that nothing heard here leaves the browser. Both are
+ // properties of what the file does not contain.
+ for (const banned of ['createObjectURL', 'fetch(', 'XMLHttpRequest', 'sendBeacon', 'WebSocket']) {
+ assert.equal(devicesSrc.includes(banned), false, 'devices.js must not use ' + banned);
+ }
+ assert.match(devicesSrc, /decodeAudioData/);
+ assert.match(devicesSrc, /testAudio\.srcObject = out/);
+});