fix(client/android): place audio with the picture on Android too

The core, Linux, Windows and host halves of the audio latency overhaul landed
with Android deliberately left inert: `JitterPolicy`'s sync target defaults to
`None`, so this ring kept behaving exactly as it always had. What was missing
was not the loop but its REFERENCE — nothing here published where a frame
actually reached glass, and a controller with no reference is the mechanism you
can prove is present but that cannot act. This wires both halves.

The decode thread now reads the host capture `pts_ns` that every `AudioPacket`
has always carried and that this client, like every other, dropped on the floor.
Against the ring depth (published by the AAudio callback through the shared
`AudioSyncCell`) and the video plane's end-to-end figure it computes

  audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns
  av_offset = audio_e2e − video_e2e        (> 0 ⇒ audio behind the picture)

and asks the ring for a depth that closes it. Only ASKS: `set_sync_target` is
clamped between the underrun-driven adaptive floor and the hard cap, so a link
whose jitter genuinely needs more buffer than the picture is away keeps its
buffer and the residual is reported instead of being taken out of the listener's
stream. Continuity outranks sync, on this ring as on the others.

The reference comes from `DisplayTracker`'s `OnFrameRendered` callback — the one
place in the client that knows a frame truly latched — and it is computed ABOVE
the HUD gate now. A sync loop that only ran while the overlay was up would be
off on exactly the devices that report latency; the stats LOCK stays gated,
which is what that early-return was really protecting. Both decode loops feed
it, so sync works with "Low-latency mode" off as well.

Two deliberate refusals:

* The figure is published RAW. The HUD shaves the OS present floor off its shown
  display/end-to-end numbers — metrics report what Punktfunk controls — but sound
  has to reach the ear when the light reaches the eye, and a floor-shaved
  reference would place audio a whole latch period early on every device.
* Below API 33 there is no render callback, so there is no confirmed present and
  the loop stays inert (target `None` ⇒ today's behaviour exactly). The release
  instant is NOT substituted for it: a release targets a FUTURE vsync and runs a
  whole latch period (8-21 ms measured) ahead of glass, well outside the loop's
  deadband — it would place audio early on every frame while looking like it was
  working.

The plane is also no longer invisible. Ring depth and the smoothed offset ride
the stats array at 33/34 and the Detailed HUD carries `audio buffer N ms · a/v
±N ms`, the same wording the desktop HUD uses — both numbers, because a deep ring
on a jittery link is correct behaviour and only the offset separates that from
audio simply held late. The 1 Hz logcat line gains `av_ms` beside its depth, and
the depth itself now has ONE publisher: the counter copy is gone in favour of the
sync cell both readers already share.

The escape hatch is two levers. `PUNKTFUNK_NO_AV_SYNC=1` keeps the contract the
desktop clients document, but an app launched from the launcher inherits no
environment, so the one a field tester can actually reach is
`adb shell setprop debug.punktfunk.no_av_sync 1` — no rebuild, exactly like
`debug.punktfunk.presenter`. A loop that steers playback has to be bisectable on
the device that reports the regression.

Verified: `cargo ndk -t arm64-v8a check` clean; `cargo clippy -p
punktfunk-client-android --all-targets -- -D warnings` clean on the host lane CI
lints, and the Android target introduces no new findings (5 pre-existing lints in
audio/mic/pad_audio/vsync are unchanged — the android-gated modules are never
linted by the host workspace); `cargo fmt --all --check` clean;
`./gradlew :app:testDebugUnitTest` green. The new HUD test was proven
non-vacuous by planting the defect first — dropping the render call fails its
three positive assertions and leaves the three absence assertions passing, which
is the shape a test that "passes for the wrong reason" would not have.

design/audio-latency-overhaul.md W4. Apple (W6) still keeps today's behaviour.
This commit is contained in:
2026-08-07 23:51:15 +02:00
parent 12a5318397
commit 70e6b80200
10 changed files with 330 additions and 31 deletions
@@ -18,12 +18,13 @@ import kotlin.math.roundToInt
* The live stats overlay — the unified HUD (`design/stats-unification.md`): headline is
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capture→decoded` headline without the `display` term when it didn't. Reads the 33-double
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
* audioAvOffsetMs]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
@@ -44,7 +45,7 @@ import kotlin.math.roundToInt
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* and the excluded-floor line when one was measured.
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
* [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).
*/
@@ -178,10 +179,42 @@ internal fun StatsOverlay(
}
}
}
if (detailed) {
audioLine(s)?.let { statLine(it, Color.White) }
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
/**
* The audio plane's own latency from the live gauges at 33/34 — `audio buffer 42 ms · a/v +18 ms`,
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
* before any audio has been queued (buffer 0 — audio off, or the ring not yet primed) and on an
* older native layout.
*
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour — the
* underrun-driven floor earned that buffer — and only the offset distinguishes it from a ring that
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
* is way too high" report had no instrument behind it).
*
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy —
* metrics report what Punktfunk controls — but sound has to reach the ear when the light reaches
* the eye, so the sync loop aligns against the RAW capture→displayed time (see the native
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
* report an alignment the listener is not getting.
*/
private fun audioLine(s: DoubleArray): String? {
if (s.size < 35) return null
val bufferMs = s[33].roundToInt()
if (bufferMs <= 0) return null
val avOffset = s[34].roundToInt()
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
return "audio buffer $bufferMs ms$avTerm"
}
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
@@ -0,0 +1,94 @@
package io.unom.punktfunk
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The stats HUD's audio line — `audio buffer N ms · a/v ±N ms`, from the live gauges at indexes
* 33/34 (`design/audio-latency-overhaul.md`).
*
* Worth pinning because the whole point of the overhaul's stats half is that the audio plane became
* OBSERVABLE. Before it, ring depth and A/V offset existed only as a log line, and on a device
* launched by a game launcher that goes to a pipe nobody can read — so the single number that
* identifies a deep ring was unobtainable on the exact device reporting the latency, and a field
* investigation ran to its conclusion without it. A measurement that never reaches a surface is
* indistinguishable from no measurement, which is what this asserts.
*
* `sdk = [36]` for the same reason as the screenshot tests: Robolectric ships android-all jars only
* up to API 36 while the app's compileSdk is 37.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class StatsOverlayAudioTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
/**
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
*/
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
val full = doubleArrayOf(
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,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
0.2, 0.3, 236.0, 1.0,
0.1, 0.3, 0.0,
bufferMs, avOffsetMs,
)
return full.copyOf(size)
}
private fun show(s: DoubleArray, verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
compose.setContent { StatsOverlay(s, verbosity = verbosity) }
}
@Test
fun detailedShowsDepthAndOffset() {
show(stats(bufferMs = 42.0, avOffsetMs = 18.0))
// Positive = audio playing BEHIND the picture, and the sign is explicit so a glance tells
// which way the loop still has to move.
compose.onNodeWithText("audio buffer 42 ms · a/v +18 ms").assertExists()
}
@Test
fun audioAheadOfThePictureReadsNegative() {
show(stats(bufferMs = 42.0, avOffsetMs = -12.0))
compose.onNodeWithText("audio buffer 42 ms · a/v -12 ms").assertExists()
}
/** Aligned (or not yet measured) drops the offset term; the depth alone is still the triage number. */
@Test
fun alignedShowsDepthAlone() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
compose.onNodeWithText("audio buffer 42 ms").assertExists()
}
/** Nothing queued (audio off, or the ring not yet primed) — the line has nothing to say. */
@Test
fun silentPlaneRendersNoLine() {
show(stats(bufferMs = 0.0, avOffsetMs = 0.0))
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
}
/** The line is DETAILED-only, like every other per-stage figure. */
@Test
fun normalTierOmitsTheLine() {
show(stats(bufferMs = 42.0, avOffsetMs = 18.0), verbosity = StatsVerbosity.NORMAL)
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
}
/** An older native lib emits 33 doubles; the overlay must omit the line, not index past the end. */
@Test
fun olderNativeLayoutOmitsTheLine() {
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
}
}
@@ -355,10 +355,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// The full 26-double unified layout (design/stats-unification.md): [fps, mbps, e2eP50,
// e2eP95, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries, colorTransfer,
// chromaFormatIdc, hostNetP50, decodeP50, hostP50, netP50, lost, skipped, fec, frames,
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
@@ -376,6 +378,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
1.0, 0.5, 1.8, 2.6,
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
0.2, 0.3, 236.0, 1.0,
// The decode term's own split (feed + codec = 0.4), and no overflow — the one
// `skipped` above is benign newest-wins pacing, not a decoder falling behind.
0.1, 0.3, 0.0,
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
// loop, i.e. inside the deadband it deliberately leaves alone.
28.0, 4.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",