# Calls native apps

The SwiftUI Mac and iPhone apps use the same Calls server and shared Go core.

## Build and run

Requires macOS 14+, Xcode, Go 1.26 and network access for pinned dependencies.

```sh
make native-mac
open native/build/Calls.app
```

Signed out, the home screen names the Calls server — `calls.oficina.works`
unless you have chosen another — and offers one button, **Sign in**. It opens
your default browser for the existing Home/Calls sign-in on that server. Open
the emailed link normally, compare the code in the browser with the app, and
confirm **Sign in to Calls app**. The app continues automatically. There is no
address to type.

**Join a call as a guest**, under it, opens a screen with one field for the
invite link someone sent you (`/j/…` or `/m/…`, on any server), a **Paste**
control, **Join** and **Cancel**. The link is checked before anything opens,
and a problem is said beside the field in plain words. Join runs that meeting's
own browser sign-in — the pending-join for someone who is not a member — on the
server the link names. The clipboard is read only through Paste, so iPhone
never asks whether Calls may paste, and only a Calls link is taken from it.

**The server is a setting.** On a Mac it is **Calls server** in **Calls →
Settings…**, after the mini-window settings; on iPhone the gear on the home
screen (or **Change** beside the server line) opens a small settings sheet with
the same field. The address must be https — plain http is accepted only for
`localhost`, `127.0.0.1` and `::1`, the rule callscore's transport enforces — with
a host and nothing after it (no path, query or name and password); whitespace
and trailing slashes are trimmed and a bare host is taken to be https. **Reset
to calls.oficina.works** puts the default back. It is kept in UserDefaults (the
default is kept as no value at all, so a later change of default reaches
everyone who never chose). Changing it while signed in asks first, then signs
out of the old server exactly as **Sign out** does — the Keychain copy
forgotten, the old server asked to revoke the session — and it cannot be
changed during a call. `-CallsServer <origin>` on the command line overrides it
for one run without saving it, which is how the iPhone UI tests point the app at
their own server.

**Links.** A meeting link — `oficina-calls://join?url=…` from the browser, or
one pasted into the signed-in home's field — never changes the server setting.
On the server you are signed in to, it opens with the session the app holds if
that session may join the meeting (`OpenLink` asks the meeting's `/info`); if it
may not — the app is signed in as somebody else — the home says so and offers a
sign-in for that meeting only. A link from a signed-in browser's **Join in the
app** also carries an `offer` (2026-10-02): the app takes it up at once, opens
no browser, and the page the person came from asks them to confirm the app's
code, handing over their own session — member or guest — for that meeting.
Otherwise a link arriving through `oficina-calls://` is offered, not
started: the home names the server the meeting is on with a **Sign in** button
(and **Not now**), so a browser never opens for a server the person has not seen
named. One pasted into the field is the person's own act, so **Open link** goes
straight to that sign-in. A link on another server
signs in there **for that meeting only**, exactly as a guest does: that session
is never kept, starts no meetings, and leaving the meeting (or Sign out) drops
it — asking that server to revoke it — and comes back to the server's own home,
signed in if a member's session is kept, signed out if not. The kept sign-in
for the configured server is not replaced by a visit.

Once signed in — however sign-in began — the app is home: **Start meeting**,
**Join meeting** when there is a meeting in hand (the link you signed in from,
or the call you just left), a field to open a link someone sent you, and
**Sign out**. A link on the instance you are signed in to opens with the session
the app already holds, pasted or arriving as `oficina-calls://join?url=…`, and
joins straight away; a link on another instance is that instance's sign-in, as
before. Leaving a call comes back home.

The session is the person's whole session whether the browser landed on a link
or on the meeting list — the link only decides where it lands. Who may start a
meeting is `requireMember`'s decision on the server, and a refusal is shown as
the server gives it. Starting goes through `POST /native/meetings/start` — the
same `store.createMeeting`, behind the same `requireMember`, as the web list's
one-click button — and answers only the meeting's identifier. The invitation is
not part of the answer: a link is a credential, and the room's own **Share** is
where one is minted.

**The sign-in is kept.** A member's session goes into the Keychain — a generic
password, this device only, readable while unlocked — and the next launch
checks it with the instance before offering anything ("Checking your
sign-in"): one `GET /settings`, member-only, read by status alone. A member's
page means signed in; the sign-in redirect means the session has ended (thirty
days, or revoked), so it is forgotten and the home says so and points at
**Sign in**; anything else —
no network, a 5xx — keeps it, uses it, and offers **Check sign-in again**. Only
a member's session on the configured server is kept: a guest's, an invited
non-member's, or one on another server is good for the meeting it came in
through and is signed in again from the next link. A session kept for a server
that is no longer the configured one (the setting changed outside the app) is
signed out at launch. A sign-in that fails — no network, a server that does not
answer — says what happened and to choose **Sign in** (or **Join a call as a
guest**) to try again.
**Sign out** forgets the Keychain copy at once and asks the instance to revoke
the session with the web's own `POST /signout`; if that cannot be reached, the
session lapses at its thirty days.

On a Mac, the this-device-only keychain needs a keychain-access-groups
entitlement that the ad hoc signed `make native-mac` build does not carry.
There the item falls back to the login keychain, protected by the login
password and the app's code signature; because an ad hoc signature changes on
every rebuild, macOS may ask once per build whether Calls may read it.

Scheduling a meeting for later, ending one, and replacing invitations stay on
the web for this milestone.

**Devices** in the call opens the camera, microphone and speaker panel. The
audio readout is behind its ⓘ (**Audio details**), with the screen-share line
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). Since 0.3.7 a tile shows it
as the speaking light on the name — since 0.4.3 the warm rim (`SpeakingGlow`
and `WarmRim`: -42 to -12 dBFS, lit once speech has kept up 0.15 s, 50 ms
attack, 300 ms release, a steady 0.6 under Reduce Motion; see DESIGN.md) — 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's rim** 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
  isn't picking anything up", and its ⓘ names the device and input being sent
  ("Scarlett 18i8 USB, input 1"). Only once the hook has had buffers while a
  connection was up, and only while one still is (`MicSample.sending`):
  alone in a call WebRTC never opens the microphone, and 0.4.6 told Paul,
  alone, that his was dead (2026-10-03).
- **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
  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
  white ring marks whoever is speaking (with hysteresis; no fade under Reduce
  Motion), and the mini window's tab shows a waveform while anyone is.
- **Their report about you** shows on their tile: "Tony's browser is blocking
  sound — they need to click to hear you", or, while you are unmuted, "Tony
  isn't getting any sound from you". Each line is in the tile's accessibility
  label and announced when it appears. The app itself is never blocked from
  playing sound, so it never reports `audioBlocked`.
- **Who can hear you yet** (spec §15.24): the app reports `hearing`, the people
  whose audio path is established here (connected, packets in and decoded
  frames at the tap, or connected and muted), read five times a second until
  it is. From the moment someone joins, their tile says "Samuel is connecting
  and can't hear you yet" — past a one-second grace, with dots that take turns
  (steady under Reduce Motion) — until their report lists you, their own
  blocked or no-sound report replacing it, and never past twenty seconds
  (`ConnectingWatch.swift`). The mini window and its tab show a small ear for
  anyone it is not showing.

The rules (`AudioLevelRules.swift`) are held by `AudioLevelTests` on an
injected clock; `TestNativeMacCallsBrowser` checks that the browser measures
energy, not only bytes, in what the app sends, and that the app's receive meter
hears the browser.

The camera is a picker on both platforms. Switching rests on the capturer
feeding the same `RTCVideoSource`, so the `RTCVideoTrack` every peer
connection already holds does not change and nothing renegotiates — the
native counterpart of the browser's `replaceTrack`. Switches are serialised;
the last pick wins.

The camera is remembered and watched (`CameraDirector`, `ManagedCamera`, held
by `CameraRecoveryTests`), because of an ATEM Mini that dropped off USB mid-call
in 0.3.1 and left the self tile on "Waiting for video":

- **Remembered.** A camera chosen in the panel is kept in UserDefaults by its
  `uniqueID`, with its name for while it is missing. A call starts on it if it
  is connected, otherwise on the built-in camera (the front one on iPhone),
  otherwise on whatever there is, and the panel says so ("ATEM Mini isn't
  connected; using FaceTime HD Camera").
- **Followed.** Cameras connecting and disconnecting are observed —
  AVFoundation's connect and disconnect notifications and, on Mac, the
  discovery session's device list by KVO — with a look every three seconds as
  well while there is no working camera. The camera in use disconnecting falls
  back to another at once ("ATEM Mini disconnected; using FaceTime HD Camera");
  the remembered camera coming back, or appearing later, is switched back to.
- **Watched.** Frames are counted on their way to the video source. No first
  frame within three seconds of opening, or none for three seconds after they
  had been arriving, restarts capture on the same device; still nothing tries
  the device's next format, then another camera. With nothing to fall back to,
  the camera is named as having no signal and kept, and a frame arriving later
  is noticed. A camera that fell silent is returned to only when it reconnects.
- **Formats.** Nearest 640 wide, then nearest 30 frames a second without going
  over, at a rate the format really supports: 30 where a range covers it, or
  the format's own rate (an ATEM's 1080p formats each run at exactly one of
  23.98, 24, 25, 29.97, 30, 50, 59.94 or 60), set on the device exactly when it
  is not a whole number. An open that has not finished in six seconds counts as
  refused.
- **Restarted** after a capture session runtime error, an interruption ending,
  or the Mac waking, at most once every five seconds.
- **Said.** Your own tile says what the camera is doing instead of an endless
  "Waiting for video": "Starting camera…", "No signal from ATEM Mini — check
  its input", "Camera disconnected", "No camera found". The panel's Camera row
  is always there in a call — "No camera found" with **Try again** when there
  is none, and the picker with a plain-words line when the camera is not
  working.

The native/browser gate drives the same code with synthetic cameras, and stalls
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 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.

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. 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,
  is followed by the module itself, which listens for both and rebuilds its
  engine.
- An explicit choice keeps its device; a change of system default does not
  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"). "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):
  a picker under Microphone, "Input 1" to "Input 18" with the driver's channel
  names where it gives them, remembered per device UID. LiveKit's engine module
  wires the microphone in as mono, and the conversion from a multi-channel
  interface keeps its first input only, so a microphone on input 2 used to send
  silence. The module's observer now sets the input unit's channel map
  (`kAudioOutputUnitProperty_ChannelMap`) so every channel it hands the engine
  is the chosen input, and connects nothing, leaving the module's own mono
  connection in place (`InputChannel.swift`). Changing it mid-call takes the
  module's input away and gives it back (`setEngineAvailability`), which
  rewires the microphone with the new map — a moment's gap, as a device switch
  has. Since 0.3.8 the map is strictly opt-in (`InputChannelMap.channel`): it is
  set only for an input other than the first, chosen on a microphone chosen by
  name that has more than one input. On the system default, or any ordinary
  microphone, the unit is left exactly as the module wired it — 0.3.3's
  capture — and the picker is not shown.
- **A chosen microphone changing rate** (a Bluetooth headset moves to its
  hands-free 16 or 24 kHz the moment its microphone opens) is listened to, and
  a running engine wired at another rate is rebuilt (`RateRule`, at most once
  every three seconds). On the system default the module follows the device
  itself, as it always has.

**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.

**Test audio** (home screen, and the device panel in a call) meters the
capture hook — before noise reduction and after it — and records from the
hook's sink. Before a call it builds a CallAudio as a call does and sends its
microphone track between two in-process peer connections that are never
connected, then asks the engine module to record (`initRecording`,
`startRecording`: what WebRTC's first send stream calls). That last step is
0.3.8's. WebRTC starts a send stream, and with it the microphone, only on a
connection whose transport has been writable (`BaseChannel::IsReadyToSendMedia_w`,
`was_ever_writable_`), so in 0.3.6 and 0.3.7 the test never opened the
microphone on any Mac: an empty meter and "We can't hear anything from …" for
every device (Paul's AirPods, 2026-09-26). The interop harness passed because
in manual rendering the module runs its input whenever its output runs; it now
also runs the start on the real device module, with 0.3.7's start as a control
that must never ask for the microphone. Calls themselves were not affected:
their connections are connected.

**Nothing in the audio start runs on the main thread** (0.3.9). Paul's
0.3.8, on a MacBook Air with AirPods Pro: "after I open the Test window,
there's a pause, then a beachball, then it works." A main-thread watchdog put
numbers on it: building the factory and its engine module (100–135 ms) and
logging the module's first device list (61–68 ms); applying the loopback's
answer, which waits for the engine's playout to start (188–229 ms); and
`initRecording`, which waits while the engine starts with the microphone — for
a Bluetooth headset, while it moves from A2DP to its hands-free profile, one to
two seconds. Every call into WebRTC's objects is a blocking hop to its own
threads, and while the worker thread is starting the engine every one of them
waits: the module's `isRecording`, `isPlaying` and device lists, the factory's
processing state, a track's `isEnabled` (mute) and its processing options,
0.9–1.2 s each with the switch simulated. So all of it runs on one serial
queue (`AudioWork`), in the order asked; the main thread reads the module's
state from `ModuleState`, which waits at most 5 ms and otherwise gives the last
answer. The window opens at once and says **Starting microphone…** beside a
spinner — **Switching Paul's AirPods Pro to microphone mode…** for a Bluetooth
headset not yet at its hands-free rate, because that wait is physics, not a
fault — and the meter comes alive when the microphone has started. Closing it
partway cancels cleanly: the stop is queued behind the start, so what the
start opens is closed (`MicStart`). A call's join builds its media the same
way (`MediaEngine.make`), and its connections' descriptions, candidates,
statistics, sender parameters and closes go through the same queue
(`PeerCalls`). Measured by `CallsInterop --audio-start-stall` on an M1 Max,
longest main-thread stall, before → after: Test audio 142–716 ms → 0–5 ms;
with a 1.5 s Bluetooth switch simulated 1,536–1,657 ms → 1–7 ms; closed
mid-start → 0–8 ms; a call's audio start with the switch 1,322–1,514 ms →
21–43 ms.

The verdict names the device the microphone really is, with its connection
and rate ("Paul's AirPods Pro (Bluetooth, 24 kHz)"), and tells apart "no sound
is arriving" (no buffers at all: the microphone was asked for and nothing came
back) from "sound is arriving, but it is silent". Bluetooth rates, which the
suppressor passes through, are metered and recorded like any other.

**Copy audio diagnostics**, in Test audio (in the panel's ⓘ it is **Copy call
diagnostics**, with the screen's account added), puts text on
the clipboard to paste into a message: the devices chosen and what they
resolve to, every CoreAudio device with its rate and transport, the engine
module's state and last few engine events with their formats, the capture
hook's rate and buffer counts, the processing report, the last ten seconds of
meter readings in dBFS, and the last 50 lines of the audio log (which is also
kept in memory, the last 200 lines). Never any audio.

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
custom-audio-device constructor. That constructor takes no audio processing
module, so it cannot carry the noise suppressor; the rules above are that
build's, carried onto LiveKit's module, and `CallsAudio` was not kept.

The camera list includes external and Continuity cameras (the Mac app
declares `NSCameraUseContinuityCameraDeviceType`) and refreshes every second
while the device panel is open. A camera switch that is refused tries to
restore the previous camera, and the picker returns to the camera actually in
use.

Apple's voice processing is **not** allowed on Mac
(`setPlatformVoiceProcessingAllowed(false)`). Left allowed, the engine module
resolves echo cancellation, noise suppression and gain to Apple's coupled
voice-processing unit; with it off, WebRTC's own software processing runs, as
it always has on Mac. One consequence: macOS offers its **Mic Mode** menu
(Voice Isolation and the rest) only to apps on voice processing, so it is not
offered to Calls on Mac. The iPhone still runs on the voice-processing unit.

The native/browser gate cannot open a microphone (a test process has no
permission to), so `CallsInterop` gives the engine module a synthetic voice
through manual rendering mode, as it gives the capturer synthetic frames; no
device is opened on either side of it.

**Reduce background noise** (in the device panel, on by default, remembered)
replaces WebRTC's classic noise suppressor with a neural one, for the noises
people actually hear: typing, wind, a room's echo. WebRTC's capture
processing runs high-pass filter, echo canceller, noise suppressor and gain
control, then hands each 10 ms frame to a capture post-processing hook
(`RTCDefaultAudioProcessingModule`); the suppressor runs there, after the
echo canceller has removed the far side. `ProcessingPlan` holds the rules:

- WebRTC's noise suppressor is off while ours runs, and ours never runs
  beside Apple's Voice Isolation (it stands aside when that is the active
  mic mode).
- WebRTC's gain control runs *before* the hook, so it would lift the room
  with a quiet voice and hand the suppressor more noise to remove. It is off
  while ours runs; `SpeechGain` lifts the voice after the noise is gone
  instead — only upward, at most 12 dB, learning only from frames the model
  calls speech, about 1 dB a second, with a peak limit.
- The models run at 48 kHz only. At any other capture rate (a Bluetooth
  headset's 16 kHz) frames pass through untouched and WebRTC's own
  suppressor and gain come back on the Mac.
- Off means no suppression at all; WebRTC's gain control comes back.
- On iPhone Apple's echo canceller and its noise suppressor are one switch
  in the voice-processing unit, and the phone has always run both; that
  pairing stays. Apple's gain control is separate and comes off while ours
  runs.

The audio readout says which suppressor is running and at what share of a
core, and what WebRTC reports as running for echo cancellation, noise
suppression and gain — what it reports, not what was asked. WebRTC skips the
hook while the microphone is muted, and the readout says so. WebRTC opens the
microphone only once a connection is sending, so a person alone in a call has
none running yet; until then the processing module reports its construction
default (16 kHz, everything off), and on Mac the readout says "The microphone
has not started yet" instead of repeating that. Every initialisation of the
capture hook is reported to the plan, a repeat of the same rate included, so a
microphone really capturing at 16 kHz brings WebRTC's own suppressor back.

**On Mac the suppressor is DeepFilterNet 3**; on iPhone it is RNNoise.

- **DeepFilterNet 3** (Rikorose/DeepFilterNet's libDF, MIT or Apache-2.0;
  notice in `LICENSE.deepfilternet`, bundled in the Mac app) is markedly
  better on non-stationary noise — typing, wind, voices in the next room. It
  takes 480-sample hops at 48 kHz in ±1 floats, so samples are scaled by
  1/32768 on the way in and back on the way out, and adds about 30 ms of
  latency. `scripts/build-deepfilter.sh` builds `native/build/DeepFilter.xcframework`
  (static; `aarch64-apple-darwin`, `aarch64-apple-ios`, `aarch64-apple-ios-sim`)
  with a pinned rustup toolchain, cbindgen for the header, and the C API
  (`--features capi`), and copies the standard model
  (`DeepFilterNet3_onnx.tar.gz`, 8 MB) beside it; the Mac bundle carries the
  model. Needs rustup (https://rustup.rs). The library is linked on Mac only.
  The model takes about 0.3 s to load (on an M1 Max; longer on an Air), so
  one is loaded three seconds after launch, at utility priority, and the
  first Test audio or call takes it ready-made (`DeepFilterPreload`). A
  loaded model is about 88 MB resident, so only one is kept spare: taking it
  does not load another at once (a call would then hold two), a new spare is
  loaded when its user is done, and a spare nobody has taken for fifteen
  minutes is let go. Without a spare a call starts on RNNoise and moves to
  DeepFilterNet when one has loaded, off the main thread, as before; a model
  is never handed from one user to the next, because its state holds the
  last audio it heard and libDF has no way to clear it. A missing model
  leaves it on RNNoise. (RNNoise itself is made in under a millisecond and is
  not preloaded.) `CALLS_DEEPFILTER_MODEL` points at a model file
  outside a bundle (the tests and the interop harness).
- **RNNoise** (xiph, BSD-3; notice in `LICENSE.rnnoise`, bundled in both apps):
  480-sample frames at 48 kHz in WebRTC's own ±32768 scale. Its model weights
  arrive as a generated C file of 78 MB, so it is not a source target:
  `scripts/build-rnnoise.sh` fetches the pinned commit and the checksummed
  model and builds `native/build/RNNoise.xcframework` (static, Mac, iPhone
  and simulator).
- Both are limited to 30 dB of attenuation: unlimited, a room goes to digital
  silence between words, which is heard as the line dropping. DeepFilterNet
  has the limit built in; RNNoise gets it by mixing the untouched input back
  in at -30 dB.
- Measured on an M1 Max (fixture test, isolated): RNNoise 0.69–0.76 ms and
  DeepFilterNet 3 1.16–1.23 ms per 10 ms frame, i.e. about 7% and 12% of one
  core. In the native/browser gate, as wall-clock share of the audio thread
  with Chromium busy on the same machine: RNNoise median 12.9%, DeepFilterNet
  median 15.1% (13–27%). DeepFilterNet has not been measured on an iPhone,
  which is why the phone stays on RNNoise; the xcframework already carries the
  iOS slices, so trying it is a Package.swift condition and a bundled model.

None of these libraries is committed: `build-mac.sh` and `build-iphone.sh`
run all three scripts (bridge, RNNoise, DeepFilter), each cached against its
pins; run them yourself before `swift test`.

Choosing a Bluetooth microphone on iPhone puts the call on the hands-free
profile, which is narrowband in both directions. That used to happen
silently to every call the moment any headset connected, because
`.allowBluetoothHFP` was set unconditionally; it is now set only while a
Bluetooth input is the chosen one, and the panel says what it costs.

On iPhone and iPad the panel also names Apple's **Mic Mode** — Standard,
Voice Isolation or Wide Spectrum — and **Change Mic Mode** opens the system
picker. Voice Isolation is the best noise suppression the device has (wind,
typing, room echo), and it reaches the call because WebRTC's iOS audio device
runs on Apple's voice-processing I/O unit. Apps cannot set the mode, only show
it and open the picker; when the route cannot honour the chosen mode, the panel
names the one actually in use. The Mac app does not offer it: WebRTC's macOS
audio device does not use Apple's voice processing, so the mode would not reach
the call.

The **audio readout** separates the two ways received audio goes wrong,
which is the distinction a person reporting "it growled" cannot make:
concealed samples are loss and sound like dropouts, while inserted and
removed samples are the jitter buffer rescaling the signal to track the
sender's clock — and a stretched voice is what a growl is. The device pickers let you compare routes while watching those counters.

**Shared screens** from anyone in the call — a browser or the other app —
take the stage as a tile of their own, captioned "Name’s screen" exactly as
the browser captions it, while the sharer's camera moves to a thumbnail
beside yours. Nothing on the wire says which m-line is a screen; the app
works it out with the browser's own rules (spec §5.4, `classifyTrack` in
`room.js`, ported in `ScreenShare.swift` with its tests), so the two never
disagree about whether they are looking at a face or a document. The tile
goes on whichever of §5.4's two endings arrives first: the `share {on:false}`
message, or the stopped transceiver taking its receiver away. Each was
checked alone against a browser when this was written (the other one
disabled); the native gate checks them together.

**Share your screen** on the Mac opens the system's own picker
(`SCContentSharingPicker`, macOS 14) for a display or a window. What a person
picks there is what they have agreed to share, so Calls asks for no Screen
Recording permission, sends nobody to System Settings, and needs no Info.plist
key — macOS has no usage string for screen capture — and the same path works in
the App Sandbox. Calls' own windows are left out of the picker, and from a shared
display too on macOS 14.4 and later, where the display is rebuilt excluding the
app so that windows opening later (the floating mini window among them) stay
out as well. The screen goes to every connection on its own `sendonly`
transceiver in its own stream, exactly as the browser sends one, announced with
`share {on:true}`; stopping stops the transceiver and renegotiates, so the far
side's track ends rather than freezing, and says `share {on:false}`. The menu
bar's stop control and a shared window closing end it the same way as the
button, and so does **Stop sharing** in the "You are sharing your screen"
notice itself (0.4.3; the iPhone's and the browser's notices have it too), which
stays on screen while the controls fade. Frames are capped at 1920 on the long side and 30 a second.

**Optimise shared screen for** (in the device panel, remembered, applied live
to a share in progress) decides what gives way when the uplink or the encoder
cannot carry everything: **Balanced** (the default) holds the frame rate and
sends full size while the link allows; **Text and detail** holds resolution
and lets frames fall; **Motion and video** holds the frame rate at 1280 on the
long side at most. They are sender parameters (`ScreenSendPolicy`), so nothing
renegotiates. While a screen goes out it asks for H.264 first (Apple's
hardware encoder), with every other codec still offered, and has high priority;
the camera beside it is capped at 500 kbit/s and 360 lines, and given its
defaults back when the share stops. While sharing, the panel shows one line on
how the screen is actually leaving, e.g. "Screen: 1920×1080 at 8 fps, limited
by bandwidth, 1.2 Mbit/s, direct (H264 hardware)", and a second line with the
encoded and sent frame rates, keyframes made and asked for, and what the screen
watchdog is doing. The Mac capture keeps six surfaces in its queue (three, in
0.4.x, left too few once the encoder and the repeat were holding theirs, and a
capture with every surface held stops dead), drops a frame that arrives more than
100 ms late, and repeats the last frame once a second while the screen is still,
so a keyframe can be made for a receiver that asks for one. A screen whose source
is not a screencast (Motion, or a share started in it) is kept within H.264 level
3.1's 1280×720 worth of blocks: Chromium answers H.264 at that level, and the
Mac's encoder refused larger frames outright (spec §15.21). The browser offers the
same three choices, as `contentHint`, `degradationPreference` and sender
parameters.

**A screen that stops is put right without anyone sharing again** (spec §15.21,
after Paul's "my screen froze on her end … I had to stop and re-share", 28 Sep).
The sharer's watchdog (`ScreenWatchdog`) looks once a second: a capture that
reports nothing for three seconds is restarted on the same content; a connection
that takes frames in and encodes or sends none for five seconds (past its first
ten, and not held back by the link) gets a keyframe, then a fresh encoder (the screen a third
smaller for thirty seconds), then a new transceiver, three seconds apart. A receiver — app or browser — whose view of someone's screen has
decoded nothing for four seconds says so in its `media` report
(`screenStalled`), the sharer sends that connection keyframes (and the fuller
steps only if its own screen has stopped leaving too), and shows
"Vicky's view of your screen stalled — recovering" while it does. Each step is in
`log show --predicate 'subsystem == "net.amadan.oficina.calls" && category ==
"screen"'` and in **Copy call diagnostics** (the panel's ⓘ; Test audio's is still
**Copy audio diagnostics**). The apps restart ICE only when a relay credential is
due for renewal, not on every stream resume as 0.4.x did.

**When keyframes are not enough** (spec §15.22, after Paul's "still seeing issues
with screen sharing after a while", 30 Sep, the notice sitting at "recovering"):
a receiver still reporting six seconds after the first keyframe gets its screen
rebuilt — a new transceiver for that person alone, so a new encoder here and a new
receiver and decoder there, at the same size and mode (a report never shrinks a
Text and detail share) — and the notice says "Restarting your share for Samuel…".
Still reported ten seconds later, everyone's screen is shared again from the same
capture, with no picker: what Stop and Share did by hand. Ten seconds after that
the notice says "Couldn't restore Samuel's view — try Stop and Share again". A
rebuild at most once a minute per person, a share again at most once every five
minutes, and neither unless frames actually left for that person since the last
step. The receiver hands a stalled screen's picture to its view again four seconds
in, logs what its statistics say is still arriving (packets, frames received,
decoded, dropped, keyframes, PLI, the decoder), holds its report for up to three
minutes, and counts any change in the decoded count as movement (a rebuilt screen's
count starts again from zero, which 0.4.3 read as still stalled). The iPhone's
broadcast repeats its last frame once a second while the screen is still, as the
Mac's capture does, so a still iPhone screen is no longer reported as stalled. The
browser does all of this too (its sharer's rebuild and share again included), and
has **Copy call diagnostics** at the foot of its Devices panel.

"Reconnecting…" is said only for a problem that lasts (spec §15.23): the
signalling stream down 4 s, a connection `disconnected`/`failed` 3 s, or no
audio for 2 s from someone unmuted — that one on their tile, "Reconnecting to
Samuel…" — and it stays a second past the recovery. The stream's own renewal
every few minutes is never said. `ReconnectWatch`; the `connection` log
category.

The gate for it wedges a receiver's screen decoder, which keyframes cannot mend:
`screen-decoder-wedge` in `CallsInterop` (run with `CALLS_DECODER_WEDGE=1`; a
wrapping decoder factory that only the gate turns on), and in Chromium an encoded
transform that swallows every frame. `wedge`, `wedge-sticky` and `reshare` in
`make native-pair`, and `wedge`, `wedge-sticky`, `web-wedge` and `web-wedge-sticky`
in `make native-browser`, hold the ladder to restoring the view within 30 s, or to
saying it cannot. `soak` and `web-soak` (`SCREEN_SOAK_SECONDS`) and the pair's
`soak` (`SCREEN_PAIR_SOAK`) are the long shares, run by hand.

The numbers behind the modes come from `TestNativeMacScreenMotion` (in
`make native-browser`): the app shares a 1080p page scrolling at 30 fps with a
window dragged across it, and Chromium measures what arrives, with the app's
uplink estimate open and capped at 1.5 Mbit/s. Before this, a 1.5 Mbit/s uplink
delivered 2 fps at full size. Balanced and motion must now arrive at 15 fps or
more, and detail must keep 1920×1080.

On **iPhone** the whole screen is shared through a ReplayKit broadcast
extension, which hands its frames to the app over a socket in an App Group
container. See [ios/README.md](ios/README.md#sharing-the-screen) for how that
works and what signing it needs.

**Knocks** are announced when someone asks to be let in while the app is
not frontmost. The admission queue is already on screen for a member who is
looking at the call; this is for the member who is in another Space,
another app, or has the phone in a pocket.

A knock also reaches a member whose app is **closed**, where the server
has an Apple push key. The app registers its device token at join
(`POST /native/devices`); the hub calls back when someone begins waiting;
the server pushes to every member who was *not* admitted in that room at
that moment, because the ones who were can already see the queue.

Without a key the server sends nothing and says nothing, which is where
this app was before push existed: knocks still reach everyone in the room
over their own streams. It is configured with `CALLS_APNS_KEY` (the .p8
as Apple issues it, via `carlos secrets set`), `CALLS_APNS_KEY_ID`,
`CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS`, `CALLS_APNS_TOPIC_MACOS`
and optionally `CALLS_APNS_SANDBOX`. All five of the first are required
together — a half-configured push is refused at boot rather than
discovered on the first knock.

Someone who arrived while the queue was visible is never announced later,
so tabbing away does not produce a pile of notifications about people who
have been waiting a while. Only an arrival during an absence is news, and
leaving the queue and knocking again is heard again.

On Mac, **switching to another app during a call** puts the call in a small
floating window, bottom right by default, and coming back to Calls restores the
call window as it was. It is the first setting in **Calls → Settings…**, on by
default, with the corner beside it; dragging the window snaps it to the nearest
corner and remembers that one. **Mini window placement** can instead be
**Anywhere I put it**: the window stays exactly where it is dropped, remembered
per display (by display ID, as an offset from that display's visible frame, so
rearranging displays does not lose it) and clamped back on screen when a display
changes. Holding **⌥ Option** while dragging places it freely even when snapping
to corners, for the rest of that call only. It resizes from its edges and
corners at 16:9, from 200 pt wide up to 40% of the screen's width, and the size
is remembered; snapping keeps it (`MiniWindowResize`, done by the window itself
rather than AppKit's `.resizable`). Clicking the window brings Calls back;
hovering shows Collapse, Mute and Leave. **Collapse** shrinks it to a tab
against the nearest screen edge showing your microphone state and who the call
is with (the other person's name, or a count); clicking the tab or dragging it
away from the edge opens the mini window again, and double-clicking it brings
the call window forward. Collapsed lasts for the rest of the call; every call
starts expanded. The tab is the same window, so it floats, follows Spaces and
stays out of screen captures exactly as the mini window does. There is no
speaking light: nothing in the app measures audio level live.

**Space** toggles the microphone while the call window (or the mini window) is
the key window, unless a text field or a control has the keyboard, where Space
is typing or the control's own press; a held Space toggles once. **Call → Mute
Microphone** (⌘⇧M) is the same toggle. The browser room does the same (never in
an input, textarea, select, contenteditable, button, link or other control, and
announced through the polite live region), and on iPhone and iPad a hardware
keyboard's Space does it through a key command that yields to text editing and
focus. Push-to-talk (holding Space while muted) is not built.

**Call → Copy Invite Link** (⌘⇧I), beside Mute, puts the meeting's invite link
on the clipboard and says "Invite link copied" briefly over the stage (and to
VoiceOver), from the call window or the mini window, with the controls shown or
not. It asks the server the way the Share button does, which returns the
meeting's current sealed invitation and never replaces or revokes one. It is
off outside a call; a guest pressing it is told only members can share. An
iPad's hardware keyboard has the same shortcut. The browser room does not:
⌘⇧I is Chrome's developer tools. It shows what Speaker view's stage shows (0.4.0), whichever layout the call
window is in: whatever you pinned, otherwise the newest shared screen, otherwise
whoever is speaking (see [DESIGN.md](DESIGN.md#speaker-view-and-your-own-order--2026-09-27-040)),
otherwise the first other participant in your order, with their muted and
camera-off state, or your own camera while you are alone. It is not
shown for a minimised call window, and ⌘H hides it with the rest of the app. The
call window is hidden by transparency rather than ordered out, because SwiftUI
treats an ordered-out window as closed, which would leave the call. The mini
window is excluded from screen capture: a shared display leaves out the whole
Calls application (macOS 14.4 and later), and the window also sets
`sharingType = .none`, so it never appears in a screen this app shares.

Members can use **Share** in the call to retrieve the current invitation, copy
it, or open the system sharing menu. This needs the server's
`POST /m/{sqid}/share` endpoint and the encrypted-invitation migration.

There are no embedded webviews. SwiftUI renders the app and native WebRTC handles
media. The shared Go core waits for a five-minute, one-use authorization bound
to a proof secret held only by the app. Browser approval creates a separate,
ordinary framework session, preserving the existing identity method and Home
assertion authentication time. No browser cookie is copied, and no new bearer
authentication type is introduced. A member's session is kept in the Keychain,
as described above.

**The server must include this branch's native authorization endpoints.** An
older server cannot complete native sign-in; the app reports that limitation.
The production handoff endpoints were deployed in `86ed9f3`. In-progress handoffs expire on
server restart. There are at most 256 pending handoffs per server process.

`make native-mac` builds for the host Mac and signs the local `.app` ad hoc.
Developer ID signing, notarization and App Store packaging remain follow-up work.
The iPhone target, device builds and Safari tests are described in
[ios/README.md](ios/README.md).

## Quality reports and Report a problem

The apps send a connection-quality report every 30 seconds in a call and one
at leave, and "Report a problem" (the ⋯ menu, the ⓘ panel, and the screen a
call ends on) sends the call diagnostics with a note. Spec §15.26 says exactly
what is in them and what never is; Settings has the disclosure and the
"Send anonymous quality reports" switch. They land in the server's log as
`calls: quality` and `calls: problem report` JSON lines under random call and
peer tags, kept for the platform's seven days. To read them:

```sh
# The call at 15:40 today (Europe/Dublin), summarised
go run ./cmd/quality-summary --since 24h --at 15:40
# A reference someone was shown, with its diagnostics
go run ./cmd/quality-summary --problem K7Q2M9PD --since 7d
# The raw lines of one call
carlos logs --app calls --account oficina --since 24h -n 5000 --field call=<tag>
```

[docs/operations/quality.md](../docs/operations/quality.md) has the line
shapes, more queries, and the kill switch (`CALLS_QUALITY_REPORTS=off`).

## Shared code

- `protocol/`: the server and client use the same Go wire types. The hub keeps
  aliases so authorization and admission behavior remain in the server.
- `callscore/`: browser sign-in handoff, bounded SSE parsing, room state, per-target ordered sends,
  generation fences, retry, resume and ICE credential fetching. No UI, database,
  or media dependency.
- `native/bridge`: a narrow gomobile Objective-C interface consumed by Swift.
- `native/apple/Sources/CallsKit`: Swift adapter and native WebRTC media. The
  platform capture/UI portions are separate from the Go protocol implementation.
- `native/apple/Sources/CallsMac`: SwiftUI app and system-browser authentication host.
- `native/apple/Sources/CallsInterop`: a separate synthetic-media test executable;
  it is not bundled with Calls.app.

Go Mobile's cgo requirement is isolated in `native/go.mod` and the native build
script. The existing server continues to build with `CGO_ENABLED=0`. WebRTC is
LiveKit's build of Google WebRTC, `livekit/webrtc-xcframework` pinned exactly to
150.7871.02 (M150); SwiftPM verifies its published artifact checksum. It replaced
stasel/WebRTC 152 because it carries what that build does not: an audio device
module on macOS (device lists and selection) and a capture post-processing hook
for noise suppression. Its Objective-C symbols are prefixed `LK`; CallsKit's
`WebRTCNames.swift` aliases the old `RTC…` names once, so code and open branches
keep using them and change only their import (`import LiveKitWebRTC`).
Go Mobile is also pinned; so are RNNoise and DeepFilterNet (see above). The bridge includes Mac, iPhone and iOS Simulator
slices. Generated frameworks, Xcode projects and binaries are ignored.

## Verification

```sh
CGO_ENABLED=0 make ci
make race
CGO_ENABLED=0 make browser
make native-test
make native-browser
make native-pair
CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/callstest
```

`TestNativeMacAudioStartStall` (in `make native-browser`) runs
`CallsInterop --audio-start-stall`: a watchdog posts to the main queue every
5 ms and times the answer while Test audio starts (plainly, with a 1.5 s
Bluetooth switch simulated by holding the engine's start on WebRTC's worker
thread, and closed partway through) and while a call's audio starts with the
same switch, all on the real engine module. Each must hold the main thread
under 100 ms; a control that blocks it for 300 ms on purpose must be seen.

`swift test` needs `native/scripts/build-bridge.sh`,
`native/scripts/build-rnnoise.sh` and `native/scripts/build-deepfilter.sh` to
have run. Its suppressor tests feed
`Tests/CallsKitTests/Fixtures/speech-48k.wav` (macOS's own speech
synthesiser, normalised to -23 dBFS, a second of silence ahead) mixed with
seeded hiss and key clicks through each real suppressor, and require the
noise-only lead-in to fall by at least 10 dB, the speech level to hold within
6 dB, and 80% of voiced frames to survive. The frame adapter, the gain stage
and the processing plan have unit tests of their own.

The native/browser gate requires the same Playwright/Chromium setup as
`make browser`, and working UDP. It creates two real magic-link sessions and a
meeting on an isolated server, starts Chromium and the separate native test
executable, then checks guest admission, actual incoming/outgoing media, clean departure and continuity through
ten-second SSE renewals, bidirectional chat and the native sender's accepted-message
history. Test speaker playback is disabled in both clients; audio checks use RTP
counters, not audible output. The native side's microphone is a synthetic
voice through the audio device module's manual rendering mode, so its capture
passes through WebRTC's real processing: the gate asserts the noise suppressor
processed frames, and that WebRTC reports its own suppressor and gain off and
its echo canceller on. `make native-browser` builds `CallsInterop` itself;
it used to run whichever binary an earlier build had left. It never needs production credentials or hardware
camera access. The Go Home test separately exercises a session minted through
Home assertions and explicit identity linking, including resume and meeting end.

The native/browser gate also shares screens both ways: the browser shares (a
fake monitor, `--auto-accept-this-tab-capture`) and the app must caption and
decode it beside the camera, then drop it when it stops; the app shares a
synthetic screen and the browser must show it under the app's name, then see its
received track *end* — once by the button and once by the capture ending on its
own. `make native-pair` puts two apps in one call with no browser: a screen
shared before the other app joined, both endings, and both apps sharing at once.
`TestNativeMacScreenRecovery` (in `make native-browser`) and
`TestNativeMacScreenPairRecovery` (in `make native-pair`) share a screen
through relay renewals every twenty seconds and stream resumes every ten, a
capture that stops, an encoder that stops, and a receiver that reports a stall,
and require the watcher's screen to keep decoding or come back without a new
share; `SCREEN_PAIR_A`/`SCREEN_PAIR_B` put an older build on either side, and
`screen-display` shares this Mac's real display through ScreenCaptureKit and the
hardware encoder (Screen Recording permission needed).
It is separate because two unsigned apps on one Mac do not reliably connect at
all (see `TestNativeMacScreenShare`), which looks like the machine rather than
the feature.

The native chat gate sends a sequence of browser messages and renders the shared
native chat view to `native/build/chat-incoming.png` for visual inspection of the
latest message. The window stays off-screen. Native chat follows new messages
and keeps its history visible during signaling reconnects.

There is no claim that a simulator or synthetic test proves physical camera,
microphone permissions, audio-device switching, sleep/wake or Home passkeys.
Those need a real-device acceptance pass. So does screen sharing: the Mac's
system picker and the iPhone's broadcast extension cannot be driven by any gate
here. Background calling (other than while sharing the iPhone's screen) and
long-call TURN renewal parity are follow-up work.

See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and acceptance limits.

### App artwork

The iPhone asset catalog and Mac application resource use the approved Oficina
Calls icon. Both are generated copies of Estilo's `brand/png/calls-1024.png`;
update them with `python3 brand/distribute.py calls /path/to/calls` from Estilo.
The Mac's icon comes from the iPhone's full-bleed square, the one master.
`native/scripts/bundle-mac.py` wraps it in an Icon Composer `AppIcon.icon` and
compiles it with `xcrun actool` into `Assets.car`, named by `CFBundleIconName`:
macOS 26 and later mask, light and tint it themselves, and actool flattens it
onto the 824-on-1024 grid for macOS 14 and 15. This needs Xcode 26 or later.
`native/scripts/generate-mac-icon.swift` derives `MacIcon.png`, the master
rounded onto that grid, for `CallsMac.icns` (`CFBundleIconFile`) and for the
Dock icon set at launch, which happens only before macOS 26 or outside a
bundle. Never feed it `Resources/AppIcon.png`, which is already on the grid:
padding it twice shrank the Dock icon to 664 of 1024 from 0.4.5. The Lucide
notice is retained in `native/LICENSE.lucide`.

Third-party notices ship in the apps' Resources:

| Notice | What | Mac | iPhone |
|---|---|---|---|
| `LICENSE.lucide` | Lucide icons | yes | yes |
| `LICENSE.webrtc` | WebRTC's own BSD licence, as LiveKit ships it in `LiveKitWebRTC.xcframework` | yes | yes |
| `PATENTS.webrtc` | WebRTC's additional IP rights grant (patents) | yes | yes |
| `LICENSE.rnnoise` | RNNoise | yes | yes |
| `LICENSE.deepfilternet` | DeepFilterNet's libDF | yes | no (not linked) |
| `LICENSE.deepfilternet-crates` | the 90 Rust crates libDF links, generated by `scripts/deepfilter-notices.py` after `build-deepfilter.sh`; rerun it when the DeepFilterNet pin changes | yes | no (not linked) |

`LICENSE.webrtc` is WebRTC's own licence only. LiveKit's framework ships no
notices for the third-party code Google's WebRTC compiles in (libvpx, libyuv,
Opus, BoringSSL, abseil and others); those have not been gathered.

### Native interface

See [DESIGN.md](DESIGN.md) for the first Oficina native visual direction, shared
views, screenshot workflow and remaining interaction work.
