Merge branch 'fix/camera-recovery' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
28 files changed,
+2170
−288
callscore/room.go+15 −0callscore/room_test.go+100 −0callscore/transport.go+28 −0internal/calls/home_native_test.go+94 −0native/README.md+65 −12native/apple/Sources/CallsInterop/Interop.swift+25 −1native/apple/Sources/CallsInterop/SyntheticVideo.swift+36 −25native/apple/Sources/CallsKit/Audio.swift+125 −62native/apple/Sources/CallsKit/Call.swift+43 −3native/apple/Sources/CallsKit/CallAudio.swift+36 −7native/apple/Sources/CallsKit/CallsModel.swift+9 −0native/apple/Sources/CallsKit/CameraRecovery.swift+424 −0native/apple/Sources/CallsKit/Capture.swift+508 −93native/apple/Sources/CallsKit/DevicePanel.swift+75 −8native/apple/Sources/CallsKit/GuestJoinView.swift+0 −2native/apple/Sources/CallsKit/MacCallView.swift+14 −3native/apple/Sources/CallsKit/MacVideoTile.swift+8 −4native/apple/Sources/CallsKit/Media.swift+27 −3native/apple/Sources/CallsKit/MiniWindow.swift+2 −1native/apple/Sources/CallsKit/NoiseSuppression.swift+15 −4native/apple/Sources/CallsMac/CallsMac.swift+26 −1native/apple/Tests/CallsKitTests/AudioHealthTests.swift+47 −2native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+72 −43native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift+329 −0native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift+9 −4native/ios/Sources/CallsPhone.swift+19 −9native/ios/project.yml+1 −1test/browser/native.mjs+18 −0
CI passed — run details
diff --git a/callscore/room.go b/callscore/room.go| index a02c121..06ec76b 100644 |
| --- a/callscore/room.go |
| +++ b/callscore/room.go |
| @@ -157,6 +157,15 @@ func (r *Room) streamLoop() { |
| } |
| delay := 500 * time.Millisecond |
| + // A fresh open refused with 409 means this session already holds a |
| + // participation in the room. For an app that is its own ghost: the |
| + // previous run of the app (quit, crashed, relaunched by an update) |
| + // whose participation is still inside its thirty-second resume |
| + // window. The session is the app's alone, so it takes its own place |
| + // back, once, rather than ending the call as "open elsewhere" before |
| + // it began. A second instance that really is elsewhere is told |
| + // lost{replaced} on its own stream, and does not take it back. |
| + tookOver := false |
| for r.ctx.Err() == nil { |
| r.mu.Lock() |
| peer := r.state.Peer |
| @@ -189,6 +198,12 @@ func (r *Room) streamLoop() { |
| r.terminal(reason) |
| return |
| case 409: |
| + if peer == "" && !tookOver { |
| + tookOver = true |
| + if r.t.takeover(r.ctx) == nil { |
| + continue |
| + } |
| + } |
| r.terminal("replaced") |
| return |
| case 401, 403, 302, 303, 307, 308: |
diff --git a/callscore/room_test.go b/callscore/room_test.go| index cfd5689..c0d9aa5 100644 |
| --- a/callscore/room_test.go |
| +++ b/callscore/room_test.go |
| @@ -302,3 +302,103 @@ func TestTargetQueueRetriesSameSequenceBeforeNextMessage(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A fresh open refused with 409 takes the session's own participation back |
| +// once, and a refused takeover — or a second 409 — is "replaced" as before. |
| +func TestFreshOpenConflictTakesOverOnce(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + takeover int |
| + location string |
| + conflicts int |
| + want string |
| + }{ |
| + {"taken back", 303, "/m/room", 1, "admitted"}, |
| + {"takeover refused", 404, "", 1, "replaced"}, |
| + {"sign-in redirect is a refusal", 303, "/signin", 1, "replaced"}, |
| + {"only once", 303, "/m/room", 2, "replaced"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + var mu sync.Mutex |
| + opens, takeovers := 0, 0 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + switch req.URL.Path { |
| + case "/m/room/ice": |
| + fmt.Fprint(w, `{"iceServers":[],"expiresAt":0,"renewAt":0}`) |
| + case "/m/room/signal": |
| + w.WriteHeader(200) |
| + case "/m/room/takeover": |
| + mu.Lock() |
| + takeovers++ |
| + mu.Unlock() |
| + if req.Method != http.MethodPost || req.Header.Get("Origin") == "" { |
| + t.Error("takeover must be a POST carrying Origin") |
| + } |
| + if tc.location != "" { |
| + w.Header().Set("Location", tc.location) |
| + } |
| + w.WriteHeader(tc.takeover) |
| + case "/m/room/stream": |
| + mu.Lock() |
| + opens++ |
| + n := opens |
| + mu.Unlock() |
| + if n <= tc.conflicts { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(409) |
| + fmt.Fprint(w, `{"peer":"ghost"}`) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + fmt.Fprint(w, "data: {\"type\":\"hello\",\"peer\":\"self\",\"boot\":\"boot\"}\n\n") |
| + fmt.Fprint(w, "data: {\"type\":\"roster\",\"roster\":{\"peers\":[{\"peer\":\"self\",\"kind\":\"member\"}]}}\n\n") |
| + w.(http.Flusher).Flush() |
| + <-req.Context().Done() |
| + default: |
| + w.WriteHeader(404) |
| + } |
| + })) |
| + defer server.Close() |
| + states := make(chan Snapshot, 64) |
| + r, err := New(server.URL, "room", server.Client(), func(e Event) { |
| + if e.State != nil { |
| + states <- *e.State |
| + } |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = r.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + timer := time.NewTimer(5 * time.Second) |
| + defer timer.Stop() |
| + for { |
| + select { |
| + case s := <-states: |
| + got := "" |
| + if s.Phase == "admitted" { |
| + got = "admitted" |
| + } else if s.Phase == "ended" { |
| + got = s.Reason |
| + } |
| + if got == "" { |
| + continue |
| + } |
| + if got != tc.want { |
| + t.Fatalf("got %q, want %q", got, tc.want) |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if takeovers != 1 { |
| + t.Fatalf("takeovers = %d, want exactly 1", takeovers) |
| + } |
| + return |
| + case <-timer.C: |
| + t.Fatal("no outcome") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/callscore/transport.go b/callscore/transport.go| index 2c4ebd2..082de13 100644 |
| --- a/callscore/transport.go |
| +++ b/callscore/transport.go |
| @@ -245,6 +245,34 @@ func (t *transport) info(ctx context.Context) (Info, error) { |
| return info, nil |
| } |
| +// takeover ends this session's own live participation in the room — the |
| +// web's "Use this tab" (POST /m/{sqid}/takeover), which answers 303 back |
| +// to the room when it has done so. Any other answer, a redirect to |
| +// sign-in among them, is a refusal. |
| +func (t *transport) takeover(ctx context.Context) error { |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + room := "/m/" + t.room |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.origin+room+"/takeover", nil) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + req.Header.Set("Origin", t.origin) |
| + res, err := t.http.Do(req) |
| + if err != nil { |
| + return errors.New("cannot reach Calls") |
| + } |
| + defer res.Body.Close() |
| + _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxResponse)) |
| + if res.StatusCode != http.StatusSeeOther { |
| + return &HTTPError{Status: res.StatusCode} |
| + } |
| + if u, err := url.Parse(res.Header.Get("Location")); err != nil || u.Path != room { |
| + return &HTTPError{Status: res.StatusCode} |
| + } |
| + return nil |
| +} |
| + |
| func (t *transport) shareLink(ctx context.Context) (string, error) { |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
diff --git a/internal/calls/home_native_test.go b/internal/calls/home_native_test.go| index 452283d..b6e5dcd 100644 |
| --- a/internal/calls/home_native_test.go |
| +++ b/internal/calls/home_native_test.go |
| @@ -97,6 +97,100 @@ func TestNativeCoreUsesHomeSessionAndResumesCall(t *testing.T) { |
| } |
| } |
| +// Paul's "This meeting is open elsewhere" (Calls 0.3.1): an app that quit |
| +// or crashed mid-call leaves its participation inside the thirty-second |
| +// resume window, and the next run of the app, on the same kept session, |
| +// was refused its fresh stream with 409 — which the core read as |
| +// "replaced" before the call had begun. The core now takes its own place |
| +// back through the web's takeover, on the real Home-mode router with its |
| +// CSRF middleware, and joins. |
| +func TestNativeCoreTakesBackItsOwnGhostParticipation(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.link(t) |
| + started := f.do("POST", "/meetings/start", "") |
| + match := regexp.MustCompile(`href="/m/([A-Za-z0-9]+)/join"`).FindStringSubmatch(started.Body.String()) |
| + if len(match) != 2 { |
| + t.Fatal("missing new meeting") |
| + } |
| + room := match[1] |
| + server := httptest.NewServer(f.handler) |
| + defer server.Close() |
| + target, _ := url.Parse(server.URL) |
| + cookies := []*http.Cookie{} |
| + for _, c := range f.cookies { |
| + cookies = append(cookies, c) |
| + } |
| + client := &http.Client{Transport: nativeRoundTripper(func(req *http.Request) (*http.Response, error) { |
| + copy := req.Clone(req.Context()) |
| + u := *req.URL |
| + copy.URL = &u |
| + copy.Host = req.URL.Host |
| + copy.URL.Host = target.Host |
| + copy.URL.Scheme = target.Scheme |
| + for _, c := range cookies { |
| + copy.AddCookie(c) |
| + } |
| + return http.DefaultTransport.RoundTrip(copy) |
| + })} |
| + admitted := func(native *callscore.Room, events chan callscore.Event) string { |
| + t.Helper() |
| + deadline := time.NewTimer(5 * time.Second) |
| + defer deadline.Stop() |
| + for { |
| + select { |
| + case e := <-events: |
| + if e.State == nil { |
| + continue |
| + } |
| + if e.State.Phase == "ended" { |
| + t.Fatalf("call ended before it began: %q", e.State.Reason) |
| + } |
| + if e.State.Phase == "admitted" && e.State.Peer != "" { |
| + return string(e.State.Peer) |
| + } |
| + case <-deadline.C: |
| + t.Fatal("native client was never admitted") |
| + } |
| + } |
| + } |
| + firstEvents := make(chan callscore.Event, 128) |
| + first, err := callscore.New(homeTestOrigin, room, client, func(e callscore.Event) { |
| + select { |
| + case firstEvents <- e: |
| + default: |
| + } |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := first.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + ghost := admitted(first, firstEvents) |
| + // The app goes away without leaving: no leave signal, the stream |
| + // simply drops, as it does when the process ends. |
| + first.Close() |
| + |
| + events := make(chan callscore.Event, 128) |
| + second, err := callscore.New(homeTestOrigin, room, client, func(e callscore.Event) { events <- e }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := second.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer second.Close() |
| + peer := admitted(second, events) |
| + if peer == ghost { |
| + t.Fatal("the new run should have a participation of its own") |
| + } |
| + for _, p := range second.Snapshot().Peers { |
| + if string(p.Peer) == ghost { |
| + t.Fatal("the ghost participation is still in the room") |
| + } |
| + } |
| +} |
| + |
| func TestNativeCoreCannotUseAgentBearerAsHumanSession(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| f.link(t) |
diff --git a/native/README.md b/native/README.md| index 862ace6..6e2ed13 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -110,6 +110,46 @@ 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 device on the call's own |
| WebRTC audio device module — LiveKit's AVAudioEngine module, which lists input |
| @@ -122,17 +162,24 @@ audio device API at all. |
| The Mac's choices follow these rules (`AudioRoutes`, held by |
| `AudioRoutesTests`): |
| -- **System default** is the first entry of each picker, and where every call |
| - starts. 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 until you change it or the device |
| - disconnects; a change of system default does not move it. |
| -- A chosen device that disconnects falls back to the system default, and the |
| - panel says so. |
| -- Leaving the call clears the choices; the next call starts on the system |
| - default. |
| -- Nothing changes Sound settings. |
| +- **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." |
| +- **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"). 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. |
| +- Nothing changes Sound settings. Mute is not remembered: every call starts |
| + unmuted. |
| The app keeps the choice itself rather than asking the module, because |
| LiveKit's engine module (M150) cannot say which device it is on — |
| @@ -194,7 +241,13 @@ echo canceller has removed the far side. `ProcessingPlan` holds the rules: |
| 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. |
| +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. |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index fc6d4c7..ddeadee 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -14,6 +14,11 @@ struct Input: Decodable { |
| } |
| @main struct Interop { |
| @MainActor static var screen: SyntheticScreen? |
| + @MainActor static let cameras = SyntheticCameras() |
| + // A defaults domain of its own, so the gate's camera choices are never |
| + // remembered into anyone's real preferences. |
| + @MainActor static let camera = ManagedCamera( |
| + backend: cameras, defaults: UserDefaults(suiteName: "calls-interop-\(UUID().uuidString)")!) |
| @MainActor static func main() async { |
| if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--lobby-snapshots" { |
| do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) } |
| @@ -27,7 +32,7 @@ struct Input: Decodable { |
| let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) } |
| do { |
| try await call.join( |
| - origin: input.origin, room: input.room, cookies: input.cookies, capture: SyntheticVideo()) |
| + origin: input.origin, room: input.room, cookies: input.cookies, capture: Interop.camera) |
| } catch { |
| print("FAIL native start") |
| exit(1) |
| @@ -73,6 +78,13 @@ struct Input: Decodable { |
| print("{\"audioSwitched\":\(succeeded)}") |
| fflush(stdout) |
| } |
| + // The camera stops sending, and the watchdog has to notice and |
| + // restart it: frames come back on the next open. |
| + if command == "camera-stall" { |
| + await MainActor.run { Interop.cameras.stalled = true } |
| + print("{\"cameraStalled\":true}") |
| + fflush(stdout) |
| + } |
| if command.hasPrefix("camera ") { |
| let id = String(command.dropFirst("camera ".count)) |
| await call.useCamera(id) |
| @@ -128,6 +140,18 @@ struct Input: Decodable { |
| // place a screen says whose it is. |
| "screenCaptions": call.screens.map { screenCaption(call.name(of: $0)) }, |
| "sharingScreen": call.sharingScreen, |
| + "cameraOpens": Interop.camera.opens, |
| + "cameraStatus": { |
| + switch call.cameraStatus { |
| + case .live?: return "live" |
| + case .starting?: return "starting" |
| + case .noSignal?: return "no-signal" |
| + case .disconnected?: return "disconnected" |
| + case .failed?: return "failed" |
| + case .noCamera?: return "none" |
| + case nil: return "" |
| + } |
| + }(), |
| ]), let text = String(data: data, encoding: .utf8) { |
| print(text) |
| fflush(stdout) |
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift| index 7697662..186a6b4 100644 |
| --- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| +++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| @@ -3,39 +3,52 @@ import Foundation |
| import CallsKit |
| import LiveKitWebRTC |
| -@MainActor final class SyntheticVideo: VideoCapture { |
| - private var source: RTCVideoSource? |
| +/// Two synthetic cameras behind the app's own camera management |
| +/// (`ManagedCamera`), so the gate drives the same choice, switching and |
| +/// recovery code the apps run — only the frames are made up. |
| +/// |
| +/// The cameras differ only in frame size, so that a switch is something |
| +/// the far side can *see* rather than something this side merely claims. |
| +/// There is no camera in CI; what the gate can still hold is the plumbing |
| +/// — that a switch is serialised, that the track and its m-line survive |
| +/// it, that frames keep arriving across it, and (`stall`) that a camera |
| +/// which stops sending is noticed and restarted. |
| +@MainActor final class SyntheticCameras: CameraBackend { |
| + private var delegate: RTCVideoCapturerDelegate? |
| private var syntheticCapturer: RTCVideoCapturer? |
| private var task: Task<Void, Never>? |
| - // Two cameras, differing only in frame size, so that a switch is |
| - // something the far side can *see* rather than something this side |
| - // merely claims. There is no camera in CI; what the gate can still hold |
| - // is the plumbing — that a switch is serialised, that the track and its |
| - // m-line survive it, and that frames keep arriving across it. |
| - private var chosen = SyntheticVideo.first |
| + private var chosen = SyntheticCameras.first |
| + /// Set by the gate: frames stop until the camera is next opened, as an |
| + /// ATEM's do when its input changes format. |
| + var stalled = false |
| nonisolated static let first = "synthetic-320" |
| nonisolated static let second = "synthetic-480" |
| - private var width: Int { chosen == SyntheticVideo.second ? 480 : 320 } |
| - private var height: Int { chosen == SyntheticVideo.second ? 360 : 240 } |
| + private var width: Int { chosen == SyntheticCameras.second ? 480 : 320 } |
| + private var height: Int { chosen == SyntheticCameras.second ? 360 : 240 } |
| + var devicesChanged: (@MainActor () -> Void)? |
| + var trouble: (@MainActor () -> Void)? |
| + |
| + func requestAccess() async throws {} |
| - nonisolated var cameras: [CameraDevice] { |
| + var devices: [CameraDevice] { |
| [ |
| - CameraDevice(id: SyntheticVideo.first, name: "Synthetic 320x240"), |
| - CameraDevice(id: SyntheticVideo.second, name: "Synthetic 480x360"), |
| + CameraDevice(id: SyntheticCameras.first, name: "Synthetic 320x240"), |
| + CameraDevice(id: SyntheticCameras.second, name: "Synthetic 480x360"), |
| ] |
| } |
| - var currentCamera: String? { chosen } |
| - func use(camera id: String) async throws { |
| - guard cameras.contains(where: { $0.id == id }) else { |
| + func formatCount(_ id: String) -> Int { 1 } |
| + |
| + func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws { |
| + guard devices.contains(where: { $0.id == id }) else { |
| throw NSError( |
| domain: "Calls", code: 4, |
| userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."]) |
| } |
| chosen = id |
| - } |
| - func start(source: RTCVideoSource) async throws { |
| - self.source = source |
| - syntheticCapturer = RTCVideoCapturer(delegate: source) |
| + stalled = false |
| + self.delegate = delegate |
| + syntheticCapturer = RTCVideoCapturer(delegate: delegate) |
| + task?.cancel() |
| task = Task { @MainActor [weak self] in |
| while !Task.isCancelled { |
| self?.frame() |
| @@ -43,14 +56,12 @@ import LiveKitWebRTC |
| } |
| } |
| } |
| - func stop() { |
| + func close() async { |
| task?.cancel() |
| task = nil |
| - source = nil |
| - syntheticCapturer = nil |
| } |
| private func frame() { |
| - guard let source, let syntheticCapturer else { return } |
| + guard !stalled, let delegate, let syntheticCapturer else { return } |
| var pixel: CVPixelBuffer? |
| CVPixelBufferCreate( |
| kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA, |
| @@ -63,7 +74,7 @@ import LiveKitWebRTC |
| CVPixelBufferGetDataSize(pixel)) |
| } |
| CVPixelBufferUnlockBaseAddress(pixel, []) |
| - source.capturer( |
| + delegate.capturer( |
| syntheticCapturer, |
| didCapture: RTCVideoFrame( |
| buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0, |
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift| index 87431b4..ca07f56 100644 |
| --- a/native/apple/Sources/CallsKit/Audio.swift |
| +++ b/native/apple/Sources/CallsKit/Audio.swift |
| @@ -1,5 +1,8 @@ |
| import AVFoundation |
| import Foundation |
| +#if os(macOS) |
| + import CoreAudio |
| +#endif |
| import LiveKitWebRTC |
| @@ -24,20 +27,26 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| /// do at all (its macOS slice had no audio device API). Until a call has |
| /// made that module, the Mac lists are empty. |
| /// |
| -/// On Mac the choices belong to this call, and the rules are: |
| +/// On Mac the person's choices are remembered — across calls and across |
| +/// launches — and the rules are: |
| /// |
| -/// - **System default** is an entry of its own and is where every call |
| -/// starts. It follows macOS — a new default device, or a device changing |
| -/// its sample rate, is the module's to follow, and it does (it listens |
| -/// for both and rebuilds its engine). |
| -/// - An explicit choice stays until the person changes it or the device |
| -/// disconnects. A change of system default does not move it. |
| -/// - A chosen device that disconnects falls back to the system default, |
| -/// and `routeNote` says so. |
| -/// - Leaving the call clears the choices, so the next call starts on the |
| -/// system default again. |
| +/// - **System default** is an entry of its own, where someone who has |
| +/// never chosen starts, and a choice like any other: picked, it is what |
| +/// is remembered. It follows macOS — a new default device, or a device |
| +/// 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. |
| +/// - 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. |
| /// - 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 |
| @@ -61,18 +70,28 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| public let selectable = true |
| #if os(macOS) |
| - /// Said when a chosen device has gone and the call has fallen back to |
| - /// the system default. Cleared by the next choice and by leaving. |
| + /// Said while a remembered device is missing and the call is on the |
| + /// system default instead. Clears itself when the device is back. |
| @Published public private(set) var routeNote = "" |
| private var control: AudioModuleControl? |
| - /// The person's choices for this call. Nil follows the system default. |
| - private var chosenInput: String? |
| - private var chosenOutput: String? |
| + private 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? |
| + private var listener: AudioObjectPropertyListenerBlock? |
| #endif |
| - public init() { |
| - refresh() |
| - } |
| + #if os(macOS) |
| + public init(defaults: UserDefaults = .standard) { |
| + self.defaults = defaults |
| + refresh() |
| + } |
| + #else |
| + public init() { |
| + refresh() |
| + } |
| + #endif |
| public func refresh() { |
| #if os(iOS) |
| @@ -93,34 +112,46 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| } |
| let ins = control.devices(true) |
| let outs = control.devices(false) |
| - var lost: [String] = [] |
| - if let id = chosenInput, !ins.contains(where: { $0.id == id }) { |
| - chosenInput = nil |
| - lost.append("microphone") |
| - // The module has already let go of a device that left its list; |
| - // asking for the default again does not depend on that. |
| - if let fallback = ins.first(where: \.isDefault) { _ = control.select(fallback.id, true) } |
| - } |
| - if let id = chosenOutput, !outs.contains(where: { $0.id == id }) { |
| - chosenOutput = nil |
| - lost.append("speaker") |
| - if let fallback = outs.first(where: \.isDefault) { _ = control.select(fallback.id, false) } |
| - } |
| - if !lost.isEmpty { |
| - routeNote = |
| - "The \(lost.joined(separator: " and ")) you chose disconnected. This call is using the system default." |
| - } |
| + 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) |
| + update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " ")) |
| update(\.inputs, ins.map(AudioRoutes.device)) |
| update(\.outputs, outs.map(AudioRoutes.device)) |
| - update(\.currentInput, chosenInput ?? ins.first(where: \.isDefault)?.id) |
| - update(\.currentOutput, chosenOutput ?? outs.first(where: \.isDefault)?.id) |
| + update(\.currentInput, input.current) |
| + update(\.currentOutput, output.current) |
| update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "") |
| #endif |
| } |
| #if os(macOS) |
| - /// The panel refreshes every second while it is open; publishing only |
| - /// what changed keeps an open menu from being rebuilt under the pointer. |
| + /// 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>( |
| _ key: ReferenceWritableKeyPath<AudioRoutes, Value>, _ value: Value |
| ) { |
| @@ -129,13 +160,35 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| func attach(_ module: RTCAudioDeviceModule) { |
| attach(AudioModuleControl(module)) |
| + listenForDevices() |
| } |
| func attach(_ control: AudioModuleControl) { |
| self.control = control |
| + appliedInput = nil |
| + appliedOutput = nil |
| 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. |
| + 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 |
| + } |
| + } |
| + |
| /// 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. |
| @@ -145,9 +198,9 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| selectable: true) |
| } |
| - /// Chooses the microphone on Mac, mid-call if one is up. The engine is |
| - /// reconfigured underneath the running track; nothing renegotiates. |
| - /// An empty id is the system default. |
| + /// 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. |
| public func use(input id: String) throws { |
| try choose(id, input: true, what: "microphone") |
| } |
| @@ -156,34 +209,22 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| try choose(id, input: false, what: "speaker") |
| } |
| - /// The system default is recorded as no choice at all, so that it is |
| - /// followed rather than held. |
| + /// The system default is remembered as a choice of its own (an empty |
| + /// 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 { chosenInput = device.isDefault ? nil : device.id } else { |
| - chosenOutput = device.isDefault ? nil : device.id |
| - } |
| - routeNote = "" |
| + if input { appliedInput = device.id } else { appliedOutput = device.id } |
| + AudioPreference(id: device.isDefault ? "" : device.id, name: device.isDefault ? "" : device.name) |
| + .save(input: input, defaults) |
| refresh() |
| } |
| - /// Leaving the call: the choices were this call's, so the module goes |
| - /// back to following macOS for the next one. With the call's audio |
| - /// stopped this only records the device; nothing is opened. |
| + /// Leaving the call. The choices are remembered, so there is nothing |
| + /// to undo; what is left is to look again at what is connected. |
| func reset() { |
| - chosenInput = nil |
| - chosenOutput = nil |
| - routeNote = "" |
| - if let control { |
| - for input in [true, false] { |
| - if let fallback = control.devices(input).first(where: \.isDefault) { |
| - _ = control.select(fallback.id, input) |
| - } |
| - } |
| - } |
| refresh() |
| } |
| @@ -252,6 +293,28 @@ 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. |
| + struct AudioPreference: Equatable, Sendable { |
| + var id: String |
| + var name: String |
| + |
| + private static func keys(_ input: Bool) -> (String, String) { |
| + input ? ("audioInputUID", "audioInputName") : ("audioOutputUID", "audioOutputName") |
| + } |
| + static func load(input: Bool, _ defaults: UserDefaults) -> AudioPreference? { |
| + let (idKey, nameKey) = keys(input) |
| + guard let id = defaults.string(forKey: idKey) else { return nil } |
| + return AudioPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") |
| + } |
| + func save(input: Bool, _ defaults: UserDefaults) { |
| + let (idKey, nameKey) = Self.keys(input) |
| + defaults.set(id, forKey: idKey) |
| + defaults.set(name, forKey: nameKey) |
| + } |
| + } |
| + |
| /// A device as the audio device module lists it. |
| struct ModuleDevice: Equatable, Sendable { |
| let id: String |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 40dff4d..dbfe124 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -61,6 +61,13 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// demand, because a picker bound to a plain computed property never |
| /// learns that a switch finished. |
| @Published public private(set) var currentCamera: String? |
| + /// What the camera is doing (see CameraStatus), and a fallback worth |
| + /// saying. Nil status for a capture that does not say, and outside a |
| + /// call. |
| + @Published public private(set) var cameraStatus: CameraStatus? |
| + @Published public private(set) var cameraNote = "" |
| + /// A real camera, so the panel shows its row even with nothing in it. |
| + @Published public private(set) var offersCameras = false |
| /// Audio routing belongs to this call. |
| public let audio = AudioRoutes() |
| /// "Reduce background noise": the neural suppressor in place of WebRTC's |
| @@ -126,9 +133,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| try await media.prepare(capture: capture) |
| guard epoch == joiningEpoch, bridge === client else { throw CancellationError() } |
| localTrack = media.localVideo |
| - currentCamera = media.currentCamera |
| + cameraChanged() |
| reportMedia() |
| - refreshDevices() |
| media.audio.reduceNoise = reduceNoise |
| audioProcessing = media.audio.report() |
| KnockNotice.requestPermission() |
| @@ -165,6 +171,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| audioHealth = nil |
| audioProcessing = nil |
| currentCamera = nil |
| + cameraStatus = nil |
| + cameraNote = "" |
| knocks.reset() |
| cameraSwitches += 1 |
| cameraWork?.cancel() |
| @@ -230,6 +238,35 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| audio.refresh() |
| } |
| + /// The capture said something changed on its own: a camera came or |
| + /// went, a fallback, the first frame. Everything camera-shaped is read |
| + /// back from it. |
| + func cameraChanged() { |
| + offersCameras = media.offersCameras |
| + refreshDevices() |
| + let now = media.currentCamera |
| + if currentCamera != now { currentCamera = now } |
| + let status = media.cameraStatus |
| + if cameraStatus != status { cameraStatus = status } |
| + let note = media.cameraNote |
| + if cameraNote != note { cameraNote = note } |
| + } |
| + |
| + /// What your own tile says in place of your picture, or nil to show it. |
| + /// Never an open-ended "Waiting for video": it says what it is waiting |
| + /// for, or why there is nothing. |
| + public var selfPlaceholder: String? { |
| + if state?.phase == "ended" { return "Not in the call" } |
| + if localTrack == nil { return "Starting camera…" } |
| + return cameraStatus?.tileText |
| + } |
| + |
| + /// The panel's "Try again", for a call with no working camera. |
| + public func retryCamera() async { |
| + await media.retryCamera() |
| + cameraChanged() |
| + } |
| + |
| /// Switches camera mid-call. Serialised: two picks are two capture |
| /// restarts on one device, and run at once the survivor is whichever |
| /// session happened to open last, which is not the one the person |
| @@ -255,7 +292,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| // wherever it actually is, and the picker has to follow the camera |
| // and not the intention. |
| guard mine == self.cameraSwitches, !Task.isCancelled else { return } |
| - self.currentCamera = self.media.currentCamera |
| + self.cameraChanged() |
| } |
| cameraWork = work |
| await work.value |
| @@ -372,6 +409,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| remoteScreens = [:] |
| sharingScreen = false |
| localTrack = nil |
| + cameraChanged() |
| default: status = "Connecting…" |
| } |
| if state.peer.isEmpty { |
| @@ -513,6 +551,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| values["webrtcEchoCancellation"] = running.echoCancellation == "off" ? 0 : 1 |
| values["webrtcNoiseSuppression"] = running.noiseSuppression == "off" ? 0 : 1 |
| values["webrtcGainControl"] = running.gainControl.hasPrefix("Calls") || running.gainControl == "off" ? 0 : 1 |
| + // Whether the readout would say the microphone has started. |
| + values["audioReadoutStarted"] = running.started ? 1 : 0 |
| return values |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift| index 0229345..5489cf0 100644 |
| --- a/native/apple/Sources/CallsKit/CallAudio.swift |
| +++ b/native/apple/Sources/CallsKit/CallAudio.swift |
| @@ -65,9 +65,18 @@ public struct ProcessingReport: Equatable, Sendable { |
| public var gainControl: String |
| /// Set when WebRTC refused the options asked of it. |
| public var refused: String? |
| + /// False until the microphone is actually capturing. WebRTC opens it |
| + /// only once there is someone to send to, so a person alone in a call |
| + /// has no microphone running yet, and what the processing module says |
| + /// before then is its construction-time default (16 kHz, everything |
| + /// off) rather than anything about their microphone. |
| + public var started = true |
| /// One or two plain sentences. |
| public var summary: String { |
| + guard started else { |
| + return "The microphone has not started yet. It starts when someone else is in the call, and this will say what is running on it then." |
| + } |
| var lines: [String] = [] |
| switch suppression { |
| case .off: lines.append("Background noise reduction is off.") |
| @@ -105,6 +114,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| private var voiceIsolation = false |
| private var refused: String? |
| private(set) var plan: ProcessingPlan |
| + private var buffersAtStart: UInt64 = 0 |
| var reduceNoise: Bool { |
| didSet { if reduceNoise != oldValue { apply() } } |
| } |
| @@ -168,12 +178,8 @@ public struct ProcessingReport: Equatable, Sendable { |
| // same routing code as the app; no device is opened either way. |
| routes.attach(module) |
| #endif |
| - // Set after the factory exists: handed to the module's initialiser, the |
| - // delegate is never called (seen in the spike, M150). The property is |
| - // weak; `denoiser` is what keeps it alive. |
| - processing.capturePostProcessingDelegate = denoiser |
| - CallAudio.upgrade(denoiser) |
| - denoiser.enabled = plan.suppressor |
| + // The listener first, then the delegate: attaching the delegate can |
| + // initialise it at once, and that report used to go nowhere. |
| denoiser.rateChanged = { [weak self] rate in |
| DispatchQueue.main.async { |
| MainActor.assumeIsolated { |
| @@ -183,6 +189,27 @@ public struct ProcessingReport: Equatable, Sendable { |
| } |
| } |
| } |
| + // Set after the factory exists: handed to the module's initialiser, the |
| + // delegate is never called (seen in the spike, M150). The property is |
| + // weak; `denoiser` is what keeps it alive. |
| + processing.capturePostProcessingDelegate = denoiser |
| + CallAudio.upgrade(denoiser) |
| + denoiser.enabled = plan.suppressor |
| + } |
| + |
| + /// Whether the microphone is capturing. The module opens it when the |
| + /// first connection starts sending, not when the track is made. Either |
| + /// the module says it is recording, or the capture hook has been handed |
| + /// audio since this call's track was made (which is also how the |
| + /// interop gate's manual rendering shows up). |
| + var capturing: Bool { |
| + #if os(macOS) |
| + return factory.audioDeviceModule.isRecording || denoiser.buffersSeen > buffersAtStart |
| + #else |
| + // Not asked on iPhone: its module is the platform one, whose |
| + // recording state has not been checked against a device here. |
| + return true |
| + #endif |
| } |
| /// The suppressor a call starts on. See `upgrade`. |
| @@ -205,6 +232,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| func makeTrack() -> RTCAudioTrack { |
| let track = factory.audioTrack(withTrackId: "microphone") |
| self.track = track |
| + buffersAtStart = denoiser.buffersSeen |
| apply() |
| return track |
| } |
| @@ -275,7 +303,8 @@ public struct ProcessingReport: Equatable, Sendable { |
| if plan.suppressor, gain == "off" { gain = "Calls, after noise reduction" } |
| return ProcessingReport( |
| suppression: denoiser.state, echoCancellation: says(state.echoCancellation), |
| - noiseSuppression: says(state.noiseSuppression), gainControl: gain, refused: refused) |
| + noiseSuppression: says(state.noiseSuppression), gainControl: gain, refused: refused, |
| + started: capturing) |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index b0a441f..8485039 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -632,6 +632,15 @@ import Foundation |
| } |
| } |
| } |
| + /// Leaves the call and returns when the leave has been sent (or given |
| + /// up on): for quitting, where the process is about to end. |
| + public func leaveNow() async { |
| + callEpoch = UUID() |
| + starting = false |
| + cancelSignIn() |
| + await call.leave() |
| + joining = false |
| + } |
| public func leave() { |
| callEpoch = UUID() |
| starting = false |
diff --git a/native/apple/Sources/CallsKit/CameraRecovery.swift b/native/apple/Sources/CallsKit/CameraRecovery.swift| new file mode 100644 |
| index 0000000..10b57c0 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CameraRecovery.swift |
| @@ -0,0 +1,424 @@ |
| +import Foundation |
| + |
| +/// What the camera is doing, said the way the self tile and the device |
| +/// panel say it. |
| +/// |
| +/// This exists because of a report against Calls 0.3.1: an ATEM Mini that |
| +/// had dropped off USB left the self tile on "Waiting for video" and the |
| +/// device panel with no Camera row at all, and nothing on screen said what |
| +/// had happened or offered a way back. Every state here has words of its |
| +/// own, and none of them is "waiting" without saying for what. |
| +public enum CameraStatus: Equatable, Sendable { |
| + /// Opening a camera, and no frame from it yet. |
| + case starting(String) |
| + /// Frames are arriving from the named camera. |
| + case live(String) |
| + /// The camera opened but sends nothing — an ATEM with no HDMI input, or |
| + /// one that is changing format. |
| + case noSignal(String) |
| + /// The camera in use went away and there is no other to use. |
| + case disconnected(String) |
| + /// There is no camera at all. |
| + case noCamera |
| + /// The camera would not open, and there is no other to use. |
| + case failed(String) |
| + |
| + /// What the self tile says in place of video. Nil while frames arrive. |
| + public var tileText: String? { |
| + switch self { |
| + case .starting: return "Starting camera…" |
| + case .live: return nil |
| + case .noSignal(let name): return "No signal from \(name) — check its input" |
| + case .disconnected: return "Camera disconnected" |
| + case .noCamera: return "No camera found" |
| + case .failed(let name): return "\(name) would not start" |
| + } |
| + } |
| + |
| + /// The plain-words line under the camera picker. Nil when all is well. |
| + public var panelText: String? { |
| + switch self { |
| + case .live: return nil |
| + case .starting(let name): return "Starting \(name)…" |
| + case .noSignal(let name): |
| + return "\(name) is connected but sending no picture. Check its input, or choose another camera." |
| + case .disconnected(let name): return "\(name) disconnected. Calls will use it again when it is back." |
| + case .noCamera: return "No camera found" |
| + case .failed(let name): return "\(name) would not start. Try again, or choose another camera." |
| + } |
| + } |
| + |
| + var live: Bool { |
| + if case .live = self { return true } |
| + return false |
| + } |
| + var disconnected: Bool { |
| + if case .disconnected = self { return true } |
| + return false |
| + } |
| +} |
| + |
| +/// The person's camera, remembered between calls and launches. |
| +/// |
| +/// Kept by `uniqueID`, which survives unplugging and replugging, with the |
| +/// name beside it so the panel can say which camera it is waiting for |
| +/// while that camera is not connected. |
| +public struct CameraPreference: Equatable, Sendable { |
| + public var id: String |
| + public var name: String |
| + public init(id: String, name: String) { |
| + self.id = id |
| + self.name = name |
| + } |
| + |
| + static let idKey = "preferredCameraID" |
| + static let nameKey = "preferredCameraName" |
| + |
| + public static func load(_ defaults: UserDefaults = .standard) -> CameraPreference? { |
| + guard let id = defaults.string(forKey: idKey), !id.isEmpty else { return nil } |
| + return CameraPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") |
| + } |
| + |
| + public func save(_ defaults: UserDefaults = .standard) { |
| + defaults.set(id, forKey: Self.idKey) |
| + defaults.set(name, forKey: Self.nameKey) |
| + } |
| +} |
| + |
| +/// The first-frame and stall watchdog, as a pure state machine over a |
| +/// frame counter and a clock. |
| +/// |
| +/// A camera that opens is not a camera that works: the ATEM Mini opens |
| +/// happily with nothing plugged into it and delivers no frame, ever. So |
| +/// the counter the capturer feeds is watched, and a camera with no first |
| +/// frame within `firstFrame`, or no frame for `stall` after it had been |
| +/// sending, is escalated one step at a time: |
| +/// |
| +/// 1. restart capture on the same device and format, |
| +/// 2. try the device's next format, |
| +/// 3. fall back to another camera. |
| +/// |
| +/// Frames arriving again at any point put it back at the bottom of the |
| +/// ladder. Each step waits for the capture to be opened again before |
| +/// the clock restarts, so a slow open is never counted as a stall. |
| +struct CameraWatchdog: Equatable { |
| + enum Step: Equatable { case restart, nextFormat, fallback } |
| + |
| + var firstFrame: TimeInterval = 3 |
| + var stall: TimeInterval = 3 |
| + |
| + /// How far up the ladder this device has gone since its last frame. |
| + private(set) var level = 0 |
| + /// Whether a frame has arrived since the capture was last opened. |
| + private(set) var flowing = false |
| + /// When the clock started: the open, or the last new frame. Nil while |
| + /// nothing is being watched (between a step and the next open). |
| + private var since: TimeInterval? |
| + private var count: UInt64 = 0 |
| + /// Top of the ladder with nowhere to go: no more steps, but a frame |
| + /// arriving still counts as the camera working again. |
| + private(set) var exhausted = false |
| + |
| + /// The capture has (re)opened. The clock starts for a first frame. |
| + mutating func opened(at now: TimeInterval, frames: UInt64) { |
| + since = now |
| + count = frames |
| + flowing = false |
| + exhausted = false |
| + } |
| + |
| + /// Nothing left to try: stop escalating, keep listening for frames. |
| + mutating func giveUp() { |
| + since = nil |
| + exhausted = true |
| + } |
| + |
| + /// A different camera, or the person's own choice: the ladder starts |
| + /// again from the bottom. |
| + mutating func reset() { |
| + level = 0 |
| + since = nil |
| + flowing = false |
| + exhausted = false |
| + } |
| + |
| + /// Stops watching until the next open. |
| + mutating func pause() { since = nil } |
| + |
| + /// Frames are not expected just now (an iPhone app in the background): |
| + /// the clock is held where it is, without climbing the ladder. |
| + mutating func hold(at now: TimeInterval, frames: UInt64) { |
| + guard since != nil else { return } |
| + since = now |
| + count = frames |
| + } |
| + |
| + /// Called on every tick with the frame counter. Answers the next step |
| + /// when one is due, and stops watching until `opened` is called again. |
| + mutating func observe(frames: UInt64, at now: TimeInterval) -> Step? { |
| + if frames != count { |
| + count = frames |
| + if since != nil || exhausted { |
| + since = now |
| + flowing = true |
| + level = 0 |
| + exhausted = false |
| + } |
| + return nil |
| + } |
| + guard let started = since else { return nil } |
| + guard now - started >= (flowing ? stall : firstFrame) else { return nil } |
| + since = nil |
| + let step: Step = level == 0 ? .restart : level == 1 ? .nextFormat : .fallback |
| + level = min(level + 1, 2) |
| + return step |
| + } |
| +} |
| + |
| +/// What the camera capture should do next. |
| +enum CameraCommand: Equatable { |
| + /// Open this device with the format at this rank (0 is the best). |
| + case open(String, format: Int) |
| + /// Stop capturing: there is nothing to capture from. |
| + case stop |
| +} |
| + |
| +/// The rules that decide which camera a call is on, and what it says |
| +/// about it — apart from AVFoundation, so they can be held by tests. |
| +/// |
| +/// - At join the remembered camera is used if it is connected, otherwise |
| +/// the built-in one, otherwise whatever there is. |
| +/// - Choosing a camera remembers it. |
| +/// - The camera in use disconnecting falls back to another at once, and |
| +/// says so ("ATEM Mini disconnected; using FaceTime HD Camera"). |
| +/// - The remembered camera reconnecting, or appearing later, is switched |
| +/// back to, without renegotiation (the capturer feeds the same source). |
| +/// - A camera that sends nothing is restarted, then tried in its next |
| +/// format, then fallen back from; with nothing to fall back to, it is |
| +/// named as having no signal and kept, in case its signal returns. |
| +/// - Session errors and waking from sleep restart capture, at most once |
| +/// every `restartSpacing` seconds. |
| +@MainActor final class CameraDirector { |
| + private(set) var devices: [CameraDevice] = [] |
| + private(set) var active: String? |
| + private(set) var format = 0 |
| + private(set) var status: CameraStatus = .noCamera |
| + /// A fallback the person should know about. Cleared by their own |
| + /// choice and by the remembered camera coming back. |
| + private(set) var note = "" |
| + private(set) var watchdog = CameraWatchdog() |
| + /// Cameras fallen back from for sending nothing, while they stay |
| + /// connected. Not returned to automatically until they reconnect. |
| + private var silent: Set<String> = [] |
| + /// Cameras that would not open, this round. |
| + private var refused: Set<String> = [] |
| + private var lastRestart: TimeInterval = -.infinity |
| + var restartSpacing: TimeInterval = 5 |
| + |
| + private let defaults: UserDefaults |
| + /// How many formats a device has to try, ranked. Supplied by the |
| + /// capture; the tests give numbers. |
| + private let formats: (String) -> Int |
| + |
| + init(defaults: UserDefaults = .standard, formats: @escaping (String) -> Int) { |
| + self.defaults = defaults |
| + self.formats = formats |
| + } |
| + |
| + var preference: CameraPreference? { CameraPreference.load(defaults) } |
| + |
| + private func name(_ id: String?) -> String { |
| + guard let id else { return "The camera" } |
| + if let device = devices.first(where: { $0.id == id }) { return device.name } |
| + if let preference, preference.id == id, !preference.name.isEmpty { return preference.name } |
| + return "The camera" |
| + } |
| + |
| + /// The camera a call should be on, given what is connected: the |
| + /// remembered one, then the built-in one, then the first. Pure, and |
| + /// what `start` and every fallback ask. |
| + nonisolated static func choose( |
| + _ devices: [CameraDevice], preferred: String?, excluding: Set<String> = [] |
| + ) -> CameraDevice? { |
| + let usable = devices.filter { !excluding.contains($0.id) } |
| + if let preferred, let device = usable.first(where: { $0.id == preferred }) { return device } |
| + return usable.first(where: \.builtIn) ?? usable.first |
| + } |
| + |
| + /// A call starts: pick the camera and open it. |
| + func start(devices: [CameraDevice]) -> CameraCommand? { |
| + self.devices = devices |
| + silent = [] |
| + refused = [] |
| + note = "" |
| + watchdog.reset() |
| + return pick(missingNote: true) |
| + } |
| + |
| + /// Chooses from scratch, excluding what has failed this round. |
| + private func pick(missingNote: Bool) -> CameraCommand? { |
| + let preferred = preference |
| + guard let device = Self.choose(devices, preferred: preferred?.id, excluding: silent.union(refused)) |
| + else { |
| + active = nil |
| + status = devices.isEmpty ? .noCamera : status |
| + if devices.isEmpty { note = "" } |
| + return .stop |
| + } |
| + if missingNote, let preferred, preferred.id != device.id, |
| + !devices.contains(where: { $0.id == preferred.id }) |
| + { |
| + let missing = preferred.name.isEmpty ? "Your camera" : preferred.name |
| + note = "\(missing) isn’t connected; using \(device.name)" |
| + } |
| + return open(device.id) |
| + } |
| + |
| + private func open(_ id: String, format: Int = 0) -> CameraCommand { |
| + if active != id { watchdog.reset() } |
| + active = id |
| + self.format = format |
| + status = .starting(name(id)) |
| + watchdog.pause() |
| + return .open(id, format: format) |
| + } |
| + |
| + /// The capture opened `id`; the watchdog starts timing a first frame. |
| + func opened(_ id: String, at now: TimeInterval, frames: UInt64) { |
| + guard id == active else { return } |
| + refused.remove(id) |
| + watchdog.opened(at: now, frames: frames) |
| + } |
| + |
| + /// The capture could not open `id`. Another camera is tried; with none |
| + /// left, the panel says so and offers to try again. |
| + func openFailed(_ id: String) -> CameraCommand? { |
| + guard id == active else { return nil } |
| + let failedName = name(id) |
| + refused.insert(id) |
| + if let other = Self.choose(devices, preferred: preference?.id, excluding: silent.union(refused)) { |
| + note = "\(failedName) would not start; using \(other.name)" |
| + return open(other.id) |
| + } |
| + status = .failed(failedName) |
| + watchdog.pause() |
| + return nil |
| + } |
| + |
| + /// A tick while frames are not expected — the iPhone app in the |
| + /// background, where the system stops every camera. |
| + func hold(frames: UInt64, at now: TimeInterval) { |
| + watchdog.hold(at: now, frames: frames) |
| + } |
| + |
| + /// A tick: the frame counter, read. |
| + func tick(frames: UInt64, at now: TimeInterval) -> CameraCommand? { |
| + guard let id = active else { return nil } |
| + let step = watchdog.observe(frames: frames, at: now) |
| + if watchdog.flowing, !status.live { |
| + status = .live(name(id)) |
| + silent.remove(id) |
| + } |
| + guard let step else { return nil } |
| + switch step { |
| + case .restart: |
| + return open(id, format: format) |
| + case .nextFormat: |
| + if format + 1 < formats(id) { return open(id, format: format + 1) } |
| + return fallBack(from: id) |
| + case .fallback: |
| + return fallBack(from: id) |
| + } |
| + } |
| + |
| + private func fallBack(from id: String) -> CameraCommand? { |
| + let silentName = name(id) |
| + if let other = Self.choose( |
| + devices, preferred: preference?.id, excluding: silent.union(refused).union([id])) |
| + { |
| + silent.insert(id) |
| + note = "No signal from \(silentName); using \(other.name)" |
| + return open(other.id) |
| + } |
| + // Nothing to fall back to. Stay on it, say so, and keep counting: an |
| + // ATEM whose input comes back starts sending on the same session. |
| + status = .noSignal(silentName) |
| + watchdog.giveUp() |
| + return nil |
| + } |
| + |
| + /// The connected cameras changed. |
| + func devicesChanged(_ now: [CameraDevice]) -> CameraCommand? { |
| + let before = Set(devices.map(\.id)) |
| + let previous = devices |
| + devices = now |
| + let present = Set(now.map(\.id)) |
| + // A camera that left is forgiven: when it comes back it is a new |
| + // session, and may be sending again. |
| + silent.formIntersection(present) |
| + refused.formIntersection(present) |
| + let preferred = preference |
| + if let id = active, !present.contains(id) { |
| + let gone = previous.first(where: { $0.id == id })?.name ?? name(id) |
| + if let other = Self.choose(now, preferred: preferred?.id, excluding: silent.union(refused)) { |
| + note = "\(gone) disconnected; using \(other.name)" |
| + return open(other.id) |
| + } |
| + active = nil |
| + note = "" |
| + status = .disconnected(gone) |
| + watchdog.reset() |
| + return .stop |
| + } |
| + // The remembered camera is back (or has just appeared): go back to it. |
| + if let preferred, active != preferred.id, present.contains(preferred.id), |
| + !before.contains(preferred.id) || active == nil |
| + { |
| + note = "" |
| + return open(preferred.id) |
| + } |
| + // Nothing running and something to run on. |
| + if active == nil, !now.isEmpty { |
| + if case .disconnected = status { note = "" } |
| + return pick(missingNote: true) |
| + } |
| + // "Camera disconnected" stays said until something comes back. |
| + if active == nil, now.isEmpty, !status.disconnected { status = .noCamera } |
| + return nil |
| + } |
| + |
| + /// The person chose a camera: it is remembered and used. |
| + func choose(_ id: String) -> CameraCommand? { |
| + guard let device = devices.first(where: { $0.id == id }) else { return nil } |
| + CameraPreference(id: device.id, name: device.name).save(defaults) |
| + silent.remove(id) |
| + refused.remove(id) |
| + note = "" |
| + watchdog.reset() |
| + return open(id) |
| + } |
| + |
| + /// "Try again": everything that failed is forgiven and the choice is |
| + /// made from scratch. |
| + func retry() -> CameraCommand? { |
| + silent = [] |
| + refused = [] |
| + note = "" |
| + watchdog.reset() |
| + return pick(missingNote: true) |
| + } |
| + |
| + /// A capture session error or interruption, or the Mac waking: restart |
| + /// on the same device, no more often than `restartSpacing`. |
| + func restart(at now: TimeInterval) -> CameraCommand? { |
| + guard let id = active, now - lastRestart >= restartSpacing else { return nil } |
| + lastRestart = now |
| + return open(id, format: format) |
| + } |
| + |
| + /// The call is over. |
| + func stop() { |
| + active = nil |
| + watchdog.reset() |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index 55bbc5b..a39a622 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -1,6 +1,13 @@ |
| import AVFoundation |
| import Foundation |
| import LiveKitWebRTC |
| +import os |
| + |
| +#if os(macOS) |
| + import AppKit |
| +#else |
| + import UIKit |
| +#endif |
| /// One camera the person can choose, named the way a menu wants it. |
| /// |
| @@ -10,9 +17,13 @@ import LiveKitWebRTC |
| public struct CameraDevice: Identifiable, Hashable, Sendable { |
| public let id: String |
| public let name: String |
| - public init(id: String, name: String) { |
| + /// The Mac's own camera (the iPhone's front one): where a call goes |
| + /// when the remembered camera is not connected. |
| + public let builtIn: Bool |
| + public init(id: String, name: String, builtIn: Bool = false) { |
| self.id = id |
| self.name = name |
| + self.builtIn = builtIn |
| } |
| } |
| @@ -29,12 +40,33 @@ public struct CameraDevice: Identifiable, Hashable, Sendable { |
| var cameras: [CameraDevice] { get } |
| var currentCamera: String? { get } |
| func use(camera id: String) async throws |
| + |
| + /// Whether the call should always show a Camera row, even with no |
| + /// camera to list: true for real cameras, where "No camera found" is |
| + /// something a person needs to be told. |
| + var offersCameras: Bool { get } |
| + /// What the camera is doing, for the self tile and the panel. Nil for a |
| + /// capture that does not say. |
| + var cameraStatus: CameraStatus? { get } |
| + /// A fallback worth saying ("ATEM Mini disconnected; using FaceTime HD |
| + /// Camera"), and usually nothing. |
| + var cameraNote: String { get } |
| + /// Called on the main actor whenever any of the above changes on its |
| + /// own — a camera connecting, a fallback, the first frame. |
| + func observe(_ changed: @escaping @MainActor () -> Void) |
| + /// The panel's "Try again". |
| + func retryCamera() async |
| } |
| extension VideoCapture { |
| public var cameras: [CameraDevice] { [] } |
| public var currentCamera: String? { nil } |
| public func use(camera id: String) async throws {} |
| + public var offersCameras: Bool { false } |
| + public var cameraStatus: CameraStatus? { nil } |
| + public var cameraNote: String { "" } |
| + public func observe(_ changed: @escaping @MainActor () -> Void) {} |
| + public func retryCamera() async {} |
| } |
| /// A screen, as §5.4 sends one: frames into a source of its own, which |
| @@ -51,131 +83,514 @@ extension VideoCapture { |
| func stop() |
| } |
| -@MainActor final class CameraCapture: VideoCapture { |
| - private var capturer: RTCCameraVideoCapturer? |
| - private var source: RTCVideoSource? |
| - private var epoch = UUID() |
| - private(set) var currentCamera: String? |
| +// --------------------------------------------------------------------- |
| +// The managed camera: device choice, recovery and the watchdog |
| +// --------------------------------------------------------------------- |
| + |
| +/// Where frames actually come from, under the recovery rules: AVFoundation |
| +/// in the apps, synthetic frames in the interop gate. Everything about |
| +/// *which* camera and *when to try again* lives in `ManagedCamera` and |
| +/// `CameraDirector`; a backend only lists, opens and closes. |
| +@MainActor public protocol CameraBackend: AnyObject { |
| + /// Asks for camera (and microphone) permission. Throws when refused. |
| + func requestAccess() async throws |
| + /// The cameras connected now. |
| + var devices: [CameraDevice] { get } |
| + /// How many formats `open` can be asked for on this device, ranked. |
| + func formatCount(_ id: String) -> Int |
| + /// Opens the device in its ranked format, delivering frames to |
| + /// `delegate`. Throws when it will not open. |
| + func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws |
| + func close() async |
| + /// Set by the managed camera: the device list changed. |
| + var devicesChanged: (@MainActor () -> Void)? { get set } |
| + /// Set by the managed camera: the session reported an error or an |
| + /// interruption, or the machine woke. Capture should be restarted. |
| + var trouble: (@MainActor () -> Void)? { get set } |
| +} |
| + |
| +/// Counts frames on their way to the video source, which is all the |
| +/// watchdog needs to know whether a camera is working. The capturer holds |
| +/// its delegate weakly, so the managed camera holds this. |
| +final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendable { |
| + private let source: RTCVideoSource |
| + private let lock = OSAllocatedUnfairLock(initialState: UInt64(0)) |
| + init(source: RTCVideoSource) { self.source = source } |
| + var count: UInt64 { lock.withLock { $0 } } |
| + func capturer(_ capturer: RTCVideoCapturer, didCapture frame: RTCVideoFrame) { |
| + lock.withLock { $0 += 1 } |
| + source.capturer(capturer, didCapture: frame) |
| + } |
| +} |
| + |
| +/// A camera under the recovery rules (see `CameraDirector`): remembered |
| +/// between calls, followed as devices come and go, and watched for frames. |
| +/// |
| +/// Every open and close runs one at a time, and an open the director has |
| +/// since moved on from is skipped, so the last decision is the one that |
| +/// holds. |
| +@MainActor public final class ManagedCamera: VideoCapture { |
| + private let backend: CameraBackend |
| + private let director: CameraDirector |
| + private var counter: FrameCounter? |
| + private var work: Task<Void, Never>? |
| + private var ticker: Task<Void, Never>? |
| + private var running = false |
| + private var changed: (@MainActor () -> Void)? |
| + private(set) public var currentCamera: String? |
| + /// Opens performed, for the gate: a stall that recovered is one more. |
| + public private(set) var opens = 0 |
| + private let clock: () -> TimeInterval |
| + var tickInterval: Duration = .milliseconds(500) |
| + |
| + public init( |
| + backend: CameraBackend, defaults: UserDefaults = .standard, |
| + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } |
| + ) { |
| + self.backend = backend |
| + self.clock = clock |
| + director = CameraDirector(defaults: defaults, formats: { [weak backend] in backend?.formatCount($0) ?? 1 }) |
| + backend.devicesChanged = { [weak self] in self?.devicesChanged() } |
| + backend.trouble = { [weak self] in self?.trouble() } |
| + } |
| + |
| + public var cameras: [CameraDevice] { backend.devices } |
| + public var offersCameras: Bool { true } |
| + public var cameraStatus: CameraStatus? { running ? director.status : nil } |
| + public var cameraNote: String { running ? director.note : "" } |
| + public func observe(_ changed: @escaping @MainActor () -> Void) { self.changed = changed } |
| + |
| + public func start(source: RTCVideoSource) async throws { |
| + try await backend.requestAccess() |
| + counter = FrameCounter(source: source) |
| + running = true |
| + ticker = Task { @MainActor [weak self] in |
| + var slow = 0 |
| + while !Task.isCancelled { |
| + guard let self else { return } |
| + try? await Task.sleep(for: self.tickInterval) |
| + guard !Task.isCancelled else { return } |
| + self.tick() |
| + // Belt and braces for the device notifications: while there is no |
| + // working camera, look again every few seconds. |
| + slow += 1 |
| + if slow % 6 == 0, !(self.director.status.live) { self.devicesChanged() } |
| + } |
| + } |
| + // Returns once the first open has been tried, so a call starts with |
| + // its camera (or its reason for having none) already known. |
| + await perform(director.start(devices: backend.devices)) |
| + } |
| + |
| + public func stop() { |
| + running = false |
| + ticker?.cancel() |
| + ticker = nil |
| + director.stop() |
| + currentCamera = nil |
| + let backend = backend |
| + let previous = work |
| + work = Task { @MainActor in |
| + await previous?.value |
| + await backend.close() |
| + } |
| + counter = nil |
| + } |
| + |
| + public func use(camera id: String) async throws { |
| + guard running else { return } |
| + guard let command = director.choose(id) else { |
| + throw NSError( |
| + domain: "Calls", code: 4, |
| + userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."]) |
| + } |
| + await perform(command) |
| + } |
| + |
| + public func retryCamera() async { |
| + guard running else { return } |
| + await perform(director.retry()) |
| + } |
| + |
| + private func tick() { |
| + guard running, let counter else { return } |
| + #if os(iOS) |
| + // iOS stops every camera while the app is in the background; that |
| + // is not a stall to recover from. |
| + if UIApplication.shared.applicationState != .active { |
| + director.hold(frames: counter.count, at: clock()) |
| + return |
| + } |
| + #endif |
| + if let command = director.tick(frames: counter.count, at: clock()) { |
| + Task { await self.perform(command) } |
| + } |
| + notify() |
| + } |
| + |
| + private func devicesChanged() { |
| + guard running else { |
| + changed?() |
| + return |
| + } |
| + if let command = director.devicesChanged(backend.devices) { |
| + Task { await self.perform(command) } |
| + } |
| + notify() |
| + } |
| + |
| + private func trouble() { |
| + guard running else { return } |
| + if let command = director.restart(at: clock()) { |
| + Task { await self.perform(command) } |
| + } |
| + } |
| + |
| + private var published: (CameraStatus, String, String?)? |
| + private func notify() { |
| + let now = (director.status, director.note, currentCamera) |
| + if let published, published == now { return } |
| + published = now |
| + changed?() |
| + } |
| + |
| + /// Runs a command after whatever is running, and returns when it has. |
| + private func perform(_ command: CameraCommand?) async { |
| + guard let command else { |
| + notify() |
| + return |
| + } |
| + let previous = work |
| + let task = Task { @MainActor [weak self] in |
| + await previous?.value |
| + await self?.execute(command) |
| + } |
| + work = task |
| + await task.value |
| + } |
| + |
| + private func execute(_ command: CameraCommand) async { |
| + guard running else { return } |
| + switch command { |
| + case .stop: |
| + await backend.close() |
| + currentCamera = nil |
| + notify() |
| + case .open(let id, let format): |
| + // The director has moved on (a later pick, a fallback): skip. |
| + guard director.active == id, director.format == format, let counter else { return } |
| + await backend.close() |
| + currentCamera = nil |
| + notify() |
| + guard running, director.active == id else { return } |
| + do { |
| + try await backend.open(id, format: format, delegate: counter) |
| + opens += 1 |
| + guard running, director.active == id else { return } |
| + currentCamera = id |
| + director.opened(id, at: clock(), frames: counter.count) |
| + } catch { |
| + guard running, director.active == id else { return } |
| + if let next = director.openFailed(id) { |
| + notify() |
| + await execute(next) |
| + return |
| + } |
| + } |
| + notify() |
| + } |
| + } |
| +} |
| - private static var devices: [AVCaptureDevice] { |
| +// --------------------------------------------------------------------- |
| +// AVFoundation |
| +// --------------------------------------------------------------------- |
| + |
| +/// The cameras connected to this device, followed as they come and go: |
| +/// AVFoundation's connect and disconnect notifications, and on Mac the |
| +/// discovery session's device list by KVO. One for the process, so the |
| +/// panel can list cameras whether or not a capture is running. |
| +@MainActor public final class CameraWatch { |
| + public static let shared = CameraWatch() |
| + public private(set) var cameras: [CameraDevice] = [] |
| + private var listeners: [UUID: @MainActor () -> Void] = [:] |
| + private var tokens: [NSObjectProtocol] = [] |
| + #if os(macOS) |
| + private let session = AVCaptureDevice.DiscoverySession( |
| + deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], |
| + mediaType: .video, position: .unspecified) |
| + private var kvo: NSKeyValueObservation? |
| + #endif |
| + |
| + private init() { |
| + for name in [AVCaptureDevice.wasConnectedNotification, AVCaptureDevice.wasDisconnectedNotification] { |
| + tokens.append( |
| + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { _ in |
| + MainActor.assumeIsolated { CameraWatch.shared.refresh() } |
| + }) |
| + } |
| #if os(macOS) |
| - AVCaptureDevice.DiscoverySession( |
| - deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], |
| - mediaType: .video, position: .unspecified |
| - ).devices |
| + kvo = session.observe(\.devices, options: [.new]) { _, _ in |
| + DispatchQueue.main.async { MainActor.assumeIsolated { CameraWatch.shared.refresh() } } |
| + } |
| + #endif |
| + cameras = Self.list(devices) |
| + } |
| + |
| + var devices: [AVCaptureDevice] { |
| + #if os(macOS) |
| + session.devices |
| #else |
| RTCCameraVideoCapturer.captureDevices() |
| #endif |
| } |
| - var cameras: [CameraDevice] { |
| - Self.devices.map { |
| - CameraDevice(id: $0.uniqueID, name: $0.localizedName) |
| + func device(_ id: String) -> AVCaptureDevice? { devices.first { $0.uniqueID == id } } |
| + |
| + static func list(_ devices: [AVCaptureDevice]) -> [CameraDevice] { |
| + devices.filter(\.isConnected).map { |
| + #if os(macOS) |
| + let builtIn = $0.deviceType == .builtInWideAngleCamera |
| + #else |
| + let builtIn = $0.position == .front |
| + #endif |
| + return CameraDevice(id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn) |
| + } |
| + } |
| + |
| + public func refresh() { |
| + let now = Self.list(devices) |
| + guard now != cameras else { return } |
| + cameras = now |
| + for listener in listeners.values { listener() } |
| + } |
| + |
| + func listen(_ listener: @escaping @MainActor () -> Void) -> UUID { |
| + let id = UUID() |
| + listeners[id] = listener |
| + return id |
| + } |
| + func unlisten(_ id: UUID) { listeners.removeValue(forKey: id) } |
| +} |
| + |
| +/// The formats a camera is opened in, best first, and the frame rate to |
| +/// ask for in each — held apart from AVFoundation so the rule can be |
| +/// tested with numbers. |
| +/// |
| +/// Nearest 640 wide first (a call does not need more, and the encoder |
| +/// scales down anyway), then nearest 30 frames a second without going |
| +/// over, then the device's own order. The rate asked for is always one |
| +/// the format supports: 30 where a range covers it, otherwise the |
| +/// fastest rate at or under 30, otherwise the slowest the format offers. |
| +/// An ATEM Mini offers 1080p at exactly 23.98, 24, 25, 29.97, 30, 50, |
| +/// 59.94 and 60 — ranges of one rate each — and asking one of those for a |
| +/// rate outside its range is an exception AVFoundation raises and WebRTC |
| +/// swallows, leaving the camera at whatever rate it was on. |
| +public struct CameraFormatChoice: Equatable, Sendable { |
| + public struct Candidate: Equatable, Sendable { |
| + public var width: Int |
| + public var height: Int |
| + /// Each supported range as (min, max) frames a second. |
| + public var ranges: [ClosedRange<Double>] |
| + public init(width: Int, height: Int, ranges: [ClosedRange<Double>]) { |
| + self.width = width |
| + self.height = height |
| + self.ranges = ranges |
| + } |
| + } |
| + |
| + /// The rate to run a format at, and whether it is a whole number WebRTC |
| + /// can be given as-is. |
| + public static func rate(for ranges: [ClosedRange<Double>], target: Double = 30) -> Double? { |
| + guard !ranges.isEmpty else { return nil } |
| + if ranges.contains(where: { $0.contains(target) }) { return target } |
| + let under = ranges.map(\.upperBound).filter { $0 <= target } |
| + if let best = under.max() { return best } |
| + return ranges.map(\.lowerBound).min() |
| + } |
| + |
| + /// The candidates' indexes, best first, one per distinct size and rate. |
| + public static func rank(_ candidates: [Candidate], width target: Int = 640) -> [Int] { |
| + var seen = Set<String>() |
| + return candidates.indices.sorted { a, b in |
| + let ca = candidates[a], cb = candidates[b] |
| + let wa = abs(ca.width - target), wb = abs(cb.width - target) |
| + if wa != wb { return wa < wb } |
| + let ra = 30 - (rate(for: ca.ranges) ?? 0), rb = 30 - (rate(for: cb.ranges) ?? 0) |
| + if abs(ra) != abs(rb) { return (ra >= 0 ? ra : 1000 - ra) < (rb >= 0 ? rb : 1000 - rb) } |
| + return a < b |
| + }.filter { |
| + let c = candidates[$0] |
| + let key = "\(c.width)x\(c.height)@\(rate(for: c.ranges) ?? 0)" |
| + return seen.insert(key).inserted |
| } |
| } |
| +} |
| + |
| +/// AVFoundation's camera, through WebRTC's capturer. |
| +@MainActor final class AVCameraBackend: CameraBackend { |
| + private var capturer: RTCCameraVideoCapturer? |
| + private var observed: [NSObjectProtocol] = [] |
| + private var listener: UUID? |
| + #if os(macOS) |
| + private var woke: NSObjectProtocol? |
| + #endif |
| + var devicesChanged: (@MainActor () -> Void)? |
| + var trouble: (@MainActor () -> Void)? |
| + /// How long an open may take before it counts as refused. A camera |
| + /// that re-enumerates mid-open can leave the completion uncalled. |
| + static let openTimeout: TimeInterval = 6 |
| + |
| + init() { |
| + let watch = CameraWatch.shared |
| + listener = watch.listen { [weak self] in self?.devicesChanged?() } |
| + #if os(macOS) |
| + woke = ( |
| + NSWorkspace.shared.notificationCenter.addObserver( |
| + forName: NSWorkspace.didWakeNotification, object: nil, queue: .main |
| + ) { [weak self] _ in |
| + MainActor.assumeIsolated { |
| + CameraWatch.shared.refresh() |
| + self?.trouble?() |
| + } |
| + }) |
| + #endif |
| + } |
| - func start(source: RTCVideoSource) async throws { |
| - let preparing = epoch |
| + func requestAccess() async throws { |
| for kind in [AVMediaType.video, .audio] { |
| let granted = await AVCaptureDevice.requestAccess(for: kind) |
| - guard preparing == epoch else { throw CancellationError() } |
| if !granted { |
| throw NSError( |
| domain: "Calls", code: 2, |
| - userInfo: [ |
| - NSLocalizedDescriptionKey: |
| - "Allow camera and microphone access in Settings to join." |
| - ]) |
| + userInfo: [NSLocalizedDescriptionKey: "Allow camera and microphone access in Settings to join."]) |
| } |
| } |
| - let devices = Self.devices |
| - #if os(iOS) |
| - let preferredDevice = devices.first { $0.position == .front } |
| - #else |
| - let preferredDevice = devices.first |
| - #endif |
| - guard let device = preferredDevice ?? devices.first else { |
| + } |
| + |
| + var devices: [CameraDevice] { |
| + CameraWatch.shared.refresh() |
| + return CameraWatch.shared.cameras |
| + } |
| + |
| + private func formats(_ device: AVCaptureDevice) -> [AVCaptureDevice.Format] { |
| + let all = RTCCameraVideoCapturer.supportedFormats(for: device) |
| + let candidates = all.map { format -> CameraFormatChoice.Candidate in |
| + let size = CMVideoFormatDescriptionGetDimensions(format.formatDescription) |
| + return CameraFormatChoice.Candidate( |
| + width: Int(size.width), height: Int(size.height), |
| + ranges: format.videoSupportedFrameRateRanges.map { $0.minFrameRate...$0.maxFrameRate }) |
| + } |
| + return CameraFormatChoice.rank(candidates).map { all[$0] } |
| + } |
| + |
| + func formatCount(_ id: String) -> Int { |
| + guard let device = CameraWatch.shared.device(id) else { return 0 } |
| + return formats(device).count |
| + } |
| + |
| + func open(_ id: String, format index: Int, delegate: RTCVideoCapturerDelegate) async throws { |
| + guard let device = CameraWatch.shared.device(id) else { |
| throw NSError( |
| - domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."]) |
| + domain: "Calls", code: 4, userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."]) |
| } |
| - self.source = source |
| - let capture = RTCCameraVideoCapturer(delegate: source) |
| - capturer = capture |
| - try await run(capture, device: device, preparing: preparing) |
| - } |
| - |
| - /// Switches the camera without touching the track. |
| - /// |
| - /// The capturer keeps delivering into the same RTCVideoSource, so the |
| - /// RTCVideoTrack every peer connection is already sending does not |
| - /// change and no renegotiation happens — the native counterpart of the |
| - /// browser's replaceTrack, and for the same reason: an m-line that |
| - /// exists and keeps existing is the one thing perfect negotiation never |
| - /// has to hear about. |
| - /// |
| - /// If opening the new camera fails, try to restore the previous capture. |
| - func use(camera id: String) async throws { |
| - guard let capture = capturer, source != nil else { return } |
| - guard let device = Self.devices.first(where: { $0.uniqueID == id }) |
| - else { |
| + let ranked = formats(device) |
| + guard !ranked.isEmpty else { |
| throw NSError( |
| - domain: "Calls", code: 4, |
| - userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."]) |
| + domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."]) |
| } |
| - epoch = UUID() |
| - let preparing = epoch |
| - let previous = currentCamera |
| - await capture.stopCapture() |
| - currentCamera = nil |
| - guard preparing == epoch else { throw CancellationError() } |
| + let format = ranked[min(index, ranked.count - 1)] |
| + let ranges = format.videoSupportedFrameRateRanges |
| + let rate = CameraFormatChoice.rate(for: ranges.map { $0.minFrameRate...$0.maxFrameRate }) ?? 30 |
| + let capture = capturer ?? makeCapturer(delegate: delegate) |
| + // WebRTC takes a whole number of frames a second. For a rate that is |
| + // not one (29.97), it is given the nearest, and the exact duration |
| + // from the format's own range is set on the device afterwards. |
| + let fps = max(1, Int(rate.rounded())) |
| + let once = OSAllocatedUnfairLock(initialState: false) |
| do { |
| - try await run(capture, device: device, preparing: preparing) |
| + try await start(capture, device: device, format: format, fps: fps, once: once) |
| } catch { |
| - if preparing == epoch, let previous, |
| - let old = Self.devices.first(where: { $0.uniqueID == previous }) |
| - { |
| - try? await run(capture, device: old, preparing: preparing) |
| - } |
| + // A failed or abandoned open may still finish later on the |
| + // capturer's own queue and start delivering frames from this |
| + // device. The capturer is cut loose — no delegate, stopped, not |
| + // reused — so those frames reach nobody, and the next open gets a |
| + // fresh one. |
| + capture.delegate = nil |
| + capture.stopCapture(completionHandler: {}) |
| + capturer = nil |
| throw error |
| } |
| + if Double(fps) != rate, |
| + let range = ranges.first(where: { $0.minFrameRate <= rate && rate <= $0.maxFrameRate }) |
| + { |
| + let duration = rate == range.maxFrameRate ? range.minFrameDuration : range.maxFrameDuration |
| + if (try? device.lockForConfiguration()) != nil { |
| + device.activeVideoMinFrameDuration = duration |
| + device.activeVideoMaxFrameDuration = duration |
| + device.unlockForConfiguration() |
| + } |
| + } |
| } |
| - /// The half start and use share: pick the format nearest 640 wide, cap |
| - /// the frame rate at 30, and start. Held together so a camera chosen |
| - /// mid-call is opened exactly the way the one chosen at join was. |
| - private func run(_ capture: RTCCameraVideoCapturer, device: AVCaptureDevice, preparing: UUID) |
| - async throws |
| - { |
| - guard |
| - let format = RTCCameraVideoCapturer.supportedFormats(for: device).min(by: { |
| - abs(Int(CMVideoFormatDescriptionGetDimensions($0.formatDescription).width) - 640) |
| - < abs(Int(CMVideoFormatDescriptionGetDimensions($1.formatDescription).width) - 640) |
| - }) |
| - else { |
| - throw NSError( |
| - domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."]) |
| - } |
| + private func start( |
| + _ capture: RTCCameraVideoCapturer, device: AVCaptureDevice, format: AVCaptureDevice.Format, |
| + fps: Int, once: OSAllocatedUnfairLock<Bool> |
| + ) async throws { |
| try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in |
| - capture.startCapture( |
| - with: device, format: format, |
| - fps: max( |
| - 1, min(30, Int(format.videoSupportedFrameRateRanges.map { $0.maxFrameRate }.max() ?? 30))) |
| - ) { error in |
| + let finish: @Sendable (Error?) -> Void = { error in |
| + let first = once.withLock { done -> Bool in |
| + defer { done = true } |
| + return !done |
| + } |
| + guard first else { return } |
| if let error { continuation.resume(throwing: error) } else { continuation.resume() } |
| } |
| + capture.startCapture(with: device, format: format, fps: fps) { finish($0) } |
| + DispatchQueue.main.asyncAfter(deadline: .now() + AVCameraBackend.openTimeout) { |
| + finish( |
| + NSError( |
| + domain: "Calls", code: 6, userInfo: [NSLocalizedDescriptionKey: "The camera did not start."])) |
| + } |
| } |
| - if preparing != epoch { |
| - await capture.stopCapture() |
| - throw CancellationError() |
| + } |
| + |
| + private func makeCapturer(delegate: RTCVideoCapturerDelegate) -> RTCCameraVideoCapturer { |
| + let capture = RTCCameraVideoCapturer(delegate: delegate) |
| + capturer = capture |
| + let session = capture.captureSession |
| + // A runtime error, or an interruption ending: WebRTC's capturer |
| + // retries a runtime error once itself; these restart it again, rate |
| + // limited by the director. |
| + for name in [ |
| + AVCaptureSession.runtimeErrorNotification, AVCaptureSession.interruptionEndedNotification, |
| + ] { |
| + observed.append( |
| + NotificationCenter.default.addObserver(forName: name, object: session, queue: .main) { |
| + [weak self] _ in |
| + MainActor.assumeIsolated { self?.trouble?() } |
| + }) |
| } |
| - currentCamera = device.uniqueID |
| + return capture |
| } |
| - func stop() { |
| - epoch = UUID() |
| - capturer?.stopCapture() |
| - capturer = nil |
| - source = nil |
| - currentCamera = nil |
| + func close() async { |
| + guard let capturer else { return } |
| + await capturer.stopCapture() |
| } |
| + |
| + deinit { |
| + for token in observed { NotificationCenter.default.removeObserver(token) } |
| + #if os(macOS) |
| + if let woke { NSWorkspace.shared.notificationCenter.removeObserver(woke) } |
| + #endif |
| + if let listener { |
| + Task { @MainActor in CameraWatch.shared.unlisten(listener) } |
| + } |
| + } |
| +} |
| + |
| +/// The platform camera: AVFoundation under the recovery rules. |
| +@MainActor final class CameraCapture { |
| + static func make() -> ManagedCamera { ManagedCamera(backend: AVCameraBackend()) } |
| } |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index 121eae2..07a29bc 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -28,13 +28,7 @@ import SwiftUI |
| public var body: some View { |
| VStack(alignment: .leading, spacing: 16) { |
| - // A picker even for one camera: a Continuity or external camera can |
| - // arrive while the panel is open, and the list refreshes under it. |
| - if !call.cameras.isEmpty { |
| - picker( |
| - "Camera", selection: $camera, |
| - options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") |
| - } |
| + cameraRow |
| #if os(iOS) |
| if audio.inputs.count > 1 { |
| @@ -92,6 +86,45 @@ import SwiftUI |
| } |
| } |
| + /// Always there in a call with a real camera — a camera that has gone |
| + /// is exactly when a person needs this row, and 0.3.1 hid it then. A |
| + /// picker even for one camera: a Continuity or external camera can |
| + /// arrive while the panel is open, and the list follows it. |
| + @ViewBuilder private var cameraRow: some View { |
| + let state = CameraRowState( |
| + cameras: call.cameras.count, offers: call.offersCameras, status: call.cameraStatus) |
| + if state != .hidden { |
| + VStack(alignment: .leading, spacing: 6) { |
| + if state == .noCamera { |
| + row("Camera", "No camera found") |
| + } else { |
| + picker( |
| + "Camera", selection: $camera, |
| + options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") |
| + } |
| + if case .picker(let line, _) = state, let line { |
| + Text(line).font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("cameraStatus") |
| + } |
| + if !call.cameraNote.isEmpty { |
| + Label(call.cameraNote, systemImage: "info.circle") |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("cameraNote") |
| + } |
| + if state.offersRetry { |
| + Button("Try again", systemImage: "arrow.clockwise") { |
| + Task { await call.retryCamera() } |
| + } |
| + .buttonStyle(.bordered) |
| + .accessibilityIdentifier("cameraRetry") |
| + .accessibilityHint("Looks for cameras again and restarts the camera.") |
| + } |
| + } |
| + } |
| + } |
| + |
| #if os(iOS) |
| private var microphonePicker: some View { |
| VStack(alignment: .leading, spacing: 6) { |
| @@ -172,7 +205,7 @@ import SwiftUI |
| options: audio.outputs.map { ($0.id, $0.name) }, identifier: "speakerPicker") |
| } |
| if !audio.inputs.isEmpty || !audio.outputs.isEmpty { |
| - Text("Changes apply to this call only.") |
| + Text("Calls remembers these for next time.") |
| .font(.footnote).foregroundStyle(.secondary) |
| } |
| if !audio.routeNote.isEmpty { |
| @@ -243,3 +276,37 @@ import SwiftUI |
| #endif |
| } |
| } |
| + |
| +/// What the panel's Camera row shows, as a rule the tests can hold. |
| +enum CameraRowState: Equatable { |
| + /// A capture with nothing to choose (the interop gate's synthetic one). |
| + case hidden |
| + /// A real camera was expected and none is connected. |
| + case noCamera |
| + /// The picker, with a plain-words line when the camera is not working, |
| + /// and "Try again" when trying again could help. |
| + case picker(line: String?, retry: Bool) |
| + |
| + init(cameras: Int, offers: Bool, status: CameraStatus?) { |
| + if cameras == 0 { |
| + self = offers ? .noCamera : .hidden |
| + return |
| + } |
| + switch status { |
| + case .noCamera?, .live?, nil: |
| + self = .picker(line: nil, retry: false) |
| + case .starting?: |
| + self = .picker(line: status?.panelText, retry: false) |
| + case .noSignal?, .disconnected?, .failed?: |
| + self = .picker(line: status?.panelText, retry: true) |
| + } |
| + } |
| + |
| + var offersRetry: Bool { |
| + switch self { |
| + case .hidden: return false |
| + case .noCamera: return true |
| + case .picker(_, let retry): return retry |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/GuestJoinView.swift b/native/apple/Sources/CallsKit/GuestJoinView.swift| index b269b38..8e8ea0c 100644 |
| --- a/native/apple/Sources/CallsKit/GuestJoinView.swift |
| +++ b/native/apple/Sources/CallsKit/GuestJoinView.swift |
| @@ -49,8 +49,6 @@ import SwiftUI |
| .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| .accessibilityLabel("Error: \(problem)") |
| } |
| - } header: { |
| - Text("Invite link") |
| } footer: { |
| Text( |
| "Paste the link someone sent you. Your browser opens to confirm who you are for this meeting; nothing is saved on this device, and your Calls server stays \(model.serverHost)." |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index cfb36d5..7725a84 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -4,6 +4,7 @@ |
| public struct MacCallView: View { |
| @ObservedObject private var call: Call |
| private let leave: () -> Void |
| + private let rejoin: (() -> Void)? |
| @State private var grid: Bool |
| @State private var showingChat: Bool |
| @State private var chosen: StageItem? |
| @@ -12,10 +13,12 @@ |
| @StateObject private var chatWindow = MacChatWindow() |
| public init( |
| - call: Call, grid: Bool = false, showingChat: Bool = false, leave: @escaping () -> Void |
| + call: Call, grid: Bool = false, showingChat: Bool = false, leave: @escaping () -> Void, |
| + rejoin: (() -> Void)? = nil |
| ) { |
| self.call = call |
| self.leave = leave |
| + self.rejoin = rejoin |
| _grid = State(initialValue: grid) |
| _showingChat = State(initialValue: showingChat) |
| } |
| @@ -101,6 +104,12 @@ |
| } |
| } |
| if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| + // An ended call used to stay on screen as a call with no way back |
| + // into it but Leave and the lobby. |
| + if call.state?.phase == "ended", let rejoin { |
| + Button("Join again", action: rejoin).buttonStyle(.borderedProminent) |
| + .accessibilityIdentifier("joinAgain") |
| + } |
| controls |
| } |
| .onAppear { |
| @@ -147,7 +156,8 @@ |
| .aspectRatio(16 / 9, contentMode: .fit) |
| } |
| MacVideoTile( |
| - track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, |
| + placeholder: call.selfPlaceholder |
| ) |
| .aspectRatio(16 / 9, contentMode: .fit) |
| ForEach(peers) { peer in |
| @@ -192,7 +202,8 @@ |
| .frame(width: size.width, height: size.height) |
| } |
| MacVideoTile( |
| - track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, |
| + placeholder: call.selfPlaceholder |
| ) |
| .frame(width: size.width, height: size.height) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index a0619b6..9ba4ab0 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -14,11 +14,14 @@ |
| /// the edge of a screen is where its menu bar and its scroll bars are, |
| /// and a face survives losing its margins where a document does not. |
| var screen = false |
| + /// Your own tile: what the camera is doing, in place of a picture that |
| + /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video. |
| + var placeholder: String? = nil |
| var body: some View { |
| ZStack { |
| Color.black |
| - if let track, !cameraOff { |
| + if let track, !cameraOff, placeholder == nil { |
| MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| VStack(spacing: 12) { |
| @@ -27,7 +30,7 @@ |
| ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle" |
| ) |
| .font(.largeTitle) |
| - Text(waiting).font(.callout) |
| + Text(waiting).font(.callout).multilineTextAlignment(.center).padding(.horizontal, 12) |
| }.foregroundStyle(.white.opacity(0.8)) |
| } |
| } |
| @@ -40,10 +43,11 @@ |
| .clipShape(RoundedRectangle(cornerRadius: 16)) |
| .accessibilityElement(children: .ignore) |
| .accessibilityLabel(tileAccessibilityLabel(label, muted: muted)) |
| - .accessibilityValue(cameraOff ? "Camera off" : track == nil ? waiting : "Video on") |
| + .accessibilityValue(cameraOff ? "Camera off" : track == nil || placeholder != nil ? waiting : "Video on") |
| } |
| private var waiting: String { |
| - cameraOff ? "Camera off" : screen ? "Waiting for the screen" : "Waiting for video" |
| + cameraOff |
| + ? "Camera off" : placeholder ?? (screen ? "Waiting for the screen" : "Waiting for video") |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index fa1f692..bb0360b 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -37,8 +37,10 @@ import LiveKitWebRTC |
| let source = factory.videoSource() |
| localVideo = factory.videoTrack(with: source, trackId: "camera") |
| localAudio = audio.makeTrack() |
| - let capture = supplied ?? CameraCapture() |
| + let capture = supplied ?? CameraCapture.make() |
| self.capture = capture |
| + offersCameras = capture.offersCameras |
| + capture.observe { [weak self] in self?.call?.cameraChanged() } |
| try await capture.start(source: source) |
| #if os(iOS) |
| // A supplied audio device owns its session configuration (SDK contract). |
| @@ -61,9 +63,31 @@ import LiveKitWebRTC |
| /// The cameras the running capture can offer, and the one it is on. |
| /// Empty before prepare, and empty for the interop executable's |
| /// synthetic frames, which is how the picker knows not to appear. |
| - var cameras: [CameraDevice] { capture?.cameras ?? [] } |
| + /// |
| + /// With real cameras the list stays live after the capture has gone |
| + /// (the call ended under the person), so the panel can still say what |
| + /// is connected and a choice there is remembered for next time. |
| + var cameras: [CameraDevice] { |
| + if let capture { return capture.cameras } |
| + return offersCameras ? CameraWatch.shared.cameras : [] |
| + } |
| var currentCamera: String? { capture?.currentCamera } |
| - func use(camera id: String) async throws { try await capture?.use(camera: id) } |
| + /// Whether this call's camera is a real one, whose row the panel always |
| + /// shows. Kept past the capture, for the same reason as `cameras`. |
| + private(set) var offersCameras = false |
| + var cameraStatus: CameraStatus? { capture?.cameraStatus } |
| + var cameraNote: String { capture?.cameraNote ?? "" } |
| + func use(camera id: String) async throws { |
| + if let capture { |
| + try await capture.use(camera: id) |
| + } else if offersCameras, let device = CameraWatch.shared.cameras.first(where: { $0.id == id }) { |
| + CameraPreference(id: device.id, name: device.name).save() |
| + } |
| + } |
| + func retryCamera() async { |
| + CameraWatch.shared.refresh() |
| + await capture?.retryCamera() |
| + } |
| func closePeers() { |
| for (_, p) in peers { p.close() } |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index bcf8172..d5c8fcf 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -287,7 +287,8 @@ |
| muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer)) |
| case nil: |
| MacVideoTile( |
| - track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff) |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, |
| + placeholder: call.selfPlaceholder) |
| } |
| } |
| // The tile is a picture, not a target: clicks and drags go through |
diff --git a/native/apple/Sources/CallsKit/NoiseSuppression.swift b/native/apple/Sources/CallsKit/NoiseSuppression.swift| index 8cdb00e..fd37649 100644 |
| --- a/native/apple/Sources/CallsKit/NoiseSuppression.swift |
| +++ b/native/apple/Sources/CallsKit/NoiseSuppression.swift |
| @@ -208,6 +208,9 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, |
| var channels = 0 |
| var initialised = false |
| var frames: UInt64 = 0 |
| + /// Every buffer WebRTC has handed over, processed or not: proof that |
| + /// the microphone is capturing. |
| + var buffers: UInt64 = 0 |
| var busySeconds: Double = 0 |
| var lastFrame: Double = -.infinity |
| var cpu: Double = 0 |
| @@ -266,17 +269,25 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, |
| // MARK: RTCAudioCustomProcessingDelegate |
| public func audioProcessingInitialize(sampleRate sampleRateHz: Int, channels: Int) { |
| - let changed = lock.withLock { s -> Bool in |
| - let changed = !s.initialised || s.rate != sampleRateHz |
| + lock.withLock { s in |
| s.rate = sampleRateHz |
| s.channels = channels |
| s.initialised = true |
| - return changed |
| } |
| - if changed { rateChanged?(sampleRateHz) } |
| + // Every time, not only on a change. WebRTC initialises its processing |
| + // at 16 kHz when the module is made, long before any microphone opens, |
| + // and that first call can arrive before anyone is listening; a |
| + // Bluetooth headset that really does capture at 16 kHz then looked |
| + // like "no change", and the plan never heard about it — leaving |
| + // WebRTC's own suppressor off while the readout said it was standing |
| + // in. The listener decides what is a change. |
| + rateChanged?(sampleRateHz) |
| } |
| + public var buffersSeen: UInt64 { lock.withLock { $0.buffers } } |
| + |
| public func audioProcessingProcess(audioBuffer: RTCAudioBuffer) { |
| + lock.withLock { $0.buffers += 1 } |
| guard audioBuffer.channels == 1 else { return } |
| process(audioBuffer.rawBuffer(forChannel: 0), frames: audioBuffer.frames) |
| } |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 250a39e..22234d2 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -52,6 +52,31 @@ final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| // and the consequence is simply no push. |
| } |
| + /// Quitting mid-call leaves the call first. Without this the process |
| + /// ended with its participation still in the room, inside the server's |
| + /// thirty-second resume window, and the next launch — an update's |
| + /// relaunch, most often — joined as a second participation of the same |
| + /// session and was told "This meeting is open elsewhere". (The core now |
| + /// takes its own place back in that case too; leaving is still the |
| + /// right thing to do, because the others in the call see you go at |
| + /// once rather than thirty seconds later.) The leave is given three |
| + /// seconds; the core's own request gives up after two. |
| + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { |
| + guard let model = CallsAppDelegate.model, model.joining else { return .terminateNow } |
| + var replied = false |
| + let reply = { @MainActor in |
| + guard !replied else { return } |
| + replied = true |
| + sender.reply(toApplicationShouldTerminate: true) |
| + } |
| + Task { @MainActor in |
| + await model.leaveNow() |
| + reply() |
| + } |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { MainActor.assumeIsolated { reply() } } |
| + return .terminateLater |
| + } |
| + |
| func applicationDidFinishLaunching(_ notification: Notification) { |
| if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") |
| ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") |
| @@ -66,7 +91,7 @@ struct ContentView: View { |
| var body: some View { |
| Group { |
| if model.joining { |
| - MacCallView(call: model.call, leave: model.leave).padding(24) |
| + MacCallView(call: model.call, leave: model.leave, rejoin: model.join).padding(24) |
| } else { |
| CallsLobby(model: model) |
| } |
diff --git a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift| index 2597fe3..6c7f093 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioHealthTests.swift |
| @@ -97,13 +97,20 @@ final class AudioHealthTests: XCTestCase { |
| final class AudioRoutesTests: XCTestCase { |
| @MainActor func testMacListsTheModuleDevicesAndCanChoose() throws { |
| #if os(macOS) |
| - let routes = AudioRoutes() |
| + let suite = "calls-audio-module-\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: suite)! |
| + defer { store.removePersistentDomain(forName: suite) } |
| + let routes = AudioRoutes(defaults: store) |
| XCTAssertTrue(routes.inputs.isEmpty, "no module yet, so nothing to list") |
| 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. |
| + 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 |
| // shape rather than the presence. |
| for device in routes.inputs + routes.outputs { |
| @@ -127,10 +134,48 @@ final class AudioRoutesTests: XCTestCase { |
| XCTAssertNoThrow(try routes.use(input: other.id)) |
| XCTAssertEqual(routes.currentInput, other.id) |
| routes.reset() |
| - XCTAssertEqual(routes.currentInput, routes.inputs.first?.id, "leaving goes back to the default") |
| + XCTAssertEqual(routes.currentInput, other.id, "leaving keeps the choice for next time") |
| } |
| #else |
| throw XCTSkip("macOS only") |
| #endif |
| } |
| } |
| + |
| +/// The readout before the microphone has started. Paul's 0.3.1 screenshot, |
| +/// alone in a call, read "The microphone runs at 16 kHz … Echo |
| +/// cancellation: off. Noise suppression: off." Those are exactly what the |
| +/// module says before any microphone opens: WebRTC opens it when the first |
| +/// connection starts sending, and until then its processing sits at its |
| +/// construction default of 16 kHz with nothing applied. |
| +final class ProcessingReadoutTests: XCTestCase { |
| + func testNotStartedSaysSoInsteadOfStaleValues() { |
| + let report = ProcessingReport( |
| + suppression: .unsupportedRate(16_000), echoCancellation: "off", noiseSuppression: "off", |
| + gainControl: "off", refused: nil, started: false) |
| + XCTAssertFalse(report.summary.contains("16 kHz")) |
| + XCTAssertFalse(report.summary.contains("Echo cancellation: off")) |
| + XCTAssertTrue(report.summary.hasPrefix("The microphone has not started yet.")) |
| + } |
| + |
| + @MainActor func testAloneInACallTheMicrophoneHasNotStarted() throws { |
| + #if os(macOS) |
| + let suite = "calls-readout-\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: suite)! |
| + defer { store.removePersistentDomain(forName: suite) } |
| + let audio = CallAudio(routes: AudioRoutes(defaults: store), audioDevice: nil) |
| + let track = audio.makeTrack() |
| + _ = track |
| + let report = audio.report() |
| + // The evidence: with a track made and nothing sending, the module is |
| + // not recording, and the processing reports the 16 kHz default with |
| + // echo cancellation off — the screenshot's numbers. |
| + XCTAssertFalse(audio.capturing) |
| + print("pre-start readout: suppression=\(audio.denoiser.state) ec=\(report.echoCancellation) ns=\(report.noiseSuppression) gain=\(report.gainControl)") |
| + XCTAssertFalse(report.started) |
| + XCTAssertTrue(report.summary.hasPrefix("The microphone has not started yet.")) |
| + #else |
| + throw XCTSkip("macOS only") |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift| index 9bd5101..90ec0db 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| @@ -2,7 +2,7 @@ |
| import XCTest |
| @testable import CallsKit |
| - /// The Mac's per-call routing rules, held against a stand-in for the |
| + /// 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. |
| @@ -34,62 +34,82 @@ |
| } |
| } |
| + private var store: UserDefaults! |
| + private var storeName = "" |
| + |
| + override func setUp() { |
| + super.setUp() |
| + storeName = "calls-audio-tests-\(UUID().uuidString)" |
| + store = UserDefaults(suiteName: storeName) |
| + } |
| + override func tearDown() { |
| + store.removePersistentDomain(forName: storeName) |
| + super.tearDown() |
| + } |
| + |
| private func routes(_ module: Module) -> AudioRoutes { |
| - let routes = AudioRoutes() |
| + let routes = AudioRoutes(defaults: store) |
| routes.attach(module.control()) |
| return routes |
| } |
| - func testEveryCallStartsOnTheSystemDefault() { |
| - let routes = routes(Module()) |
| + func testFirstCallStartsOnTheSystemDefault() { |
| + let module = Module() |
| + let routes = routes(module) |
| 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") |
| } |
| - func testSelectionsAreIndependentAndResetForNextCall() throws { |
| - let module = Module() |
| - let routes = routes(module) |
| - let other = self.routes(Module()) |
| + /// 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()) |
| try routes.use(input: "usb") |
| try routes.use(output: "headset") |
| + routes.reset() // leaving the call |
| XCTAssertEqual(routes.currentInput, "usb") |
| XCTAssertEqual(routes.currentOutput, "headset") |
| - XCTAssertEqual(module.selected.map(\.id), ["usb", "headset"]) |
| - XCTAssertEqual(other.currentInput, "default") |
| - XCTAssertEqual(other.currentOutput, "default") |
| - routes.reset() |
| - XCTAssertEqual(routes.currentInput, "default") |
| - XCTAssertEqual(routes.currentOutput, "default") |
| - XCTAssertEqual( |
| - module.selected.suffix(2).map(\.id), ["default", "default"], |
| - "leaving puts the module back on the system default for the next call") |
| + |
| + let next = Module() |
| + 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") |
| + XCTAssertEqual(store.string(forKey: "audioInputUID"), "usb") |
| + XCTAssertEqual(store.string(forKey: "audioInputName"), "usb") |
| } |
| - func testEmptyIdIsTheSystemDefault() throws { |
| - let module = Module() |
| - let routes = routes(module) |
| + func testSystemDefaultIsRememberedWhenPicked() throws { |
| + let routes = routes(Module()) |
| try routes.use(input: "usb") |
| try routes.use(input: "") |
| XCTAssertEqual(routes.currentInput, "default") |
| - XCTAssertEqual(module.selected.last?.id, "default") |
| + XCTAssertEqual(store.string(forKey: "audioInputUID"), "", "the default is a choice of its own") |
| + let next = Module() |
| + let launch = self.routes(next) |
| + XCTAssertEqual(launch.currentInput, "default") |
| + XCTAssertTrue(next.selected.isEmpty) |
| } |
| - func testRefusedSwitchKeepsPreviousSelection() throws { |
| + func testMissingDeviceAtJoinUsesTheDefaultSaysSoAndReturns() { |
| + AudioPreference(id: "scarlett", name: "Scarlett 18i8").save(input: true, store) |
| let module = Module() |
| let routes = routes(module) |
| - 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")) |
| - XCTAssertEqual(routes.currentOutput, "default") |
| - XCTAssertEqual(module.selected.count, 1, "a refused choice never reaches the 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") |
| } |
| - func testDisconnectedInputFallsBackWithoutChangingSelectedOutput() throws { |
| + func testDisconnectMidCallFallsBackAndComesBackOnItsOwn() throws { |
| let module = Module() |
| let routes = routes(module) |
| try routes.use(input: "usb") |
| @@ -100,30 +120,39 @@ |
| XCTAssertEqual(routes.currentOutput, "headset") |
| XCTAssertEqual(module.selected.last?.id, "default") |
| XCTAssertEqual(module.selected.last?.input, true) |
| - XCTAssertTrue(routes.routeNote.contains("microphone")) |
| - XCTAssertFalse(routes.routeNote.contains("speaker")) |
| - // The device coming back does not take the call back to it: the |
| - // fallback is where the call is now, and the note stays until the |
| - // person chooses again. |
| + XCTAssertEqual(routes.routeNote, "usb isn’t connected; using Built-in Microphone.") |
| + let count = module.selected.count |
| + routes.refresh() |
| + XCTAssertEqual(module.selected.count, count, "the module is moved once, not on every look") |
| module.inputs = ["built-in", "usb"] |
| routes.refresh() |
| - XCTAssertEqual(routes.currentInput, "default") |
| - XCTAssertFalse(routes.routeNote.isEmpty) |
| - try routes.use(input: "usb") |
| + XCTAssertEqual(routes.currentInput, "usb") |
| + XCTAssertEqual(module.selected.last?.id, "usb") |
| XCTAssertEqual(routes.routeNote, "") |
| } |
| + func testRefusedSwitchKeepsPreviousSelection() throws { |
| + let module = Module() |
| + let routes = routes(module) |
| + 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")) |
| + 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) |
| - // Following: macOS moves its default and the entry follows it, with |
| - // nothing asked of the module (it follows the default itself). |
| module.defaultOutput = "Headset" |
| routes.refresh() |
| XCTAssertEqual(routes.currentOutput, "default") |
| XCTAssertEqual(routes.outputName, "System default (Headset)") |
| XCTAssertTrue(module.selected.isEmpty) |
| - // Chosen: a later change of default leaves the choice where it is. |
| try routes.use(output: "speakers") |
| let count = module.selected.count |
| module.defaultOutput = "Built-in Speakers" |
| @@ -133,7 +162,7 @@ |
| } |
| func testNoModuleMeansNothingToChoose() { |
| - let routes = AudioRoutes() |
| + let routes = AudioRoutes(defaults: store) |
| XCTAssertTrue(routes.inputs.isEmpty) |
| XCTAssertNil(routes.currentInput) |
| XCTAssertThrowsError(try routes.use(input: "")) |
diff --git a/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift| new file mode 100644 |
| index 0000000..db5a2f6 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift |
| @@ -0,0 +1,329 @@ |
| +import LiveKitWebRTC |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// The camera rules from Paul's ATEM Mini report against 0.3.1: the camera |
| +/// remembered, followed as it comes and goes, and watched for frames. |
| +@MainActor final class CameraRecoveryTests: XCTestCase { |
| + private let atem = CameraDevice(id: "atem", name: "ATEM Mini") |
| + private let faceTime = CameraDevice(id: "facetime", name: "FaceTime HD Camera", builtIn: true) |
| + private let phone = CameraDevice(id: "phone", name: "Paul’s iPhone Camera") |
| + |
| + private func defaults() -> UserDefaults { |
| + let name = "calls-camera-tests-\(UUID().uuidString)" |
| + let defaults = UserDefaults(suiteName: name)! |
| + addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| + return defaults |
| + } |
| + |
| + private func director(_ defaults: UserDefaults, formats: Int = 2) -> CameraDirector { |
| + CameraDirector(defaults: defaults, formats: { _ in formats }) |
| + } |
| + |
| + // MARK: Choosing |
| + |
| + func testJoinUsesTheRememberedCamera() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + XCTAssertEqual(d.start(devices: [faceTime, atem]), .open("atem", format: 0)) |
| + XCTAssertEqual(d.status, .starting("ATEM Mini")) |
| + XCTAssertEqual(d.note, "") |
| + } |
| + |
| + func testJoinFallsBackToTheBuiltInCameraAndSaysSo() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + // The built-in camera, not merely the first listed. |
| + XCTAssertEqual(d.start(devices: [phone, faceTime]), .open("facetime", format: 0)) |
| + XCTAssertEqual(d.note, "ATEM Mini isn’t connected; using FaceTime HD Camera") |
| + } |
| + |
| + func testWithNothingRememberedTheBuiltInCameraIsFirstChoice() { |
| + let d = director(defaults()) |
| + XCTAssertEqual(d.start(devices: [atem, faceTime]), .open("facetime", format: 0)) |
| + XCTAssertEqual(d.note, "") |
| + XCTAssertEqual(CameraDirector.choose([atem, phone], preferred: nil)?.id, "atem") |
| + XCTAssertNil(CameraDirector.choose([], preferred: "atem")) |
| + } |
| + |
| + func testNoCameraAtAllIsSaidAndStopped() { |
| + let d = director(defaults()) |
| + XCTAssertEqual(d.start(devices: []), .stop) |
| + XCTAssertEqual(d.status, .noCamera) |
| + XCTAssertEqual(d.status.tileText, "No camera found") |
| + // One arriving later is used. |
| + XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0)) |
| + } |
| + |
| + func testChoosingACameraRemembersIt() { |
| + let store = defaults() |
| + let d = director(store) |
| + _ = d.start(devices: [faceTime, atem]) |
| + XCTAssertEqual(d.choose("atem"), .open("atem", format: 0)) |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(id: "atem", name: "ATEM Mini")) |
| + XCTAssertNil(d.choose("missing"), "a camera that is not connected cannot be chosen") |
| + } |
| + |
| + // MARK: Coming and going |
| + |
| + func testActiveCameraDisconnectingFallsBackAtOnceAndSaysSo() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + _ = d.start(devices: [faceTime, atem]) |
| + d.opened("atem", at: 0, frames: 0) |
| + XCTAssertEqual(d.devicesChanged([faceTime]), .open("facetime", format: 0)) |
| + XCTAssertEqual(d.note, "ATEM Mini disconnected; using FaceTime HD Camera") |
| + } |
| + |
| + func testRememberedCameraReconnectingIsReturnedTo() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + _ = d.start(devices: [faceTime, atem]) |
| + _ = d.devicesChanged([faceTime]) |
| + XCTAssertEqual(d.active, "facetime") |
| + // Something unrelated changing does not move the call. |
| + XCTAssertNil(d.devicesChanged([faceTime, phone])) |
| + XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) |
| + XCTAssertEqual(d.note, "", "the fallback note goes when the camera is back") |
| + } |
| + |
| + func testRememberedCameraAppearingLaterIsSwitchedTo() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + XCTAssertEqual(d.start(devices: [faceTime]), .open("facetime", format: 0)) |
| + XCTAssertEqual(d.devicesChanged([faceTime, atem]), .open("atem", format: 0)) |
| + } |
| + |
| + func testLastCameraDisconnectingStopsAndSaysDisconnected() { |
| + let d = director(defaults()) |
| + _ = d.start(devices: [atem]) |
| + XCTAssertEqual(d.devicesChanged([]), .stop) |
| + XCTAssertEqual(d.status, .disconnected("ATEM Mini")) |
| + XCTAssertEqual(d.status.tileText, "Camera disconnected") |
| + XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0), "and back when it is") |
| + } |
| + |
| + func testOpenFailureTriesAnotherCameraThenGivesUpWithRetry() { |
| + let d = director(defaults()) |
| + _ = d.start(devices: [atem, faceTime]) // facetime (built in) |
| + XCTAssertEqual(d.openFailed("facetime"), .open("atem", format: 0)) |
| + XCTAssertEqual(d.note, "FaceTime HD Camera would not start; using ATEM Mini") |
| + XCTAssertNil(d.openFailed("atem")) |
| + XCTAssertEqual(d.status, .failed("ATEM Mini")) |
| + XCTAssertEqual(d.retry(), .open("facetime", format: 0), "Try again forgives both") |
| + } |
| + |
| + // MARK: The watchdog |
| + |
| + func testWatchdogLadder() { |
| + var dog = CameraWatchdog() |
| + dog.opened(at: 0, frames: 0) |
| + XCTAssertNil(dog.observe(frames: 0, at: 2.9)) |
| + XCTAssertEqual(dog.observe(frames: 0, at: 3), .restart) |
| + XCTAssertNil(dog.observe(frames: 0, at: 10), "nothing more until reopened") |
| + dog.opened(at: 10, frames: 0) |
| + XCTAssertEqual(dog.observe(frames: 0, at: 13), .nextFormat) |
| + dog.opened(at: 13, frames: 0) |
| + XCTAssertEqual(dog.observe(frames: 0, at: 16), .fallback) |
| + } |
| + |
| + func testWatchdogStallAfterFramesAndRecovery() { |
| + var dog = CameraWatchdog() |
| + dog.opened(at: 0, frames: 0) |
| + XCTAssertNil(dog.observe(frames: 5, at: 1)) |
| + XCTAssertTrue(dog.flowing) |
| + XCTAssertNil(dog.observe(frames: 9, at: 2)) |
| + XCTAssertNil(dog.observe(frames: 9, at: 4.9)) |
| + XCTAssertEqual(dog.observe(frames: 9, at: 5), .restart) |
| + dog.opened(at: 5, frames: 9) |
| + XCTAssertNil(dog.observe(frames: 12, at: 6)) |
| + XCTAssertEqual(dog.level, 0, "frames put it back at the bottom of the ladder") |
| + } |
| + |
| + func testNoFirstFrameRestartsThenNextFormatThenFallsBack() { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let d = director(store) |
| + _ = d.start(devices: [faceTime, atem]) |
| + d.opened("atem", at: 0, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0), "restart, same format") |
| + d.opened("atem", at: 4, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 7), .open("atem", format: 1), "then the next format") |
| + d.opened("atem", at: 8, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 11), .open("facetime", format: 0), "then another camera") |
| + XCTAssertEqual(d.note, "No signal from ATEM Mini; using FaceTime HD Camera") |
| + d.opened("facetime", at: 12, frames: 0) |
| + XCTAssertNil(d.tick(frames: 3, at: 12.5)) |
| + XCTAssertEqual(d.status, .live("FaceTime HD Camera")) |
| + // The silent ATEM is not returned to just because something else |
| + // changed; only its reconnecting (a new session) forgives it. |
| + XCTAssertNil(d.devicesChanged([faceTime, atem, phone])) |
| + _ = d.devicesChanged([faceTime, phone]) |
| + XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) |
| + } |
| + |
| + func testOneFormatSkipsStraightToFallback() { |
| + let d = director(defaults(), formats: 1) |
| + _ = d.start(devices: [atem]) |
| + d.opened("atem", at: 0, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0)) |
| + d.opened("atem", at: 3, frames: 0) |
| + // No next format, no other camera: give up, say so, keep listening. |
| + XCTAssertNil(d.tick(frames: 0, at: 6)) |
| + XCTAssertEqual(d.status, .noSignal("ATEM Mini")) |
| + XCTAssertEqual(d.status.tileText, "No signal from ATEM Mini — check its input") |
| + XCTAssertNil(d.tick(frames: 0, at: 60), "no restarting forever") |
| + XCTAssertNil(d.tick(frames: 1, at: 61)) |
| + XCTAssertEqual(d.status, .live("ATEM Mini"), "its signal coming back is noticed") |
| + } |
| + |
| + func testHeldClockDoesNotClimbTheLadder() { |
| + let d = director(defaults()) |
| + _ = d.start(devices: [atem]) |
| + d.opened("atem", at: 0, frames: 0) |
| + for second in stride(from: 1.0, through: 30, by: 1) { d.hold(frames: 0, at: second) } |
| + XCTAssertNil(d.tick(frames: 0, at: 31), "the background is not a stall") |
| + XCTAssertEqual(d.tick(frames: 0, at: 33), .open("atem", format: 0)) |
| + } |
| + |
| + func testRestartsAreRateLimited() { |
| + let d = director(defaults()) |
| + _ = d.start(devices: [atem]) |
| + XCTAssertEqual(d.restart(at: 100), .open("atem", format: 0)) |
| + XCTAssertNil(d.restart(at: 102)) |
| + XCTAssertEqual(d.restart(at: 105), .open("atem", format: 0)) |
| + } |
| + |
| + // MARK: Formats |
| + |
| + func testFrameRateIsAlwaysOneTheFormatSupports() { |
| + XCTAssertEqual(CameraFormatChoice.rate(for: [1...30]), 30) |
| + XCTAssertEqual(CameraFormatChoice.rate(for: [29.97...29.97]), 29.97) |
| + XCTAssertEqual(CameraFormatChoice.rate(for: [25...25]), 25) |
| + XCTAssertEqual(CameraFormatChoice.rate(for: [50...50, 59.94...59.94]), 50) |
| + XCTAssertEqual(CameraFormatChoice.rate(for: [23.976...23.976, 60...60]), 23.976) |
| + XCTAssertNil(CameraFormatChoice.rate(for: [])) |
| + } |
| + |
| + func testATEMFormatsRankByRateNearestThirty() { |
| + let rates: [Double] = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60] |
| + let formats = rates.map { |
| + CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [$0...$0]) |
| + } |
| + let ranked = CameraFormatChoice.rank(formats).map { rates[$0] } |
| + XCTAssertEqual(ranked.prefix(3), [30, 29.97, 25]) |
| + XCTAssertEqual(ranked.count, rates.count) |
| + } |
| + |
| + func testSmallerFormatsComeFirstAndDuplicatesGo() { |
| + let formats = [ |
| + CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [1...30]), |
| + CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), |
| + CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), |
| + CameraFormatChoice.Candidate(width: 1280, height: 720, ranges: [1...60]), |
| + ] |
| + XCTAssertEqual(CameraFormatChoice.rank(formats), [1, 3, 0]) |
| + } |
| + |
| + // MARK: The panel |
| + |
| + func testCameraRowStates() { |
| + XCTAssertEqual(CameraRowState(cameras: 0, offers: false, status: nil), .hidden) |
| + XCTAssertEqual(CameraRowState(cameras: 0, offers: true, status: nil), .noCamera) |
| + XCTAssertTrue(CameraRowState(cameras: 0, offers: true, status: .noCamera).offersRetry) |
| + XCTAssertEqual( |
| + CameraRowState(cameras: 2, offers: true, status: .live("ATEM Mini")), .picker(line: nil, retry: false)) |
| + XCTAssertEqual( |
| + CameraRowState(cameras: 1, offers: true, status: .starting("ATEM Mini")), |
| + .picker(line: "Starting ATEM Mini…", retry: false)) |
| + let silent = CameraRowState(cameras: 1, offers: true, status: .noSignal("ATEM Mini")) |
| + XCTAssertTrue(silent.offersRetry) |
| + XCTAssertEqual( |
| + silent, |
| + .picker( |
| + line: "ATEM Mini is connected but sending no picture. Check its input, or choose another camera.", |
| + retry: true)) |
| + XCTAssertTrue(CameraRowState(cameras: 1, offers: true, status: .failed("ATEM Mini")).offersRetry) |
| + } |
| + |
| + func testSelfTileSaysWhatItIsWaitingFor() { |
| + XCTAssertEqual(CameraStatus.starting("ATEM Mini").tileText, "Starting camera…") |
| + XCTAssertEqual(CameraStatus.disconnected("ATEM Mini").tileText, "Camera disconnected") |
| + XCTAssertNil(CameraStatus.live("ATEM Mini").tileText) |
| + } |
| + |
| + // MARK: The managed camera, end to end with a fake backend |
| + |
| + private final class FakeBackend: CameraBackend { |
| + var devices: [CameraDevice] |
| + var opened: [String] = [] |
| + var refuse: Set<String> = [] |
| + var devicesChanged: (@MainActor () -> Void)? |
| + var trouble: (@MainActor () -> Void)? |
| + init(_ devices: [CameraDevice]) { self.devices = devices } |
| + func requestAccess() async throws {} |
| + func formatCount(_ id: String) -> Int { 1 } |
| + func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws { |
| + if refuse.contains(id) { throw NSError(domain: "test", code: 1) } |
| + opened.append(id) |
| + } |
| + func close() async {} |
| + } |
| + |
| + func testManagedCameraFollowsDisconnectAndReturn() async throws { |
| + let store = defaults() |
| + CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| + let backend = FakeBackend([faceTime, atem]) |
| + let camera = ManagedCamera(backend: backend, defaults: store) |
| + var changes = 0 |
| + camera.observe { changes += 1 } |
| + RTCInitializeSSL() |
| + let factory = RTCPeerConnectionFactory() |
| + try await camera.start(source: factory.videoSource()) |
| + XCTAssertEqual(camera.currentCamera, "atem") |
| + XCTAssertEqual(camera.cameraStatus, .starting("ATEM Mini")) |
| + |
| + backend.devices = [faceTime] |
| + backend.devicesChanged?() |
| + try await waitFor { camera.currentCamera == "facetime" } |
| + XCTAssertEqual(camera.cameraNote, "ATEM Mini disconnected; using FaceTime HD Camera") |
| + |
| + backend.devices = [faceTime, atem] |
| + backend.devicesChanged?() |
| + try await waitFor { camera.currentCamera == "atem" } |
| + XCTAssertEqual(camera.cameraNote, "") |
| + XCTAssertEqual(backend.opened, ["atem", "facetime", "atem"]) |
| + XCTAssertGreaterThan(changes, 0) |
| + camera.stop() |
| + XCTAssertNil(camera.cameraStatus) |
| + } |
| + |
| + func testManagedCameraRefusedOpenFallsBack() async throws { |
| + let backend = FakeBackend([faceTime, atem]) |
| + backend.refuse = ["facetime"] |
| + let camera = ManagedCamera(backend: backend, defaults: defaults()) |
| + RTCInitializeSSL() |
| + try await camera.start(source: RTCPeerConnectionFactory().videoSource()) |
| + XCTAssertEqual(camera.currentCamera, "atem") |
| + XCTAssertEqual(camera.cameraNote, "FaceTime HD Camera would not start; using ATEM Mini") |
| + do { |
| + try await camera.use(camera: "nothing") |
| + XCTFail("an unknown camera is refused") |
| + } catch {} |
| + camera.stop() |
| + } |
| + |
| + private func waitFor(_ condition: @escaping @MainActor () -> Bool) async throws { |
| + for _ in 0..<200 { |
| + if condition() { return } |
| + try await Task.sleep(for: .milliseconds(10)) |
| + } |
| + XCTFail("condition never held") |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift| index 8389351..6d0c267 100644 |
| --- a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift |
| @@ -103,14 +103,19 @@ final class CaptureDenoiserTests: XCTestCase { |
| XCTAssertEqual(fake.calls, 2) |
| } |
| - func testRateChangesAreReportedOnceEach() { |
| + /// Every initialisation is reported, a repeat included: WebRTC's first |
| + /// one is its 16 kHz construction default, before any microphone, and a |
| + /// headset really capturing at 16 kHz repeats it. Reporting only changes |
| + /// hid that second one, and the plan never put WebRTC's own suppressor |
| + /// back on (Calls 0.3.1's "16 kHz … Noise suppression: off"). |
| + func testEveryInitialisationIsReported() { |
| let (denoiser, _, _) = adapter() |
| let seen = LockedRates() |
| denoiser.rateChanged = { seen.append($0) } |
| - denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| - denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) |
| - XCTAssertEqual(seen.rates, [48_000, 16_000]) |
| + denoiser.audioProcessingInitialize(sampleRate: 16_000, channels: 1) |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + XCTAssertEqual(seen.rates, [16_000, 16_000, 48_000]) |
| } |
| func testTheToggleAndVoiceIsolationBothPassThrough() { |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index db20ceb..24f96f6 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -158,7 +158,8 @@ struct PhoneCall: View { |
| .aspectRatio(4 / 3, contentMode: .fit) |
| } |
| PhoneVideoTile( |
| - track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, |
| + placeholder: call.selfPlaceholder |
| ) |
| .aspectRatio(4 / 3, contentMode: .fit) |
| ForEach(peers) { peer in |
| @@ -191,7 +192,8 @@ struct PhoneCall: View { |
| .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 18)) |
| } |
| PhoneVideoTile( |
| - track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff |
| + track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff, |
| + placeholder: call.selfPlaceholder |
| ) |
| .frame( |
| width: min(160, geometry.size.width * 0.32), |
| @@ -308,17 +310,25 @@ struct PhoneVideoTile: View { |
| /// more than anywhere, cropping a laptop's screen to a portrait tile |
| /// would leave the middle third of it. |
| var screen = false |
| + /// Your own tile: what the camera is doing, in place of a picture that |
| + /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video. |
| + var placeholder: String? = nil |
| var body: some View { |
| ZStack { |
| Color.black |
| - if let track, !cameraOff { |
| + if let track, !cameraOff, placeholder == nil { |
| PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| - Image( |
| - systemName: cameraOff |
| - ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle" |
| - ) |
| - .font(.largeTitle).foregroundStyle(.white.opacity(0.8)) |
| + VStack(spacing: 6) { |
| + Image( |
| + systemName: cameraOff |
| + ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle" |
| + ) |
| + .font(.largeTitle) |
| + if let placeholder, !cameraOff { |
| + Text(placeholder).font(.caption).multilineTextAlignment(.center).padding(.horizontal, 6) |
| + } |
| + }.foregroundStyle(.white.opacity(0.8)) |
| } |
| } |
| .overlay(alignment: .bottomLeading) { |
| @@ -333,7 +343,7 @@ struct PhoneVideoTile: View { |
| .accessibilityValue( |
| cameraOff |
| ? "Camera off" |
| - : track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on") |
| + : placeholder ?? (track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on")) |
| } |
| } |
| struct PhoneVideo: UIViewRepresentable { |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 60fb255..8b8811b 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.1' |
| + MARKETING_VERSION: '0.3.2' |
| 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/native.mjs b/test/browser/native.mjs| index 4415282..e7a19b5 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -87,6 +87,10 @@ try { |
| assert.equal(sample.stats.webrtcGainControl, 0, "WebRTC's gain control is off"); |
| assert.equal(sample.stats.webrtcEchoCancellation, 1, "echo cancellation stays on"); |
| console.log('PASS WebRTC noise suppression and gain off, echo cancellation on'); |
| + // With audio flowing, the readout reports what is running rather than |
| + // "The microphone has not started yet". |
| + assert.equal(sample.stats.audioReadoutStarted, 1, 'the audio readout knows the microphone has started'); |
| + console.log('PASS the audio readout reports a started microphone'); |
| // Each remote video has its own decoded frame count; local preview alone |
| // cannot satisfy this check. Read the actual room test observation contract. |
| // Remote videos exclude the muted local preview. getVideoPlaybackQuality |
| @@ -153,6 +157,20 @@ try { |
| await wait(async () => await remoteFrames() > refusedFrames + 15, 'media survives a refused camera switch'); |
| console.log('PASS camera switched mid-call without disturbing the connection'); |
| } |
| + // A camera that stops sending — an ATEM Mini whose input changed format |
| + // — is noticed by the watchdog and restarted, on the same track, and the |
| + // far side's picture comes back without anyone touching anything. |
| + { |
| + await wait(() => sample?.cameraStatus === 'live', 'the camera reports live before the stall'); |
| + const opensBefore = sample.cameraOpens; |
| + native.stdin.write('camera-stall\n'); |
| + await wait(() => sample?.cameraOpens > opensBefore, 'the watchdog restarts a stalled camera', 20); |
| + await wait(() => sample?.cameraStatus === 'live', 'the restarted camera reports live again', 20); |
| + const recoveredFrames = await remoteFrames(); |
| + await wait(async () => await remoteFrames() > recoveredFrames + 15, 'browser decodes again after the camera recovers'); |
| + assert.equal(sample.stats.peerConnections, 1, 'recovering the camera keeps the same peer connection'); |
| + console.log('PASS a stalled camera was restarted and the far side kept its picture'); |
| + } |
| await page.locator('[data-chat-text]').fill('browser chat probe'); |
| await page.locator('[data-chat-send]').click(); |
| await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat'); |