| 1 | // room-hearing.test.mjs — room.js's hearing watch (spec §15.20), gated |
| 2 | // without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-hearing.test.mjs |
| 5 | // |
| 6 | // Same trick as room-mute.test.mjs: the functions are lifted out of the |
| 7 | // shipped room.js by name, and so are the constants they read, so a |
| 8 | // rename or a retuned threshold fails here loudly instead of passing |
| 9 | // against a copy. |
| 10 | // |
| 11 | // audioSample one stats report read against the last: the level, and |
| 12 | // whether it was sound at all |
| 13 | // judgeSilence the five-second rule, and what it refuses to count |
| 14 | // selfMicWatched whether our own microphone may be judged at all: |
| 15 | // never alone, never muted |
| 16 | // judgeSpeaking the speaking highlight's hysteresis |
| 17 | // hearingShown muted, then blocked, then no sound, then live sound |
| 18 | // |
| 19 | // The speaking glow that replaced the meter has room-glow.test.mjs. |
| 20 | // remoteNotice what a peer's own report says about hearing us |
| 21 | import { test } from 'node:test'; |
| 22 | import assert from 'node:assert/strict'; |
| 23 | import { readFileSync } from 'node:fs'; |
| 24 | import { fileURLToPath } from 'node:url'; |
| 25 | |
| 26 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 27 | const src = readFileSync(roomJS, 'utf8'); |
| 28 | |
| 29 | function lift(source, name) { |
| 30 | const start = source.indexOf('function ' + name + '('); |
| 31 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 32 | let depth = 0; |
| 33 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 34 | if (source[i] === '{') depth += 1; |
| 35 | else if (source[i] === '}') { |
| 36 | depth -= 1; |
| 37 | if (depth === 0) return source.slice(start, i + 1); |
| 38 | } |
| 39 | } |
| 40 | assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| 41 | } |
| 42 | |
| 43 | // liftConst reads one `const NAME = <number>;` line, so the thresholds |
| 44 | // under test are the shipped ones. |
| 45 | function liftConst(name) { |
| 46 | const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); |
| 47 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 48 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 49 | } |
| 50 | |
| 51 | const CONSTS = ['FLAT_LEVEL', 'NO_SOUND_AFTER', 'SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD'] |
| 52 | .map(liftConst).join('\n'); |
| 53 | const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD };')(); |
| 54 | |
| 55 | const build = (name) => |
| 56 | // eslint-disable-next-line no-new-func |
| 57 | new Function(CONSTS + '\n' + lift(src, name) + '; return ' + name + ';')(); |
| 58 | |
| 59 | const audioSample = build('audioSample'); |
| 60 | const judgeSilence = build('judgeSilence'); |
| 61 | const selfMicWatched = build('selfMicWatched'); |
| 62 | const judgeSpeaking = build('judgeSpeaking'); |
| 63 | const hearingShown = build('hearingShown'); |
| 64 | const remoteNotice = build('remoteNotice'); |
| 65 | const hearingRecord = build('hearingRecord'); |
| 66 | |
| 67 | test('the thresholds are the ones the spec names', () => { |
| 68 | // -80 dBFS, and five seconds. |
| 69 | assert.equal(consts.FLAT_LEVEL, 0.0001); |
| 70 | assert.equal(consts.NO_SOUND_AFTER, 5000); |
| 71 | }); |
| 72 | |
| 73 | test('audioSample prefers audioLevel, and falls back to energy over time', () => { |
| 74 | const first = audioSample(null, { audioLevel: 0.2, packetsReceived: 10, totalAudioEnergy: 1, totalSamplesDuration: 2 }); |
| 75 | assert.equal(first.level, 0.2); |
| 76 | assert.equal(first.heard, true); |
| 77 | assert.equal(first.grew, false, 'a first report has nothing to have grown from'); |
| 78 | // No audioLevel: the RMS since the last poll stands in. Energy is the |
| 79 | // sum of squares times duration, so 0.0004 J over 1 s is RMS 0.02. |
| 80 | const prev = { energy: 1, duration: 2, packets: 10 }; |
| 81 | const next = audioSample(prev, { packetsReceived: 60, totalAudioEnergy: 1.0004, totalSamplesDuration: 3 }); |
| 82 | assert.ok(Math.abs(next.level - 0.02) < 1e-9, String(next.level)); |
| 83 | assert.equal(next.grew, true); |
| 84 | // A beep between two polls: the instant level reads flat, the energy |
| 85 | // says there was sound. |
| 86 | const beep = audioSample(prev, { audioLevel: 0, packetsReceived: 60, totalAudioEnergy: 1.0004, totalSamplesDuration: 3 }); |
| 87 | assert.equal(beep.level, 0); |
| 88 | assert.equal(beep.heard, true); |
| 89 | // Flat is flat. |
| 90 | const flat = audioSample(prev, { audioLevel: 0, packetsReceived: 60, totalAudioEnergy: 1, totalSamplesDuration: 3 }); |
| 91 | assert.equal(flat.heard, false); |
| 92 | // Counters that went backwards (a replaced track, a new source) are |
| 93 | // not read as a burst of sound. |
| 94 | const reset = audioSample(prev, { packetsReceived: 5, totalAudioEnergy: 0, totalSamplesDuration: 0.2 }); |
| 95 | assert.equal(reset.level, 0); |
| 96 | assert.equal(reset.heard, false); |
| 97 | assert.equal(reset.grew, false); |
| 98 | // A report with nothing in it draws as silence, but is not evidence of |
| 99 | // it: the five-second rule is only fed what a browser measured. |
| 100 | const blank = audioSample(null, { packetsReceived: 5 }); |
| 101 | assert.equal(blank.level, 0); |
| 102 | assert.equal(blank.measurable, false); |
| 103 | assert.equal(first.measurable, true); |
| 104 | assert.equal(audioSample(prev, { totalAudioEnergy: 1, totalSamplesDuration: 3 }).measurable, true); |
| 105 | }); |
| 106 | |
| 107 | test('judgeSilence: five flat seconds of an arriving, eligible stream, and not a moment sooner', () => { |
| 108 | const rec = hearingRecord(); |
| 109 | const t0 = 1000000; |
| 110 | assert.equal(judgeSilence(rec, t0, true, true, false), false); |
| 111 | assert.equal(judgeSilence(rec, t0 + 4999, true, true, false), false); |
| 112 | assert.equal(judgeSilence(rec, t0 + 5000, true, true, false), true); |
| 113 | assert.equal(rec.silent, true); |
| 114 | // Any sound ends it at once. |
| 115 | assert.equal(judgeSilence(rec, t0 + 5200, true, true, true), false); |
| 116 | assert.equal(rec.silent, false); |
| 117 | // And the count starts again from the next flat poll. |
| 118 | assert.equal(judgeSilence(rec, t0 + 5400, true, true, false), false); |
| 119 | assert.equal(judgeSilence(rec, t0 + 10399, true, true, false), false); |
| 120 | assert.equal(judgeSilence(rec, t0 + 10400, true, true, false), true); |
| 121 | }); |
| 122 | |
| 123 | test('our own microphone: alone never warns; with someone, five silent seconds do', () => { |
| 124 | // Mac 0.4.6, 2026-10-03: alone in a call, nothing is sent, and the |
| 125 | // warning said a working microphone was dead. Same rule in the browser. |
| 126 | const rec = hearingRecord(); |
| 127 | const t0 = 1000000; |
| 128 | for (let t = 0; t <= 30000; t += 200) { |
| 129 | judgeSilence(rec, t0 + t, selfMicWatched(true, false, false), true, false); |
| 130 | assert.equal(rec.silent, false, 'alone, at ' + t + ' ms'); |
| 131 | } |
| 132 | // Muted, with someone: the Muted state says it, not this (0.3.6). |
| 133 | for (let t = 30000; t <= 40000; t += 200) { |
| 134 | judgeSilence(rec, t0 + t, selfMicWatched(true, true, true), true, false); |
| 135 | assert.equal(rec.silent, false, 'muted, at ' + t + ' ms'); |
| 136 | } |
| 137 | // Unmuted, someone connected, flat: the warning, five seconds on. |
| 138 | const start = t0 + 40200; |
| 139 | assert.equal(judgeSilence(rec, start, selfMicWatched(true, false, true), true, false), false); |
| 140 | assert.equal(judgeSilence(rec, start + 4999, selfMicWatched(true, false, true), true, false), false); |
| 141 | assert.equal(judgeSilence(rec, start + 5000, selfMicWatched(true, false, true), true, false), true); |
| 142 | // They leave: gone at once. |
| 143 | assert.equal(judgeSilence(rec, start + 5200, selfMicWatched(true, false, false), true, false), false); |
| 144 | }); |
| 145 | |
| 146 | test('judgeSilence counts nothing that is not the sender’s silence', () => { |
| 147 | const t0 = 1000000; |
| 148 | for (const [eligible, arriving, why] of [ |
| 149 | [false, true, 'a muted peer, or a tab that is not playing, is not judged'], |
| 150 | [true, false, 'no packets is a connection’s problem, not a microphone’s'], |
| 151 | ]) { |
| 152 | const rec = hearingRecord(); |
| 153 | judgeSilence(rec, t0, eligible, arriving, false); |
| 154 | assert.equal(judgeSilence(rec, t0 + 60000, eligible, arriving, false), false, why); |
| 155 | } |
| 156 | // Eligibility lost halfway resets the count rather than pausing it. |
| 157 | const rec = hearingRecord(); |
| 158 | judgeSilence(rec, t0, true, true, false); |
| 159 | judgeSilence(rec, t0 + 3000, false, true, false); |
| 160 | assert.equal(judgeSilence(rec, t0 + 6000, true, true, false), false); |
| 161 | assert.equal(judgeSilence(rec, t0 + 11000, true, true, false), true); |
| 162 | }); |
| 163 | |
| 164 | test('judgeSpeaking holds through a gap between syllables', () => { |
| 165 | const rec = hearingRecord(); |
| 166 | assert.equal(judgeSpeaking(rec, 0.01), false); |
| 167 | assert.equal(judgeSpeaking(rec, 0.1), true); |
| 168 | // Between the two thresholds: held, and the hold is not spent. |
| 169 | for (let i = 0; i < 10; i++) assert.equal(judgeSpeaking(rec, 0.03), true); |
| 170 | // Under the lower one: off only after the hold. |
| 171 | for (let i = 1; i < consts.SPEAK_HOLD; i++) assert.equal(judgeSpeaking(rec, 0), true, 'poll ' + i); |
| 172 | assert.equal(judgeSpeaking(rec, 0), false); |
| 173 | // And a level between the two does not turn it back on. |
| 174 | assert.equal(judgeSpeaking(rec, 0.03), false); |
| 175 | }); |
| 176 | |
| 177 | test('hearingShown: muted, then blocked, then no sound, then the meter', () => { |
| 178 | const on = { muted: false, cameraOff: false }; |
| 179 | const muted = { muted: true, cameraOff: false }; |
| 180 | assert.equal(hearingShown(muted, true, true), 'muted', 'a muted peer shows Muted and nothing else'); |
| 181 | assert.equal(hearingShown(on, true, true), 'blocked', 'this browser refusing sound comes before their silence'); |
| 182 | assert.equal(hearingShown(on, false, true), 'silent'); |
| 183 | assert.equal(hearingShown(on, false, false), 'meter'); |
| 184 | // Unknown is not muted (§15.19). |
| 185 | assert.equal(hearingShown(null, false, false), 'meter'); |
| 186 | assert.equal(hearingShown(null, true, false), 'blocked'); |
| 187 | }); |
| 188 | |
| 189 | test('remoteNotice: their browser blocking, or no sound from us while we are not muted', () => { |
| 190 | const me = 'c'.repeat(32); |
| 191 | const on = { muted: false, cameraOff: false }; |
| 192 | assert.equal(remoteNotice('Tony', null, me, false), ''); |
| 193 | assert.equal(remoteNotice('Tony', on, me, false), ''); |
| 194 | assert.equal(remoteNotice('Tony', { ...on, audioBlocked: true }, me, false), |
| 195 | 'Tony’s browser is blocking sound — they need to click to hear you.'); |
| 196 | assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: [me] }, me, false), |
| 197 | 'Tony isn’t getting any sound from you.'); |
| 198 | assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: [me] }, me, true), '', |
| 199 | 'muted, we are sending nothing on purpose and there is nothing to fix'); |
| 200 | assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: ['d'.repeat(32)] }, me, false), '', |
| 201 | 'somebody else’s silence is not ours'); |
| 202 | }); |
| 203 | |