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>
6 files changed,
+444
−124
Makefile+2 −1internal/calls/static/calls.css+96 −46internal/calls/static/room.js+94 −34test/browser/call.mjs+98 −19test/browser/room-glow.test.mjs+149 −0test/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); |