// room-hearing.test.mjs — room.js's hearing watch (spec §15.20), gated // without a browser. // // node --test test/browser/room-hearing.test.mjs // // Same trick as room-mute.test.mjs: the functions are lifted out of the // shipped room.js by name, and so are the constants they read, so a // rename or a retuned threshold fails here loudly instead of passing // against a copy. // // audioSample one stats report read against the last: the level, and // whether it was sound at all // judgeSilence the five-second rule, and what it refuses to count // selfMicWatched whether our own microphone may be judged at all: // never alone, never muted // judgeSpeaking the speaking highlight's hysteresis // hearingShown muted, then blocked, then no sound, then live sound // // The speaking glow that replaced the meter has room-glow.test.mjs. // remoteNotice what a peer's own report says about hearing us import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); const src = readFileSync(roomJS, 'utf8'); function lift(source, name) { const start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); 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 room.js'); } // liftConst reads one `const NAME = ;` line, so the thresholds // under test are the shipped ones. function liftConst(name) { const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); assert.ok(m, name + ' is not a numeric const in room.js any more'); return 'const ' + name + ' = ' + m[1] + ';'; } const CONSTS = ['FLAT_LEVEL', 'NO_SOUND_AFTER', 'SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD'] .map(liftConst).join('\n'); const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD };')(); const build = (name) => // eslint-disable-next-line no-new-func new Function(CONSTS + '\n' + lift(src, name) + '; return ' + name + ';')(); const audioSample = build('audioSample'); const judgeSilence = build('judgeSilence'); const selfMicWatched = build('selfMicWatched'); const judgeSpeaking = build('judgeSpeaking'); const hearingShown = build('hearingShown'); const remoteNotice = build('remoteNotice'); const hearingRecord = build('hearingRecord'); test('the thresholds are the ones the spec names', () => { // -80 dBFS, and five seconds. assert.equal(consts.FLAT_LEVEL, 0.0001); assert.equal(consts.NO_SOUND_AFTER, 5000); }); test('audioSample prefers audioLevel, and falls back to energy over time', () => { const first = audioSample(null, { audioLevel: 0.2, packetsReceived: 10, totalAudioEnergy: 1, totalSamplesDuration: 2 }); assert.equal(first.level, 0.2); assert.equal(first.heard, true); assert.equal(first.grew, false, 'a first report has nothing to have grown from'); // No audioLevel: the RMS since the last poll stands in. Energy is the // sum of squares times duration, so 0.0004 J over 1 s is RMS 0.02. const prev = { energy: 1, duration: 2, packets: 10 }; const next = audioSample(prev, { packetsReceived: 60, totalAudioEnergy: 1.0004, totalSamplesDuration: 3 }); assert.ok(Math.abs(next.level - 0.02) < 1e-9, String(next.level)); assert.equal(next.grew, true); // A beep between two polls: the instant level reads flat, the energy // says there was sound. const beep = audioSample(prev, { audioLevel: 0, packetsReceived: 60, totalAudioEnergy: 1.0004, totalSamplesDuration: 3 }); assert.equal(beep.level, 0); assert.equal(beep.heard, true); // Flat is flat. const flat = audioSample(prev, { audioLevel: 0, packetsReceived: 60, totalAudioEnergy: 1, totalSamplesDuration: 3 }); assert.equal(flat.heard, false); // Counters that went backwards (a replaced track, a new source) are // not read as a burst of sound. const reset = audioSample(prev, { packetsReceived: 5, totalAudioEnergy: 0, totalSamplesDuration: 0.2 }); assert.equal(reset.level, 0); assert.equal(reset.heard, false); assert.equal(reset.grew, false); // A report with nothing in it draws as silence, but is not evidence of // it: the five-second rule is only fed what a browser measured. const blank = audioSample(null, { packetsReceived: 5 }); assert.equal(blank.level, 0); assert.equal(blank.measurable, false); assert.equal(first.measurable, true); assert.equal(audioSample(prev, { totalAudioEnergy: 1, totalSamplesDuration: 3 }).measurable, true); }); test('judgeSilence: five flat seconds of an arriving, eligible stream, and not a moment sooner', () => { const rec = hearingRecord(); const t0 = 1000000; assert.equal(judgeSilence(rec, t0, true, true, false), false); assert.equal(judgeSilence(rec, t0 + 4999, true, true, false), false); assert.equal(judgeSilence(rec, t0 + 5000, true, true, false), true); assert.equal(rec.silent, true); // Any sound ends it at once. assert.equal(judgeSilence(rec, t0 + 5200, true, true, true), false); assert.equal(rec.silent, false); // And the count starts again from the next flat poll. assert.equal(judgeSilence(rec, t0 + 5400, true, true, false), false); assert.equal(judgeSilence(rec, t0 + 10399, true, true, false), false); assert.equal(judgeSilence(rec, t0 + 10400, true, true, false), true); }); test('our own microphone: alone never warns; with someone, five silent seconds do', () => { // Mac 0.4.6, 2026-10-03: alone in a call, nothing is sent, and the // warning said a working microphone was dead. Same rule in the browser. const rec = hearingRecord(); const t0 = 1000000; for (let t = 0; t <= 30000; t += 200) { judgeSilence(rec, t0 + t, selfMicWatched(true, false, false), true, false); assert.equal(rec.silent, false, 'alone, at ' + t + ' ms'); } // Muted, with someone: the Muted state says it, not this (0.3.6). for (let t = 30000; t <= 40000; t += 200) { judgeSilence(rec, t0 + t, selfMicWatched(true, true, true), true, false); assert.equal(rec.silent, false, 'muted, at ' + t + ' ms'); } // Unmuted, someone connected, flat: the warning, five seconds on. const start = t0 + 40200; assert.equal(judgeSilence(rec, start, selfMicWatched(true, false, true), true, false), false); assert.equal(judgeSilence(rec, start + 4999, selfMicWatched(true, false, true), true, false), false); assert.equal(judgeSilence(rec, start + 5000, selfMicWatched(true, false, true), true, false), true); // They leave: gone at once. assert.equal(judgeSilence(rec, start + 5200, selfMicWatched(true, false, false), true, false), false); }); test('judgeSilence counts nothing that is not the sender’s silence', () => { const t0 = 1000000; for (const [eligible, arriving, why] of [ [false, true, 'a muted peer, or a tab that is not playing, is not judged'], [true, false, 'no packets is a connection’s problem, not a microphone’s'], ]) { const rec = hearingRecord(); judgeSilence(rec, t0, eligible, arriving, false); assert.equal(judgeSilence(rec, t0 + 60000, eligible, arriving, false), false, why); } // Eligibility lost halfway resets the count rather than pausing it. const rec = hearingRecord(); judgeSilence(rec, t0, true, true, false); judgeSilence(rec, t0 + 3000, false, true, false); assert.equal(judgeSilence(rec, t0 + 6000, true, true, false), false); assert.equal(judgeSilence(rec, t0 + 11000, true, true, false), true); }); test('judgeSpeaking holds through a gap between syllables', () => { const rec = hearingRecord(); assert.equal(judgeSpeaking(rec, 0.01), false); assert.equal(judgeSpeaking(rec, 0.1), true); // Between the two thresholds: held, and the hold is not spent. for (let i = 0; i < 10; i++) assert.equal(judgeSpeaking(rec, 0.03), true); // Under the lower one: off only after the hold. for (let i = 1; i < consts.SPEAK_HOLD; i++) assert.equal(judgeSpeaking(rec, 0), true, 'poll ' + i); assert.equal(judgeSpeaking(rec, 0), false); // And a level between the two does not turn it back on. assert.equal(judgeSpeaking(rec, 0.03), false); }); test('hearingShown: muted, then blocked, then no sound, then the meter', () => { const on = { muted: false, cameraOff: false }; const muted = { muted: true, cameraOff: false }; assert.equal(hearingShown(muted, true, true), 'muted', 'a muted peer shows Muted and nothing else'); assert.equal(hearingShown(on, true, true), 'blocked', 'this browser refusing sound comes before their silence'); assert.equal(hearingShown(on, false, true), 'silent'); assert.equal(hearingShown(on, false, false), 'meter'); // Unknown is not muted (§15.19). assert.equal(hearingShown(null, false, false), 'meter'); assert.equal(hearingShown(null, true, false), 'blocked'); }); test('remoteNotice: their browser blocking, or no sound from us while we are not muted', () => { const me = 'c'.repeat(32); const on = { muted: false, cameraOff: false }; assert.equal(remoteNotice('Tony', null, me, false), ''); assert.equal(remoteNotice('Tony', on, me, false), ''); assert.equal(remoteNotice('Tony', { ...on, audioBlocked: true }, me, false), 'Tony’s browser is blocking sound — they need to click to hear you.'); assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: [me] }, me, false), 'Tony isn’t getting any sound from you.'); assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: [me] }, me, true), '', 'muted, we are sending nothing on purpose and there is nothing to fix'); assert.equal(remoteNotice('Tony', { ...on, noSoundFrom: ['d'.repeat(32)] }, me, false), '', 'somebody else’s silence is not ours'); });