G14/G16 leg 3. This supersedes the nominal constant0e40b374shipped, which was always labelled a stopgap. Measured on glass 2026-08-07: a DualSense over USB into an Android phone, streaming to a Linux host, flat and face up, arrived as |accel| = 0.811 g where 1.000 was owed. The parse forwarded the pad's raw i16s verbatim, and raw device units are not wire units.0e40b374rescaled acceleration by the nominal 10000/8192 and deliberately left gyro alone, because a constant provably cannot fix gyro: the same still average showed this unit's accel calibration is near-identity (~1% off) while its gyro's emphatically is not — a near-identity gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. That scale is per unit, and the only thing that knows it is the pad. So the client now asks. HidUsbLink grows a GET_REPORT path — EP0, the exact mirror of the SET_REPORT it already had — and DsCapture reads the pad's IMU calibration feature report ONCE, while claiming it: 0x05 / 41 B on a DualSense or Edge, 0x02 / 37 B on a USB DualShock 4. DsDevice.MotionCal then applies hid-playstation's own arithmetic per axis, which is the same math the host's contract test (crates/pf-inject/tests/motion_contract.rs, SonyImuCalibration) reads from the other end: gyro raw × speed_2x × 20 / (|plus−bias| + |minus−bias|), accel (raw − (plus − range/2)) × 20000 / range. Long arithmetic, because the gyro multiplier overflows an Int, and clamped, because both are >1 multipliers and a full-scale flick would otherwise wrap the i16 into a motion in the opposite direction. Reading the blob also removes acceleration's residual ~1% factory bias that the nominal constant left behind. Once at claim and never per report. EP0 is independent of the interrupt endpoints so the read is safe alongside the reader thread, but a blocking control transfer in the report path would wreck capture latency, and the calibration is fixed for the life of the connection anyway. The capture logs the derived resolutions, which is the discriminator for whether a blob was read at all: a real pad declares ≈16 LSB per deg/s, the fallback reads back as exactly 20. A pad that refuses, answers short, or declares zeroes (a clone, a broken unit) keeps today's behaviour per axis — nominal accel, gyro straight through. Nothing here ever zeroes motion: slightly mis-scaled beats silent. Not covered. The axis frame is still untouched: this leg puts gravity on Y where the Apple leg put it on Z, so at least one client's frame is wrong, and settling it needs the bare-metal Linux reference reading G16 step 1 calls for. Rescaling is frame-independent, so it stands however that resolves — remapping is not, so it stays out. Bluetooth's grouped plus/minus layout is not implemented either: this path is USB-only by construction (Android exposes no raw path to a Classic pad), and a half-used generalisation would be a latent bug rather than a feature. Gate: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` green, 16 DsDeviceTest cases run 0 failed, and the five new ones were confirmed present in the JUnit XML rather than merely compiled. Non-vacuity checked by mutation — perturbing the gyro conversion fails 6 tests, including all four new ones that assert a number. On-glass re-verification owed, on the rig that measured the defect (DualSense → USB → phone → 192.168.1.21): at rest |a| = 1.00 g exactly via ~/gyroscope.py, and a nominal 90 deg yaw integrating to ~90 deg via ~/integrate.py — the same 90 deg that read ~62.7 deg before this change.
punktfunk — Android client (phone & TV)
The native Android app for streaming a punktfunk host to your phone, tablet, or Android TV. A Compose app that finds hosts on your network, pairs with a PIN, and streams at the display's own resolution — with hardware HEVC decode, HDR10, and controller support, built for both touch and the couch (D-pad / gamepad focus navigation).
Features
- Hardware decode — NDK
AMediaCodecHEVC →SurfaceView, including HDR10 (Main10 / BT.2020 PQ), with low-latency tuning and a live stats HUD. - Audio both ways — Opus + AAudio playback with a jitter ring, plus mic uplink to the host.
- Controller support — buttons + axes with rumble and HID feedback (lightbar / adaptive triggers); D-pad / gamepad focus navigation for TV and phone.
- Find hosts automatically — native mDNS discovery; first connect does a one-time SPAKE2 PIN pairing (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
- Compose UI — Connect / Settings / Stream screens with Material You theming.
Built for arm64-v8a + armeabi-v7a + x86_64 — the 32-bit armeabi-v7a slice is what keeps the
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
Get it
Published to Google Play (Internal Testing) — join the beta via the Discord. Per-device setup and pairing: docs.punktfunk.unom.io/docs/install-client.
How it's built — Rust-heavy
Kotlin can't import the cbindgen C header the way Swift can, so a native bridge is unavoidable. We
write it in Rust and link punktfunk-core directly — so the Android client reuses the Linux
client's orchestration (audio jitter ring, VK keymap inverse, latency/skew math, capture state
machine, trust logic) instead of re-porting it into Kotlin.
| Side | Owns |
|---|---|
Rust (native/ → libpunktfunk_android.so) |
the JNI seam, NativeClient (QUIC control + UDP data plane), AnnexB → AMediaCodec decode (incl. HDR10), Opus + AAudio audio + mic, controller feedback, latency math, trust/pairing, mdns-sd discovery |
Kotlin (app/, kit/) |
Compose UI, SurfaceView lifecycle, input capture, the Wi-Fi MulticastLock + permission UX, Keystore identity |
The single seam is io.unom.punktfunk.kit.NativeBridge ⇄ Java_io_unom_punktfunk_kit_NativeBridge_*.
native/ Rust cdylib (workspace member) — links punktfunk-core directly
src/lib.rs crate doc · JNI_OnLoad · version probes
src/session/ session lifecycle: connect/pair + trust, plane start/stop, input shims
src/decode.rs AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
src/audio.rs · src/mic.rs Opus + AAudio playback / mic uplink
src/feedback.rs · src/stats.rs rumble + HID feedback; live video stats
src/discovery.rs native mdns-sd browse of the host's _punktfunk._udp advert
app/ :app — Compose UI: Connect / Settings / Stream (phone + TV)
kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · Keymap · Keystore identity
Build & run
Prerequisites: Android SDK + NDK r30 (30.0.14904198), platforms;android-37.0,
build-tools;37.0.0, cmake;3.22.1 (builds libopus); JDK 21 (AGP 9.2 runs on JDK 17–21, not
a newer default); Rust with rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android and
cargo install cargo-ndk. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
2026.05.01 · compileSdk 37 · minSdk 28).
Android Studio: open clients/android — it uses its bundled JBR 21, and the cargoNdk* task
builds the .so as part of the normal build.
CLI (point Gradle at JDK 21 if your machine default is newer):
export JAVA_HOME="$(/usr/libexec/java_home -v 21)" # or your Temurin 21 path
cd clients/android
./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first
./gradlew :app:installDebug # onto a running emulator/device
# emulators from env setup: emulator -avd pf_phone | emulator -avd pf_tv
The debug APK lands in app/build/outputs/apk/debug/. Launch it, pick a host, pair, and stream.
Related
- Documentation — quick start, pairing, troubleshooting
- Project README — the host, the other clients, and how it all fits together