// audio.test.mjs — the studio audio choice, gated without a browser. // // node --test test/browser/audio.test.mjs // // `audioConstraints` decides what the microphone is asked for, and it // is written twice on purpose: the holding screen opens a stream and // stops it, and the room opens its own at join, so devices.js and // room.js each carry a copy. Duplication between those two files is // this app's existing shape (forgetDevices is the same arrangement), // and the cost of it is drift — a choice that holds on the holding // screen, is silently dropped at join, and leaves someone listening to // noise suppression they turned off one page earlier. // // So the first test here is not about constraints at all. It is that // the two copies are still the same source. The rest is lifted out of // devices.js and run, the way negotiation.test.mjs runs offerCollision, // so what is under test is the shipped function and not a copy that can // go stale quietly. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url)); const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); // lift returns the source of one `function name(...) {...}` by matching // braces from its declaration — negotiation.test.mjs's helper, and it // fails loudly on a rename rather than passing on a stale duplicate. function lift(source, name, where) { let start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); if (source.slice(start - 6, start) === 'async ') start -= 6; let depth = 0; for (let i = source.indexOf('{', start); i < source.length; i++) { if (source[i] === '{') depth += 1; else if (source[i] === '}') { depth -= 1; if (depth === 0) return source.slice(start, i + 1); } } assert.fail('unbalanced braces reading ' + name + ' out of ' + where); } const devicesSrc = readFileSync(devicesJS, 'utf8'); const roomSrc = readFileSync(roomJS, 'utf8'); const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js'); const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js'); test('devices.js and room.js still ask for the microphone the same way', () => { // If this fails, one of the two was edited and the other was not. Copy // the change across rather than relaxing the assertion: the point of // the pair is that the holding screen's choice survives the join. assert.equal(fromDevices, fromRoom); }); // eslint-disable-next-line no-new-func const audioConstraints = new Function(fromDevices + '; return audioConstraints;')(); test('the voice pipeline is on unless it is turned off', () => { const c = audioConstraints({}); assert.equal(c.echoCancellation, true); assert.equal(c.noiseSuppression, true); assert.equal(c.autoGainControl, true); }); test('voice isolation is never asked for on top of the pipeline', () => { // The second suppressor is the one that does the damage: Apple's // system Voice Isolation running after the browser's own noise // suppression returns a growl with the keystrokes still in it. The // default path names three flags and leaves the fourth alone, so // whatever the machine is set to is what a person gets. assert.equal('voiceIsolation' in audioConstraints({}), false); assert.equal('voiceIsolation' in audioConstraints({ rawAudio: false }), false); }); test('studio audio turns all of it off, system voice isolation included', () => { // The one place voiceIsolation is named: someone may have switched it // on for the whole machine, and an interface in a treated room is // exactly the case that has to be able to say no to it. const c = audioConstraints({ rawAudio: true }); assert.equal(c.echoCancellation, false); assert.equal(c.noiseSuppression, false); assert.equal(c.autoGainControl, false); assert.equal(c.voiceIsolation, false); }); test('the processing is asked for as an ideal and never as exact', () => { // The safety property, and the reason rawHonoured exists. `exact` here // would mean a browser that cannot turn its filters off refuses // getUserMedia outright — costing someone their microphone over a // preference, which is the opposite of what the ladder in either file // is built to do. Booleans are ideals; objects are not. A name the // constraints leave out entirely is not under test here: that is the // test above. for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) { const c = audioConstraints(prefs); for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) { if (!(key in c)) continue; assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key])); } } }); test('a remembered microphone is still asked for exactly', () => { // The device id is the one thing that *is* exact: a remembered device // that has gone should fail so the caller can notice and forget it, // which is what disambiguate and forgetDevices are for. const c = audioConstraints({ mic: 'mic-1' }); assert.deepEqual(c.deviceId, { exact: 'mic-1' }); }); test('no remembered microphone names no device at all', () => { assert.equal('deviceId' in audioConstraints({}), false); }); test('studio audio and a remembered microphone are independent', () => { const c = audioConstraints({ mic: 'mic-1', rawAudio: true }); assert.deepEqual(c.deviceId, { exact: 'mic-1' }); assert.equal(c.noiseSuppression, false); }); test('the room lifts the audio ceiling, and only for studio audio', () => { // Turning the filters off buys nothing if Opus then runs at its // default of roughly 32 kbit/s, so the two ship together. The number // is read out of room.js rather than restated here. const m = /const RAW_AUDIO_BITRATE = (\d+);/.exec(roomSrc); assert.notEqual(m, null, 'RAW_AUDIO_BITRATE is not a number constant in room.js any more'); assert.ok(Number(m[1]) >= 64000, 'a ceiling below 64 kbit/s is not worth the switch'); // The ceiling follows the preference, not the connection. tuneAudioSender // runs for every connection and decides inside itself, so studio audio // flipped mid-call reaches the connections that already exist — and // flipped back lets them go again. Gating the *call* on prefs.rawAudio // was the half-feature: it only ever raised, and only on connections // built after the switch. const tune = /function tuneAudioSender\(pc\) \{[\s\S]*?\n \}/.exec(roomSrc); assert.notEqual(tune, null, 'tuneAudioSender is not in room.js any more'); assert.match(tune[0], /if \(prefs\.rawAudio\)/, 'tuneAudioSender must read the preference itself'); assert.match(tune[0], /delete params\.encodings\[0\]\.maxBitrate/, 'tuneAudioSender must give the ceiling back when studio audio goes off'); assert.match(roomSrc, /for \(const entry of pcs\.values\(\)\) tuneAudioSender\(entry\.pc\);/, 'an audio switch must re-tune the connections that already exist'); });