oficina / meet Public

Calls web: meters on every tile, and say when sound is not getting through

Spec §15.20, the web half. Every camera tile carries a level meter read
from getStats (inbound-rtp for a peer, media-source for our own), five
times a second, never from Web Audio. A peer who says they are unmuted,
whose packets are arriving and whose level has been flat for five
seconds is "No sound from <name>", with an explanation behind an ⓘ; our
own microphone flat while unmuted says so on the self tile. Muted still
wins over everything, and a speaking highlight rides the tile's edge.

A play() refused by policy is now audioBlocked: a page banner ("Click
anywhere to turn on sound") and a click or key anywhere resumes every
refused tile. Both facts go back to the hub in the media report as
audioBlocked and noSoundFrom, only while true, so the ordinary report is
still the two-key object; a 400 for the extras falls back to the two
keys for the life of the page. A peer's report puts "<name>'s browser is
blocking sound" or "<name> isn't getting any sound from you" on their
tile, and says it once through the media live region.

calls.css also carries the holding screen's audio-test rows, which the
next commit wires up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 888b73581a9e40c83d193d3b5c3c61f501bd2ed1 parent c727e43
5 files changed, +1089 −25
  • internal/calls/static/calls.css +133 −0
  • internal/calls/static/room.js +641 −12
  • test/browser/negotiation.test.mjs +3 −1
  • test/browser/room-hearing.test.mjs +195 −0
  • test/browser/room-mute.test.mjs +117 −12
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 782e9a8..40aacc5 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -408,3 +408,136 @@
#controls.calls-controls form { display: flex; }
#controls.calls-controls .calls-toggle-state { font-size: inherit; }
}
+
+/* Spec §15.20: the level meter beside a tile's name. Five bars of
+ rising height, lit from the left as the level rises — a shape, not a
+ colour, so a lit bar is told from an unlit one by its opacity and
+ position as well as its tone. The steps are discrete and there is no
+ transition on them at all: a meter updated five times a second has
+ nothing to animate, and so nothing to turn off for reduced motion. */
+.calls-meter {
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 2px;
+ block-size: 0.9em;
+ flex: none;
+}
+.calls-meter[hidden] { display: none; }
+.calls-meter i {
+ display: block;
+ inline-size: 3px;
+ border-radius: 1px;
+ background: currentColor;
+ opacity: 0.25;
+}
+.calls-meter i:nth-child(1) { block-size: 20%; }
+.calls-meter i:nth-child(2) { block-size: 40%; }
+.calls-meter i:nth-child(3) { block-size: 60%; }
+.calls-meter i:nth-child(4) { block-size: 80%; }
+.calls-meter i:nth-child(5) { block-size: 100%; }
+.calls-meter[data-level='1'] i:nth-child(-n + 1),
+.calls-meter[data-level='2'] i:nth-child(-n + 2),
+.calls-meter[data-level='3'] i:nth-child(-n + 3),
+.calls-meter[data-level='4'] i:nth-child(-n + 4),
+.calls-meter[data-level='5'] i:nth-child(-n + 5) {
+ opacity: 1;
+}
+
+/* What a tile says about its audio when the meter cannot: no sound,
+ sound blocked, a microphone picking nothing up. Words, with the
+ weight as a second cue, in the caption's own pill. The ⓘ is a native
+ disclosure, so it is a keyboard control with no script of its own,
+ and what it opens takes a line of the caption to itself. */
+.calls-tile-hear {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--rst-sp-1);
+ font-weight: 600;
+}
+.calls-tile-hear[hidden],
+.calls-tile-notice[hidden] { display: none; }
+.calls-info summary {
+ cursor: pointer;
+ list-style: none;
+ min-inline-size: 24px;
+ min-block-size: 24px;
+ display: inline-grid;
+ place-items: center;
+ font-weight: 400;
+}
+.calls-info summary::-webkit-details-marker { display: none; }
+.calls-info[open] { flex-basis: 100%; }
+.calls-info p {
+ margin: var(--rst-sp-1) 0 0;
+ font-weight: 400;
+ max-inline-size: 30rem;
+}
+/* A peer's own report about hearing us: their browser blocking sound,
+ or no sound reaching them from us. A line of its own in the caption,
+ because it is about the call and not about the name beside it. */
+.calls-tile-notice {
+ flex-basis: 100%;
+ font-weight: 600;
+}
+
+/* Someone speaking: a ring inside the tile's edge. A second cue only —
+ the meter beside the name is the first — and drawn with an inset
+ shadow so the grid does not move. It fades only for people who have
+ not asked for reduced motion. */
+.calls-tile-speaking {
+ box-shadow: inset 0 0 0 3px var(--rst-accent, currentColor);
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-tile { transition: box-shadow 150ms ease-out; }
+}
+
+/* The page's own "Click anywhere to turn on sound", above the call. */
+.calls-sound-banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-3);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ background: var(--rst-surface-2);
+ color: var(--rst-text);
+}
+.calls-sound-banner[hidden] { display: none; }
+.calls-sound-banner p { margin: 0; font-weight: 600; }
+
+/* The holding screen's audio test (spec §15.20): a disclosure under the
+ device choices, one row per test — the button, its meter, and the
+ sentence that says what it found. The sentence is the result; the
+ meter only shows it happening. */
+.calls-audio-test {
+ margin-block: var(--rst-sp-3);
+}
+.calls-audio-test[hidden] { display: none; }
+.calls-audio-test summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+.calls-audio-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--rst-sp-2) var(--rst-sp-3);
+ margin-block: var(--rst-sp-3);
+}
+.calls-audio-row meter {
+ flex: 1 1 8rem;
+ max-inline-size: 16rem;
+ block-size: 0.75rem;
+}
+.calls-audio-row .calls-raw-help,
+.calls-audio-result {
+ flex-basis: 100%;
+ margin: 0;
+}
+.calls-audio-result {
+ color: var(--rst-text);
+ font-weight: 600;
+}
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 258b39f..5ce6733 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -213,6 +213,34 @@
// squeezed down a pipe built for speech. It is a ceiling and not a
// floor — the encoder still spends only what the link will carry.
const RAW_AUDIO_BITRATE = 128000;
+ // §15.20: the hearing watch. How often the levels are read — five
+ // times a second, which is as fast as a bar is worth moving and slow
+ // enough that one small getStats per connection costs nothing a
+ // person could notice.
+ const LEVEL_EVERY = 200;
+ // An audioLevel below this is silence: -80 dBFS, well under any room's
+ // own noise floor, so a microphone reading it is not hearing the room
+ // at all. Anything at or above it counts as sound.
+ const FLAT_LEVEL = 0.0001;
+ // How long a stream that is arriving must stay that flat before it is
+ // called silent — long enough to cover somebody simply not talking in
+ // a quiet room, short enough to catch "can you hear me?" early.
+ const NO_SOUND_AFTER = 5000;
+ // Packets count as arriving if one came this recently. Wider than a
+ // poll, because a sender using DTX sends a packet every 400 ms or so
+ // through silence, and silence with packets is exactly the case the
+ // watch is for.
+ const ARRIVING_WINDOW = 1500;
+ // The speaking highlight, with hysteresis: on at SPEAK_ON, and off only
+ // after SPEAK_HOLD polls below SPEAK_OFF, so a tile does not flicker
+ // between syllables.
+ const SPEAK_ON = 0.04;
+ const SPEAK_OFF = 0.015;
+ const SPEAK_HOLD = 4;
+ // How many bars a meter has.
+ const METER_STEPS = 5;
+ // §4.3's limit on the list the hub will take.
+ const MAX_NO_SOUND = 15;
// §5.4: the tile key a peer's screen gets, built by shareKey.
const SHARE_SUFFIX = '#share';
// §5.4, sending: "Optimise shared screen for". What a screen gives up
@@ -267,6 +295,28 @@
// The state this tab last put on the broadcast lane, as mediaKey
// writes it, so a toggle that changes nothing sends nothing.
let mediaSent = '';
+ // §15.20: whether the report may carry the two optional keys. A server
+ // from before them answers 400 to anything but the two booleans, and
+ // the first such answer turns them off for the life of the page.
+ let mediaExtras = true;
+ // §15.20: peer id -> what this tab is hearing from that peer, as
+ // pollLevels reads it (hearingRecord). selfHearing is the same record
+ // for our own microphone, read off the sender.
+ const hearing = new Map();
+ const selfHearing = hearingRecord();
+ // Tile keys whose element the browser refused to play. Non-empty is
+ // `audioBlocked`: the page banner shows, and the report says so.
+ const refusedTiles = new Set();
+ let audioBlocked = false;
+ // The autoplay policy as the browser states it in advance, where it
+ // will (Firefox). A hint that puts the banner up early; the refusal
+ // is still the authority, and a play that succeeds takes it down.
+ let autoplayDoubted = autoplayPolicyDoubtful();
+ let soundBanner = null; // {box, text, button} once built
+ let levelTimer = 0;
+ let levelsBusy = false;
+ // The noSoundFrom list as last computed, joined, so a change is seen.
+ let noSoundKey = '';
// §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt`
// and `lastFetchAt` are all milliseconds since the epoch: the server
@@ -2045,6 +2095,10 @@
// rebuilt under a new generation has renewed nothing yet, and a
// record left behind would say it had.
renewals.delete(id);
+ // §15.20: what was heard belongs to a connection too. A rebuilt one
+ // starts its counters again, and a silence timed on the old one is
+ // not a silence on the new.
+ hearing.delete(id);
if (forget) {
stale.delete(id);
gen.delete(id);
@@ -3182,11 +3236,14 @@
// broadcast queues one untargeted message and resolves when the hub
// has answered for it: true if it was accepted, false for every other
// ending, including a deadline that ran out with nothing decided.
- function broadcast(type, payload) {
+ // `onStatus`, if given, hears the status of the answer that settled it
+ // — the one thing the boolean cannot say, and what §15.20's report
+ // needs to tell an old server's 400 from a network that failed.
+ function broadcast(type, payload, onStatus) {
if (!state.peer || state.stopped) return Promise.resolve(false);
return new Promise((resolve) => {
const q = queue.get(BROADCAST) || [];
- q.push({ body: broadcastBody(state.peer, type, payload), resolve });
+ q.push({ body: broadcastBody(state.peer, type, payload), resolve, onStatus });
queue.set(BROADCAST, q);
runBroadcast();
});
@@ -3238,6 +3295,7 @@
return;
}
if (verdict === 'sent' || verdict === 'drop') {
+ if (typeof head.onStatus === 'function') head.onStatus(res.status);
settleBroadcast(q, accepted(verdict));
wait = BACKOFF_MIN;
continue;
@@ -4038,21 +4096,43 @@
// peer that never reports — a client from before this — is unknown,
// and unknown is drawn as neither muted nor camera-off.
- // mediaFrom accepts exactly the two booleans and nothing looser, for a
- // roster entry or a `media` payload alike. Pure, and lifted by name in
+ // mediaFrom accepts the two booleans and nothing looser, for a roster
+ // entry or a `media` payload alike: either is missing or not a
+ // boolean, and the whole report is unknown. §15.20's two optional keys
+ // are read more forgivingly, because they are news about hearing and
+ // not about who is muted: an `audioBlocked` that is not exactly true,
+ // or a `noSoundFrom` entry that is not a peer id, is dropped on its
+ // own and the rest of the report stands. Pure, and lifted by name in
// the node test.
function mediaFrom(value) {
if (!value || typeof value !== 'object') return null;
if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null;
- return { muted: value.muted, cameraOff: value.cameraOff };
+ const out = { muted: value.muted, cameraOff: value.cameraOff };
+ if (value.audioBlocked === true) out.audioBlocked = true;
+ if (Array.isArray(value.noSoundFrom)) {
+ const ids = [];
+ for (const id of value.noSoundFrom) {
+ if (ids.length >= 15) break;
+ if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
+ }
+ if (ids.length) out.noSoundFrom = ids;
+ }
+ return out;
}
function sameMedia(a, b) {
- return !!a && !!b && a.muted === b.muted && a.cameraOff === b.cameraOff;
+ return !!a && !!b && mediaKey(a) === mediaKey(b);
}
+ // mediaKey is one string per distinct report, the optional keys
+ // included, so a change in what this tab can hear is a change that is
+ // sent. The list is sorted here: the same peers in another order are
+ // the same report.
function mediaKey(m) {
- return (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-');
+ let key = (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-');
+ if (m.audioBlocked) key += 'b';
+ if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(',');
+ return key;
}
// localMedia is what this tab is actually sending. No microphone at
@@ -4064,19 +4144,45 @@
return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo };
}
+ // mediaPayload is the report itself: §15.19's two booleans, and
+ // §15.20's two keys only when they carry bad news — so the ordinary
+ // report is still exactly the two-key object every server takes.
+ // `plain` is a server that has refused the extras once. Pure, and
+ // lifted by name in the node test.
+ function mediaPayload(mine, blocked, silentFrom, plain) {
+ const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff };
+ if (plain) return out;
+ if (blocked) out.audioBlocked = true;
+ if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15);
+ return out;
+ }
+
// reportMedia puts our state on the broadcast lane, once per change,
// and only once admitted: before that the hub refuses it, and the
// roster that admits us is where it is sent from. A refusal forgets
// what was sent, so the next toggle or roster tries again — which
// against a server from before §15.19 is a quiet 400 each time.
+ //
+ // A 400 for a report that carried §15.20's keys is a server from
+ // between the two, and the answer is to stop sending them and say the
+ // two booleans at once, for the rest of this page's life: being heard
+ // as muted or not matters more than being heard as unable to hear.
function reportMedia() {
if (!state.admitted || state.stopped || !state.peer) return;
- const now = localMedia();
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras);
const key = mediaKey(now);
if (key === mediaSent) return;
mediaSent = key;
- broadcast('media', now).then((ok) => {
+ const extras = key !== mediaKey(mediaPayload(now, false, null, true));
+ let fallBack = false;
+ broadcast('media', now, (status) => {
+ if (status === 400 && extras && mediaExtras) {
+ mediaExtras = false;
+ fallBack = true;
+ }
+ }).then((ok) => {
if (!ok && mediaSent === key) mediaSent = '';
+ if (fallBack) reportMedia();
});
}
@@ -4091,7 +4197,7 @@
learnMedia(rp.peer, rp.media);
continue;
}
- const now = localMedia();
+ const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras);
mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
}
reportMedia();
@@ -4198,8 +4304,475 @@
t.muted.hidden = !(media && media.muted);
t.off.hidden = !(media && media.cameraOff);
t.figure.classList.toggle('calls-tile-camera-off', !!(media && media.cameraOff));
+ drawHearing(key);
+ }
+
+ // ---------------------------------------------------------------
+ // Hearing (§15.20): what this tab can hear, and what it is sending
+ // ---------------------------------------------------------------
+ //
+ // Written after a call on 2026-09-25 in which one side could not hear
+ // the other and neither could tell why. §15.19 says who is muted; this
+ // says whether sound is actually arriving. Every tile carries a meter
+ // for the audio behind it, read from getStats and never from Web Audio
+ // (an AudioContext made without a gesture starts suspended and reads
+ // nothing), and three things are said in words:
+ //
+ // - a stream that is arriving but has been flat for five seconds
+ // from someone who says they are not muted: "No sound from …",
+ // and the same list goes back to the hub as `noSoundFrom`, so the
+ // person who cannot be heard is told so on their own screen;
+ // - a browser that refused to play: "Sound blocked", a page banner,
+ // and `audioBlocked` in the report, for the same reason;
+ // - our own microphone flat for five seconds while unmuted.
+ //
+ // Everything here is about sound that has already reached a browser.
+ // Nothing about it leaves this tab except the two keys above.
+
+ // hearingRecord is one stream's running state between polls.
+ function hearingRecord() {
+ return {
+ level: 0, // the latest level, 0..1
+ peak: 0, // the loudest level seen, for the gate
+ energy: -1, // totalAudioEnergy at the last poll
+ duration: -1, // totalSamplesDuration at the last poll
+ packets: -1, // packetsReceived at the last poll
+ packetAt: 0, // when packetsReceived last grew
+ sampled: false, // a report has been read at all
+ quietSince: 0, // when the current flat stretch began, or 0
+ silent: false, // flat for NO_SOUND_AFTER while it counted
+ speaking: false,
+ speakHold: 0,
+ notice: '', // the remote notice last drawn, for the live region
+ };
+ }
+
+ // audioSample reads one audio stats report against the record's last
+ // one: the level to draw, whether it was sound, and the counters for
+ // next time. `audioLevel` is the browser's own reading; where it is
+ // missing, the root mean square since the last poll stands in for it,
+ // from totalAudioEnergy over totalSamplesDuration. Sound is either one
+ // at or over FLAT_LEVEL — the energy half is what catches a beep that
+ // fell between two polls. A report carrying neither is `measurable:
+ // false`: a browser that does not say how loud a stream is has not
+ // said it is silent, and the five-second rule must not read it so.
+ // Pure, and lifted by name in the node test.
+ function audioSample(prev, r) {
+ const energy = typeof r.totalAudioEnergy === 'number' ? r.totalAudioEnergy : -1;
+ const duration = typeof r.totalSamplesDuration === 'number' ? r.totalSamplesDuration : -1;
+ const packets = typeof r.packetsReceived === 'number' ? r.packetsReceived : -1;
+ let rms = -1;
+ if (prev && prev.energy >= 0 && energy >= prev.energy && duration > prev.duration && prev.duration >= 0) {
+ rms = Math.sqrt((energy - prev.energy) / (duration - prev.duration));
+ }
+ let level = typeof r.audioLevel === 'number' ? r.audioLevel : rms;
+ if (!(level >= 0)) level = 0;
+ level = Math.min(1, level);
+ return {
+ level,
+ measurable: typeof r.audioLevel === 'number' || energy >= 0,
+ heard: level >= FLAT_LEVEL || rms >= FLAT_LEVEL,
+ energy,
+ duration,
+ packets,
+ grew: !!prev && packets >= 0 && prev.packets >= 0 && packets > prev.packets,
+ };
+ }
+
+ // judgeSilence is the five-second rule. It counts only while the
+ // stream is `eligible` (the sender says it is unmuted, and this tab is
+ // in a position to hear it) and `arriving` (packets are coming in); a
+ // stream that is not arriving is a connection's problem, not a
+ // microphone's, and says nothing here. Any sound ends a flat stretch
+ // at once, which is the hysteresis: on only after five quiet seconds,
+ // off at the first sound. Pure over the record, and lifted by name in
+ // the node test.
+ function judgeSilence(rec, now, eligible, arriving, heard) {
+ if (!eligible || !arriving || heard) {
+ rec.quietSince = 0;
+ rec.silent = false;
+ return false;
+ }
+ if (!rec.quietSince) rec.quietSince = now;
+ rec.silent = now - rec.quietSince >= NO_SOUND_AFTER;
+ return rec.silent;
+ }
+
+ // judgeSpeaking is the tile's highlight: on above SPEAK_ON, off after
+ // SPEAK_HOLD polls under SPEAK_OFF, and held in between. Pure over the
+ // record, and lifted by name in the node test.
+ function judgeSpeaking(rec, level) {
+ if (level >= SPEAK_ON) {
+ rec.speaking = true;
+ rec.speakHold = SPEAK_HOLD;
+ } else if (rec.speaking && level < SPEAK_OFF) {
+ rec.speakHold -= 1;
+ if (rec.speakHold <= 0) rec.speaking = false;
+ }
+ return rec.speaking;
+ }
+
+ // levelStep turns a level into how many of the meter's bars are lit.
+ // Decibels, because that is how loudness is heard: -60 dBFS and under
+ // is one bar, 0 dBFS is all of them, and silence is none — so any
+ // sound at all lights something, and a flat stream is visibly flat.
+ // Pure, and lifted by name in the node test.
+ function levelStep(level) {
+ if (!(level >= FLAT_LEVEL)) return 0;
+ const db = 20 * Math.log10(Math.min(1, level));
+ return Math.max(1, Math.min(METER_STEPS, Math.ceil((db + 60) / 12)));
+ }
+
+ // hearingShown is what a tile says about its audio, in order: the
+ // Muted badge, when the person says so (a meter beside it would only
+ // argue); "Sound blocked", when this browser will not play; "No sound",
+ // when the five-second rule says so; and otherwise the meter. The same
+ // order for our own tile, which is never blocked. Pure, and lifted by
+ // name in the node test.
+ function hearingShown(media, blocked, silent) {
+ if (media && media.muted) return 'muted';
+ if (blocked) return 'blocked';
+ if (silent) return 'silent';
+ return 'meter';
+ }
+
+ // remoteNotice is what a peer's own report says about hearing us: that
+ // their browser is refusing sound, or that they are getting none from
+ // us while we are not muted. Pure, and lifted by name in the node test.
+ function remoteNotice(name, media, me, muted) {
+ if (!media) return '';
+ if (media.audioBlocked) {
+ return name + '’s browser is blocking sound — they need to click to hear you.';
+ }
+ if (!muted && me && Array.isArray(media.noSoundFrom) && media.noSoundFrom.includes(me)) {
+ return name + ' isn’t getting any sound from you.';
+ }
+ return '';
+ }
+
+ // noSoundList is the `noSoundFrom` this tab would report now: every
+ // peer the five-second rule has judged silent, sorted, and never more
+ // than the hub will take.
+ function noSoundList() {
+ const out = [];
+ for (const [id, rec] of hearing) if (rec.silent) out.push(id);
+ return out.sort().slice(0, MAX_NO_SOUND);
+ }
+
+ // The words and the explanation behind the ⓘ, for each thing a tile
+ // can say. The explanations are what someone who has never used this
+ // app needs in order to do something about it.
+ const HEAR_SELF_SILENT = 'Your microphone isn’t picking anything up';
+ const HEAR_SELF_WHY = 'Calls is sending your microphone, but it has been silent for a few seconds, ' +
+ 'so the others hear nothing. Check the microphone chosen under Devices, or on the holding screen, ' +
+ 'and the input your computer’s sound settings are using. A microphone with its own mute ' +
+ 'switch, or an audio interface sending a different input, looks exactly like this.';
+ const HEAR_BLOCKED = 'Sound blocked — click to hear';
+ const HEAR_BLOCKED_WHY = 'This browser will not play sound until you interact with the page. ' +
+ 'Click anywhere, or press any key, and the call’s sound comes on.';
+ const HEAR_SILENT_WHY = 'Their audio is reaching this browser, but it has been silent for a few ' +
+ 'seconds while they are not muted. Their microphone may not be picking anything up: the wrong ' +
+ 'input chosen, a microphone muted on the device itself, or an audio interface sending a ' +
+ 'different input. Calls lets them know.';
+
+ // hearingMarks builds one tile's hearing parts: the meter beside the
+ // name, the sentence with its ⓘ, and the line for a peer's notice.
+ // The meter is aria-hidden — a level moving five times a second is
+ // noise to a screen reader — and everything it means that matters is
+ // said in the sentence instead.
+ function hearingMarks() {
+ const meter = document.createElement('span');
+ meter.className = 'calls-meter';
+ meter.setAttribute('aria-hidden', 'true');
+ meter.dataset.level = '0';
+ meter.hidden = true;
+ for (let i = 0; i < METER_STEPS; i++) meter.append(document.createElement('i'));
+ const box = document.createElement('div');
+ box.className = 'calls-tile-hear';
+ box.hidden = true;
+ const text = document.createElement('span');
+ const info = document.createElement('details');
+ info.className = 'calls-info';
+ const summary = document.createElement('summary');
+ summary.setAttribute('aria-label', 'What this means');
+ summary.textContent = 'ⓘ';
+ const why = document.createElement('p');
+ info.append(summary, why);
+ box.append(text, info);
+ const notice = document.createElement('span');
+ notice.className = 'calls-tile-notice';
+ notice.hidden = true;
+ return { meter, box, text, why, notice, kind: '' };
+ }
+
+ // drawHearing brings one tile's hearing parts up to date. Called from
+ // markTile and from every level poll, so it writes nothing that has
+ // not changed: a details element someone has opened stays open, and
+ // the live region hears a notice once.
+ function drawHearing(key) {
+ const t = tiles.get(key);
+ if (!t || !t.hear) return;
+ const h = t.hear;
+ const self = key === 'self';
+ const rec = self ? selfHearing : hearing.get(key) || null;
+ const media = self ? localMedia() : remoteMedia.get(key) || null;
+ const blocked = !self && refusedTiles.has(key);
+ const shown = hearingShown(media, blocked, !!(rec && rec.silent));
+ t.hearShown = shown;
+ h.meter.hidden = shown !== 'meter';
+ const step = String(shown === 'meter' && rec ? levelStep(rec.level) : 0);
+ if (h.meter.dataset.level !== step) h.meter.dataset.level = step;
+ t.figure.classList.toggle('calls-tile-speaking', shown === 'meter' && !!(rec && rec.speaking));
+ const name = self ? '' : names.get(key) || 'Someone';
+ let kind = '';
+ let text = '';
+ let why = '';
+ if (shown === 'blocked') {
+ kind = 'blocked';
+ text = HEAR_BLOCKED;
+ why = HEAR_BLOCKED_WHY;
+ } else if (shown === 'silent') {
+ kind = self ? 'self' : 'silent:' + name;
+ text = self ? HEAR_SELF_SILENT : 'No sound from ' + name;
+ why = self ? HEAR_SELF_WHY : HEAR_SILENT_WHY;
+ }
+ if (h.kind !== kind) {
+ h.kind = kind;
+ h.text.textContent = text;
+ h.why.textContent = why;
+ h.box.hidden = !kind;
+ }
+ if (self) return;
+ const notice = remoteNotice(name, media, state.peer, localMedia().muted);
+ if (h.notice.textContent !== notice) {
+ h.notice.textContent = notice;
+ h.notice.hidden = !notice;
+ }
+ if (rec && rec.notice !== notice) {
+ rec.notice = notice;
+ announceMedia(notice);
+ }
+ }
+
+ // audioPart finds a connection's microphone sender or receiver — the
+ // camera m-line's audio, which is the only audio a connection carries
+ // (§5.4's screen has none).
+ function audioPart(pc, which) {
+ try {
+ return pc[which]().find((x) => x.track && x.track.kind === 'audio') || null;
+ } catch {
+ return null;
+ }
+ }
+
+ // audioReport is the one report of `type` in a sender's or receiver's
+ // own stats, which are a few objects rather than the connection's
+ // whole walk.
+ async function audioReport(part, type) {
+ const stats = await part.getStats();
+ let found = null;
+ stats.forEach((r) => {
+ if (!found && r.type === type && (r.kind === 'audio' || r.mediaType === 'audio')) found = r;
+ });
+ return found;
+ }
+
+ // sampleInto takes one report into one record, and returns the sample.
+ function sampleInto(rec, report, now) {
+ const s = audioSample(rec.sampled ? rec : null, report);
+ rec.sampled = true;
+ rec.level = s.level;
+ rec.peak = Math.max(rec.peak, s.level);
+ rec.energy = s.energy;
+ rec.duration = s.duration;
+ rec.packets = s.packets;
+ if (s.grew) rec.packetAt = now;
+ judgeSpeaking(rec, s.level);
+ return s;
+ }
+
+ // pollLevels is the hearing watch: one receiver's stats per connection,
+ // and one sender's for our own microphone, five times a second. Never
+ // two at once — a getStats that takes longer than the interval would
+ // otherwise stack.
+ async function pollLevels() {
+ if (levelsBusy || state.stopped) return;
+ levelsBusy = true;
+ try {
+ const now = Date.now();
+ let selfReport = null;
+ for (const [id, entry] of pcs) {
+ let rec = hearing.get(id);
+ if (!rec) {
+ rec = hearingRecord();
+ hearing.set(id, rec);
+ }
+ const receiver = audioPart(entry.pc, 'getReceivers');
+ let report = null;
+ try {
+ report = receiver ? await audioReport(receiver, 'inbound-rtp') : null;
+ } catch {
+ // A closing connection: nothing heard.
+ }
+ if (!report) {
+ rec.level = 0;
+ judgeSilence(rec, now, false, false, false);
+ } else {
+ const s = sampleInto(rec, report, now);
+ const t = tiles.get(id);
+ const media = remoteMedia.get(id) || null;
+ // Eligible: the peer has said it is unmuted, and this tab is
+ // playing it. A paused element pulls no audio through the
+ // decoder, so every level reads flat, and that is this
+ // browser's silence and not the sender's.
+ const eligible = !!(s.measurable && media && !media.muted && t && t.video && !t.video.paused &&
+ !refusedTiles.has(id) && entry.pc.connectionState === 'connected');
+ judgeSilence(rec, now, eligible, now - rec.packetAt < ARRIVING_WINDOW, s.heard);
+ }
+ if (!selfReport) {
+ const sender = audioPart(entry.pc, 'getSenders');
+ try {
+ selfReport = sender ? await audioReport(sender, 'media-source') : null;
+ } catch {
+ selfReport = null;
+ }
+ }
+ }
+ const track = local && local.getAudioTracks()[0];
+ if (selfReport && track && track.readyState === 'live') {
+ const s = sampleInto(selfHearing, selfReport, now);
+ judgeSilence(selfHearing, now, s.measurable && !localMedia().muted, true, s.heard);
+ } else {
+ selfHearing.level = 0;
+ selfHearing.speaking = false;
+ judgeSilence(selfHearing, now, false, false, false);
+ }
+ const key = noSoundList().join(',');
+ if (key !== noSoundKey) {
+ noSoundKey = key;
+ reportMedia();
+ }
+ for (const k of tiles.keys()) drawHearing(k);
+ } finally {
+ levelsBusy = false;
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Sound the browser refused to play (§15.20)
+ // ---------------------------------------------------------------
+ //
+ // Chromium plays a MediaStream without a gesture whatever its autoplay
+ // policy says, but Safari, Firefox and a site set to "Block" in Edge
+ // may not. Each refused tile already offers its own Play; this is the
+ // page-level half, because a first-time visitor looking at a paused
+ // face does not know that the silence and the picture are the same
+ // problem. Any click or key anywhere is the gesture, and it resumes
+ // every tile at once — shouldPlay's rule still holds, because this is
+ // the person's gesture and not a retry behind their back.
+
+ // autoplayPolicyDoubtful asks the browser in advance, where it will
+ // say (Firefox's getAutoplayPolicy). Only a hint: the refusal itself
+ // is what decides.
+ function autoplayPolicyDoubtful() {
+ try {
+ const policy = typeof navigator !== 'undefined' && typeof navigator.getAutoplayPolicy === 'function'
+ ? navigator.getAutoplayPolicy('mediaelement')
+ : '';
+ return policy === 'disallowed' || policy === 'allowed-muted';
+ } catch {
+ return false;
+ }
}
+ // soundRefused records one tile's refusal, or its end, and puts the
+ // consequences everywhere they go: the banner, the report, the tile.
+ function soundRefused(key, refused) {
+ const had = refusedTiles.has(key);
+ if (refused) refusedTiles.add(key);
+ else refusedTiles.delete(key);
+ if (had === refused) return;
+ const blocked = refusedTiles.size > 0;
+ if (blocked !== audioBlocked) {
+ audioBlocked = blocked;
+ reportMedia();
+ }
+ drawBanner();
+ drawHearing(key);
+ }
+
+ function bannerShown() {
+ if (audioBlocked) return true;
+ if (!autoplayDoubted) return false;
+ for (const t of tiles.values()) if (!t.self && t.stream) return true;
+ return false;
+ }
+
+ // soundBannerEl builds the banner now, empty and hidden, for the reason
+ // warningEl gives: a live region inserted along with its text is one
+ // nobody was watching.
+ function soundBannerEl() {
+ if (soundBanner) return soundBanner;
+ const box = document.createElement('div');
+ box.id = 'sound-banner';
+ box.className = 'calls-sound-banner';
+ box.hidden = true;
+ const text = document.createElement('p');
+ text.setAttribute('role', 'status');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('rst-btn', 'primary');
+ button.textContent = 'Turn on sound';
+ // The click reaches the page-wide listener below as well; the button
+ // is here for a keyboard, which needs something to land on.
+ box.append(text, button);
+ root.prepend(box);
+ soundBanner = { box, text, button };
+ return soundBanner;
+ }
+
+ function drawBanner() {
+ const b = soundBannerEl();
+ const show = bannerShown() && !state.stopped;
+ if (show === !b.box.hidden) return;
+ if (show) {
+ b.box.hidden = false;
+ b.text.textContent = 'Click anywhere to turn on sound';
+ return;
+ }
+ b.text.textContent = '';
+ b.box.hidden = true;
+ }
+
+ // resumeSound is the gesture's work: every remote tile that is not
+ // playing is asked again, and the answers decide whether the banner
+ // stays.
+ function resumeSound() {
+ autoplayDoubted = false;
+ for (const [key, t] of tiles) {
+ if (t.self || !t.stream || !t.video) continue;
+ if (!t.video.paused && !refusedTiles.has(key)) continue;
+ if (t.play) t.play.hidden = true;
+ startPlaying(key, t);
+ }
+ drawBanner();
+ }
+
+ // A click or a key anywhere, while the banner is up. Capture, so a
+ // control that stops propagation still counts as the gesture it was.
+ // Space would also toggle the microphone below, and turning on sound
+ // is what that first key was for — so that one press is kept from it.
+ function onGesture(ev) {
+ if (!bannerShown()) return;
+ if (ev.type === 'keydown' && spaceKeyAction(ev, ev.target) === 'toggle') {
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ }
+ resumeSound();
+ }
+ document.addEventListener('click', onGesture, true);
+ document.addEventListener('keydown', onGesture, true);
+
// ---------------------------------------------------------------
// Terminal states (§4.5, §4.2)
// ---------------------------------------------------------------
@@ -4234,6 +4807,7 @@
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
clearInterval(statsTimer);
+ clearInterval(levelTimer);
stopIceTimers();
closeStream();
closeAllPCs();
@@ -4244,6 +4818,7 @@
stopSharing();
remoteMedia.clear();
renderTiles();
+ drawBanner();
iceWarning(false);
controlsEnabled(false);
// §6: nothing can be sent from here, so the composer says so. The
@@ -4349,6 +4924,8 @@
if (!wanted.includes(key)) {
tiles.get(key).figure.remove();
tiles.delete(key);
+ // A tile that has gone cannot be blocked any more.
+ soundRefused(key, false);
}
}
if (local) {
@@ -4443,9 +5020,15 @@
// §15.19's two marks, hidden until markTile says otherwise. A
// screen has neither: it has no microphone, and it is never "off".
const marks = key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX ? null : tileMarks();
+ // §15.20's hearing parts go with them, on the same tiles and for
+ // the same reason: a screen carries no audio to meter.
+ const hear = marks ? hearingMarks() : null;
if (marks) {
nameEl.after(marks.muted);
figure.append(marks.off);
+ marks.muted.after(hear.meter);
+ // After Play, so the caption's buttons keep their order.
+ label.append(hear.box, hear.notice);
}
tilesEl.append(figure);
// `self` is kept on the record because rearmFrames reads it: the
@@ -4454,6 +5037,7 @@
t = {
figure, video, label, nameEl, note, retry, play,
muted: marks && marks.muted, off: marks && marks.off,
+ hear, hearShown: '',
remove: null, stream: null, track: null, self: isSelf,
// What judgeRender remembers between polls: the element's own
// clock, the decoder's count, and how long the two have
@@ -4511,14 +5095,20 @@
// startPlaying asks the element to play and, if the browser refuses
// (an autoplay policy that wants a gesture first), offers the gesture
- // rather than leaving a still frame nobody can explain.
+ // rather than leaving a still frame nobody can explain. A refusal by
+ // policy — NotAllowedError, and nothing else — is also §15.20's
+ // `audioBlocked`: the page banner, and the report that tells the
+ // people this tab cannot hear.
function startPlaying(key, t) {
const attempt = t.video.play();
if (!attempt || typeof attempt.catch !== 'function') return;
attempt.then(() => {
if (t.play) t.play.hidden = true;
- }).catch(() => {
+ if (!t.self) autoplayDoubted = false;
+ soundRefused(key, false);
+ }).catch((err) => {
if (t.play) t.play.hidden = false;
+ if (err && err.name === 'NotAllowedError' && tiles.get(key) === t) soundRefused(key, true);
});
}
@@ -4825,6 +5415,7 @@
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
clearInterval(statsTimer);
+ clearInterval(levelTimer);
stopIceTimers();
if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, { keepalive: true });
closeStream();
@@ -5314,6 +5905,25 @@
}
}
+ function hearingState() {
+ const out = {};
+ for (const [key, t] of tiles) {
+ if (!t.hear) continue;
+ const rec = key === 'self' ? selfHearing : hearing.get(key) || null;
+ out[key] = {
+ shown: t.hearShown,
+ level: rec ? rec.level : 0,
+ peak: rec ? rec.peak : 0,
+ bars: Number(t.hear.meter.dataset.level) || 0,
+ silent: !!(rec && rec.silent),
+ speaking: t.figure.classList.contains('calls-tile-speaking'),
+ text: t.hear.box.hidden ? '' : t.hear.text.textContent,
+ notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent,
+ };
+ }
+ return out;
+ }
+
function candidateState() {
const out = {};
for (const [id, entry] of pcs) {
@@ -5503,6 +6113,17 @@
// peer id. Peers that never reported are absent.
media: localMedia(),
remoteMedia: Object.fromEntries(remoteMedia),
+ // §15.20, by tile key ('self' for ours): what the tile is saying
+ // about its audio ('muted', 'blocked', 'silent' or 'meter'), the
+ // level as last read and the loudest seen, the meter's bars, the
+ // speaking highlight, and the notice a peer's own report put
+ // there. Levels are numbers between 0 and 1 and say nothing
+ // about what was said.
+ hearing: hearingState(),
+ audioBlocked,
+ soundBanner: !!(soundBanner && !soundBanner.box.hidden),
+ noSoundFrom: noSoundList(),
+ mediaExtras,
};
},
};
@@ -5519,6 +6140,7 @@
// inserted along with its own text is a region the reader was not
// watching, and the change goes unannounced.
warningEl();
+ soundBannerEl();
renderStatus();
renderQueue();
await startLocal();
@@ -5528,6 +6150,13 @@
// camera that stopped encoding (Codex S4-5), so the walk runs
// either way and does whichever halves apply.
statsTimer = setInterval(pollFrames, 1000);
+ // §15.20's meters, five times a second and cheaper per turn: one
+ // receiver's and one sender's own stats, not the connection's walk.
+ levelTimer = setInterval(() => {
+ pollLevels().catch((err) => {
+ console.error('meet: levels', err);
+ });
+ }, LEVEL_EVERY);
connectStream();
}
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index af952ba..4b3d1bc 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -617,6 +617,8 @@ const reconciler = new Function(
'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX',
// §15.19's roster half is its own function, gated in room-mute.test.mjs.
'rosterMedia',
+ // §15.20: closePC drops what was heard on the connection it closes.
+ 'hearing',
// shareKey comes in as source rather than as a stub: closePC forgets a
// peer's share tile through it (§5.4), and a stub would be testing the
// stub.
@@ -633,7 +635,7 @@ function reconcileOver(pcs, seqOut, lastIn, frames) {
frames || new Map(),
new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {},
0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share',
- () => {},
+ () => {}, new Map(),
);
return { reconcile, closePC, built, chat };
}
diff --git a/test/browser/room-hearing.test.mjs b/test/browser/room-hearing.test.mjs
new file mode 100644
index 0000000..fe0f7b0
--- /dev/null
+++ b/test/browser/room-hearing.test.mjs
@@ -0,0 +1,195 @@
+// 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
+// judgeSpeaking the speaking highlight's hysteresis
+// levelStep a level to the meter's bars
+// hearingShown muted, then blocked, then no sound, then the meter
+// 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 = <number>;` 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', 'METER_STEPS']
+ .map(liftConst).join('\n');
+const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD, METER_STEPS };')();
+
+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 judgeSpeaking = build('judgeSpeaking');
+const levelStep = build('levelStep');
+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('levelStep: silence is no bars, any sound at least one, full scale all', () => {
+ assert.equal(levelStep(0), 0);
+ assert.equal(levelStep(0.00005), 0);
+ assert.equal(levelStep(undefined), 0);
+ assert.equal(levelStep(NaN), 0);
+ assert.equal(levelStep(0.0001), 1, '-80 dBFS is sound, and shows');
+ assert.equal(levelStep(0.001), 1, '-60 dBFS');
+ assert.equal(levelStep(0.01), 2, '-40 dBFS');
+ assert.equal(levelStep(0.1), 4, '-20 dBFS');
+ assert.equal(levelStep(1), consts.METER_STEPS);
+ assert.equal(levelStep(5), consts.METER_STEPS, 'never more bars than the meter has');
+ let last = 0;
+ for (let l = 0.0001; l <= 1; l *= 1.5) {
+ const s = levelStep(l);
+ assert.ok(s >= last, 'louder is never fewer bars');
+ last = s;
+ }
+});
+
+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('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');
+});
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs
index 927b0ea..ed51af4 100644
--- a/test/browser/room-mute.test.mjs
+++ b/test/browser/room-mute.test.mjs
@@ -9,9 +9,11 @@
//
// mediaFrom what counts as a report: exactly two booleans, from a
// roster entry or a `media` payload, and nothing looser —
-// an absent or malformed one is unknown, never "on"
+// an absent or malformed one is unknown, never "on" —
+// plus §15.20's two optional keys, read forgivingly
// sameMedia whether the hub's word on us already matches ours
// mediaKey the dedupe key a toggle is compared on
+// mediaPayload the report: two keys, and §15.20's only with bad news
// mediaChange the sentence the live region says, and when it says
// nothing at all
// rosterMedia with reportMedia: when our own report goes — on the
@@ -39,14 +41,19 @@ function lift(source, name) {
assert.fail('unbalanced braces reading ' + name + ' out of room.js');
}
-const build = (name) =>
+// build lifts one function, and any it calls that are named after it.
+const build = (name, ...with_) =>
// eslint-disable-next-line no-new-func
- new Function(lift(src, name) + '; return ' + name + ';')();
+ new Function([name, ...with_].map((n) => lift(src, n)).join('\n') + '; return ' + name + ';')();
const mediaFrom = build('mediaFrom');
-const sameMedia = build('sameMedia');
+const sameMedia = build('sameMedia', 'mediaKey');
const mediaKey = build('mediaKey');
const mediaChange = build('mediaChange');
+const mediaPayload = build('mediaPayload');
+
+const P1 = 'a'.repeat(32);
+const P2 = 'b'.repeat(32);
test('mediaFrom takes exactly two booleans and nothing looser', () => {
assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false });
@@ -62,6 +69,47 @@ test('mediaFrom takes exactly two booleans and nothing looser', () => {
}
});
+test('mediaFrom reads §15.20’s optional keys, and drops a bad one on its own', () => {
+ assert.deepEqual(
+ mediaFrom({ muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] }),
+ { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] });
+ // A malformed optional key costs only itself: the booleans still stand.
+ for (const [bad, want] of [
+ [{ audioBlocked: 'yes' }, {}],
+ [{ audioBlocked: false }, {}],
+ [{ noSoundFrom: 'x' }, {}],
+ [{ noSoundFrom: [] }, {}],
+ [{ noSoundFrom: [P1, 'nope', 7, P1.toUpperCase(), P1] }, { noSoundFrom: [P1] }],
+ ]) {
+ assert.deepEqual(mediaFrom({ muted: true, cameraOff: false, ...bad }),
+ { muted: true, cameraOff: false, ...want }, JSON.stringify(bad));
+ }
+ // Never more than the hub's fifteen.
+ const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(32, '0'));
+ assert.equal(mediaFrom({ muted: false, cameraOff: false, noSoundFrom: many }).noSoundFrom.length, 15);
+ // And the required pair is still required.
+ assert.equal(mediaFrom({ muted: 'no', cameraOff: false, audioBlocked: true }), null);
+});
+
+test('sameMedia and mediaKey see a change in hearing as a change', () => {
+ const on = { muted: false, cameraOff: false };
+ assert.equal(sameMedia(on, { ...on, audioBlocked: true }), false);
+ assert.equal(sameMedia(on, { ...on, noSoundFrom: [P1] }), false);
+ assert.equal(sameMedia({ ...on, noSoundFrom: [P1, P2] }, { ...on, noSoundFrom: [P2, P1] }), true,
+ 'the same peers in another order are the same report');
+ assert.notEqual(mediaKey({ ...on, noSoundFrom: [P1] }), mediaKey({ ...on, noSoundFrom: [P2] }));
+});
+
+test('mediaPayload is the two-key object unless there is bad news', () => {
+ const mine = { muted: false, cameraOff: true };
+ assert.deepEqual(mediaPayload(mine, false, [], false), { muted: false, cameraOff: true });
+ assert.deepEqual(Object.keys(mediaPayload(mine, false, null, false)), ['muted', 'cameraOff']);
+ assert.deepEqual(mediaPayload(mine, true, [P1], false),
+ { muted: false, cameraOff: true, audioBlocked: true, noSoundFrom: [P1] });
+ // A server that refused the extras once gets the two keys for good.
+ assert.deepEqual(mediaPayload(mine, true, [P1], true), { muted: false, cameraOff: true });
+});
+
test('sameMedia never matches unknown', () => {
const on = { muted: false, cameraOff: false };
assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true);
@@ -106,29 +154,39 @@ test('the dispatch routes media, and the report rides the broadcast lane', () =>
// above is dead without this, and the roster path hides it (the
// browser gate caught exactly that).
assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/);
- assert.match(src, /broadcast\('media', now\)/);
+ assert.match(src, /broadcast\('media', now,/);
});
// rosterMedia and reportMedia, lifted together over real Maps with only
// the wire and the local stream stubbed: the decision under test is when
// a report goes, and that is the pair of them.
-function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true } = {}) {
+function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true, status = 200 } = {}) {
const sent = [];
- const env = { local, answer };
+ const env = { local, answer, status, blocked: false, noSound: [] };
const made = new Function(
- 'state', 'remoteMedia', 'localMedia', 'broadcast',
- ['mediaFrom', 'sameMedia', 'mediaKey', 'learnMedia', 'reportMedia', 'rosterMedia']
+ 'state', 'remoteMedia', 'localMedia', 'broadcast', 'env',
+ ['mediaFrom', 'sameMedia', 'mediaKey', 'mediaPayload', 'learnMedia', 'reportMedia', 'rosterMedia']
.map((n) => lift(src, n)).join('\n') +
'\nlet mediaSent = "";' +
- '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent };',
+ '\nlet mediaExtras = true;' +
+ '\nlet audioBlocked = false;' +
+ '\nconst noSoundList = () => env.noSound;' +
+ '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' +
+ '\n block: (on) => { audioBlocked = on; } };',
)(
{ admitted, stopped: false, peer: 'me' },
new Map(),
() => env.local,
- (type, payload) => {
+ (type, payload, onStatus) => {
sent.push([type, payload]);
- return Promise.resolve(env.answer);
+ // The status of the answer goes to onStatus first, as pumpBroadcast
+ // does it, and then the boolean settles the promise.
+ const extra = Object.keys(payload).length > 2;
+ const status = extra ? env.status : (env.answer ? 200 : 400);
+ if (onStatus) onStatus(status);
+ return Promise.resolve(status === 200);
},
+ env,
);
return { ...made, sent, env };
}
@@ -172,3 +230,50 @@ test('a refused report is forgotten, so the next chance tries again', async () =
r.reportMedia();
assert.equal(r.sent.length, 2);
});
+
+test('bad news about hearing is reported, and its end too', () => {
+ const r = reporter();
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] });
+ assert.equal(r.sent.length, 0);
+ r.env.noSound = [P1];
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, noSoundFrom: [P1] });
+ r.block(true);
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1] });
+ r.reportMedia();
+ assert.equal(r.sent.length, 2, 'nothing changed, nothing sent');
+ r.block(false);
+ r.env.noSound = [];
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false });
+ assert.deepEqual(Object.keys(r.sent.at(-1)[1]), ['muted', 'cameraOff'],
+ 'the ordinary report is exactly the two keys an old server takes');
+});
+
+test('a roster carrying our hearing as we would report it costs nothing', () => {
+ const r = reporter();
+ r.env.noSound = [P1];
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, noSoundFrom: [P1] } }] });
+ assert.equal(r.sent.length, 0);
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] });
+ assert.equal(r.sent.length, 1, 'a roster that lost the list is told again');
+});
+
+test('a 400 for the extras falls back to the two keys, at once and for good', async () => {
+ const r = reporter({ status: 400 });
+ r.env.noSound = [P1];
+ r.reportMedia();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(r.extras(), false);
+ assert.equal(r.sent.length, 2);
+ assert.deepEqual(r.sent[1][1], { muted: false, cameraOff: false },
+ 'the fallback goes straight away, without waiting for the next change');
+ r.block(true);
+ r.env.noSound = [P1, P2];
+ r.reportMedia();
+ assert.equal(r.sent.length, 2, 'with the extras off, a change in hearing alone is no change');
+ r.env.local = { muted: true, cameraOff: false };
+ r.reportMedia();
+ assert.deepEqual(r.sent.at(-1)[1], { muted: true, cameraOff: false });
+});