Merge fix/glass-and-glow: glass controls and the speaking glow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 files changed,
+1273
−270
Makefile+2 −1internal/calls/static/calls.css+96 −46internal/calls/static/room.js+94 −34native/DESIGN.md+32 −0native/README.md+6 −3native/apple/Sources/CallsInterop/CallSnapshots.swift+79 −0native/apple/Sources/CallsInterop/Interop.swift+6 −0native/apple/Sources/CallsKit/AudioMeter.swift+153 −43native/apple/Sources/CallsKit/Call.swift+15 −0native/apple/Sources/CallsKit/CallActionStyle.swift+88 −54native/apple/Sources/CallsKit/CallFixtures.swift+19 −0native/apple/Sources/CallsKit/CallsAppearance.swift+3 −0native/apple/Sources/CallsKit/DevicePanelAudio.swift+2 −2native/apple/Sources/CallsKit/MacFullWindowCall.swift+16 −7native/apple/Sources/CallsKit/MacVideoTile.swift+11 −8native/apple/Sources/CallsKit/MiniWindow.swift+6 −5native/apple/Sources/CallsKit/MutedBadge.swift+3 −3native/apple/Sources/CallsKit/SpeakingGlow.swift+122 −0native/apple/Tests/CallsKitTests/AudioLevelTests.swift+1 −1native/apple/Tests/CallsKitTests/CallChromeTests.swift+18 −5native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift+195 −0native/ios/Sources/CallsPhone.swift+11 −8native/ios/Sources/PhoneCallFixture.swift+8 −2native/ios/Sources/PhoneFullScreenCall.swift+4 −5native/ios/UITests/CallsPhoneUITests.swift+29 −0test/browser/call.mjs+98 −19test/browser/native.mjs+2 −0test/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/native/DESIGN.md b/native/DESIGN.md| index 91d7222..b25df2a 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -161,3 +161,35 @@ Snapshots: `CallsInterop --call-snapshots <dir>` (also part of |
| iPhone's come from `CallsPhoneUITests.testFullScreenCallSnapshots` against the |
| debug build's `-CallsCallFixture` calls (`CallFixture`), written to |
| `CALLS_SNAPSHOT_DIR` or `/tmp/immersive-call`. |
| + |
| +## Glass and the speaking glow · 2026-09-25 (0.3.7) |
| + |
| +Supersedes the hairline edges and button tiles of 0.3.5's **Over the picture**. |
| + |
| +- **One glass language, no hairlines.** Every surface over the picture — the |
| + bar, the title pill, notices, name labels, the iPhone's round buttons and the |
| + mini window's tab — is borderless glass: Liquid Glass on macOS 26 and iOS 26, |
| + the ultra-thin material before, with black at 62 % on it |
| + (`CallOverlay.scrim`). Rendered over white the glass measures #5B5B5B, so |
| + white on it is 6.8:1 (6.2:1 from the black alone). The environment value |
| + `callOverlayGlass` turns the glass off to show the fallback. |
| +- **Buttons are glyphs on the pane**: white, a light wash under the pointer, |
| + and a capsule only for a state — near-white for on (sharing, a panel open), |
| + amber with the slashed symbol for microphone or camera off, red for Leave. |
| +- **Devices** over the picture is opaque and dark (`CallOverlay.panelFill`) |
| + with the dark appearance's labels: 16.7:1 primary and 5.1:1 secondary as |
| + rendered. It holds the controls up while open and never fades with them. |
| +- **The speaking glow** replaces the white ring round a tile and the meter |
| + beside the name, on Mac, iPhone, web and the mini window's tab: the accent's |
| + hue lit up (`CallsAppearance.glow`) as a halo behind the name label, bright |
| + as the voice (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The |
| + halo redraws on a timeline only until it arrives; on the web it is a |
| + registered `--glow` transitioned by CSS. "No sound from …" and the microphone |
| + warnings stay as words by the name; the Devices panel keeps a compact meter. |
| + |
| +Snapshots: `CallsInterop --glass-snapshots <dir>` renders real windows below |
| +the desktop and captures them with `screencapture -l` (an off-screen bitmap |
| +draws no Liquid Glass); the iPhone's are |
| +`CallsPhoneUITests.testGlowAndGlassSnapshots` (`-CallsFixtureSpeaking`, |
| +`-CallsFixtureNoGlass`). |
| + |
diff --git a/native/README.md b/native/README.md| index 4378e5f..d3dded6 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -107,11 +107,14 @@ while sharing; the panel itself shows one warning line only when something is |
| wrong (a stretched or broken-up signal, echo cancellation off, noise reduction |
| bypassed, processing refused — `AudioWarning`). |
| -**Sound is measured, not inferred** (spec §15.20). On 2026-09-25 Tony, in a |
| +**Sound is measured, not inferred** (spec §15.20). Since 0.3.7 a tile shows it |
| +as the speaking glow behind the name (`SpeakingGlow`: -42 to -12 dBFS over a |
| +0.3 floor, lit once speech has kept up 0.15 s, 50 ms attack, 300 ms release, |
| +a steady 0.6 under Reduce Motion) in place of the white ring and the meter. On 2026-09-25 Tony, in a |
| browser, could not hear Paul on the Mac app, and Paul's panel looked fine |
| because nothing on it measured sound. Now: |
| -- **Your tile** and the panel's **Microphone level** meter what is sent — the |
| +- **Your tile's glow** and the panel's **Microphone level** meter what is sent — the |
| capture hook's level after noise reduction (`CaptureLevel`, read at 10 Hz). |
| Unmuted and capturing, with nothing reaching the hook above -90 dBFS |
| *before* the suppressor for five seconds, the tile says "Your microphone |
| @@ -120,7 +123,7 @@ because nothing on it measured sound. Now: |
| - **Everyone else's tile** meters what arrives from them: a renderer on their |
| remote audio track (`RemoteAudioTap`), which sees each decoded frame before |
| the output volume, read at 5 Hz. Muted, the tile shows the Muted badge and no |
| - meter. Reporting themselves unmuted, connected, with audio bytes still rising |
| + glow. Reporting themselves unmuted, connected, with audio bytes still rising |
| (inbound-rtp, one `statistics(for:)` per receiver a second) and the decoded |
| peak under -85 dBFS for five seconds, it says "No sound from Bea" instead, |
| and the app reports those people as `noSoundFrom` through the bridge. A thin |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| index 5ce2c9c..add0cb4 100644 |
| --- a/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -52,4 +52,83 @@ import SwiftUI |
| try await shot("contrast-busy-hidden", .focus, shown: false, backdrop: .busy) |
| } |
| } |
| + |
| + /// The glass and the glow as the window server draws them (0.3.7). An |
| + /// off-screen bitmap has no Liquid Glass and no backdrop to blur, so |
| + /// these are real windows, parked below the desktop where nobody sees |
| + /// them, and captured by `screencapture -l`: the control bar over black, |
| + /// white and a busy frame, in glass and in the material older systems |
| + /// draw; the Devices panel; the names glowing at four levels; the tab. |
| + static func saveGlass(to folder: URL) async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + let size = CGSize(width: 1000, height: 640) |
| + func shot( |
| + _ name: String, _ fixture: CallFixture, grid: Bool = false, devices: Bool = false, |
| + glass: Bool = true, backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil, |
| + speaking: FixtureSpeaking? = nil |
| + ) async throws { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + me?.apply(to: call) |
| + speaking?.apply(to: call) |
| + try await renderLive( |
| + MacCallView( |
| + call: call, grid: grid, showingDevices: devices, style: .fullWindow, |
| + controlsShown: true, leave: {}, rejoin: {} |
| + ) |
| + .tint(CallsAppearance.accent) |
| + .environment(\.fixtureBackdrop, backdrop) |
| + .environment(\.callOverlayGlass, glass), |
| + size: size, to: folder.appendingPathComponent("mac-\(name).png")) |
| + } |
| + for glass in [true, false] { |
| + let kind = glass ? "glass" : "fallback" |
| + for backdrop in FixtureBackdrop.allCases { |
| + try await shot("bar-\(kind)-\(backdrop.rawValue)", .focus, glass: glass, backdrop: backdrop) |
| + } |
| + try await shot("bar-\(kind)-busy-muted", .focus, glass: glass, backdrop: .busy, me: .muted) |
| + try await shot("bar-\(kind)-busy-sharing", .focus, glass: glass, backdrop: .busy, me: .sharing) |
| + try await shot("devices-\(kind)", .focus, devices: true, glass: glass, backdrop: .busy) |
| + } |
| + for level in FixtureSpeaking.allCases { |
| + try await shot("glow-\(level.rawValue)", .grid, grid: true, speaking: level) |
| + try await shot("glow-focus-\(level.rawValue)", .focus, speaking: level) |
| + } |
| + for level in [FixtureSpeaking.silent, .high] { |
| + let call = Call(playAudio: false) |
| + CallFixture.focus.apply(to: call) |
| + level.apply(to: call) |
| + try await renderLive( |
| + ZStack { |
| + FixtureBackdrop.busy.view |
| + MiniWindowSnapshot.tab(call: call) |
| + }, |
| + size: CGSize(width: MiniWindowTab.size.width + 40, height: MiniWindowTab.size.height + 40), |
| + to: folder.appendingPathComponent("mac-tab-\(level.rawValue).png")) |
| + } |
| + } |
| + |
| + /// A real window below the desktop, captured by the window server. |
| + static func renderLive(_ content: some View, size: CGSize, to file: URL) async throws { |
| + let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark)) |
| + view.frame.size = size |
| + let window = NSWindow( |
| + contentRect: NSRect(origin: CGPoint(x: 0, y: 0), size: size), |
| + styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) |
| + window.appearance = NSAppearance(named: .darkAqua) |
| + window.contentView = view |
| + window.orderBack(nil) |
| + try await Task.sleep(for: .milliseconds(600)) |
| + view.layoutSubtreeIfNeeded() |
| + let capture = Process() |
| + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), file.path] |
| + try capture.run() |
| + capture.waitUntilExit() |
| + window.close() |
| + guard capture.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } |
| + } |
| } |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index 1ce3a5a..8e341c6 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -30,6 +30,12 @@ struct Input: Decodable { |
| } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--glass-snapshots" { |
| + do { |
| + try await CallSnapshots.saveGlass(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| + } catch { exit(1) } |
| + return |
| + } |
| if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-selftest" { |
| exit(await AudioSelfTestHarness.run() ? 0 : 1) |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift| index c8e48d6..7f7840f 100644 |
| --- a/native/apple/Sources/CallsKit/AudioMeter.swift |
| +++ b/native/apple/Sources/CallsKit/AudioMeter.swift |
| @@ -1,14 +1,17 @@ |
| import Combine |
| import SwiftUI |
| -// Spec §15.20 on the tiles: a meter for every voice, measured where it can |
| -// be — what you send on your own tile, what arrives on everyone else's — |
| -// and a sentence in place of the meter when the sound has gone flat. |
| +// Spec §15.20 on the tiles: every voice measured where it can be — what |
| +// you send on your own tile, what arrives on everyone else's — shown as |
| +// the speaking glow behind the name (0.3.7; see SpeakingGlow), and a |
| +// sentence in its place when the sound has gone flat. |
| // |
| // The levels live in small observable objects of their own, one per tile, |
| -// rather than on `Call`: a meter that changes a few times a second must not |
| -// re-render the whole call view each time. Each object publishes only when |
| -// what it shows changes — a meter step, a line appearing — not per reading. |
| +// rather than on `Call`: a level that changes a few times a second must |
| +// not re-render the whole call view each time. Each object publishes only |
| +// when what it shows changes — a step, a line appearing — not per |
| +// reading. The glow has an object of its own (GlowLevel), so only the |
| +// glow redraws as it breathes. |
| /// Your own microphone, for your tile. |
| @MainActor public final class MicAudio: ObservableObject { |
| @@ -22,6 +25,8 @@ import SwiftUI |
| /// The microphone has more than one input, so the ⓘ points at the |
| /// Input picker. |
| @Published public private(set) var channels = false |
| + /// The glow behind your name. |
| + public let glow = GlowLevel() |
| public init() {} |
| @@ -41,6 +46,8 @@ import SwiftUI |
| @Published public private(set) var speaking = false |
| /// Their report about you: blocked, or not hearing you. |
| @Published public private(set) var notices: [String] = [] |
| + /// The glow behind their name. |
| + public let glow = GlowLevel() |
| public init() {} |
| @@ -51,6 +58,34 @@ import SwiftUI |
| } |
| } |
| +/// One name's speaking glow, observed alone: only the glow redraws as it |
| +/// follows a voice. Publishes when the glow's target or lit state moves by |
| +/// something visible (SpeakingGlow.update says when), a few times a |
| +/// second at most while someone speaks and not at all in silence; between |
| +/// those the view works the brightness out for itself. |
| +@MainActor public final class GlowLevel: ObservableObject { |
| + @Published public private(set) var glow = SpeakingGlow() |
| + private var rule = SpeakingGlow() |
| + |
| + public init() {} |
| + |
| + func update(level: Float, speaking: Bool, now: Double) { |
| + if rule.update(level: level, speaking: speaking, now: now) { glow = rule } |
| + } |
| + |
| + /// Takes another glow's state whole: the mini window's tab showing the |
| + /// loudest person speaking. |
| + func follow(_ other: SpeakingGlow) { |
| + rule = other |
| + if glow != other { glow = other } |
| + } |
| + |
| + func reset() { follow(SpeakingGlow()) } |
| + |
| + /// Holds the glow at a level, for the snapshots. |
| + @_spi(Fixtures) public func showFixture(level: Float) { follow(.fixture(level: level)) } |
| +} |
| + |
| /// Every meter in a call, fed by `Call` (see `Call.watchLevels`). |
| @MainActor public final class AudioLevels { |
| public let mic = MicAudio() |
| @@ -59,6 +94,8 @@ import SwiftUI |
| private var micWatch = MicWatch() |
| /// Anyone other than you speaking, for the mini window's tab. |
| public let anyoneSpeaking = SpeakingNow() |
| + /// The glow of the loudest of them, for the same tab. |
| + public let othersGlow = GlowLevel() |
| public init() {} |
| @@ -77,6 +114,7 @@ import SwiftUI |
| mic.show( |
| step: micWatch.step, muted: sample.muted, speaking: micWatch.speaking, |
| silent: micWatch.silent, source: source, channels: channels) |
| + mic.glow.update(level: sample.sentPeak, speaking: micWatch.speaking, now: now) |
| if micWatch.silent, !wasSilent { AudioAnnouncer.say(AudioWords.micSilent) } |
| } |
| @@ -92,19 +130,24 @@ import SwiftUI |
| let model = peer(id) |
| let fresh = notices.filter { !model.notices.contains($0) } |
| model.show(state: state, speaking: watch.speaking, notices: notices) |
| + model.glow.update(level: sample.level, speaking: watch.speaking, now: now) |
| if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) } |
| for line in fresh { AudioAnnouncer.say(line) } |
| return state |
| } |
| /// Forgets everyone not in `present`, and says whether anyone left is |
| - /// speaking. |
| + /// speaking, and how brightly the loudest of them glows. |
| func settle(present: Set<String>) { |
| for id in Array(watches.keys) where !present.contains(id) { |
| watches.removeValue(forKey: id) |
| peers[id]?.show(state: .meter(0), speaking: false, notices: []) |
| + peers[id]?.glow.reset() |
| } |
| anyoneSpeaking.set(watches.values.contains { $0.speaking }) |
| + let loudest = watches.keys.compactMap { peers[$0]?.glow.glow } |
| + .filter(\.lit).max { $0.target < $1.target } |
| + othersGlow.follow(loudest ?? SpeakingGlow()) |
| } |
| /// The people whose sound has gone flat, in a stable order: what this |
| @@ -116,9 +159,14 @@ import SwiftUI |
| func reset() { |
| micWatch = MicWatch() |
| mic.show(step: 0, muted: false, speaking: false, silent: false, source: "", channels: false) |
| - for id in Array(watches.keys) { peers[id]?.show(state: .meter(0), speaking: false, notices: []) } |
| + mic.glow.reset() |
| + for id in Array(watches.keys) { |
| + peers[id]?.show(state: .meter(0), speaking: false, notices: []) |
| + peers[id]?.glow.reset() |
| + } |
| watches = [:] |
| anyoneSpeaking.set(false) |
| + othersGlow.reset() |
| } |
| } |
| @@ -141,13 +189,24 @@ import SwiftUI |
| public enum TileAudio { |
| case you(MicAudio) |
| case peer(PeerAudio, name: String) |
| + |
| + /// The glow behind the tile's name. |
| + @MainActor public var glow: GlowLevel { |
| + switch self { |
| + case .you(let mic): mic.glow |
| + case .peer(let peer, _): peer.glow |
| + } |
| + } |
| } |
| /// A tile's sound, read out of its model as plain values. |
| public struct TileAudioInfo: Equatable { |
| public enum Status: Equatable { |
| + /// Muted: the name label says so, and there is nothing to glow. |
| case none |
| - case meter(Int) |
| + /// Sound is live: the glow behind the name carries it. |
| + case live |
| + /// "No sound from Bea", in place of the glow. |
| case noSound(String) |
| } |
| public var status: Status |
| @@ -168,7 +227,7 @@ public struct TileAudioInfo: Equatable { |
| @MainActor static func you(_ mic: MicAudio) -> TileAudioInfo { |
| TileAudioInfo( |
| - status: mic.muted ? .none : .meter(mic.step), speaking: mic.speaking, |
| + status: mic.muted ? .none : .live, speaking: mic.speaking, |
| notices: mic.silent ? [AudioWords.micSilent] : [], |
| detail: mic.silent |
| ? AudioWords.micSilentDetail(source: mic.source, channels: mic.channels) : nil) |
| @@ -179,7 +238,7 @@ public struct TileAudioInfo: Equatable { |
| switch audio.state { |
| case .muted: status = .none |
| case .noSound: status = .noSound(name) |
| - case .meter(let step): status = .meter(step) |
| + case .meter: status = .live |
| } |
| return TileAudioInfo( |
| status: status, speaking: audio.speaking, notices: audio.notices, |
| @@ -217,23 +276,29 @@ public struct TileAudioReader<Content: View>: View { |
| } |
| } |
| -/// A small level meter: bars that light from the left. Steps, not a |
| -/// continuous level, so it never animates frame by frame, and with Reduce |
| -/// Motion it does not animate at all. Hidden from assistive technology: |
| -/// the words beside it carry anything worth saying. |
| +/// A small level meter: bars that light from the left, in the Calls teal. |
| +/// Steps, not a continuous level, so it never animates frame by frame, |
| +/// and with Reduce Motion it does not animate at all. The device panel's |
| +/// "Microphone level" row, for setting up; the tiles have the glow. |
| +/// Hidden from assistive technology: the words beside it carry anything |
| +/// worth saying. |
| public struct AudioLevelBars: View { |
| let step: Int |
| var height: CGFloat = 12 |
| @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| + @Environment(\.colorScheme) private var scheme |
| public init(step: Int, height: CGFloat = 12) { |
| self.step = step |
| self.height = height |
| } |
| public var body: some View { |
| + // The glow's teal on a dark panel; the deeper accent on a light sheet, |
| + // where the lit one would be too pale to tell from the unlit. |
| + let lit = scheme == .dark ? CallsAppearance.glow : CallsAppearance.accent |
| HStack(alignment: .bottom, spacing: 2) { |
| ForEach(0..<MeterScale.steps, id: \.self) { bar in |
| - RoundedRectangle(cornerRadius: 1) |
| - .fill(bar < step ? Color.white : CallOverlay.secondaryText.opacity(0.4)) |
| + Capsule() |
| + .fill(bar < step ? lit : Color.secondary.opacity(0.35)) |
| .frame(width: 3, height: height * CGFloat(bar + 2) / CGFloat(MeterScale.steps + 1)) |
| } |
| } |
| @@ -243,23 +308,17 @@ public struct AudioLevelBars: View { |
| } |
| } |
| -/// The sound beside a tile's name: the meter, or "No sound from Bea" with |
| -/// its ⓘ. Nothing while muted — the name label already says Muted. |
| +/// What a tile says beside its name about its sound: "No sound from Bea" |
| +/// with its ⓘ, or nothing — live sound is the glow behind the name, and |
| +/// muted is the name label's own mark. |
| public struct TileAudioStatus: View { |
| let info: TileAudioInfo? |
| public init(_ info: TileAudioInfo?) { self.info = info } |
| public var body: some View { |
| - switch info?.status { |
| - case .meter(let step)?: |
| - AudioLevelBars(step: step) |
| - .padding(.horizontal, 7).padding(.vertical, 5) |
| - .callOverlaySurface(Capsule()) |
| - case .noSound(let name)?: |
| + if case .noSound(let name)? = info?.status { |
| AudioLine( |
| text: AudioWords.noSound(from: name), systemImage: "speaker.slash.fill", |
| detail: info?.detail) |
| - default: |
| - EmptyView() |
| } |
| } |
| } |
| @@ -320,25 +379,69 @@ struct AudioLine: View { |
| } |
| extension View { |
| - /// The speaking highlight round a tile: a white ring, on and off in |
| - /// steps (see SpeakingGate), and without a fade under Reduce Motion. |
| - public func speakingRing(_ on: Bool, cornerRadius: CGFloat) -> some View { |
| - modifier(SpeakingRing(on: on, cornerRadius: cornerRadius)) |
| + /// The speaking glow behind a name label (0.3.7): the Calls teal as a |
| + /// soft halo round `shape`, lit while that person speaks and as bright |
| + /// as their voice, from `glow` (nil draws nothing: a screen's label). |
| + /// Behind the label and outside its layout, so nothing moves when it |
| + /// lights; no border, no flash. |
| + public func speakingGlow<S: Shape>(_ glow: GlowLevel?, in shape: S) -> some View { |
| + background { |
| + if let glow { SpeakingGlowHalo(level: glow, shape: shape) } |
| + } |
| } |
| } |
| -private struct SpeakingRing: ViewModifier { |
| - let on: Bool |
| - let cornerRadius: CGFloat |
| +/// The halo itself. It redraws only while the glow is moving: a |
| +/// timeline that runs from a new reading until the glow has arrived |
| +/// where that reading sent it (`SpeakingGlow.settles`), at up to 30 Hz, |
| +/// and stops. In silence it draws nothing and nothing runs. With Reduce |
| +/// Motion there is no timeline at all: a steady glow while lit. |
| +struct SpeakingGlowHalo<S: Shape>: View { |
| + @ObservedObject var level: GlowLevel |
| + let shape: S |
| @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| - func body(content: Content) -> some View { |
| - content.overlay { |
| - RoundedRectangle(cornerRadius: cornerRadius) |
| - .strokeBorder(Color.white.opacity(on ? 0.85 : 0), lineWidth: 3) |
| - .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: on) |
| - .allowsHitTesting(false) |
| + |
| + var body: some View { |
| + let glow = level.glow |
| + if reduceMotion { |
| + halo(glow.value(at: 0, reduceMotion: true)) |
| + } else { |
| + TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in |
| + halo(glow.value(at: ProcessInfo.processInfo.systemUptime)) |
| + } |
| } |
| } |
| + |
| + /// Spread and brightness both follow the value: a faint close halo for |
| + /// a quiet voice, a wider brighter one for a loud one. |
| + @ViewBuilder private func halo(_ value: Double) -> some View { |
| + if value > 0.01 { |
| + let v = CGFloat(value) |
| + ZStack { |
| + shape.fill(CallsAppearance.glow.opacity(0.35 + 0.45 * value)) |
| + .padding(-(2 + 6 * v)).blur(radius: 4 + 8 * v) |
| + shape.stroke(CallsAppearance.glow.opacity(0.5 * value), lineWidth: 2 + 2 * v) |
| + .blur(radius: 2) |
| + } |
| + .allowsHitTesting(false) |
| + .accessibilityHidden(true) |
| + } |
| + } |
| + |
| + private static func date(uptime: Double) -> Date { |
| + Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime) |
| + } |
| +} |
| + |
| +/// Every thirtieth of a second from now until `until`, then nothing. |
| +struct GlowSchedule: TimelineSchedule { |
| + let until: Date |
| + func entries(from start: Date, mode: TimelineScheduleMode) -> [Date] { |
| + guard until > start else { return [start] } |
| + let every = mode == .lowFrequency ? 0.25 : 1.0 / 30 |
| + let count = min(Int(until.timeIntervalSince(start) / every) + 2, 300) |
| + return (0..<count).map { start.addingTimeInterval(Double($0) * every) } |
| + } |
| } |
| /// A tile's accessibility label with its sound said after the name. |
| @@ -347,14 +450,21 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool, audio: TileAudi |
| } |
| /// A waveform while someone is speaking, and nothing otherwise: the mini |
| -/// window's tab. Hidden from assistive technology, which would hear it |
| -/// flicker on and off with every sentence. |
| +/// window's tab, with the same glow behind it as a name has. Hidden from |
| +/// assistive technology, which would hear it flicker on and off with |
| +/// every sentence. |
| public struct SpeakingGlyph: View { |
| @ObservedObject var speaking: SpeakingNow |
| - public init(speaking: SpeakingNow) { self.speaking = speaking } |
| + let glow: GlowLevel? |
| + public init(speaking: SpeakingNow, glow: GlowLevel? = nil) { |
| + self.speaking = speaking |
| + self.glow = glow |
| + } |
| public var body: some View { |
| Image(systemName: "waveform") |
| .font(.caption.bold()).foregroundStyle(.white) |
| + .frame(width: 22, height: 22) |
| + .speakingGlow(glow, in: Circle()) |
| .opacity(speaking.value ? 1 : 0) |
| .help("Someone is speaking") |
| .accessibilityHidden(true) |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 68e0ea0..9f959a9 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -769,6 +769,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| micPeakSinceSample = 0 |
| values["noSoundFrom"] = UInt64(levels.noSoundFrom.count) |
| values["micSilent"] = levels.mic.silent ? 1 : 0 |
| + // The speaking glow behind your own name (0.3.7): lit while what is |
| + // sent is speech that has kept up. |
| + values["micGlow"] = levels.mic.glow.glow.lit ? 1 : 0 |
| return values |
| } |
| } |
| @@ -804,4 +807,16 @@ extension Call { |
| self.cameraOff = cameraOff |
| self.sharingScreen = sharingScreen |
| } |
| + |
| + /// Everyone's glow, and yours, held at one level, for the snapshots. |
| + @_spi(Fixtures) public func showFixtureSpeaking(level: Float) { |
| + levels.mic.glow.showFixture(level: level) |
| + // Nobody muted glows: muted, the speaking gate is shut. |
| + if muted { levels.mic.glow.reset() } |
| + for peer in state?.peers ?? [] where peer.peer != state?.peer && !isMuted(peer.peer) { |
| + levels.peer(peer.peer).glow.showFixture(level: level) |
| + } |
| + levels.othersGlow.showFixture(level: level) |
| + levels.anyoneSpeaking.set(levels.othersGlow.glow.lit) |
| + } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift| index 979f80e..8653a85 100644 |
| --- a/native/apple/Sources/CallsKit/CallActionStyle.swift |
| +++ b/native/apple/Sources/CallsKit/CallActionStyle.swift |
| @@ -41,64 +41,91 @@ public struct CallActionStyle: ButtonStyle { |
| /// The look of everything the full-window call puts over its video — the |
| /// control bar and its buttons, the title pill, the name labels and the |
| -/// notices — as one system. It is always dark, whatever the system |
| -/// appearance, because what is under it is always video, and video can be |
| -/// anything from a black room to a white wall. |
| +/// notices — as one system (0.3.7): borderless glass panes, dark whatever |
| +/// the system appearance, because what is under them is always video, and |
| +/// video can be anything from a black room to a white wall. |
| /// |
| -/// Each surface is black at `scrim` over a blur. The blur only softens; |
| -/// the black does the work, so the contrast holds even where the blur is |
| -/// not drawn (an off-screen snapshot) and even over pure white, the worst |
| -/// case: white under 70 % black is #4D4D4D, so white on the bar is 8.5:1 |
| -/// at worst and 21:1 at best (over black). The ratios below are measured |
| -/// against that worst case; `CallOverlayContrastTests` holds them. |
| +/// Each surface is Liquid Glass where the system has it (macOS 26, iOS |
| +/// 26), and the ultra-thin material before that, with black at `scrim` |
| +/// laid on it. The glass and the blur only soften; the black does the |
| +/// work, so the contrast holds over anything and even where neither is |
| +/// drawn: white under 62 % black is #616161, so white on a surface is |
| +/// 6.2:1 at worst and 21:1 at best (over black). Rendered, the glass over |
| +/// white measures #5B5B5B (6.8:1). No hairline edges anywhere: a surface |
| +/// is told from the picture by its tone, and from black video by the |
| +/// glass's own light. `CallOverlayContrastTests` holds the ratios. |
| public enum CallOverlay { |
| - /// The black laid over whatever the surface sits on. |
| - public static let scrim: Double = 0.7 |
| - /// A resting button's fill: white, lightly. Over the worst-case bar it |
| - /// is #696969, and a white symbol on it is 5.5:1. |
| - public static let buttonFill = Color.white.opacity(0.16) |
| - /// A hairline round a resting button and round the surfaces, so their |
| - /// edge shows over black too, where the scrim alone would vanish. |
| - public static let edge = Color.white.opacity(0.18) |
| + /// The black laid over the glass or material. |
| + public static let scrim: Double = 0.62 |
| + /// A resting button has no fill of its own: a white symbol on the pane. |
| + /// Under the pointer it lightens by this, and pressed by `pressFill`; |
| + /// white on the hovered pane is still 4.9:1 at worst. |
| + public static let hoverFill = Color.white.opacity(0.1) |
| + public static let pressFill = Color.white.opacity(0.18) |
| /// A control that is on — a panel open, the screen being shared: a |
| - /// near-white fill with a near-black symbol, 15:1 between them and 7.5:1 |
| - /// from the bar at its lightest. |
| + /// near-white capsule with a near-black symbol, 15:1 between them and |
| + /// 6.8:1 from the pane at its lightest. |
| public static let onFill = Color(white: 0.94) |
| public static let onSymbol = Color(white: 0.1) |
| - /// Microphone or camera off: amber, with the slashed symbol in white |
| - /// (5.7:1). A warning that others cannot hear or see you, and distinct |
| - /// from Leave's red and from the white of a control that is on. |
| + /// Microphone or camera off: an amber capsule, with the slashed symbol |
| + /// in white (5.7:1). A warning that others cannot hear or see you, and |
| + /// distinct from Leave's red and from the white of a control that is on. |
| public static let offFill = Color(red: 0.65, green: 0.30, blue: 0.02) |
| - /// Leave and End call: a red deep enough for white on it to read (5.0:1; |
| - /// the system red would be 3.4:1). |
| + /// Leave and End call: a red capsule deep enough for white on it to read |
| + /// (5.0:1; the system red would be 3.4:1). |
| public static let leaveFill = Color(red: 0.84, green: 0.16, blue: 0.14) |
| /// The unread count on Chat: white on violet, 6.0:1. |
| public static let badgeFill = Color(red: 0.49, green: 0.23, blue: 0.85) |
| - /// Secondary text on a surface: the status line, a quiet notice. 6.0:1 |
| + /// Secondary text on a surface: the status line, a quiet notice. 5.0:1 |
| /// over the worst-case surface. |
| - public static let secondaryText = Color.white.opacity(0.78) |
| + public static let secondaryText = Color.white.opacity(0.85) |
| /// A notice that must be seen (you are sharing your screen): a light |
| - /// amber, 4.9:1 over the worst-case surface. |
| - public static let alertText = Color(red: 1.0, green: 0.72, blue: 0.35) |
| + /// amber, 4.6:1 over the worst-case surface. |
| + public static let alertText = Color(red: 1.0, green: 0.85, blue: 0.6) |
| + /// A panel over the picture that is read rather than glanced at (the |
| + /// Devices panel): opaque, so nothing of the video shows through the |
| + /// text. The dark appearance's own label colours sit on it at full |
| + /// contrast (primary 17:1, secondary 7.9:1). |
| + public static let panelFill = Color(white: 0.12) |
| } |
| -/// A surface over the picture: black at `CallOverlay.scrim` over a dark |
| -/// blur, with a hairline edge. Dark whatever the system appearance; only |
| -/// the background is made dark, so a sheet or popover the content opens |
| -/// keeps the person's own appearance. |
| +private struct CallOverlayGlassKey: EnvironmentKey { |
| + static let defaultValue = true |
| +} |
| + |
| +extension EnvironmentValues { |
| + /// Whether the overlay's surfaces may use Liquid Glass where the system |
| + /// has it. True in the apps; the snapshots turn it off to show the |
| + /// material every older system draws instead. |
| + public var callOverlayGlass: Bool { |
| + get { self[CallOverlayGlassKey.self] } |
| + set { self[CallOverlayGlassKey.self] = newValue } |
| + } |
| +} |
| + |
| +/// A surface over the picture: borderless glass — Liquid Glass on macOS |
| +/// and iOS 26, the ultra-thin material before — with black at |
| +/// `CallOverlay.scrim` on it. Only the background is made dark, so a sheet |
| +/// or popover the content opens keeps the person's own appearance. |
| public struct CallOverlaySurface<S: InsettableShape>: ViewModifier { |
| let shape: S |
| + @Environment(\.callOverlayGlass) private var glass |
| public init(_ shape: S) { self.shape = shape } |
| public func body(content: Content) -> some View { |
| - content |
| - .background { |
| - ZStack { |
| - shape.fill(.ultraThinMaterial) |
| - shape.fill(Color.black.opacity(CallOverlay.scrim)) |
| + if glass, #available(macOS 26, iOS 26, *) { |
| + content |
| + .background(shape.fill(Color.black.opacity(CallOverlay.scrim))) |
| + .glassEffect(.regular, in: shape) |
| + } else { |
| + content |
| + .background { |
| + ZStack { |
| + shape.fill(.ultraThinMaterial) |
| + shape.fill(Color.black.opacity(CallOverlay.scrim)) |
| + } |
| + .environment(\.colorScheme, .dark) |
| } |
| - .environment(\.colorScheme, .dark) |
| - } |
| - .overlay(shape.strokeBorder(CallOverlay.edge, lineWidth: 1)) |
| + } |
| } |
| } |
| @@ -109,12 +136,14 @@ extension View { |
| } |
| } |
| -/// A button over the picture, in one of four states. The state is always |
| -/// the symbol as well as the fill: a muted microphone is slashed and amber, |
| -/// a control that is on is white with a dark symbol. |
| +/// A button over the picture, in one of four states. At rest it is only a |
| +/// white symbol on the pane, lightening under the pointer; a filled |
| +/// capsule appears behind it only in a state that needs one, and the state |
| +/// is always the symbol as well as the fill: a muted microphone is slashed |
| +/// and amber, a control that is on is white with a dark symbol. |
| public struct CallOverlayButtonStyle: ButtonStyle { |
| public enum Tone: Sendable { |
| - /// Resting: a white symbol on a light fill. |
| + /// Resting: a white symbol on the pane. |
| case normal |
| /// On: a panel open, the screen being shared. |
| case on |
| @@ -149,25 +178,30 @@ private struct OverlayButtonBody<Label: View>: View { |
| let round: Bool |
| let pressed: Bool |
| let enabled: Bool |
| + @State private var hovering = false |
| var body: some View { |
| - let shape = RoundedRectangle(cornerRadius: round ? size / 2 : 14, style: .continuous) |
| + // Leave is wider than it is tall, so it reads as a capsule and not as |
| + // one more round state behind a symbol. |
| + let width = tone == .leave && !round ? size * 1.4 : size |
| label.labelStyle(.iconOnly) |
| .font(round ? .title2.weight(.semibold) : .title3.weight(.medium)) |
| - .frame(width: size, height: size) |
| + .frame(width: width, height: size) |
| .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| - .background(fill, in: shape) |
| - .overlay { if tone == .normal { shape.strokeBorder(CallOverlay.edge, lineWidth: 1) } } |
| - .opacity(!enabled ? 0.45 : pressed ? 0.7 : 1) |
| - .contentShape(shape) |
| + .background(fill, in: Capsule()) |
| + .opacity(!enabled ? 0.45 : pressed && tone != .normal ? 0.75 : 1) |
| + .contentShape(Capsule()) |
| + .onHover { hovering = $0 } |
| } |
| private var fill: Color { |
| switch tone { |
| - case .normal: CallOverlay.buttonFill |
| - case .on: CallOverlay.onFill |
| - case .off: CallOverlay.offFill |
| - case .leave: CallOverlay.leaveFill |
| + case .normal: |
| + guard enabled else { return .clear } |
| + return pressed ? CallOverlay.pressFill : hovering ? CallOverlay.hoverFill : .clear |
| + case .on: return CallOverlay.onFill |
| + case .off: return CallOverlay.offFill |
| + case .leave: return CallOverlay.leaveFill |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index 3754da1..26ab720 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -45,6 +45,25 @@ import SwiftUI |
| } |
| } |
| +/// How loudly everyone is speaking, for the snapshots of the glow behind |
| +/// the names: silent, and low, middling and loud speech. |
| +@_spi(Fixtures) public enum FixtureSpeaking: String, CaseIterable, Sendable { |
| + case silent, low, mid, high |
| + |
| + public var decibels: Float { |
| + switch self { |
| + case .silent: -60 |
| + case .low: -29 |
| + case .mid: -21 |
| + case .high: -12 |
| + } |
| + } |
| + |
| + @MainActor public func apply(to call: Call) { |
| + call.showFixtureSpeaking(level: pow(10, decibels / 20)) |
| + } |
| +} |
| + |
| /// What the snapshots put behind the call's overlay in place of video, to |
| /// show that the controls read over anything: black, white, and a busy |
| /// frame with light and dark and colour and fine detail in it. |
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift| index 887232f..d7f64f6 100644 |
| --- a/native/apple/Sources/CallsKit/CallsAppearance.swift |
| +++ b/native/apple/Sources/CallsKit/CallsAppearance.swift |
| @@ -3,5 +3,8 @@ import SwiftUI |
| /// Calls's first native palette; app-specific until a second app adopts it. |
| public enum CallsAppearance { |
| public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54) |
| + /// The accent's hue lit up, for the speaking glow: the accent itself is |
| + /// a deep teal that would read as a shadow over video, not a light. |
| + public static let glow = Color(red: 0.18, green: 0.80, blue: 0.86) |
| public static let background = Color.primary.opacity(0.035) |
| } |
diff --git a/native/apple/Sources/CallsKit/DevicePanelAudio.swift b/native/apple/Sources/CallsKit/DevicePanelAudio.swift| index 2c06cee..91550af 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanelAudio.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanelAudio.swift |
| @@ -14,9 +14,9 @@ public struct MicLevelRow: View { |
| VStack(alignment: .leading, spacing: 4) { |
| HStack(spacing: 8) { |
| Text("Microphone level").font(.subheadline) |
| + // The tiles' glow is for a call; setting up wants to see the |
| + // level itself, so the panel keeps a compact meter, borderless. |
| AudioLevelBars(step: mic.muted ? 0 : mic.step, height: 14) |
| - .padding(.horizontal, 6).padding(.vertical, 4) |
| - .background(Color.black.opacity(0.75), in: Capsule()) |
| Text(words).font(.footnote).foregroundStyle(.secondary) |
| } |
| if mic.silent { |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index b8a6f0e..e6b3895 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -230,7 +230,6 @@ |
| compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| ) |
| .frame(width: pip.width, height: pip.height) |
| - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.white.opacity(0.18))) |
| .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| .gesture( |
| @@ -388,8 +387,16 @@ |
| .frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44))) |
| } |
| .frame(width: Self.panelWidth) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| - .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + // Read, not glanced at, and over video: opaque and dark, with the |
| + // dark appearance's full-contrast labels, whatever the system's |
| + // appearance (a light material over a picture was grey on grey). |
| + // It is not part of the controls' fade: it holds them up while |
| + // open (`.menuOpen`) and carries no opacity of its own. |
| + .foregroundStyle(.white) |
| + .background(CallOverlay.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| + .environment(\.colorScheme, .dark) |
| + .shadow(color: .black.opacity(0.35), radius: 14, y: 4) |
| + .accessibilityIdentifier("devicesPanel") |
| .padding(.leading, 16).padding(.bottom, bottom) |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) |
| .transition(panelTransition(edge: .bottom)) |
| @@ -430,8 +437,9 @@ |
| private var bar: some View { |
| controls(fullWindow: true) |
| .padding(10) |
| - .callOverlaySurface(RoundedRectangle(cornerRadius: 20, style: .continuous)) |
| - .shadow(color: .black.opacity(0.3), radius: 12, y: 4) |
| + // One glass pane, no edge, the buttons sitting on it. |
| + .callOverlaySurface(Capsule()) |
| + .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| .frame(maxWidth: 760) |
| .background( |
| GeometryReader { bar in |
| @@ -461,8 +469,9 @@ |
| } |
| } |
| - /// A notice over the picture: a capsule of the overlay's dark surface, |
| - /// with white text and dark-appearance controls, whatever the system's. |
| + /// A notice over the picture: a capsule of the overlay's borderless |
| + /// glass, with white text and dark-appearance controls, whatever the |
| + /// system's. |
| struct NoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| content.font(.callout).foregroundStyle(.white) |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 2bb58cd..0648f67 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -27,7 +27,7 @@ |
| /// does not fill the tile. A tile among others on the full-window |
| /// stage is a shade lighter than the stage, so it reads as a tile. |
| var backdrop = Color.black |
| - /// The sound on this tile (spec §15.20): your microphone's meter, or a |
| + /// The sound on this tile (spec §15.20): your microphone's glow, or a |
| /// remote person's with "No sound from" and their notices. Nil for a |
| /// screen. |
| var audio: TileAudio? = nil |
| @@ -56,20 +56,23 @@ |
| VStack(alignment: .leading, spacing: 6) { |
| TileAudioNotices(info) |
| HStack(spacing: 6) { |
| - if compact { |
| - TileNameLabel(label: label, muted: muted) |
| - } else { |
| - Text(label).font(.callout.bold()).foregroundStyle(.white) |
| - .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| - .background(.black.opacity(0.65), in: Capsule()) |
| + Group { |
| + if compact { |
| + TileNameLabel(label: label, muted: muted) |
| + } else { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white) |
| + .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| + .callOverlaySurface(Capsule()) |
| + } |
| } |
| + // Speaking is the glow behind the name, as bright as the voice. |
| + .speakingGlow(audio?.glow, in: Capsule()) |
| TileAudioStatus(info) |
| } |
| } |
| .padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0) |
| } |
| .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } } |
| - .speakingRing(info?.speaking == true, cornerRadius: cornerRadius) |
| .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
| .accessibilityElement(children: .ignore) |
| .accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info)) |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index d0ac85c..43654cb 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -446,7 +446,7 @@ |
| if call.muted, !showingControls { |
| Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly) |
| .font(.callout.bold()).foregroundStyle(.white) |
| - .padding(8).background(.black.opacity(0.65), in: Circle()) |
| + .padding(8).callOverlaySurface(Circle()) |
| .accessibilityLabel("Your microphone is muted") |
| } |
| Spacer() |
| @@ -485,7 +485,8 @@ |
| /// the call is with. Click it to open the mini window again, drag it out |
| /// to open it there, double-click it for the whole call. |
| /// |
| - /// A small waveform shows while someone else is speaking, from the |
| + /// A small waveform shows while someone else is speaking, with the |
| + /// speaking glow behind it following the loudest of them, from the |
| /// receive meters (spec §15.20), which read five times a second — quick |
| /// enough to follow speech, where the five-second audio readout was not. |
| struct MiniTabView: View { |
| @@ -510,7 +511,7 @@ |
| Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail) |
| .foregroundStyle(.white) |
| Spacer(minLength: 0) |
| - SpeakingGlyph(speaking: call.levels.anyoneSpeaking) |
| + SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow) |
| } |
| .padding(.horizontal, 7) |
| .frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height) |
| @@ -520,9 +521,9 @@ |
| click: { count in count >= 2 ? actions.open() : actions.expand() }, |
| dropped: { _ in actions.tabReleased() }, resized: {}) |
| ) |
| - .background(Color(white: 0.12).opacity(0.94)) |
| + // The call's glass, borderless, as over the picture. |
| + .callOverlaySurface(Capsule()) |
| .clipShape(Capsule()) |
| - .overlay(Capsule().strokeBorder(.white.opacity(0.15))) |
| .accessibilityElement(children: .ignore) |
| .accessibilityAddTraits(.isButton) |
| .accessibilityLabel("Calls, \(caption)") |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index 498ef98..e734fa9 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -11,7 +11,7 @@ public struct MutedBadge: View { |
| Label("Muted", systemImage: "mic.slash.fill") |
| .font(.caption.bold()).foregroundStyle(.white) |
| .padding(.horizontal, 8).padding(.vertical, 4) |
| - .background(.black.opacity(0.65), in: Capsule()) |
| + .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
| } |
| @@ -48,8 +48,8 @@ public struct TileNameLabel: View { |
| .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| .fixedSize() |
| .padding(.horizontal, 7).padding(.vertical, 3) |
| - // The overlay's dark surface, as the controls have: white on it is |
| - // 8.5:1 even over a white wall. |
| + // The overlay's glass, as the controls have: white on it is 6.2:1 |
| + // even over a white wall. |
| .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
diff --git a/native/apple/Sources/CallsKit/SpeakingGlow.swift b/native/apple/Sources/CallsKit/SpeakingGlow.swift| new file mode 100644 |
| index 0000000..3af8a57 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/SpeakingGlow.swift |
| @@ -0,0 +1,122 @@ |
| +import Foundation |
| + |
| +// 0.3.7: the speaking glow. The white ring round a tile and the meter |
| +// beside a name were two marks for one thing; they are now one — a soft |
| +// glow in the Calls teal behind the name label, lit while that person is |
| +// speaking and breathing with their voice. This is the rule behind it, |
| +// on the caller's clock, with nothing about views or the main actor in it: |
| +// what a reading makes the glow aim for, and how bright it is at any |
| +// moment on the way there. |
| +// |
| +// The levels arrive a few times a second (Call.watchLevels: 10 Hz for |
| +// your microphone, 5 Hz for everyone else). Between readings the glow is |
| +// an exponential approach from where it was to where it is going — quick |
| +// rising (`attack`), slower falling (`release`) — so a view can ask for |
| +// the value at any instant without being told anything new, and a |
| +// reading that changes nothing visible changes nothing at all. |
| + |
| +/// The glow behind one name: whether it is lit, where its brightness is |
| +/// heading, and how bright it is at a given time. |
| +public struct SpeakingGlow: Equatable, Sendable { |
| + /// The level at which a lit glow is at its dimmest, and the level at |
| + /// which it is brightest. Speech peaks sit around -20 to -6 dBFS; the |
| + /// bottom is where the speaking highlight lets go (SpeakingGate.offDb). |
| + public static let quietDb: Float = SpeakingGate.offDb |
| + public static let loudDb: Float = -12 |
| + /// The least a lit glow shows, so it dims between syllables but does |
| + /// not go out while someone is still talking. |
| + public static let floor: Double = 0.3 |
| + /// Time constants, in seconds: how quickly the glow rises to a louder |
| + /// reading and falls to a quieter one (63 % of the way in that time). |
| + public static let attack: Double = 0.05 |
| + public static let release: Double = 0.3 |
| + /// How long sound at a speaking level must keep up before the glow |
| + /// lights: the gap between the first and the latest loud reading in one |
| + /// stretch of speaking. A click, a cough or a door is one reading and |
| + /// never lights it; a word is several. |
| + public static let onset: Double = 0.15 |
| + /// With Reduce Motion: one brightness while lit, and no movement. |
| + public static let steady: Double = 0.6 |
| + /// A change of target smaller than this is not worth a redraw. |
| + public static let resolution: Double = 0.02 |
| + /// Closer than this to its target, the glow has arrived. |
| + static let arrived: Double = 0.005 |
| + |
| + /// Lit: speaking, per SpeakingGate, and past the onset. |
| + public private(set) var lit = false |
| + /// Where the brightness is heading, 0...1. |
| + public private(set) var target: Double = 0 |
| + /// The brightness at `at`, where the approach to `target` began. |
| + private var from: Double = 0 |
| + private var at: Double = 0 |
| + private var loudFrom: Double? |
| + private var loudLast: Double? |
| + |
| + public init() {} |
| + |
| + /// A reading's brightness, 0...1, by decibels between `quietDb` and |
| + /// `loudDb` — before the floor a lit glow never goes under. |
| + public static func intensity(level: Float) -> Double { |
| + let db = MeterScale.decibels(level) |
| + guard db.isFinite else { return 0 } |
| + let x = Double((db - quietDb) / (loudDb - quietDb)) |
| + return min(1, max(0, x)) |
| + } |
| + |
| + /// Takes one reading: the level, whether the person is speaking per |
| + /// the speaking gate, and when. Returns whether what is drawn changes, |
| + /// so the caller publishes only then. |
| + @discardableResult |
| + public mutating func update(level: Float, speaking: Bool, now: Double) -> Bool { |
| + if !speaking { |
| + loudFrom = nil |
| + loudLast = nil |
| + } else if MeterScale.decibels(level) >= SpeakingGate.onDb { |
| + if loudFrom == nil { loudFrom = now } |
| + loudLast = now |
| + } |
| + var nowLit = false |
| + if speaking { |
| + if lit { |
| + nowLit = true |
| + } else if let first = loudFrom, let last = loudLast { |
| + nowLit = last - first >= Self.onset |
| + } |
| + } |
| + let next = nowLit ? Self.floor + (1 - Self.floor) * Self.intensity(level: level) : 0 |
| + if nowLit == lit, abs(next - target) < Self.resolution { return false } |
| + from = value(at: now) |
| + at = now |
| + target = next |
| + lit = nowLit |
| + return true |
| + } |
| + |
| + /// The brightness at `now`, 0...1. With Reduce Motion, `steady` while |
| + /// lit and nothing otherwise: no approach, so nothing moves. |
| + public func value(at now: Double, reduceMotion: Bool = false) -> Double { |
| + if reduceMotion { return lit ? Self.steady : 0 } |
| + let elapsed = max(0, now - at) |
| + let tau = target > from ? Self.attack : Self.release |
| + return target + (from - target) * exp(-elapsed / tau) |
| + } |
| + |
| + /// When the glow will have arrived at its target, on the same clock: |
| + /// the moment a view can stop redrawing it. |
| + public var settles: Double { |
| + let gap = abs(from - target) |
| + guard gap > Self.arrived else { return at } |
| + let tau = target > from ? Self.attack : Self.release |
| + return at + tau * log(gap / Self.arrived) |
| + } |
| + |
| + /// A glow held at `level`'s brightness, for the snapshots. |
| + @_spi(Fixtures) public static func fixture(level: Float) -> SpeakingGlow { |
| + var glow = SpeakingGlow() |
| + let on = MeterScale.decibels(level) >= SpeakingGate.onDb |
| + glow.lit = on |
| + glow.target = on ? floor + (1 - floor) * intensity(level: level) : 0 |
| + glow.from = glow.target |
| + return glow |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift| index 61ff778..ed82243 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift |
| @@ -210,7 +210,7 @@ final class AudioLevelTests: XCTestCase { |
| peer.show(state: .muted, speaking: false, notices: []) |
| let muted = TileAudioInfo.peer(peer, name: "Bea") |
| - XCTAssertEqual(muted.status, .none, "muted: the badge, and no meter") |
| + XCTAssertEqual(muted.status, .none, "muted: the badge, and no glow") |
| XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true, audio: muted), "Bea, muted") |
| let you = MicAudio() |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| index 3d453a6..e174ddb 100644 |
| --- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -314,15 +314,17 @@ final class CallOverlayContrastTests: XCTestCase { |
| XCTAssertEqual(ratio((1, 1, 1), (0x77 / 255.0, 0x77 / 255.0, 0x77 / 255.0)), 4.48, accuracy: 0.01) |
| let white: RGB = (1, 1, 1) |
| for (name, video) in [("white", white), ("black", (0, 0, 0)), ("grey", (0.2, 0.2, 0.2))] as [(String, RGB)] { |
| + // The black alone, as if neither glass nor blur were drawn: the |
| + // rendered glass over white measures darker than this (#5B5B5B). |
| let surface = over(Color.black.opacity(CallOverlay.scrim), video) |
| - let resting = over(CallOverlay.buttonFill, surface) |
| + let hovered = over(CallOverlay.hoverFill, surface) |
| let checks: [(String, Double, Double)] = [ |
| - ("white on the surface", ratio(white, surface), 7), |
| - ("white symbol on a resting button", ratio(white, resting), 4.5), |
| + ("white on the surface (a resting glyph, a name, the title)", ratio(white, surface), 6), |
| + ("a white glyph under the pointer", ratio(white, hovered), 4.5), |
| ("secondary text", ratio(over(CallOverlay.secondaryText, surface), surface), 4.5), |
| ("alert text", ratio(rgb(CallOverlay.alertText).0, surface), 4.5), |
| - ("an on button against the bar", ratio(rgb(CallOverlay.onFill).0, surface), 3), |
| - ("an on button's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5), |
| + ("an on capsule against the pane", ratio(rgb(CallOverlay.onFill).0, surface), 3), |
| + ("an on capsule's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5), |
| ("white on amber (off)", ratio(white, rgb(CallOverlay.offFill).0), 4.5), |
| ("white on Leave's red", ratio(white, rgb(CallOverlay.leaveFill).0), 4.5), |
| ("the unread badge", ratio(white, rgb(CallOverlay.badgeFill).0), 4.5), |
| @@ -332,6 +334,17 @@ final class CallOverlayContrastTests: XCTestCase { |
| } |
| } |
| } |
| + |
| + /// The Devices panel over the picture: opaque, so the video never |
| + /// enters into it, with the dark appearance's own label colours. |
| + func testTheDevicesPanelIsReadable() { |
| + let panel = rgb(CallOverlay.panelFill).0 |
| + XCTAssertEqual(rgb(CallOverlay.panelFill).1, 1, "opaque") |
| + XCTAssertGreaterThanOrEqual(ratio((1, 1, 1), panel), 15, "primary text") |
| + // secondaryLabelColor in the dark appearance: white at 55 %. |
| + let secondary = over(Color.white.opacity(0.55), panel) |
| + XCTAssertGreaterThanOrEqual(ratio(secondary, panel), 4.5, "secondary text") |
| + } |
| } |
| final class PiPLayoutTests: XCTestCase { |
diff --git a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift| new file mode 100644 |
| index 0000000..1034510 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift |
| @@ -0,0 +1,195 @@ |
| +import XCTest |
| + |
| +@_spi(Fixtures) @testable import CallsKit |
| + |
| +/// The speaking glow's rule (0.3.7) on a clock the tests own: what lights |
| +/// it and what does not, how quickly it rises and falls, silence, and |
| +/// Reduce Motion. |
| +final class SpeakingGlowTests: XCTestCase { |
| + private func level(db: Float) -> Float { pow(10, db / 20) } |
| + |
| + /// Feeds a gate and a glow the same readings, as AudioLevels does. |
| + private struct Voice { |
| + var gate = SpeakingGate() |
| + var glow = SpeakingGlow() |
| + mutating func hear(db: Float, at now: Double) { |
| + let level = db.isFinite ? pow(10, db / 20) : 0 |
| + let speaking = gate.update(level: level, now: now) |
| + glow.update(level: level, speaking: speaking, now: now) |
| + } |
| + } |
| + |
| + func testBrightnessFollowsDecibels() { |
| + XCTAssertEqual(SpeakingGlow.intensity(level: 0), 0) |
| + XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -60)), 0) |
| + XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.quietDb)), 0, accuracy: 1e-6) |
| + XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -27)), 0.5, accuracy: 1e-3) |
| + XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.loudDb)), 1, accuracy: 1e-6) |
| + XCTAssertEqual(SpeakingGlow.intensity(level: 1), 1) |
| + var last = 0.0 |
| + for db in stride(from: Float(-90), through: 0, by: 1) { |
| + let x = SpeakingGlow.intensity(level: level(db: db)) |
| + XCTAssertGreaterThanOrEqual(x, last, "louder is never dimmer") |
| + last = x |
| + } |
| + } |
| + |
| + func testSilenceIsNoGlow() { |
| + var voice = Voice() |
| + for t in stride(from: 0.0, through: 5, by: 0.1) { |
| + voice.hear(db: -.infinity, at: t) |
| + XCTAssertFalse(voice.glow.lit) |
| + XCTAssertEqual(voice.glow.value(at: t), 0) |
| + XCTAssertEqual(voice.glow.value(at: t, reduceMotion: true), 0) |
| + } |
| + // A quiet room's tone, under the speaking level, is not speech either. |
| + for t in stride(from: 5.0, through: 10, by: 0.1) { voice.hear(db: -55, at: t) } |
| + XCTAssertFalse(voice.glow.lit) |
| + } |
| + |
| + func testABriefNoiseDoesNotLightIt() { |
| + // One loud reading — a click, a cough, a door — then quiet. The gate |
| + // calls that speaking for its hangover; the glow waits for more. |
| + var voice = Voice() |
| + voice.hear(db: -8, at: 0) |
| + XCTAssertTrue(voice.gate.speaking, "the gate is on at once") |
| + for t in stride(from: 0.1, through: 2, by: 0.1) { |
| + voice.hear(db: -70, at: t) |
| + XCTAssertFalse(voice.glow.lit, "at \(t)s") |
| + XCTAssertEqual(voice.glow.value(at: t), 0) |
| + } |
| + // Two clicks a second and a half apart are two brief noises, not a |
| + // voice, read at the peers' five times a second. |
| + voice.hear(db: -8, at: 3) |
| + for t in stride(from: 3.2, through: 4.2, by: 0.2) { voice.hear(db: -70, at: t) } |
| + voice.hear(db: -8, at: 4.4) |
| + for t in stride(from: 4.6, through: 5.4, by: 0.2) { |
| + voice.hear(db: -70, at: t) |
| + XCTAssertFalse(voice.glow.lit, "at \(t)s") |
| + } |
| + } |
| + |
| + func testSpeechLightsItOnceItKeepsUp() { |
| + var voice = Voice() |
| + voice.hear(db: -20, at: 0) |
| + XCTAssertFalse(voice.glow.lit, "one reading is not yet a voice") |
| + voice.hear(db: -20, at: 0.1) |
| + XCTAssertFalse(voice.glow.lit) |
| + voice.hear(db: -20, at: 0.2) |
| + XCTAssertTrue(voice.glow.lit, "sound at a speaking level for \(SpeakingGlow.onset)s is") |
| + XCTAssertEqual( |
| + voice.glow.target, |
| + SpeakingGlow.floor + (1 - SpeakingGlow.floor) * SpeakingGlow.intensity(level: level(db: -20)), |
| + accuracy: 1e-9) |
| + // Between syllables it dims but stays lit, as the gate does. |
| + voice.hear(db: -45, at: 0.3) |
| + XCTAssertTrue(voice.glow.lit) |
| + XCTAssertEqual(voice.glow.target, SpeakingGlow.floor, accuracy: 1e-9) |
| + // And goes out when the gate lets go. |
| + for t in stride(from: 0.4, through: 1.2, by: 0.1) { voice.hear(db: -70, at: t) } |
| + XCTAssertFalse(voice.glow.lit) |
| + XCTAssertEqual(voice.glow.target, 0) |
| + } |
| + |
| + func testItRisesQuicklyAndFallsSlowly() { |
| + var glow = SpeakingGlow() |
| + let loud = level(db: -12) |
| + glow.update(level: loud, speaking: true, now: 0) |
| + glow.update(level: loud, speaking: true, now: 0.15) |
| + XCTAssertTrue(glow.lit) |
| + XCTAssertEqual(glow.value(at: 0.15), 0, accuracy: 1e-9, "from where it was") |
| + // Attack: 63 % of the way in one time constant, all of it in five. |
| + XCTAssertEqual( |
| + glow.value(at: 0.15 + SpeakingGlow.attack), 1 - exp(-1), accuracy: 1e-6) |
| + XCTAssertGreaterThan(glow.value(at: 0.15 + 5 * SpeakingGlow.attack), 0.99) |
| + XCTAssertLessThan(glow.settles, 0.15 + 6 * SpeakingGlow.attack, "and the view stops redrawing") |
| + |
| + // Release: silence at 1 s, and it fades over hundreds of milliseconds, |
| + // not at once and not at the attack's pace. |
| + glow.update(level: 0, speaking: false, now: 1) |
| + XCTAssertFalse(glow.lit) |
| + XCTAssertEqual(glow.value(at: 1), 1, accuracy: 1e-3) |
| + XCTAssertEqual(glow.value(at: 1 + SpeakingGlow.release), exp(-1), accuracy: 1e-3) |
| + XCTAssertGreaterThan(glow.value(at: 1 + SpeakingGlow.attack), 0.8, "falls slower than it rises") |
| + XCTAssertLessThan(glow.value(at: 1 + 5 * SpeakingGlow.release), 0.01) |
| + XCTAssertGreaterThan(glow.settles, 1 + 4 * SpeakingGlow.release) |
| + XCTAssertLessThan(glow.settles, 1 + 7 * SpeakingGlow.release) |
| + XCTAssertTrue((0.25...0.4).contains(SpeakingGlow.release)) |
| + XCTAssertEqual(SpeakingGlow.attack, 0.05) |
| + } |
| + |
| + func testAReadingMidwayCarriesOnFromWhereItWas() { |
| + var glow = SpeakingGlow() |
| + glow.update(level: level(db: -12), speaking: true, now: 0) |
| + glow.update(level: level(db: -12), speaking: true, now: 0.2) |
| + let midway = glow.value(at: 0.225) |
| + XCTAssertTrue(glow.update(level: level(db: -40), speaking: true, now: 0.225)) |
| + XCTAssertEqual(glow.value(at: 0.225), midway, accuracy: 1e-9, "no jump when the target moves") |
| + } |
| + |
| + func testAReadingThatChangesNothingVisibleIsNotAChange() { |
| + var glow = SpeakingGlow() |
| + XCTAssertFalse(glow.update(level: 0, speaking: false, now: 0), "silence stays silence") |
| + glow.update(level: level(db: -20), speaking: true, now: 1) |
| + XCTAssertTrue(glow.update(level: level(db: -20), speaking: true, now: 1.2), "lights") |
| + XCTAssertFalse( |
| + glow.update(level: level(db: -20.1), speaking: true, now: 1.3), "a tenth of a dB is not a redraw") |
| + } |
| + |
| + func testReduceMotionIsOneSteadyGlow() { |
| + var glow = SpeakingGlow() |
| + glow.update(level: level(db: -40), speaking: true, now: 0) |
| + XCTAssertEqual(glow.value(at: 0, reduceMotion: true), 0, "not lit yet: nothing") |
| + glow.update(level: level(db: -10), speaking: true, now: 0) |
| + glow.update(level: level(db: -10), speaking: true, now: 0.2) |
| + for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] { |
| + glow.update(level: level(db: db), speaking: true, now: t) |
| + for dt in [0, 0.01, 0.1, 1.0] { |
| + XCTAssertEqual(glow.value(at: t + dt, reduceMotion: true), SpeakingGlow.steady) |
| + } |
| + } |
| + glow.update(level: 0, speaking: false, now: 1) |
| + XCTAssertEqual(glow.value(at: 1, reduceMotion: true), 0, "out at once, no fade") |
| + } |
| + |
| + func testTheFixtureHoldsALevel() { |
| + XCTAssertFalse(SpeakingGlow.fixture(level: level(db: -60)).lit) |
| + let mid = SpeakingGlow.fixture(level: level(db: -20)) |
| + XCTAssertTrue(mid.lit) |
| + XCTAssertEqual(mid.value(at: 0), mid.value(at: 100), "still") |
| + } |
| + |
| + @MainActor func testTheTilesAndTheTabGlow() { |
| + let levels = AudioLevels() |
| + let unmuted = PeerMedia(muted: false, cameraOff: false) |
| + func tick(_ t: Double, bea: Float, cy: Float) { |
| + levels.updatePeer( |
| + "bea", name: "Bea", |
| + sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: bea)), |
| + notices: [], now: t) |
| + levels.updatePeer( |
| + "cy", name: "Cy", |
| + sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: cy)), |
| + notices: [], now: t) |
| + levels.settle(present: ["bea", "cy"]) |
| + } |
| + for t in stride(from: 0.0, through: 1, by: 0.2) { tick(t, bea: -15, cy: -60) } |
| + XCTAssertTrue(levels.peer("bea").glow.glow.lit) |
| + XCTAssertFalse(levels.peer("cy").glow.glow.lit) |
| + XCTAssertEqual(levels.othersGlow.glow, levels.peer("bea").glow.glow, "the tab follows Bea") |
| + levels.settle(present: ["cy"]) |
| + XCTAssertFalse(levels.peer("bea").glow.glow.lit, "someone who left does not glow") |
| + XCTAssertFalse(levels.othersGlow.glow.lit) |
| + |
| + // Your own tile, and muting puts it out. |
| + let mic = { (muted: Bool, db: Float) in |
| + MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db)) |
| + } |
| + for t in stride(from: 0.0, through: 0.5, by: 0.1) { |
| + levels.updateMic(mic(false, -15), now: t, source: "", channels: false) |
| + } |
| + XCTAssertTrue(levels.mic.glow.glow.lit) |
| + levels.updateMic(mic(true, -15), now: 0.6, source: "", channels: false) |
| + XCTAssertFalse(levels.mic.glow.glow.lit) |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index d6b4391..9b1f839 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -396,7 +396,7 @@ struct PhoneVideoTile: View { |
| /// Behind a picture that is not there: a shade lighter than the stage |
| /// for a tile among others, so it reads as a tile. |
| var backdrop = Color.black |
| - /// The sound on this tile (spec §15.20): your microphone's meter, or a |
| + /// The sound on this tile (spec §15.20): your microphone's glow, or a |
| /// remote person's with "No sound from" and their notices. Nil for a |
| /// screen. |
| var audio: TileAudio? = nil |
| @@ -425,20 +425,23 @@ struct PhoneVideoTile: View { |
| VStack(alignment: .leading, spacing: 4) { |
| TileAudioNotices(info) |
| HStack(spacing: 5) { |
| - if compact { |
| - TileNameLabel(label: label, muted: muted) |
| - } else { |
| - Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1) |
| - .padding(.horizontal, 10).padding(.vertical, 6) |
| - .background(.black.opacity(0.65), in: Capsule()) |
| + Group { |
| + if compact { |
| + TileNameLabel(label: label, muted: muted) |
| + } else { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1) |
| + .padding(.horizontal, 10).padding(.vertical, 6) |
| + .callOverlaySurface(Capsule()) |
| + } |
| } |
| + // Speaking is the glow behind the name, as bright as the voice. |
| + .speakingGlow(audio?.glow, in: Capsule()) |
| TileAudioStatus(info) |
| } |
| } |
| .padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets()) |
| } |
| .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } } |
| - .speakingRing(info?.speaking == true, cornerRadius: cornerRadius) |
| .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
| .accessibilityElement(children: .ignore) |
| .accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info)) |
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift| index b38a236..05bef8a 100644 |
| --- a/native/ios/Sources/PhoneCallFixture.swift |
| +++ b/native/ios/Sources/PhoneCallFixture.swift |
| @@ -8,8 +8,11 @@ |
| /// optionally `-CallsFixtureControls shown|hidden` to pin the controls, |
| /// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark |
| /// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy` |
| - /// for a frame in place of the stage, and `-CallsFixtureSelf |
| - /// muted|cameraOff|sharing` for your own state. |
| + /// for a frame in place of the stage, `-CallsFixtureSelf |
| + /// muted|cameraOff|sharing` for your own state, `-CallsFixtureSpeaking |
| + /// silent|low|mid|high` for everyone's speaking glow, and |
| + /// `-CallsFixtureNoGlass YES` for the material older systems draw in |
| + /// place of Liquid Glass. |
| struct PhoneCallFixture: View { |
| static var requested: CallFixture? { |
| UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) |
| @@ -33,6 +36,7 @@ |
| .environment( |
| \.fixtureBackdrop, |
| defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) |
| + .environment(\.callOverlayGlass, !defaults.bool(forKey: "CallsFixtureNoGlass")) |
| .preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light) |
| .onAppear { |
| guard !applied else { return } |
| @@ -40,6 +44,8 @@ |
| fixture.apply(to: call) |
| UserDefaults.standard.string(forKey: "CallsFixtureSelf") |
| .flatMap(FixtureSelf.init(rawValue:))?.apply(to: call) |
| + UserDefaults.standard.string(forKey: "CallsFixtureSpeaking") |
| + .flatMap(FixtureSpeaking.init(rawValue:))?.apply(to: call) |
| } |
| } |
| } |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index 93cf394..856c147 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -184,7 +184,6 @@ extension PhoneCall { |
| compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| ) |
| .frame(width: pip.width, height: pip.height) |
| - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.white.opacity(0.2))) |
| .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| .onTapGesture { chrome.tapped() } |
| @@ -336,8 +335,8 @@ extension PhoneCall { |
| } |
| } |
| -/// A round call button over the picture: the overlay's dark surface with |
| -/// a white symbol, or filled for End call's red and a muted microphone's |
| +/// A round call button over the picture: a borderless glass circle with a |
| +/// white symbol, or filled for End call's red and a muted microphone's |
| /// amber (see CallOverlay). |
| struct RoundCallButtonStyle: ButtonStyle { |
| let tone: CallOverlayButtonStyle.Tone |
| @@ -359,8 +358,8 @@ struct RoundCallButtonStyle: ButtonStyle { |
| } |
| } |
| -/// A notice over the picture: the overlay's dark surface, white text, and |
| -/// dark-appearance controls whatever the system's. |
| +/// A notice over the picture: the overlay's borderless glass, white text, |
| +/// and dark-appearance controls whatever the system's. |
| struct PhoneNoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| content.font(.callout).foregroundStyle(.white) |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index 07b7ce2..e100302 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -272,6 +272,35 @@ final class CallsPhoneUITests: XCTestCase { |
| } |
| } |
| + /// 0.3.7: the names glowing at four levels, in the grid and in focus, |
| + /// and the glass over black, white and a busy frame, in Liquid Glass and |
| + /// in the material older systems draw instead. |
| + @MainActor func testGlowAndGlassSnapshots() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + for level in ["silent", "low", "mid", "high"] { |
| + for fixture in ["grid", "focus"] { |
| + let app = launchCall( |
| + fixture, ["-CallsFixtureControls", "shown", "-CallsFixtureSpeaking", level], dark: true) |
| + XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(app, "glow-\(fixture)-\(level)") |
| + } |
| + } |
| + for glass in [true, false] { |
| + for backdrop in ["black", "white", "busy"] { |
| + let app = launchCall( |
| + "notices", |
| + [ |
| + "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop, |
| + "-CallsMicBesideMenu", "YES", "-CallsFixtureNoGlass", glass ? "NO" : "YES", |
| + ], dark: true) |
| + XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(app, "bar-\(glass ? "glass" : "fallback")-\(backdrop)") |
| + } |
| + } |
| + } |
| + |
| /// Landscape keeps both bar buttons on screen, clear of the Dynamic |
| /// Island's side. |
| @MainActor func testLandscapeKeepsTheBarOnScreen() throws { |
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/native.mjs b/test/browser/native.mjs| index abd8d74..d98bd65 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -143,6 +143,8 @@ try { |
| await wait(() => sample?.stats.micPeakPermille > 10, "the app's own meter sees what it sends"); |
| assert.equal(sample.stats.micSilent, 0, "a live synthetic microphone is not reported as picking nothing up"); |
| console.log(`PASS the app's microphone meter reads what it sends, with no silence warning (noSoundFrom ${sample.stats.noSoundFrom})`); |
| + await wait(() => sample?.stats.micGlow === 1, "the app's own name glows while its synthetic voice speaks"); |
| + console.log('PASS the speaking glow behind the app\'s own name lights for its voice'); |
| } |
| // Choose the system default on the audio device module during the live |
| // call, then refuse an unavailable microphone. Both must leave media and |
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); |