Commit Graph
6 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Fable 5 984f7be896 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>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 e08fd91cd1 feat(android): a timeline presenter — frames reach glass on the panel's schedule, not decode's
The Android port of the Apple client's stage-4 deadline discipline, closing
the side-by-side feel gap (both clients 120 Hz; Android released decoded
buffers the instant they appeared, with zero vsync awareness — the latch
phase inherited every network+decode jitter and bursts queued behind the
display).

The presenter (async loop only; the sync loop stays the untouched escape
hatch behind the Low-latency toggle):

- decode/vsync.rs: an AChoreographer thread (dlsym'd like the other
  above-floor symbols) publishing the panel's vsync grid + frame timelines
  (postVsyncCallback, API 33; postFrameCallback64 fallback on 31/32) and
  ticking the decode loop's event channel. Started lazily on the first
  decoded frame.
- decode/presenter.rs: a newest-wins slot (Lowest latency, default) or a
  1-3 frame smoothing FIFO with preroll/underflow re-arm (Smoothness) between
  decode and release; a glass budget of exactly ONE undisplayed release in
  flight, reopened at the target timeline's DEADLINE (SurfaceFlinger's latch
  — reopening at present time would halve the sustainable rate) with a 100 ms
  stale force-open backstop; the release itself via
  releaseOutputBufferAtTime(expectedPresent) so the latch phase is
  deterministic. debug.punktfunk.presenter=arrival sysprop restores the
  legacy path for a rebuild-free on-device A/B.
- Metrics: DisplayTracker is now always-on and carries the release stamp, so
  the display stage splits into pace (decoded→release) + latch
  (release→displayed); a 1 Hz pf.present logcat line (released/displays/
  paced/noBudget/forced/qDry + pace/latch p50/max + measured vsync) makes a
  HUD-off wireless A/B readable; nativeVideoStats grows to 30 doubles
  (26=paceP50, 27=latchP50, 28=presents, 29=presenterActive; 0-25 frozen)
  and the DETAILED HUD prints the split + presents.
- Intent parity: present_priority/smooth_buffer — the Apple client's
  stored values and labels — as globals, profile-overlay fields (round-trip
  + scope markers), and Settings pickers under Decoding; threaded through
  nativeStartVideo into the presenter config.

Verified: cargo ndk check/clippy clean for arm64 (the two type_complexity
warnings are pre-existing audio/mic ones), armv7 via the kit gradle task,
host cargo check clean, rustfmt clean, gradle :app/:kit unit tests all pass.
On-device before/after on the Nothing Phone 3 still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Opus 5 6a6be17ce7 fix(client/android): a session that receives no video at all now asks, and says so
The keyframe backstop added for the black LG TV only arms while AUs are
actually going into the decoder (`fed > fed_at_output`) — deliberately, so
an idle stream never asks for anything. That leaves its mirror image
uncovered: a session that receives NOTHING. A decoder cannot be starved of
output when it was handed no input, so no signal in either loop fires, and
the session sits connected — audio, input and the control plane all alive —
behind a black surface.

That state is what a user just reported as "the stats are all basically 0":
fps and Mb/s are counted at AU receipt (`note_received`), so all-zero stats
with a drawn overlay means the decode thread started and received nothing.
Same bug as the black screen, seen from the HUD.

Both loops now watch for it: nothing received 1.5 s into a session ⇒ request
a keyframe and log it, re-asking every 2 s while it lasts. Where it can help
it does — the host encoding fine while every picture references an IDR this
client never saw is precisely a keyframe request away. Where it can't, the
log line is the point: "no video received N ms into the session" separates
"the host never sent a picture" from "we received AUs and lost them", which
no previous black-screen report could distinguish.

Not a root cause. The remaining occurrences are still unattributed — this
makes the next report diagnosable and recovers the case that is ours to
recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:30:59 +02:00
enricobuehlerandClaude Opus 5 e44baf6768 fix(client/android): a decoder fed nothing it can decode must ask for a new anchor
android / android (push) Canceled after 33s
apple / swift (push) Canceled after 27s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 2s
ci / docs-site (push) Canceled after 3s
ci / bench (push) Canceled after 2s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6s
An Android TV box reported a black screen with a perfectly healthy HUD: AUs
arriving at 40 fps, ~312 bytes each. That size is all-P-frames — no IDR anywhere
in the window, and nothing asking for one. The stats being readable is the rest
of the story: the overlay is a layer over the SurfaceView in the same window, so
the panel was fine and the surface simply never received a frame.

The decode thread only starts at `surfaceCreated`, so a slow box can be handed
the stream mid-GOP. A hardware decoder does not error on references it never
had; it emits nothing at all. Under infinite GOP the host sends no further IDR
unless asked, and neither Android loop ever asked: every recovery trigger they
have keys off a drop, a gap or a decode error, and a decoder that quietly
produces nothing trips none of them. The session stayed black for its whole life.

The shared gate has this case (`on_no_output`, which pf-client-core and the Apple
client both feed) but its per-AU streak counts one-in/one-out decodes, and
MediaCodec is pipelined — "this AU produced no output" is not something these
loops can observe. A wall-clock silence window is the same signal in the shape
Android can measure: fed for 500 ms with nothing coming back arms the freeze and
requests a re-anchor keyframe, and the gate's deadline keeps re-asking until one
lands. 500 ms so it can never fire on a decoder that is merely slow to spin up.

Also log the first presented frame. The periodic tally starts at 300 rendered
frames, which is no help whatsoever on a session that renders none — its absence
is what separates "never reached glass" from "reached glass and looked wrong".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:57:13 +02:00
enricobuehlerandClaude Opus 4.8 f439b69451 refactor(android/W8): split decode.rs into decode/ directory module
Break the 1747-line clients/android/native/src/decode.rs into a decode/ directory
module (mod.rs + 5 concern submodules):
  - decode/setup.rs      : codec creation + low-latency config + thread/frame-rate
                           tuning + HDR static-info encode
  - decode/display.rs    : DisplayTracker + render-callback registration + HDR dataspace
  - decode/latency.rs    : realtime clock + decoded-pts / user-flags stat recording
  - decode/sync_loop.rs  : the synchronous poll decode loop (+ feed/drain) — moved WHOLE
  - decode/async_loop.rs : the event-driven async decode loop (+ helpers) — moved WHOLE
decode/mod.rs keeps the consts, DecodeOptions, and the `run` entry point + the
`codec_mime`/`codec_label` re-export, so every crate::decode::X path stays byte-stable.
The module has no decoder struct (free functions + small types), so both decode loops
move byte-for-byte and their separately-inlined received-stat recording is NOT unified.
16 helper fns/types became pub(super) for sibling access; zero field bumps. lib.rs
unchanged (`#[cfg(target_os="android")] mod decode;` resolves to decode/mod.rs).

Verified: cargo-ndk check (aarch64-linux-android, clean) + the gradle cargoNdkDebug
build (arm64-v8a / armeabi-v7a / x86_64). On-device runtime re-verification still owed
per the plan (the two decode loops are a hot path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:06:24 +02:00