oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1// audio.test.mjs — the studio audio choice, gated without a browser.
2//
3// node --test test/browser/audio.test.mjs
4//
5// `audioConstraints` decides what the microphone is asked for, and it
6// is written twice on purpose: the holding screen opens a stream and
7// stops it, and the room opens its own at join, so devices.js and
8// room.js each carry a copy. Duplication between those two files is
9// this app's existing shape (forgetDevices is the same arrangement),
10// and the cost of it is drift — a choice that holds on the holding
11// screen, is silently dropped at join, and leaves someone listening to
12// noise suppression they turned off one page earlier.
13//
14// So the first test here is not about constraints at all. It is that
15// the two copies are still the same source. The rest is lifted out of
16// devices.js and run, the way negotiation.test.mjs runs offerCollision,
17// so what is under test is the shipped function and not a copy that can
18// go stale quietly.
19import { test } from 'node:test';
20import assert from 'node:assert/strict';
21import { readFileSync } from 'node:fs';
22import { fileURLToPath } from 'node:url';
23
24const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url));
25const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
26
27// lift returns the source of one `function name(...) {...}` by matching
28// braces from its declaration — negotiation.test.mjs's helper, and it
29// fails loudly on a rename rather than passing on a stale duplicate.
30function lift(source, name, where) {
31 let start = source.indexOf('function ' + name + '(');
32 assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing');
33 if (source.slice(start - 6, start) === 'async ') start -= 6;
34 let depth = 0;
35 for (let i = source.indexOf('{', start); i < source.length; i++) {
36 if (source[i] === '{') depth += 1;
37 else if (source[i] === '}') {
38 depth -= 1;
39 if (depth === 0) return source.slice(start, i + 1);
40 }
41 }
42 assert.fail('unbalanced braces reading ' + name + ' out of ' + where);
43}
44
45const devicesSrc = readFileSync(devicesJS, 'utf8');
46const roomSrc = readFileSync(roomJS, 'utf8');
47
48const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js');
49const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js');
50
51test('devices.js and room.js still ask for the microphone the same way', () => {
52 // If this fails, one of the two was edited and the other was not. Copy
53 // the change across rather than relaxing the assertion: the point of
54 // the pair is that the holding screen's choice survives the join.
55 assert.equal(fromDevices, fromRoom);
56});
57
58// eslint-disable-next-line no-new-func
59const audioConstraints = new Function(fromDevices + '; return audioConstraints;')();
60
61test('the voice pipeline is on unless it is turned off', () => {
62 const c = audioConstraints({});
63 assert.equal(c.echoCancellation, true);
64 assert.equal(c.noiseSuppression, true);
65 assert.equal(c.autoGainControl, true);
66});
67
68test('voice isolation is never asked for on top of the pipeline', () => {
69 // The second suppressor is the one that does the damage: Apple's
70 // system Voice Isolation running after the browser's own noise
71 // suppression returns a growl with the keystrokes still in it. The
72 // default path names three flags and leaves the fourth alone, so
73 // whatever the machine is set to is what a person gets.
74 assert.equal('voiceIsolation' in audioConstraints({}), false);
75 assert.equal('voiceIsolation' in audioConstraints({ rawAudio: false }), false);
76});
77
78test('studio audio turns all of it off, system voice isolation included', () => {
79 // The one place voiceIsolation is named: someone may have switched it
80 // on for the whole machine, and an interface in a treated room is
81 // exactly the case that has to be able to say no to it.
82 const c = audioConstraints({ rawAudio: true });
83 assert.equal(c.echoCancellation, false);
84 assert.equal(c.noiseSuppression, false);
85 assert.equal(c.autoGainControl, false);
86 assert.equal(c.voiceIsolation, false);
87});
88
89test('the processing is asked for as an ideal and never as exact', () => {
90 // The safety property, and the reason rawHonoured exists. `exact` here
91 // would mean a browser that cannot turn its filters off refuses
92 // getUserMedia outright — costing someone their microphone over a
93 // preference, which is the opposite of what the ladder in either file
94 // is built to do. Booleans are ideals; objects are not. A name the
95 // constraints leave out entirely is not under test here: that is the
96 // test above.
97 for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) {
98 const c = audioConstraints(prefs);
99 for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) {
100 if (!(key in c)) continue;
101 assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key]));
102 }
103 }
104});
105
106test('a remembered microphone is still asked for exactly', () => {
107 // The device id is the one thing that *is* exact: a remembered device
108 // that has gone should fail so the caller can notice and forget it,
109 // which is what disambiguate and forgetDevices are for.
110 const c = audioConstraints({ mic: 'mic-1' });
111 assert.deepEqual(c.deviceId, { exact: 'mic-1' });
112});
113
114test('no remembered microphone names no device at all', () => {
115 assert.equal('deviceId' in audioConstraints({}), false);
116});
117
118test('studio audio and a remembered microphone are independent', () => {
119 const c = audioConstraints({ mic: 'mic-1', rawAudio: true });
120 assert.deepEqual(c.deviceId, { exact: 'mic-1' });
121 assert.equal(c.noiseSuppression, false);
122});
123
124test('the room lifts the audio ceiling, and only for studio audio', () => {
125 // Turning the filters off buys nothing if Opus then runs at its
126 // default of roughly 32 kbit/s, so the two ship together. The number
127 // is read out of room.js rather than restated here.
128 const m = /const RAW_AUDIO_BITRATE = (\d+);/.exec(roomSrc);
129 assert.notEqual(m, null, 'RAW_AUDIO_BITRATE is not a number constant in room.js any more');
130 assert.ok(Number(m[1]) >= 64000, 'a ceiling below 64 kbit/s is not worth the switch');
131
132 // The ceiling follows the preference, not the connection. tuneAudioSender
133 // runs for every connection and decides inside itself, so studio audio
134 // flipped mid-call reaches the connections that already exist — and
135 // flipped back lets them go again. Gating the *call* on prefs.rawAudio
136 // was the half-feature: it only ever raised, and only on connections
137 // built after the switch.
138 const tune = /function tuneAudioSender\(pc\) \{[\s\S]*?\n \}/.exec(roomSrc);
139 assert.notEqual(tune, null, 'tuneAudioSender is not in room.js any more');
140 assert.match(tune[0], /if \(prefs\.rawAudio\)/, 'tuneAudioSender must read the preference itself');
141 assert.match(tune[0], /delete params\.encodings\[0\]\.maxBitrate/,
142 'tuneAudioSender must give the ceiling back when studio audio goes off');
143 assert.match(roomSrc, /for \(const entry of pcs\.values\(\)\) tuneAudioSender\(entry\.pc\);/,
144 'an audio switch must re-tune the connections that already exist');
145});
146