diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index be16e50..3249e01 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -34,6 +34,9 @@ suspend fun connectToHost( // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID. val gamepadPref = Gamepad.resolvePref(settings.gamepad) return withContext(Dispatchers.IO) { + // Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media + // sockets) — must be applied before connect, since sockets are tagged at creation. + NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode) NativeBridge.nativeConnect( host, port, w, h, hz, identity.certPem, identity.privateKeyPem, pinHex, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 8ed1280..59891c6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -55,13 +55,14 @@ data class Settings( */ val libraryEnabled: Boolean = true, /** - * Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs - * the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm). - * Off: a conservative profile (the standard `low-latency` key only), for a device that thermally - * throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency - * lock and HDMI game-mode signalling stay on regardless — they're harmless. + * "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder + * ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF + * max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency + * lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul + * pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some + * phones, so it's opt-in until it's proven per-device. */ - val lowLatencyMode: Boolean = true, + val lowLatencyMode: Boolean = false, /** * Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default): * a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits @@ -99,7 +100,7 @@ class SettingsStore(context: Context) { ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true), libraryEnabled = prefs.getBoolean(K_LIBRARY, true), - lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true), + lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, false), autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true), ) @@ -139,7 +140,14 @@ class SettingsStore(context: Context) { const val K_TOUCH_MODE = "touch_mode" const val K_GAMEPAD_UI = "gamepad_ui_enabled" const val K_LIBRARY = "library_enabled" - const val K_LOW_LATENCY = "low_latency_mode" + + /** + * Deliberately NOT the original `"low_latency_mode"` key: that one shipped default-ON, so + * any install that ever saved settings persisted `true` — under the old key, flipping the + * default to off would leave exactly the regressed devices stuck on the overhaul. The fresh + * key restarts everyone at the safe default; the stale one is abandoned unread. + */ + const val K_LOW_LATENCY = "low_latency_mode_experimental" const val K_AUTO_WAKE = "auto_wake_enabled" /** Legacy Boolean the enum replaced — read once as the migration default, never written. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 5d5ae1c..efd1712 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -326,9 +326,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an ) { c -> update(s.copy(compositor = c)) } ToggleRow( - title = "Low-latency mode", - subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " + - "device overheats or glitches during long sessions.", + title = "Low-latency mode (experimental)", + subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " + + "decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " + + "some devices — turn off if the stream misbehaves.", checked = s.lowLatencyMode, onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) }, ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 67be750..8ce26d4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -64,7 +64,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } // Touch model is fixed per session (re-keys the gesture handler below if it ever changes). val touchMode = initialSettings.touchMode - // Master low-latency toggle, resolved once for the session and passed to the decoder at start. + // "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the + // default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack — + // decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and + // HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect). val lowLatencyMode = initialSettings.lowLatencyMode // 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. @@ -118,7 +121,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API // 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission // beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release. + // Part of the experimental low-latency stack — not created at all when the toggle is off. val wifiLock = remember(handle) { + if (!lowLatencyMode) return@remember null val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { WifiManager.WIFI_MODE_FULL_LOW_LATENCY @@ -133,8 +138,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) runCatching { wifiLock?.acquire() } // HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) — - // the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API + // 30+. Part of the experimental low-latency stack. + if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window?.setPreferMinimalPostProcessing(true) } controller?.let { @@ -166,7 +172,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh controller?.show(WindowInsetsCompat.Type.systemBars()) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window?.setPreferMinimalPostProcessing(false) } runCatching { if (wifiLock?.isHeld == true) wifiLock.release() } @@ -191,11 +197,13 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { SurfaceView(ctx).apply { holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { - // Rank MediaCodecList decoders for the negotiated MIME (framework-only - // API) and hand the chosen one to Rust, which creates it by name and - // applies the per-SoC vendor low-latency keys. + // Low-latency mode: rank MediaCodecList decoders for the negotiated + // MIME (framework-only API) and hand the chosen one to Rust, which + // creates it by name and applies the per-SoC vendor low-latency keys. + // Off ⇒ no ranking: the platform resolves its default decoder for the + // MIME, exactly as before the overhaul. val mime = NativeBridge.nativeVideoMime(handle) - val choice = VideoDecoders.pickDecoder(mime) + val choice = if (lowLatencyMode) VideoDecoders.pickDecoder(mime) else null NativeBridge.nativeStartVideo( handle, holder.surface, @@ -204,7 +212,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { choice?.lowLatencyFeature ?: false, isTv, ) - NativeBridge.nativeStartAudio(handle) + NativeBridge.nativeStartAudio(handle, lowLatencyMode) if (micWanted) NativeBridge.nativeStartMic(handle) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 9619871..6ccdf19 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -123,6 +123,15 @@ object NativeBridge { */ external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean + /** + * Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport + * defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE + * [nativeConnect] (the tag is applied at socket creation); `HostConnect.connectToHost` does. + * The rest of the toggle rides explicit per-session parameters ([nativeStartVideo] / + * [nativeStartAudio]). Cheap (one atomic store); UI-safe. + */ + external fun nativeSetLowLatencyMode(enabled: Boolean) + /** * The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` / * `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this @@ -134,10 +143,12 @@ object NativeBridge { * Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the * decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for - * the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC - * tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised - * `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream - * refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started. + * the MIME — what the pre-overhaul client always did); [lowLatencyMode] is the user's + * "Low-latency mode (experimental)" toggle (off, the default, runs the original decode + * pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether + * [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI + * mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless + * hint otherwise). No-op if already started. */ external fun nativeStartVideo( handle: Long, @@ -183,10 +194,12 @@ object NativeBridge { external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean) /** - * Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. No-op - * if already started. Best-effort — a failure leaves video streaming. + * Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust. + * [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the + * HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video + * streaming. */ - external fun nativeStartAudio(handle: Long) + external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean) /** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */ external fun nativeStopAudio(handle: Long) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt index 3fd5887..63a2851 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt @@ -57,7 +57,17 @@ object VideoDecoders { val name = info.name val lower = name.lowercase() if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue + // Never a secure decoder: `.secure` names are the DRM-pipeline twins of the real + // decoder and require a secure surface — configuring one for a clear stream fails (or + // renders black). The plain twin is also in the list, so drop rather than rank + // (a `.secure` twin can otherwise OUT-score its plain sibling when only it advertises + // FEATURE_LowLatency). Moonlight filters the same way. + if (lower.endsWith(".secure")) continue val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue + val secureRequired = runCatching { + caps.isFeatureRequired(CodecCapabilities.FEATURE_SecurePlayback) + }.getOrDefault(false) + if (secureRequired) continue val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { info.isHardwareAccelerated diff --git a/clients/android/native/src/adpf.rs b/clients/android/native/src/adpf.rs index 6d7b9aa..006c661 100644 --- a/clients/android/native/src/adpf.rs +++ b/clients/android/native/src/adpf.rs @@ -103,8 +103,10 @@ pub struct HintSession { impl HintSession { /// Open a session hinting `tids` with an initial per-frame target of `target_ns` nanoseconds. /// `None` when ADPF is unavailable (device API < 33) or the platform declines — the caller then - /// runs unhinted (a no-op, not an error). - pub fn create(target_ns: i64, tids: &[i32]) -> Option { + /// runs unhinted (a no-op, not an error). `prefer_performance` (the experimental low-latency + /// mode) additionally biases the governor away from power efficiency (API 35+); off, the + /// session runs with the platform default, as it did before the overhaul. + pub fn create(target_ns: i64, tids: &[i32], prefer_performance: bool) -> Option { if target_ns <= 0 || tids.is_empty() { return None; } @@ -119,9 +121,11 @@ impl HintSession { // Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is // latency-critical, so we want it kept on fast cores at high clocks over battery savings. // Best-effort; absent below API 35. - if let Some(f) = api.set_prefer_power_efficiency { - // SAFETY: `session` is the live session just created; the fn takes it + a bool. - unsafe { f(session, false) }; + if prefer_performance { + if let Some(f) = api.set_prefer_power_efficiency { + // SAFETY: `session` is the live session just created; the fn takes it + a bool. + unsafe { f(session, false) }; + } } Some(Self { api, session }) } diff --git a/clients/android/native/src/audio.rs b/clients/android/native/src/audio.rs index 4ea0a63..5a538f6 100644 --- a/clients/android/native/src/audio.rs +++ b/clients/android/native/src/audio.rs @@ -116,8 +116,10 @@ pub struct AudioPlayback { impl AudioPlayback { /// Open AAudio (LowLatency, 48 kHz/f32, the host-resolved channel layout) with a realtime /// callback draining a jitter ring, then spawn the Opus decode thread. `None` on failure (the - /// caller leaves video streaming). - pub fn start(client: Arc) -> Option { + /// caller leaves video streaming). `game_audio` (the experimental low-latency mode) tags the + /// stream usage=Game for the HAL's game-audio routing; off, the stream is untagged as it was + /// before the overhaul. + pub fn start(client: Arc, game_audio: bool) -> Option { // Build playback from the host-RESOLVED channel count (never the request): 2 = stereo / // 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR. let channels = punktfunk_core::audio::normalize_channels(client.audio_channels) as usize; @@ -226,7 +228,7 @@ impl AudioPlayback { AudioCallbackResult::Continue }; - let stream = AudioStreamBuilder::new()? + let builder = AudioStreamBuilder::new()? .direction(AudioDirection::Output) .sample_rate(SAMPLE_RATE) // The wire order (FL FR FC LFE RL RR SL SR) is the standard AAudio/Android channel @@ -234,12 +236,19 @@ impl AudioPlayback { // from `channel_count` (the ndk crate's builder exposes no setChannelMask); the host // captures + Opus-encodes in exactly this order. .channel_count(channels as i32) - .format(AudioFormat::PCM_Float) - // Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies - // its low-latency game-audio routing/policy and it's grouped correctly with the - // game-mode profile. Advisory — ignored where the device has no such policy. - .usage(AudioUsage::Game) - .content_type(AudioContentType::Movie) + .format(AudioFormat::PCM_Float); + // Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies + // its low-latency game-audio routing/policy and it's grouped correctly with the + // game-mode profile. Advisory — ignored where the device has no such policy. Part of + // the experimental low-latency stack; off, the stream stays untagged. + let builder = if game_audio { + builder + .usage(AudioUsage::Game) + .content_type(AudioContentType::Movie) + } else { + builder + }; + let stream = builder .performance_mode(AudioPerformanceMode::LowLatency) .sharing_mode(sharing) .data_callback(Box::new(callback)) diff --git a/clients/android/native/src/decode.rs b/clients/android/native/src/decode.rs index 9e9e1b2..fa053b8 100644 --- a/clients/android/native/src/decode.rs +++ b/clients/android/native/src/decode.rs @@ -36,9 +36,11 @@ const IN_FLIGHT_CAP: usize = 64; /// this deep is a lost datagram (or an old host that never sends any) and gets evicted. const PENDING_SPLIT_CAP: usize = 256; -/// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as -/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop -/// presents a decoded frame the instant it's ready instead of waiting out a poll interval. +/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous +/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a +/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when +/// the user's "Low-latency mode (experimental)" toggle is ON — off, the sync loop always runs (the +/// original pipeline). const USE_ASYNC_DECODE: bool = true; /// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to @@ -50,8 +52,10 @@ pub(crate) struct DecodeOptions { /// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via /// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name. pub ll_feature: bool, - /// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒ - /// conservative, an escape hatch for a device that throttles under the clocks). + /// The user's "Low-latency mode (experimental)" master toggle. On ⇒ the full overhaul: async + /// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV + /// mode switch. Off (default) ⇒ the original pre-overhaul pipeline, kept as the known-good + /// baseline while the overhaul is experimental. pub low_latency_mode: bool, /// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's /// refresh mode, vs. the softer seamless hint on a phone/tablet. @@ -67,17 +71,16 @@ pub fn run( stats: Arc, opts: DecodeOptions, ) { - if USE_ASYNC_DECODE { + if opts.low_latency_mode && USE_ASYNC_DECODE { run_async(client, window, shutdown, stats, opts); } else { run_sync(client, window, shutdown, stats, opts); } } -/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind -/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short -/// output dequeue while input is backed up. -#[allow(dead_code)] +/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off, +/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the +/// only blocking wait is a short output dequeue while input is backed up. fn run_sync( client: Arc, window: NativeWindow, @@ -160,7 +163,11 @@ fn run_sync( // above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link // it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent // there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it). - if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) { + // The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack; + // off, every form factor gets the original soft seamless hint. + if mode.refresh_hz > 0 + && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode) + { log::debug!( "decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)", mode.refresh_hz @@ -319,8 +326,12 @@ fn run_sync( // or where the platform declines → `None`, and the loop runs unhinted). hint_tried = true; let tids = client.hot_thread_ids(); - boost_hot_threads(&tids); - hint = crate::adpf::HintSession::create(frame_period_ns, &tids); + // The pump/audio priority boost is part of the experimental low-latency stack; the + // ADPF session itself predates it and always runs (max-performance bias gated inside). + if low_latency_mode { + boost_hot_threads(&tids); + } + hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode); log::info!( "decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns", if hint.is_some() { @@ -396,12 +407,15 @@ fn create_codec(mime: &str, preferred: Option<&str>) -> Option { MediaCodec::from_decoder_type(mime) } -/// Apply the low-latency MediaFormat keys for `codec_name`. The standard AOSP `low-latency` key is -/// always set (API 30+, harmless/ignored elsewhere). When `aggressive` (the "Low-latency mode" -/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off -/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way -/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock -/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks. +/// Apply the low-latency MediaFormat keys for `codec_name`. +/// +/// `aggressive` = the "Low-latency mode (experimental)" master toggle. **Off** (default) ⇒ the +/// pre-overhaul key set, byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor +/// twin, `priority = 0` AND `operating-rate = MAX` set together — kept as the known-good baseline +/// (the profile every device streamed with before the overhaul). **On** ⇒ the Moonlight-parity +/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC +/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a +/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint. /// /// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm /// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA @@ -411,6 +425,12 @@ fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: // Standard key: request the no-reorder low-latency path where the platform decoder supports it. format.set_i32("low-latency", 1); if !aggressive { + // The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by + // other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate + // sentinel — decode each frame at max clocks rather than pacing to the frame rate. + format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1); + format.set_i32("priority", 0); // 0 = realtime + format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible" return; } // MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes). @@ -600,7 +620,11 @@ fn run_async( mode.width, mode.height ); - if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) { + // The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack; + // off, every form factor gets the original soft seamless hint. + if mode.refresh_hz > 0 + && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode) + { log::debug!( "decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)", mode.refresh_hz @@ -716,8 +740,12 @@ fn run_async( if !hint_tried { hint_tried = true; let tids = client.hot_thread_ids(); - boost_hot_threads(&tids); - hint = crate::adpf::HintSession::create(frame_period_ns, &tids); + // The pump/audio priority boost is part of the experimental low-latency stack; the + // ADPF session itself predates it and always runs (max-performance bias gated inside). + if low_latency_mode { + boost_hot_threads(&tids); + } + hint = crate::adpf::HintSession::create(frame_period_ns, &tids, low_latency_mode); log::info!( "decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns", if hint.is_some() { diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index d92fe9d..9ed5d0f 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -32,6 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde } } +/// `NativeBridge.nativeSetLowLatencyMode(enabled)` — apply the user's "Low-latency mode +/// (experimental)" toggle to the process-wide transport defaults, today just DSCP/QoS marking on +/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket +/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the +/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`). +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode( + _env: JNIEnv, + _this: JObject, + enabled: jboolean, +) { + punktfunk_core::transport::set_dscp_default(enabled != 0); +} + /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, /// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`. /// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game. diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index a5b32b5..9ed9411 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -233,14 +233,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta }) } -/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already -/// started or on a `0` handle. Best-effort: a failure leaves video streaming. +/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread. +/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio +/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video +/// streaming. #[cfg(target_os = "android")] #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio( _env: JNIEnv, _this: JObject, handle: jlong, + low_latency_mode: jboolean, ) { if handle == 0 { return; @@ -251,7 +254,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio( if guard.is_some() { return; // already playing } - match crate::audio::AudioPlayback::start(h.client.clone()) { + match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) { Some(p) => *guard = Some(p), None => log::error!("nativeStartAudio: playback init failed (video unaffected)"), } diff --git a/crates/punktfunk-core/src/transport/mod.rs b/crates/punktfunk-core/src/transport/mod.rs index 6cda77f..72c918c 100644 --- a/crates/punktfunk-core/src/transport/mod.rs +++ b/crates/punktfunk-core/src/transport/mod.rs @@ -6,7 +6,7 @@ mod qos; mod udp; pub use loopback::{loopback_pair, LoopbackTransport}; -pub use qos::{grow_socket_buffers, set_media_qos, MediaClass}; +pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass}; /// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected /// socket (the GameStream video sender) rather than going through [`UdpTransport`]. #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-core/src/transport/qos.rs b/crates/punktfunk-core/src/transport/qos.rs index cde2420..ceef431 100644 --- a/crates/punktfunk-core/src/transport/qos.rs +++ b/crates/punktfunk-core/src/transport/qos.rs @@ -7,11 +7,13 @@ //! [`set_media_qos`] DSCP-tags the latency-sensitive video/audio traffic (+ Linux `SO_PRIORITY`) so a //! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it //! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It -//! is **opt-in** (`PUNKTFUNK_DSCP=1`): DSCP can interact badly with some consumer ISPs/routers, and on +//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client +//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on //! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the //! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows). use std::net::UdpSocket; +use std::sync::atomic::{AtomicBool, Ordering}; /// Target kernel socket-buffer size (`SO_SNDBUF`/`SO_RCVBUF`). A high-resolution frame is a burst (a /// 5120×1440 keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default UDP @@ -66,17 +68,27 @@ impl MediaClass { } } -/// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi -/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so -/// tagging the video/audio sockets can win real airtime priority against other traffic on the link; -/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject -/// marked packets, so it stays opt-in there. `PUNKTFUNK_DSCP` overrides either way — `1`/`true`/`on` -/// forces it on, `0`/`false`/`off` forces it off (e.g. to rule QoS out while debugging a flaky AP). +/// Runtime default for DSCP marking when `PUNKTFUNK_DSCP` is unset (see [`set_dscp_default`]). +/// Off unless an embedder opts in — on Wi-Fi, access points commonly map DSCP to WMM access +/// categories (a real airtime-priority win), but wired paths rarely honour it and some bleach or +/// reject marked packets, so it never turns on by itself. +static DSCP_DEFAULT: AtomicBool = AtomicBool::new(false); + +/// Opt in to (or back out of) DSCP marking for sockets created from now on. Must be called BEFORE +/// connecting — the tag is applied at socket creation. The Android client ties this to its +/// experimental low-latency mode; `PUNKTFUNK_DSCP` still overrides in either direction. +pub fn set_dscp_default(enabled: bool) { + DSCP_DEFAULT.store(enabled, Ordering::Relaxed); +} + +/// Whether DSCP/QoS marking is enabled: `PUNKTFUNK_DSCP` when set (`1`/`true`/`on` forces it on, +/// `0`/`false`/`off` forces it off — e.g. to rule QoS out while debugging a flaky AP), else the +/// [`set_dscp_default`] runtime default. pub(crate) fn dscp_enabled() -> bool { match std::env::var("PUNKTFUNK_DSCP").as_deref() { Ok("1") | Ok("true") | Ok("on") => true, Ok("0") | Ok("false") | Ok("off") => false, - _ => cfg!(target_os = "android"), + _ => DSCP_DEFAULT.load(Ordering::Relaxed), } }