| 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. |
| 19 | import { test } from 'node:test'; |
| 20 | import assert from 'node:assert/strict'; |
| 21 | import { readFileSync } from 'node:fs'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | |
| 24 | const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url)); |
| 25 | const 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. |
| 30 | function 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 | |
| 45 | const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| 46 | const roomSrc = readFileSync(roomJS, 'utf8'); |
| 47 | |
| 48 | const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js'); |
| 49 | const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js'); |
| 50 | |
| 51 | test('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 |
| 59 | const audioConstraints = new Function(fromDevices + '; return audioConstraints;')(); |
| 60 | |
| 61 | test('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 | |
| 68 | test('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 | |
| 78 | test('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 | |
| 89 | test('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 | |
| 106 | test('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 | |
| 114 | test('no remembered microphone names no device at all', () => { |
| 115 | assert.equal('deviceId' in audioConstraints({}), false); |
| 116 | }); |
| 117 | |
| 118 | test('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 | |
| 124 | test('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 | |