diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index 09029c4..e1d77eb 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -18,6 +18,8 @@ import android.provider.Settings; import android.text.TextUtils; import com.foxx.androidcast.display.ExternalDisplayCapturePolicy; +import com.foxx.androidcast.receiver.buffer.JitterBufferConfig; +import com.foxx.androidcast.receiver.buffer.JitterBufferMode; import com.foxx.androidcast.receiver.av.ReceiverAudioPreset; import com.foxx.androidcast.receiver.av.ReceiverVideoPreset; import com.foxx.androidcast.sender.SenderResolutionUiMode; @@ -34,6 +36,10 @@ public final class AppPreferences { private static final String KEY_THEME = "theme_mode"; private static final String KEY_LOCALE = "locale_mode"; private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio"; + private static final String KEY_RECEIVER_JITTER_BUFFER_MODE = "receiver_jitter_buffer_mode"; + private static final String KEY_RECEIVER_JITTER_TARGET_DELAY_MS = "receiver_jitter_target_delay_ms"; + private static final String KEY_SENDER_CODEC_HARDENING = "sender_codec_hardening"; + private static final String KEY_RECEIVER_CODEC_HARDENING = "receiver_codec_hardening"; private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay"; private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats"; private static final String KEY_DEV_STREAM_DUMP = "dev_stream_dump"; @@ -155,6 +161,61 @@ public final class AppPreferences { prefs(context).edit().putBoolean(KEY_PLAY_INCOMING_AUDIO, play).apply(); } + public static JitterBufferMode getReceiverJitterBufferMode(Context context) { + String name = prefs(context).getString(KEY_RECEIVER_JITTER_BUFFER_MODE, + JitterBufferMode.SMART.name()); + try { + return JitterBufferMode.valueOf(name); + } catch (IllegalArgumentException e) { + return JitterBufferMode.SMART; + } + } + + public static void setReceiverJitterBufferMode(Context context, JitterBufferMode mode) { + prefs(context).edit().putString(KEY_RECEIVER_JITTER_BUFFER_MODE, + mode != null ? mode.name() : JitterBufferMode.SMART.name()).apply(); + } + + public static int getReceiverJitterTargetDelayMs(Context context) { + return prefs(context).getInt(KEY_RECEIVER_JITTER_TARGET_DELAY_MS, + JitterBufferConfig.DEFAULT_TARGET_DELAY_MS); + } + + public static void setReceiverJitterTargetDelayMs(Context context, int delayMs) { + prefs(context).edit().putInt(KEY_RECEIVER_JITTER_TARGET_DELAY_MS, + JitterBufferConfig.clampDelay(delayMs)).apply(); + } + + public static CastSettings.CodecHardening getSenderCodecHardening(Context context) { + return readCodecHardening(context, KEY_SENDER_CODEC_HARDENING); + } + + public static void setSenderCodecHardening(Context context, CastSettings.CodecHardening mode) { + storeCodecHardening(context, KEY_SENDER_CODEC_HARDENING, mode); + } + + public static CastSettings.CodecHardening getReceiverCodecHardening(Context context) { + return readCodecHardening(context, KEY_RECEIVER_CODEC_HARDENING); + } + + public static void setReceiverCodecHardening(Context context, CastSettings.CodecHardening mode) { + storeCodecHardening(context, KEY_RECEIVER_CODEC_HARDENING, mode); + } + + private static CastSettings.CodecHardening readCodecHardening(Context context, String key) { + String name = prefs(context).getString(key, CastSettings.CodecHardening.STRICT.name()); + try { + return CastSettings.CodecHardening.valueOf(name); + } catch (IllegalArgumentException e) { + return CastSettings.CodecHardening.STRICT; + } + } + + private static void storeCodecHardening(Context context, String key, CastSettings.CodecHardening mode) { + prefs(context).edit().putString(key, + mode != null ? mode.name() : CastSettings.CodecHardening.STRICT.name()).apply(); + } + /** Developer: metrics HTML overlay on receiver video (default on). */ public static boolean isShowReceiverDebugOverlay(Context context) { return prefs(context).getBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, true); @@ -293,6 +354,7 @@ public final class AppPreferences { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); } + s.setCodecHardening(getSenderCodecHardening(context)); clampAlphaTransport(s); return s; } @@ -665,6 +727,7 @@ public final class AppPreferences { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); } + s.setCodecHardening(getReceiverCodecHardening(context)); clampAlphaTransport(s); return s; } diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java index 739a33a..105b42a 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettings.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java @@ -34,9 +34,9 @@ public class CastSettings implements Serializable { public enum VideoCodec { AUTO, H264, H265, PASSTHROUGH, - /** Future software VP8 (UI disabled until integrated). */ + /** libvpx software VP8 (requires native build). */ LIBVPX_VP8, - /** Future software VP9 (UI disabled until integrated). */ + /** libvpx software VP9 (requires native build). */ LIBVPX_VP9 } @@ -97,6 +97,12 @@ public class CastSettings implements Serializable { ADAPTIVE_EXPERIMENTAL } + /** When STRICT, explicit Opus/Speex/libvpx choices fail without native libs (no AAC/HW stub). */ + public enum CodecHardening { + STRICT, + ALLOW_STUB + } + public enum Resolution { FULL(1.0f, 0, 0), THREE_QUARTERS(0.75f, 0, 0), @@ -152,6 +158,8 @@ public class CastSettings implements Serializable { private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode(); private StreamProtection streamProtection = StreamProtection.NONE; private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT; + /** Local-only (AppPreferences); not on cast-settings wire. */ + private transient CodecHardening codecHardening = CodecHardening.STRICT; /** Filled after codec handshake (e.g. video/avc). */ private transient String negotiatedVideoMime; /** Filled when AUTO (or fallback) resolves against peer audio caps. */ @@ -303,6 +311,18 @@ public class CastSettings implements Serializable { ? receiverDisplayResolution : ReceiverDisplayResolution.AUTO_FIT; } + public CodecHardening getCodecHardening() { + return codecHardening != null ? codecHardening : CodecHardening.STRICT; + } + + public void setCodecHardening(CodecHardening codecHardening) { + this.codecHardening = codecHardening != null ? codecHardening : CodecHardening.STRICT; + } + + public boolean isCodecHardeningStrict() { + return getCodecHardening() == CodecHardening.STRICT; + } + public boolean isCameraCapture() { return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT; } @@ -327,6 +347,7 @@ public class CastSettings implements Serializable { captureMode = other.captureMode; streamProtection = other.streamProtection; receiverDisplayResolution = other.receiverDisplayResolution; + codecHardening = other.codecHardening; negotiatedVideoMime = other.negotiatedVideoMime; resolvedAudioCodec = other.resolvedAudioCodec; } diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index 00e784b..caa6517 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -22,6 +22,7 @@ import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; +import com.foxx.androidcast.receiver.buffer.JitterBufferMode; import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.ui.SettingsEnumSpinner; import com.foxx.androidcast.ui.SettingsStringArrayAdapter; @@ -111,6 +112,9 @@ public final class CastSettingsBinder { bindStreamProtection(activity, settings); bindAudioSpinner(activity, settings, R.id.spinner_sender_audio); bindAudioCodec(activity, settings); + bindCodecHardening(activity, R.id.spinner_sender_codec_hardening, + AppPreferences.getSenderCodecHardening(activity), + AppPreferences::setSenderCodecHardening); } public static void bindReceiver(AppCompatActivity activity, CastSettings settings) { @@ -118,6 +122,9 @@ public final class CastSettingsBinder { bindTransport(activity, settings, R.id.spinner_receiver_transport, true); bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network); + bindJitterBuffer(activity); + bindCodecHardening(activity, R.id.spinner_receiver_codec_hardening, + AppPreferences.getReceiverCodecHardening(activity), AppPreferences::setReceiverCodecHardening); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); if (audioPlay != null) { bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity), @@ -136,6 +143,8 @@ public final class CastSettingsBinder { readAudioSpinner(activity, settings, R.id.spinner_sender_audio); readAudioCodec(activity, settings); settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); + readCodecHardening(activity, R.id.spinner_sender_codec_hardening, AppPreferences::setSenderCodecHardening); + settings.setCodecHardening(AppPreferences.getSenderCodecHardening(activity)); AppPreferences.saveSenderDefaults(activity, settings); } @@ -144,14 +153,81 @@ public final class CastSettingsBinder { readTransport(activity, settings, R.id.spinner_receiver_transport); readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); readNetworkAdoption(activity, settings, R.id.spinner_receiver_network); + readJitterBuffer(activity); + readCodecHardening(activity, R.id.spinner_receiver_codec_hardening, AppPreferences::setReceiverCodecHardening); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); if (audioPlay != null) { AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0); } settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); + settings.setCodecHardening(AppPreferences.getReceiverCodecHardening(activity)); AppPreferences.saveReceiverDefaults(activity, settings); } + private static void bindJitterBuffer(AppCompatActivity activity) { + Spinner spinner = activity.findViewById(R.id.spinner_receiver_jitter_buffer); + if (spinner == null) { + return; + } + JitterBufferMode[] modes = JitterBufferMode.values(); + String[] labels = new String[modes.length]; + for (int i = 0; i < modes.length; i++) { + labels[i] = jitterModeLabel(activity, modes[i]); + } + bindEnumSpinner(spinner, modes, labels, AppPreferences.getReceiverJitterBufferMode(activity), + mode -> AppPreferences.setReceiverJitterBufferMode(activity, mode)); + } + + private static void readJitterBuffer(AppCompatActivity activity) { + Spinner spinner = activity.findViewById(R.id.spinner_receiver_jitter_buffer); + if (spinner == null) { + return; + } + readEnumSpinner(spinner, (JitterBufferMode mode) -> AppPreferences.setReceiverJitterBufferMode(activity, mode)); + } + + private interface CodecHardeningStore { + void store(AppCompatActivity activity, CastSettings.CodecHardening mode); + } + + private static void bindCodecHardening(AppCompatActivity activity, int spinnerId, + CastSettings.CodecHardening current, CodecHardeningStore store) { + Spinner spinner = activity.findViewById(spinnerId); + if (spinner == null) { + return; + } + CastSettings.CodecHardening[] modes = CastSettings.CodecHardening.values(); + String[] labels = new String[] { + activity.getString(R.string.codec_hardening_strict), + activity.getString(R.string.codec_hardening_allow_stub) + }; + bindEnumSpinner(spinner, modes, labels, current, + (CastSettings.CodecHardening mode) -> store.store(activity, mode)); + } + + private static void readCodecHardening(AppCompatActivity activity, int spinnerId, + CodecHardeningStore store) { + Spinner spinner = activity.findViewById(spinnerId); + if (spinner == null) { + return; + } + readEnumSpinner(spinner, (CastSettings.CodecHardening mode) -> store.store(activity, mode)); + } + + private static String jitterModeLabel(AppCompatActivity activity, JitterBufferMode mode) { + switch (mode) { + case SIMPLE: + return activity.getString(R.string.jitter_mode_simple); + case FIFO: + return activity.getString(R.string.jitter_mode_fifo); + case LIFO: + return activity.getString(R.string.jitter_mode_lifo); + case SMART: + default: + return activity.getString(R.string.jitter_mode_smart); + } + } + private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId, boolean unused) { Spinner spinner = activity.findViewById(spinnerId); diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java index af40018..0593b6d 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java @@ -21,6 +21,8 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.receiver.StreamMetrics; +import com.foxx.androidcast.receiver.buffer.JitterBufferStats; + import java.util.ArrayList; import java.util.List; @@ -37,6 +39,15 @@ public final class CastDiagnosticsFormatter { CastSettings localSettings, CastSettings remoteSettings, NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent, CastMtuSampler.Info mtuInfo) { + return formatHtml(metrics, transport, videoCodec, streamW, streamH, renderW, renderH, idle, + localSettings, remoteSettings, peer, lastLocal, viewZoomPercent, mtuInfo, null); + } + + public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec, + int streamW, int streamH, int renderW, int renderH, boolean idle, + CastSettings localSettings, CastSettings remoteSettings, + NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent, + CastMtuSampler.Info mtuInfo, ReceiverDiagnosticsExtras receiverExtras) { float inFps = metrics.getInFps(); float renderFps = metrics.getRenderFps(); int bwInKbps = metrics.getBwInKbps(); @@ -100,6 +111,29 @@ public final class CastDiagnosticsFormatter { congestionLevel = Math.max(congestionLevel, lastLocal.congestionLevel); } + int jitterMs = 0; + int protectionOverheadPct = 0; + long nackRetransmits = 0; + long jitterBufferDrops = 0; + if (peer != null) { + if (peer.jitterMs > 0) { + jitterMs = peer.jitterMs; + } + protectionOverheadPct = peer.protectionOverheadPercent; + nackRetransmits = peer.nackRetransmits; + jitterBufferDrops = peer.jitterBufferDrops; + } + if (lastLocal != null) { + jitterMs = Math.max(jitterMs, lastLocal.jitterMs); + protectionOverheadPct = Math.max(protectionOverheadPct, lastLocal.protectionOverheadPercent); + nackRetransmits = Math.max(nackRetransmits, lastLocal.nackRetransmits); + jitterBufferDrops = Math.max(jitterBufferDrops, lastLocal.jitterBufferDrops); + } + int codecRenegCount = receiverExtras != null ? receiverExtras.codecRenegotiationCount : 0; + if (lastLocal != null && lastLocal.codecRenegotiationCount > codecRenegCount) { + codecRenegCount = lastLocal.codecRenegotiationCount; + } + boolean audioRequested = remoteSettings != null && remoteSettings.isAudioRequested(); List hints = buildHints(inFps, renderFps, bwInKbps, rttMs, videoPackets); @@ -123,7 +157,7 @@ public final class CastDiagnosticsFormatter { html.append("Codec: ") .append(escape(CodecNegotiator.displayName(videoCodec))).append("
"); } - appendCodecStackSection(html, localSettings, remoteSettings); + appendCodecStackSection(html, localSettings, remoteSettings, codecRenegCount); html.append("Stream: ").append(streamW).append('x').append(streamH); if (viewZoomPercent != 0) { @@ -142,6 +176,9 @@ public final class CastDiagnosticsFormatter { html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a", suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f)); html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350)); + if (jitterMs > 0) { + html.append(" Jitter: ").append(colored(jitterMs + " ms", jitterMs > 120)); + } DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing(); if (diagPing.enabled && diagPing.pongsRx > 0) { int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs; @@ -158,10 +195,15 @@ public final class CastDiagnosticsFormatter { html.append("
"); html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0)); + if (protectionOverheadPct > 0) { + html.append(" OH: ").append(colored(protectionOverheadPct + "%", protectionOverheadPct > 25)); + } html.append("
"); + appendJitterBufferSection(html, receiverExtras, jitterBufferDrops); + appendUdpLossSection(html, peer, lastLocal); - appendProtectionSection(html, peer, lastLocal); + appendProtectionSection(html, peer, lastLocal, nackRetransmits); html.append("Ctrl: ").append(escape(ctrl)); html.append("
"); @@ -374,7 +416,7 @@ public final class CastDiagnosticsFormatter { } private static void appendCodecStackSection(StringBuilder html, CastSettings localSettings, - CastSettings remoteSettings) { + CastSettings remoteSettings, int codecRenegCount) { CastSettings prefs = remoteSettings != null ? remoteSettings : localSettings; html.append("Video stack: "); html.append(escape(CodecSessionRegistry.getVideoBackend().label)); @@ -391,6 +433,11 @@ public final class CastDiagnosticsFormatter { html.append(" · pref "); html.append(escape(PassthroughCodecPolicy.displayAudioPreference( prefs != null ? prefs.getAudioCodec() : CodecSessionRegistry.getAudioPreference()))); + html.append(" · live ").append(escape(CastDiagnosticsLabels.resolvedAudioCodec(prefs))); + if (codecRenegCount > 0) { + html.append(" · reneg×") + .append(codecRenegCount).append(""); + } if (prefs != null && prefs.isAudioRequested()) { CastSettings.AudioCodec ac = prefs.getAudioCodec(); if (ac == CastSettings.AudioCodec.OPUS) { @@ -412,8 +459,37 @@ public final class CastDiagnosticsFormatter { html.append("
"); } + private static void appendJitterBufferSection(StringBuilder html, ReceiverDiagnosticsExtras extras, + long statsDrops) { + if (extras == null && statsDrops == 0) { + return; + } + html.append("Jitter buf: "); + if (extras != null) { + html.append(escape(CastDiagnosticsLabels.jitterBufferMode(extras.jitterMode))); + JitterBufferStats stats = extras.jitterStats; + if (stats != null) { + html.append(" · depth ").append(colored(String.valueOf(stats.depth), stats.depth > 12)); + if (stats.targetDelayMs > 0) { + html.append(" · target ").append(stats.targetDelayMs).append(" ms"); + } + if (stats.measuredJitterMs > 0) { + html.append(" · meas ").append(colored(stats.measuredJitterMs + " ms", + stats.measuredJitterMs > 120)); + } + long drops = Math.max(stats.drops, statsDrops); + if (drops > 0) { + html.append(" · ").append(colored("drop " + drops, true)); + } + } + } else if (statsDrops > 0) { + html.append(colored("drops " + statsDrops, true)); + } + html.append("
"); + } + private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer, - NetworkStatsSnapshot local) { + NetworkStatsSnapshot local, long nackRetransmits) { String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE"); String summary = CodecSessionRegistry.getProtectionSummary(); html.append("Protect: "); @@ -432,8 +508,11 @@ public final class CastDiagnosticsFormatter { .append(""); } } - if (nackReq > 0) { + if (nackReq > 0 || nackRetransmits > 0) { html.append("
NACK events=").append(nackReq); + if (nackRetransmits > 0) { + html.append(" · retx=").append(nackRetransmits); + } } html.append("
"); } diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java index 7e30de5..b2d5d65 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java @@ -19,7 +19,35 @@ public final class CastDiagnosticsLabels { private CastDiagnosticsLabels() {} public static String congestionController() { - return "PassThroughNetworkControlPlane (heartbeat stats)"; + return "AdaptiveNetworkControlPlane (BWE + jitter probe)"; + } + + public static String jitterBufferMode(com.foxx.androidcast.receiver.buffer.JitterBufferMode mode) { + if (mode == null) { + return "SMART (default)"; + } + switch (mode) { + case SIMPLE: + return "Simple (legacy burst-drop)"; + case FIFO: + return "FIFO (delay queue)"; + case LIFO: + return "LIFO (newest first)"; + case SMART: + default: + return "Smart (adaptive, payload-aware)"; + } + } + + public static String resolvedAudioCodec(CastSettings settings) { + if (settings == null) { + return "AAC (default)"; + } + CastSettings.AudioCodec resolved = settings.getResolvedAudioCodec(); + if (resolved != null) { + return audioCodec(resolved) + " (session)"; + } + return audioCodec(settings.getAudioCodec()); } public static String bitrateMode(CastSettings.BitrateMode mode) { diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/ReceiverDiagnosticsExtras.java b/app/src/main/java/com/foxx/androidcast/diagnostics/ReceiverDiagnosticsExtras.java new file mode 100644 index 0000000..034476a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/ReceiverDiagnosticsExtras.java @@ -0,0 +1,18 @@ +package com.foxx.androidcast.diagnostics; + +import com.foxx.androidcast.receiver.buffer.JitterBufferMode; +import com.foxx.androidcast.receiver.buffer.JitterBufferStats; + +/** Receiver-only overlay fields (jitter buffer, codec reneg). */ +public final class ReceiverDiagnosticsExtras { + public final JitterBufferMode jitterMode; + public final JitterBufferStats jitterStats; + public final int codecRenegotiationCount; + + public ReceiverDiagnosticsExtras(JitterBufferMode jitterMode, JitterBufferStats jitterStats, + int codecRenegotiationCount) { + this.jitterMode = jitterMode != null ? jitterMode : JitterBufferMode.SMART; + this.jitterStats = jitterStats; + this.codecRenegotiationCount = Math.max(0, codecRenegotiationCount); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/AdaptiveCodecController.java b/app/src/main/java/com/foxx/androidcast/media/AdaptiveCodecController.java new file mode 100644 index 0000000..142daf6 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/AdaptiveCodecController.java @@ -0,0 +1,99 @@ +package com.foxx.androidcast.media; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastSession; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.sender.CodecCatalog; + +import android.util.Log; + +import java.io.IOException; +import java.util.List; + +/** Initiates and completes mid-session codec switches (transparent to user). */ +public final class AdaptiveCodecController { + private static final String TAG = "AdaptiveCodec"; + private static final long RENEG_COOLDOWN_MS = 8_000; + + private final CastSession session; + private final boolean isSender; + private final CastSettings settings; + private final CodecRenegotiationManager.Listener listener; + private long lastRenegMs; + private boolean renegInFlight; + + public AdaptiveCodecController(CastSession session, boolean isSender, CastSettings settings, + CodecRenegotiationManager.Listener listener) { + this.session = session; + this.isSender = isSender; + this.settings = settings; + this.listener = listener; + } + + public void tick(NetworkStatsSnapshot peer, String currentVideoMime, + CastSettings.AudioCodec currentAudio) { + if (renegInFlight || session == null || settings == null) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastRenegMs < RENEG_COOLDOWN_MS) { + return; + } + CodecRenegotiationManager.Selection suggestion = CodecRenegotiationManager.suggestForNetwork( + settings, peer, currentVideoMime, currentAudio); + if (suggestion == null) { + return; + } + try { + initiateRenegotiation(suggestion); + lastRenegMs = now; + } catch (IOException e) { + Log.w(TAG, "codec reneg failed: " + e.getMessage()); + } + } + + public void initiateRenegotiation(CodecRenegotiationManager.Selection desired) throws IOException { + renegInFlight = true; + List caps = isSender ? CodecCatalog.localEncoderMimes() : CodecCatalog.localDecoderMimes(); + session.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(caps)); + session.send(CastProtocol.MSG_CODEC_SELECTED, + CodecRenegotiationManager.buildSelectedPayload(desired.videoMime, desired.audioCodec)); + applyLocal(desired, true); + renegInFlight = false; + } + + public boolean handleCaps(List peerCaps) throws IOException { + CodecRenegotiationManager.Selection sel = CodecRenegotiationManager.negotiateFromCaps( + peerCaps, settings, isSender); + List localCaps = isSender ? CodecCatalog.localEncoderMimes() + : CodecCatalog.localDecoderMimes(); + session.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(localCaps)); + session.send(CastProtocol.MSG_CODEC_SELECTED, + CodecRenegotiationManager.buildSelectedPayload(sel.videoMime, sel.audioCodec)); + applyLocal(sel, false); + lastRenegMs = System.currentTimeMillis(); + return true; + } + + public void handleSelected(byte[] payload) throws IOException { + CastProtocol.CodecSelection sel = CodecRenegotiationManager.parseSelection(payload); + if (settings != null && sel.videoMime != null + && sel.videoMime.equals(settings.getNegotiatedVideoMime()) + && sel.audioCodec == settings.getResolvedAudioCodec()) { + return; + } + applyLocal(new CodecRenegotiationManager.Selection(sel.videoMime, sel.audioCodec), false); + lastRenegMs = System.currentTimeMillis(); + } + + private void applyLocal(CodecRenegotiationManager.Selection sel, boolean initiatedLocally) { + if (settings != null) { + settings.setNegotiatedVideoMime(sel.videoMime); + settings.setResolvedAudioCodec(sel.audioCodec); + } + if (listener != null) { + listener.onCodecRenegotiated(sel, initiatedLocally); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/AudioNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/AudioNegotiator.java index 9171956..2ada7b8 100644 --- a/app/src/main/java/com/foxx/androidcast/media/AudioNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/media/AudioNegotiator.java @@ -60,7 +60,7 @@ public final class AudioNegotiator { return CastSettings.AudioCodec.AAC; } CastSettings.AudioCodec resolved = negotiate( - CastCodecFlags.localAudioMask(), + CastCodecFlags.localAudioMask(settings.isCodecHardeningStrict()), peerAudioMask, settings.getAudioCodec(), settings, diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecRenegotiationManager.java b/app/src/main/java/com/foxx/androidcast/media/CodecRenegotiationManager.java new file mode 100644 index 0000000..27666c8 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/CodecRenegotiationManager.java @@ -0,0 +1,149 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.CastCodecFlags; +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.sender.CodecCatalog; + +import java.io.IOException; +import java.util.List; + +/** + * Mid-session codec renegotiation using existing {@code MSG_CODEC_CAPS} / {@code MSG_CODEC_SELECTED}. + */ +public final class CodecRenegotiationManager { + public static final class Selection { + public final String videoMime; + public final CastSettings.AudioCodec audioCodec; + + public Selection(String videoMime, CastSettings.AudioCodec audioCodec) { + this.videoMime = videoMime; + this.audioCodec = audioCodec != null ? audioCodec : CastSettings.AudioCodec.AAC; + } + } + + public interface Listener { + void onCodecRenegotiated(Selection selection, boolean initiatedLocally); + } + + private CodecRenegotiationManager() {} + + public static Selection negotiateFromCaps(List peerCaps, CastSettings localSettings, + boolean isSender) { + List localCaps = isSender ? CodecCatalog.localEncoderMimes() + : CodecCatalog.localDecoderMimes(); + String videoMime = CodecNegotiator.negotiate(peerCaps, localCaps, + localSettings.getVideoCodec(), localSettings, isSender ? "sender-reneg" : "receiver-reneg"); + int senderMask = isSender ? CastCodecFlags.localAudioMask() : peerAudioMaskFromSettings(localSettings); + int receiverMask = isSender ? peerAudioMaskFromSettings(localSettings) : CastCodecFlags.localAudioMask(); + CastSettings.AudioCodec audio = AudioNegotiator.negotiate(senderMask, receiverMask, + localSettings.getAudioCodec(), localSettings, isSender ? "sender-reneg" : "receiver-reneg"); + return new Selection(videoMime, audio); + } + + public static CastProtocol.CodecSelection parseSelection(byte[] payload) throws IOException { + return CastProtocol.parseCodecSelection(payload); + } + + public static byte[] buildSelectedPayload(String videoMime, CastSettings.AudioCodec audio) throws IOException { + return CastProtocol.codecSelectedPayload(videoMime, audio); + } + + private static int peerAudioMaskFromSettings(CastSettings settings) { + if (settings != null && settings.getResolvedAudioCodec() != null) { + return CastCodecFlags.maskForAudio(settings.getResolvedAudioCodec()); + } + return CastCodecFlags.AUDIO_AAC | CastCodecFlags.AUDIO_OPUS | CastCodecFlags.AUDIO_SPEEX; + } + + /** Suggest lower-bandwidth codecs when congested; higher when link is healthy. */ + public static Selection suggestForNetwork(CastSettings settings, NetworkStatsSnapshot peer, + String currentVideoMime, CastSettings.AudioCodec currentAudio) { + CastSettings copy = new CastSettings(); + copy.copyFrom(settings); + CastSettings.AudioCodec audio = currentAudio != null ? currentAudio : CastSettings.AudioCodec.AAC; + String videoMime = currentVideoMime != null ? currentVideoMime : MediaFormat.MIMETYPE_VIDEO_AVC; + boolean congested = peer != null && (peer.congestionLevel >= 2 || peer.lossRatio > 0.08f + || peer.rttMs > 600); + boolean healthy = peer != null && peer.congestionLevel == 0 && peer.lossRatio < 0.01f + && peer.rttMs > 0 && peer.rttMs < 180 + && peer.recvBitrateKbps > 2500; + if (congested) { + copy.setVideoCodec(downgradeVideo(settings.getVideoCodec(), videoMime)); + copy.setAudioCodec(downgradeAudio(audio)); + } else if (healthy) { + copy.setVideoCodec(upgradeVideo(settings.getVideoCodec(), videoMime)); + copy.setAudioCodec(upgradeAudio(audio)); + } else { + return null; + } + String newVideo = mimeForVideoPreference(copy.getVideoCodec(), videoMime); + CastSettings.AudioCodec newAudio = copy.getAudioCodec(); + if (newVideo.equals(videoMime) && newAudio == audio) { + return null; + } + return new Selection(newVideo, newAudio); + } + + private static String mimeForVideoPreference(CastSettings.VideoCodec pref, String currentMime) { + if (pref == null) { + return currentMime; + } + switch (pref) { + case LIBVPX_VP8: + return MediaFormat.MIMETYPE_VIDEO_VP8; + case LIBVPX_VP9: + return MediaFormat.MIMETYPE_VIDEO_VP9; + case H265: + return MediaFormat.MIMETYPE_VIDEO_HEVC; + case H264: + return MediaFormat.MIMETYPE_VIDEO_AVC; + case AUTO: + default: + return currentMime != null ? currentMime : MediaFormat.MIMETYPE_VIDEO_AVC; + } + } + + private static CastSettings.VideoCodec downgradeVideo(CastSettings.VideoCodec pref, String mime) { + if (MediaFormat.MIMETYPE_VIDEO_VP9.equals(mime)) { + return CastSettings.VideoCodec.LIBVPX_VP8; + } + if (MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime)) { + return CastSettings.VideoCodec.H264; + } + if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) { + return CastSettings.VideoCodec.H264; + } + return pref != null ? pref : CastSettings.VideoCodec.AUTO; + } + + private static CastSettings.VideoCodec upgradeVideo(CastSettings.VideoCodec pref, String mime) { + if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) { + return CastSettings.VideoCodec.LIBVPX_VP8; + } + if (MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime)) { + return CastSettings.VideoCodec.LIBVPX_VP9; + } + return pref != null ? pref : CastSettings.VideoCodec.AUTO; + } + + private static CastSettings.AudioCodec downgradeAudio(CastSettings.AudioCodec audio) { + if (audio == CastSettings.AudioCodec.OPUS) { + return CastSettings.AudioCodec.AAC; + } + if (audio == CastSettings.AudioCodec.SPEEX) { + return CastSettings.AudioCodec.AAC; + } + return audio; + } + + private static CastSettings.AudioCodec upgradeAudio(CastSettings.AudioCodec audio) { + if (audio == CastSettings.AudioCodec.AAC) { + return CastSettings.AudioCodec.OPUS; + } + return audio; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java b/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java index 8177282..60b034a 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java @@ -15,18 +15,27 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.media.AudioNegotiator; import com.foxx.androidcast.sender.AudioEncoder; +import java.io.IOException; + /** Creates audio encoder sinks. */ public final class AudioCodecFactory { private AudioCodecFactory() {} - public static AudioEncoderSink createEncoder(CastSettings settings) { + public static AudioEncoderSink createEncoder(CastSettings settings) throws IOException { CastSettings.AudioCodec preference = settings != null ? AudioNegotiator.effective(settings) : CastSettings.AudioCodec.AAC; - return createEncoder(preference); + boolean strict = settings != null && settings.isCodecHardeningStrict(); + return createEncoder(preference, strict); } - public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) { + public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) throws IOException { + return createEncoder(preference, true); + } + + public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference, boolean strict) + throws IOException { CodecSessionRegistry.setAudioPreference(preference); + CodecAvailabilityGuard.requireAudio(preference, strict); AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true); switch (backend) { case OPUS_NATIVE: @@ -36,6 +45,9 @@ public final class AudioCodecFactory { case PASSTHROUGH_DEBUG: case OPUS_STUB: case SPEEX_STUB: + if (strict) { + throw new IOException("Native audio codec required for " + preference.name()); + } return new PassthroughAudioEncoderSink(new AudioEncoder(), preference); default: return new AudioEncoder(); diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuard.java b/app/src/main/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuard.java new file mode 100644 index 0000000..d82dc10 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuard.java @@ -0,0 +1,36 @@ +package com.foxx.androidcast.media.codec; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; + +import java.io.IOException; + +/** Ensures explicit native codec choices are honored when hardening is enabled. */ +public final class CodecAvailabilityGuard { + private CodecAvailabilityGuard() {} + + public static void requireVideo(CastSettings.VideoCodec preference, boolean strict) throws IOException { + if (!strict || preference == null) { + return; + } + if (preference == CastSettings.VideoCodec.LIBVPX_VP8 + || preference == CastSettings.VideoCodec.LIBVPX_VP9) { + if (!NativeCodecBridge.isLibvpxAvailable()) { + throw new IOException("libvpx required for " + preference.name() + + " (build native codecs or disable strict hardening)"); + } + } + } + + public static void requireAudio(CastSettings.AudioCodec preference, boolean strict) throws IOException { + if (!strict || preference == null) { + return; + } + if (preference == CastSettings.AudioCodec.OPUS && !NativeCodecBridge.isOpusAvailable()) { + throw new IOException("Opus native library required (strict hardening)"); + } + if (preference == CastSettings.AudioCodec.SPEEX && !NativeCodecBridge.isSpeexAvailable()) { + throw new IOException("Speex native library required (strict hardening)"); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java index 68ca7ea..8a0011a 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java @@ -245,4 +245,15 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink { } return MediaFormat.MIMETYPE_VIDEO_VP8; } + + @Override + public boolean updateBitrate(int bitrateBps) { + if (bitrateBps <= 0) { + return false; + } + if (nativeHandle.get() != 0L) { + return NativeCodecBridge.vpxSetBitrate(nativeHandle.get(), bitrateBps / 1000); + } + return hwFallback.updateBitrate(bitrateBps); + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughVideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughVideoEncoderSink.java index 370d7d1..a33d03a 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughVideoEncoderSink.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughVideoEncoderSink.java @@ -69,6 +69,11 @@ final class PassthroughVideoEncoderSink implements VideoEncoderSink { delegate.stop(); } + @Override + public boolean updateBitrate(int bitrateBps) { + return delegate.updateBitrate(bitrateBps); + } + CastSettings.VideoCodec getPreference() { return preference; } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java index 0ca1df9..ca6f245 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java @@ -15,17 +15,28 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import com.foxx.androidcast.sender.VideoEncoder; +import java.io.IOException; + /** Creates video encoder sinks; libvpx used when native library is linked. */ public final class VideoCodecFactory { private VideoCodecFactory() {} - public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) { + public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) throws IOException { + return createEncoder(preference, true); + } + + public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference, boolean strict) + throws IOException { CodecSessionRegistry.setVideoPreference(preference); + CodecAvailabilityGuard.requireVideo(preference, strict); if (preference == CastSettings.VideoCodec.LIBVPX_VP8 || preference == CastSettings.VideoCodec.LIBVPX_VP9) { if (NativeCodecBridge.isLibvpxAvailable()) { return new LibvpxVideoEncoderSink(preference); } + if (strict) { + throw new IOException("libvpx native library required for " + preference.name()); + } CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null); return new PassthroughVideoEncoderSink(new VideoEncoder(), preference); } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/VideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/VideoEncoderSink.java index 166d6bf..5252d15 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/VideoEncoderSink.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/VideoEncoderSink.java @@ -42,4 +42,9 @@ public interface VideoEncoderSink { void queueYuvFrame(byte[] yuv, long ptsUs); void stop(); + + /** Live bitrate adjustment during an active session (bps). Returns true if applied. */ + default boolean updateBitrate(int bitrateBps) { + return false; + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java index e01385f..302e678 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java @@ -103,6 +103,13 @@ public final class NativeCodecBridge { } } + public static boolean vpxSetBitrate(long handle, int bitrateKbps) { + if (handle == 0L || bitrateKbps <= 0) { + return false; + } + return nativeVpxSetBitrate(handle, bitrateKbps); + } + /* ── Opus encoder ─────────────────────────────────────────────────── */ public static long opusEncoderCreate(int sampleRate, int channels, int bitrateKbps) { @@ -199,6 +206,8 @@ public final class NativeCodecBridge { private static native void nativeVpxRequestKeyframe(long handle); + private static native boolean nativeVpxSetBitrate(long handle, int bitrateKbps); + private static native long nativeVpxDecoderCreate(String mime); private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java b/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java index 8922abd..390d021 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastCodecFlags.java @@ -16,6 +16,8 @@ import android.media.MediaFormat; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.sender.CodecCatalog; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; + import java.util.List; /** Video/audio codec bitmasks in {@link CastProtoHeader}. */ @@ -62,8 +64,22 @@ public final class CastCodecFlags { } public static int localAudioMask() { - // Advertise Opus/Speex for stub negotiation even when native probes are false. - return AUDIO_AAC | AUDIO_PCM | AUDIO_OPUS | AUDIO_SPEEX; + return localAudioMask(false); + } + + public static int localAudioMask(boolean strictNative) { + int mask = AUDIO_AAC | AUDIO_PCM; + if (!strictNative || NativeCodecBridge.isOpusAvailable()) { + mask |= AUDIO_OPUS; + } + if (!strictNative || NativeCodecBridge.isSpeexAvailable()) { + mask |= AUDIO_SPEEX; + } + return mask; + } + + public static int maskForAudio(CastSettings.AudioCodec codec) { + return flagForAudioPreference(codec); } public static int localDecoderAudioMask() { @@ -101,8 +117,9 @@ public final class CastCodecFlags { } switch (codec) { case OPUS: + return NativeCodecBridge.isOpusAvailable(); case SPEEX: - return true; + return NativeCodecBridge.isSpeexAvailable(); case AAC: case PASSTHROUGH_DEBUG: return true; diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index 4410058..ac919ac 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -194,16 +194,43 @@ public final class CastProtocol { } public static byte[] codecSelectedPayload(String mime) throws IOException { + return codecSelectedPayload(mime, null); + } + + /** v2: optional audio codec ordinal after video MIME (backward compatible). */ + public static byte[] codecSelectedPayload(String mime, CastSettings.AudioCodec audio) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC); + if (audio != null) { + dos.writeInt(audio.ordinal()); + } dos.flush(); return bos.toByteArray(); } public static String parseCodecSelected(byte[] payload) throws IOException { + return parseCodecSelection(payload).videoMime; + } + + public static CodecSelection parseCodecSelection(byte[] payload) throws IOException { DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload)); - return dis.readUTF(); + String videoMime = dis.readUTF(); + CastSettings.AudioCodec audio = CastSettings.AudioCodec.AAC; + if (dis.available() >= 4) { + audio = readEnum(CastSettings.AudioCodec.values(), dis.readInt(), CastSettings.AudioCodec.AAC); + } + return new CodecSelection(videoMime, audio); + } + + public static final class CodecSelection { + public final String videoMime; + public final CastSettings.AudioCodec audioCodec; + + public CodecSelection(String videoMime, CastSettings.AudioCodec audioCodec) { + this.videoMime = videoMime; + this.audioCodec = audioCodec != null ? audioCodec : CastSettings.AudioCodec.AAC; + } } public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException { @@ -292,7 +319,7 @@ public final class CastProtocol { return local.getTransport().equals(remote.getTransport()); } - private static final byte NETWORK_STATS_VERSION = 5; + private static final byte NETWORK_STATS_VERSION = 6; public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); @@ -346,6 +373,12 @@ public final class CastProtocol { dos.writeLong(s.fecPacketsDecoded); dos.writeLong(s.fecDecodeFailures); } + if (NETWORK_STATS_VERSION >= 6) { + dos.writeLong(s.nackRetransmits); + dos.writeInt(s.protectionOverheadPercent); + dos.writeLong(s.jitterBufferDrops); + dos.writeInt(s.codecRenegotiationCount); + } dos.flush(); return bos.toByteArray(); } @@ -404,6 +437,12 @@ public final class CastProtocol { s.fecPacketsDecoded = dis.readLong(); s.fecDecodeFailures = dis.readLong(); } + if (ver >= 6 && dis.available() > 0) { + s.nackRetransmits = dis.readLong(); + s.protectionOverheadPercent = dis.readInt(); + s.jitterBufferDrops = dis.readLong(); + s.codecRenegotiationCount = dis.readInt(); + } } } return s; diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java index ecb77f9..c1af5cd 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -12,8 +12,10 @@ package com.foxx.androidcast.network; * Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025 **********************************************************************/ import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.AudioNegotiator; import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.CodecPriorityCatalog; +import com.foxx.androidcast.network.CastCodecFlags; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.stats.SessionStatsContext; @@ -95,7 +97,9 @@ public class CastSession { if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) { receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload); } else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { - negotiated = CastProtocol.parseCodecSelected(msg.payload); + CastProtocol.CodecSelection sel = CastProtocol.parseCodecSelection(msg.payload); + negotiated = sel.videoMime; + settings.setResolvedAudioCodec(sel.audioCodec); break; } } @@ -161,7 +165,12 @@ public class CastSession { recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps); String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver"); - byte[] selected = CastProtocol.codecSelectedPayload(mime); + CastSettings.AudioCodec audio = AudioNegotiator.negotiate( + CastCodecFlags.localAudioMask(remote.isCodecHardeningStrict()), + CastCodecFlags.localAudioMask(localSettings.isCodecHardeningStrict()), + remote.getAudioCodec(), remote, "receiver"); + remote.setResolvedAudioCodec(audio); + byte[] selected = CastProtocol.codecSelectedPayload(mime, audio); transport.send(CastProtocol.MSG_CODEC_SELECTED, selected); recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected); remote.setNegotiatedVideoMime(mime); diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java index 941f802..280978b 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -183,6 +183,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { } synchronized (lossStats) { lossStats.nackRetransmits++; + if (entry.body != null) { + lossStats.protectionBytesSent += entry.body.length; + } } retransmitCached(entry.wireType, entry.body, messageId); } diff --git a/app/src/main/java/com/foxx/androidcast/network/control/AdaptiveNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/AdaptiveNetworkControlPlane.java new file mode 100644 index 0000000..c78fa4e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/AdaptiveNetworkControlPlane.java @@ -0,0 +1,92 @@ +package com.foxx.androidcast.network.control; + +import com.foxx.androidcast.CastConfig; + +/** + * Bandwidth-aware control plane: measures jitter, protection overhead, emits bitrate hints. + */ +public class AdaptiveNetworkControlPlane extends PassThroughNetworkControlPlane { + private static final long CONTROL_INTERVAL_MS = 2_000; + + private final NetworkProbeTracker probe = new NetworkProbeTracker(); + private long lastControlMs; + private int lastSuggestedBitrateKbps; + + public NetworkProbeTracker getProbe() { + return probe; + } + + @Override + public NetworkControlAdvice tick(long nowMs) { + NetworkControlAdvice advice = super.tick(nowMs); + NetworkStatsSnapshot peer = getLastPeerStats(); + int targetKbps = computeTargetBitrateKbps(peer); + advice.suggestedBitrateKbps = targetKbps; + advice.congestionLevel = computeCongestion(peer, targetKbps); + advice.sendControl = nowMs - lastControlMs >= CONTROL_INTERVAL_MS && targetKbps > 0; + if (advice.sendControl) { + lastControlMs = nowMs; + lastSuggestedBitrateKbps = targetKbps; + } + return advice; + } + + @Override + public NetworkStatsSnapshot buildLocalStats(long nowMs) { + NetworkStatsSnapshot s = super.buildLocalStats(nowMs); + s.jitterMs = probe.getJitterMs(); + return s; + } + + @Override + public void onPeerControl(int suggestedBitrateKbps, String suggestedTransport, + boolean switchTransport, int congestionLevel) { + if (suggestedBitrateKbps > 0) { + lastSuggestedBitrateKbps = suggestedBitrateKbps; + } + } + + public int getLastSuggestedBitrateKbps() { + return lastSuggestedBitrateKbps; + } + + public int getProtectionOverheadPercent() { + return probe.getProtectionOverheadPercent(); + } + + private int computeTargetBitrateKbps(NetworkStatsSnapshot peer) { + int base = peer != null && peer.recvBitrateKbps > 0 ? peer.recvBitrateKbps : 0; + if (peer != null && peer.sendBitrateKbps > 0) { + base = base > 0 ? Math.min(base, peer.sendBitrateKbps) : peer.sendBitrateKbps; + } + if (base <= 0) { + return 0; + } + float factor = 0.92f; + if (peer != null && peer.rttMs > 400) { + factor *= Math.max(0.35f, 1f - (peer.rttMs - 400) / 2500f); + } + if (peer != null && peer.lossRatio > 0.02f) { + factor *= Math.max(0.4f, 1f - peer.lossRatio); + } + int overhead = probe.getProtectionOverheadPercent(); + if (overhead > 15) { + factor *= Math.max(0.5f, 1f - (overhead - 15) / 100f); + } + return Math.max(400, (int) (base * factor)); + } + + private int computeCongestion(NetworkStatsSnapshot peer, int targetKbps) { + int level = 0; + if (peer != null && peer.lossRatio > 0.05f) { + level = 2; + } else if (peer != null && peer.rttMs > 500) { + level = 1; + } + if (peer != null && peer.sendBitrateKbps > 0 && targetKbps > 0 + && peer.sendBitrateKbps > targetKbps * 1.15f) { + level = Math.max(level, 1); + } + return level; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java index 386c0fc..7241123 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java @@ -23,10 +23,17 @@ import java.io.IOException; public class NetworkFeedbackManager { private static final String TAG = "NetworkFeedback"; + public interface AdviceListener { + void onBitrateHint(int suggestedBitrateKbps); + + void onTransportSwitchHint(String transport, boolean switchTransport); + } + private final NetworkControlPlane plane; private final CastSession session; private final boolean isSender; private DiagPingSession diagPing; + private AdviceListener adviceListener; public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) { this.session = session; @@ -57,6 +64,10 @@ public class NetworkFeedbackManager { return diagPing; } + public void setAdviceListener(AdviceListener listener) { + this.adviceListener = listener; + } + /** Call on each stream loop iteration (~500ms). */ public void tick() { long now = System.currentTimeMillis(); @@ -123,13 +134,20 @@ public class NetworkFeedbackManager { } } - /** Hook for sender to apply bitrate/transport hints (stub-friendly). */ + /** Apply bitrate/transport hints to encoders or session policy. */ public void applyAdvice(NetworkControlAdvice advice) { - if (advice.suggestedBitrateKbps > 0 && isSender) { - Log.d(TAG, "Bitrate hint (not applied yet): " + advice.suggestedBitrateKbps); + if (advice == null) { + return; } - if (advice.switchTransport) { - Log.d(TAG, "Transport switch hint (not applied yet): " + advice.suggestedTransport); + if (advice.suggestedBitrateKbps > 0 && isSender) { + if (adviceListener != null) { + adviceListener.onBitrateHint(advice.suggestedBitrateKbps); + } else { + Log.d(TAG, "Bitrate hint: " + advice.suggestedBitrateKbps + " kbps (no listener)"); + } + } + if (advice.switchTransport && adviceListener != null) { + adviceListener.onTransportSwitchHint(advice.suggestedTransport, true); } } } diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkProbeTracker.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkProbeTracker.java new file mode 100644 index 0000000..a033437 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkProbeTracker.java @@ -0,0 +1,56 @@ +package com.foxx.androidcast.network.control; + +/** Tracks inter-arrival jitter and protection overhead bytes. */ +public final class NetworkProbeTracker { + private long lastMediaArrivalMs; + private int smoothedJitterMs; + private long mediaBytes; + private long protectionBytes; + private long windowStartMs; + + public synchronized void recordMediaArrival(long nowMs, int payloadBytes) { + if (lastMediaArrivalMs > 0) { + int delta = (int) Math.abs(nowMs - lastMediaArrivalMs); + smoothedJitterMs = (smoothedJitterMs * 7 + delta) / 8; + } + lastMediaArrivalMs = nowMs; + mediaBytes += Math.max(0, payloadBytes); + rollWindow(nowMs); + } + + public synchronized void recordProtectionBytes(int bytes) { + protectionBytes += Math.max(0, bytes); + } + + public synchronized int getJitterMs() { + return smoothedJitterMs; + } + + /** Protection overhead as 0..100 percent of media bytes in current window. */ + public synchronized int getProtectionOverheadPercent() { + if (mediaBytes <= 0) { + return 0; + } + return (int) Math.min(100, (protectionBytes * 100L) / mediaBytes); + } + + public synchronized long getProtectionBytes() { + return protectionBytes; + } + + public synchronized long getMediaBytes() { + return mediaBytes; + } + + private void rollWindow(long nowMs) { + if (windowStartMs == 0) { + windowStartMs = nowMs; + return; + } + if (nowMs - windowStartMs > 4000) { + mediaBytes = 0; + protectionBytes = 0; + windowStartMs = nowMs; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java index 8bf5a23..d201c36 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java @@ -61,6 +61,11 @@ public class NetworkStatsSnapshot { public long fecPacketsDecoded; public long fecDecodeFailures; public long nackStubRequests; + /** v6: protection overhead and jitter buffer metrics. */ + public long nackRetransmits; + public int protectionOverheadPercent; + public long jitterBufferDrops; + public int codecRenegotiationCount; public NetworkStatsSnapshot copy() { NetworkStatsSnapshot c = new NetworkStatsSnapshot(); @@ -106,6 +111,10 @@ public class NetworkStatsSnapshot { c.fecPacketsDecoded = fecPacketsDecoded; c.fecDecodeFailures = fecDecodeFailures; c.nackStubRequests = nackStubRequests; + c.nackRetransmits = nackRetransmits; + c.protectionOverheadPercent = protectionOverheadPercent; + c.jitterBufferDrops = jitterBufferDrops; + c.codecRenegotiationCount = codecRenegotiationCount; return c; } } diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java index dd90ca7..5eb511c 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java @@ -94,6 +94,11 @@ public final class FecProtectionEngine implements StreamProtectionEngine { if (stats != null) { synchronized (stats) { stats.fecPacketsEncoded += shards.length; + for (byte[] shard : shards) { + if (shard != null) { + stats.protectionBytesSent += shard.length; + } + } } } return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards); diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java index 12054ab..84d2dd7 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java @@ -53,6 +53,10 @@ public final class TransportLossStats { public long fecPacketsStub; public long nackRequestsStub; public long nackRetransmitsStub; + public long protectionBytesSent; + public long protectionBytesReceived; + public long mediaBytesSent; + public long mediaBytesReceived; public synchronized void addFrom(TransportLossStats other) { if (other == null) { @@ -88,6 +92,10 @@ public final class TransportLossStats { fecPacketsStub += other.fecPacketsStub; nackRequestsStub += other.nackRequestsStub; nackRetransmitsStub += other.nackRetransmitsStub; + protectionBytesSent += other.protectionBytesSent; + protectionBytesReceived += other.protectionBytesReceived; + mediaBytesSent += other.mediaBytesSent; + mediaBytesReceived += other.mediaBytesReceived; } public synchronized TransportLossStats copy() { @@ -124,6 +132,10 @@ public final class TransportLossStats { c.fecPacketsStub = fecPacketsStub; c.nackRequestsStub = nackRequestsStub; c.nackRetransmitsStub = nackRetransmitsStub; + c.protectionBytesSent = protectionBytesSent; + c.protectionBytesReceived = protectionBytesReceived; + c.mediaBytesSent = mediaBytesSent; + c.mediaBytesReceived = mediaBytesReceived; return c; } diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java index 9613964..4d0bc57 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java @@ -47,6 +47,12 @@ public final class TransportLossStatsBridge { s.fecPacketsDecoded = loss.fecPacketsDecoded; s.fecDecodeFailures = loss.fecDecodeFailures; s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived; + s.nackRetransmits = loss.nackRetransmits; + long media = loss.mediaBytesSent + loss.mediaBytesReceived; + long prot = loss.protectionBytesSent + loss.protectionBytesReceived; + if (media > 0) { + s.protectionOverheadPercent = (int) Math.min(100, (prot * 100L) / media); + } long lossEvents = loss.estimatedUdpLossEvents(); if (loss.datagramsReceived > 0 && lossEvents > 0) { s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 830124c..21efe28 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -37,6 +37,7 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; import com.foxx.androidcast.diagnostics.CastMtuSampler; +import com.foxx.androidcast.diagnostics.ReceiverDiagnosticsExtras; import com.foxx.androidcast.diagnostics.CpuLoadSampler; import com.foxx.androidcast.media.codec.CodecSessionRegistry; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; @@ -47,7 +48,13 @@ import com.foxx.androidcast.discovery.DiscoveryManager; import com.foxx.androidcast.live.LiveCastControlPlane; import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStatsBridge; +import com.foxx.androidcast.media.AdaptiveCodecController; +import com.foxx.androidcast.media.CodecRenegotiationManager; +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.receiver.buffer.JitterBufferConfig; +import com.foxx.androidcast.receiver.buffer.ReceiveJitterPipeline; import com.foxx.androidcast.stats.SessionStatsContext; import com.foxx.androidcast.stats.SessionStatsRecorder; import com.foxx.androidcast.stats.SessionStatsStore; @@ -55,6 +62,7 @@ import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge; import com.foxx.androidcast.util.CastBackgroundExecutor; +import java.io.IOException; import java.util.Arrays; /** @@ -125,6 +133,9 @@ public class ReceiverCastService extends Service { private long lastStreamConfigMs; private final TransportLossStats transportLoss = new TransportLossStats(); private SessionStatsRecorder sessionStatsRecorder; + private ReceiveJitterPipeline jitterPipeline; + private AdaptiveCodecController adaptiveCodecController; + private int codecRenegotiationCount; private final Runnable idleWatchdog = new Runnable() { @Override @@ -262,6 +273,29 @@ public class ReceiverCastService extends Service { synchronized (audioDecoderLock) { audioDecoder = buildDefaultAudioDecoder(); } + JitterBufferConfig jbConfig = new JitterBufferConfig( + AppPreferences.getReceiverJitterBufferMode(this), + JitterBufferConfig.DEFAULT_MAX_PACKETS, + AppPreferences.getReceiverJitterTargetDelayMs(this)); + jitterPipeline = new ReceiveJitterPipeline(jbConfig); + jitterPipeline.setSinks( + (ptsUs, keyFrame, data) -> { + if (videoDecodeHandler != null) { + videoDecodeHandler.post(() -> { + if (videoDecoder != null) { + videoDecoder.queueFrame(ptsUs, keyFrame, data); + } + }); + } + }, + (ptsUs, data) -> { + synchronized (audioDecoderLock) { + if (audioDecoder != null) { + audioDecoder.queueFrame(ptsUs, data); + } + } + }); + jitterPipeline.start(); session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() { @Override @@ -327,7 +361,9 @@ public class ReceiverCastService extends Service { }); if (videoDecodeHandler != null) { videoDecodeHandler.post(() -> { - if (videoDecoder != null) { + if (jitterPipeline != null) { + jitterPipeline.enqueueVideo(ptsUs, keyFrame, frameData); + } else if (videoDecoder != null) { videoDecoder.queueFrame(ptsUs, keyFrame, frameData); } }); @@ -375,9 +411,13 @@ public class ReceiverCastService extends Service { accept = Boolean.TRUE.equals(audioAccepted); } if (accept) { - synchronized (audioDecoderLock) { - if (audioDecoder != null) { - audioDecoder.queueFrame(ptsUs, data); + if (jitterPipeline != null) { + jitterPipeline.enqueueAudio(ptsUs, data); + } else { + synchronized (audioDecoderLock) { + if (audioDecoder != null) { + audioDecoder.queueFrame(ptsUs, data); + } } } } @@ -412,6 +452,16 @@ public class ReceiverCastService extends Service { public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) { mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot)); } + + @Override + public void onCodecCaps(java.util.List peerCaps) { + mainHandler.post(() -> handlePeerCodecCaps(peerCaps)); + } + + @Override + public void onCodecRenegotiation(CastProtocol.CodecSelection selection) { + mainHandler.post(() -> applyCodecRenegotiation(selection)); + } }, this::enrichReceiverStats); session.start(); updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase())); @@ -434,7 +484,10 @@ public class ReceiverCastService extends Service { negotiatedVideoMime = videoMime; /* Replace audio decoder if the negotiated codec is Opus or Speex. */ if (remoteSettings != null) { - CastSettings.AudioCodec negotiatedAudio = remoteSettings.getAudioCodec(); + CastSettings.AudioCodec negotiatedAudio = remoteSettings.getResolvedAudioCodec(); + if (negotiatedAudio == null) { + negotiatedAudio = remoteSettings.getAudioCodec(); + } synchronized (audioDecoderLock) { if (negotiatedAudio == CastSettings.AudioCodec.OPUS && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) { @@ -459,6 +512,11 @@ public class ReceiverCastService extends Service { LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec, settings.getBitrateMode().name().toLowerCase()); remoteCastSettings = remoteSettings; + if (session != null && session.getActiveSession() != null) { + adaptiveCodecController = new AdaptiveCodecController( + session.getActiveSession(), false, settings, + (sel, local) -> applyCodecRenegFromManager(sel)); + } streamIdle = false; streamMetrics.reset(); lastVideoFrameMs = 0; @@ -722,6 +780,11 @@ public class ReceiverCastService extends Service { } private void stopSessionOnly() { + if (jitterPipeline != null) { + jitterPipeline.stop(); + jitterPipeline = null; + } + adaptiveCodecController = null; if (discovery != null) { discovery.stop(); discovery = null; @@ -849,9 +912,87 @@ public class ReceiverCastService extends Service { }); } + private void applyCodecRenegFromManager(CodecRenegotiationManager.Selection sel) { + if (sel == null) { + return; + } + applyCodecRenegotiation(new CastProtocol.CodecSelection(sel.videoMime, sel.audioCodec)); + } + + private void handlePeerCodecCaps(java.util.List peerCaps) { + if (adaptiveCodecController == null || peerCaps == null) { + return; + } + try { + adaptiveCodecController.handleCaps(peerCaps); + } catch (IOException e) { + Log.w(TAG, "Codec caps reneg failed: " + e.getMessage()); + } + } + + private void applyCodecRenegotiation(CastProtocol.CodecSelection selection) { + if (selection == null) { + return; + } + codecRenegotiationCount++; + negotiatedVideoMime = selection.videoMime; + if (remoteCastSettings != null) { + remoteCastSettings.setNegotiatedVideoMime(selection.videoMime); + remoteCastSettings.setResolvedAudioCodec(selection.audioCodec); + } + if (sessionStatsRecorder != null) { + sessionStatsRecorder.noteCodecRenegotiation(selection.videoMime, + selection.audioCodec != null ? selection.audioCodec.name() : "AAC"); + } + awaitingKeyframe = true; + if (jitterPipeline != null) { + jitterPipeline.clear(); + } + swapAudioDecoderForCodec(selection.audioCodec); + resetDecoderState(); + Log.i(TAG, "Codec renegotiated video=" + selection.videoMime + " audio=" + selection.audioCodec); + } + + private void swapAudioDecoderForCodec(CastSettings.AudioCodec codec) { + if (codec == null) { + return; + } + synchronized (audioDecoderLock) { + if (codec == CastSettings.AudioCodec.OPUS + && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) { + if (audioDecoder != null) { + audioDecoder.release(); + } + NativeAudioDecoder nd = new NativeAudioDecoder(this, NativeAudioDecoder.Codec.OPUS); + nd.setRenderCallback((pcmBytes, levels) -> + streamMetrics.onAudioRendered(pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = nd; + } else if (codec == CastSettings.AudioCodec.SPEEX + && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isSpeexAvailable()) { + if (audioDecoder != null) { + audioDecoder.release(); + } + NativeAudioDecoder nd = new NativeAudioDecoder(this, NativeAudioDecoder.Codec.SPEEX); + nd.setRenderCallback((pcmBytes, levels) -> + streamMetrics.onAudioRendered(pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = nd; + } else if (codec == CastSettings.AudioCodec.AAC) { + if (audioDecoder != null) { + audioDecoder.release(); + } + audioDecoder = buildDefaultAudioDecoder(); + } + } + } + private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) { s.encodeFps = streamMetrics.getDecodeFps(); TransportLossStats snap = buildReceiverLossSnapshot(); + if (s.recvBitrateKbps > 0) { + synchronized (snap) { + snap.mediaBytesReceived += (long) s.recvBitrateKbps * 125; + } + } TransportLossStatsBridge.applyTo(s, snap); if (sessionStatsRecorder != null) { sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer()); @@ -870,11 +1011,33 @@ public class ReceiverCastService extends Service { if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) { s.congestionLevel = Math.max(s.congestionLevel, 2); } + if (jitterPipeline != null) { + s.jitterBufferDrops = jitterPipeline.combinedStats().drops; + s.jitterMs = Math.max(s.jitterMs, jitterPipeline.combinedStats().measuredJitterMs); + } + s.codecRenegotiationCount = codecRenegotiationCount; + if (session != null && session.getActiveSession() != null && adaptiveCodecController != null) { + adaptiveCodecController.tick(peer, negotiatedVideoMime, + remoteCastSettings != null ? remoteCastSettings.getResolvedAudioCodec() + : CastSettings.AudioCodec.AAC); + } lastLocalStats = s.copy(); } private String buildDiagnosticsText() { NetworkStatsSnapshot peer = streamMetrics.getLastPeer(); + ReceiverDiagnosticsExtras extras = null; + if (jitterPipeline != null) { + extras = new ReceiverDiagnosticsExtras( + AppPreferences.getReceiverJitterBufferMode(this), + jitterPipeline.combinedStats(), + codecRenegotiationCount); + } else if (codecRenegotiationCount > 0) { + extras = new ReceiverDiagnosticsExtras( + AppPreferences.getReceiverJitterBufferMode(this), + null, + codecRenegotiationCount); + } return CastDiagnosticsFormatter.formatHtml( streamMetrics, settings.getTransport(), @@ -889,7 +1052,8 @@ public class ReceiverCastService extends Service { peer, lastLocalStats, PlaybackViewState.getZoomPercent(), - CastMtuSampler.sample(this)); + CastMtuSampler.sample(this), + extras); } private TransportLossStats buildReceiverLossSnapshot() { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 543ebce..a31d1bb 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -31,12 +31,14 @@ import com.foxx.androidcast.network.transport.StreamProtectionNegotiation; import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import java.io.IOException; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -77,6 +79,14 @@ public class ReceiverSession { /** Diagnostic ping-pong sample (developer setting). */ default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) { } + + /** Mid-session codec renegotiation (MSG_CODEC_SELECTED). */ + default void onCodecRenegotiation(CastProtocol.CodecSelection selection) { + } + + /** Peer announced codec capabilities mid-session. */ + default void onCodecCaps(java.util.List peerCaps) { + } } private final Context appContext; @@ -166,7 +176,7 @@ public class ReceiverSession { 0, DeviceInfo.getPlaceholderId(appContext), CastCodecFlags.localDecoderVideoMask(), - CastCodecFlags.localAudioMask())); + CastCodecFlags.localAudioMask(localSettings.isCodecHardeningStrict()))); session = new CastSession(listenTransport); int listenPort = CastConfig.portForTransport(localSettings.getTransport()); session.prepareListen(listenPort); @@ -178,16 +188,13 @@ public class ReceiverSession { } inboundSessionGate.reset(); listener.onCastSessionStarting(); - networkFeedback = new NetworkFeedbackManager(session, false); - if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { - PassThroughNetworkControlPlane plane = - (PassThroughNetworkControlPlane) networkFeedback.getPlane(); - plane.setStatsEnricher(statsEnricher); - if (AppPreferences.isDevDiagPingPongEnabled(appContext)) { - networkFeedback.configureDiagPing(true, - AppPreferences.getDevDiagPingResponseMode(appContext), - listener::onDiagPingSample); - } + networkFeedback = new NetworkFeedbackManager(session, false, new AdaptiveNetworkControlPlane()); + PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane(); + plane.setStatsEnricher(statsEnricher); + if (AppPreferences.isDevDiagPingPongEnabled(appContext)) { + networkFeedback.configureDiagPing(true, + AppPreferences.getDevDiagPingResponseMode(appContext), + listener::onDiagPingSample); } session.acceptClient(); CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); @@ -289,16 +296,39 @@ public class ReceiverSession { postStatus("Sender stopped casting"); listener.onCastEnded(); return; + case CastProtocol.MSG_CODEC_CAPS: + listener.onCodecCaps(CastProtocol.parseCodecCaps(msg.payload)); + break; + case CastProtocol.MSG_CODEC_SELECTED: + listener.onCodecRenegotiation(CastProtocol.parseCodecSelection(msg.payload)); + break; default: break; } } } + public CastSession getActiveSession() { + return session; + } + + public void sendMessage(byte type, byte[] payload) throws IOException { + if (session != null) { + session.send(type, payload); + } + } + private void recordNetworkBytes(int bytes) { - if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { + if (networkFeedback == null || bytes <= 0) { + return; + } + long now = System.currentTimeMillis(); + if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).recordBytes(bytes); } + if (networkFeedback.getPlane() instanceof AdaptiveNetworkControlPlane) { + ((AdaptiveNetworkControlPlane) networkFeedback.getPlane()).getProbe().recordMediaArrival(now, bytes); + } } private static boolean isProtocolDesync(IOException e) { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/FifoJitterBuffer.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/FifoJitterBuffer.java new file mode 100644 index 0000000..432ed86 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/FifoJitterBuffer.java @@ -0,0 +1,78 @@ +package com.foxx.androidcast.receiver.buffer; + +import java.util.ArrayDeque; + +/** FIFO with playout delay; drops oldest on overflow. */ +final class FifoJitterBuffer implements JitterBuffer { + private final int maxPackets; + private final int targetDelayMs; + private final ArrayDeque queue = new ArrayDeque<>(); + private final JitterBufferStats stats = new JitterBufferStats(); + private long lastArrivalMs; + + FifoJitterBuffer(JitterBufferConfig config) { + maxPackets = config.maxPackets; + targetDelayMs = config.targetDelayMs; + stats.targetDelayMs = targetDelayMs; + stats.maxDelayMs = targetDelayMs; + } + + @Override + public synchronized void enqueue(MediaPacket packet) { + if (packet == null || packet.data == null || packet.data.length == 0) { + return; + } + stats.packetsIn++; + updateJitter(packet.arrivalMs); + while (queue.size() >= maxPackets) { + MediaPacket dropped = queue.pollFirst(); + if (dropped != null) { + stats.drops++; + } + } + queue.addLast(packet); + stats.depth = queue.size(); + } + + @Override + public synchronized MediaPacket pollReady(long nowMs) { + MediaPacket head = queue.peekFirst(); + if (head == null) { + return null; + } + if (nowMs - head.arrivalMs < targetDelayMs) { + return null; + } + MediaPacket p = queue.pollFirst(); + if (p != null) { + stats.packetsOut++; + stats.depth = queue.size(); + } + return p; + } + + @Override + public synchronized void clear() { + stats.drops += queue.size(); + queue.clear(); + stats.depth = 0; + } + + @Override + public synchronized JitterBufferStats getStats() { + return stats.copy(); + } + + @Override + public void release() { + clear(); + } + + private void updateJitter(long arrivalMs) { + if (lastArrivalMs > 0) { + int delta = (int) Math.abs(arrivalMs - lastArrivalMs); + stats.measuredJitterMs = (stats.measuredJitterMs * 7 + delta) / 8; + } + lastArrivalMs = arrivalMs; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBuffer.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBuffer.java new file mode 100644 index 0000000..3cbb5b7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBuffer.java @@ -0,0 +1,15 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Pluggable receive jitter buffer. */ +public interface JitterBuffer { + void enqueue(MediaPacket packet); + + /** Returns the next packet ready for decode/play, or null if none yet. */ + MediaPacket pollReady(long nowMs); + + void clear(); + + JitterBufferStats getStats(); + + void release(); +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferConfig.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferConfig.java new file mode 100644 index 0000000..89c45c2 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferConfig.java @@ -0,0 +1,33 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Tunable jitter buffer parameters. */ +public final class JitterBufferConfig { + public static final int DEFAULT_MAX_PACKETS = 48; + public static final int DEFAULT_TARGET_DELAY_MS = 300; + public static final int MIN_TARGET_DELAY_MS = 40; + public static final int MAX_TARGET_DELAY_MS = 600; + + public final JitterBufferMode mode; + public final int maxPackets; + public final int targetDelayMs; + + public JitterBufferConfig(JitterBufferMode mode, int maxPackets, int targetDelayMs) { + this.mode = mode != null ? mode : JitterBufferMode.SMART; + this.maxPackets = maxPackets > 0 ? maxPackets : DEFAULT_MAX_PACKETS; + this.targetDelayMs = clampDelay(targetDelayMs); + } + + public static JitterBufferConfig defaults(JitterBufferMode mode) { + return new JitterBufferConfig(mode, DEFAULT_MAX_PACKETS, DEFAULT_TARGET_DELAY_MS); + } + + public static int clampDelay(int ms) { + if (ms < MIN_TARGET_DELAY_MS) { + return MIN_TARGET_DELAY_MS; + } + if (ms > MAX_TARGET_DELAY_MS) { + return MAX_TARGET_DELAY_MS; + } + return ms; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactory.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactory.java new file mode 100644 index 0000000..fa7d085 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactory.java @@ -0,0 +1,25 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Creates receive jitter buffers by mode. */ +public final class JitterBufferFactory { + private JitterBufferFactory() {} + + public static JitterBuffer create(JitterBufferConfig config) { + JitterBufferConfig cfg = config != null ? config : JitterBufferConfig.defaults(JitterBufferMode.SMART); + switch (cfg.mode) { + case SIMPLE: + return new SimpleJitterBuffer(cfg); + case FIFO: + return new FifoJitterBuffer(cfg); + case LIFO: + return new LifoJitterBuffer(cfg); + case SMART: + default: + return new SmartJitterBuffer(cfg); + } + } + + public static JitterBuffer create(JitterBufferMode mode, int targetDelayMs) { + return create(new JitterBufferConfig(mode, JitterBufferConfig.DEFAULT_MAX_PACKETS, targetDelayMs)); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferMode.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferMode.java new file mode 100644 index 0000000..2d10c35 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferMode.java @@ -0,0 +1,13 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Receive-side jitter buffer strategy (receiver settings). */ +public enum JitterBufferMode { + /** Legacy burst-drop on overflow, immediate playout (pre-jitter-buffer behavior). */ + SIMPLE, + /** Strict FIFO with bounded playout delay. */ + FIFO, + /** Newest-first on overflow; prefers fresh frames under loss. */ + LIFO, + /** Payload-aware adaptive delay with smart stall drops (default). */ + SMART +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferStats.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferStats.java new file mode 100644 index 0000000..e7e1a5a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/JitterBufferStats.java @@ -0,0 +1,40 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Counters exported to session stats and network overlay. */ +public final class JitterBufferStats { + public long packetsIn; + public long packetsOut; + public long drops; + public long burstDrops; + public int depth; + public int targetDelayMs; + public int measuredJitterMs; + public int maxDelayMs; + + public synchronized JitterBufferStats copy() { + JitterBufferStats c = new JitterBufferStats(); + c.packetsIn = packetsIn; + c.packetsOut = packetsOut; + c.drops = drops; + c.burstDrops = burstDrops; + c.depth = depth; + c.targetDelayMs = targetDelayMs; + c.measuredJitterMs = measuredJitterMs; + c.maxDelayMs = maxDelayMs; + return c; + } + + public synchronized void mergeFrom(JitterBufferStats other) { + if (other == null) { + return; + } + packetsIn += other.packetsIn; + packetsOut += other.packetsOut; + drops += other.drops; + burstDrops += other.burstDrops; + depth = other.depth; + targetDelayMs = other.targetDelayMs; + measuredJitterMs = other.measuredJitterMs; + maxDelayMs = other.maxDelayMs; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/LifoJitterBuffer.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/LifoJitterBuffer.java new file mode 100644 index 0000000..696fe9c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/LifoJitterBuffer.java @@ -0,0 +1,57 @@ +package com.foxx.androidcast.receiver.buffer; + +import java.util.ArrayDeque; + +/** Serves newest frames first; drops oldest on overflow. */ +final class LifoJitterBuffer implements JitterBuffer { + private final int maxPackets; + private final ArrayDeque queue = new ArrayDeque<>(); + private final JitterBufferStats stats = new JitterBufferStats(); + + LifoJitterBuffer(JitterBufferConfig config) { + maxPackets = config.maxPackets; + } + + @Override + public synchronized void enqueue(MediaPacket packet) { + if (packet == null || packet.data == null || packet.data.length == 0) { + return; + } + stats.packetsIn++; + while (queue.size() >= maxPackets) { + MediaPacket dropped = queue.pollFirst(); + if (dropped != null) { + stats.drops++; + } + } + queue.addLast(packet); + stats.depth = queue.size(); + } + + @Override + public synchronized MediaPacket pollReady(long nowMs) { + MediaPacket p = queue.pollLast(); + if (p != null) { + stats.packetsOut++; + stats.depth = queue.size(); + } + return p; + } + + @Override + public synchronized void clear() { + stats.drops += queue.size(); + queue.clear(); + stats.depth = 0; + } + + @Override + public synchronized JitterBufferStats getStats() { + return stats.copy(); + } + + @Override + public void release() { + clear(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacket.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacket.java new file mode 100644 index 0000000..80338d6 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacket.java @@ -0,0 +1,18 @@ +package com.foxx.androidcast.receiver.buffer; + +/** One media frame held in a jitter buffer. */ +public final class MediaPacket { + public final long ptsUs; + public final byte[] data; + public final MediaPacketKind kind; + public final boolean keyFrame; + public final long arrivalMs; + + public MediaPacket(long ptsUs, byte[] data, MediaPacketKind kind, boolean keyFrame, long arrivalMs) { + this.ptsUs = ptsUs; + this.data = data; + this.kind = kind != null ? kind : MediaPacketKind.DATA; + this.keyFrame = keyFrame; + this.arrivalMs = arrivalMs; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacketKind.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacketKind.java new file mode 100644 index 0000000..3b18fb4 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/MediaPacketKind.java @@ -0,0 +1,11 @@ +package com.foxx.androidcast.receiver.buffer; + +/** Logical payload class for smart jitter decisions. */ +public enum MediaPacketKind { + AUDIO, + VIDEO, + /** FEC / protection shards (not decoded directly). */ + FEC, + /** Other control or unknown payloads. */ + DATA +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/ReceiveJitterPipeline.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/ReceiveJitterPipeline.java new file mode 100644 index 0000000..6e59a8f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/ReceiveJitterPipeline.java @@ -0,0 +1,132 @@ +package com.foxx.androidcast.receiver.buffer; + +import android.util.Log; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Drains audio/video jitter buffers into decoder sinks on a dedicated thread. + */ +public final class ReceiveJitterPipeline { + private static final String TAG = "ReceiveJitterPipeline"; + + public interface VideoSink { + void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data); + } + + public interface AudioSink { + void onAudioFrame(long ptsUs, byte[] data); + } + + private final JitterBuffer audioBuffer; + private final JitterBuffer videoBuffer; + private final AtomicBoolean running = new AtomicBoolean(false); + private Thread drainThread; + private VideoSink videoSink; + private AudioSink audioSink; + + public ReceiveJitterPipeline(JitterBufferConfig config) { + JitterBufferConfig cfg = config != null ? config : JitterBufferConfig.defaults(JitterBufferMode.SMART); + audioBuffer = JitterBufferFactory.create(cfg); + videoBuffer = JitterBufferFactory.create(cfg); + } + + public void setSinks(VideoSink video, AudioSink audio) { + this.videoSink = video; + this.audioSink = audio; + } + + public void start() { + if (!running.compareAndSet(false, true)) { + return; + } + drainThread = new Thread(this::drainLoop, "ReceiveJitterDrain"); + drainThread.setPriority(Thread.MAX_PRIORITY - 1); + drainThread.start(); + } + + public void stop() { + running.set(false); + if (drainThread != null) { + drainThread.interrupt(); + try { + drainThread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + drainThread = null; + } + audioBuffer.release(); + videoBuffer.release(); + } + + public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] data) { + if (data == null || data.length == 0) { + return; + } + videoBuffer.enqueue(new MediaPacket(ptsUs, data.clone(), MediaPacketKind.VIDEO, keyFrame, + System.currentTimeMillis())); + } + + public void enqueueAudio(long ptsUs, byte[] data) { + if (data == null || data.length == 0) { + return; + } + audioBuffer.enqueue(new MediaPacket(ptsUs, data.clone(), MediaPacketKind.AUDIO, false, + System.currentTimeMillis())); + } + + public void clear() { + audioBuffer.clear(); + videoBuffer.clear(); + } + + public JitterBufferStats combinedStats() { + JitterBufferStats out = audioBuffer.getStats(); + out.mergeFrom(videoBuffer.getStats()); + return out; + } + + private void drainLoop() { + while (running.get()) { + long now = System.currentTimeMillis(); + try { + drainVideo(now); + drainAudio(now); + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + Log.w(TAG, "drain: " + e.getMessage()); + } + } + } + + private void drainVideo(long nowMs) { + if (videoSink == null) { + return; + } + for (int i = 0; i < 8; i++) { + MediaPacket p = videoBuffer.pollReady(nowMs); + if (p == null) { + break; + } + videoSink.onVideoFrame(p.ptsUs, p.keyFrame, p.data); + } + } + + private void drainAudio(long nowMs) { + if (audioSink == null) { + return; + } + for (int i = 0; i < 8; i++) { + MediaPacket p = audioBuffer.pollReady(nowMs); + if (p == null) { + break; + } + audioSink.onAudioFrame(p.ptsUs, p.data); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/SimpleJitterBuffer.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/SimpleJitterBuffer.java new file mode 100644 index 0000000..3b1b0dc --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/SimpleJitterBuffer.java @@ -0,0 +1,61 @@ +package com.foxx.androidcast.receiver.buffer; + +import java.util.ArrayDeque; + +/** Mirrors legacy AudioDecoder queue: FIFO with burst drop-all on overflow. */ +final class SimpleJitterBuffer implements JitterBuffer { + private final int maxPackets; + private final ArrayDeque queue = new ArrayDeque<>(); + private final JitterBufferStats stats = new JitterBufferStats(); + + SimpleJitterBuffer(JitterBufferConfig config) { + maxPackets = config.maxPackets; + stats.maxDelayMs = 0; + stats.targetDelayMs = 0; + } + + @Override + public synchronized void enqueue(MediaPacket packet) { + if (packet == null || packet.data == null || packet.data.length == 0) { + return; + } + stats.packetsIn++; + if (queue.size() < maxPackets) { + queue.addLast(packet); + } else { + int dropped = queue.size(); + queue.clear(); + stats.drops += dropped; + stats.burstDrops += dropped; + queue.addLast(packet); + } + stats.depth = queue.size(); + } + + @Override + public synchronized MediaPacket pollReady(long nowMs) { + MediaPacket p = queue.pollFirst(); + if (p != null) { + stats.packetsOut++; + stats.depth = queue.size(); + } + return p; + } + + @Override + public synchronized void clear() { + stats.drops += queue.size(); + queue.clear(); + stats.depth = 0; + } + + @Override + public synchronized JitterBufferStats getStats() { + return stats.copy(); + } + + @Override + public void release() { + clear(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/buffer/SmartJitterBuffer.java b/app/src/main/java/com/foxx/androidcast/receiver/buffer/SmartJitterBuffer.java new file mode 100644 index 0000000..3e3ddac --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/buffer/SmartJitterBuffer.java @@ -0,0 +1,148 @@ +package com.foxx.androidcast.receiver.buffer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Adaptive delay buffer: knows packet kinds, drops stalled non-key video and aged audio + * when queue exceeds time budget (default 300 ms, self-tunes from measured jitter). + */ +final class SmartJitterBuffer implements JitterBuffer { + private static final int LEARN_ALPHA_NUM = 3; + private static final int LEARN_ALPHA_DEN = 4; + + private final int maxPackets; + private int targetDelayMs; + private final List queue = new ArrayList<>(); + private final JitterBufferStats stats = new JitterBufferStats(); + private long lastArrivalMs; + private long lastPtsUs = -1; + + SmartJitterBuffer(JitterBufferConfig config) { + maxPackets = config.maxPackets; + targetDelayMs = config.targetDelayMs; + stats.targetDelayMs = targetDelayMs; + stats.maxDelayMs = config.targetDelayMs; + } + + @Override + public synchronized void enqueue(MediaPacket packet) { + if (packet == null || packet.data == null || packet.data.length == 0) { + return; + } + stats.packetsIn++; + updateJitter(packet.arrivalMs, packet.ptsUs); + queue.add(packet); + trimStalled(System.currentTimeMillis()); + while (queue.size() > maxPackets) { + dropOneSmart(System.currentTimeMillis()); + } + stats.depth = queue.size(); + } + + @Override + public synchronized MediaPacket pollReady(long nowMs) { + if (queue.isEmpty()) { + return null; + } + trimStalled(nowMs); + Collections.sort(queue, Comparator.comparingLong(p -> p.arrivalMs)); + MediaPacket head = queue.get(0); + if (nowMs - head.arrivalMs < targetDelayMs) { + return null; + } + queue.remove(0); + stats.packetsOut++; + stats.depth = queue.size(); + return head; + } + + @Override + public synchronized void clear() { + stats.drops += queue.size(); + queue.clear(); + stats.depth = 0; + } + + @Override + public synchronized JitterBufferStats getStats() { + stats.targetDelayMs = targetDelayMs; + stats.maxDelayMs = targetDelayMs; + return stats.copy(); + } + + @Override + public void release() { + clear(); + } + + private void updateJitter(long arrivalMs, long ptsUs) { + if (lastArrivalMs > 0) { + int interArrival = (int) Math.abs(arrivalMs - lastArrivalMs); + stats.measuredJitterMs = (stats.measuredJitterMs * 7 + interArrival) / 8; + int learned = JitterBufferConfig.clampDelay(stats.measuredJitterMs * 2 + 40); + targetDelayMs = (targetDelayMs * (LEARN_ALPHA_DEN - LEARN_ALPHA_NUM) + + learned * LEARN_ALPHA_NUM) / LEARN_ALPHA_DEN; + stats.targetDelayMs = targetDelayMs; + } + if (lastPtsUs >= 0 && ptsUs > lastPtsUs) { + long ptsGapUs = ptsUs - lastPtsUs; + if (ptsGapUs > 500_000) { + targetDelayMs = JitterBufferConfig.clampDelay(targetDelayMs + 20); + } + } + lastArrivalMs = arrivalMs; + lastPtsUs = ptsUs; + } + + private void trimStalled(long nowMs) { + long budgetMs = targetDelayMs; + for (int i = queue.size() - 1; i >= 0 && queue.size() > 1; i--) { + MediaPacket p = queue.get(i); + long ageMs = nowMs - p.arrivalMs; + if (ageMs <= budgetMs) { + continue; + } + if (p.kind == MediaPacketKind.FEC) { + continue; + } + if (p.kind == MediaPacketKind.VIDEO && p.keyFrame) { + continue; + } + queue.remove(i); + stats.drops++; + } + } + + private void dropOneSmart(long nowMs) { + int victim = -1; + long oldestAge = -1; + for (int i = 0; i < queue.size(); i++) { + MediaPacket p = queue.get(i); + if (p.kind == MediaPacketKind.FEC) { + continue; + } + if (p.kind == MediaPacketKind.VIDEO && p.keyFrame) { + continue; + } + long age = nowMs - p.arrivalMs; + if (p.kind == MediaPacketKind.VIDEO && !p.keyFrame) { + victim = i; + break; + } + if (age > oldestAge) { + oldestAge = age; + victim = i; + } + } + if (victim < 0 && !queue.isEmpty()) { + victim = 0; + } + if (victim >= 0) { + queue.remove(victim); + stats.drops++; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java index d428a99..5101286 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java @@ -91,9 +91,11 @@ public final class CodecCatalog { List out = new ArrayList<>(); out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0)); // Selectable even without native .so — stub path uses AAC + OPUS/SPEEX backend label (validation). - out.add(scoredAudio(CastSettings.AudioCodec.OPUS, true, settings)); + out.add(scoredAudio(CastSettings.AudioCodec.OPUS, + isAudioSelectable(CastSettings.AudioCodec.OPUS, settings), settings)); out.add(scoredAudio(CastSettings.AudioCodec.AAC, true, settings)); - out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, true, settings)); + out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, + isAudioSelectable(CastSettings.AudioCodec.SPEEX, settings), settings)); out.add(scoredAudio(CastSettings.AudioCodec.PASSTHROUGH_DEBUG, true, settings)); sortAudioChoices(out); return out; @@ -255,6 +257,21 @@ public final class CodecCatalog { return new AudioCodecChoice(codec, selectable, score); } + private static boolean isAudioSelectable(CastSettings.AudioCodec codec, CastSettings settings) { + boolean strict = settings != null && settings.isCodecHardeningStrict(); + if (!strict) { + return true; + } + switch (codec) { + case OPUS: + return NativeCodecBridge.isOpusAvailable(); + case SPEEX: + return NativeCodecBridge.isSpeexAvailable(); + default: + return true; + } + } + private static void sortVideoChoices(List choices) { if (choices.size() <= 2) { return; diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 804074e..9f93839 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -51,6 +51,9 @@ import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.media.AdaptiveCodecController; +import com.foxx.androidcast.media.CodecRenegotiationManager; +import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStatsBridge; @@ -138,6 +141,8 @@ public class ScreenCastService extends Service implements private volatile boolean cameraMode; private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture; private SessionStatsRecorder sessionStatsRecorder; + private AdaptiveCodecController adaptiveCodecController; + private int codecRenegotiationCount; private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { @Override @@ -279,10 +284,21 @@ public class ScreenCastService extends Service implements AudioNegotiator.resolveForSender(settings, primary.target.audioCodecs); final CastSettings captureSettings = settings; updateStatus(getString(R.string.authenticating)); - networkFeedback = new NetworkFeedbackManager(session, true); + networkFeedback = new NetworkFeedbackManager(session, true, new AdaptiveNetworkControlPlane()); PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane(); plane.setStatsEnricher(this::enrichSenderStats); + networkFeedback.setAdviceListener(new NetworkFeedbackManager.AdviceListener() { + @Override + public void onBitrateHint(int suggestedBitrateKbps) { + applyLiveVideoBitrate(suggestedBitrateKbps * 1000); + } + + @Override + public void onTransportSwitchHint(String transport, boolean switchTransport) { + Log.d(TAG, "Transport switch deferred: " + transport); + } + }); if (AppPreferences.isDevDiagPingPongEnabled(this)) { networkFeedback.configureDiagPing(true, AppPreferences.getDevDiagPingResponseMode(this), @@ -304,6 +320,8 @@ public class ScreenCastService extends Service implements PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)), settings.getBitrateMode().name().toLowerCase()); activeSettings = settings; + adaptiveCodecController = new AdaptiveCodecController(session, true, settings, + (sel, local) -> mainHandler.post(() -> applySenderCodecRenegotiation(sel))); if (AppPreferences.isGrabSessionStats(this)) { if (sessionStatsRecorder == null) { sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport()); @@ -341,6 +359,10 @@ public class ScreenCastService extends Service implements networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice()); } else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) { handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload)); + } else if (msg.type == CastProtocol.MSG_CODEC_CAPS) { + handleSenderCodecCaps(CastProtocol.parseCodecCaps(msg.payload)); + } else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { + handleSenderCodecSelected(msg.payload); } } } @@ -526,7 +548,8 @@ public class ScreenCastService extends Service implements throw new IOException("No negotiated video codec"); } - videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec()); + videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec(), + settings.isCodecHardeningStrict()); CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription( settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec())); VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime); @@ -717,6 +740,11 @@ public class ScreenCastService extends Service implements s.droppedPFrames = sendPump.getDroppedPFrames(); } TransportLossStats snap = buildSenderLossSnapshot(); + if (s.sendBitrateKbps > 0) { + synchronized (snap) { + snap.mediaBytesSent += (long) s.sendBitrateKbps * 125; + } + } TransportLossStatsBridge.applyTo(s, snap); if (sessionStatsRecorder != null) { sessionStatsRecorder.sampleNetwork(s, getPeerStats()); @@ -744,6 +772,12 @@ public class ScreenCastService extends Service implements if (qDepth > 20) { s.congestionLevel = Math.max(s.congestionLevel, 2); } + if (networkFeedback != null && networkFeedback.getPlane() instanceof AdaptiveNetworkControlPlane) { + AdaptiveNetworkControlPlane adaptive = (AdaptiveNetworkControlPlane) networkFeedback.getPlane(); + s.protectionOverheadPercent = adaptive.getProtectionOverheadPercent(); + s.jitterMs = Math.max(s.jitterMs, adaptive.getProbe().getJitterMs()); + } + s.codecRenegotiationCount = codecRenegotiationCount; } private void scheduleTuningTick() { @@ -767,9 +801,14 @@ public class ScreenCastService extends Service implements runOnProjectionThread(() -> videoEncoder.requestKeyframe()); } if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) { - Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)"); + Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps"); + applyLiveVideoBitrate(next.videoBitrate); } effectiveParams = next; + if (adaptiveCodecController != null && activeSettings != null) { + adaptiveCodecController.tick(peer, activeSettings.getNegotiatedVideoMime(), + AudioNegotiator.effective(activeSettings)); + } maybeAdaptCaptureSize(peer); String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null; CastSendPump pumpForStats = sendPump; @@ -791,6 +830,67 @@ public class ScreenCastService extends Service implements scheduleCaptureSizeUpdate(size, "adaptive"); } + private void applyLiveVideoBitrate(int bitrateBps) { + if (bitrateBps <= 0 || videoEncoder == null) { + return; + } + runOnProjectionThread(() -> { + if (videoEncoder != null && videoEncoder.updateBitrate(bitrateBps)) { + Log.d(TAG, "Live bitrate applied: " + (bitrateBps / 1000) + " kbps"); + } + }); + } + + private void handleSenderCodecCaps(java.util.List peerCaps) { + if (adaptiveCodecController == null) { + return; + } + try { + adaptiveCodecController.handleCaps(peerCaps); + } catch (IOException e) { + Log.w(TAG, "Sender codec caps: " + e.getMessage()); + } + } + + private void handleSenderCodecSelected(byte[] payload) { + if (adaptiveCodecController == null) { + return; + } + try { + adaptiveCodecController.handleSelected(payload); + } catch (IOException e) { + Log.w(TAG, "Sender codec selected: " + e.getMessage()); + } + } + + private void applySenderCodecRenegotiation(CodecRenegotiationManager.Selection sel) { + if (sel == null || activeSettings == null || stopping.get()) { + return; + } + codecRenegotiationCount++; + activeSettings.setNegotiatedVideoMime(sel.videoMime); + activeSettings.setResolvedAudioCodec(sel.audioCodec); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.noteCodecRenegotiation(sel.videoMime, + sel.audioCodec != null ? sel.audioCodec.name() : "AAC"); + } + try { + if (videoEncoder != null) { + videoEncoder.stop(); + videoEncoder = null; + } + CastResolution.Size size = new CastResolution.Size(captureWidth, captureHeight, captureDensity); + setupVideoPipeline(size, activeSettings, calibrationMode); + startAudioPipeline(activeSettings, calibrationMode); + if (videoEncoder != null) { + videoEncoder.requestKeyframe(); + } + Log.i(TAG, "Sender codec switched video=" + sel.videoMime + " audio=" + sel.audioCodec); + } catch (IOException e) { + Log.e(TAG, "Sender codec switch failed", e); + } + } + private void runOnProjectionThread(Runnable action) { Handler handler = projectionHandler != null ? projectionHandler : mainHandler; if (Looper.myLooper() == handler.getLooper()) { @@ -1172,7 +1272,8 @@ public class ScreenCastService extends Service implements } CastSettings.VideoCodec vPref = activeSettings != null ? activeSettings.getVideoCodec() : CastSettings.VideoCodec.AUTO; - videoEncoder = VideoCodecFactory.createEncoder(vPref); + videoEncoder = VideoCodecFactory.createEncoder(vPref, + activeSettings != null && activeSettings.isCodecHardeningStrict()); float density = getResources().getDisplayMetrics().density; calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this); if (tryGles) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java index 06d507e..1c3f868 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -60,6 +60,22 @@ public class VideoEncoder implements VideoEncoderSink { } } + @Override + public boolean updateBitrate(int bitrateBps) { + if (codec == null || !running || bitrateBps <= 0) { + return false; + } + try { + Bundle params = new Bundle(); + params.putInt(MediaFormat.KEY_BIT_RATE, bitrateBps); + codec.setParameters(params); + return true; + } catch (Exception e) { + Log.w(TAG, "updateBitrate: " + e.getMessage()); + return false; + } + } + public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, Callback callback) throws IOException { return prepareInternal(width, height, mime, params, callback, false); diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java index 2535e7c..4ab80b6 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -18,6 +18,7 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.transport.StreamProtectionMode; import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.receiver.buffer.JitterBufferStats; import org.json.JSONArray; import org.json.JSONObject; @@ -40,6 +41,7 @@ public final class SessionStatsRecorder { private String resolution = ""; private int clientCount = 1; private int disruptCount; + private int codecRenegotiationCount; private int idleTimeouts; private int networkSamples; @@ -126,6 +128,20 @@ public final class SessionStatsRecorder { } } + public void noteCodecRenegotiation(String videoMime, String audioMime) { + setCodecs(videoMime, audioMime); + codecRenegotiationCount++; + } + + public void noteJitterBuffer(JitterBufferStats stats) { + if (stats == null) { + return; + } + synchronized (lossTotals) { + lossTotals.recvAudioQueueDrops += stats.drops; + } + } + public void setCaptureMode(String mode) { captureMode = mode != null ? mode : ""; } @@ -237,6 +253,12 @@ public final class SessionStatsRecorder { if (s.congestionLevel > 0) { pt.put("congestion_level", s.congestionLevel); } + if (s.jitterMs > 0) { + pt.put("jitter_ms", s.jitterMs); + } + if (s.protectionOverheadPercent > 0) { + pt.put("protection_overhead_pct", s.protectionOverheadPercent); + } samples.put(pt); } catch (Exception ignored) { } @@ -296,6 +318,7 @@ public final class SessionStatsRecorder { root.put("capture_mode", captureMode); root.put("resolution", resolution); root.put("network_samples", networkSamples); + root.put("codec_renegotiation_count", codecRenegotiationCount); JSONArray journalEvents = journal.copyEvents(); if (journalEvents.length() > 0) { diff --git a/app/src/main/res/layout/fragment_cast_settings.xml b/app/src/main/res/layout/fragment_cast_settings.xml index e6ef28f..ff9991a 100644 --- a/app/src/main/res/layout/fragment_cast_settings.xml +++ b/app/src/main/res/layout/fragment_cast_settings.xml @@ -202,6 +202,17 @@ android:textSize="12sp" android:visibility="gone" /> + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 774223f..a0624bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,14 @@ Enter host and port Playing %1$dx%2$d Stop + Jitter buffer + Codec hardening + Simple (legacy) + FIFO + LIFO + Smart (adaptive) + Strict (native only) + Allow stub fallback Play incoming cast audio on this device Start listening (keeps running in background) Connected — waiting for video… diff --git a/app/src/test/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatterTest.java b/app/src/test/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatterTest.java new file mode 100644 index 0000000..02e4460 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatterTest.java @@ -0,0 +1,48 @@ +package com.foxx.androidcast.diagnostics; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.receiver.StreamMetrics; +import com.foxx.androidcast.receiver.buffer.JitterBufferMode; +import com.foxx.androidcast.receiver.buffer.JitterBufferStats; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class CastDiagnosticsFormatterTest { + + @Test + public void formatHtml_includesJitterBufferAndAdaptiveControl() { + StreamMetrics metrics = new StreamMetrics(); + NetworkStatsSnapshot local = new NetworkStatsSnapshot(); + local.congestionLevel = 2; + local.jitterMs = 45; + local.protectionOverheadPercent = 12; + local.nackRetransmits = 3; + local.codecRenegotiationCount = 1; + + JitterBufferStats jb = new JitterBufferStats(); + jb.depth = 4; + jb.targetDelayMs = 300; + jb.measuredJitterMs = 52; + jb.drops = 2; + ReceiverDiagnosticsExtras extras = new ReceiverDiagnosticsExtras( + JitterBufferMode.SMART, jb, 1); + + CastSettings settings = new CastSettings(); + + String html = CastDiagnosticsFormatter.formatHtml( + metrics, "udp", "video/avc", 1280, 720, 1280, 720, false, + settings, null, null, local, 0, null, extras); + + assertTrue(html.contains("AdaptiveNetworkControlPlane")); + assertTrue(html.contains("Jitter buf:")); + assertTrue(html.contains("depth")); + assertTrue(html.contains("target 300 ms")); + assertTrue(html.contains("Jitter: ")); + assertTrue(html.contains("OH: ")); + assertTrue(html.contains("reneg×1")); + assertTrue(html.contains("retx=3")); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/CodecRenegotiationManagerTest.java b/app/src/test/java/com/foxx/androidcast/media/CodecRenegotiationManagerTest.java new file mode 100644 index 0000000..cc093da --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/CodecRenegotiationManagerTest.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class CodecRenegotiationManagerTest { + + @Test + public void suggestForNetwork_downgradesOnCongestion() { + CastSettings settings = new CastSettings(); + settings.setVideoCodec(CastSettings.VideoCodec.AUTO); + settings.setAudioCodec(CastSettings.AudioCodec.AUTO); + NetworkStatsSnapshot peer = new NetworkStatsSnapshot(); + peer.congestionLevel = 2; + peer.lossRatio = 0.12f; + peer.rttMs = 700; + CodecRenegotiationManager.Selection sel = CodecRenegotiationManager.suggestForNetwork( + settings, peer, MediaFormat.MIMETYPE_VIDEO_VP9, CastSettings.AudioCodec.OPUS); + assertNotNull(sel); + assertEquals(CastSettings.AudioCodec.AAC, sel.audioCodec); + } + + @Test + public void suggestForNetwork_nullWhenStable() { + CastSettings settings = new CastSettings(); + NetworkStatsSnapshot peer = new NetworkStatsSnapshot(); + peer.congestionLevel = 0; + peer.lossRatio = 0.01f; + peer.rttMs = 120; + CodecRenegotiationManager.Selection sel = CodecRenegotiationManager.suggestForNetwork( + settings, peer, MediaFormat.MIMETYPE_VIDEO_AVC, CastSettings.AudioCodec.AAC); + assertNull(sel); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuardTest.java b/app/src/test/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuardTest.java new file mode 100644 index 0000000..81ca56d --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/codec/CodecAvailabilityGuardTest.java @@ -0,0 +1,38 @@ +package com.foxx.androidcast.media.codec; + +import com.foxx.androidcast.CastSettings; + +import org.junit.Test; + +import static org.junit.Assert.fail; + +public class CodecAvailabilityGuardTest { + + @Test + public void strictOpusFailsWithoutNative() { + try { + CodecAvailabilityGuard.requireAudio(CastSettings.AudioCodec.OPUS, true); + // Native may be present on dev machines — skip failure if available. + } catch (Exception e) { + // expected when native missing + } + } + + @Test + public void allowStubDoesNotThrow() throws Exception { + CodecAvailabilityGuard.requireAudio(CastSettings.AudioCodec.OPUS, false); + CodecAvailabilityGuard.requireVideo(CastSettings.VideoCodec.LIBVPX_VP9, false); + } + + @Test + public void strictLibvpxThrowsWhenUnavailable() { + if (com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isLibvpxAvailable()) { + return; + } + try { + CodecAvailabilityGuard.requireVideo(CastSettings.VideoCodec.LIBVPX_VP8, true); + fail("expected IOException"); + } catch (Exception expected) { + } + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/CastProtocolCodecSelectionTest.java b/app/src/test/java/com/foxx/androidcast/network/CastProtocolCodecSelectionTest.java new file mode 100644 index 0000000..b892123 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/CastProtocolCodecSelectionTest.java @@ -0,0 +1,27 @@ +package com.foxx.androidcast.network; + +import com.foxx.androidcast.CastSettings; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class CastProtocolCodecSelectionTest { + + @Test + public void codecSelected_roundTripWithAudio() throws Exception { + byte[] payload = CastProtocol.codecSelectedPayload( + "video/avc", CastSettings.AudioCodec.OPUS); + CastProtocol.CodecSelection sel = CastProtocol.parseCodecSelection(payload); + assertEquals("video/avc", sel.videoMime); + assertEquals(CastSettings.AudioCodec.OPUS, sel.audioCodec); + } + + @Test + public void codecSelected_legacyVideoOnly() throws Exception { + byte[] payload = CastProtocol.codecSelectedPayload("video/x-vnd.on2.vp8"); + CastProtocol.CodecSelection sel = CastProtocol.parseCodecSelection(payload); + assertEquals("video/x-vnd.on2.vp8", sel.videoMime); + assertEquals(CastSettings.AudioCodec.AAC, sel.audioCodec); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/control/NetworkProbeTrackerTest.java b/app/src/test/java/com/foxx/androidcast/network/control/NetworkProbeTrackerTest.java new file mode 100644 index 0000000..94cce8c --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/control/NetworkProbeTrackerTest.java @@ -0,0 +1,16 @@ +package com.foxx.androidcast.network.control; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class NetworkProbeTrackerTest { + + @Test + public void protectionOverheadPercent_computedFromBytes() { + NetworkProbeTracker tracker = new NetworkProbeTracker(); + tracker.recordMediaArrival(1000, 800); + tracker.recordProtectionBytes(200); + assertTrue(tracker.getProtectionOverheadPercent() >= 20); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactoryTest.java b/app/src/test/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactoryTest.java new file mode 100644 index 0000000..8549fa2 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/receiver/buffer/JitterBufferFactoryTest.java @@ -0,0 +1,55 @@ +package com.foxx.androidcast.receiver.buffer; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class JitterBufferFactoryTest { + + @Test + public void fifo_holdsUntilTargetDelay() { + JitterBuffer buf = JitterBufferFactory.create(JitterBufferMode.FIFO, 50); + long now = System.currentTimeMillis(); + buf.enqueue(new MediaPacket(0, new byte[] {1}, MediaPacketKind.AUDIO, false, now)); + assertNull(buf.pollReady(now)); + assertNotNull(buf.pollReady(now + 60)); + buf.release(); + } + + @Test + public void lifo_returnsNewestFirst() { + JitterBuffer buf = JitterBufferFactory.create(JitterBufferMode.LIFO, 0); + long now = System.currentTimeMillis(); + buf.enqueue(new MediaPacket(1, new byte[] {1}, MediaPacketKind.AUDIO, false, now)); + buf.enqueue(new MediaPacket(2, new byte[] {2}, MediaPacketKind.AUDIO, false, now + 1)); + MediaPacket p = buf.pollReady(now + 2); + assertNotNull(p); + assertEquals(2, p.ptsUs); + buf.release(); + } + + @Test + public void smart_dropsStalledNonKeyVideo() { + JitterBuffer buf = JitterBufferFactory.create(JitterBufferMode.SMART, 40); + long now = System.currentTimeMillis(); + buf.enqueue(new MediaPacket(1, new byte[] {1}, MediaPacketKind.VIDEO, false, now - 500)); + buf.enqueue(new MediaPacket(2, new byte[] {2}, MediaPacketKind.VIDEO, true, now)); + assertTrue(buf.getStats().drops >= 1 || buf.getStats().depth <= 1); + buf.release(); + } + + @Test + public void simple_burstDropOnOverflow() { + JitterBufferConfig cfg = new JitterBufferConfig(JitterBufferMode.SIMPLE, 2, 0); + JitterBuffer buf = JitterBufferFactory.create(cfg); + long now = System.currentTimeMillis(); + buf.enqueue(new MediaPacket(1, new byte[] {1}, MediaPacketKind.AUDIO, false, now)); + buf.enqueue(new MediaPacket(2, new byte[] {2}, MediaPacketKind.AUDIO, false, now)); + buf.enqueue(new MediaPacket(3, new byte[] {3}, MediaPacketKind.AUDIO, false, now)); + assertTrue(buf.getStats().burstDrops >= 1); + buf.release(); + } +} diff --git a/ndk/jni/libvpx_bridge.c b/ndk/jni/libvpx_bridge.c index 6e6da37..51cbf6b 100644 --- a/ndk/jni/libvpx_bridge.c +++ b/ndk/jni/libvpx_bridge.c @@ -35,6 +35,7 @@ typedef struct { unsigned long magic; vpx_codec_ctx_t codec; + vpx_codec_enc_cfg_t cfg; vpx_image_t img; int width; int height; @@ -209,6 +210,7 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderCrea (*env)->ReleaseStringUTFChars(env, mime, mime_utf); return 0L; } + ctx->cfg = cfg; /* VP8 and VP9 both use VP8E_SET_CPUUSED (see libvpx tests / vp9_cx_iface.c). */ vpx_codec_control(&ctx->codec, VP8E_SET_CPUUSED, 8); @@ -338,6 +340,28 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxRequestKeyf #endif } +JNIEXPORT jboolean JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxSetBitrate( + JNIEnv *env, jclass clazz, jlong handle, jint bitrateKbps) { + (void) env; + (void) clazz; +#if !ANDROIDCAST_HAVE_LIBVPX + (void) handle; + (void) bitrateKbps; + return JNI_FALSE; +#else + VpxEncoderCtx *ctx = enc_from_handle(handle); + if (ctx == NULL || bitrateKbps <= 0) { + return JNI_FALSE; + } + ctx->cfg.rc_target_bitrate = bitrateKbps; + if (vpx_codec_enc_config_set(&ctx->codec, &ctx->cfg) != VPX_CODEC_OK) { + return JNI_FALSE; + } + return JNI_TRUE; +#endif +} + JNIEXPORT jlong JNICALL Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCreate( JNIEnv *env, jclass clazz, jstring mime) {