Merge branch 'fix/scarlett-launch-and-speaking-glow' into main
Calls 0.3.7: the Scarlett at first launch (devices from CoreAudio, the engine pinned before it wires), the speaking glow in place of the ring and tile meter, borderless glass controls, and dragging the picture to move the call window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 files changed,
+3060
−549
Makefile+2 −1internal/calls/static/calls.css+96 −46internal/calls/static/room.js+94 −34native/DESIGN.md+38 −1native/README.md+62 −19native/apple/Sources/CallsInterop/CallSnapshots.swift+86 −0native/apple/Sources/CallsInterop/Interop.swift+6 −0native/apple/Sources/CallsKit/Audio.swift+197 −134native/apple/Sources/CallsKit/AudioDevices.swift+513 −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/CallAudio.swift+93 −13native/apple/Sources/CallsKit/CallFixtures.swift+19 −0native/apple/Sources/CallsKit/CallsAppearance.swift+3 −0native/apple/Sources/CallsKit/DevicePanelAudio.swift+2 −2native/apple/Sources/CallsKit/InputChannel.swift+28 −9native/apple/Sources/CallsKit/MacCallView.swift+3 −0native/apple/Sources/CallsKit/MacFullWindowCall.swift+46 −21native/apple/Sources/CallsKit/MacVideoTile.swift+17 −8native/apple/Sources/CallsKit/MiniWindow.swift+6 −5native/apple/Sources/CallsKit/MutedBadge.swift+3 −3native/apple/Sources/CallsKit/SpeakingGlow.swift+122 −0native/apple/Sources/CallsKit/WindowMove.swift+226 −0native/apple/Tests/CallsKitTests/AudioHealthTests.swift+14 −6native/apple/Tests/CallsKitTests/AudioLevelTests.swift+1 −1native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+317 −80native/apple/Tests/CallsKitTests/CallChromeTests.swift+18 −5native/apple/Tests/CallsKitTests/InputChannelTests.swift+7 −5native/apple/Tests/CallsKitTests/ScarlettReproTests.swift+57 −0native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift+195 −0native/apple/Tests/CallsKitTests/WindowMoveTests.swift+226 −0native/ios/Sources/CallsPhone.swift+11 −8native/ios/Sources/PhoneCallFixture.swift+8 −2native/ios/Sources/PhoneFullScreenCall.swift+4 −5native/ios/UITests/CallsPhoneUITests.swift+29 −0native/ios/project.yml+1 −1test/browser/call.mjs+98 −19test/browser/native.mjs+2 −0test/browser/room-glow.test.mjs+149 −0test/browser/room-hearing.test.mjs+5 −24
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.472s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 8.611s ? amadan.net/oficina/calls/protocol [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 8.529s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 24.273s
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 1dab73a..b25df2a 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -109,7 +109,12 @@ Full window is the default. The mini window and tab are unchanged. |
| over it and fade with the controls (not in full screen, where the system |
| shows them), a shade behind them keeps them legible, and a 32 pt strip |
| across the top drags the window and double-clicks as the person's title-bar |
| - setting says (`WindowDragStrip`, `TitleBarDoubleClick`). The title pill sits |
| + setting says (`WindowDragStrip`, `TitleBarDoubleClick`). Since 0.3.7 the |
| + picture does too, as in FaceTime (`WindowMove`): a drag anywhere on the |
| + stage that is not a control, a panel or the self view moves the window |
| + once it has gone 4 pt (less is a click, which shows the controls), and a |
| + double click there does what the title bar's would. Resizing is AppKit's |
| + own, from every edge and corner, the top included. The title pill sits |
| to the right of the traffic lights and the notices in a column beneath it. |
| `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot |
| quietly restore it mid-call, and puts it back for the lobby and Classic. |
| @@ -156,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 7d8233e..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 |
| @@ -184,10 +187,9 @@ one mid-call: the watchdog has to restart it and the browser has to decode the |
| picture again on the same connection. |
| The microphone is a picker on both platforms, and on Mac so is the speaker. |
| -On iPhone it is an audio session route. On Mac it is a device on the call's own |
| -WebRTC audio device module — LiveKit's AVAudioEngine module, which lists input |
| -and output devices (the system default among them, following macOS) and |
| -switches mid-call without renegotiating. A choice there moves this call and |
| +On iPhone it is an audio session route. On Mac it is a CoreAudio device that |
| +the call's AVAudioEngine (LiveKit's engine module) is put on; switching mid-call |
| +rebuilds the engine without renegotiating. A choice there moves this call and |
| leaves the Mac's system setting alone. Before the move to LiveKit's build the |
| Mac could only report what macOS had chosen: that build's macOS slice had no |
| audio device API at all. |
| @@ -196,9 +198,10 @@ The Mac's choices follow these rules (`AudioRoutes`, held by |
| `AudioRoutesTests`): |
| - **Remembered** across calls and launches, in UserDefaults: the device's |
| - CoreAudio UID, and its name for while it is missing. Calls 0.3.1 forgot them |
| - at the end of every call. The panel says "Calls remembers these for next |
| - time." |
| + CoreAudio UID, and its name for while it is missing. A device back under a |
| + new UID (a USB interface on another port) is found by that name when exactly |
| + one connected device has it. Calls 0.3.1 forgot them at the end of every |
| + call. The panel says "Calls remembers these for next time." |
| - **System default** is the first entry of each picker, where someone who has |
| never chosen starts, and a choice like any other (remembered when picked). It |
| follows macOS: a new default device, or the device changing its sample rate, |
| @@ -208,9 +211,19 @@ The Mac's choices follow these rules (`AudioRoutes`, held by |
| move it. |
| - A chosen device that is not connected — at join or mid-call — puts the call on |
| the system default for now, and the panel says so ("Scarlett 18i8 isn't |
| - connected; using MacBook Pro Microphone"). The choice is kept, and the call |
| - goes back to the device when it reappears; CoreAudio's device-list property |
| - is listened to for that as well as the module's own report. No poller. |
| + connected; using MacBook Pro Microphone"). "Connected" means CoreAudio's own |
| + device list, never the engine module's. The note never names the missing |
| + device as the one in use. The choice is kept, and the call goes back to the |
| + device when it reappears; CoreAudio's device list and default devices are |
| + listened to for that, as well as the module's own report. No poller. |
| +- A device the engine will not start on (the module gives up without saying |
| + so; the app notices an engine that is not running two seconds after a start, |
| + with no stop since) is set aside: "Calls couldn't open Scarlett 18i8 USB; |
| + using MacBook Pro Microphone", and the call goes back to the default. Choosing |
| + it again, or anything being plugged in or taken out, tries it again. |
| +- Everything above is logged, with device lists, each engine start and the |
| + device its unit is actually on: |
| + `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "audio"' --info`. |
| - Nothing changes Sound settings. Mute is not remembered: every call starts |
| unmuted. |
| - **Input**, for a microphone with more than one input (an audio interface): |
| @@ -226,12 +239,42 @@ The Mac's choices follow these rules (`AudioRoutes`, held by |
| rewires the microphone with the new map — a moment's gap, as a device switch |
| has. Input 1 on a device never mapped is left exactly as the module had it. |
| -The app keeps the choice itself rather than asking the module, because |
| -LiveKit's engine module (M150) cannot say which device it is on — |
| -`inputDevice` and `outputDevice` always read back nil — and |
| -`trySetInputDevice`/`trySetOutputDevice` report success and failure the wrong |
| -way round. The device list is reliable, so a device that is not listed is |
| -refused, and one that leaves the list has disconnected. |
| +**How the engine is put on a device** (`AudioDevices.swift`, 0.3.7). Up to |
| +0.3.6 the app chose through LiveKit's module (`inputDevice = …`). Paul's |
| +Scarlett 18i8 was not picked up at launch and would not stay chosen, and the |
| +module's source (webrtc-sdk `m150_release`, `audio_engine_device.mm`) and a |
| +probe of it show four reasons, any one of them enough: |
| + |
| +1. The module lists only "default" until the first peer connection |
| + initialises it, and raises no device-update callback when it does. At |
| + launch the remembered Scarlett was missing from a one-entry list, so the |
| + call fell back with "Scarlett 18i8 USB isn't connected; using …", and the |
| + speaker's note named the Scarlett as the fallback because it was the |
| + default output. |
| +2. Its list leaves out every virtual or aggregate device, and any device whose |
| + input streams have an unknown terminal type while it also has outputs. |
| +3. It wires the microphone in with the *previous* device's format and only |
| + then moves the engine's unit to the chosen device, so a device at another |
| + sample rate starts silent or not at all. A start that fails has already |
| + released the old engine, so the call is left with no audio. |
| +4. `trySetInputDevice`/`trySetOutputDevice` answer YES on failure and NO on |
| + success, and `inputDevice`/`outputDevice` always read back nil. |
| + |
| +So the module is left on its default and never asked to choose. The pickers |
| +list CoreAudio's devices (ready at launch, virtual and aggregate devices |
| +included), and the module's "will enable" hook puts the engine's unit on the |
| +chosen device before the module reads a format from it. On a Mac, |
| +AVAudioEngine's input and output are one audio unit (measured: by default it |
| +runs on macOS's private `CADefaultDeviceAggregate` of the two defaults), so a |
| +microphone and speaker that are different devices are joined in a private |
| +aggregate of the app's own: microphone first (AVAudioEngine will not start an |
| +aggregate whose first device has no input), the speaker's clock leading, the |
| +microphone drift-corrected. A microphone with outputs of its own has the |
| +engine's output mapped past them. With nothing chosen the engine is left |
| +exactly as before, on macOS's own default. A change mid-call rebuilds the |
| +engine (both sides taken away and given back), a moment's gap in both |
| +directions. The device the unit is actually on is read back as the engine |
| +starts, and is what the readout names. |
| Calls 0.2.1 (build 3) routed audio differently: its own CoreAudio audio |
| device (`CallsAudio`, one pair of HAL units per call) behind WebRTC's |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| index 5ce2c9c..2d1336a 100644 |
| --- a/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -41,6 +41,13 @@ import SwiftUI |
| try await shot("notices-hidden", .notices, shown: false) |
| try await shot("classic-focus", .focus, style: .classic) |
| try await shot("classic-grid", .grid, grid: true, style: .classic) |
| + // The self view in the corner the title pill shares: it sits below |
| + // the pill while the controls show. |
| + let corner = UserDefaults.standard.string(forKey: PiPCorner.key) |
| + UserDefaults.standard.set(PiPCorner.topLeading.rawValue, forKey: PiPCorner.key) |
| + try await shot("pip-top-leading-controls", .focus) |
| + try await shot("pip-top-leading-hidden", .focus, shown: false) |
| + UserDefaults.standard.set(corner, forKey: PiPCorner.key) |
| // The overlay over the worst pictures there are for it, in each of |
| // your own states: it should look the same in light and dark. |
| for backdrop in FixtureBackdrop.allCases { |
| @@ -52,4 +59,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/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift| index 441b2f4..207b7b2 100644 |
| --- a/native/apple/Sources/CallsKit/Audio.swift |
| +++ b/native/apple/Sources/CallsKit/Audio.swift |
| @@ -21,11 +21,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// The audio devices a call is on, and the ability to change them. |
| /// |
| /// On iPhone the microphone is a route on the audio session. On Mac it is |
| -/// a device on WebRTC's audio device module: the AVAudioEngine module in |
| -/// LiveKit's WebRTC build lists input and output devices and switches |
| -/// between them mid-call, which the build this app used before could not |
| -/// do at all (its macOS slice had no audio device API). Until a call has |
| -/// made that module, the Mac lists are empty. |
| +/// a CoreAudio device the call's audio engine is put on (AudioDevices.swift |
| +/// says why the engine module is no longer asked to choose). Until a call |
| +/// has made its audio, the Mac lists are empty. |
| /// |
| /// On Mac the person's choices are remembered — across calls and across |
| /// launches — and the rules are: |
| @@ -36,26 +34,23 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// changing its sample rate, is the module's to follow, and it does (it |
| /// listens for both and rebuilds its engine). |
| /// - A chosen device is kept by its CoreAudio UID, with its name beside it |
| -/// for while it is missing. A change of system default does not move it. |
| +/// for while it is missing, and found by that name when it comes back |
| +/// under another UID (a USB interface moved to another port). A change of |
| +/// system default does not move it. |
| /// - A chosen device that is not connected — at join, or disconnecting |
| /// mid-call — puts the call on the system default for now, and |
| /// `routeNote` says so ("Scarlett 18i8 isn't connected; using MacBook Pro |
| -/// Microphone"). The choice is kept, and when the device comes back the |
| -/// call goes back to it on its own. |
| +/// Microphone"). "Connected" is CoreAudio's device list, which is ready at |
| +/// launch; the engine module's list, which is not, is never asked. The |
| +/// choice is kept, and when the device comes back the call goes back to |
| +/// it on its own. |
| +/// - A device the engine cannot start on is said as such ("Calls couldn't |
| +/// open …"), the call goes back to the default, and choosing it again, or |
| +/// the device list changing, tries it again. |
| /// - Nothing here changes the Mac's Sound settings. |
| /// |
| /// Calls 0.3.1 forgot the choices at the end of every call, so a Scarlett |
| /// chosen yesterday was the MacBook's microphone today (Paul, 2026-09-24). |
| -/// |
| -/// The choice is kept here rather than read back from the module, because |
| -/// the engine module in this build cannot say which device it is on: |
| -/// `AudioEngineDevice` does not implement `GetRecordingDevice` or |
| -/// `GetPlayoutDevice`, so `inputDevice` and `outputDevice` always read back |
| -/// nil. `trySetInputDevice`'s answer cannot be used either — it reports |
| -/// success when the native call returns non-zero, which is failure. What |
| -/// can be relied on is the device list, so a choice is refused when its |
| -/// device is not listed, and a disconnection is noticed when it leaves the |
| -/// list. |
| @MainActor public final class AudioRoutes: ObservableObject { |
| @Published public private(set) var inputs: [AudioDevice] = [] |
| @Published public private(set) var currentInput: String? |
| @@ -70,8 +65,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| public let selectable = true |
| #if os(macOS) |
| - /// Said while a remembered device is missing and the call is on the |
| - /// system default instead. Clears itself when the device is back. |
| + /// Said while a remembered device is missing, or could not be opened, |
| + /// and the call is on the system default instead. Clears itself when |
| + /// the device is back. |
| @Published public private(set) var routeNote = "" |
| /// The microphone's input channels, labelled, when it has more than |
| /// one (an audio interface); empty for an ordinary microphone. See |
| @@ -80,15 +76,20 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// The input the call sends from, 0-based. Remembered per device. |
| @Published public private(set) var inputChannel = 0 |
| /// The microphone and, on an interface, its input: "Scarlett 18i8 USB, |
| - /// input 1". Empty before a call has a module. |
| + /// input 1". Empty before a call has audio. |
| @Published public private(set) var inputSource = "" |
| private var inputDevice: InputDeviceChannels? |
| - private var control: AudioModuleControl? |
| + private var control: AudioEngineControl? |
| let defaults: UserDefaults |
| - /// The device each side of the module was last moved to. Nil is where |
| - /// the module starts: following the system default. |
| - private var appliedInput: String? |
| - private var appliedOutput: String? |
| + /// Devices the engine could not start on, by UID, with why. Cleared |
| + /// when the person chooses the device again or what is connected |
| + /// changes. |
| + private var failed: [String: String] = [:] |
| + /// The microphone the engine was last seen running on, read back from |
| + /// its unit — the truth when it is known. |
| + private var engineInput: String? |
| + private var lastInventory: AudioInventory? |
| + private var lastDecision = "" |
| private var listener: AudioObjectPropertyListenerBlock? |
| #endif |
| @@ -118,49 +119,48 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| update(\.currentInput, nil) |
| update(\.currentOutput, nil) |
| update(\.outputName, "") |
| + update(\.routeNote, "") |
| return |
| } |
| - let ins = control.devices(true) |
| - let outs = control.devices(false) |
| - let input = follow(AudioPreference.load(input: true, defaults), ins, input: true, applied: &appliedInput) |
| - let output = follow(AudioPreference.load(input: false, defaults), outs, input: false, applied: &appliedOutput) |
| + let inventory = control.inventory() |
| + if inventory != lastInventory { |
| + // Something was plugged in or taken out: a device that could not |
| + // be opened gets another try. |
| + if let last = lastInventory, Set(last.devices.map(\.uid)) != Set(inventory.devices.map(\.uid)) { |
| + failed = [:] |
| + } |
| + lastInventory = inventory |
| + AudioLog.audio.info( |
| + "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; "), privacy: .public); default in \(inventory.defaultInput ?? "none", privacy: .public), out \(inventory.defaultOutput ?? "none", privacy: .public)") |
| + control.logModuleLists() |
| + } |
| + let input = AudioRouteRules.resolve( |
| + AudioPreference.load(input: true, defaults), inventory, input: true, failed: failed) |
| + let output = AudioRouteRules.resolve( |
| + AudioPreference.load(input: false, defaults), inventory, input: false, failed: failed) |
| + input.remember?.save(input: true, defaults) |
| + output.remember?.save(input: false, defaults) |
| + let decision = "input \(input.current): \(input.reason); output \(output.current): \(output.reason)" |
| + if decision != lastDecision { |
| + lastDecision = decision |
| + AudioLog.audio.info("routes: \(decision, privacy: .public)") |
| + } |
| + if control.pin(input.explicit, output.explicit) { |
| + AudioLog.audio.info( |
| + "routes: engine asked for input \(input.explicit ?? "default", privacy: .public), output \(output.explicit ?? "default", privacy: .public); rebuilding a running engine") |
| + control.rebuild() |
| + } |
| update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " ")) |
| - update(\.inputs, ins.map(AudioRoutes.device)) |
| - update(\.outputs, outs.map(AudioRoutes.device)) |
| + update(\.inputs, AudioRoutes.list(inventory, input: true)) |
| + update(\.outputs, AudioRoutes.list(inventory, input: false)) |
| update(\.currentInput, input.current) |
| update(\.currentOutput, output.current) |
| update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "") |
| - readChannels(input.current) |
| + readChannels(input.explicit ?? inventory.defaultInput) |
| #endif |
| } |
| #if os(macOS) |
| - /// One side of the module, brought into line with the remembered |
| - /// choice: the chosen device when it is listed, the system default |
| - /// when it is not (with a note), and the module moved only when that |
| - /// is somewhere other than where it already is. |
| - private func follow( |
| - _ preference: AudioPreference?, _ list: [ModuleDevice], input: Bool, applied: inout String? |
| - ) -> (current: String?, note: String?) { |
| - guard let control else { return (nil, nil) } |
| - let fallback = list.first(where: \.isDefault) |
| - var target = fallback?.id |
| - var note: String? |
| - if let preference, !preference.id.isEmpty { |
| - if list.contains(where: { $0.id == preference.id }) { |
| - target = preference.id |
| - } else { |
| - let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name |
| - let using = fallback.map { $0.name } ?? "the system default" |
| - note = "\(what) isn’t connected; using \(using)." |
| - } |
| - } |
| - if let target, (applied ?? fallback?.id) != target, control.select(target, input) { |
| - applied = target |
| - } |
| - return (target, note) |
| - } |
| - |
| /// The panel refreshes while it is open; publishing only what changed |
| /// keeps an open menu from being rebuilt under the pointer. |
| private func update<Value: Equatable>( |
| @@ -169,49 +169,79 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| if self[keyPath: key] != value { self[keyPath: key] = value } |
| } |
| - func attach(_ module: RTCAudioDeviceModule) { |
| - attach(AudioModuleControl(module)) |
| + /// "System default (MacBook Pro Microphone)" first, then every device |
| + /// CoreAudio has for that side. |
| + nonisolated static func list(_ inventory: AudioInventory, input: Bool) -> [AudioDevice] { |
| + let head = inventory.systemDefault(input).map { |
| + AudioDevice(id: AudioRouteRules.defaultID, name: "System default (\($0.name))", selectable: true) |
| + } ?? AudioDevice(id: AudioRouteRules.defaultID, name: "System default", selectable: true) |
| + return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) } |
| + } |
| + |
| + func attach(_ module: RTCAudioDeviceModule, pin: AudioEnginePin) { |
| + attach(AudioEngineControl(module, pin: pin)) |
| listenForDevices() |
| } |
| - func attach(_ control: AudioModuleControl) { |
| + func attach(_ control: AudioEngineControl) { |
| self.control = control |
| - appliedInput = nil |
| - appliedOutput = nil |
| + failed = [:] |
| + engineInput = nil |
| + lastInventory = nil |
| + lastDecision = "" |
| refresh() |
| } |
| - /// CoreAudio's own device list, listened to directly: the module |
| - /// reports list changes too, but a remembered device coming back is |
| - /// the moment the call should move to it, and that is worth hearing |
| - /// twice rather than not at all. No poller. |
| + /// CoreAudio's own device list and defaults, listened to directly: the |
| + /// moment a remembered device comes back is the moment the call should |
| + /// move to it. No poller. |
| private func listenForDevices() { |
| guard listener == nil else { return } |
| - var address = AudioObjectPropertyAddress( |
| - mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, |
| - mElement: kAudioObjectPropertyElementMain) |
| let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in |
| DispatchQueue.main.async { MainActor.assumeIsolated { self?.refresh() } } |
| } |
| - if AudioObjectAddPropertyListenerBlock( |
| - AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr |
| - { |
| - listener = block |
| + var ok = false |
| + for selector in [ |
| + kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice, |
| + kAudioHardwarePropertyDefaultOutputDevice, |
| + ] { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| + ok = |
| + AudioObjectAddPropertyListenerBlock( |
| + AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block) == noErr || ok |
| } |
| + if ok { listener = block } |
| } |
| - /// The module lists the system default as a device of its own, which |
| - /// follows whatever macOS is set to. Said as such, so choosing it is |
| - /// understood as "whatever the Mac uses" rather than one microphone. |
| - nonisolated static func device(_ io: ModuleDevice) -> AudioDevice { |
| - AudioDevice( |
| - id: io.id, name: io.isDefault ? "System default (\(io.name))" : io.name, |
| - selectable: true) |
| + /// The engine started on a unit whose microphone is `uid`: what the |
| + /// readout and the note name from now on. |
| + func engineStarted(input uid: String?) { |
| + guard uid != engineInput else { return } |
| + engineInput = uid |
| + AudioLog.audio.info("engine running with input \(uid ?? "unknown", privacy: .public)") |
| + readChannels(currentInput == AudioRouteRules.defaultID ? lastInventory?.defaultInput : currentInput) |
| + } |
| + |
| + /// The engine did not start on the devices it was put on. They are |
| + /// set aside, the call goes back to the default, and the panel says so. |
| + func engineFailed(_ target: EngineTarget, reason: String) { |
| + let uids: [String] |
| + switch target { |
| + case .systemDefault: return |
| + case .device(let uid): uids = [uid] |
| + case .pair(let input, let output): uids = [input, output] |
| + } |
| + let (input, output) = control?.choices() ?? (nil, nil) |
| + for uid in uids where uid == input || uid == output { failed[uid] = reason } |
| + AudioLog.audio.error( |
| + "engine failed on \(target.description, privacy: .public): \(reason, privacy: .public); falling back to the system default") |
| + refresh() |
| } |
| /// Chooses the microphone on Mac, mid-call if one is up, and remembers |
| - /// it. The engine is reconfigured underneath the running track; |
| - /// nothing renegotiates. An empty id is the system default. |
| + /// it. The engine is rebuilt on the device underneath the running |
| + /// track; nothing renegotiates. An empty id is the system default. |
| public func use(input id: String) throws { |
| try choose(id, input: true, what: "microphone") |
| } |
| @@ -224,18 +254,25 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// UID), so that it is followed rather than held. |
| private func choose(_ id: String, input: Bool, what: String) throws { |
| guard let control else { throw AudioRoutes.gone(what) } |
| - let list = control.devices(input) |
| - let device = id.isEmpty ? list.first(where: \.isDefault) : list.first { $0.id == id } |
| - guard let device, control.select(device.id, input) else { throw AudioRoutes.gone(what) } |
| - if input { appliedInput = device.id } else { appliedOutput = device.id } |
| - AudioPreference(id: device.isDefault ? "" : device.id, name: device.isDefault ? "" : device.name) |
| - .save(input: input, defaults) |
| + if id.isEmpty || id == AudioRouteRules.defaultID { |
| + AudioPreference(id: "", name: "").save(input: input, defaults) |
| + AudioLog.audio.info("chosen \(what, privacy: .public): system default") |
| + refresh() |
| + return |
| + } |
| + guard let device = control.inventory().list(input).first(where: { $0.uid == id }) else { |
| + AudioLog.audio.error("chosen \(what, privacy: .public) \(id, privacy: .public) is not connected; refused") |
| + throw AudioRoutes.gone(what) |
| + } |
| + failed[device.uid] = nil |
| + AudioPreference(id: device.uid, name: device.name).save(input: input, defaults) |
| + AudioLog.audio.info("chosen \(what, privacy: .public): \(device.describe, privacy: .public)") |
| refresh() |
| } |
| /// The current microphone's channels and the remembered one of them. |
| - private func readChannels(_ id: String?) { |
| - let device = control.flatMap { $0.channels(id) } |
| + private func readChannels(_ uid: String?) { |
| + let device = control.flatMap { $0.channels(uid) } |
| inputDevice = device |
| let channel = device.map { InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults) } ?? 0 |
| update(\.inputChannels, device.map { d in d.count > 1 ? (0..<d.count).map(d.label) : [] } ?? []) |
| @@ -327,9 +364,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| } |
| #if os(macOS) |
| - /// A remembered microphone or speaker: the CoreAudio UID the module |
| - /// lists it by, and its name for while it is missing. An empty UID is |
| - /// the system default, chosen. |
| + /// A remembered microphone or speaker: its CoreAudio UID, and its name |
| + /// for while it is missing (and for finding it again under a new UID). |
| + /// An empty UID is the system default, chosen. |
| struct AudioPreference: Equatable, Sendable { |
| var id: String |
| var name: String |
| @@ -349,64 +386,82 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| } |
| } |
| - /// A device as the audio device module lists it. |
| + /// A device as the audio device module lists it. Logged beside |
| + /// CoreAudio's list, never chosen from. |
| struct ModuleDevice: Equatable, Sendable { |
| let id: String |
| let name: String |
| let isDefault: Bool |
| } |
| - /// The audio device module as the routing rules see it: two lists and a |
| - /// way to move the call. The real one is LiveKit's engine module; the |
| - /// tests give it lists and record the moves, so the rules are held |
| - /// without opening a microphone. |
| - struct AudioModuleControl { |
| - var devices: (_ input: Bool) -> [ModuleDevice] |
| - /// False when the device is not listed. See AudioRoutes for why the |
| - /// module's own answer is not asked. |
| - var select: (_ id: String, _ input: Bool) -> Bool |
| - /// The input channels of the device behind a module device id. |
| - var channels: (_ id: String?) -> InputDeviceChannels? |
| + /// The call's audio as the routing rules see it: what is connected, and |
| + /// a way to put the engine on a device. The real one is CoreAudio and |
| + /// LiveKit's engine module; the tests give it an inventory and record |
| + /// what is asked, so the rules are held without opening a microphone. |
| + struct AudioEngineControl { |
| + var inventory: () -> AudioInventory |
| + /// Hands the explicit choices to the engine. True when an engine is |
| + /// running on something else and has to be rebuilt to move. |
| + var pin: (_ input: String?, _ output: String?) -> Bool |
| + var choices: () -> (input: String?, output: String?) |
| + /// Makes the module build its engine again, so the pin applies now. |
| + var rebuild: () -> Void |
| + /// The input channels of the device with this UID (nil: the default). |
| + var channels: (_ uid: String?) -> InputDeviceChannels? |
| /// Makes the module wire its microphone in again, so a new channel |
| /// map is applied. A no-op while it is not capturing: the map is read |
| /// whenever the input is next wired in. |
| var rebuildInput: () -> Void |
| + /// For the log: what the engine module lists, beside CoreAudio. |
| + var logModuleLists: () -> Void |
| init( |
| - devices: @escaping (_ input: Bool) -> [ModuleDevice], |
| - select: @escaping (_ id: String, _ input: Bool) -> Bool, |
| - channels: @escaping (_ id: String?) -> InputDeviceChannels? = { _ in nil }, |
| - rebuildInput: @escaping () -> Void = {} |
| + inventory: @escaping () -> AudioInventory, |
| + pin: @escaping (_ input: String?, _ output: String?) -> Bool = { _, _ in false }, |
| + choices: @escaping () -> (input: String?, output: String?) = { (nil, nil) }, |
| + rebuild: @escaping () -> Void = {}, |
| + channels: @escaping (_ uid: String?) -> InputDeviceChannels? = { _ in nil }, |
| + rebuildInput: @escaping () -> Void = {}, |
| + logModuleLists: @escaping () -> Void = {} |
| ) { |
| - self.devices = devices |
| - self.select = select |
| + self.inventory = inventory |
| + self.pin = pin |
| + self.choices = choices |
| + self.rebuild = rebuild |
| self.channels = channels |
| self.rebuildInput = rebuildInput |
| + self.logModuleLists = logModuleLists |
| } |
| - init(_ module: RTCAudioDeviceModule) { |
| - devices = { [weak module] input in |
| - guard let module else { return [] } |
| - return (input ? module.inputDevices : module.outputDevices).map { |
| - ModuleDevice(id: $0.deviceId, name: $0.name, isDefault: $0.isDefault) |
| - } |
| - } |
| - select = { [weak module] id, input in |
| - guard let module, |
| - let device = (input ? module.inputDevices : module.outputDevices).first(where: { |
| - $0.deviceId == id |
| - }) |
| + init(_ module: RTCAudioDeviceModule, pin enginePin: AudioEnginePin) { |
| + inventory = CoreAudioDevices.inventory |
| + choices = { enginePin.choices } |
| + // A running engine is rebuilt when what it would be put on now is |
| + // not what it was put on. Before an engine exists (the module makes |
| + // one for the first connection) there is nothing to move: the first |
| + // engine is made on the choice. |
| + pin = { [weak module] input, output in |
| + _ = enginePin.set(input: input, output: output) |
| + guard let module, !module.isManualRenderingMode, module.isPlaying || module.isRecording, |
| + let applied = enginePin.lastApplied |
| else { return false } |
| - if input { module.inputDevice = device } else { module.outputDevice = device } |
| - return true |
| + return enginePin.target() != applied |
| } |
| - channels = { id in CoreAudioInput.device(forModuleID: id).flatMap(CoreAudioInput.describe) } |
| - // The module has no "rewire the input" call. Taking the input away |
| - // and giving it back does it: the module recreates its engine for |
| - // output alone, then wires the microphone in again, asking the |
| - // observer first — which is where the map is applied. (Re-selecting |
| - // the same device does nothing: the module ignores a state that has |
| - // not changed.) |
| + // The module has no "rebuild" call. Taking both sides away releases |
| + // its engine; giving them back makes a new one, and the pin is |
| + // applied to it in the "will enable" hook. |
| + rebuild = { [weak module] in |
| + guard let module, !module.isManualRenderingMode else { return } |
| + let saved = module.engineAvailability |
| + _ = module.setEngineAvailability(RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) |
| + let result = module.setEngineAvailability(saved) |
| + AudioLog.audio.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| + } |
| + channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) } |
| + // Taking the input away and giving it back rewires the microphone, |
| + // asking the observer first — which is where the map is applied. |
| + // (Re-selecting the same device does nothing: the module ignores a |
| + // state that has not changed.) |
| rebuildInput = { [weak module] in |
| guard let module, module.isRecording, !module.isManualRenderingMode else { return } |
| _ = module.setEngineAvailability( |
| @@ -414,6 +469,14 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| _ = module.setEngineAvailability( |
| RTCAudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)) |
| } |
| + logModuleLists = { [weak module] in |
| + guard let module else { return } |
| + func line(_ list: [RTCIODevice]) -> String { |
| + list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") |
| + } |
| + AudioLog.audio.info( |
| + "engine module lists (not used to choose): inputs \(line(module.inputDevices), privacy: .public); outputs \(line(module.outputDevices), privacy: .public)") |
| + } |
| } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/AudioDevices.swift b/native/apple/Sources/CallsKit/AudioDevices.swift| new file mode 100644 |
| index 0000000..51acaad |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/AudioDevices.swift |
| @@ -0,0 +1,513 @@ |
| +#if os(macOS) |
| + import AudioToolbox |
| + import AVFoundation |
| + import CoreAudio |
| + import Foundation |
| + import os |
| + |
| + // Which devices a Mac call's audio runs on, and how the call is put on |
| + // them. |
| + // |
| + // Calls 0.3.0 to 0.3.6 chose devices through LiveKit's engine module |
| + // (`inputDevice = …`). Paul's Scarlett 18i8 was not picked up at launch |
| + // and would not stay chosen (2026-09-25). Read from the module's source |
| + // (webrtc-sdk m150_release, audio_engine_device.mm and |
| + // RTCAudioDeviceModule.mm) and measured on a Mac, that route has four |
| + // faults, any one of which loses the Scarlett: |
| + // |
| + // 1. The module lists only "default" until the first peer connection |
| + // initialises it, and says nothing when it does (no device-update |
| + // callback). The app attached to it at launch, found its remembered |
| + // Scarlett missing from a one-entry list, said "Scarlett 18i8 USB |
| + // isn't connected; using …" and put the call on the default. |
| + // 2. Its list drops any device whose input streams have an unknown |
| + // terminal type while it also has outputs, and every virtual or |
| + // aggregate device, so an interface can be missing from it for good. |
| + // 3. It wires the microphone in with the *old* device's format and only |
| + // then moves the engine to the chosen device, so a device at another |
| + // sample rate starts silent or not at all — and a start that fails |
| + // releases the engine it had, leaving the call with no audio. |
| + // 4. `trySetInputDevice` answers YES on failure and NO on success. |
| + // |
| + // So the module is left on its default and never asked to choose. The |
| + // app lists devices from CoreAudio itself (ready at launch), and puts the |
| + // engine on the chosen device from the module's own "will enable" hook, |
| + // before the module reads any format: `AudioEnginePin`. On a Mac the |
| + // engine's input and output are one audio unit, so a microphone and a |
| + // speaker that are different devices are joined in a private aggregate |
| + // device, as macOS itself does for the two system defaults. |
| + |
| + /// The subsystem Paul's `log show` reads: |
| + /// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls"'`. |
| + enum AudioLog { |
| + static let audio = Logger(subsystem: "net.amadan.oficina.calls", category: "audio") |
| + } |
| + |
| + /// One CoreAudio device, as the routing rules need it. |
| + struct SystemAudioDevice: Equatable, Sendable { |
| + var uid: String |
| + var name: String |
| + var inputChannels: Int |
| + var outputChannels: Int |
| + var sampleRate: Double = 0 |
| + var transport: String = "" |
| + |
| + var describe: String { |
| + "\(name) [\(uid)] in=\(inputChannels) out=\(outputChannels) \(Int(sampleRate)) Hz \(transport)" |
| + } |
| + } |
| + |
| + /// What is connected, and which devices macOS has as its defaults. |
| + struct AudioInventory: Equatable, Sendable { |
| + var devices: [SystemAudioDevice] = [] |
| + var defaultInput: String? |
| + var defaultOutput: String? |
| + |
| + var inputs: [SystemAudioDevice] { devices.filter { $0.inputChannels > 0 } } |
| + var outputs: [SystemAudioDevice] { devices.filter { $0.outputChannels > 0 } } |
| + func list(_ input: Bool) -> [SystemAudioDevice] { input ? inputs : outputs } |
| + func systemDefault(_ input: Bool) -> SystemAudioDevice? { |
| + let uid = input ? defaultInput : defaultOutput |
| + return devices.first { $0.uid == uid } |
| + } |
| + } |
| + |
| + /// CoreAudio, read. Property queries only: nothing here opens a device |
| + /// or needs microphone permission. |
| + enum CoreAudioDevices { |
| + /// Private aggregates this app makes (AudioEnginePin), and the one |
| + /// AVAudioEngine makes of the two system defaults. Neither is a device |
| + /// anyone chose. |
| + static let ownAggregatePrefix = "net.amadan.oficina.calls.pair." |
| + static func isInternal(uid: String) -> Bool { |
| + uid.hasPrefix(ownAggregatePrefix) || uid.hasPrefix("CADefaultDeviceAggregate") |
| + } |
| + |
| + static func inventory() -> AudioInventory { |
| + var list: [SystemAudioDevice] = [] |
| + for id in allDeviceIDs() { |
| + guard let uid = CoreAudioInput.string(id, kAudioDevicePropertyDeviceUID), |
| + !isInternal(uid: uid), !isHidden(id) |
| + else { continue } |
| + let device = SystemAudioDevice( |
| + uid: uid, name: CoreAudioInput.string(id, kAudioObjectPropertyName) ?? uid, |
| + inputChannels: channels(id, input: true), outputChannels: channels(id, input: false), |
| + sampleRate: sampleRate(id), transport: transport(id)) |
| + if device.inputChannels > 0 || device.outputChannels > 0 { list.append(device) } |
| + } |
| + return AudioInventory( |
| + devices: list, |
| + defaultInput: defaultDevice(input: true).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }, |
| + defaultOutput: defaultDevice(input: false).flatMap { CoreAudioInput.string($0, kAudioDevicePropertyDeviceUID) }) |
| + } |
| + |
| + static func allDeviceIDs() -> [AudioDeviceID] { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, |
| + mElement: kAudioObjectPropertyElementMain) |
| + var size: UInt32 = 0 |
| + guard |
| + AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) |
| + == noErr, size > 0 |
| + else { return [] } |
| + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size) |
| + guard |
| + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) |
| + == noErr |
| + else { return [] } |
| + return ids |
| + } |
| + |
| + static func defaultDevice(input: Bool) -> AudioDeviceID? { |
| + input ? CoreAudioInput.defaultInput() : defaultOutput() |
| + } |
| + |
| + static func defaultOutput() -> AudioDeviceID? { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: kAudioHardwarePropertyDefaultOutputDevice, |
| + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| + var device = AudioDeviceID(kAudioObjectUnknown) |
| + var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| + let status = AudioObjectGetPropertyData( |
| + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) |
| + return status == noErr && device != kAudioObjectUnknown ? device : nil |
| + } |
| + |
| + static func channels(_ device: AudioDeviceID, input: Bool) -> Int { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: kAudioDevicePropertyStreamConfiguration, |
| + mScope: input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput, |
| + mElement: kAudioObjectPropertyElementMain) |
| + var size: UInt32 = 0 |
| + guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 |
| + else { return 0 } |
| + let raw = UnsafeMutableRawPointer.allocate( |
| + byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment) |
| + defer { raw.deallocate() } |
| + let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1) |
| + guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { return 0 } |
| + return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list)) |
| + } |
| + |
| + static func uint32(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> UInt32? { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| + guard AudioObjectHasProperty(device, &address) else { return nil } |
| + var value: UInt32 = 0 |
| + var size = UInt32(MemoryLayout<UInt32>.size) |
| + return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr ? value : nil |
| + } |
| + |
| + static func isHidden(_ device: AudioDeviceID) -> Bool { |
| + uint32(device, kAudioDevicePropertyIsHidden) == 1 |
| + } |
| + |
| + static func isAlive(_ device: AudioDeviceID) -> Bool { |
| + uint32(device, kAudioDevicePropertyDeviceIsAlive) != 0 |
| + } |
| + |
| + static func sampleRate(_ device: AudioDeviceID) -> Double { |
| + var address = AudioObjectPropertyAddress( |
| + mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, |
| + mElement: kAudioObjectPropertyElementMain) |
| + var rate: Float64 = 0 |
| + var size = UInt32(MemoryLayout<Float64>.size) |
| + return AudioObjectGetPropertyData(device, &address, 0, nil, &size, &rate) == noErr ? rate : 0 |
| + } |
| + |
| + static func transport(_ device: AudioDeviceID) -> String { |
| + guard let type = uint32(device, kAudioDevicePropertyTransportType) else { return "" } |
| + switch type { |
| + case kAudioDeviceTransportTypeBuiltIn: return "built-in" |
| + case kAudioDeviceTransportTypeUSB: return "usb" |
| + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: return "bluetooth" |
| + case kAudioDeviceTransportTypeVirtual: return "virtual" |
| + case kAudioDeviceTransportTypeAggregate: return "aggregate" |
| + case kAudioDeviceTransportTypeThunderbolt: return "thunderbolt" |
| + case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: return "display" |
| + case kAudioDeviceTransportTypeAirPlay: return "airplay" |
| + default: return String(format: "0x%08x", type) |
| + } |
| + } |
| + |
| + static func uid(_ device: AudioDeviceID) -> String? { |
| + CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) |
| + } |
| + |
| + /// The device an audio unit is on. |
| + static func current(of unit: AudioUnit) -> AudioDeviceID? { CoreAudioInput.device(of: unit) } |
| + |
| + static func setCurrent(_ device: AudioDeviceID, on unit: AudioUnit) -> OSStatus { |
| + var device = device |
| + return AudioUnitSetProperty( |
| + unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, |
| + UInt32(MemoryLayout<AudioDeviceID>.size)) |
| + } |
| + } |
| + |
| + /// A remembered choice matched against what is connected, and what the |
| + /// call does about it. Pure: the tests hold every case of it. |
| + struct RouteResolution: Equatable, Sendable { |
| + /// The picker's selection: "default", or the chosen device's UID. |
| + var current: String |
| + /// The device the choice asks the engine for; nil follows the system |
| + /// default. |
| + var explicit: String? |
| + /// Said when the choice cannot be honoured. |
| + var note: String? |
| + /// A preference to store in place of the old one: the device came back |
| + /// under a new UID (a USB interface on another port) and was found by |
| + /// its name. |
| + var remember: AudioPreference? |
| + /// Why, for the log. |
| + var reason: String |
| + } |
| + |
| + enum AudioRouteRules { |
| + static let defaultID = "default" |
| + |
| + /// - Parameters: |
| + /// - failed: devices the engine could not start on, by UID, with why. |
| + static func resolve( |
| + _ preference: AudioPreference?, _ inventory: AudioInventory, input: Bool, |
| + failed: [String: String] = [:] |
| + ) -> RouteResolution { |
| + guard let preference, !preference.id.isEmpty, preference.id != defaultID else { |
| + return RouteResolution( |
| + current: defaultID, explicit: nil, note: nil, |
| + reason: preference == nil ? "nothing chosen: system default" : "system default chosen") |
| + } |
| + let list = inventory.list(input) |
| + var device = list.first { $0.uid == preference.id } |
| + var remember: AudioPreference? |
| + var how = "by UID" |
| + if device == nil, !preference.name.isEmpty { |
| + let named = list.filter { $0.name == preference.name } |
| + if named.count == 1 { |
| + device = named[0] |
| + remember = AudioPreference(id: named[0].uid, name: named[0].name) |
| + how = "by name (UID was \(preference.id))" |
| + } |
| + } |
| + let what = preference.name.isEmpty ? "The \(input ? "microphone" : "speaker") you chose" : preference.name |
| + guard let device else { |
| + return RouteResolution( |
| + current: defaultID, explicit: nil, |
| + note: note("\(what) isn’t connected", fallback: inventory.systemDefault(input), missing: what, input: input), |
| + reason: "remembered \(preference.id) (\(preference.name)) is not in CoreAudio's \(input ? "input" : "output") list") |
| + } |
| + if let why = failed[device.uid] { |
| + return RouteResolution( |
| + current: defaultID, explicit: nil, |
| + note: note("Calls couldn’t open \(device.name)", fallback: inventory.systemDefault(input), missing: device.name, input: input), |
| + remember: remember, reason: "\(device.uid) failed to start: \(why)") |
| + } |
| + return RouteResolution( |
| + current: device.uid, explicit: device.uid, note: nil, remember: remember, |
| + reason: "remembered device connected, matched \(how)") |
| + } |
| + |
| + /// "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone." |
| + /// Never names the missing device as the one in use: when the default |
| + /// has the same name (a second unit of the same model), it is called |
| + /// the Mac's default instead. |
| + static func note(_ head: String, fallback: SystemAudioDevice?, missing: String, input: Bool) -> String { |
| + let generic = "the Mac’s default \(input ? "microphone" : "speaker")" |
| + guard let fallback, !fallback.name.isEmpty, fallback.name != missing else { |
| + return "\(head); using \(generic)." |
| + } |
| + return "\(head); using \(fallback.name)." |
| + } |
| + } |
| + |
| + /// Where the engine's one audio unit should be: left to AVAudioEngine's |
| + /// own default, on one device, or on a pair joined in an aggregate. |
| + enum EngineTarget: Equatable, Sendable, CustomStringConvertible { |
| + case systemDefault |
| + case device(String) |
| + case pair(input: String, output: String) |
| + |
| + /// Planned as for a call that both plays and records, even while the |
| + /// engine only plays: the module makes its engine for playout first and |
| + /// adds the microphone to that same engine later, and a unit moved to |
| + /// another device in between would keep the format it was wired with. |
| + /// |
| + /// - Parameters: |
| + /// - input/output: the explicit choices (nil follows the default). |
| + static func plan(input: String?, output: String?, defaultInput: String?, defaultOutput: String?) |
| + -> EngineTarget |
| + { |
| + guard input != nil || output != nil else { return .systemDefault } |
| + switch (input ?? defaultInput, output ?? defaultOutput) { |
| + case (let i?, let o?): return i == o ? .device(i) : .pair(input: i, output: o) |
| + case (let i?, nil): return .device(i) |
| + case (nil, let o?): return .device(o) |
| + case (nil, nil): return .systemDefault |
| + } |
| + } |
| + |
| + var description: String { |
| + switch self { |
| + case .systemDefault: return "system default" |
| + case .device(let uid): return uid |
| + case .pair(let i, let o): return "\(i) + \(o)" |
| + } |
| + } |
| + } |
| + |
| + /// The chosen devices, handed from the main actor to the module's |
| + /// thread, and applied to each engine the module makes before it reads a |
| + /// format from it. Also where the engine's actual device is read back. |
| + final class AudioEnginePin: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var input: String? |
| + private var output: String? |
| + private var aggregate: (key: EngineTarget, id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? |
| + /// What the last engine was put on, and whether that was an explicit |
| + /// choice. Read on the main actor to tell whether a rebuild is due. |
| + private var applied: EngineTarget? |
| + |
| + /// Returns true when the choice changed. |
| + func set(input: String?, output: String?) -> Bool { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + guard input != self.input || output != self.output else { return false } |
| + self.input = input |
| + self.output = output |
| + return true |
| + } |
| + |
| + var choices: (input: String?, output: String?) { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return (input, output) |
| + } |
| + |
| + var lastApplied: EngineTarget? { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return applied |
| + } |
| + |
| + /// What putting an engine on its devices came to. |
| + enum Outcome: Equatable { |
| + case applied(EngineTarget) |
| + /// The devices could not be set; the engine is on the default. |
| + case failed(EngineTarget, String) |
| + } |
| + |
| + /// The target for the choices as they stand, against the defaults as |
| + /// they stand. Devices that have gone are left out, so a microphone |
| + /// unplugged a moment ago is not asked for. |
| + func target() -> EngineTarget { |
| + let (input, output) = choices |
| + return EngineTarget.plan( |
| + input: input.flatMap(Self.alive), output: output.flatMap(Self.alive), |
| + defaultInput: CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid), |
| + defaultOutput: CoreAudioDevices.defaultDevice(input: false).flatMap(CoreAudioDevices.uid)) |
| + } |
| + |
| + /// Called from the module's "will enable" hook, before it reads the |
| + /// engine's formats and wires anything. |
| + func apply(to engine: AVAudioEngine, recording: Bool) -> Outcome { |
| + let target = target() |
| + lock.lock() |
| + applied = target |
| + lock.unlock() |
| + guard target != .systemDefault else { return .applied(target) } |
| + func fail(_ why: String) -> Outcome { |
| + lock.lock() |
| + applied = .systemDefault |
| + lock.unlock() |
| + return .failed(target, why) |
| + } |
| + guard let device = resolve(target) else { return fail("no device could be made for it") } |
| + // Input and output are one unit on a Mac; asking for `inputNode` |
| + // makes an input, so it is only asked for when recording. |
| + guard let unit = recording ? engine.inputNode.audioUnit : engine.outputNode.audioUnit else { |
| + return fail("the engine has no audio unit") |
| + } |
| + let status = CoreAudioDevices.setCurrent(device, on: unit) |
| + return status == noErr ? .applied(target) : fail("setting the device failed, OSStatus \(status)") |
| + } |
| + |
| + /// The input device behind a device an engine is on, and where its |
| + /// channels start on it (0: our aggregates put the microphone first). |
| + /// Nil for a device that is not one of ours. |
| + func inputLayout(of device: AudioDeviceID) -> (uid: String, offset: Int)? { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + guard let aggregate, aggregate.id == device, case .pair(let input, _) = aggregate.key else { return nil } |
| + return (input, aggregate.offset) |
| + } |
| + |
| + /// The microphone the engine is actually on, read back from its unit: |
| + /// the device itself, the microphone half of one of our aggregates, or |
| + /// the system default input under macOS's own aggregate. |
| + func actualInput(of unit: AudioUnit) -> String? { |
| + guard let device = CoreAudioDevices.current(of: unit), let uid = CoreAudioDevices.uid(device) else { |
| + return nil |
| + } |
| + if let layout = inputLayout(of: device) { return layout.uid } |
| + if CoreAudioDevices.isInternal(uid: uid) { |
| + return CoreAudioDevices.defaultDevice(input: true).flatMap(CoreAudioDevices.uid) |
| + } |
| + return uid |
| + } |
| + |
| + private static func alive(_ uid: String) -> String? { |
| + guard let id = CoreAudioInput.device(uid: uid), CoreAudioDevices.isAlive(id) else { return nil } |
| + return uid |
| + } |
| + |
| + private func resolve(_ target: EngineTarget) -> AudioDeviceID? { |
| + switch target { |
| + case .systemDefault: return nil |
| + case .device(let uid): return CoreAudioInput.device(uid: uid) |
| + case .pair(let input, let output): |
| + lock.lock() |
| + if let aggregate, aggregate.key == target, CoreAudioDevices.isAlive(aggregate.id) { |
| + lock.unlock() |
| + return aggregate.id |
| + } |
| + let old = aggregate |
| + aggregate = nil |
| + lock.unlock() |
| + if let old { AudioHardwareDestroyAggregateDevice(old.id) } |
| + guard let made = Self.makePair(input: input, output: output) else { return nil } |
| + lock.lock() |
| + aggregate = (target, made.id, made.uid, made.offset, made.outputOffset) |
| + lock.unlock() |
| + return made.id |
| + } |
| + } |
| + |
| + /// A private aggregate of a microphone and a speaker. The microphone |
| + /// goes first: AVAudioEngine refuses an aggregate whose first device |
| + /// has no input ("IsFormatSampleRateAndChannelCountValid(outputHWFormat)", |
| + /// measured on macOS 26 with the MacBook's own two), and its inputs then |
| + /// start at channel 1. The speaker's clock leads, with the microphone |
| + /// drift-corrected to it. A microphone with outputs of its own (an |
| + /// interface) puts those ahead of the speaker's, so the engine's output |
| + /// is mapped past them (`outputOffset`, applied when it starts). |
| + private static func makePair(input: String, output: String) |
| + -> (id: AudioDeviceID, uid: String, offset: Int, outputOffset: Int)? |
| + { |
| + let uid = CoreAudioDevices.ownAggregatePrefix + UUID().uuidString |
| + let description: [String: Any] = [ |
| + kAudioAggregateDeviceNameKey: "Calls", |
| + kAudioAggregateDeviceUIDKey: uid, |
| + kAudioAggregateDeviceIsPrivateKey: 1, |
| + kAudioAggregateDeviceMainSubDeviceKey: output, |
| + kAudioAggregateDeviceSubDeviceListKey: [ |
| + [kAudioSubDeviceUIDKey: input, kAudioSubDeviceDriftCompensationKey: 1], |
| + [kAudioSubDeviceUIDKey: output], |
| + ], |
| + ] |
| + var id = AudioDeviceID(kAudioObjectUnknown) |
| + let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &id) |
| + guard status == noErr, id != kAudioObjectUnknown else { |
| + AudioLog.audio.error("aggregate of \(input, privacy: .public) + \(output, privacy: .public) refused, OSStatus \(status)") |
| + return nil |
| + } |
| + let outputOffset = CoreAudioInput.device(uid: input).map { CoreAudioDevices.channels($0, input: false) } ?? 0 |
| + AudioLog.audio.info( |
| + "aggregate \(uid, privacy: .public): in \(input, privacy: .public), out \(output, privacy: .public) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))") |
| + return (id, uid, 0, outputOffset) |
| + } |
| + |
| + /// Sends the engine's output to the speaker's channels of one of our |
| + /// aggregates when the microphone's own outputs come first. Called as |
| + /// the engine starts, once its output format is set. |
| + func mapOutput(on unit: AudioUnit) { |
| + guard let device = CoreAudioDevices.current(of: unit) else { return } |
| + lock.lock() |
| + let offset = aggregate.flatMap { $0.id == device ? $0.outputOffset : nil } ?? 0 |
| + lock.unlock() |
| + guard offset > 0 else { return } |
| + var format = AudioStreamBasicDescription() |
| + var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
| + guard AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &format, &size) == noErr |
| + else { return } |
| + let total = CoreAudioDevices.channels(device, input: false) |
| + let client = Int(format.mChannelsPerFrame) |
| + var map = [Int32](repeating: -1, count: total) |
| + for k in 0..<client where offset + k < total { map[offset + k] = Int32(k) } |
| + let status = AudioUnitSetProperty( |
| + unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Input, 0, &map, |
| + UInt32(MemoryLayout<Int32>.size * map.count)) |
| + AudioLog.audio.info("output channel map past \(offset) microphone outputs: OSStatus \(status)") |
| + } |
| + |
| + /// The aggregate goes with the call's audio. |
| + func close() { |
| + lock.lock() |
| + let old = aggregate |
| + aggregate = nil |
| + lock.unlock() |
| + if let old { AudioHardwareDestroyAggregateDevice(old.id) } |
| + } |
| + |
| + deinit { close() } |
| + } |
| +#endif |
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/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift| index 05772fa..b3f6a25 100644 |
| --- a/native/apple/Sources/CallsKit/CallAudio.swift |
| +++ b/native/apple/Sources/CallsKit/CallAudio.swift |
| @@ -121,6 +121,8 @@ public struct ProcessingReport: Equatable, Sendable { |
| #if os(macOS) |
| private let devices: EngineWatch |
| + /// Where the engine is put (AudioDevices.swift); read by the tests. |
| + var enginePin: AudioEnginePin { devices.pin } |
| #endif |
| /// `audioDevice` is the iPhone tests' silent device. It takes the old |
| @@ -171,12 +173,15 @@ public struct ProcessingReport: Equatable, Sendable { |
| // canceller. WebRTC's software processing is what ran before this |
| // module, and is what runs now. |
| _ = module.setPlatformVoiceProcessingAllowed(false) |
| - devices = EngineWatch(routes: routes, input: input, channels: InputChannelMapper(defaults: routes.defaults)) |
| + let pin = AudioEnginePin() |
| + devices = EngineWatch( |
| + routes: routes, module: module, input: input, pin: pin, |
| + channels: InputChannelMapper(defaults: routes.defaults, layout: pin.inputLayout)) |
| module.observer = devices |
| if input != nil { _ = module.setManualRenderingMode(true) } |
| // Attached in manual rendering too, so the interop harness drives the |
| // same routing code as the app; no device is opened either way. |
| - routes.attach(module) |
| + routes.attach(module, pin: pin) |
| #endif |
| // The listener first, then the delegate: attaching the delegate can |
| // initialise it at once, and that report used to go nowhere. |
| @@ -317,20 +322,32 @@ public struct ProcessingReport: Equatable, Sendable { |
| #if os(macOS) |
| /// The module's observer. The device-list callback keeps the pickers |
| - /// current; configuring the input is where a synthetic source is wired |
| - /// in, or — for a real microphone — where the chosen input of a |
| - /// multi-channel one is mapped (InputChannel.swift). The rest of the |
| - /// protocol is the engine lifecycle, left alone (every one of those |
| - /// answers 0, "carry on") except that the map is applied once more just |
| - /// before the engine starts. |
| + /// current; "will enable" is where the engine is put on the chosen |
| + /// devices, before the module reads a format from it (AudioDevices.swift); |
| + /// configuring the input is where a synthetic source is wired in, or — |
| + /// for a real microphone — where the chosen input of a multi-channel one |
| + /// is mapped (InputChannel.swift). Starting and stopping are watched so a |
| + /// device the engine cannot start on is noticed rather than left silent. |
| private final class EngineWatch: NSObject, RTCAudioDeviceModuleDelegate, @unchecked Sendable { |
| weak var routes: AudioRoutes? |
| + weak var module: RTCAudioDeviceModule? |
| let input: AudioInputSource? |
| + let pin: AudioEnginePin |
| let channels: InputChannelMapper |
| private var node: AVAudioSourceNode? |
| - init(routes: AudioRoutes, input: AudioInputSource?, channels: InputChannelMapper) { |
| + private let lock = NSLock() |
| + /// Starts and stops seen, so a start that is followed by neither a |
| + /// running engine nor a deliberate stop is known to have failed. |
| + private var starts = 0 |
| + private var stops = 0 |
| + init( |
| + routes: AudioRoutes, module: RTCAudioDeviceModule, input: AudioInputSource?, pin: AudioEnginePin, |
| + channels: InputChannelMapper |
| + ) { |
| self.routes = routes |
| + self.module = module |
| self.input = input |
| + self.pin = pin |
| self.channels = channels |
| } |
| @@ -340,6 +357,25 @@ public struct ProcessingReport: Equatable, Sendable { |
| } |
| } |
| + func audioDeviceModule( |
| + _ audioDeviceModule: RTCAudioDeviceModule, willEnableEngine engine: AVAudioEngine, |
| + isPlayoutEnabled: Bool, isRecordingEnabled: Bool, isVoiceProcessingEnabled: Bool |
| + ) -> Int { |
| + guard input == nil, !engine.isInManualRenderingMode else { return 0 } |
| + let outcome = pin.apply(to: engine, recording: isRecordingEnabled) |
| + switch outcome { |
| + case .applied(let target): |
| + AudioLog.audio.info( |
| + "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): on \(target.description, privacy: .public)") |
| + case .failed(let target, let why): |
| + AudioLog.audio.error( |
| + "engine will enable: could not put it on \(target.description, privacy: .public): \(why, privacy: .public)") |
| + DispatchQueue.main.async { [weak self] in |
| + MainActor.assumeIsolated { self?.routes?.engineFailed(target, reason: why) } |
| + } |
| + } |
| + return 0 |
| + } |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, |
| didReceiveSpeechActivityEvent speechActivityEvent: RTCSpeechActivityEvent |
| @@ -354,22 +390,66 @@ public struct ProcessingReport: Equatable, Sendable { |
| // By now the engine has connected the input and set the unit's |
| // client format, so the map is sized to what it will run with. |
| // Only with the microphone in use: asking for `inputNode` makes one. |
| - if input == nil, isRecordingEnabled, !engine.isInManualRenderingMode { |
| - channels.apply(to: engine.inputNode) |
| + guard input == nil, !engine.isInManualRenderingMode else { return 0 } |
| + if isRecordingEnabled { channels.apply(to: engine.inputNode) } |
| + let unit = isRecordingEnabled ? engine.inputNode.audioUnit : engine.outputNode.audioUnit |
| + if isPlayoutEnabled, let unit { pin.mapOutput(on: unit) } |
| + let actual = unit.flatMap(pin.actualInput) |
| + let target = pin.lastApplied ?? .systemDefault |
| + if isRecordingEnabled { |
| + let format = engine.inputNode.inputFormat(forBus: 0) |
| + AudioLog.audio.info( |
| + "engine starting on \(target.description, privacy: .public): microphone \(actual ?? "unknown", privacy: .public), \(format.channelCount) ch at \(Int(format.sampleRate)) Hz") |
| + } else { |
| + AudioLog.audio.info("engine starting on \(target.description, privacy: .public) for playout only") |
| + } |
| + lock.lock() |
| + starts += 1 |
| + let seen = (starts, stops) |
| + lock.unlock() |
| + DispatchQueue.main.async { [weak self] in |
| + MainActor.assumeIsolated { |
| + if isRecordingEnabled { self?.routes?.engineStarted(input: actual) } |
| + } |
| + } |
| + // The module retries a start a few times and then gives up without |
| + // telling its observer. Looked at once it has had time: an engine |
| + // that is not running, with no stop since, did not start. |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in |
| + MainActor.assumeIsolated { |
| + guard let self, let module = self.module else { return } |
| + self.lock.lock() |
| + let now = (self.starts, self.stops) |
| + self.lock.unlock() |
| + guard now == seen, !module.isEngineRunning else { return } |
| + AudioLog.audio.error("engine did not start on \(target.description, privacy: .public)") |
| + self.routes?.engineFailed(target, reason: "the audio engine would not start on it") |
| + } |
| } |
| return 0 |
| } |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine, |
| isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| - ) -> Int { 0 } |
| + ) -> Int { |
| + stopped() |
| + return 0 |
| + } |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, didDisableEngine engine: AVAudioEngine, |
| isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| ) -> Int { 0 } |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine |
| - ) -> Int { 0 } |
| + ) -> Int { |
| + stopped() |
| + return 0 |
| + } |
| + private func stopped() { |
| + lock.lock() |
| + stops += 1 |
| + lock.unlock() |
| + } |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, engine: AVAudioEngine, |
| configureInputFromSource source: AVAudioNode?, toDestination destination: AVAudioNode, |
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/InputChannel.swift b/native/apple/Sources/CallsKit/InputChannel.swift| index a50db7a..f9de6a5 100644 |
| --- a/native/apple/Sources/CallsKit/InputChannel.swift |
| +++ b/native/apple/Sources/CallsKit/InputChannel.swift |
| @@ -79,13 +79,21 @@ |
| /// (0-based), so a mono mixdown of them — or the first of them — is |
| /// that input. Nil means leave the unit alone: input 1 on a unit this |
| /// app has never mapped is exactly what the module does already. |
| - public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool) -> [Int32]? { |
| + /// |
| + /// `offset` is where the microphone's channels start on the unit's |
| + /// device: 0 for the microphone itself, and past the speaker's own |
| + /// inputs on an aggregate that joins the two (AudioEnginePin), where |
| + /// input 1 of the microphone is not input 1 of the device. |
| + public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool, offset: Int = 0) |
| + -> [Int32]? |
| + { |
| let width = max(clientChannels, 1) |
| - if channel <= 0 { |
| + let source = max(channel, 0) + max(offset, 0) |
| + if source <= 0 { |
| // Undo an earlier choice on the same unit: straight through. |
| return mappedBefore ? (0..<width).map { Int32($0) } : nil |
| } |
| - return Array(repeating: Int32(channel), count: width) |
| + return Array(repeating: Int32(source), count: width) |
| } |
| /// Input channels in a stream configuration: the sum over its buffers. |
| @@ -208,34 +216,45 @@ |
| /// time the unit's client format is the one it will run with). |
| final class InputChannelMapper: @unchecked Sendable { |
| private let defaults: UserDefaults |
| + /// The microphone behind an aggregate the engine is on, and where its |
| + /// channels start (AudioEnginePin.inputLayout). Nil for a device that |
| + /// is the microphone itself. |
| + private let layout: (AudioDeviceID) -> (uid: String, offset: Int)? |
| private let lock = NSLock() |
| private var mapped = false |
| /// What was last applied, for the readout: the device and the channel. |
| private(set) var applied: (uid: String, channel: Int)? |
| - init(defaults: UserDefaults) { self.defaults = defaults } |
| + init(defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }) { |
| + self.defaults = defaults |
| + self.layout = layout |
| + } |
| func apply(to node: AVAudioNode?) { |
| guard let node = node as? AVAudioInputNode, let unit = node.audioUnit, |
| let device = CoreAudioInput.device(of: unit), |
| - let uid = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) |
| + let unitUID = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) |
| else { return } |
| - let count = CoreAudioInput.inputChannelCount(device) |
| + let (uid, offset) = layout(device) ?? (unitUID, 0) |
| + let count = CoreAudioInput.device(uid: uid).map(CoreAudioInput.inputChannelCount) ?? 0 |
| let channel = InputChannelPreference.load(uid: uid, channels: count, defaults) |
| lock.lock() |
| defer { lock.unlock() } |
| guard |
| let map = InputChannelMap.map( |
| channel: channel, clientChannels: CoreAudioInput.clientChannels(of: unit), |
| - mappedBefore: mapped) |
| + mappedBefore: mapped, offset: offset) |
| else { |
| applied = (uid, 0) |
| return |
| } |
| - if CoreAudioInput.setChannelMap(map, on: unit) == noErr { |
| - mapped = channel > 0 |
| + let status = CoreAudioInput.setChannelMap(map, on: unit) |
| + if status == noErr { |
| + mapped = channel + offset > 0 |
| applied = (uid, channel) |
| } |
| + AudioLog.audio.info( |
| + "input channel map \(map.map(String.init).joined(separator: ","), privacy: .public) for \(uid, privacy: .public) input \(channel + 1): OSStatus \(status)") |
| } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index c153497..2a1c893 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -18,6 +18,9 @@ |
| @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue |
| @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue |
| @State var pipDrag: CGSize = .zero |
| + /// The title pill's height, so a self view in the top-left corner |
| + /// sits below it rather than under it. |
| + @State var titleHeight: CGFloat = 0 |
| @State var menuTracking = false |
| @State var sheetShowing = false |
| @State var dismissedNotices: Set<String> = [] |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 0816cf8..fdd5a54 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -34,9 +34,9 @@ |
| ZStack { |
| Color.black |
| if let fixtureBackdrop { |
| - fixtureBackdrop.view.accessibilityHidden(true) |
| + fixtureBackdrop.view.accessibilityHidden(true).movesWindow() |
| } else { |
| - fullWindowStage(in: size) |
| + fullWindowStage(in: size).movesWindow() |
| } |
| if !grid { selfView(in: size) } |
| topShade |
| @@ -181,30 +181,40 @@ |
| let across = |
| fitted.width >= 200 |
| ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) |
| + let rows = (count + across - 1) / across |
| + let gridHeight = CGFloat(rows) * tile.height + CGFloat(max(rows - 1, 0)) * 4 |
| + // The last row's names clear the bar while it shows: lifted by as |
| + // much of the bar as reaches over them (the grid is centred). |
| + let below = max(0, (size.height - 8 - gridHeight) / 2) + 4 |
| + let lastLift = max(0, lift - below) |
| + let firstOfLastRow = (rows - 1) * across |
| ScrollView { |
| LazyVGrid( |
| columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), |
| spacing: 4 |
| ) { |
| - ForEach(call.screens, id: \.self) { peer in |
| + ForEach(Array(call.screens.enumerated()), id: \.element) { index, peer in |
| MacVideoTile( |
| track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| - screen: true, cornerRadius: 6, compact: true, backdrop: Self.tileBackdrop |
| + screen: true, cornerRadius: 6, compact: true, |
| + labelLift: index >= firstOfLastRow ? lastLift : 0, backdrop: Self.tileBackdrop |
| ) |
| .frame(width: tile.width, height: tile.height) |
| } |
| MacVideoTile( |
| track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 6, |
| - compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| + compact: true, labelLift: call.screens.count >= firstOfLastRow ? lastLift : 0, |
| + backdrop: Self.tileBackdrop, audio: call.selfAudio |
| ) |
| .frame(width: tile.width, height: tile.height) |
| - ForEach(peers) { peer in |
| + ForEach(Array(peers.enumerated()), id: \.element.id) { index, peer in |
| MacVideoTile( |
| track: call.remoteTracks[peer.peer], label: peer.name, |
| muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer), |
| - cornerRadius: 6, compact: true, backdrop: Self.tileBackdrop, |
| - audio: call.tileAudio(peer.peer) |
| + cornerRadius: 6, compact: true, |
| + labelLift: call.screens.count + 1 + index >= firstOfLastRow ? lastLift : 0, |
| + backdrop: Self.tileBackdrop, audio: call.tileAudio(peer.peer) |
| ) |
| .frame(width: tile.width, height: tile.height) |
| } |
| @@ -230,7 +240,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( |
| @@ -260,8 +269,14 @@ |
| var trailing: CGFloat = 0 |
| if showingChat { trailing += Self.panelWidth + 12 } |
| if stageShowsScreen, !peers.isEmpty { trailing += strip(in: size).width + 12 } |
| + // The title pill sits beside the traffic lights while the controls |
| + // show; a self view in that corner goes below it. |
| + let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing |
| + let top = |
| + corner == .topLeading && chrome.visible |
| + ? max(Self.titleBarHeight, 6 + titleHeight + 4) : Self.titleBarHeight |
| return EdgeInsets( |
| - top: Self.titleBarHeight, leading: 0, |
| + top: top, leading: 0, |
| bottom: chrome.visible ? Self.barReserve - 12 : 0, trailing: trailing) |
| } |
| @@ -293,6 +308,10 @@ |
| } |
| .padding(.horizontal, 12).padding(.vertical, 7) |
| .callOverlaySurface(RoundedRectangle(cornerRadius: 12, style: .continuous)) |
| + .background(GeometryReader { proxy in |
| + Color.clear.onAppear { titleHeight = proxy.size.height } |
| + .onChange(of: proxy.size.height) { _, height in titleHeight = height } |
| + }) |
| .accessibilityElement(children: .contain) |
| .accessibilityIdentifier("callTitle") |
| .frame(maxWidth: 420, alignment: .leading) |
| @@ -388,8 +407,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 +457,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 +489,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) |
| @@ -611,11 +640,7 @@ |
| override func mouseDown(with event: NSEvent) { |
| guard let window else { return } |
| if event.clickCount == 2 { |
| - switch TitleBarDoubleClick.current() { |
| - case .zoom: window.zoom(nil) |
| - case .minimize: window.miniaturize(nil) |
| - case .none: break |
| - } |
| + TitleBarDoubleClick.current().perform(on: window) |
| } else { |
| window.performDrag(with: event) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 38f9380..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)) |
| @@ -112,6 +115,12 @@ |
| } |
| required init?(coder: NSCoder) { nil } |
| + // A picture takes no clicks: they go to the SwiftUI view it is in, so |
| + // a drag on it moves the window (WindowMove) or the self view, as a |
| + // drag on a tile with no picture does, the click that brings the |
| + // window forward included. |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + |
| func attach(_ next: RTCVideoTrack) { |
| guard track !== next else { return } |
| detach() |
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/Sources/CallsKit/WindowMove.swift b/native/apple/Sources/CallsKit/WindowMove.swift| new file mode 100644 |
| index 0000000..5255f8e |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/WindowMove.swift |
| @@ -0,0 +1,226 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + |
| + /// Moving the full-window call by its picture, as FaceTime does. With no |
| + /// title bar, the 32 pt strip across the top (`WindowDragStrip`) was the |
| + /// only place a window could be picked up by; now a press anywhere on the |
| + /// stage that is not something of its own — the bar, a panel, a notice, |
| + /// the self view, which drags itself, a tile's ⓘ — moves the window once |
| + /// the pointer has gone `threshold` points with the button down. Less |
| + /// than that is a click, which shows the controls as any click does. |
| + /// |
| + /// Resizing needs nothing from here: AppKit keeps the few points inside |
| + /// every edge (and more at the corners) for itself, above any content, |
| + /// as long as the window is `.resizable` — which `CallWindowChrome` |
| + /// leaves alone. `WindowMoveTests` holds both. |
| + enum WindowMove { |
| + /// How far the pointer goes with the button down before a press on |
| + /// the picture is a drag rather than a click: a shaky click stays a |
| + /// click. The mini window uses the same slack. |
| + static let threshold: CGFloat = 4 |
| + |
| + /// Whether a press that went down at `start` and is now at `now` is a |
| + /// drag. |
| + static func isDrag(from start: CGPoint, to now: CGPoint) -> Bool { |
| + hypot(now.x - start.x, now.y - start.y) >= threshold |
| + } |
| + |
| + /// Whether the window can be moved at all: not in full screen, where it |
| + /// is the screen, and not when AppKit says it is not movable. |
| + static func canMove(fullScreen: Bool, movable: Bool) -> Bool { |
| + !fullScreen && movable |
| + } |
| + |
| + /// The window's frame origin for the pointer at `pointer`, when it was |
| + /// at `origin` with the pointer at `grabbed` (all in screen points). |
| + static func origin(from origin: CGPoint, grabbed: CGPoint, pointer: CGPoint) -> CGPoint { |
| + CGPoint(x: origin.x + pointer.x - grabbed.x, y: origin.y + pointer.y - grabbed.y) |
| + } |
| + |
| + /// One press on the picture: a click until it passes the threshold, |
| + /// then where the window should be for each position of the pointer. |
| + struct Press: Equatable { |
| + let origin: CGPoint |
| + let grabbed: CGPoint |
| + private(set) var moving = false |
| + |
| + init(origin: CGPoint, grabbed: CGPoint) { |
| + self.origin = origin |
| + self.grabbed = grabbed |
| + } |
| + |
| + /// Nil while it is still a click. |
| + mutating func moved(to pointer: CGPoint) -> CGPoint? { |
| + if !moving, !WindowMove.isDrag(from: grabbed, to: pointer) { return nil } |
| + moving = true |
| + return WindowMove.origin(from: origin, grabbed: grabbed, pointer: pointer) |
| + } |
| + } |
| + } |
| + |
| + extension View { |
| + /// A press and drag on this view moves its window (see `WindowMove`). |
| + /// Views above it with gestures or controls of their own keep them. |
| + func movesWindow() -> some View { modifier(WindowMoveModifier()) } |
| + |
| + /// Lets the click that brings the window forward reach this view's |
| + /// gestures, as it reaches a title bar (macOS 15 and later). Given to |
| + /// the stage by `movesWindow`; a scroll view's content needs it again, |
| + /// because it does not reach through one. |
| + func takesActivatingClicks() -> some View { modifier(ActivatingClicks()) } |
| + } |
| + |
| + private struct WindowMoveModifier: ViewModifier { |
| + @State private var mover = WindowMover() |
| + |
| + func body(content: Content) -> some View { |
| + content |
| + // Every point of it, the gaps between tiles included, not only |
| + // where something is drawn. |
| + .contentShape(Rectangle()) |
| + .gesture( |
| + // Only a drag: a click on the picture is left alone, and shows |
| + // the controls as it always has (CallWindowActivity). |
| + DragGesture(minimumDistance: WindowMove.threshold, coordinateSpace: .global) |
| + .onChanged { _ in mover.begin() } |
| + .onEnded { _ in mover.end() } |
| + ) |
| + // What a double click on the title bar does, as on the strip that |
| + // stands in for it: zoom, by default. |
| + .onTapGesture(count: 2) { mover.doubleClicked() } |
| + .takesActivatingClicks() |
| + .background(WindowFinder(mover: mover)) |
| + } |
| + } |
| + |
| + private struct ActivatingClicks: ViewModifier { |
| + func body(content: Content) -> some View { |
| + if #available(macOS 15, *) { |
| + content.allowsWindowActivationEvents(true) |
| + } else { |
| + content |
| + } |
| + } |
| + } |
| + |
| + /// Moves the window for a drag on the picture. The gesture is used only |
| + /// to say that one has begun, on the picture and not on anything over |
| + /// it: its values are in the view's coordinates, which shift as the |
| + /// window does, and once the window followed the pointer SwiftUI stopped |
| + /// reporting the drag after its first step. So the window's own mouse |
| + /// events are watched instead, where the pointer is on screen, and |
| + /// passed on untouched. |
| + @MainActor final class WindowMover { |
| + weak var window: NSWindow? { |
| + didSet { if window !== oldValue { watch() } } |
| + } |
| + /// Where on screen the button last went down in the window. |
| + private var down: CGPoint? |
| + private var press: WindowMove.Press? |
| + private var monitor: Any? |
| + |
| + /// A drag on the picture has passed the threshold. |
| + func begin() { |
| + guard press == nil, let window, let down, |
| + WindowMove.canMove( |
| + fullScreen: window.styleMask.contains(.fullScreen), movable: window.isMovable) |
| + else { return } |
| + // The window has not moved since the button went down. It follows |
| + // from the next drag event on. |
| + press = WindowMove.Press(origin: window.frame.origin, grabbed: down) |
| + } |
| + |
| + func doubleClicked() { |
| + guard let window, !window.styleMask.contains(.fullScreen) else { return } |
| + TitleBarDoubleClick.current().perform(on: window) |
| + } |
| + |
| + func end() { press = nil } |
| + |
| + func stop() { |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + monitor = nil |
| + press = nil |
| + } |
| + |
| + private func watch() { |
| + stop() |
| + guard window != nil else { return } |
| + monitor = NSEvent.addLocalMonitorForEvents( |
| + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] |
| + ) { [weak self] event in |
| + nonisolated(unsafe) let event = event |
| + MainActor.assumeIsolated { |
| + guard let self, event.window === self.window else { return } |
| + switch event.type { |
| + case .leftMouseDown: self.down = Self.screenLocation(of: event) |
| + case .leftMouseDragged: self.move(to: Self.screenLocation(of: event)) |
| + default: self.end() |
| + } |
| + } |
| + return event |
| + } |
| + } |
| + |
| + private func move(to pointer: CGPoint) { |
| + guard let window, let origin = press?.moved(to: pointer) else { return } |
| + let screen = NSScreen.screens.first { $0.frame.contains(pointer) } ?? window.screen |
| + let frame = window.constrainFrameRect( |
| + NSRect(origin: origin, size: window.frame.size), to: screen) |
| + window.setFrameOrigin(frame.origin) |
| + } |
| + |
| + /// Where an event happened on screen, in AppKit's screen points (y up |
| + /// from the bottom of the main display). From the event itself, not |
| + /// its place in the window: the window is moving under the pointer, |
| + /// and an event queued before a move is placed against where the |
| + /// window was. |
| + private static func screenLocation(of event: NSEvent) -> CGPoint { |
| + guard let global = event.cgEvent?.location, let main = NSScreen.screens.first else { |
| + return NSEvent.mouseLocation |
| + } |
| + return CGPoint(x: global.x, y: main.frame.maxY - global.y) |
| + } |
| + |
| + deinit { |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + } |
| + } |
| + |
| + extension TitleBarDoubleClick { |
| + @MainActor func perform(on window: NSWindow) { |
| + switch self { |
| + case .zoom: window.zoom(nil) |
| + case .minimize: window.miniaturize(nil) |
| + case .none: break |
| + } |
| + } |
| + } |
| + |
| + /// Finds the window a SwiftUI view is in, for the mover. Takes no |
| + /// clicks. |
| + private struct WindowFinder: NSViewRepresentable { |
| + let mover: WindowMover |
| + |
| + func makeNSView(context: Context) -> FinderView { |
| + let view = FinderView() |
| + view.mover = mover |
| + return view |
| + } |
| + func updateNSView(_ view: FinderView, context: Context) { |
| + view.mover = mover |
| + mover.window = view.window |
| + } |
| + static func dismantleNSView(_ view: FinderView, coordinator: ()) { view.mover?.window = nil } |
| + |
| + final class FinderView: NSView { |
| + var mover: WindowMover? |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + mover?.window = window |
| + } |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift| index 6c7f093..8f2242a 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| @@ -105,10 +105,19 @@ final class AudioRoutesTests: XCTestCase { |
| XCTAssertThrowsError(try routes.use(input: "anything")) |
| let audio = CallAudio(routes: routes, audioDevice: nil) |
| - _ = audio |
| XCTAssertTrue(routes.selectable) |
| - // What this machine's module lists, for the record: every input |
| - // device macOS has, named as macOS names it, the default among them. |
| + // The regression behind Paul's "not picking up the Scarlett at |
| + // launch": the engine module lists only its "default" entry until a |
| + // connection initialises it. The pickers come from CoreAudio, which |
| + // is complete now. |
| + XCTAssertEqual( |
| + audio.factory.audioDeviceModule.inputDevices.map(\.deviceId), ["default"], |
| + "LiveKit's module has not enumerated before the first connection") |
| + XCTAssertEqual( |
| + routes.inputs.count, CoreAudioDevices.inventory().inputs.count + 1, |
| + "every CoreAudio input, and System default, before any connection") |
| + // What this machine lists, for the record: every input device macOS |
| + // has, named as macOS names it, the default among them. |
| for device in routes.inputs { print("audio input: \(device.name) [\(device.id)]") } |
| for device in routes.outputs { print("audio output: \(device.name) [\(device.id)]") } |
| // A machine with no input at all is possible, so this asserts the |
| @@ -127,9 +136,8 @@ final class AudioRoutesTests: XCTestCase { |
| XCTAssertNoThrow(try routes.use(input: first.id)) |
| XCTAssertEqual(routes.currentInput, first.id) |
| } |
| - // A device other than the default, on the real module: the choice |
| - // must hold, although this module cannot read back which device it |
| - // is on (see AudioRoutes). Choosing only records it; nothing records. |
| + // A device other than the default: the choice must hold. Choosing |
| + // only records it; no engine is running, so nothing records. |
| if let other = routes.inputs.first(where: { !$0.name.hasPrefix("System default") }) { |
| XCTAssertNoThrow(try routes.use(input: other.id)) |
| XCTAssertEqual(routes.currentInput, other.id) |
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/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift| index 90ec0db..4973f72 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| @@ -2,36 +2,61 @@ |
| import XCTest |
| @testable import CallsKit |
| - /// The Mac's remembered routing rules, held against a stand-in for the |
| - /// audio device module so no microphone is opened. The module itself |
| - /// follows the system default and sample-rate changes; these hold what |
| - /// AudioRoutes adds on top of it. |
| + /// The Mac's remembered routing rules, held against a stand-in for |
| + /// CoreAudio and the engine so no microphone is opened. The module |
| + /// itself follows the system default and sample-rate changes; these hold |
| + /// what AudioRoutes adds on top of it. |
| @MainActor final class CallAudioSelectionTests: XCTestCase { |
| - private final class Module { |
| - var defaultInput = "Built-in Microphone" |
| - var defaultOutput = "Built-in Speakers" |
| - var inputs = ["built-in", "usb"] |
| - var outputs = ["speakers", "headset"] |
| - var selected: [(id: String, input: Bool)] = [] |
| - |
| - func control() -> AudioModuleControl { |
| - AudioModuleControl( |
| - devices: { input in |
| - let head = ModuleDevice( |
| - id: "default", name: input ? self.defaultInput : self.defaultOutput, isDefault: true) |
| - return [head] |
| - + (input ? self.inputs : self.outputs).map { |
| - ModuleDevice(id: $0, name: $0, isDefault: false) |
| - } |
| + /// CoreAudio and the engine, as far as the rules can see them. |
| + private final class Machine { |
| + var devices: [SystemAudioDevice] = [ |
| + SystemAudioDevice(uid: "BuiltInMicrophoneDevice", name: "MacBook Pro Microphone", inputChannels: 1, outputChannels: 0), |
| + SystemAudioDevice(uid: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", inputChannels: 0, outputChannels: 2), |
| + SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0), |
| + SystemAudioDevice(uid: "headset", name: "Headset", inputChannels: 0, outputChannels: 2), |
| + ] |
| + var defaultInput = "BuiltInMicrophoneDevice" |
| + var defaultOutput = "BuiltInSpeakerDevice" |
| + /// What the engine was last asked for; the engine is "running" when set. |
| + var pinned: (input: String?, output: String?) = (nil, nil) |
| + var running = false |
| + var applied: EngineTarget = .systemDefault |
| + var rebuilds = 0 |
| + /// What the engine module lists: only "default" until a connection |
| + /// has initialised it, as LiveKit's does. Never used to choose. |
| + var moduleList = ["default"] |
| + |
| + static let scarlett = SystemAudioDevice( |
| + uid: "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0001:1,2", name: "Scarlett 18i8 USB", |
| + inputChannels: 18, outputChannels: 8, sampleRate: 44_100, transport: "usb") |
| + |
| + var inventory: AudioInventory { |
| + AudioInventory(devices: devices, defaultInput: defaultInput, defaultOutput: defaultOutput) |
| + } |
| + func target() -> EngineTarget { |
| + EngineTarget.plan( |
| + input: pinned.input, output: pinned.output, defaultInput: defaultInput, defaultOutput: defaultOutput) |
| + } |
| + |
| + func control() -> AudioEngineControl { |
| + AudioEngineControl( |
| + inventory: { self.inventory }, |
| + pin: { input, output in |
| + self.pinned = (input, output) |
| + return self.running && self.target() != self.applied |
| }, |
| - select: { id, input in |
| - guard id == "default" || (input ? self.inputs : self.outputs).contains(id) else { |
| - return false |
| - } |
| - self.selected.append((id, input)) |
| - return true |
| + choices: { self.pinned }, |
| + rebuild: { |
| + self.rebuilds += 1 |
| + self.applied = self.target() |
| }) |
| } |
| + |
| + /// The module making its first engine, for the first connection. |
| + func start() { |
| + running = true |
| + applied = target() |
| + } |
| } |
| private var store: UserDefaults! |
| @@ -47,127 +72,339 @@ |
| super.tearDown() |
| } |
| - private func routes(_ module: Module) -> AudioRoutes { |
| + private func routes(_ machine: Machine) -> AudioRoutes { |
| let routes = AudioRoutes(defaults: store) |
| - routes.attach(module.control()) |
| + routes.attach(machine.control()) |
| return routes |
| } |
| func testFirstCallStartsOnTheSystemDefault() { |
| - let module = Module() |
| - let routes = routes(module) |
| + let machine = Machine() |
| + let routes = routes(machine) |
| XCTAssertEqual(routes.currentInput, "default") |
| XCTAssertEqual(routes.currentOutput, "default") |
| - XCTAssertEqual(routes.inputs.first?.name, "System default (Built-in Microphone)") |
| - XCTAssertEqual(routes.outputName, "System default (Built-in Speakers)") |
| - XCTAssertTrue(module.selected.isEmpty, "the module already follows the default") |
| + XCTAssertEqual(routes.inputs.first?.name, "System default (MacBook Pro Microphone)") |
| + XCTAssertEqual(routes.outputName, "System default (MacBook Pro Speakers)") |
| + XCTAssertEqual(routes.inputs.map(\.id), ["default", "BuiltInMicrophoneDevice", "usb"]) |
| + XCTAssertEqual(routes.outputs.map(\.id), ["default", "BuiltInSpeakerDevice", "headset"]) |
| + XCTAssertTrue(machine.pinned == (nil, nil), "nothing chosen: the engine follows the default") |
| + machine.start() |
| + XCTAssertEqual(machine.applied, .systemDefault) |
| + } |
| + |
| + /// Paul, 2026-09-25 (0.3.6): "Still not picking up the Scarlett when I |
| + /// load the app for the first time." The engine module lists only |
| + /// "default" until the first connection initialises it; 0.3.6 matched |
| + /// the remembered UID against that one-entry list at launch and fell |
| + /// back. The lists and the choice now come from CoreAudio, which has |
| + /// the Scarlett at launch. |
| + func testRememberedMicrophoneResolvesAtLaunchBeforeTheModuleHasEnumerated() { |
| + let machine = Machine() |
| + machine.devices.append(Machine.scarlett) |
| + machine.defaultOutput = Machine.scarlett.uid |
| + XCTAssertEqual(machine.moduleList, ["default"], "the module has not enumerated yet") |
| + AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) |
| + let routes = routes(machine) |
| + XCTAssertEqual(routes.currentInput, Machine.scarlett.uid) |
| + XCTAssertEqual(routes.routeNote, "", "no claim that a connected Scarlett isn't connected") |
| + XCTAssertEqual(machine.pinned.input, Machine.scarlett.uid) |
| + machine.start() |
| + XCTAssertEqual(machine.applied, .device(Machine.scarlett.uid), "the first engine is made on it") |
| + XCTAssertEqual(machine.rebuilds, 0, "no rebuild: the engine was made on the choice") |
| + } |
| + |
| + /// The device appearing only after launch — plugged in, or CoreAudio |
| + /// late to publish it — is picked up on the next device-list update, |
| + /// and a running engine is moved to it. |
| + func testRememberedMicrophoneAppearingLaterIsResolvedOnTheNextUpdate() { |
| + let machine = Machine() |
| + AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) |
| + let routes = routes(machine) |
| + machine.start() |
| + XCTAssertEqual(routes.currentInput, "default") |
| + XCTAssertEqual(routes.routeNote, "Scarlett 18i8 USB isn’t connected; using MacBook Pro Microphone.") |
| + routes.refresh() |
| + XCTAssertEqual(machine.rebuilds, 0, "nothing to move to yet") |
| + machine.devices.append(Machine.scarlett) |
| + routes.refresh() // CoreAudio's device-list listener |
| + XCTAssertEqual(routes.currentInput, Machine.scarlett.uid) |
| + XCTAssertEqual(routes.routeNote, "") |
| + XCTAssertEqual(machine.rebuilds, 1, "the running engine is rebuilt on the Scarlett") |
| + XCTAssertEqual(machine.applied, .pair(input: Machine.scarlett.uid, output: "BuiltInSpeakerDevice")) |
| + routes.refresh() |
| + XCTAssertEqual(machine.rebuilds, 1, "and once, not on every look") |
| + } |
| + |
| + /// A USB interface moved to another port comes back under a new UID. |
| + /// It is found by name, and the new UID is remembered. |
| + func testADeviceBackUnderANewUIDIsFoundByName() { |
| + let machine = Machine() |
| + var moved = Machine.scarlett |
| + moved.uid = "AppleUSBAudioEngine:Focusrite:Scarlett 18i8 USB:0002:1,2" |
| + machine.devices.append(moved) |
| + AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) |
| + let routes = routes(machine) |
| + XCTAssertEqual(routes.currentInput, moved.uid) |
| + XCTAssertEqual(routes.routeNote, "") |
| + XCTAssertEqual(AudioPreference.load(input: true, store)?.id, moved.uid) |
| + } |
| + |
| + /// Paul's 0.3.6 panel: "Scarlett 18i8 USB isn't connected; using |
| + /// Scarlett 18i8 USB." The remembered speaker was the Scarlett, the |
| + /// module's one-entry list lacked it, and the default it fell back to |
| + /// was the Scarlett. Resolved against CoreAudio the Scarlett is simply |
| + /// found; and a note can never name the missing device as the one in |
| + /// use. |
| + func testTheNoteIsNeverSelfContradictory() { |
| + let machine = Machine() |
| + machine.devices.append(Machine.scarlett) |
| + machine.defaultOutput = Machine.scarlett.uid |
| + AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: false, store) |
| + AudioPreference(id: Machine.scarlett.uid, name: "Scarlett 18i8 USB").save(input: true, store) |
| + let routes = routes(machine) |
| + XCTAssertEqual(routes.routeNote, "") |
| + XCTAssertEqual(routes.currentOutput, Machine.scarlett.uid) |
| + |
| + // A second unit of the same model is the default while the chosen |
| + // one, a different unit with its own UID, is away: two with the same |
| + // name are ambiguous, so it is not taken for the chosen one, and the |
| + // note does not say "using Scarlett 18i8 USB". |
| + let fallback = SystemAudioDevice(uid: "other", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8) |
| + let missing = AudioPreference(id: "gone", name: "Scarlett 18i8 USB") |
| + let two = AudioInventory( |
| + devices: [fallback, SystemAudioDevice(uid: "third", name: "Scarlett 18i8 USB", inputChannels: 18, outputChannels: 8)], |
| + defaultInput: "other", defaultOutput: "other") |
| + let resolved = AudioRouteRules.resolve(missing, two, input: true) |
| + XCTAssertEqual(resolved.current, "default") |
| + XCTAssertEqual(resolved.note, "Scarlett 18i8 USB isn’t connected; using the Mac’s default microphone.") |
| + |
| + // Every note, over a spread of cases: the missing name never |
| + // follows "using". |
| + for (pref, inventory) in [ |
| + (missing, two), |
| + (AudioPreference(id: "x", name: "MacBook Pro Microphone"), machine.inventory), |
| + (AudioPreference(id: "x", name: ""), machine.inventory), |
| + (AudioPreference(id: "x", name: "Mic"), AudioInventory()), |
| + ] { |
| + for input in [true, false] { |
| + guard let note = AudioRouteRules.resolve(pref, inventory, input: input).note else { continue } |
| + let using = note.components(separatedBy: "; using ").last ?? "" |
| + XCTAssertFalse(!pref.name.isEmpty && using.hasPrefix(pref.name), note) |
| + } |
| + } |
| } |
| - /// Paul, 2026-09-24: "it should remember the Scarlett between app |
| - /// launches". A new AudioRoutes on the same defaults is a new launch. |
| func testChoicesAreRememberedAcrossCallsAndLaunches() throws { |
| - let routes = routes(Module()) |
| + let machine = Machine() |
| + let routes = routes(machine) |
| try routes.use(input: "usb") |
| try routes.use(output: "headset") |
| routes.reset() // leaving the call |
| XCTAssertEqual(routes.currentInput, "usb") |
| XCTAssertEqual(routes.currentOutput, "headset") |
| - let next = Module() |
| + let next = Machine() |
| let launch = self.routes(next) |
| XCTAssertEqual(launch.currentInput, "usb") |
| XCTAssertEqual(launch.currentOutput, "headset") |
| - XCTAssertEqual(next.selected.map(\.id), ["usb", "headset"], "applied at join, before anyone asks") |
| + XCTAssertTrue(next.pinned == ("usb", "headset"), "applied at join, before anyone asks") |
| + next.start() |
| + XCTAssertEqual(next.applied, .pair(input: "usb", output: "headset")) |
| XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb") |
| - XCTAssertEqual(store.string(forKey: "audioInputName"), "usb") |
| + XCTAssertEqual(store.string(forKey: "audioInputName"), "USB Mic") |
| } |
| func testSystemDefaultIsRememberedWhenPicked() throws { |
| - let routes = routes(Module()) |
| + let machine = Machine() |
| + let routes = routes(machine) |
| try routes.use(input: "usb") |
| try routes.use(input: "") |
| XCTAssertEqual(routes.currentInput, "default") |
| XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the default is a choice of its own") |
| - let next = Module() |
| + try routes.use(input: "usb") |
| + try routes.use(input: "default") |
| + XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the picker's own id for it too") |
| + let next = Machine() |
| let launch = self.routes(next) |
| XCTAssertEqual(launch.currentInput, "default") |
| - XCTAssertTrue(next.selected.isEmpty) |
| - } |
| - |
| - func testMissingDeviceAtJoinUsesTheDefaultSaysSoAndReturns() { |
| - AudioPreference(id: "scarlett", name: "Scarlett 18i8").save(input: true, store) |
| - let module = Module() |
| - let routes = routes(module) |
| - XCTAssertEqual(routes.currentInput, "default") |
| - XCTAssertEqual(routes.routeNote, "Scarlett 18i8 isn’t connected; using Built-in Microphone.") |
| - XCTAssertTrue(module.selected.isEmpty) |
| - module.inputs.append("scarlett") |
| - routes.refresh() |
| - XCTAssertEqual(routes.currentInput, "scarlett") |
| - XCTAssertEqual(module.selected.last?.id, "scarlett") |
| - XCTAssertEqual(routes.routeNote, "") |
| - XCTAssertEqual(AudioPreference.load(input: true, store)?.id, "scarlett", "the preference was kept") |
| + XCTAssertTrue(next.pinned == (nil, nil)) |
| } |
| func testDisconnectMidCallFallsBackAndComesBackOnItsOwn() throws { |
| - let module = Module() |
| - let routes = routes(module) |
| + let machine = Machine() |
| + let routes = routes(machine) |
| + machine.start() |
| try routes.use(input: "usb") |
| try routes.use(output: "headset") |
| - module.inputs = ["built-in"] |
| + XCTAssertEqual(machine.rebuilds, 2) |
| + machine.devices.removeAll { $0.uid == "usb" } |
| routes.refresh() |
| XCTAssertEqual(routes.currentInput, "default") |
| XCTAssertEqual(routes.currentOutput, "headset") |
| - XCTAssertEqual(module.selected.last?.id, "default") |
| - XCTAssertEqual(module.selected.last?.input, true) |
| - XCTAssertEqual(routes.routeNote, "usb isn’t connected; using Built-in Microphone.") |
| - let count = module.selected.count |
| + XCTAssertEqual(machine.applied, .pair(input: "BuiltInMicrophoneDevice", output: "headset")) |
| + XCTAssertEqual(routes.routeNote, "USB Mic isn’t connected; using MacBook Pro Microphone.") |
| + let count = machine.rebuilds |
| routes.refresh() |
| - XCTAssertEqual(module.selected.count, count, "the module is moved once, not on every look") |
| - module.inputs = ["built-in", "usb"] |
| + XCTAssertEqual(machine.rebuilds, count, "the engine is moved once, not on every look") |
| + machine.devices.append(SystemAudioDevice(uid: "usb", name: "USB Mic", inputChannels: 1, outputChannels: 0)) |
| routes.refresh() |
| XCTAssertEqual(routes.currentInput, "usb") |
| - XCTAssertEqual(module.selected.last?.id, "usb") |
| + XCTAssertEqual(machine.applied, .pair(input: "usb", output: "headset")) |
| XCTAssertEqual(routes.routeNote, "") |
| } |
| func testRefusedSwitchKeepsPreviousSelection() throws { |
| - let module = Module() |
| - let routes = routes(module) |
| + let machine = Machine() |
| + let routes = routes(machine) |
| try routes.use(input: "usb") |
| XCTAssertThrowsError(try routes.use(input: "missing")) { error in |
| XCTAssertEqual((error as NSError).code, 5) |
| } |
| XCTAssertEqual(routes.currentInput, "usb") |
| XCTAssertThrowsError(try routes.use(output: "missing")) |
| + XCTAssertThrowsError(try routes.use(output: "usb"), "a microphone is not a speaker") |
| XCTAssertEqual(routes.currentOutput, "default") |
| - XCTAssertEqual(module.selected.count, 1, "a refused choice never reaches the module") |
| XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb", "nor the memory") |
| } |
| func testSystemDefaultFollowsChangesButExplicitChoiceDoesNot() throws { |
| - let module = Module() |
| - let routes = routes(module) |
| - module.defaultOutput = "Headset" |
| + let machine = Machine() |
| + let routes = routes(machine) |
| + machine.start() |
| + machine.defaultOutput = "headset" |
| routes.refresh() |
| XCTAssertEqual(routes.currentOutput, "default") |
| XCTAssertEqual(routes.outputName, "System default (Headset)") |
| - XCTAssertTrue(module.selected.isEmpty) |
| - try routes.use(output: "speakers") |
| - let count = module.selected.count |
| - module.defaultOutput = "Built-in Speakers" |
| + XCTAssertEqual(machine.rebuilds, 0, "the module follows the default itself") |
| + try routes.use(output: "BuiltInSpeakerDevice") |
| + let count = machine.rebuilds |
| + machine.defaultOutput = "BuiltInSpeakerDevice" |
| + routes.refresh() |
| + machine.defaultOutput = "headset" |
| routes.refresh() |
| - XCTAssertEqual(module.selected.count, count) |
| - XCTAssertEqual(routes.currentOutput, "speakers") |
| + XCTAssertEqual(routes.currentOutput, "BuiltInSpeakerDevice") |
| + XCTAssertEqual(machine.rebuilds, count) |
| + } |
| + |
| + /// A device the engine cannot start on is said so, and set aside |
| + /// until it is chosen again or what is connected changes: never a |
| + /// loop of rebuilds. |
| + func testAnEngineFailureFallsBackSaysSoAndRetriesOnChange() throws { |
| + let machine = Machine() |
| + machine.devices.append(Machine.scarlett) |
| + let routes = routes(machine) |
| + machine.start() |
| + try routes.use(input: Machine.scarlett.uid) |
| + XCTAssertEqual(machine.rebuilds, 1) |
| + routes.engineFailed(machine.applied, reason: "the audio engine would not start on it") |
| + XCTAssertEqual(routes.currentInput, "default") |
| + XCTAssertEqual(routes.routeNote, "Calls couldn’t open Scarlett 18i8 USB; using MacBook Pro Microphone.") |
| + XCTAssertEqual(machine.applied, .systemDefault) |
| + XCTAssertEqual(machine.rebuilds, 2) |
| + routes.refresh() |
| + routes.refresh() |
| + XCTAssertEqual(machine.rebuilds, 2, "no retry loop") |
| + XCTAssertEqual(AudioPreference.load(input: true, store)?.id, Machine.scarlett.uid, "the choice is kept") |
| + |
| + machine.devices.append(SystemAudioDevice(uid: "new", name: "AirPods", inputChannels: 1, outputChannels: 2)) |
| + routes.refresh() |
| + XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "something changed: tried again") |
| + XCTAssertEqual(routes.routeNote, "") |
| + |
| + routes.engineFailed(machine.applied, reason: "again") |
| + XCTAssertEqual(routes.currentInput, "default") |
| + try routes.use(input: Machine.scarlett.uid) |
| + XCTAssertEqual(routes.currentInput, Machine.scarlett.uid, "choosing it again tries again") |
| } |
| func testNoModuleMeansNothingToChoose() { |
| let routes = AudioRoutes(defaults: store) |
| XCTAssertTrue(routes.inputs.isEmpty) |
| XCTAssertNil(routes.currentInput) |
| - XCTAssertThrowsError(try routes.use(input: "")) |
| + XCTAssertThrowsError(try routes.use(input: "usb")) |
| routes.reset() |
| XCTAssertTrue(routes.outputs.isEmpty) |
| } |
| } |
| + |
| + /// Where the engine's one audio unit goes (on a Mac, input and output |
| + /// are a single unit). |
| + final class EngineTargetTests: XCTestCase { |
| + func testPlans() { |
| + func plan(_ i: String?, _ o: String?, di: String? = "mic", do d: String? = "spk") -> EngineTarget { |
| + EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d) |
| + } |
| + XCTAssertEqual(plan(nil, nil), .systemDefault, "nothing chosen: macOS's own default aggregate") |
| + // Paul's Mac: the Scarlett chosen as microphone, and the default |
| + // speaker is the Scarlett — one device, no aggregate. |
| + XCTAssertEqual(plan("scarlett", nil, do: "scarlett"), .device("scarlett")) |
| + XCTAssertEqual(plan("scarlett", "scarlett"), .device("scarlett")) |
| + XCTAssertEqual(plan("usb", nil), .pair(input: "usb", output: "spk")) |
| + XCTAssertEqual(plan(nil, "headset"), .pair(input: "mic", output: "headset")) |
| + XCTAssertEqual(plan("usb", nil, do: nil), .device("usb"), "no speaker at all") |
| + XCTAssertEqual(plan(nil, "headset", di: nil), .device("headset"), "no microphone at all") |
| + } |
| + |
| + func testTheAggregateOffsetMapsTheMicrophonesOwnInputs() { |
| + // A speaker with two inputs of its own ahead of the microphone. |
| + XCTAssertEqual(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 2), [2]) |
| + XCTAssertEqual(InputChannelMap.map(channel: 1, clientChannels: 2, mappedBefore: false, offset: 2), [3, 3]) |
| + XCTAssertNil(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 0)) |
| + } |
| + } |
| + |
| + /// The real CoreAudio on this machine: the inventory the app lists at |
| + /// launch, with no engine and no permission needed. |
| + final class CoreAudioInventoryTests: XCTestCase { |
| + func testTheInventoryIsReadyWithoutAnEngine() { |
| + let inventory = CoreAudioDevices.inventory() |
| + for device in inventory.devices { |
| + print("CoreAudio: \(device.describe)") |
| + XCTAssertFalse(device.name.isEmpty) |
| + XCTAssertFalse(CoreAudioDevices.isInternal(uid: device.uid), "no aggregate of ours or macOS's is listed") |
| + } |
| + if let input = inventory.defaultInput { |
| + XCTAssertTrue(inventory.inputs.contains { $0.uid == input } || inventory.devices.isEmpty) |
| + } |
| + } |
| + } |
| +#endif |
| + |
| +#if os(macOS) |
| + import LiveKitWebRTC |
| + |
| + /// The pin on a real engine module, playing (which needs no microphone |
| + /// permission): a chosen speaker puts the engine on an aggregate of the |
| + /// default microphone and that speaker before the module wires it, the |
| + /// engine runs there, and going back to the default rebuilds it. |
| + final class EnginePinIntegrationTests: XCTestCase { |
| + @MainActor func testAChosenSpeakerRunsTheEngineOnItAndBack() throws { |
| + let inventory = CoreAudioDevices.inventory() |
| + guard let speaker = inventory.defaultOutput, let microphone = inventory.defaultInput else { |
| + throw XCTSkip("this machine has no default input and output") |
| + } |
| + let suite = "calls-engine-pin-\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: suite)! |
| + defer { store.removePersistentDomain(forName: suite) } |
| + let routes = AudioRoutes(defaults: store) |
| + let audio = CallAudio(routes: routes, audioDevice: nil) |
| + let module = audio.factory.audioDeviceModule |
| + try routes.use(output: speaker) |
| + // The first connection initialises the module, as in a call. |
| + let pc = audio.factory.peerConnection( |
| + with: RTCConfiguration(), |
| + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil) |
| + defer { pc?.close() } |
| + XCTAssertEqual(module.initPlayout(), 0) |
| + XCTAssertEqual(module.startPlayout(), 0) |
| + defer { _ = module.stopPlayout() } |
| + XCTAssertTrue(module.isEngineRunning, "the engine runs on the pinned devices") |
| + let expected: EngineTarget = microphone == speaker ? .device(speaker) : .pair(input: microphone, output: speaker) |
| + XCTAssertEqual(audio.enginePin.lastApplied, expected) |
| + |
| + try routes.use(output: "") |
| + XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default") |
| + XCTAssertTrue(module.isEngineRunning) |
| + } |
| + } |
| #endif |
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/InputChannelTests.swift b/native/apple/Tests/CallsKitTests/InputChannelTests.swift| index cfafd6a..f34076e 100644 |
| --- a/native/apple/Tests/CallsKitTests/InputChannelTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/InputChannelTests.swift |
| @@ -79,12 +79,14 @@ |
| private func routes(channels: InputDeviceChannels?, rebuilt: @escaping () -> Void) -> AudioRoutes { |
| let routes = AudioRoutes(defaults: store) |
| routes.attach( |
| - AudioModuleControl( |
| - devices: { input in |
| - [ModuleDevice(id: "default", name: "Default", isDefault: true)] |
| - + (input ? [ModuleDevice(id: "scarlett", name: "Scarlett 18i8 USB", isDefault: false)] : []) |
| + AudioEngineControl( |
| + inventory: { |
| + AudioInventory( |
| + devices: [ |
| + SystemAudioDevice(uid: "mbp", name: "MacBook Pro Microphone", inputChannels: 1, outputChannels: 0), |
| + SystemAudioDevice(uid: "scarlett", name: "Scarlett 18i8 USB", inputChannels: 4, outputChannels: 0), |
| + ], defaultInput: "mbp", defaultOutput: nil) |
| }, |
| - select: { _, _ in true }, |
| channels: { id in id == "scarlett" ? channels : nil }, |
| rebuildInput: rebuilt)) |
| return routes |
diff --git a/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift b/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift| new file mode 100644 |
| index 0000000..588cc6b |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/ScarlettReproTests.swift |
| @@ -0,0 +1,57 @@ |
| +#if os(macOS) |
| + import CoreAudio |
| + import LiveKitWebRTC |
| + import XCTest |
| + @testable import CallsKit |
| + |
| + /// Paul's first-launch report, reproduced without a Scarlett: a public |
| + /// aggregate device stands in for an interface that is connected but is |
| + /// not the system default, and is remembered as the microphone. Opt-in |
| + /// (CALLS_AUDIO_REPRO=1) because it adds a device to the Mac for the |
| + /// length of the test. |
| + final class ScarlettReproTests: XCTestCase { |
| + @MainActor func testARememberedNonDefaultInterfaceIsUsedAtFirstLaunch() throws { |
| + guard ProcessInfo.processInfo.environment["CALLS_AUDIO_REPRO"] == "1" else { |
| + throw XCTSkip("set CALLS_AUDIO_REPRO=1 to add a stand-in interface to this Mac") |
| + } |
| + let inventory = CoreAudioDevices.inventory() |
| + guard let mic = inventory.defaultInput, let speaker = inventory.defaultOutput else { |
| + throw XCTSkip("no default devices") |
| + } |
| + let uid = "net.amadan.oficina.calls.repro.\(UUID().uuidString)" |
| + let description: [String: Any] = [ |
| + kAudioAggregateDeviceNameKey: "Stand-in Scarlett", kAudioAggregateDeviceUIDKey: uid, |
| + kAudioAggregateDeviceMainSubDeviceKey: speaker, |
| + kAudioAggregateDeviceSubDeviceListKey: [ |
| + [kAudioSubDeviceUIDKey: mic, kAudioSubDeviceDriftCompensationKey: 1], [kAudioSubDeviceUIDKey: speaker], |
| + ], |
| + ] |
| + var device = AudioDeviceID(0) |
| + XCTAssertEqual(AudioHardwareCreateAggregateDevice(description as CFDictionary, &device), noErr) |
| + defer { AudioHardwareDestroyAggregateDevice(device) } |
| + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) |
| + |
| + let suite = "calls-repro-\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: suite)! |
| + defer { store.removePersistentDomain(forName: suite) } |
| + store.set(uid, forKey: "audioInputUID") |
| + store.set("Stand-in Scarlett", forKey: "audioInputName") |
| + |
| + let routes = AudioRoutes(defaults: store) |
| + let audio = CallAudio(routes: routes, audioDevice: nil) |
| + print("REPRO at launch: input \(routes.currentInput ?? "nil"), note \"\(routes.routeNote)\"") |
| + XCTAssertEqual(routes.currentInput, uid, "the remembered interface at launch") |
| + XCTAssertEqual(routes.routeNote, "") |
| + |
| + let pc = audio.factory.peerConnection( |
| + with: RTCConfiguration(), |
| + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil) |
| + defer { pc?.close() } |
| + let listed = audio.factory.audioDeviceModule.inputDevices.map(\.deviceId) |
| + print("REPRO engine module inputs after the first connection: \(listed)") |
| + routes.refresh() |
| + XCTAssertEqual(routes.currentInput, uid, "and after the first connection") |
| + XCTAssertEqual(routes.routeNote, "") |
| + } |
| + } |
| +#endif |
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/apple/Tests/CallsKitTests/WindowMoveTests.swift b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift| new file mode 100644 |
| index 0000000..43ce68b |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift |
| @@ -0,0 +1,226 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + import XCTest |
| + |
| + import LiveKitWebRTC |
| + |
| + @testable @_spi(Fixtures) import CallsKit |
| + |
| + /// Paul, on 0.3.6: "I should be able to click anywhere on the window to |
| + /// resize it." The full-window call has had no title bar since 0.3.5, so |
| + /// moving it is the picture's job, and resizing must still work from |
| + /// every edge, the top included. |
| + final class WindowMoveRuleTests: XCTestCase { |
| + func testAShortPressIsAClickAndALongerOneADrag() { |
| + XCTAssertEqual(WindowMove.threshold, 4) |
| + let start = CGPoint(x: 100, y: 100) |
| + XCTAssertFalse(WindowMove.isDrag(from: start, to: start)) |
| + XCTAssertFalse(WindowMove.isDrag(from: start, to: CGPoint(x: 102, y: 102)), "a shaky click") |
| + XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 104, y: 100))) |
| + XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 97, y: 97)), "any direction") |
| + } |
| + |
| + func testThePressFollowsThePointerOnceItIsADrag() { |
| + var press = WindowMove.Press(origin: CGPoint(x: 200, y: 300), grabbed: CGPoint(x: 700, y: 650)) |
| + XCTAssertNil(press.moved(to: CGPoint(x: 702, y: 651)), "still a click") |
| + XCTAssertFalse(press.moving) |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 740, y: 625)), CGPoint(x: 240, y: 275)) |
| + XCTAssertTrue(press.moving) |
| + // Once moving, it goes wherever the pointer goes, back to where it |
| + // started included: the window is not left 4 pt short. |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 701, y: 650)), CGPoint(x: 201, y: 300)) |
| + XCTAssertEqual(press.moved(to: CGPoint(x: 700, y: 650)), CGPoint(x: 200, y: 300)) |
| + } |
| + |
| + func testOnlyAWindowThatCanMoveIsMoved() { |
| + XCTAssertTrue(WindowMove.canMove(fullScreen: false, movable: true)) |
| + XCTAssertFalse(WindowMove.canMove(fullScreen: true, movable: true), "full screen is the screen") |
| + XCTAssertFalse(WindowMove.canMove(fullScreen: false, movable: false)) |
| + } |
| + } |
| + |
| + /// The real full-window call in a titled, resizable window like the app's, |
| + /// driven with mouse events sent to it: what a press on each part of it |
| + /// does to the window. The window is never made key or active: a call |
| + /// window behind another app's must move by its picture too. |
| + @MainActor final class CallWindowMouseTests: XCTestCase { |
| + private var window: NSWindow! |
| + private let start = NSRect(x: 200, y: 200, width: 1000, height: 700) |
| + |
| + override func setUp() async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| + } |
| + |
| + override func tearDown() async throws { |
| + window?.close() |
| + window = nil |
| + UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| + } |
| + |
| + private func open(_ fixture: CallFixture = .focus, grid: Bool = false, controls: Bool = false) { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + window = NSWindow( |
| + contentRect: start, styleMask: [.titled, .closable, .miniaturizable, .resizable], |
| + backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView( |
| + rootView: MacCallView( |
| + call: call, grid: grid, style: .fullWindow, controlsShown: controls, leave: {}, rejoin: {})) |
| + window.orderFrontRegardless() |
| + pump(0.8) |
| + window.setFrame(start, display: true) |
| + pump(0.2) |
| + } |
| + |
| + private func pump(_ seconds: Double) { |
| + let until = Date(timeIntervalSinceNow: seconds) |
| + while let event = NSApp.nextEvent( |
| + matching: .any, until: until, inMode: .default, dequeue: true) |
| + { |
| + NSApp.sendEvent(event) |
| + } |
| + } |
| + |
| + private func event(_ type: NSEvent.EventType, at point: NSPoint, clicks: Int = 1) -> NSEvent { |
| + NSEvent.mouseEvent( |
| + with: type, location: point, modifierFlags: [], |
| + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, |
| + context: nil, eventNumber: 0, clickCount: clicks, pressure: type == .leftMouseUp ? 0 : 1)! |
| + } |
| + |
| + /// Presses at `point` (window coordinates, y up), drags by `by` in |
| + /// steps as a hand would, and lets go; returns how far the window went. |
| + private func drag(from point: NSPoint, by: CGVector) -> CGVector { |
| + let before = window.frame.origin |
| + let from = window.convertPoint(toScreen: point) |
| + NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| + pump(0.05) |
| + for step in 1...6 { |
| + let part = CGFloat(step) / 6 |
| + let there = NSPoint(x: from.x + by.dx * part, y: from.y + by.dy * part) |
| + NSApp.sendEvent(event(.leftMouseDragged, at: window.convertPoint(fromScreen: there))) |
| + pump(0.03) |
| + } |
| + let end = NSPoint(x: from.x + by.dx, y: from.y + by.dy) |
| + NSApp.sendEvent(event(.leftMouseUp, at: window.convertPoint(fromScreen: end))) |
| + pump(0.2) |
| + return CGVector(dx: window.frame.minX - before.x, dy: window.frame.minY - before.y) |
| + } |
| + |
| + private var bounds: NSRect { window.contentView!.bounds } |
| + |
| + func testTheCallLeavesTheWindowResizableWithNoTitleBar() { |
| + open() |
| + XCTAssertTrue(window.styleMask.contains(.resizable)) |
| + XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) |
| + XCTAssertEqual(window.titleVisibility, .hidden) |
| + XCTAssertTrue(window.titlebarAppearsTransparent) |
| + } |
| + |
| + /// AppKit keeps a few points inside each edge, and more at the corners, |
| + /// for resizing, whatever the content — the drag strip along the top |
| + /// included. Only past them do the call's own views get the click. |
| + func testTheEdgesAndCornersBelongToTheWindow() { |
| + open() |
| + let frameView = window.contentView!.superview! |
| + let edges: [(String, NSPoint)] = [ |
| + ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1)), |
| + ("bottom", NSPoint(x: bounds.midX, y: 1)), |
| + ("left", NSPoint(x: 1, y: bounds.midY)), |
| + ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY)), |
| + ("top left", NSPoint(x: 4, y: bounds.maxY - 4)), |
| + ("top right", NSPoint(x: bounds.maxX - 4, y: bounds.maxY - 4)), |
| + ("bottom left", NSPoint(x: 4, y: 4)), |
| + ("bottom right", NSPoint(x: bounds.maxX - 4, y: 4)), |
| + ] |
| + for (edge, point) in edges { |
| + XCTAssertTrue(frameView.hitTest(point) === frameView, "\(edge) is the window's") |
| + } |
| + XCTAssertTrue( |
| + frameView.hitTest(NSPoint(x: bounds.midX, y: bounds.maxY - 12)) is WindowDragStrip.StripView, |
| + "below the edge, the strip") |
| + } |
| + |
| + func testEveryEdgeAndCornerResizesTheWindow() { |
| + open() |
| + // (where, drag, which edges should move: minX, maxX, minY, maxY) |
| + let cases: [(String, NSPoint, CGVector, [Bool])] = [ |
| + ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1), CGVector(dx: 0, dy: 30), [false, false, false, true]), |
| + ("bottom", NSPoint(x: bounds.midX, y: 1), CGVector(dx: 0, dy: -30), [false, false, true, false]), |
| + ("left", NSPoint(x: 1, y: bounds.midY), CGVector(dx: -30, dy: 0), [true, false, false, false]), |
| + ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY), CGVector(dx: 30, dy: 0), [false, true, false, false]), |
| + ("top left", NSPoint(x: 1, y: bounds.maxY - 1), CGVector(dx: -30, dy: 30), [true, false, false, true]), |
| + ("top right", NSPoint(x: bounds.maxX - 1, y: bounds.maxY - 1), CGVector(dx: 30, dy: 30), [false, true, false, true]), |
| + ] |
| + for (edge, point, by, moves) in cases { |
| + window.setFrame(start, display: true) |
| + pump(0.1) |
| + // AppKit's resize reads the events that follow the press itself, |
| + // so they are queued first. |
| + let from = window.convertPoint(toScreen: point) |
| + let to = window.convertPoint(fromScreen: NSPoint(x: from.x + by.dx, y: from.y + by.dy)) |
| + NSApp.postEvent(event(.leftMouseDragged, at: to), atStart: false) |
| + NSApp.postEvent(event(.leftMouseUp, at: to), atStart: false) |
| + NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| + pump(0.1) |
| + let now = window.frame |
| + let moved = [now.minX != start.minX, now.maxX != start.maxX, now.minY != start.minY, now.maxY != start.maxY] |
| + XCTAssertEqual(moved, moves, "\(edge): \(start) became \(now)") |
| + XCTAssertGreaterThan(now.width * now.height, start.width * start.height, "\(edge) grew") |
| + } |
| + } |
| + |
| + func testDraggingThePictureMovesTheWindow() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 40, dy: -25)) |
| + XCTAssertEqual(moved, CGVector(dx: 40, dy: -25)) |
| + XCTAssertEqual(window.frame.size, start.size) |
| + let again = drag(from: NSPoint(x: 150, y: 300), by: CGVector(dx: -30, dy: 20)) |
| + XCTAssertEqual(again, CGVector(dx: -30, dy: 20), "anywhere on it") |
| + } |
| + |
| + func testAClickOnThePictureDoesNotMoveTheWindow() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 2, dy: 1)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + |
| + /// A picture arriving is an AppKit view over the tile, which took |
| + /// the press for itself before 0.3.7, so the stage's gesture never |
| + /// saw it. Behind another app's window too. |
| + func testDraggingLiveVideoMovesTheWindow() { |
| + let factory = RTCPeerConnectionFactory() |
| + let track = factory.videoTrack(with: factory.videoSource(), trackId: "window-move") |
| + window = NSWindow( |
| + contentRect: start, styleMask: [.titled, .closable, .resizable], backing: .buffered, |
| + defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.contentView = NSHostingView( |
| + rootView: MacVideoTile(track: track, label: "Ana", cornerRadius: 0, compact: true) |
| + .movesWindow()) |
| + window.orderFrontRegardless() |
| + pump(0.8) |
| + let point = NSPoint(x: bounds.midX, y: bounds.midY) |
| + XCTAssertFalse( |
| + window.contentView!.superview!.hitTest(point) is RTCMTLNSVideoView, "the picture takes no clicks") |
| + XCTAssertEqual(drag(from: point, by: CGVector(dx: 40, dy: -25)), CGVector(dx: 40, dy: -25)) |
| + } |
| + |
| + /// The self view drags itself to a corner (a gesture of its own, which |
| + /// the picture's does not take from it), and the window stays put. |
| + func testTheSelfViewIsNotAHandle() { |
| + open() |
| + let moved = drag(from: NSPoint(x: bounds.maxX - 100, y: 80), by: CGVector(dx: -750, dy: 450)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + |
| + func testTheBarIsNotAHandle() { |
| + open(controls: true) |
| + let moved = drag(from: NSPoint(x: bounds.midX, y: 40), by: CGVector(dx: 30, dy: 30)) |
| + XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| + } |
| + } |
| +#endif |
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/native/ios/project.yml b/native/ios/project.yml| index 7b9fe71..bb998cd 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.3.6' |
| + MARKETING_VERSION: '0.3.7' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
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); |