fix(android): the presenter paces to the panel's real grid, not the app's down-rated vsync
On-glass (A024, 120 Hz panel, 120 fps session) the first presenter build released only 60/s and the HUD display term hit 40 ms. Root cause, in two layers: Android down-rates a game-category uid's choreographer stream to 60 Hz (frame-rate categories / game default frame rate), and under that override Display.getRefreshRate REPORTS THE OVERRIDE — so the presenter's panel grid read 16.67 ms on an 8.33 ms panel and the subdivision became a no-op, pacing the video at half rate and dropping every other frame. Three-part fix, verified live on the same device: - Kotlin passes the panel rate from the supported-modes TABLE (MainActivity.streamPanelFps — the mode list is not override-filtered) instead of display.refreshRate, and votes the app's render rate up via View.requestedFrameRate = streamHz (API 35+) while streaming. - The native vsync clock LEARNS the panel period from observed timeline spacing (downward-only: the finest spacing SurfaceFlinger ever reports is the true grid) and next_target subdivides the reported timeline onto it — full-rate on down-rated devices, a no-op where callbacks match the panel. - OnFrameRendered display/latch samples get the e2e clamp (0..10 s): a vendor's first callbacks can carry a garbage system_nano (observed: an epoch-sized latch max) that would poison every max it lands in. pf.present gained panelMs next to vsyncMs, and a one-shot cadence diagnostic logs Δ/timelines/spacing/panel on the third tick. After: released=120 displays=120 paced=0, pace p50 <1 ms, latch p50 ~16 ms idle / ~22 ms under game load (2 refresh intervals at 8.33 — the same composited-pipeline law the Apple client measured), HUD display ~17-26 ms vs 40 before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -471,9 +471,26 @@ class MainActivity : ComponentActivity() {
|
||||
setConsoleHighRefreshRate(false)
|
||||
return
|
||||
}
|
||||
val target = streamModeFor(hz) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
|
||||
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
|
||||
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
|
||||
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
|
||||
* supported-modes list is not override-filtered. `0` when unresolvable.
|
||||
*/
|
||||
fun streamPanelFps(hz: Int): Int =
|
||||
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
|
||||
|
||||
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
|
||||
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
|
||||
if (hz <= 0) return null
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
val current = disp?.mode ?: return
|
||||
val current = disp?.mode ?: return null
|
||||
val sameRes = disp.supportedModes.filter {
|
||||
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
|
||||
}
|
||||
@@ -481,7 +498,7 @@ class MainActivity : ComponentActivity() {
|
||||
val k = (rate / hz).toInt()
|
||||
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
|
||||
}
|
||||
val target = sameRes.minWithOrNull(
|
||||
return sameRes.minWithOrNull(
|
||||
compareBy(
|
||||
{
|
||||
when {
|
||||
@@ -492,8 +509,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
|
||||
),
|
||||
) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
|
||||
@@ -63,6 +63,7 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// requested.
|
||||
val composeView = androidx.compose.ui.platform.LocalView.current
|
||||
val window = activity?.window
|
||||
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
|
||||
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
|
||||
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
|
||||
val controller = remember(window) {
|
||||
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
|
||||
}
|
||||
@@ -343,7 +347,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
|
||||
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
|
||||
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
|
||||
val streamHz = NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0
|
||||
if (isTv) {
|
||||
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
|
||||
} else {
|
||||
@@ -355,6 +358,15 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
|
||||
}
|
||||
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
|
||||
// panel, but the platform separately down-rates a quiet app's choreographer stream
|
||||
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
|
||||
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
|
||||
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
|
||||
// that braces. Reset to no-preference on the way out.
|
||||
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
|
||||
composeView.requestedFrameRate = streamHz.toFloat()
|
||||
}
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
|
||||
// index via the router; poll threads stopped + joined before the router is released and the
|
||||
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
|
||||
@@ -487,6 +499,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 35) {
|
||||
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
|
||||
}
|
||||
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
|
||||
window?.setSoftInputMode(priorSoftInput)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
|
||||
@@ -586,6 +601,12 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
isTv,
|
||||
initialSettings.presentPriorityWire(),
|
||||
initialSettings.smoothBuffer,
|
||||
// The panel's own refresh — from the mode TABLE (streamPanelFps),
|
||||
// because display.refreshRate reports a per-uid override, not the
|
||||
// panel. Fallback: the (possibly lying) live rate.
|
||||
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
|
||||
if (micWanted) NativeBridge.nativeStartMic(handle)
|
||||
|
||||
@@ -223,6 +223,9 @@ object NativeBridge {
|
||||
isTv: Boolean,
|
||||
presentPriority: Int,
|
||||
smoothBuffer: Int,
|
||||
/** The display mode's own refresh rate (0 = unknown) — the latch grid the presenter
|
||||
* subdivides onto when the platform down-rates the app's choreographer stream. */
|
||||
panelFps: Int,
|
||||
)
|
||||
|
||||
/** Stop + join the decode thread without closing the session. No-op on `0`. */
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(super) fn run_async(
|
||||
is_tv,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
@@ -387,9 +388,12 @@ pub(super) fn run_async(
|
||||
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
|
||||
if had_output && vsync.is_none() {
|
||||
if let Some(tx) = vsync_tx.take() {
|
||||
vsync = VsyncClock::start(Box::new(move || {
|
||||
let _ = tx.send(DecodeEvent::Vsync);
|
||||
}));
|
||||
vsync = VsyncClock::start(
|
||||
panel_hz,
|
||||
Box::new(move || {
|
||||
let _ = tx.send(DecodeEvent::Vsync);
|
||||
}),
|
||||
);
|
||||
if vsync.is_none() {
|
||||
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
|
||||
}
|
||||
|
||||
@@ -178,8 +178,12 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
}
|
||||
}
|
||||
}
|
||||
let display_us = paired.map(|(d, _)| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
let latch_us = paired.map(|(_, r)| ((displayed_ns - r).max(0) / 1000) as u64);
|
||||
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
|
||||
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
|
||||
// window), and one such sample would poison every max/percentile it lands in.
|
||||
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
|
||||
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
|
||||
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
|
||||
// Always-on half: the presenter's pf-present line reads these with the HUD off.
|
||||
t.meter.note_latch(latch_us);
|
||||
if !t.stats.enabled() {
|
||||
|
||||
@@ -115,6 +115,10 @@ pub(crate) struct DecodeOptions {
|
||||
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
||||
/// Only meaningful with `present_priority` = smooth.
|
||||
pub smooth_buffer: i32,
|
||||
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
|
||||
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
|
||||
/// stream is down-rated below the panel (see `vsync.rs`).
|
||||
pub panel_hz: i32,
|
||||
}
|
||||
|
||||
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
||||
|
||||
@@ -340,10 +340,13 @@ impl Presenter {
|
||||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||||
let panel_ms = clock
|
||||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||||
.unwrap_or(0.0);
|
||||
log::info!(
|
||||
target: "pf.present",
|
||||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2}",
|
||||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2} panelMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
@@ -355,6 +358,7 @@ impl Presenter {
|
||||
latch_p50,
|
||||
latch_max,
|
||||
period_ms,
|
||||
panel_ms,
|
||||
);
|
||||
self.released = 0;
|
||||
self.paced_drops = 0;
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(super) fn run_sync(
|
||||
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
|
||||
present_priority: _,
|
||||
smooth_buffer: _,
|
||||
panel_hz: _,
|
||||
} = opts;
|
||||
boost_thread_priority();
|
||||
let mode = client.mode();
|
||||
|
||||
@@ -51,7 +51,18 @@ pub(super) struct VsyncShared {
|
||||
/// The latest vsync callback's frame time (0 = no callback yet).
|
||||
last_vsync_ns: AtomicI64,
|
||||
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
|
||||
///
|
||||
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
|
||||
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
|
||||
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
|
||||
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
|
||||
/// [`Self::next_target`].
|
||||
period_ns: AtomicI64,
|
||||
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
|
||||
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
|
||||
panel_period_ns: AtomicI64,
|
||||
/// Callback count, for the one-shot cadence diagnostic log.
|
||||
ticks: std::sync::atomic::AtomicU32,
|
||||
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
|
||||
timelines: Mutex<Vec<FrameTimeline>>,
|
||||
}
|
||||
@@ -62,33 +73,60 @@ impl VsyncShared {
|
||||
self.period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
|
||||
pub(super) fn panel_period_ns(&self) -> i64 {
|
||||
self.panel_period_ns.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
|
||||
/// deadline is still `margin` away, extrapolated forward by whole periods once the stored
|
||||
/// set has aged out (timelines refresh once per vsync callback; a frame can decode anywhere
|
||||
/// inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
|
||||
///
|
||||
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
|
||||
/// at the app's assigned render rate, but the panel latches at its own — when the app is
|
||||
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
|
||||
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
|
||||
/// by whole panel periods (while its deadline still clears the margin) restores the true
|
||||
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
|
||||
/// a no-op.
|
||||
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
|
||||
let g = self
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for t in g.iter() {
|
||||
if t.deadline_ns > now_ns + margin_ns {
|
||||
return Some(*t);
|
||||
let mut t = {
|
||||
let g = self
|
||||
.timelines
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let found = g
|
||||
.iter()
|
||||
.find(|t| t.deadline_ns > now_ns + margin_ns)
|
||||
.copied();
|
||||
match found {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let last = g.last().copied()?;
|
||||
let period = self.period_ns();
|
||||
if period <= 0 {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole
|
||||
// periods until its deadline clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.deadline_ns);
|
||||
let k = behind / period + 1;
|
||||
FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
deadline_ns: last.deadline_ns + k * period,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let panel = self.panel_period_ns.load(Ordering::Relaxed);
|
||||
if panel > 0 {
|
||||
while t.deadline_ns - panel > now_ns + margin_ns {
|
||||
t.deadline_ns -= panel;
|
||||
t.expected_present_ns -= panel;
|
||||
}
|
||||
}
|
||||
let last = g.last().copied()?;
|
||||
let period = self.period_ns();
|
||||
if period <= 0 {
|
||||
return None;
|
||||
}
|
||||
// All stored timelines have passed — step the last one forward whole periods until its
|
||||
// deadline clears `now + margin` again.
|
||||
let behind = (now_ns + margin_ns).saturating_sub(last.deadline_ns);
|
||||
let k = behind / period + 1;
|
||||
Some(FrameTimeline {
|
||||
expected_present_ns: last.expected_present_ns + k * period,
|
||||
deadline_ns: last.deadline_ns + k * period,
|
||||
})
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +232,43 @@ impl CallbackCtx {
|
||||
.shared
|
||||
.last_vsync_ns
|
||||
.swap(frame_time_ns, Ordering::Relaxed);
|
||||
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
|
||||
// spacing ever observed is the panel's true period — trustworthy where the configured
|
||||
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
|
||||
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
|
||||
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
|
||||
// valid, widening on a later down-rated window never is.
|
||||
if timelines.len() >= 2 {
|
||||
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
|
||||
if (2_000_000..=42_000_000).contains(&spacing) {
|
||||
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
|
||||
if cur == 0 || spacing < cur - 200_000 {
|
||||
self.shared
|
||||
.panel_period_ns
|
||||
.store(spacing, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
|
||||
// panel period is exactly the down-rating question, and this line answers it on-glass.
|
||||
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
|
||||
let spacing = if timelines.len() >= 2 {
|
||||
timelines[1].expected_present_ns - timelines[0].expected_present_ns
|
||||
} else {
|
||||
0
|
||||
};
|
||||
log::info!(
|
||||
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
|
||||
if prev > 0 {
|
||||
(frame_time_ns - prev) as f64 / 1e6
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
timelines.len(),
|
||||
spacing as f64 / 1e6,
|
||||
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||
);
|
||||
}
|
||||
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
|
||||
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
|
||||
let mut period = 0i64;
|
||||
@@ -289,15 +364,23 @@ pub(super) struct VsyncClock {
|
||||
impl VsyncClock {
|
||||
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
|
||||
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
|
||||
/// `None` when the platform surface is missing (very old device) — the presenter then runs
|
||||
/// clock-less (ASAP targets, predicted-latch budget).
|
||||
pub(super) fn start(on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
|
||||
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
|
||||
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
|
||||
/// budget).
|
||||
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
|
||||
let api = ChoreoApi::resolve()?;
|
||||
let timelines_live = api.post_vsync.is_some();
|
||||
let shared = Arc::new(VsyncShared {
|
||||
stop: AtomicBool::new(false),
|
||||
last_vsync_ns: AtomicI64::new(0),
|
||||
period_ns: AtomicI64::new(0),
|
||||
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
ticks: std::sync::atomic::AtomicU32::new(0),
|
||||
timelines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let thread_shared = shared.clone();
|
||||
|
||||
@@ -31,6 +31,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
is_tv: jboolean,
|
||||
present_priority: jni::sys::jint,
|
||||
smooth_buffer: jni::sys::jint,
|
||||
panel_fps: jni::sys::jint,
|
||||
) {
|
||||
use super::VideoThread;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -76,6 +77,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
is_tv: is_tv != 0,
|
||||
present_priority,
|
||||
smooth_buffer,
|
||||
panel_hz: panel_fps,
|
||||
};
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-decode".into())
|
||||
|
||||
Reference in New Issue
Block a user