feat(android): tiered stats HUD + windowed lost/skipped/FEC counters

Stats overlay verbosity tiers (Off/Compact/Normal/Detailed, 3-finger tap
cycles live, old Boolean pref migrated) and the unified-spec line-4
reliability counters: lost/FEC windowed from the connector's cumulative
totals, skipped from the client's own newest-wins drops. Adds the
fec_recovered_shards accessor to NativeClient, mirrored from the
data-plane pump like frames_dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:29:14 +02:00
parent b12c7d3deb
commit 4c9c7e606e
12 changed files with 343 additions and 113 deletions
@@ -346,11 +346,12 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpR
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad, GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) }, ) { update(s.copy(gamepad = it)) },
toggle( choice(
"hud", "Interface", "Statistics overlay", "hud", "Interface", "Statistics overlay",
"Show FPS, throughput and latency while streaming.", "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
s.statsHudEnabled, "A 3-finger tap cycles the tiers live.",
) { update(s.copy(statsHudEnabled = it)) }, STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle( toggle(
"library", null, "Game library", "library", null, "Game library",
"Browse a paired host's games with Y (experimental).", "Browse a paired host's games with Y (experimental).",
@@ -30,8 +30,13 @@ data class Settings(
* host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */ * host emits it when it can, else falls back. AMediaCodec decodes whichever the host resolves. */
val codec: String = "auto", val codec: String = "auto",
val micEnabled: Boolean = false, val micEnabled: Boolean = false,
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */ /**
val statsHudEnabled: Boolean = true, * How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
* decoder/feed/equation HUD is [StatsVerbosity.DETAILED], and a single terse line is
* [StatsVerbosity.COMPACT]. A 3-finger tap cycles through the tiers live.
*/
val statsVerbosity: StatsVerbosity = StatsVerbosity.NORMAL,
/** /**
* Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default): * Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge, * the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
@@ -81,6 +86,27 @@ data class Settings(
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH } enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps:
* - [OFF] — no overlay (and native sampling is gated off, one atomic load per frame).
* - [COMPACT] — one line: `fps · end-to-end ms · Mb/s` (+ a loss flag when frames drop).
* - [NORMAL] — adds the resolution/refresh line, the end-to-end p50/p95 headline, and the
* reliability counters (lost / skipped / FEC) when nonzero. The default.
* - [DETAILED] — the full HUD: also the decoder label, the video-feed descriptor, and the
* `host+network + decode` stage equation.
* A 3-finger tap in-stream cycles Off → Compact → Normal → Detailed → Off (see [next]).
*/
enum class StatsVerbosity(val label: String) {
OFF("Off"),
COMPACT("Compact"),
NORMAL("Normal"),
DETAILED("Detailed");
/** The next tier for the live 3-finger-tap cycle (wraps Detailed → Off). */
fun next(): StatsVerbosity = entries[(ordinal + 1) % entries.size]
}
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ /** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val prefs = private val prefs =
@@ -97,7 +123,16 @@ class SettingsStore(context: Context) {
audioChannels = prefs.getInt(K_AUDIO_CH, 2), audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto", codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false), micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true), statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
// everyone else (incl. fresh installs) lands on NORMAL — the old always-full HUD toned
// down to the new default, which is the whole point of adding tiers.
?: if (prefs.contains(K_HUD) && !prefs.getBoolean(K_HUD, true)) {
StatsVerbosity.OFF
} else {
StatsVerbosity.NORMAL
},
touchMode = prefs.getString(K_TOUCH_MODE, null) touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } } ?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct). // Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
@@ -120,7 +155,7 @@ class SettingsStore(context: Context) {
.putInt(K_AUDIO_CH, s.audioChannels) .putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec) .putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name) .putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled) .putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled) .putBoolean(K_LIBRARY, s.libraryEnabled)
@@ -140,6 +175,10 @@ class SettingsStore(context: Context) {
const val K_AUDIO_CH = "audio_channels" const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec" const val K_CODEC = "codec"
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
* written. */
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode" const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_GAMEPAD_UI = "gamepad_ui_enabled"
@@ -269,6 +308,9 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope", "gamescope",
) )
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** (mode, label) for the touch-input model. */ /** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf( val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad", TouchMode.TRACKPAD to "Trackpad",
@@ -403,11 +403,17 @@ private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
checked = s.autoWakeEnabled, checked = s.autoWakeEnabled,
onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) }, onCheckedChange = { on -> update(s.copy(autoWakeEnabled = on)) },
) )
ToggleRow( SettingDropdown(
title = "Stats overlay", label = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", options = STATS_VERBOSITY_OPTIONS,
checked = s.statsHudEnabled, selected = s.statsVerbosity,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, ) { v -> update(s.copy(statsVerbosity = v)) }
Text(
"How much the in-stream overlay shows: Compact is a single fps · latency · bitrate " +
"line; Normal adds the resolution and reliability lines; Detailed adds the decoder, " +
"colour and latency-breakdown lines. A 3-finger tap cycles the tiers live.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -16,61 +16,63 @@ import kotlin.math.roundToInt
/** /**
* The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is * The live stats overlay — the unified HUD (`design/stats-unification.md`, Android v1: headline is
* `capture→decoded`, tiled by `host+network` + `decode`). Reads the 18-double layout from * `capture→decoded`, tiled by `host+network` + `decode`). Reads the 22-double layout from
* [NativeBridge.nativeVideoStats]: * [NativeBridge.nativeVideoStats]:
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, * `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms]`. Indexes 1013 * colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* (present on a current native lib) describe the negotiated video feed and render as a * fec, frames]`.
* codec/depth/colour/chroma line; 14/15 render as the stage equation — split into *
* `host + network + decode` when the Phase-2 terms at 16/17 are nonzero (a current host sends * [verbosity] selects how many lines render (each tier a superset of the last — see
* per-AU 0xCF timings; an old host leaves them 0 and the combined `host+network` term stands); * [StatsVerbosity]):
* older layouts just omit those lines. * - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/ */
@Composable @Composable
internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) { internal fun StatsOverlay(
if (s.size < 10) return s: DoubleArray,
verbosity: StatsVerbosity,
decoderLabel: String = "",
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt() val w = s[6].toInt()
val h = s[7].toInt() val h = s[7].toInt()
val hz = s[8].toInt() val hz = s[8].toInt()
val latValid = s[4] != 0.0 val latValid = s[4] != 0.0
val skew = s[5] != 0.0 val skew = s[5] != 0.0
val lost = s[9].toLong() val lost = s[9].toLong()
val detailed = verbosity == StatsVerbosity.DETAILED
Column( Column(
modifier = modifier modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
Text( // Compact: everything the glance-value needs on one line, nothing else.
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", if (verbosity == StatsVerbosity.COMPACT) {
color = Color.White, statLine(compactLine(s, latValid), Color.White)
fontFamily = FontFamily.Monospace, return@Column
fontSize = 12.sp,
)
if (decoderLabel.isNotEmpty()) {
Text(
decoderLabel,
color = Color(0xFFB0D0FF),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
} }
videoFeedLine(s)?.let { feed ->
Text( statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
feed, if (detailed && decoderLabel.isNotEmpty()) {
color = Color.White, statLine(decoderLabel, Color(0xFFB0D0FF))
fontFamily = FontFamily.Monospace, }
fontSize = 12.sp, if (detailed) {
) videoFeedLine(s)?.let { statLine(it, Color.White) }
} }
if (latValid) { if (latValid) {
val tag = if (skew) "" else " (same-host clock)" val tag = if (skew) "" else " (same-host clock)"
Text( statLine(
"end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag", "end-to-end ${"%.1f".format(s[2])} ms p50 · ${"%.1f".format(s[3])} p95 · capture→decoded$tag",
color = Color.White, Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
) )
if (s.size >= 16) { if (detailed && s.size >= 16) {
// Phase-2 split (s[16]/s[17]): render `host + network` separately when the host // Phase-2 split (s[16]/s[17]): render `host + network` separately when the host
// reported its share this window; otherwise the combined term (old host / no // reported its share this window; otherwise the combined term (old host / no
// matched 0xCF timing). // matched 0xCF timing).
@@ -79,25 +81,60 @@ internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: M
} else { } else {
"= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}" "= host+network ${"%.1f".format(s[14])} + decode ${"%.1f".format(s[15])}"
} }
Text( statLine(equation, Color.White)
equation,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
} }
} }
if (lost > 0) { counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
Text(
"lost $lost",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
} }
} }
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
* `· ⚠ lost {n}` is appended when the window (or, on an old lib, the session) dropped frames — the
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(s[2])} ms")
add("${s[1].roundToInt()} Mb/s")
}
val lostWindow = if (s.size >= 22) s[18].toLong() else s[9].toLong()
val suffix = if (lostWindow > 0) " ⚠ lost $lostWindow" else ""
return parts.joinToString(" · ") + suffix
}
/**
* Format the spec's line-4 counters from the per-window doubles at 1821 —
* `lost {n} ({pct}%) · skipped {m} · FEC {k}`, each term only when nonzero, the whole line `null`
* when all are zero (spec: "only rendered when any value is nonzero"). `pct = lost/(frames+lost)`
* (the received count rides at index 21). A pre-window layout (< 22 doubles) falls back to the
* session-cumulative `lostTotal` so an older native lib still reports loss.
*/
private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
if (s.size < 22) return if (lostTotal > 0) "lost $lostTotal" else null
val lost = s[18].toLong()
val skipped = s[19].toLong()
val fec = s[20].toLong()
val frames = s[21].toLong()
if (lost == 0L && skipped == 0L && fec == 0L) return null
return buildList {
if (lost > 0) {
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
add("lost $lost (${"%.1f".format(pct)}%)")
}
if (skipped > 0) add("skipped $skipped")
if (fec > 0) add("FEC $fec")
}.joinToString(" · ")
}
/** /**
* Format the negotiated video-feed descriptor from the trailing four stats doubles * Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g. * `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
@@ -54,15 +54,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
Manifest.permission.RECORD_AUDIO, Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame // Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the // the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native // frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default // the native window, so re-showing never renders stale data. A 3-finger tap cycles the
// comes from Settings. // verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() } val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") } var decoderLabel by remember { mutableStateOf("") }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode val touchMode = initialSettings.touchMode
// "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the // "Low-latency mode" master toggle, resolved once for the session. On (the default) enables the
@@ -73,9 +77,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream // TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead. // refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) } val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (showStats) { if (statsOn) {
while (true) { while (true) {
delay(1000) delay(1000)
stats = NativeBridge.nativeVideoStats(handle) stats = NativeBridge.nativeVideoStats(handle)
@@ -250,8 +254,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
) )
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but // Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches. // BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) { if (statsOn) {
stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
} }
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
@@ -262,7 +268,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
else -> streamTouchInput( else -> streamTouchInput(
handle, handle,
trackpad = touchMode == TouchMode.TRACKPAD, trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats }, onCycleStats = { statsVerbosity = statsVerbosity.next() },
) )
} }
}, },
@@ -40,7 +40,7 @@ private const val ACCEL_MAX = 3.0f
* *
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click; * Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving * two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD). * windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
*/ */
/** /**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen * Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
@@ -93,7 +93,7 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
internal suspend fun PointerInputScope.streamTouchInput( internal suspend fun PointerInputScope.streamTouchInput(
handle: Long, handle: Long,
trackpad: Boolean, trackpad: Boolean,
onToggleStats: () -> Unit, onCycleStats: () -> Unit,
) { ) {
var lastTapUp = 0L var lastTapUp = 0L
var lastTapX = 0f var lastTapX = 0f
@@ -218,7 +218,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) { } else if (!moved) {
when { when {
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle maxFingers >= 3 -> onCycleStats() // in-stream HUD verbosity cycle
maxFingers == 2 -> { // two-finger tap → right click maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true) NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false) NativeBridge.nativeSendPointerButton(handle, 3, false)
@@ -58,7 +58,15 @@ class ScreenshotTest {
@Test @Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene() } fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
@Test @Test
fun trust() = shootScreen("trust") { fun trust() = shootScreen("trust") {
@@ -30,6 +30,7 @@ import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.HostStatus
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
compositor = 1, compositor = 1,
gamepad = 2, gamepad = 2,
micEnabled = true, micEnabled = true,
statsHudEnabled = true, statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD, touchMode = TouchMode.TRACKPAD,
), ),
onChange = {}, onChange = {},
@@ -177,9 +178,12 @@ internal fun PairDialog() {
) )
} }
/** The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient. */ /**
* The live stats HUD (the real StatsOverlay) over a synthetic "streamed frame" gradient, at the
* given [verbosity] tier — one scene per tier documents how far each tones the overlay down.
*/
@Composable @Composable
internal fun StreamScene() { internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -187,17 +191,21 @@ internal fun StreamScene() {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))), Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
), ),
) { ) {
// The full 18-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50, // The full 22-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lost, bitDepth, colorPrimaries, colorTransfer, // e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50]. 10/9/16/1 = a 10-bit BT.2020 // chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames].
// PQ (HDR) 4:2:0 feed so the HUD renders its video-feed line; the Phase-2 stage terms // 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// (host 0.6 + network 0.3 + decode 0.4) tile the 1.3 ms headline so it renders the full // video-feed line; the Phase-2 stage terms (host 0.6 + network 0.3 + decode 0.4) tile the
// split equation, and the decoder label line shows the ranked low-latency decoder. // 1.3 ms headline so it renders the full split equation, and the decoder label shows the
// ranked low-latency decoder. Light per-window loss (lost 2 · skipped 1 · FEC 5 of 238) so
// the reliability line (NORMAL/DETAILED) and the compact loss flag both render.
StatsOverlay( StatsOverlay(
doubleArrayOf( doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 0.0, 238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3, 10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
), ),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency", decoderLabel = "c2.qti.hevc.decoder · low-latency",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp), modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
) )
+43 -14
View File
@@ -493,10 +493,14 @@ fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
} }
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed /// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat. /// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
/// frame available"), stamped at the callback so the event-channel hop + coalescing wait in the
/// loop never inflates the decode stage.
struct OutputReady { struct OutputReady {
index: usize, index: usize,
pts_us: u64, pts_us: u64,
decoded_ns: i128,
} }
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its /// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
@@ -507,8 +511,12 @@ enum DecodeEvent {
Au(Frame), Au(Frame),
/// An input buffer slot freed (index) — we can queue an AU into it. /// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize), InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts). /// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
OutputAvailable { index: usize, pts_us: u64 }, OutputAvailable {
index: usize,
pts_us: u64,
decoded_ns: i128,
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace). /// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged, FormatChanged,
/// The codec reported an error; `fatal` when neither recoverable nor transient. /// The codec reported an error; `fatal` when neither recoverable nor transient.
@@ -569,6 +577,10 @@ fn run_async(
let _ = out_tx.send(DecodeEvent::OutputAvailable { let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx, index: idx,
pts_us: info.presentation_time_us().max(0) as u64, pts_us: info.presentation_time_us().max(0) as u64,
// The `decoded` HUD point: stamp HERE, on the codec's looper thread, so the
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
}); });
})), })),
on_format_changed: Some(Box::new(move |_fmt| { on_format_changed: Some(Box::new(move |_fmt| {
@@ -697,29 +709,30 @@ fn run_async(
}; };
let work_t0 = Instant::now(); let work_t0 = Instant::now();
let mut fmt_dirty = false; let mut fmt_dirty = false;
let mut au_dropped = false; let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 { if let Some(ev) = ev0 {
au_dropped |= dispatch_event( aus_dropped += u64::from(dispatch_event(
ev, ev,
&mut pending_aus, &mut pending_aus,
&mut free_inputs, &mut free_inputs,
&mut ready, &mut ready,
&mut fmt_dirty, &mut fmt_dirty,
&mut fatal, &mut fatal,
); ));
} }
// Coalesce every other event already queued into this one work pass — correct newest-only // Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding. // presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() { while let Ok(ev) = ev_rx.try_recv() {
au_dropped |= dispatch_event( aus_dropped += u64::from(dispatch_event(
ev, ev,
&mut pending_aus, &mut pending_aus,
&mut free_inputs, &mut free_inputs,
&mut ready, &mut ready,
&mut fmt_dirty, &mut fmt_dirty,
&mut fatal, &mut fatal,
); ));
} }
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty { if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds); apply_hdr_dataspace(&codec, &window, &mut applied_ds);
} }
@@ -768,7 +781,7 @@ fn run_async(
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the // dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream. // control stream.
let dropped = client.frames_dropped(); let dropped = client.frames_dropped();
if dropped > last_dropped || au_dropped { if dropped > last_dropped || aus_dropped > 0 {
last_dropped = dropped; last_dropped = dropped;
let now = Instant::now(); let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) { if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
@@ -863,7 +876,15 @@ fn dispatch_event(
} }
} }
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i), DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }), DecodeEvent::OutputAvailable {
index,
pts_us,
decoded_ns,
} => ready.push(OutputReady {
index,
pts_us,
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true, DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Error { fatal: f } => { DecodeEvent::Error { fatal: f } => {
if f { if f {
@@ -935,15 +956,19 @@ fn present_ready(
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner); .unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() { for o in ready.iter() {
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us); note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
} }
} }
let last = ready.len() - 1; let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() { for (i, o) in ready.drain(..).enumerate() {
let render = i == last; let render = i == last;
match codec.release_output_buffer_by_index(o.index, render) { match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => *rendered += 1, Ok(()) if render => *rendered += 1,
Ok(()) => *discarded += 1, Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => { Err(e) => {
log::warn!( log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}", "decode: release_output_buffer_by_index({}, {render}): {e}",
@@ -952,6 +977,7 @@ fn present_ready(
} }
} }
} }
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
} }
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR /// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
@@ -1143,6 +1169,7 @@ fn drain(
log::warn!("decode: release_output_buffer(discard): {e}"); log::warn!("decode: release_output_buffer(discard): {e}");
} }
discarded += 1; discarded += 1;
stats.note_skipped(1); // HUD `skipped` counter; no-op while hidden
} }
} }
Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => { Ok(DequeuedOutputBufferInfoResult::OutputFormatChanged) => {
@@ -1203,18 +1230,20 @@ fn note_decoded(
in_flight, in_flight,
clock_offset, clock_offset,
buf.info().presentation_time_us().max(0) as u64, buf.info().presentation_time_us().max(0) as u64,
now_realtime_ns(), // sync loop: the dequeue IS the availability instant
); );
} }
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has /// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this. /// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls
/// this with the `decoded` stamp taken in the output callback itself (the availability instant).
fn note_decoded_pts( fn note_decoded_pts(
stats: &crate::stats::VideoStats, stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>, in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64, clock_offset: i64,
pts_us: u64, pts_us: u64,
decoded_ns: i128,
) { ) {
let decoded_ns = now_realtime_ns();
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go. // Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None; let mut received_ns = None;
while let Some(&(p, r)) = in_flight.front() { while let Some(&(p, r)) = in_flight.front() {
+26 -7
View File
@@ -144,16 +144,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
} }
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// (unified stats spec, `design/stats-unification.md`). Returns 18 doubles /// (unified stats spec, `design/stats-unification.md`). Returns 22 doubles
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost, /// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, /// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms]` /// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow]`
/// (the two flags are 1.0/0.0; indexes 015 match the previous 16-double layout — 013 the original /// (the two flags are 1.0/0.0; indexes 017 match the previous 18-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15 /// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17 /// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` = /// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this /// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
/// window, i.e. an old host), or `null` when no decode thread is running. Poll ~1 Hz from the UI; each call /// window, i.e. an old host; 1821 are the spec's per-window line-4 counters — `lost` =
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
/// the cumulative session total for older readers), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on /// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device). /// the host build too (Kotlin only ever calls it on device).
#[no_mangle] #[no_mangle]
@@ -171,10 +175,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
if h.video.lock().unwrap().is_none() { if h.video.lock().unwrap().is_none() {
return std::ptr::null_mut(); // not streaming → no stats return std::ptr::null_mut(); // not streaming → no stats
} }
let snap = h.stats.drain(); let snap = h
.stats
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode(); let mode = h.client.mode();
let color = h.client.color; let color = h.client.color;
let buf: [f64; 18] = [ let buf: [f64; 22] = [
snap.fps, snap.fps,
snap.mbps, snap.mbps,
snap.e2e_p50_ms, snap.e2e_p50_ms,
@@ -200,6 +206,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
// when no timing matched this window (old host) — the HUD keeps the combined term. // when no timing matched this window (old host) — the HUD keeps the combined term.
snap.host_p50_ms, snap.host_p50_ms,
snap.net_p50_ms, snap.net_p50_ms,
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
snap.lost as f64,
snap.skipped as f64,
snap.fec as f64,
snap.frames as f64,
]; ];
let arr = match env.new_double_array(buf.len() as jsize) { let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a, Ok(a) => a,
@@ -228,7 +241,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
if handle != 0 { if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
h.stats.set_enabled(enabled != 0); // The current cumulative counters seed the window baselines, so the first snapshot's
// `lost`/`FEC` cover only time the HUD was actually up.
h.stats.set_enabled(
enabled != 0,
h.client.frames_dropped(),
h.client.fec_recovered_shards(),
);
} }
}) })
} }
+58 -4
View File
@@ -3,7 +3,10 @@
//! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received //! headline `end-to-end` = capture→decoded (p50/p95) tiled by `host+network` = capture→received
//! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the //! and `decode` = received→decoded (stage p50s). When the host emits per-AU 0xCF host timings, the
//! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old //! `host+network` term further splits into `host` + `network` (Phase 2, `note_host_split`); an old
//! host emits none and the combined term stands. The decode thread is the sole writer //! host emits none and the combined term stands. The spec's line-4 counters are per-window too:
//! `lost` / `FEC` are windowed here from the connector's cumulative counters (the caller passes the
//! current totals into `set_enabled`/`drain`), `skipped` counts the client's own newest-wins drops
//! (`note_skipped`). The decode thread is the sole writer
//! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI //! (`note_received` per access unit at receipt, `note_decoded` per decoder output buffer); the JNI
//! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on //! accessor `nativeVideoStats` drains a snapshot ~1 Hz and resets the window. Sampling is gated on
//! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the //! the HUD actually being visible (`set_enabled`, driven by `nativeSetVideoStatsEnabled`) so the
@@ -54,6 +57,14 @@ struct Inner {
net_us: Vec<u64>, net_us: Vec<u64>,
/// `decode` stage = received→decoded samples, in microseconds (client-local, single clock). /// `decode` stage = received→decoded samples, in microseconds (client-local, single clock).
decode_us: Vec<u64>, decode_us: Vec<u64>,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
/// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop
/// and FEC-recovered totals as of the last drain (or the enable that opened the window), so
/// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4).
last_dropped_total: u64,
last_fec_total: u64,
/// Whether the host answered the clock-skew handshake (latency is cross-machine valid). /// Whether the host answered the clock-skew handshake (latency is cross-machine valid).
skew_corrected: bool, skew_corrected: bool,
} }
@@ -76,6 +87,16 @@ pub struct Snapshot {
pub net_p50_ms: f64, pub net_p50_ms: f64,
pub lat_valid: bool, pub lat_valid: bool,
pub skew_corrected: bool, pub skew_corrected: bool,
/// Access units received this window (the count behind `fps`) — lets the HUD compute the
/// spec's loss percentage `lost / (received + lost)` exactly.
pub frames: u64,
/// Unrecoverable network frame drops this window (spec `lost`, windowed from the
/// session-cumulative connector counter).
pub lost: u64,
/// Client-side newest-wins/pacing drops this window (spec `skipped`).
pub skipped: u64,
/// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter).
pub fec: u64,
} }
/// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty. /// Percentile over a sorted-in-place µs sample vec, in ms. 0.0 when empty.
@@ -101,6 +122,9 @@ impl VideoStats {
host_us: Vec::with_capacity(256), host_us: Vec::with_capacity(256),
net_us: Vec::with_capacity(256), net_us: Vec::with_capacity(256),
decode_us: Vec::with_capacity(256), decode_us: Vec::with_capacity(256),
skipped: 0,
last_dropped_total: 0,
last_fec_total: 0,
skew_corrected: false, skew_corrected: false,
}), }),
} }
@@ -115,8 +139,10 @@ impl VideoStats {
} }
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes /// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible. /// in counters (or a window start) from before the overlay was visible. `dropped_total` /
pub fn set_enabled(&self, on: bool) { /// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
/// windowing baselines so the first snapshot's `lost` / `FEC` cover only time the HUD was up.
pub fn set_enabled(&self, on: bool, dropped_total: u64, fec_total: u64) {
let was = self.enabled.swap(on, Ordering::Relaxed); let was = self.enabled.swap(on, Ordering::Relaxed);
if on && !was { if on && !was {
let mut g = self let mut g = self
@@ -131,6 +157,9 @@ impl VideoStats {
g.host_us.clear(); g.host_us.clear();
g.net_us.clear(); g.net_us.clear();
g.decode_us.clear(); g.decode_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
} }
} }
@@ -206,6 +235,22 @@ impl VideoStats {
g.net_us.push(net_us); g.net_us.push(net_us);
} }
/// Record client-side frame skips (spec `skipped`): decoded output buffers released without
/// rendering under the newest-wins policy, or parked AUs dropped on queue overflow.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_skipped(&self, n: u64) {
if n == 0 || !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.skipped += n;
}
/// Record one decoded output frame: its capture→decoded `end-to-end` sample and its /// Record one decoded output frame: its capture→decoded `end-to-end` sample and its
/// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for /// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for
/// this pts predates the HUD being shown). /// this pts predates the HUD being shown).
@@ -229,7 +274,9 @@ impl VideoStats {
} }
/// Compute the window's rates + latency percentiles, then reset for the next window. /// Compute the window's rates + latency percentiles, then reset for the next window.
pub fn drain(&self) -> Snapshot { /// `dropped_total` / `fec_total` are the connector's session-cumulative counters now; the
/// snapshot's `lost` / `fec` are their deltas since the last drain (or the enabling show).
pub fn drain(&self, dropped_total: u64, fec_total: u64) -> Snapshot {
// Poison-proof for the same reason as `note_received` — a poisoned window still drains // Poison-proof for the same reason as `note_received` — a poisoned window still drains
// fine. // fine.
let mut g = self let mut g = self
@@ -255,6 +302,10 @@ impl VideoStats {
net_p50_ms: pctl_ms(&g.net_us, 0.50), net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(), lat_valid: !g.e2e_us.is_empty(),
skew_corrected: g.skew_corrected, skew_corrected: g.skew_corrected,
frames: g.frames,
lost: dropped_total.saturating_sub(g.last_dropped_total),
skipped: g.skipped,
fec: fec_total.saturating_sub(g.last_fec_total),
}; };
g.window_start = Instant::now(); g.window_start = Instant::now();
g.frames = 0; g.frames = 0;
@@ -264,6 +315,9 @@ impl VideoStats {
g.host_us.clear(); g.host_us.clear();
g.net_us.clear(); g.net_us.clear();
g.decode_us.clear(); g.decode_us.clear();
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
snap snap
} }
} }
+20
View File
@@ -314,6 +314,11 @@ pub struct NativeClient {
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss /// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
/// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them). /// yields reference-missing frames the decoder silently conceals (a decode-error trigger misses them).
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
/// Cumulative count of FEC shards the reassembler recovered (parity repaired a lost data
/// packet), mirrored from the data-plane pump's `Session` like `frames_dropped`. Observability
/// for the client stats HUDs (the unified spec's per-window `FEC` counter — proof FEC is
/// earning its keep); readers window it by diffing successive reads.
fec_recovered: Arc<AtomicU64>,
/// Kernel ids of the client's latency-critical native threads: the internal data-plane pump /// Kernel ids of the client's latency-critical native threads: the internal data-plane pump
/// (UDP receive + FEC reassembly) plus any embedder plane threads registered via /// (UDP receive + FEC reassembly) plus any embedder plane threads registered via
/// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session /// [`NativeClient::register_hot_thread`]. The Android client feeds these to an ADPF hint session
@@ -490,6 +495,7 @@ impl NativeClient {
let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default())); let probe = Arc::new(Mutex::new(ProbeState::default()));
let frames_dropped = Arc::new(AtomicU64::new(0)); let frames_dropped = Arc::new(AtomicU64::new(0));
let fec_recovered = Arc::new(AtomicU64::new(0));
let hot_tids = Arc::new(Mutex::new(Vec::new())); let hot_tids = Arc::new(Mutex::new(Vec::new()));
let host = host.to_string(); let host = host.to_string();
@@ -499,6 +505,7 @@ impl NativeClient {
let mode_slot_w = mode_slot.clone(); let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone(); let probe_w = probe.clone();
let frames_dropped_w = frames_dropped.clone(); let frames_dropped_w = frames_dropped.clone();
let fec_recovered_w = fec_recovered.clone();
let hot_tids_w = hot_tids.clone(); let hot_tids_w = hot_tids.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new() let worker = std::thread::Builder::new()
@@ -550,6 +557,7 @@ impl NativeClient {
mode_slot: mode_slot_w, mode_slot: mode_slot_w,
probe: probe_w, probe: probe_w,
frames_dropped: frames_dropped_w, frames_dropped: frames_dropped_w,
fec_recovered: fec_recovered_w,
hot_tids: hot_tids_w, hot_tids: hot_tids_w,
})); }));
}) })
@@ -592,6 +600,7 @@ impl NativeClient {
quit, quit,
worker: Some(worker), worker: Some(worker),
frames_dropped, frames_dropped,
fec_recovered,
hot_tids, hot_tids,
mode: mode_slot, mode: mode_slot,
host_fingerprint: fingerprint, host_fingerprint: fingerprint,
@@ -734,6 +743,14 @@ impl NativeClient {
self.frames_dropped.load(Ordering::Relaxed) self.frames_dropped.load(Ordering::Relaxed)
} }
/// Cumulative FEC shards the host→client reassembler recovered (a parity shard repaired a lost
/// data packet — loss that never became a dropped frame). Monotonic for the session; a stats
/// HUD windows it by diffing successive reads, pairing it with
/// [`frames_dropped`](Self::frames_dropped) (the losses FEC could NOT absorb).
pub fn fec_recovered_shards(&self) -> u64 {
self.fec_recovered.load(Ordering::Relaxed)
}
/// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the /// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the
/// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the /// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the
/// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit) /// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit)
@@ -987,6 +1004,7 @@ struct WorkerArgs {
mode_slot: Arc<std::sync::Mutex<Mode>>, mode_slot: Arc<std::sync::Mutex<Mode>>,
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
fec_recovered: Arc<AtomicU64>,
hot_tids: Arc<Mutex<Vec<i32>>>, hot_tids: Arc<Mutex<Vec<i32>>>,
} }
@@ -1024,6 +1042,7 @@ async fn worker_main(args: WorkerArgs) {
mode_slot, mode_slot,
probe, probe,
frames_dropped, frames_dropped,
fec_recovered,
hot_tids, hot_tids,
} = args; } = args;
let setup = async { let setup = async {
@@ -1362,6 +1381,7 @@ async fn worker_main(args: WorkerArgs) {
// through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads. // through a total-loss drought where no AU completes. Cheap: a few relaxed atomic loads.
let st = session.stats(); let st = session.stats();
frames_dropped.store(st.frames_dropped, Ordering::Relaxed); frames_dropped.store(st.frames_dropped, Ordering::Relaxed);
fec_recovered.store(st.fec_recovered_shards, Ordering::Relaxed);
let probe_active = { let probe_active = {
let mut p = pump_probe.lock().unwrap(); let mut p = pump_probe.lock().unwrap();
if p.active && !p.done { if p.active && !p.done {