Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions apps/desktop-gpui/src/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ impl DeviceSnapshot {
pub fn enumerate() -> Self {
Self {
cameras: list_cameras(),
microphones: list_microphones(),
microphones: list_microphone_names(),
displays: list_displays(),
windows: list_windows(),
}
Expand Down Expand Up @@ -553,23 +553,24 @@ pub fn camera_formats(device_id: &str) -> Result<Vec<CameraFormat>, String> {
.formats)
}

/// Mirrors `MicrophoneFeed::list_with_settings`: the default input device is
/// inserted first so it heads the list, then every other input device is
/// appended, deduped by name.
fn list_microphone_names() -> Vec<MicrophoneOption> {
cap_recording::feeds::microphone::MicrophoneFeed::list_names()
.into_iter()
.map(|name| MicrophoneOption {
name,
sample_rate: None,
channels: None,
})
.collect()
}

fn list_microphones() -> Vec<MicrophoneOption> {
// CPAL's configuration lookup opens an input AudioUnit and can prompt for consent.
#[cfg(target_os = "macos")]
if !crate::permissions::check_raw().is_some_and(|permissions| {
permissions.microphone == crate::permissions::MediaAuthorization::Authorized
}) {
return cap_recording::feeds::microphone::MicrophoneFeed::list_names()
.into_iter()
.map(|name| MicrophoneOption {
name,
sample_rate: None,
channels: None,
})
.collect();
return list_microphone_names();
}

let host = cpal::default_host();
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop-gpui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ mod onboarding_audio;
mod onboarding_window;
mod permissions;
mod permissions_ui;
#[cfg(test)]
mod picker_benchmark;
#[cfg(debug_assertions)]
mod picker_ui_benchmark;
mod platform;
mod presets;
mod recording;
Expand Down Expand Up @@ -644,6 +648,8 @@ fn main() {
// the primary display and record for N seconds (or capture once). The
// end-to-end check drives the recorder this way because unprivileged
// synthetic clicks are dropped.
#[cfg(debug_assertions)]
picker_ui_benchmark::run(window_handle, cx);
if let Ok(auto) = std::env::var("CAP_GPUI_AUTO_RECORD")
&& let Some((mode, secs)) = parse_auto_record(&auto)
{
Expand Down
126 changes: 126 additions & 0 deletions apps/desktop-gpui/src/picker_benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
use std::time::{Duration, Instant};

use cap_recording::feeds::{camera, microphone::MicrophoneFeed};
use kameo::Actor;

use crate::devices::{DeviceSnapshot, InputSnapshot, TargetSnapshot};

fn measure<T>(stage: &str, sample: usize, run: impl FnOnce() -> T) -> T {
let started = Instant::now();
let result = run();
println!(
"{}",
serde_json::json!({
"stage": stage,
"sample": sample,
"elapsedMs": started.elapsed().as_secs_f64() * 1000.0,
})
);
result
}

#[tokio::test]
#[ignore = "opens local capture devices to measure native picker dependencies"]
async fn native_picker_latency() -> anyhow::Result<()> {
for sample in 0..10 {
let snapshot = measure("gpui_startup_discovery", sample, DeviceSnapshot::enumerate);
println!(
"{}",
serde_json::json!({
"sample": sample,
"cameras": snapshot.cameras.len(),
"microphones": snapshot.microphones.len(),
"displays": snapshot.displays.len(),
"windows": snapshot.windows.len(),
})
);
measure("camera_picker_discovery", sample, || {
InputSnapshot::cameras(&[])
});
measure(
"microphone_picker_discovery",
sample,
InputSnapshot::microphones,
);
measure("tauri_device_inventory", sample, || {
(
cap_camera::list_cameras().collect::<Vec<_>>(),
MicrophoneFeed::list_names(),
)
});
measure("target_discovery", sample, TargetSnapshot::enumerate);
let names = MicrophoneFeed::list_names();
measure("microphone_metadata_all_devices", sample, || {
names
.iter()
.map(|name| MicrophoneFeed::list().swap_remove(name))
.collect::<Vec<_>>()
});
measure("microphone_metadata_named_devices", sample, || {
names
.iter()
.map(|name| MicrophoneFeed::device_with_settings(name, None))
.collect::<Vec<_>>()
});
}

let device_id = std::env::var("CAP_PICKER_BENCH_CAMERA_ID")?;
let camera_info = cap_camera::list_cameras()
.find(|camera| camera.device_id() == device_id)
.ok_or_else(|| anyhow::anyhow!("Benchmark camera is unavailable"))?;
let id = camera::DeviceOrModelID::from_info(&camera_info);
for (stage, reuse) in [
("camera_repeated_selection", false),
("camera_reused_selection", true),
] {
measure_camera_selection(stage, reuse, &id).await?;
}
Ok(())
}

async fn measure_camera_selection(
stage: &str,
reuse: bool,
id: &camera::DeviceOrModelID,
) -> anyhow::Result<()> {
let feed = camera::CameraFeed::spawn(camera::CameraFeed::default());
let (sender, receiver) = flume::bounded(4);
feed.ask(camera::AddSender(sender)).await?;
for sample in 0..10 {
while receiver.try_recv().is_ok() {}
if sample > 0 {
tokio::time::timeout(Duration::from_secs(5), receiver.recv_async()).await??;
}
let started = Instant::now();
let reused = reuse
&& feed
.ask(camera::CheckInput {
id: id.clone(),
settings: None,
})
.await?;
if !reused {
feed.ask(camera::SetInput {
id: id.clone(),
settings: None,
})
.await?
.await?;
}
let ready_ms = started.elapsed().as_secs_f64() * 1000.0;
tokio::time::timeout(Duration::from_secs(5), receiver.recv_async()).await??;
println!(
"{}",
serde_json::json!({
"stage": stage,
"sample": sample,
"elapsedMs": ready_ms,
"frameMs": started.elapsed().as_secs_f64() * 1000.0,
"reused": reused,
})
);
}
feed.ask(camera::RemoveInput).await?;
feed.stop_gracefully().await?;
Ok(())
}
106 changes: 106 additions & 0 deletions apps/desktop-gpui/src/picker_ui_benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use std::time::{Duration, Instant};

use gpui::{App, WindowHandle};

use crate::{
app_windows,
feeds::Feeds,
main_window::{MainWindow, TargetType},
};

pub fn run(main: WindowHandle<MainWindow>, cx: &mut App) {
let Some(output) = std::env::var_os("CAP_PICKER_BENCHMARK_OUTPUT") else {
return;
};
cx.spawn(async move |cx| {
let delay = std::env::var("CAP_PICKER_BENCHMARK_DELAY_MS")
.ok()
.and_then(|delay| delay.parse().ok())
.unwrap_or(2000);
cx.background_executor()
.timer(Duration::from_millis(delay))
.await;
let mut samples = Vec::new();
for sample in 0..6 {
let started = Instant::now();
let kind = if sample % 2 == 0 {
TargetType::Display
} else {
TargetType::Window
};
loop {
let enumerating = main
.update(cx, |view, _, _| view.is_enumerating_devices())
.unwrap_or(true);
if !enumerating || started.elapsed() > Duration::from_secs(15) {
break;
}
cx.background_executor()
.timer(Duration::from_millis(5))
.await;
}
let enumeration_ms = started.elapsed().as_secs_f64() * 1000.0;
let readiness = main.update(cx, |view, _, cx| {
view.arm_overlay(kind, cx);
Feeds::global(cx).read(cx).input_readiness()
});
let mut errors = Vec::new();
if let Ok(readiness) = readiness {
for input in [readiness.camera, readiness.microphone]
.into_iter()
.flatten()
{
if let Err(error) = input.await {
errors.push(error);
}
}
}
let inputs_ms = started.elapsed().as_secs_f64() * 1000.0;
let (sender, receiver) = flume::bounded(1);
cx.update(|cx| {
let overlay = cx
.global::<app_windows::AppWindows>()
.overlays
.first()
.map(|(_, window)| *window);
if let Some(overlay) = overlay {
let _ = overlay.update(cx, |_, window, cx| {
cx.on_next_frame(window, move |_, window, cx| {
cx.on_next_frame(window, move |_, _, _| {
let _ = sender.send(());
});
window.refresh();
});
window.refresh();
});
}
});
let ready = matches!(
futures_util::future::select(
Box::pin(receiver.recv_async()),
Box::pin(cx.background_executor().timer(Duration::from_secs(20))),
)
.await,
futures_util::future::Either::Left((Ok(()), _))
);
samples.push(serde_json::json!({
"sample": sample,
"mode": if kind == TargetType::Display { "display" } else { "window" },
"elapsedMs": started.elapsed().as_secs_f64() * 1000.0,
"enumerationMs": enumeration_ms,
"inputsMs": inputs_ms,
"ready": ready && errors.is_empty(),
"errors": errors,
}));
cx.update(app_windows::dismiss_target_overlays);
cx.background_executor()
.timer(Duration::from_millis(300))
.await;
}
if let Err(error) = std::fs::write(output, serde_json::json!(samples).to_string()) {
tracing::error!(%error, "Could not write picker benchmark results");
}
cx.update(|cx| cx.quit());
})
.detach();
}
58 changes: 58 additions & 0 deletions apps/desktop/scripts/desktop-picker-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Desktop picker latency

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.

## Benchmarks

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.

```sh
cd apps/desktop-gpui
CAP_PICKER_BENCH_CAMERA_ID=<device-id> cargo test -p cap-desktop-gpui --bin cap-gpui picker_benchmark::native_picker_latency -- --ignored --nocapture
```

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.

- Tauri measures from the target-mode request through native window creation, frontend initialization, input restoration, and two animation frames with the recording button enabled.
- GPUI includes pending device discovery and selected-input readiness, then waits for two rendered overlay frames.
- 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.

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.

For a Tauri build with bundled frontend assets and no development server:

```sh
pnpm --filter @cap/desktop build
TAURI_CONFIG='{"identifier":"so.cap.desktop.picker-benchmark","productName":"Cap Picker Benchmark","build":{"devUrl":null}}' cargo build -p cap-desktop --features tauri/custom-protocol
```

## What changed

- Startup restoration bypasses the global mutation handler that displays native error dialogs. Explicit device selections retain their normal error handling.
- Matching simultaneous Tauri requests share pending setup. A different device, configuration, or camera-window request supersedes it.
- 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.
- Camera-only mode retains its normal camera setup path, which also handles its different preview routing.
- GPUI startup lists microphone names without opening every device's audio configuration. Detailed configuration remains available when opening the microphone menu.
- Tauri microphone details configure only the requested device, avoiding a complete configuration scan for each row.
- The picker keeps its action label while readiness checks run.

## Local comparison, 2026-09-09

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.

| UI measurement | Before | After |
| --- | ---: | ---: |
| Tauri first display picker | 1,921 ms | 955 ms |
| Tauri reopening, median of five alternating display/window openings | 815 ms | 65 ms |
| GPUI first display picker | 1,046 ms | 679 ms |
| GPUI reopening, median of five alternating display/window openings | 28 ms | 24 ms |

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.

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.

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.

## Validation boundaries

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