Skip to content

Commit 09696b8

Browse files
authored
perf(android): improve emulator stream performance
Improve Android emulator WebRTC smoothness by preferring gRPC screenshots, sharing one source per UDID/codec, defaulting Android local streams to software H.264 at a 960px long edge, and exposing Android encoder/source metrics.
1 parent c18dcee commit 09696b8

17 files changed

Lines changed: 1651 additions & 113 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ Use `simdeck service reset` only when you want to rotate the service token and
9090
restart the LaunchAgent.
9191
The service uses port 4310 unless you pass `-p` or `--port`, or set a default
9292
in `~/.simdeck/config.json`.
93-
SimDeck-owned Android emulator boots use host GPU rendering by default; use
93+
SimDeck-owned Android emulator boots use host GPU rendering by default; on macOS
94+
they start the emulator with `-qt-hide-window` so the Qt renderer stays active
95+
without showing the native emulator window. Managed boots also open the emulator
96+
gRPC endpoint for event-driven Android video capture, with shared-video polling
97+
kept as a fallback. Use
9498
`simdeck service restart --android-gpu auto` or
9599
`--android-gpu swiftshader_indirect` only as a machine-specific fallback.
96100
Managed Android boots also add `-no-audio` by default. Set

docs/api/health.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,19 @@ GET /api/metrics
6161

6262
Useful fields:
6363

64-
| Field | What to look for |
65-
| ---------------------------------- | -------------------------------------------- |
66-
| `latest_first_frame_ms` | First-frame startup time |
67-
| `frames_dropped_server` | Server dropping stale frames to stay current |
68-
| `keyframe_requests` | Stream refresh or recovery activity |
69-
| `stream_pipeline_resets` | Encoder resets after all viewers disconnect |
70-
| `latest_accessibility_snapshot_ms` | Most recent native accessibility duration |
71-
| `max_accessibility_snapshot_ms` | Slowest native accessibility duration |
72-
| `accessibility_snapshot_timeouts` | Native accessibility calls that timed out |
73-
| `active_streams` | Open browser streams |
74-
| `encoders[].encoder.overloadState` | `nominal`, `strained`, or `overloaded` |
75-
| `client_streams` | Recent browser decoder and render reports |
64+
| Field | What to look for |
65+
| ---------------------------------- | ---------------------------------------------------------- |
66+
| `latest_first_frame_ms` | First-frame startup time |
67+
| `frames_dropped_server` | Server dropping stale frames to stay current |
68+
| `keyframe_requests` | Stream refresh or recovery activity |
69+
| `stream_pipeline_resets` | Encoder resets after all viewers disconnect |
70+
| `latest_accessibility_snapshot_ms` | Most recent native accessibility duration |
71+
| `max_accessibility_snapshot_ms` | Slowest native accessibility duration |
72+
| `accessibility_snapshot_timeouts` | Native accessibility calls that timed out |
73+
| `active_streams` | Open browser streams |
74+
| `encoders[].encoder.overloadState` | `nominal`, `strained`, or `overloaded` |
75+
| `androidEncoders[]` | Android video source kind, `videoCodec`, and encoder stats |
76+
| `client_streams` | Recent browser decoder and render reports |
7677

7778
If `overloadState` is `overloaded` or dropped frames keep increasing, lower stream quality or restart with software encoding:
7879

docs/guide/video.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Video and streaming
22

3-
SimDeck streams live device video to the browser. Local sessions default to full-resolution 60 fps. Remote or constrained sessions can trade detail for lower CPU and latency.
3+
SimDeck streams live device video to the browser. Local iOS sessions default to full-resolution 60 fps. Android emulator browser sessions default to software H.264 with the `balanced` profile capped at 960px on the long edge because the host reads and encodes emulator RGBA frames from the emulator gRPC screenshot stream. Remote or constrained sessions can trade detail for lower CPU and latency.
44

55
iOS simulator H.264 uses VideoToolbox for hardware encoding and x264 for software encoding.
6-
Android emulator H.264 uses the emulator `-share-vid` display surface. SimDeck reads BGRA frames from the `videmulator<console-port>` shared memory region and encodes them on the Mac, so normal Android live video stays on the native shared display path.
6+
Android emulator H.264 uses the emulator gRPC `streamScreenshot` API when SimDeck owns the boot. SimDeck receives raw RGBA frames, pads odd dimensions for H.264, and encodes them on the Mac. If the gRPC endpoint is unavailable, SimDeck falls back to the emulator `-share-vid` display surface and reads BGRA frames from the `videmulator<console-port>` shared memory region.
77

88
## When encoding runs
99

@@ -13,7 +13,10 @@ shared refresh pump active while frame subscribers exist.
1313
For Android, SimDeck starts emulators with `-share-vid`, maps the shared display
1414
region, and feeds changed BGRA frames into the native host H.264 encoder.
1515
SimDeck-owned Android boots also default to `-gpu host`, matching the native
16-
emulator app's accelerated renderer while staying in headless shared-video mode.
16+
emulator app's accelerated renderer while staying hidden. On macOS, managed
17+
boots use `-qt-hide-window` instead of `-no-window` so the Qt render loop stays
18+
active without showing the emulator window. Managed Android boots also reserve a
19+
per-AVD `-grpc` port for event-driven screenshot streaming.
1720

1821
The browser reports whether the page and stream canvas are foreground. When all
1922
known viewers are hidden or the last frame subscriber disconnects, the native
@@ -38,17 +41,17 @@ simdeck service restart --stream-quality ci-software
3841

3942
Common profiles:
4043

41-
| Profile | Use it for |
42-
| ------------- | --------------------------------------- |
43-
| `full` | Default local full-resolution 60 fps |
44-
| `smooth` | Full-size 60 fps with lower bitrate |
45-
| `balanced` | Good local quality with less bandwidth |
46-
| `economy` | Remote browser or busy machine |
47-
| `low` | Slower Wi-Fi or shared hosts |
48-
| `tiny` | Pull request previews and low bandwidth |
49-
| `ci-software` | Virtualized CI Macs |
50-
51-
The browser also has stream controls for transport, resolution, FPS, and refresh.
44+
| Profile | Use it for |
45+
| ------------- | ----------------------------------------------------- |
46+
| `full` | Default local full-resolution 60 fps |
47+
| `smooth` | 60 fps with lower bitrate; Android caps this at 960px |
48+
| `balanced` | Good local quality with less bandwidth |
49+
| `economy` | Remote browser or busy machine |
50+
| `low` | Slower Wi-Fi or shared hosts |
51+
| `tiny` | Pull request previews and low bandwidth |
52+
| `ci-software` | Virtualized CI Macs |
53+
54+
The browser also has stream controls for transport, resolution, FPS, encoder mode, and refresh. Choosing `Full res` for an Android emulator keeps the native shared-video dimensions, which can be expensive on tall phone profiles. Set `SIMDECK_ANDROID_VIDEO_CODEC=hardware` or choose Hardware in the browser when you explicitly want VideoToolbox for Android.
5255

5356
## Pick an Android GPU mode
5457

packages/client/src/app/AppShell.tsx

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ const LOCAL_STREAM_DEFAULTS: StreamConfig = {
150150
fps: 60,
151151
quality: "full",
152152
};
153+
const ANDROID_LOCAL_STREAM_DEFAULTS: StreamConfig = {
154+
encoder: "software",
155+
fps: 60,
156+
quality: "balanced",
157+
};
153158
const REMOTE_STREAM_DEFAULTS: StreamConfig = {
154159
encoder: "software",
155160
fps: 30,
@@ -313,6 +318,34 @@ function defaultStreamConfigForTransport(
313318
return base;
314319
}
315320

321+
function streamConfigForSelectedSimulator(
322+
config: StreamConfig,
323+
simulator: SimulatorMetadata | null,
324+
remote: boolean,
325+
userTouched: boolean,
326+
): StreamConfig {
327+
if (remote || userTouched || !isAndroidSimulator(simulator)) {
328+
return config;
329+
}
330+
const quality =
331+
config.quality === "full" || config.quality === "quality"
332+
? ANDROID_LOCAL_STREAM_DEFAULTS.quality
333+
: config.quality;
334+
const encoder =
335+
config.encoder === "auto"
336+
? ANDROID_LOCAL_STREAM_DEFAULTS.encoder
337+
: config.encoder;
338+
if (quality === config.quality && encoder === config.encoder) {
339+
return config;
340+
}
341+
return {
342+
...config,
343+
encoder,
344+
maxEdge: undefined,
345+
quality,
346+
};
347+
}
348+
316349
function shouldForceInitialFitMode(): boolean {
317350
if (typeof window === "undefined") {
318351
return false;
@@ -854,6 +887,37 @@ export function AppShell({
854887
};
855888
}, [remoteStream, syncStreamConfig]);
856889

890+
const effectiveStreamConfig = useMemo(
891+
() =>
892+
streamConfigForSelectedSimulator(
893+
streamConfig,
894+
selectedSimulator,
895+
remoteStream,
896+
streamConfigUserTouchedRef.current,
897+
),
898+
[remoteStream, selectedSimulator, streamConfig],
899+
);
900+
901+
useEffect(() => {
902+
if (streamConfigUserTouchedRef.current) {
903+
return;
904+
}
905+
setStreamConfig((current) => {
906+
const next = streamConfigForSelectedSimulator(
907+
current,
908+
selectedSimulator,
909+
remoteStream,
910+
false,
911+
);
912+
return streamConfigsEqual(current, next) ? current : next;
913+
});
914+
}, [
915+
remoteStream,
916+
selectedSimulator?.platform,
917+
selectedSimulator?.udid,
918+
streamConfig.quality,
919+
]);
920+
857921
const {
858922
deviceNaturalSize,
859923
error: streamError,
@@ -869,7 +933,7 @@ export function AppShell({
869933
paused: !streamConfigReady,
870934
remote: remoteStream,
871935
simulator: selectedSimulator,
872-
streamConfig,
936+
streamConfig: effectiveStreamConfig,
873937
streamConfigApplyKey,
874938
streamTransport,
875939
});
@@ -886,29 +950,42 @@ export function AppShell({
886950
void refreshRef.current();
887951
}, [streamStatus.error, streamStatus.state, syncStreamConfig]);
888952

889-
const updateStreamEncoder = useCallback((encoder: StreamEncoder) => {
890-
streamConfigUserTouchedRef.current = true;
891-
streamConfigUserChangeAtRef.current = Date.now();
892-
setStreamConfigReady(true);
893-
setStreamConfigApplyKey((current) => current + 1);
894-
setStreamConfig((current) => ({ ...current, encoder }));
895-
}, []);
953+
const updateStreamEncoder = useCallback(
954+
(encoder: StreamEncoder) => {
955+
streamConfigUserTouchedRef.current = true;
956+
streamConfigUserChangeAtRef.current = Date.now();
957+
setStreamConfigReady(true);
958+
setStreamConfigApplyKey((current) => current + 1);
959+
setStreamConfig({ ...effectiveStreamConfig, encoder });
960+
},
961+
[effectiveStreamConfig],
962+
);
896963

897-
const updateStreamFps = useCallback((fps: StreamFps) => {
898-
streamConfigUserTouchedRef.current = true;
899-
streamConfigUserChangeAtRef.current = Date.now();
900-
setStreamConfigReady(true);
901-
setStreamConfigApplyKey((current) => current + 1);
902-
setStreamConfig((current) => ({ ...current, fps }));
903-
}, []);
964+
const updateStreamFps = useCallback(
965+
(fps: StreamFps) => {
966+
streamConfigUserTouchedRef.current = true;
967+
streamConfigUserChangeAtRef.current = Date.now();
968+
setStreamConfigReady(true);
969+
setStreamConfigApplyKey((current) => current + 1);
970+
setStreamConfig({ ...effectiveStreamConfig, fps });
971+
},
972+
[effectiveStreamConfig],
973+
);
904974

905-
const updateStreamQuality = useCallback((quality: StreamQualityPreset) => {
906-
streamConfigUserTouchedRef.current = true;
907-
streamConfigUserChangeAtRef.current = Date.now();
908-
setStreamConfigReady(true);
909-
setStreamConfigApplyKey((current) => current + 1);
910-
setStreamConfig((current) => ({ ...current, maxEdge: undefined, quality }));
911-
}, []);
975+
const updateStreamQuality = useCallback(
976+
(quality: StreamQualityPreset) => {
977+
streamConfigUserTouchedRef.current = true;
978+
streamConfigUserChangeAtRef.current = Date.now();
979+
setStreamConfigReady(true);
980+
setStreamConfigApplyKey((current) => current + 1);
981+
setStreamConfig({
982+
...effectiveStreamConfig,
983+
maxEdge: undefined,
984+
quality,
985+
});
986+
},
987+
[effectiveStreamConfig],
988+
);
912989

913990
const updateStreamTransport = useCallback((transport: StreamTransport) => {
914991
setStreamTransport(transport);
@@ -3747,7 +3824,7 @@ export function AppShell({
37473824
!selectedSimulator.isBooted &&
37483825
!selectedSimulatorTransitionKind,
37493826
)}
3750-
streamConfig={streamConfig}
3827+
streamConfig={effectiveStreamConfig}
37513828
streamTransport={streamTransport}
37523829
deviceChromeAvailable={selectedSupportsChrome}
37533830
deviceChromeVisible={deviceChromeToggleActive}

0 commit comments

Comments
 (0)