oficina / meet Public

Web: speaking glow on the name label, glass control bar, no hairlines

The ring round the tile and the meter beside the name give way to a
teal glow on the name label: judgeGlow (the gate: speaking, and loud for
150 ms), glowIntensity (-42..-12 dBFS over a 0.3 floor) and glowStyle
(--glow, and a 50 ms attack or 300 ms release as the transition on a
registered --glow; one steady 0.6 under reduced motion). The control
bar is one glass pane (backdrop-filter, a near-opaque fallback where it
is missing) with borderless buttons and capsules only for a state.

room-glow.test.mjs holds the mapping and runs in make browser; the
meters scenario now drives a steady tone and checks the glow lights on
both tiles, goes out, and that no ring or meter is drawn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a73fa04c2cd054336f81c0e06329f160bd6056f4 parent dcb1ba7
6 files changed, +444 −124
  • Makefile +2 −1
  • internal/calls/static/calls.css +96 −46
  • internal/calls/static/room.js +94 −34
  • test/browser/call.mjs +98 −19
  • test/browser/room-glow.test.mjs +149 −0
  • test/browser/room-hearing.test.mjs +5 −24
diff --git a/Makefile b/Makefile
index eb72cda..eb52588 100644
--- a/Makefile
+++ b/Makefile
@@ -130,7 +130,7 @@ ci: vet fmt-check test migration-check
# -count=1 because call.mjs is not an input to Go's test cache: node
# opens it, not the test, so an edited browser script would otherwise be
# answered with a cached pass from before the edit.
-# The five .test.mjs files run first because they need no browser at
+# The .test.mjs files run first because they need no browser at
# all: they lift functions out of room.js and chat.js by name — pure
# ones for their answers, whole loops for what they do — and check
# §5.1's glare decision against the W3C truth table; §6's DOM safety,
@@ -150,6 +150,7 @@ browser:
node --test test/browser/room-mute.test.mjs
node --test test/browser/room-space.test.mjs
node --test test/browser/room-hearing.test.mjs
+ node --test test/browser/room-glow.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 40aacc5..4b0960a 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -409,41 +409,7 @@
#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,
+/* What a tile says about its audio when the glow 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,
@@ -481,17 +447,6 @@
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;
@@ -541,3 +496,98 @@
color: var(--rst-text);
font-weight: 600;
}
+
+/* 0.3.7: one glass language over the picture, as the native apps have
+ it, and no hairline edges anywhere. A surface is black at 62 % over a
+ blurred, saturated backdrop; the black does the work, so white on it
+ is 6.2:1 even over a white wall and where backdrop-filter is missing,
+ and there a near-opaque black stands in for the blur. */
+.calls-tile-name,
+#controls.calls-controls {
+ background: rgb(0 0 0 / 0.62);
+ color: #fff;
+ -webkit-backdrop-filter: blur(18px) saturate(1.6);
+ backdrop-filter: blur(18px) saturate(1.6);
+}
+@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
+ .calls-tile-name,
+ #controls.calls-controls { background: rgb(18 18 18 / 0.92); }
+}
+.calls-tile-name { border-radius: 999px; }
+.calls-tile-name:has(.calls-tile-hear:not([hidden]), .calls-tile-notice:not([hidden])) {
+ border-radius: 14px;
+}
+.calls-tile-name button,
+.calls-tile-name a {
+ color: #fff;
+ border: 0;
+ background: rgb(255 255 255 / 0.14);
+}
+
+/* The speaking glow behind the name, which replaced the ring round the
+ tile and the meter beside the name. room.js sets --glow (0..1, how
+ loudly they are speaking, 0 when they are not) and --glow-t, the
+ transition's duration: quick rising, slower falling, so it breathes
+ with the voice. A box-shadow, so nothing moves when it lights; the
+ registered property is what lets the number itself be transitioned,
+ and a browser without @property steps five times a second instead.
+ The teal is the accent's hue lit up (#2ECCDB). With reduced motion,
+ room.js holds one steady value and nothing is transitioned. */
+@property --glow {
+ syntax: '<number>';
+ inherits: true;
+ initial-value: 0;
+}
+.calls-tile-name {
+ --glow: 0;
+ box-shadow:
+ 0 0 calc(4px + 16px * var(--glow)) calc(1px + 4px * var(--glow)) rgb(46 204 219 / calc(0.9 * var(--glow))),
+ 0 0 0 calc(1.5px * var(--glow)) rgb(46 204 219 / calc(0.5 * var(--glow)));
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-tile-name { transition: --glow var(--glow-t, 300ms) ease-out; }
+}
+
+/* The room's control bar: one glass pane the buttons sit on, and the
+ buttons borderless on it — white glyph and word, a light wash under
+ the pointer, and a filled capsule only for a state: microphone or
+ camera off in amber (the slashed word says it too), sharing or a
+ panel open in near-white with dark text, Leave in red. The state is
+ also the word in .calls-toggle-state and aria-pressed, so never
+ colour alone. estilo's ring for [aria-pressed] is replaced by the
+ capsule. */
+#controls.calls-controls {
+ padding: 8px;
+ border-radius: 28px;
+ gap: 4px;
+}
+#controls.calls-controls button {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ color: #fff;
+ border-radius: 999px;
+}
+#controls.calls-controls button:hover { background: rgb(255 255 255 / 0.1); }
+#controls.calls-controls button:active { background: rgb(255 255 255 / 0.18); }
+#controls.calls-controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+#controls.calls-controls #mute[aria-pressed='true'],
+#controls.calls-controls #camera[aria-pressed='true'] {
+ background: #a64d05;
+ color: #fff;
+}
+#controls.calls-controls #share[aria-pressed='true'],
+#controls.calls-controls [aria-expanded='true'] {
+ background: #f0f0f0;
+ color: #1a1a1a;
+}
+#controls.calls-controls #leave,
+#controls.calls-controls #leave:hover {
+ background: #d62924;
+ color: #fff;
+}
+#controls.calls-controls #leave:active { background: #b3201c; }
+
+.calls-tile-name .calls-tile-note { color: #fff; }
+/* No hairline round a tile either: the picture's own edge is the edge. */
+.calls-tile { border: 0; }
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 5ce6733..5dc5ab5 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -237,8 +237,21 @@
const SPEAK_ON = 0.04;
const SPEAK_OFF = 0.015;
const SPEAK_HOLD = 4;
- // How many bars a meter has.
- const METER_STEPS = 5;
+ // The speaking glow behind a tile's name, which replaced the ring round
+ // the tile and the meter beside the name (0.3.7), as the native apps
+ // have it (SpeakingGlow.swift): a reading's brightness by decibels
+ // between GLOW_QUIET_DB and GLOW_LOUD_DB, never under GLOW_FLOOR while
+ // lit; lit only once sound at SPEAK_ON has kept up for GLOW_ONSET ms, so
+ // a click never lights it; rising over GLOW_ATTACK ms and falling over
+ // GLOW_RELEASE ms, which is a CSS transition on --glow; and one steady
+ // GLOW_STEADY for anyone who has asked for reduced motion.
+ const GLOW_QUIET_DB = -42;
+ const GLOW_LOUD_DB = -12;
+ const GLOW_FLOOR = 0.3;
+ const GLOW_ONSET = 150;
+ const GLOW_ATTACK = 50;
+ const GLOW_RELEASE = 300;
+ const GLOW_STEADY = 0.6;
// §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.
@@ -4343,6 +4356,10 @@
silent: false, // flat for NO_SOUND_AFTER while it counted
speaking: false,
speakHold: 0,
+ glowLit: false, // the speaking glow is lit (judgeGlow)
+ glow: 0, // where its brightness is heading, 0..1
+ loudFrom: 0, // the first and latest loud readings since
+ loudLast: 0, // speaking began, or 0
notice: '', // the remote notice last drawn, for the live region
};
}
@@ -4412,21 +4429,54 @@
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;
+ // glowIntensity is a level's brightness for the glow, 0..1: decibels,
+ // because that is how loudness is heard, from nothing at GLOW_QUIET_DB
+ // to all of it at GLOW_LOUD_DB. Pure, and lifted by name in the node
+ // test.
+ function glowIntensity(level) {
+ if (!(level > 0)) return 0;
const db = 20 * Math.log10(Math.min(1, level));
- return Math.max(1, Math.min(METER_STEPS, Math.ceil((db + 60) / 12)));
+ return Math.max(0, Math.min(1, (db - GLOW_QUIET_DB) / (GLOW_LOUD_DB - GLOW_QUIET_DB)));
+ }
+
+ // judgeGlow is the glow's gate and target, taken after judgeSpeaking
+ // on the same reading: lit while speaking once loud readings have kept
+ // up for GLOW_ONSET ms, and then as bright as the reading, over the
+ // floor. Returns the target brightness. Pure over the record, and
+ // lifted by name in the node test.
+ function judgeGlow(rec, level, now) {
+ if (!rec.speaking) {
+ rec.loudFrom = 0;
+ rec.loudLast = 0;
+ rec.glowLit = false;
+ } else if (level >= SPEAK_ON) {
+ if (!rec.loudFrom) rec.loudFrom = now;
+ rec.loudLast = now;
+ }
+ if (rec.speaking && !rec.glowLit && rec.loudFrom && rec.loudLast - rec.loudFrom >= GLOW_ONSET) {
+ rec.glowLit = true;
+ }
+ rec.glow = rec.glowLit ? GLOW_FLOOR + (1 - GLOW_FLOOR) * glowIntensity(level) : 0;
+ return rec.glow;
+ }
+
+ // glowStyle is what a name label is given for a glow: the value for
+ // --glow, as a string with two places so an unchanged glow writes
+ // nothing, and the transition's duration for getting there — the
+ // attack going up, the release coming down, and none at all under
+ // reduced motion, where a lit glow is one steady brightness. Pure, and
+ // lifted by name in the node test.
+ function glowStyle(lit, target, before, reduced) {
+ const value = reduced ? (lit ? GLOW_STEADY : 0) : lit ? target : 0;
+ const time = reduced ? 0 : value > before ? GLOW_ATTACK : GLOW_RELEASE;
+ return { value: value.toFixed(2), time: time + 'ms' };
}
// 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
+ // Muted badge, when the person says so (a glow 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
+ // when the five-second rule says so; and otherwise the live sound, the
+ // glow behind the name ('meter', the state's name from before). 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) {
@@ -4475,18 +4525,19 @@
'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.
+ // reducedMotion: the person has asked for less movement, so the glow
+ // holds one brightness rather than following the voice.
+ function reducedMotion() {
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ }
+
+ // hearingMarks builds one tile's hearing parts: the sentence with its
+ // ⓘ, and the line for a peer's notice. The live sound is the glow on
+ // the name label itself (drawHearing), which is a box-shadow and so
+ // nothing a screen reader hears — a level moving five times a second is
+ // noise to one — 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;
@@ -4502,7 +4553,7 @@
const notice = document.createElement('span');
notice.className = 'calls-tile-notice';
notice.hidden = true;
- return { meter, box, text, why, notice, kind: '' };
+ return { box, text, why, notice, kind: '', glow: '0.00' };
}
// drawHearing brings one tile's hearing parts up to date. Called from
@@ -4519,10 +4570,15 @@
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 lit = shown === 'meter' && !!(rec && rec.glowLit);
+ const glow = glowStyle(lit, rec ? rec.glow : 0, Number(h.glow), reducedMotion());
+ if (h.glow !== glow.value) {
+ h.glow = glow.value;
+ t.label.style.setProperty('--glow-t', glow.time);
+ t.label.style.setProperty('--glow', glow.value);
+ }
+ const litText = lit ? 'on' : 'off';
+ if (t.label.dataset.glow !== litText) t.label.dataset.glow = litText;
const name = self ? '' : names.get(key) || 'Someone';
let kind = '';
let text = '';
@@ -4588,6 +4644,7 @@
rec.packets = s.packets;
if (s.grew) rec.packetAt = now;
judgeSpeaking(rec, s.level);
+ judgeGlow(rec, s.level, now);
return s;
}
@@ -4616,6 +4673,8 @@
}
if (!report) {
rec.level = 0;
+ rec.speaking = false;
+ judgeGlow(rec, 0, now);
judgeSilence(rec, now, false, false, false);
} else {
const s = sampleInto(rec, report, now);
@@ -4645,6 +4704,7 @@
} else {
selfHearing.level = 0;
selfHearing.speaking = false;
+ judgeGlow(selfHearing, 0, now);
judgeSilence(selfHearing, now, false, false, false);
}
const key = noSoundList().join(',');
@@ -5026,7 +5086,6 @@
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);
}
@@ -5914,9 +5973,10 @@
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'),
+ // The glow behind the name as drawn: lit, and its --glow.
+ speaking: t.label.dataset.glow === 'on',
+ glow: Number(t.hear.glow) || 0,
text: t.hear.box.hidden ? '' : t.hear.text.textContent,
notice: t.hear.notice.hidden ? '' : t.hear.notice.textContent,
};
@@ -6114,9 +6174,9 @@
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
+ // about its audio ('muted', 'blocked', 'silent' or 'meter', which
+ // is live sound), the level as last read and the loudest seen,
+ // the speaking glow (lit, and its brightness), 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(),
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 194c835..66f8c47 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1202,16 +1202,65 @@ const refuseFirstPlay = () => {
};
};
-// meterBars is the meter beside a tile's name as it is drawn: whether it
-// is showing, and how many bars are lit.
-function meterBars(page, key) {
+// glowOf is the speaking glow on a tile's name label as it is drawn:
+// whether it is lit, its --glow, and the shadow the browser computed from
+// it — plus whether anything of the ring or the meter it replaced is left
+// on the tile.
+function glowOf(page, key) {
return page.evaluate((k) => {
- const m = document.querySelector('#tiles figure[data-tile="' + k + '"] .calls-meter');
- if (!m) return null;
- return { shown: !m.hidden && m.getClientRects().length > 0, bars: Number(m.dataset.level) || 0 };
+ const figure = document.querySelector('#tiles figure[data-tile="' + k + '"]');
+ const label = figure && figure.querySelector('.calls-tile-name');
+ if (!label) return null;
+ const style = getComputedStyle(label);
+ return {
+ lit: label.dataset.glow === 'on',
+ glow: Number(style.getPropertyValue('--glow')) || 0,
+ shadow: style.boxShadow,
+ // The old ring was an inset shadow on the figure (a floating self
+ // view's own drop shadow is not one).
+ ring: figure.classList.contains('calls-tile-speaking') || /inset/.test(getComputedStyle(figure).boxShadow),
+ meter: !!figure.querySelector('.calls-meter'),
+ };
}, key);
}
+// speakInto replaces a page's microphone on every connection with a
+// steady tone, loud enough to be speech to the glow's gate. Chromium's
+// fake microphone only beeps, and a beep is exactly the brief noise the
+// glow is built to ignore. stopSpeaking puts the microphone back.
+function speakInto(page) {
+ return page.evaluate(async () => {
+ const ctx = new AudioContext();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ gain.gain.value = 0.3;
+ osc.frequency.value = 220;
+ const dest = ctx.createMediaStreamDestination();
+ osc.connect(gain).connect(dest);
+ osc.start();
+ await ctx.resume();
+ const tone = dest.stream.getAudioTracks()[0];
+ window.__toneRestore = [];
+ for (const pc of window.__pcs || []) {
+ for (const sn of pc.getSenders()) {
+ if (sn.track && sn.track.kind === 'audio') {
+ window.__toneRestore.push([sn, sn.track]);
+ await sn.replaceTrack(tone);
+ }
+ }
+ }
+ window.__toneCtx = ctx;
+ return window.__toneRestore.length;
+ });
+}
+function stopSpeaking(page) {
+ return page.evaluate(async () => {
+ for (const [sn, track] of window.__toneRestore || []) await sn.replaceTrack(track);
+ if (window.__toneCtx) await window.__toneCtx.close();
+ window.__toneRestore = [];
+ });
+}
+
function mediaLive(page) {
return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
}
@@ -1495,22 +1544,49 @@ const run = {
const h = await hearingOf(pageB, a);
return h && h.peak > 0 && h.shown === 'meter' ? h : false;
}, 15000);
- check(heard, 'B’s meter for A reads a level above zero (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
- const lit = await until(async () => {
- const m = await meterBars(pageB, a);
- return m && m.shown && m.bars > 0 ? m : false;
- }, 10000);
- check(lit, 'the meter beside A’s name on B lights at least one bar (' + JSON.stringify(lit || (await meterBars(pageB, a))) + ')');
+ check(heard, 'B reads a level above zero for A (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
+ // The glow replaced the ring and the meter (0.3.7): neither is drawn.
+ const plain = await glowOf(pageB, a);
+ check(plain && !plain.ring && !plain.meter,
+ 'no ring round A’s tile and no meter beside the name on B (' + JSON.stringify(plain) + ')');
const self = await until(async () => {
const h = await hearingOf(pageA, 'self');
return h && h.peak > 0 ? h : false;
}, 15000);
- check(self, 'A’s own meter reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
+ check(self, 'A’s own tile reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
+ // A speaks — a steady tone rather than the fake microphone's beep —
+ // and A's name glows on B, and on A's own tile.
+ const toned = await speakInto(pageA);
+ check(toned > 0, 'A’s microphone is replaced by a tone (' + toned + ' senders)');
+ const lit = await until(async () => {
+ const g = await glowOf(pageB, a);
+ const h = await hearingOf(pageB, a);
+ return g && g.lit && g.glow > 0 && h && h.speaking && h.glow > 0 && g.shadow !== 'none' ? { g, h } : false;
+ }, 10000);
+ check(lit, 'A’s name glows on B while A speaks (' + JSON.stringify(lit || (await glowOf(pageB, a))) + ')');
const selfLit = await until(async () => {
- const m = await meterBars(pageA, 'self');
- return m && m.shown && m.bars > 0 ? m : false;
+ const g = await glowOf(pageA, 'self');
+ return g && g.lit && g.glow > 0 ? g : false;
+ }, 10000);
+ check(selfLit, 'and on A’s own tile (' + JSON.stringify(selfLit || (await glowOf(pageA, 'self'))) + ')');
+ // For a look at it: CALLS_GLOW_SNAPSHOTS=<dir> keeps B's room, glowing.
+ if (process.env.CALLS_GLOW_SNAPSHOTS) {
+ await pageB.screenshot({ path: process.env.CALLS_GLOW_SNAPSHOTS + '/web-room-glowing.png', fullPage: true });
+ }
+ const ringless = await glowOf(pageB, a);
+ check(ringless && !ringless.ring, 'speaking draws no ring round the tile (' + JSON.stringify(ringless) + ')');
+ // Silence — the track disabled, so packets carry nothing — puts it
+ // out, over the release rather than at once. (The fake microphone's
+ // beep, back, would light it again: Chromium's audioLevel lingers.)
+ await pageA.evaluate(() => {
+ for (const [sn] of window.__toneRestore || []) if (sn.track) sn.track.enabled = false;
+ });
+ const out = await until(async () => {
+ const g = await glowOf(pageB, a);
+ return g && !g.lit && g.glow === 0 && g.shadow.startsWith('rgba(46, 204, 219, 0)') ? g : false;
}, 10000);
- check(selfLit, 'and A’s own tile shows it (' + JSON.stringify(selfLit || (await meterBars(pageA, 'self'))) + ')');
+ await stopSpeaking(pageA);
+ check(out, 'and it goes out when A stops (' + JSON.stringify(out || (await glowOf(pageB, a))) + ')');
// A beeping microphone is not a silent one, on either side, for
// longer than the rule's five seconds.
@@ -1527,8 +1603,11 @@ const run = {
return h && h.shown === 'muted' && m && m.muted ? { h, m } : false;
}, 10000);
check(muted, 'B shows A as Muted once A mutes (' + JSON.stringify(muted || (await hearingOf(pageB, a))) + ')');
- const meterGone = await meterBars(pageB, a);
- check(meterGone && !meterGone.shown, 'the meter gives way to the badge (' + JSON.stringify(meterGone) + ')');
+ const glowGone = await until(async () => {
+ const g = await glowOf(pageB, a);
+ return g && !g.lit && g.glow === 0 ? g : false;
+ }, 3000);
+ check(glowGone, 'muted, nothing glows (' + JSON.stringify(glowGone || (await glowOf(pageB, a))) + ')');
const mutedQuiet = await stayed(pageB, 6500, (s) =>
s.hearing[a] && s.hearing[a].shown === 'muted' && s.hearing[a].text === '' && !s.noSoundFrom.length);
check(!mutedQuiet, 'a muted peer is never "No sound from" (' + JSON.stringify(mutedQuiet && mutedQuiet.hearing[a]) + ')');
@@ -1540,7 +1619,7 @@ const run = {
const h = await hearingOf(pageB, a);
return h && h.shown === 'meter' ? h : false;
}, 10000);
- check(back, 'unmuting brings the meter back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
+ check(back, 'unmuting brings live sound back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
// Silence without muting: the microphone track is disabled under the
// call, so packets keep flowing and carry nothing, and A's report
diff --git a/test/browser/room-glow.test.mjs b/test/browser/room-glow.test.mjs
new file mode 100644
index 0000000..80d5790
--- /dev/null
+++ b/test/browser/room-glow.test.mjs
@@ -0,0 +1,149 @@
+// room-glow.test.mjs — room.js's speaking glow (0.3.7), which replaced the
+// ring round a tile and the meter beside its name, gated without a
+// browser.
+//
+// node --test test/browser/room-glow.test.mjs
+//
+// The same trick as room-hearing.test.mjs: the functions and the
+// constants they read are lifted out of the shipped room.js by name, so a
+// retuned constant or a renamed function fails here rather than passing
+// against a copy.
+//
+// glowIntensity a level to the glow's brightness, by decibels
+// judgeGlow the gate (speaking, and loud for long enough) and the
+// target brightness over the floor
+// glowStyle what a label is given: --glow, and the transition's
+// duration (attack up, release down, none when reduced)
+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');
+const cssFile = fileURLToPath(new URL('../../internal/calls/static/calls.css', import.meta.url));
+const css = readFileSync(cssFile, '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');
+}
+
+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 NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB', 'GLOW_FLOOR',
+ 'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY'];
+const CONSTS = NAMES.map(liftConst).join('\n');
+// eslint-disable-next-line no-new-func
+const c = new Function(CONSTS + '; return {' + NAMES.join(',') + '};')();
+const build = (name) =>
+ // eslint-disable-next-line no-new-func
+ new Function(CONSTS + '\n' + lift(src, 'judgeSpeaking') + '\n' + lift(src, name) + '; return ' + name + ';')();
+const glowIntensity = build('glowIntensity');
+const glowStyle = build('glowStyle');
+// judgeGlow reads glowIntensity; hearingRecord is the record it works on.
+// eslint-disable-next-line no-new-func
+const judge = new Function(CONSTS + '\n' + ['judgeSpeaking', 'glowIntensity', 'judgeGlow', 'hearingRecord']
+ .map((n) => lift(src, n)).join('\n') + '; return { judgeSpeaking, judgeGlow, hearingRecord };')();
+
+const db = (d) => Math.pow(10, d / 20);
+// One poll, as sampleInto takes it: the speaking gate, then the glow.
+const hear = (rec, level, now) => {
+ judge.judgeSpeaking(rec, level);
+ return judge.judgeGlow(rec, level, now);
+};
+
+test('the constants are the native app’s', () => {
+ // SpeakingGlow.swift: -42 to -12 dBFS, a 0.3 floor, 150 ms onset,
+ // 50 ms attack, 300 ms release, 0.6 steady.
+ assert.equal(c.GLOW_QUIET_DB, -42);
+ assert.equal(c.GLOW_LOUD_DB, -12);
+ assert.equal(c.GLOW_FLOOR, 0.3);
+ assert.equal(c.GLOW_ONSET, 150);
+ assert.equal(c.GLOW_ATTACK, 50);
+ assert.ok(c.GLOW_RELEASE >= 250 && c.GLOW_RELEASE <= 400);
+ assert.equal(c.GLOW_STEADY, 0.6);
+});
+
+test('glowIntensity: silence is none, loud is all, louder is never dimmer', () => {
+ assert.equal(glowIntensity(0), 0);
+ assert.equal(glowIntensity(undefined), 0);
+ assert.equal(glowIntensity(NaN), 0);
+ assert.equal(glowIntensity(db(-60)), 0);
+ assert.ok(Math.abs(glowIntensity(db(-27)) - 0.5) < 1e-9);
+ assert.ok(Math.abs(glowIntensity(db(-12)) - 1) < 1e-9);
+ assert.equal(glowIntensity(5), 1);
+ let last = 0;
+ for (let l = 0.0001; l <= 1; l *= 1.3) {
+ const x = glowIntensity(l);
+ assert.ok(x >= last);
+ last = x;
+ }
+});
+
+test('silence never lights it', () => {
+ const rec = judge.hearingRecord();
+ for (let t = 1000; t < 6000; t += 200) assert.equal(hear(rec, 0, t), 0);
+ for (let t = 6000; t < 9000; t += 200) assert.equal(hear(rec, db(-50), t), 0, 'a quiet room is not a voice');
+ assert.equal(rec.glowLit, false);
+});
+
+test('a brief noise does not light it; a voice that keeps up does', () => {
+ const rec = judge.hearingRecord();
+ // A click: one loud poll. The speaking gate holds for its hangover;
+ // the glow waits for a second loud poll that never comes.
+ assert.equal(hear(rec, db(-6), 1000), 0);
+ assert.equal(rec.speaking, true);
+ for (let t = 1200; t <= 3000; t += 200) assert.equal(hear(rec, 0, t), 0, 'at ' + t);
+ assert.equal(rec.glowLit, false);
+ // Speech: loud polls 200 ms apart light it on the second.
+ assert.equal(hear(rec, db(-20), 4000), 0);
+ const lit = hear(rec, db(-20), 4200);
+ assert.equal(rec.glowLit, true);
+ assert.ok(Math.abs(lit - (c.GLOW_FLOOR + (1 - c.GLOW_FLOOR) * glowIntensity(db(-20)))) < 1e-9);
+ // Between syllables it dims to the floor, still lit.
+ assert.equal(hear(rec, db(-45), 4400), c.GLOW_FLOOR);
+ assert.equal(rec.glowLit, true);
+ // And goes out once the speaking gate lets go.
+ let t = 4600;
+ while (rec.speaking) hear(rec, 0, (t += 200));
+ assert.equal(rec.glowLit, false);
+ assert.equal(rec.glow, 0);
+});
+
+test('glowStyle: attack going up, release coming down, a steady glow under reduced motion', () => {
+ assert.deepEqual(glowStyle(true, 0.8, 0, false), { value: '0.80', time: c.GLOW_ATTACK + 'ms' });
+ assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { value: '0.30', time: c.GLOW_RELEASE + 'ms' });
+ assert.deepEqual(glowStyle(false, 0.9, 0.3, false), { value: '0.00', time: c.GLOW_RELEASE + 'ms' },
+ 'not lit is nothing, whatever the target');
+ for (const target of [0.3, 0.55, 1]) {
+ assert.deepEqual(glowStyle(true, target, 0.1, true), { value: '0.60', time: '0ms' }, 'reduced: one value');
+ }
+ assert.deepEqual(glowStyle(false, 0, 0.6, true), { value: '0.00', time: '0ms' }, 'reduced: off at once');
+ // Two places, so an unchanged glow writes nothing to the style.
+ assert.equal(glowStyle(true, 0.41234, 0, false).value, '0.41');
+});
+
+test('calls.css draws the glow from --glow and has no ring or meter left', () => {
+ assert.match(css, /@property --glow/);
+ assert.match(css, /\.calls-tile-name \{\s*--glow: 0;\s*box-shadow:[^}]*var\(--glow\)/);
+ assert.match(css, /transition: --glow var\(--glow-t/);
+ assert.match(css, /@media \(prefers-reduced-motion: no-preference\) \{\s*\.calls-tile-name \{ transition/,
+ 'the transition is only for people who have not asked for less motion');
+ assert.doesNotMatch(css, /\.calls-tile-speaking/, 'the ring is gone');
+ assert.doesNotMatch(css, /\.calls-meter/, 'the meter is gone');
+ assert.doesNotMatch(src, /calls-meter|calls-tile-speaking/, 'and room.js draws neither');
+});
diff --git a/test/browser/room-hearing.test.mjs b/test/browser/room-hearing.test.mjs
index fe0f7b0..74d26fc 100644
--- a/test/browser/room-hearing.test.mjs
+++ b/test/browser/room-hearing.test.mjs
@@ -12,8 +12,9 @@
// 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
+// 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';
@@ -45,9 +46,9 @@ function liftConst(name) {
return 'const ' + name + ' = ' + m[1] + ';';
}
-const CONSTS = ['FLAT_LEVEL', 'NO_SOUND_AFTER', 'SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'METER_STEPS']
+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, METER_STEPS };')();
+const consts = new Function(CONSTS + '; return { FLAT_LEVEL, NO_SOUND_AFTER, SPEAK_HOLD };')();
const build = (name) =>
// eslint-disable-next-line no-new-func
@@ -56,7 +57,6 @@ const build = (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');
@@ -67,25 +67,6 @@ test('the thresholds are the ones the spec names', () => {
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);