Skip to content

Commit 6354032

Browse files
Merge pull request #2263 from CapSoftware/improve/desktop-picker-startup
improve: make desktop picker startup faster and quieter
2 parents 1553419 + 1e76eae commit 6354032

14 files changed

Lines changed: 822 additions & 62 deletions

File tree

apps/desktop-gpui/src/devices.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ impl DeviceSnapshot {
158158
pub fn enumerate() -> Self {
159159
Self {
160160
cameras: list_cameras(),
161-
microphones: list_microphones(),
161+
microphones: list_microphone_names(),
162162
displays: list_displays(),
163163
windows: list_windows(),
164164
}
@@ -553,23 +553,24 @@ pub fn camera_formats(device_id: &str) -> Result<Vec<CameraFormat>, String> {
553553
.formats)
554554
}
555555

556-
/// Mirrors `MicrophoneFeed::list_with_settings`: the default input device is
557-
/// inserted first so it heads the list, then every other input device is
558-
/// appended, deduped by name.
556+
fn list_microphone_names() -> Vec<MicrophoneOption> {
557+
cap_recording::feeds::microphone::MicrophoneFeed::list_names()
558+
.into_iter()
559+
.map(|name| MicrophoneOption {
560+
name,
561+
sample_rate: None,
562+
channels: None,
563+
})
564+
.collect()
565+
}
566+
559567
fn list_microphones() -> Vec<MicrophoneOption> {
560568
// CPAL's configuration lookup opens an input AudioUnit and can prompt for consent.
561569
#[cfg(target_os = "macos")]
562570
if !crate::permissions::check_raw().is_some_and(|permissions| {
563571
permissions.microphone == crate::permissions::MediaAuthorization::Authorized
564572
}) {
565-
return cap_recording::feeds::microphone::MicrophoneFeed::list_names()
566-
.into_iter()
567-
.map(|name| MicrophoneOption {
568-
name,
569-
sample_rate: None,
570-
channels: None,
571-
})
572-
.collect();
573+
return list_microphone_names();
573574
}
574575

575576
let host = cpal::default_host();

apps/desktop-gpui/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ mod onboarding_audio;
4545
mod onboarding_window;
4646
mod permissions;
4747
mod permissions_ui;
48+
#[cfg(test)]
49+
mod picker_benchmark;
50+
#[cfg(debug_assertions)]
51+
mod picker_ui_benchmark;
4852
mod platform;
4953
mod presets;
5054
mod recording;
@@ -644,6 +648,8 @@ fn main() {
644648
// the primary display and record for N seconds (or capture once). The
645649
// end-to-end check drives the recorder this way because unprivileged
646650
// synthetic clicks are dropped.
651+
#[cfg(debug_assertions)]
652+
picker_ui_benchmark::run(window_handle, cx);
647653
if let Ok(auto) = std::env::var("CAP_GPUI_AUTO_RECORD")
648654
&& let Some((mode, secs)) = parse_auto_record(&auto)
649655
{
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use std::time::{Duration, Instant};
2+
3+
use cap_recording::feeds::{camera, microphone::MicrophoneFeed};
4+
use kameo::Actor;
5+
6+
use crate::devices::{DeviceSnapshot, InputSnapshot, TargetSnapshot};
7+
8+
fn measure<T>(stage: &str, sample: usize, run: impl FnOnce() -> T) -> T {
9+
let started = Instant::now();
10+
let result = run();
11+
println!(
12+
"{}",
13+
serde_json::json!({
14+
"stage": stage,
15+
"sample": sample,
16+
"elapsedMs": started.elapsed().as_secs_f64() * 1000.0,
17+
})
18+
);
19+
result
20+
}
21+
22+
#[tokio::test]
23+
#[ignore = "opens local capture devices to measure native picker dependencies"]
24+
async fn native_picker_latency() -> anyhow::Result<()> {
25+
for sample in 0..10 {
26+
let snapshot = measure("gpui_startup_discovery", sample, DeviceSnapshot::enumerate);
27+
println!(
28+
"{}",
29+
serde_json::json!({
30+
"sample": sample,
31+
"cameras": snapshot.cameras.len(),
32+
"microphones": snapshot.microphones.len(),
33+
"displays": snapshot.displays.len(),
34+
"windows": snapshot.windows.len(),
35+
})
36+
);
37+
measure("camera_picker_discovery", sample, || {
38+
InputSnapshot::cameras(&[])
39+
});
40+
measure(
41+
"microphone_picker_discovery",
42+
sample,
43+
InputSnapshot::microphones,
44+
);
45+
measure("tauri_device_inventory", sample, || {
46+
(
47+
cap_camera::list_cameras().collect::<Vec<_>>(),
48+
MicrophoneFeed::list_names(),
49+
)
50+
});
51+
measure("target_discovery", sample, TargetSnapshot::enumerate);
52+
let names = MicrophoneFeed::list_names();
53+
measure("microphone_metadata_all_devices", sample, || {
54+
names
55+
.iter()
56+
.map(|name| MicrophoneFeed::list().swap_remove(name))
57+
.collect::<Vec<_>>()
58+
});
59+
measure("microphone_metadata_named_devices", sample, || {
60+
names
61+
.iter()
62+
.map(|name| MicrophoneFeed::device_with_settings(name, None))
63+
.collect::<Vec<_>>()
64+
});
65+
}
66+
67+
let device_id = std::env::var("CAP_PICKER_BENCH_CAMERA_ID")?;
68+
let camera_info = cap_camera::list_cameras()
69+
.find(|camera| camera.device_id() == device_id)
70+
.ok_or_else(|| anyhow::anyhow!("Benchmark camera is unavailable"))?;
71+
let id = camera::DeviceOrModelID::from_info(&camera_info);
72+
for (stage, reuse) in [
73+
("camera_repeated_selection", false),
74+
("camera_reused_selection", true),
75+
] {
76+
measure_camera_selection(stage, reuse, &id).await?;
77+
}
78+
Ok(())
79+
}
80+
81+
async fn measure_camera_selection(
82+
stage: &str,
83+
reuse: bool,
84+
id: &camera::DeviceOrModelID,
85+
) -> anyhow::Result<()> {
86+
let feed = camera::CameraFeed::spawn(camera::CameraFeed::default());
87+
let (sender, receiver) = flume::bounded(4);
88+
feed.ask(camera::AddSender(sender)).await?;
89+
for sample in 0..10 {
90+
while receiver.try_recv().is_ok() {}
91+
if sample > 0 {
92+
tokio::time::timeout(Duration::from_secs(5), receiver.recv_async()).await??;
93+
}
94+
let started = Instant::now();
95+
let reused = reuse
96+
&& feed
97+
.ask(camera::CheckInput {
98+
id: id.clone(),
99+
settings: None,
100+
})
101+
.await?;
102+
if !reused {
103+
feed.ask(camera::SetInput {
104+
id: id.clone(),
105+
settings: None,
106+
})
107+
.await?
108+
.await?;
109+
}
110+
let ready_ms = started.elapsed().as_secs_f64() * 1000.0;
111+
tokio::time::timeout(Duration::from_secs(5), receiver.recv_async()).await??;
112+
println!(
113+
"{}",
114+
serde_json::json!({
115+
"stage": stage,
116+
"sample": sample,
117+
"elapsedMs": ready_ms,
118+
"frameMs": started.elapsed().as_secs_f64() * 1000.0,
119+
"reused": reused,
120+
})
121+
);
122+
}
123+
feed.ask(camera::RemoveInput).await?;
124+
feed.stop_gracefully().await?;
125+
Ok(())
126+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::time::{Duration, Instant};
2+
3+
use gpui::{App, WindowHandle};
4+
5+
use crate::{
6+
app_windows,
7+
feeds::Feeds,
8+
main_window::{MainWindow, TargetType},
9+
};
10+
11+
pub fn run(main: WindowHandle<MainWindow>, cx: &mut App) {
12+
let Some(output) = std::env::var_os("CAP_PICKER_BENCHMARK_OUTPUT") else {
13+
return;
14+
};
15+
cx.spawn(async move |cx| {
16+
let delay = std::env::var("CAP_PICKER_BENCHMARK_DELAY_MS")
17+
.ok()
18+
.and_then(|delay| delay.parse().ok())
19+
.unwrap_or(2000);
20+
cx.background_executor()
21+
.timer(Duration::from_millis(delay))
22+
.await;
23+
let mut samples = Vec::new();
24+
for sample in 0..6 {
25+
let started = Instant::now();
26+
let kind = if sample % 2 == 0 {
27+
TargetType::Display
28+
} else {
29+
TargetType::Window
30+
};
31+
loop {
32+
let enumerating = main
33+
.update(cx, |view, _, _| view.is_enumerating_devices())
34+
.unwrap_or(true);
35+
if !enumerating || started.elapsed() > Duration::from_secs(15) {
36+
break;
37+
}
38+
cx.background_executor()
39+
.timer(Duration::from_millis(5))
40+
.await;
41+
}
42+
let enumeration_ms = started.elapsed().as_secs_f64() * 1000.0;
43+
let readiness = main.update(cx, |view, _, cx| {
44+
view.arm_overlay(kind, cx);
45+
Feeds::global(cx).read(cx).input_readiness()
46+
});
47+
let mut errors = Vec::new();
48+
if let Ok(readiness) = readiness {
49+
for input in [readiness.camera, readiness.microphone]
50+
.into_iter()
51+
.flatten()
52+
{
53+
if let Err(error) = input.await {
54+
errors.push(error);
55+
}
56+
}
57+
}
58+
let inputs_ms = started.elapsed().as_secs_f64() * 1000.0;
59+
let (sender, receiver) = flume::bounded(1);
60+
cx.update(|cx| {
61+
let overlay = cx
62+
.global::<app_windows::AppWindows>()
63+
.overlays
64+
.first()
65+
.map(|(_, window)| *window);
66+
if let Some(overlay) = overlay {
67+
let _ = overlay.update(cx, |_, window, cx| {
68+
cx.on_next_frame(window, move |_, window, cx| {
69+
cx.on_next_frame(window, move |_, _, _| {
70+
let _ = sender.send(());
71+
});
72+
window.refresh();
73+
});
74+
window.refresh();
75+
});
76+
}
77+
});
78+
let ready = matches!(
79+
futures_util::future::select(
80+
Box::pin(receiver.recv_async()),
81+
Box::pin(cx.background_executor().timer(Duration::from_secs(20))),
82+
)
83+
.await,
84+
futures_util::future::Either::Left((Ok(()), _))
85+
);
86+
samples.push(serde_json::json!({
87+
"sample": sample,
88+
"mode": if kind == TargetType::Display { "display" } else { "window" },
89+
"elapsedMs": started.elapsed().as_secs_f64() * 1000.0,
90+
"enumerationMs": enumeration_ms,
91+
"inputsMs": inputs_ms,
92+
"ready": ready && errors.is_empty(),
93+
"errors": errors,
94+
}));
95+
cx.update(app_windows::dismiss_target_overlays);
96+
cx.background_executor()
97+
.timer(Duration::from_millis(300))
98+
.await;
99+
}
100+
if let Err(error) = std::fs::write(output, serde_json::json!(samples).to_string()) {
101+
tracing::error!(%error, "Could not write picker benchmark results");
102+
}
103+
cx.update(|cx| cx.quit());
104+
})
105+
.detach();
106+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Desktop picker latency
2+
3+
The display and window pickers should reuse healthy selected devices. Restoring saved inputs must not open error dialogs for disconnected devices. Recording still waits for input setup, and missing selections remain saved for reconnection.
4+
5+
## Benchmarks
6+
7+
The ignored native benchmark runs real device discovery, microphone configuration lookups, camera setup, and camera frame delivery. It compares repeated camera setup with reuse and compares microphone lookup strategies in the same process. It opens the specified camera without creating recordings.
8+
9+
```sh
10+
cd apps/desktop-gpui
11+
CAP_PICKER_BENCH_CAMERA_ID=<device-id> cargo test -p cap-desktop-gpui --bin cap-gpui picker_benchmark::native_picker_latency -- --ignored --nocapture
12+
```
13+
14+
Both debug apps also support `CAP_PICKER_BENCHMARK_OUTPUT=<absolute-json-path>` and `CAP_PICKER_BENCHMARK_DELAY_MS=0`. They open and dismiss six alternating display/window pickers, write timings, and exit. The default delay is 2000 ms. Run them sequentially after compilation has finished.
15+
16+
- Tauri measures from the target-mode request through native window creation, frontend initialization, input restoration, and two animation frames with the recording button enabled.
17+
- GPUI includes pending device discovery and selected-input readiness, then waits for two rendered overlay frames.
18+
- Sample zero measures the first picker in a fresh process. Later samples measure reopening. These are picker timings, not total process launch times. They are not directly interchangeable across UI implementations.
19+
20+
Use isolated settings for these app runs. GPUI accepts `CAP_GPUI_APP_DATA_DIR`. Build Tauri with a separate `TAURI_CONFIG` identifier and seed only that identifier's application-data `store`. Set the recording mode to `studio` and select the test inputs in that store. Never point a benchmark at customer recordings or an active recording session.
21+
22+
For a Tauri build with bundled frontend assets and no development server:
23+
24+
```sh
25+
pnpm --filter @cap/desktop build
26+
TAURI_CONFIG='{"identifier":"so.cap.desktop.picker-benchmark","productName":"Cap Picker Benchmark","build":{"devUrl":null}}' cargo build -p cap-desktop --features tauri/custom-protocol
27+
```
28+
29+
## What changed
30+
31+
- Startup restoration bypasses the global mutation handler that displays native error dialogs. Explicit device selections retain their normal error handling.
32+
- Matching simultaneous Tauri requests share pending setup. A different device, configuration, or camera-window request supersedes it.
33+
- Repeated Tauri selections reuse a feed only when its identity and settings match and frames or audio samples arrived within 250 ms. Stalled feeds continue through setup and recovery.
34+
- Camera-only mode retains its normal camera setup path, which also handles its different preview routing.
35+
- GPUI startup lists microphone names without opening every device's audio configuration. Detailed configuration remains available when opening the microphone menu.
36+
- Tauri microphone details configure only the requested device, avoiding a complete configuration scan for each row.
37+
- The picker keeps its action label while readiness checks run.
38+
39+
## Local comparison, 2026-09-09
40+
41+
On macOS with five cameras, five microphones, one display, and twelve listed windows; the built-in camera and microphone were selected. The baseline used the original Rust selection/discovery paths from `0c403be8a57f7ba4de67140273708a392c8b4050`. Both variants used the same frontend, timing hooks, settings, and build profile. Runs were sequential with no compilation in progress.
42+
43+
| UI measurement | Before | After |
44+
| --- | ---: | ---: |
45+
| Tauri first display picker | 1,921 ms | 955 ms |
46+
| Tauri reopening, median of five alternating display/window openings | 815 ms | 65 ms |
47+
| GPUI first display picker | 1,046 ms | 679 ms |
48+
| GPUI reopening, median of five alternating display/window openings | 28 ms | 24 ms |
49+
50+
All 24 openings reached readiness. GPUI reopening was already fast; its improvement is in discovery during startup. Across the optimized Tauri run, native logs showed one microphone setup and one camera setup for all six openings.
51+
52+
The final native comparison measured median microphone metadata lookup time of 1,579 ms for repeated full scans versus 187 ms for named lookups. Repeated camera setup took 433 ms; checking an already-streaming matching camera took 0.02 ms, with subsequent frames received in every sample. That last number is the feed acknowledgment time, not UI latency or first-frame startup.
53+
54+
An additional Tauri run restored the unavailable `Shure MV7+` and `046d:08e5` selections. All six pickers reached readiness without blocking dialogs, and both selections remained persisted. Cold samples are individual observations, not percentile estimates.
55+
56+
## Validation boundaries
57+
58+
The local measurements exercise macOS hardware and debug Rust builds with bundled production frontend assets. They do not establish Windows hardware latency or release-package behavior. Keep cold samples separate from reopening samples, and preserve device counts and first-frame checks when comparing runs.

0 commit comments

Comments
 (0)