1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-07-29 02:07:35 +03:00

Add adaptive jitter buffer, BWE, and mid-session codec renegotiation.

Receiver pipeline gets configurable jitter buffering with live stats in the debug overlay; sender/receiver share AdaptiveNetworkControlPlane for congestion-aware bitrate and codec switches without a protocol version bump.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-08 14:55:13 +02:00
parent fb8b0c43e7
commit 4c15be763b
54 changed files with 2210 additions and 49 deletions

View File

@@ -18,6 +18,8 @@ import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy; 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.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset; import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
import com.foxx.androidcast.sender.SenderResolutionUiMode; 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_THEME = "theme_mode";
private static final String KEY_LOCALE = "locale_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_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_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_GRAB_SESSION_STATS = "dev_grab_session_stats";
private static final String KEY_DEV_STREAM_DUMP = "dev_stream_dump"; 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(); 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). */ /** Developer: metrics HTML overlay on receiver video (default on). */
public static boolean isShowReceiverDebugOverlay(Context context) { public static boolean isShowReceiverDebugOverlay(Context context) {
return prefs(context).getBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, true); return prefs(context).getBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, true);
@@ -293,6 +354,7 @@ public final class AppPreferences {
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
} }
s.setCodecHardening(getSenderCodecHardening(context));
clampAlphaTransport(s); clampAlphaTransport(s);
return s; return s;
} }
@@ -665,6 +727,7 @@ public final class AppPreferences {
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
} }
s.setCodecHardening(getReceiverCodecHardening(context));
clampAlphaTransport(s); clampAlphaTransport(s);
return s; return s;
} }

View File

@@ -34,9 +34,9 @@ public class CastSettings implements Serializable {
public enum VideoCodec { public enum VideoCodec {
AUTO, H264, H265, PASSTHROUGH, AUTO, H264, H265, PASSTHROUGH,
/** Future software VP8 (UI disabled until integrated). */ /** libvpx software VP8 (requires native build). */
LIBVPX_VP8, LIBVPX_VP8,
/** Future software VP9 (UI disabled until integrated). */ /** libvpx software VP9 (requires native build). */
LIBVPX_VP9 LIBVPX_VP9
} }
@@ -97,6 +97,12 @@ public class CastSettings implements Serializable {
ADAPTIVE_EXPERIMENTAL 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 { public enum Resolution {
FULL(1.0f, 0, 0), FULL(1.0f, 0, 0),
THREE_QUARTERS(0.75f, 0, 0), THREE_QUARTERS(0.75f, 0, 0),
@@ -152,6 +158,8 @@ public class CastSettings implements Serializable {
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode(); private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
private StreamProtection streamProtection = StreamProtection.NONE; private StreamProtection streamProtection = StreamProtection.NONE;
private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT; 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). */ /** Filled after codec handshake (e.g. video/avc). */
private transient String negotiatedVideoMime; private transient String negotiatedVideoMime;
/** Filled when AUTO (or fallback) resolves against peer audio caps. */ /** Filled when AUTO (or fallback) resolves against peer audio caps. */
@@ -303,6 +311,18 @@ public class CastSettings implements Serializable {
? receiverDisplayResolution : ReceiverDisplayResolution.AUTO_FIT; ? 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() { public boolean isCameraCapture() {
return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT; return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT;
} }
@@ -327,6 +347,7 @@ public class CastSettings implements Serializable {
captureMode = other.captureMode; captureMode = other.captureMode;
streamProtection = other.streamProtection; streamProtection = other.streamProtection;
receiverDisplayResolution = other.receiverDisplayResolution; receiverDisplayResolution = other.receiverDisplayResolution;
codecHardening = other.codecHardening;
negotiatedVideoMime = other.negotiatedVideoMime; negotiatedVideoMime = other.negotiatedVideoMime;
resolvedAudioCodec = other.resolvedAudioCodec; resolvedAudioCodec = other.resolvedAudioCodec;
} }

View File

@@ -22,6 +22,7 @@ import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.receiver.buffer.JitterBufferMode;
import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.sender.CodecCatalog;
import com.foxx.androidcast.ui.SettingsEnumSpinner; import com.foxx.androidcast.ui.SettingsEnumSpinner;
import com.foxx.androidcast.ui.SettingsStringArrayAdapter; import com.foxx.androidcast.ui.SettingsStringArrayAdapter;
@@ -111,6 +112,9 @@ public final class CastSettingsBinder {
bindStreamProtection(activity, settings); bindStreamProtection(activity, settings);
bindAudioSpinner(activity, settings, R.id.spinner_sender_audio); bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
bindAudioCodec(activity, settings); bindAudioCodec(activity, settings);
bindCodecHardening(activity, R.id.spinner_sender_codec_hardening,
AppPreferences.getSenderCodecHardening(activity),
AppPreferences::setSenderCodecHardening);
} }
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) { 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); bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network); 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); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
if (audioPlay != null) { if (audioPlay != null) {
bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity), bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
@@ -136,6 +143,8 @@ public final class CastSettingsBinder {
readAudioSpinner(activity, settings, R.id.spinner_sender_audio); readAudioSpinner(activity, settings, R.id.spinner_sender_audio);
readAudioCodec(activity, settings); readAudioCodec(activity, settings);
settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
readCodecHardening(activity, R.id.spinner_sender_codec_hardening, AppPreferences::setSenderCodecHardening);
settings.setCodecHardening(AppPreferences.getSenderCodecHardening(activity));
AppPreferences.saveSenderDefaults(activity, settings); AppPreferences.saveSenderDefaults(activity, settings);
} }
@@ -144,14 +153,81 @@ public final class CastSettingsBinder {
readTransport(activity, settings, R.id.spinner_receiver_transport); readTransport(activity, settings, R.id.spinner_receiver_transport);
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network); 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); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
if (audioPlay != null) { if (audioPlay != null) {
AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0); AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
} }
settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
settings.setCodecHardening(AppPreferences.getReceiverCodecHardening(activity));
AppPreferences.saveReceiverDefaults(activity, settings); 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, private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId,
boolean unused) { boolean unused) {
Spinner spinner = activity.findViewById(spinnerId); Spinner spinner = activity.findViewById(spinnerId);

View File

@@ -21,6 +21,8 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.network.diag.DiagPingMetrics;
import com.foxx.androidcast.receiver.StreamMetrics; import com.foxx.androidcast.receiver.StreamMetrics;
import com.foxx.androidcast.receiver.buffer.JitterBufferStats;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -37,6 +39,15 @@ public final class CastDiagnosticsFormatter {
CastSettings localSettings, CastSettings remoteSettings, CastSettings localSettings, CastSettings remoteSettings,
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent, NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
CastMtuSampler.Info mtuInfo) { 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 inFps = metrics.getInFps();
float renderFps = metrics.getRenderFps(); float renderFps = metrics.getRenderFps();
int bwInKbps = metrics.getBwInKbps(); int bwInKbps = metrics.getBwInKbps();
@@ -100,6 +111,29 @@ public final class CastDiagnosticsFormatter {
congestionLevel = Math.max(congestionLevel, lastLocal.congestionLevel); 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(); boolean audioRequested = remoteSettings != null && remoteSettings.isAudioRequested();
List<String> hints = buildHints(inFps, renderFps, bwInKbps, rttMs, videoPackets); List<String> hints = buildHints(inFps, renderFps, bwInKbps, rttMs, videoPackets);
@@ -123,7 +157,7 @@ public final class CastDiagnosticsFormatter {
html.append("Codec: <font color='").append(COLOR_OK).append("'>") html.append("Codec: <font color='").append(COLOR_OK).append("'>")
.append(escape(CodecNegotiator.displayName(videoCodec))).append("</font><br/>"); .append(escape(CodecNegotiator.displayName(videoCodec))).append("</font><br/>");
} }
appendCodecStackSection(html, localSettings, remoteSettings); appendCodecStackSection(html, localSettings, remoteSettings, codecRenegCount);
html.append("Stream: ").append(streamW).append('x').append(streamH); html.append("Stream: ").append(streamW).append('x').append(streamH);
if (viewZoomPercent != 0) { if (viewZoomPercent != 0) {
@@ -142,6 +176,9 @@ public final class CastDiagnosticsFormatter {
html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a", html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f)); suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350)); 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(); DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing();
if (diagPing.enabled && diagPing.pongsRx > 0) { if (diagPing.enabled && diagPing.pongsRx > 0) {
int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs; int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs;
@@ -158,10 +195,15 @@ public final class CastDiagnosticsFormatter {
html.append("<br/>"); html.append("<br/>");
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0)); html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
if (protectionOverheadPct > 0) {
html.append(" OH: ").append(colored(protectionOverheadPct + "%", protectionOverheadPct > 25));
}
html.append("<br/>"); html.append("<br/>");
appendJitterBufferSection(html, receiverExtras, jitterBufferDrops);
appendUdpLossSection(html, peer, lastLocal); appendUdpLossSection(html, peer, lastLocal);
appendProtectionSection(html, peer, lastLocal); appendProtectionSection(html, peer, lastLocal, nackRetransmits);
html.append("Ctrl: ").append(escape(ctrl)); html.append("Ctrl: ").append(escape(ctrl));
html.append("<br/>"); html.append("<br/>");
@@ -374,7 +416,7 @@ public final class CastDiagnosticsFormatter {
} }
private static void appendCodecStackSection(StringBuilder html, CastSettings localSettings, private static void appendCodecStackSection(StringBuilder html, CastSettings localSettings,
CastSettings remoteSettings) { CastSettings remoteSettings, int codecRenegCount) {
CastSettings prefs = remoteSettings != null ? remoteSettings : localSettings; CastSettings prefs = remoteSettings != null ? remoteSettings : localSettings;
html.append("<font color='").append(COLOR_DIM).append("'>Video stack:</font> "); html.append("<font color='").append(COLOR_DIM).append("'>Video stack:</font> ");
html.append(escape(CodecSessionRegistry.getVideoBackend().label)); html.append(escape(CodecSessionRegistry.getVideoBackend().label));
@@ -391,6 +433,11 @@ public final class CastDiagnosticsFormatter {
html.append(" · pref "); html.append(" · pref ");
html.append(escape(PassthroughCodecPolicy.displayAudioPreference( html.append(escape(PassthroughCodecPolicy.displayAudioPreference(
prefs != null ? prefs.getAudioCodec() : CodecSessionRegistry.getAudioPreference()))); prefs != null ? prefs.getAudioCodec() : CodecSessionRegistry.getAudioPreference())));
html.append(" · live ").append(escape(CastDiagnosticsLabels.resolvedAudioCodec(prefs)));
if (codecRenegCount > 0) {
html.append(" · <font color='").append(COLOR_OK).append("'>reneg×")
.append(codecRenegCount).append("</font>");
}
if (prefs != null && prefs.isAudioRequested()) { if (prefs != null && prefs.isAudioRequested()) {
CastSettings.AudioCodec ac = prefs.getAudioCodec(); CastSettings.AudioCodec ac = prefs.getAudioCodec();
if (ac == CastSettings.AudioCodec.OPUS) { if (ac == CastSettings.AudioCodec.OPUS) {
@@ -412,8 +459,37 @@ public final class CastDiagnosticsFormatter {
html.append("<br/>"); html.append("<br/>");
} }
private static void appendJitterBufferSection(StringBuilder html, ReceiverDiagnosticsExtras extras,
long statsDrops) {
if (extras == null && statsDrops == 0) {
return;
}
html.append("<font color='").append(COLOR_DIM).append("'>Jitter buf:</font> ");
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("<br/>");
}
private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer, 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 mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE");
String summary = CodecSessionRegistry.getProtectionSummary(); String summary = CodecSessionRegistry.getProtectionSummary();
html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> "); html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> ");
@@ -432,8 +508,11 @@ public final class CastDiagnosticsFormatter {
.append("</font>"); .append("</font>");
} }
} }
if (nackReq > 0) { if (nackReq > 0 || nackRetransmits > 0) {
html.append("<br/>NACK events=").append(nackReq); html.append("<br/>NACK events=").append(nackReq);
if (nackRetransmits > 0) {
html.append(" · retx=").append(nackRetransmits);
}
} }
html.append("<br/>"); html.append("<br/>");
} }

View File

@@ -19,7 +19,35 @@ public final class CastDiagnosticsLabels {
private CastDiagnosticsLabels() {} private CastDiagnosticsLabels() {}
public static String congestionController() { 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) { public static String bitrateMode(CastSettings.BitrateMode mode) {

View File

@@ -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);
}
}

View File

@@ -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<String> 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<String> peerCaps) throws IOException {
CodecRenegotiationManager.Selection sel = CodecRenegotiationManager.negotiateFromCaps(
peerCaps, settings, isSender);
List<String> 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);
}
}
}

View File

@@ -60,7 +60,7 @@ public final class AudioNegotiator {
return CastSettings.AudioCodec.AAC; return CastSettings.AudioCodec.AAC;
} }
CastSettings.AudioCodec resolved = negotiate( CastSettings.AudioCodec resolved = negotiate(
CastCodecFlags.localAudioMask(), CastCodecFlags.localAudioMask(settings.isCodecHardeningStrict()),
peerAudioMask, peerAudioMask,
settings.getAudioCodec(), settings.getAudioCodec(),
settings, settings,

View File

@@ -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<String> peerCaps, CastSettings localSettings,
boolean isSender) {
List<String> 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;
}
}

View File

@@ -15,18 +15,27 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.AudioNegotiator; import com.foxx.androidcast.media.AudioNegotiator;
import com.foxx.androidcast.sender.AudioEncoder; import com.foxx.androidcast.sender.AudioEncoder;
import java.io.IOException;
/** Creates audio encoder sinks. */ /** Creates audio encoder sinks. */
public final class AudioCodecFactory { public final class AudioCodecFactory {
private AudioCodecFactory() {} private AudioCodecFactory() {}
public static AudioEncoderSink createEncoder(CastSettings settings) { public static AudioEncoderSink createEncoder(CastSettings settings) throws IOException {
CastSettings.AudioCodec preference = settings != null CastSettings.AudioCodec preference = settings != null
? AudioNegotiator.effective(settings) : CastSettings.AudioCodec.AAC; ? 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); CodecSessionRegistry.setAudioPreference(preference);
CodecAvailabilityGuard.requireAudio(preference, strict);
AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true); AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true);
switch (backend) { switch (backend) {
case OPUS_NATIVE: case OPUS_NATIVE:
@@ -36,6 +45,9 @@ public final class AudioCodecFactory {
case PASSTHROUGH_DEBUG: case PASSTHROUGH_DEBUG:
case OPUS_STUB: case OPUS_STUB:
case SPEEX_STUB: case SPEEX_STUB:
if (strict) {
throw new IOException("Native audio codec required for " + preference.name());
}
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference); return new PassthroughAudioEncoderSink(new AudioEncoder(), preference);
default: default:
return new AudioEncoder(); return new AudioEncoder();

View File

@@ -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)");
}
}
}

View File

@@ -245,4 +245,15 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
} }
return MediaFormat.MIMETYPE_VIDEO_VP8; 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);
}
} }

View File

@@ -69,6 +69,11 @@ final class PassthroughVideoEncoderSink implements VideoEncoderSink {
delegate.stop(); delegate.stop();
} }
@Override
public boolean updateBitrate(int bitrateBps) {
return delegate.updateBitrate(bitrateBps);
}
CastSettings.VideoCodec getPreference() { CastSettings.VideoCodec getPreference() {
return preference; return preference;
} }

View File

@@ -15,17 +15,28 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.sender.VideoEncoder; import com.foxx.androidcast.sender.VideoEncoder;
import java.io.IOException;
/** Creates video encoder sinks; libvpx used when native library is linked. */ /** Creates video encoder sinks; libvpx used when native library is linked. */
public final class VideoCodecFactory { public final class VideoCodecFactory {
private 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); CodecSessionRegistry.setVideoPreference(preference);
CodecAvailabilityGuard.requireVideo(preference, strict);
if (preference == CastSettings.VideoCodec.LIBVPX_VP8 if (preference == CastSettings.VideoCodec.LIBVPX_VP8
|| preference == CastSettings.VideoCodec.LIBVPX_VP9) { || preference == CastSettings.VideoCodec.LIBVPX_VP9) {
if (NativeCodecBridge.isLibvpxAvailable()) { if (NativeCodecBridge.isLibvpxAvailable()) {
return new LibvpxVideoEncoderSink(preference); return new LibvpxVideoEncoderSink(preference);
} }
if (strict) {
throw new IOException("libvpx native library required for " + preference.name());
}
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null); CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null);
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference); return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
} }

View File

@@ -42,4 +42,9 @@ public interface VideoEncoderSink {
void queueYuvFrame(byte[] yuv, long ptsUs); void queueYuvFrame(byte[] yuv, long ptsUs);
void stop(); void stop();
/** Live bitrate adjustment during an active session (bps). Returns true if applied. */
default boolean updateBitrate(int bitrateBps) {
return false;
}
} }

View File

@@ -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 ─────────────────────────────────────────────────── */ /* ── Opus encoder ─────────────────────────────────────────────────── */
public static long opusEncoderCreate(int sampleRate, int channels, int bitrateKbps) { 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 void nativeVpxRequestKeyframe(long handle);
private static native boolean nativeVpxSetBitrate(long handle, int bitrateKbps);
private static native long nativeVpxDecoderCreate(String mime); private static native long nativeVpxDecoderCreate(String mime);
private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface); private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface);

View File

@@ -16,6 +16,8 @@ import android.media.MediaFormat;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.sender.CodecCatalog;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import java.util.List; import java.util.List;
/** Video/audio codec bitmasks in {@link CastProtoHeader}. */ /** Video/audio codec bitmasks in {@link CastProtoHeader}. */
@@ -62,8 +64,22 @@ public final class CastCodecFlags {
} }
public static int localAudioMask() { public static int localAudioMask() {
// Advertise Opus/Speex for stub negotiation even when native probes are false. return localAudioMask(false);
return AUDIO_AAC | AUDIO_PCM | AUDIO_OPUS | AUDIO_SPEEX; }
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() { public static int localDecoderAudioMask() {
@@ -101,8 +117,9 @@ public final class CastCodecFlags {
} }
switch (codec) { switch (codec) {
case OPUS: case OPUS:
return NativeCodecBridge.isOpusAvailable();
case SPEEX: case SPEEX:
return true; return NativeCodecBridge.isSpeexAvailable();
case AAC: case AAC:
case PASSTHROUGH_DEBUG: case PASSTHROUGH_DEBUG:
return true; return true;

View File

@@ -194,16 +194,43 @@ public final class CastProtocol {
} }
public static byte[] codecSelectedPayload(String mime) throws IOException { 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(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos); DataOutputStream dos = new DataOutputStream(bos);
dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC); dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC);
if (audio != null) {
dos.writeInt(audio.ordinal());
}
dos.flush(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
public static String parseCodecSelected(byte[] payload) throws IOException { 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)); 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 { 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()); 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 { public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
@@ -346,6 +373,12 @@ public final class CastProtocol {
dos.writeLong(s.fecPacketsDecoded); dos.writeLong(s.fecPacketsDecoded);
dos.writeLong(s.fecDecodeFailures); 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(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
@@ -404,6 +437,12 @@ public final class CastProtocol {
s.fecPacketsDecoded = dis.readLong(); s.fecPacketsDecoded = dis.readLong();
s.fecDecodeFailures = 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; return s;

View File

@@ -12,8 +12,10 @@ package com.foxx.androidcast.network;
* Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025 * Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025
**********************************************************************/ **********************************************************************/
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.AudioNegotiator;
import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.sender.CodecCatalog;
import com.foxx.androidcast.stats.SessionStatsContext; import com.foxx.androidcast.stats.SessionStatsContext;
@@ -95,7 +97,9 @@ public class CastSession {
if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) { if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) {
receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload); receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload);
} else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { } 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; break;
} }
} }
@@ -161,7 +165,12 @@ public class CastSession {
recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps); recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps);
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver"); 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); transport.send(CastProtocol.MSG_CODEC_SELECTED, selected);
recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected); recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected);
remote.setNegotiatedVideoMime(mime); remote.setNegotiatedVideoMime(mime);

View File

@@ -183,6 +183,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} }
synchronized (lossStats) { synchronized (lossStats) {
lossStats.nackRetransmits++; lossStats.nackRetransmits++;
if (entry.body != null) {
lossStats.protectionBytesSent += entry.body.length;
}
} }
retransmitCached(entry.wireType, entry.body, messageId); retransmitCached(entry.wireType, entry.body, messageId);
} }

View File

@@ -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;
}
}

View File

@@ -23,10 +23,17 @@ import java.io.IOException;
public class NetworkFeedbackManager { public class NetworkFeedbackManager {
private static final String TAG = "NetworkFeedback"; 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 NetworkControlPlane plane;
private final CastSession session; private final CastSession session;
private final boolean isSender; private final boolean isSender;
private DiagPingSession diagPing; private DiagPingSession diagPing;
private AdviceListener adviceListener;
public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) { public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) {
this.session = session; this.session = session;
@@ -57,6 +64,10 @@ public class NetworkFeedbackManager {
return diagPing; return diagPing;
} }
public void setAdviceListener(AdviceListener listener) {
this.adviceListener = listener;
}
/** Call on each stream loop iteration (~500ms). */ /** Call on each stream loop iteration (~500ms). */
public void tick() { public void tick() {
long now = System.currentTimeMillis(); 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) { public void applyAdvice(NetworkControlAdvice advice) {
if (advice.suggestedBitrateKbps > 0 && isSender) { if (advice == null) {
Log.d(TAG, "Bitrate hint (not applied yet): " + advice.suggestedBitrateKbps); return;
} }
if (advice.switchTransport) { if (advice.suggestedBitrateKbps > 0 && isSender) {
Log.d(TAG, "Transport switch hint (not applied yet): " + advice.suggestedTransport); 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);
} }
} }
} }

View File

@@ -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;
}
}
}

View File

@@ -61,6 +61,11 @@ public class NetworkStatsSnapshot {
public long fecPacketsDecoded; public long fecPacketsDecoded;
public long fecDecodeFailures; public long fecDecodeFailures;
public long nackStubRequests; 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() { public NetworkStatsSnapshot copy() {
NetworkStatsSnapshot c = new NetworkStatsSnapshot(); NetworkStatsSnapshot c = new NetworkStatsSnapshot();
@@ -106,6 +111,10 @@ public class NetworkStatsSnapshot {
c.fecPacketsDecoded = fecPacketsDecoded; c.fecPacketsDecoded = fecPacketsDecoded;
c.fecDecodeFailures = fecDecodeFailures; c.fecDecodeFailures = fecDecodeFailures;
c.nackStubRequests = nackStubRequests; c.nackStubRequests = nackStubRequests;
c.nackRetransmits = nackRetransmits;
c.protectionOverheadPercent = protectionOverheadPercent;
c.jitterBufferDrops = jitterBufferDrops;
c.codecRenegotiationCount = codecRenegotiationCount;
return c; return c;
} }
} }

View File

@@ -94,6 +94,11 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
if (stats != null) { if (stats != null) {
synchronized (stats) { synchronized (stats) {
stats.fecPacketsEncoded += shards.length; 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); return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards);

View File

@@ -53,6 +53,10 @@ public final class TransportLossStats {
public long fecPacketsStub; public long fecPacketsStub;
public long nackRequestsStub; public long nackRequestsStub;
public long nackRetransmitsStub; public long nackRetransmitsStub;
public long protectionBytesSent;
public long protectionBytesReceived;
public long mediaBytesSent;
public long mediaBytesReceived;
public synchronized void addFrom(TransportLossStats other) { public synchronized void addFrom(TransportLossStats other) {
if (other == null) { if (other == null) {
@@ -88,6 +92,10 @@ public final class TransportLossStats {
fecPacketsStub += other.fecPacketsStub; fecPacketsStub += other.fecPacketsStub;
nackRequestsStub += other.nackRequestsStub; nackRequestsStub += other.nackRequestsStub;
nackRetransmitsStub += other.nackRetransmitsStub; nackRetransmitsStub += other.nackRetransmitsStub;
protectionBytesSent += other.protectionBytesSent;
protectionBytesReceived += other.protectionBytesReceived;
mediaBytesSent += other.mediaBytesSent;
mediaBytesReceived += other.mediaBytesReceived;
} }
public synchronized TransportLossStats copy() { public synchronized TransportLossStats copy() {
@@ -124,6 +132,10 @@ public final class TransportLossStats {
c.fecPacketsStub = fecPacketsStub; c.fecPacketsStub = fecPacketsStub;
c.nackRequestsStub = nackRequestsStub; c.nackRequestsStub = nackRequestsStub;
c.nackRetransmitsStub = nackRetransmitsStub; c.nackRetransmitsStub = nackRetransmitsStub;
c.protectionBytesSent = protectionBytesSent;
c.protectionBytesReceived = protectionBytesReceived;
c.mediaBytesSent = mediaBytesSent;
c.mediaBytesReceived = mediaBytesReceived;
return c; return c;
} }

View File

@@ -47,6 +47,12 @@ public final class TransportLossStatsBridge {
s.fecPacketsDecoded = loss.fecPacketsDecoded; s.fecPacketsDecoded = loss.fecPacketsDecoded;
s.fecDecodeFailures = loss.fecDecodeFailures; s.fecDecodeFailures = loss.fecDecodeFailures;
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived; 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(); long lossEvents = loss.estimatedUdpLossEvents();
if (loss.datagramsReceived > 0 && lossEvents > 0) { if (loss.datagramsReceived > 0 && lossEvents > 0) {
s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived); s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);

View File

@@ -37,6 +37,7 @@ import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
import com.foxx.androidcast.diagnostics.CastMtuSampler; import com.foxx.androidcast.diagnostics.CastMtuSampler;
import com.foxx.androidcast.diagnostics.ReceiverDiagnosticsExtras;
import com.foxx.androidcast.diagnostics.CpuLoadSampler; import com.foxx.androidcast.diagnostics.CpuLoadSampler;
import com.foxx.androidcast.media.codec.CodecSessionRegistry; import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; 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.live.LiveCastControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge; 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.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.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder; import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore; 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.network.selftest.NetworkSelfTestBridge;
import com.foxx.androidcast.util.CastBackgroundExecutor; import com.foxx.androidcast.util.CastBackgroundExecutor;
import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
/** /**
@@ -125,6 +133,9 @@ public class ReceiverCastService extends Service {
private long lastStreamConfigMs; private long lastStreamConfigMs;
private final TransportLossStats transportLoss = new TransportLossStats(); private final TransportLossStats transportLoss = new TransportLossStats();
private SessionStatsRecorder sessionStatsRecorder; private SessionStatsRecorder sessionStatsRecorder;
private ReceiveJitterPipeline jitterPipeline;
private AdaptiveCodecController adaptiveCodecController;
private int codecRenegotiationCount;
private final Runnable idleWatchdog = new Runnable() { private final Runnable idleWatchdog = new Runnable() {
@Override @Override
@@ -262,6 +273,29 @@ public class ReceiverCastService extends Service {
synchronized (audioDecoderLock) { synchronized (audioDecoderLock) {
audioDecoder = buildDefaultAudioDecoder(); 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() { session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
@Override @Override
@@ -327,7 +361,9 @@ public class ReceiverCastService extends Service {
}); });
if (videoDecodeHandler != null) { if (videoDecodeHandler != null) {
videoDecodeHandler.post(() -> { videoDecodeHandler.post(() -> {
if (videoDecoder != null) { if (jitterPipeline != null) {
jitterPipeline.enqueueVideo(ptsUs, keyFrame, frameData);
} else if (videoDecoder != null) {
videoDecoder.queueFrame(ptsUs, keyFrame, frameData); videoDecoder.queueFrame(ptsUs, keyFrame, frameData);
} }
}); });
@@ -375,9 +411,13 @@ public class ReceiverCastService extends Service {
accept = Boolean.TRUE.equals(audioAccepted); accept = Boolean.TRUE.equals(audioAccepted);
} }
if (accept) { if (accept) {
synchronized (audioDecoderLock) { if (jitterPipeline != null) {
if (audioDecoder != null) { jitterPipeline.enqueueAudio(ptsUs, data);
audioDecoder.queueFrame(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) { public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) {
mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot)); mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot));
} }
@Override
public void onCodecCaps(java.util.List<String> peerCaps) {
mainHandler.post(() -> handlePeerCodecCaps(peerCaps));
}
@Override
public void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
mainHandler.post(() -> applyCodecRenegotiation(selection));
}
}, this::enrichReceiverStats); }, this::enrichReceiverStats);
session.start(); session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase())); updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
@@ -434,7 +484,10 @@ public class ReceiverCastService extends Service {
negotiatedVideoMime = videoMime; negotiatedVideoMime = videoMime;
/* Replace audio decoder if the negotiated codec is Opus or Speex. */ /* Replace audio decoder if the negotiated codec is Opus or Speex. */
if (remoteSettings != null) { if (remoteSettings != null) {
CastSettings.AudioCodec negotiatedAudio = remoteSettings.getAudioCodec(); CastSettings.AudioCodec negotiatedAudio = remoteSettings.getResolvedAudioCodec();
if (negotiatedAudio == null) {
negotiatedAudio = remoteSettings.getAudioCodec();
}
synchronized (audioDecoderLock) { synchronized (audioDecoderLock) {
if (negotiatedAudio == CastSettings.AudioCodec.OPUS if (negotiatedAudio == CastSettings.AudioCodec.OPUS
&& com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) { && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) {
@@ -459,6 +512,11 @@ public class ReceiverCastService extends Service {
LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec, LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
settings.getBitrateMode().name().toLowerCase()); settings.getBitrateMode().name().toLowerCase());
remoteCastSettings = remoteSettings; remoteCastSettings = remoteSettings;
if (session != null && session.getActiveSession() != null) {
adaptiveCodecController = new AdaptiveCodecController(
session.getActiveSession(), false, settings,
(sel, local) -> applyCodecRenegFromManager(sel));
}
streamIdle = false; streamIdle = false;
streamMetrics.reset(); streamMetrics.reset();
lastVideoFrameMs = 0; lastVideoFrameMs = 0;
@@ -722,6 +780,11 @@ public class ReceiverCastService extends Service {
} }
private void stopSessionOnly() { private void stopSessionOnly() {
if (jitterPipeline != null) {
jitterPipeline.stop();
jitterPipeline = null;
}
adaptiveCodecController = null;
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
discovery = null; 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<String> 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) { private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
s.encodeFps = streamMetrics.getDecodeFps(); s.encodeFps = streamMetrics.getDecodeFps();
TransportLossStats snap = buildReceiverLossSnapshot(); TransportLossStats snap = buildReceiverLossSnapshot();
if (s.recvBitrateKbps > 0) {
synchronized (snap) {
snap.mediaBytesReceived += (long) s.recvBitrateKbps * 125;
}
}
TransportLossStatsBridge.applyTo(s, snap); TransportLossStatsBridge.applyTo(s, snap);
if (sessionStatsRecorder != null) { if (sessionStatsRecorder != null) {
sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer()); sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
@@ -870,11 +1011,33 @@ public class ReceiverCastService extends Service {
if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) { if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
s.congestionLevel = Math.max(s.congestionLevel, 2); 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(); lastLocalStats = s.copy();
} }
private String buildDiagnosticsText() { private String buildDiagnosticsText() {
NetworkStatsSnapshot peer = streamMetrics.getLastPeer(); 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( return CastDiagnosticsFormatter.formatHtml(
streamMetrics, streamMetrics,
settings.getTransport(), settings.getTransport(),
@@ -889,7 +1052,8 @@ public class ReceiverCastService extends Service {
peer, peer,
lastLocalStats, lastLocalStats,
PlaybackViewState.getZoomPercent(), PlaybackViewState.getZoomPercent(),
CastMtuSampler.sample(this)); CastMtuSampler.sample(this),
extras);
} }
private TransportLossStats buildReceiverLossSnapshot() { private TransportLossStats buildReceiverLossSnapshot() {

View File

@@ -31,12 +31,14 @@ import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.transport.TransportLossStats; 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.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.diag.DiagPingMetrics; import com.foxx.androidcast.network.diag.DiagPingMetrics;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadFactory;
@@ -77,6 +79,14 @@ public class ReceiverSession {
/** Diagnostic ping-pong sample (developer setting). */ /** Diagnostic ping-pong sample (developer setting). */
default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) { 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<String> peerCaps) {
}
} }
private final Context appContext; private final Context appContext;
@@ -166,7 +176,7 @@ public class ReceiverSession {
0, 0,
DeviceInfo.getPlaceholderId(appContext), DeviceInfo.getPlaceholderId(appContext),
CastCodecFlags.localDecoderVideoMask(), CastCodecFlags.localDecoderVideoMask(),
CastCodecFlags.localAudioMask())); CastCodecFlags.localAudioMask(localSettings.isCodecHardeningStrict())));
session = new CastSession(listenTransport); session = new CastSession(listenTransport);
int listenPort = CastConfig.portForTransport(localSettings.getTransport()); int listenPort = CastConfig.portForTransport(localSettings.getTransport());
session.prepareListen(listenPort); session.prepareListen(listenPort);
@@ -178,16 +188,13 @@ public class ReceiverSession {
} }
inboundSessionGate.reset(); inboundSessionGate.reset();
listener.onCastSessionStarting(); listener.onCastSessionStarting();
networkFeedback = new NetworkFeedbackManager(session, false); networkFeedback = new NetworkFeedbackManager(session, false, new AdaptiveNetworkControlPlane());
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane();
PassThroughNetworkControlPlane plane = plane.setStatsEnricher(statsEnricher);
(PassThroughNetworkControlPlane) networkFeedback.getPlane(); if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
plane.setStatsEnricher(statsEnricher); networkFeedback.configureDiagPing(true,
if (AppPreferences.isDevDiagPingPongEnabled(appContext)) { AppPreferences.getDevDiagPingResponseMode(appContext),
networkFeedback.configureDiagPing(true, listener::onDiagPingSample);
AppPreferences.getDevDiagPingResponseMode(appContext),
listener::onDiagPingSample);
}
} }
session.acceptClient(); session.acceptClient();
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
@@ -289,16 +296,39 @@ public class ReceiverSession {
postStatus("Sender stopped casting"); postStatus("Sender stopped casting");
listener.onCastEnded(); listener.onCastEnded();
return; 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: default:
break; 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) { 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); ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).recordBytes(bytes);
} }
if (networkFeedback.getPlane() instanceof AdaptiveNetworkControlPlane) {
((AdaptiveNetworkControlPlane) networkFeedback.getPlane()).getProbe().recordMediaArrival(now, bytes);
}
} }
private static boolean isProtocolDesync(IOException e) { private static boolean isProtocolDesync(IOException e) {

View File

@@ -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<MediaPacket> 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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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<MediaPacket> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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);
}
}
}

View File

@@ -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<MediaPacket> 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();
}
}

View File

@@ -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<MediaPacket> 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++;
}
}
}

View File

@@ -91,9 +91,11 @@ public final class CodecCatalog {
List<AudioCodecChoice> out = new ArrayList<>(); List<AudioCodecChoice> out = new ArrayList<>();
out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0)); out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0));
// Selectable even without native .so — stub path uses AAC + OPUS/SPEEX backend label (validation). // 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.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)); out.add(scoredAudio(CastSettings.AudioCodec.PASSTHROUGH_DEBUG, true, settings));
sortAudioChoices(out); sortAudioChoices(out);
return out; return out;
@@ -255,6 +257,21 @@ public final class CodecCatalog {
return new AudioCodecChoice(codec, selectable, score); 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<VideoCodecChoice> choices) { private static void sortVideoChoices(List<VideoCodecChoice> choices) {
if (choices.size() <= 2) { if (choices.size() <= 2) {
return; return;

View File

@@ -51,6 +51,9 @@ import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; 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.control.PassThroughNetworkControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge; import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
@@ -138,6 +141,8 @@ public class ScreenCastService extends Service implements
private volatile boolean cameraMode; private volatile boolean cameraMode;
private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture; private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
private SessionStatsRecorder sessionStatsRecorder; private SessionStatsRecorder sessionStatsRecorder;
private AdaptiveCodecController adaptiveCodecController;
private int codecRenegotiationCount;
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
@Override @Override
@@ -279,10 +284,21 @@ public class ScreenCastService extends Service implements
AudioNegotiator.resolveForSender(settings, primary.target.audioCodecs); AudioNegotiator.resolveForSender(settings, primary.target.audioCodecs);
final CastSettings captureSettings = settings; final CastSettings captureSettings = settings;
updateStatus(getString(R.string.authenticating)); updateStatus(getString(R.string.authenticating));
networkFeedback = new NetworkFeedbackManager(session, true); networkFeedback = new NetworkFeedbackManager(session, true, new AdaptiveNetworkControlPlane());
PassThroughNetworkControlPlane plane = PassThroughNetworkControlPlane plane =
(PassThroughNetworkControlPlane) networkFeedback.getPlane(); (PassThroughNetworkControlPlane) networkFeedback.getPlane();
plane.setStatsEnricher(this::enrichSenderStats); 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)) { if (AppPreferences.isDevDiagPingPongEnabled(this)) {
networkFeedback.configureDiagPing(true, networkFeedback.configureDiagPing(true,
AppPreferences.getDevDiagPingResponseMode(this), AppPreferences.getDevDiagPingResponseMode(this),
@@ -304,6 +320,8 @@ public class ScreenCastService extends Service implements
PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)), PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)),
settings.getBitrateMode().name().toLowerCase()); settings.getBitrateMode().name().toLowerCase());
activeSettings = settings; activeSettings = settings;
adaptiveCodecController = new AdaptiveCodecController(session, true, settings,
(sel, local) -> mainHandler.post(() -> applySenderCodecRenegotiation(sel)));
if (AppPreferences.isGrabSessionStats(this)) { if (AppPreferences.isGrabSessionStats(this)) {
if (sessionStatsRecorder == null) { if (sessionStatsRecorder == null) {
sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport()); sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
@@ -341,6 +359,10 @@ public class ScreenCastService extends Service implements
networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice()); networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
} else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) { } else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) {
handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload)); 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"); throw new IOException("No negotiated video codec");
} }
videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec()); videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec(),
settings.isCodecHardeningStrict());
CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription( CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription(
settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec())); settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec()));
VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime); VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime);
@@ -717,6 +740,11 @@ public class ScreenCastService extends Service implements
s.droppedPFrames = sendPump.getDroppedPFrames(); s.droppedPFrames = sendPump.getDroppedPFrames();
} }
TransportLossStats snap = buildSenderLossSnapshot(); TransportLossStats snap = buildSenderLossSnapshot();
if (s.sendBitrateKbps > 0) {
synchronized (snap) {
snap.mediaBytesSent += (long) s.sendBitrateKbps * 125;
}
}
TransportLossStatsBridge.applyTo(s, snap); TransportLossStatsBridge.applyTo(s, snap);
if (sessionStatsRecorder != null) { if (sessionStatsRecorder != null) {
sessionStatsRecorder.sampleNetwork(s, getPeerStats()); sessionStatsRecorder.sampleNetwork(s, getPeerStats());
@@ -744,6 +772,12 @@ public class ScreenCastService extends Service implements
if (qDepth > 20) { if (qDepth > 20) {
s.congestionLevel = Math.max(s.congestionLevel, 2); 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() { private void scheduleTuningTick() {
@@ -767,9 +801,14 @@ public class ScreenCastService extends Service implements
runOnProjectionThread(() -> videoEncoder.requestKeyframe()); runOnProjectionThread(() -> videoEncoder.requestKeyframe());
} }
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) { 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; effectiveParams = next;
if (adaptiveCodecController != null && activeSettings != null) {
adaptiveCodecController.tick(peer, activeSettings.getNegotiatedVideoMime(),
AudioNegotiator.effective(activeSettings));
}
maybeAdaptCaptureSize(peer); maybeAdaptCaptureSize(peer);
String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null; String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
CastSendPump pumpForStats = sendPump; CastSendPump pumpForStats = sendPump;
@@ -791,6 +830,67 @@ public class ScreenCastService extends Service implements
scheduleCaptureSizeUpdate(size, "adaptive"); 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<String> 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) { private void runOnProjectionThread(Runnable action) {
Handler handler = projectionHandler != null ? projectionHandler : mainHandler; Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
if (Looper.myLooper() == handler.getLooper()) { if (Looper.myLooper() == handler.getLooper()) {
@@ -1172,7 +1272,8 @@ public class ScreenCastService extends Service implements
} }
CastSettings.VideoCodec vPref = activeSettings != null CastSettings.VideoCodec vPref = activeSettings != null
? activeSettings.getVideoCodec() : CastSettings.VideoCodec.AUTO; ? activeSettings.getVideoCodec() : CastSettings.VideoCodec.AUTO;
videoEncoder = VideoCodecFactory.createEncoder(vPref); videoEncoder = VideoCodecFactory.createEncoder(vPref,
activeSettings != null && activeSettings.isCodecHardeningStrict());
float density = getResources().getDisplayMetrics().density; float density = getResources().getDisplayMetrics().density;
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this); calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
if (tryGles) { if (tryGles) {

View File

@@ -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, public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException { Callback callback) throws IOException {
return prepareInternal(width, height, mime, params, callback, false); return prepareInternal(width, height, mime, params, callback, false);

View File

@@ -18,6 +18,7 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.StreamProtectionMode; import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.receiver.buffer.JitterBufferStats;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -40,6 +41,7 @@ public final class SessionStatsRecorder {
private String resolution = ""; private String resolution = "";
private int clientCount = 1; private int clientCount = 1;
private int disruptCount; private int disruptCount;
private int codecRenegotiationCount;
private int idleTimeouts; private int idleTimeouts;
private int networkSamples; 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) { public void setCaptureMode(String mode) {
captureMode = mode != null ? mode : ""; captureMode = mode != null ? mode : "";
} }
@@ -237,6 +253,12 @@ public final class SessionStatsRecorder {
if (s.congestionLevel > 0) { if (s.congestionLevel > 0) {
pt.put("congestion_level", s.congestionLevel); 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); samples.put(pt);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
@@ -296,6 +318,7 @@ public final class SessionStatsRecorder {
root.put("capture_mode", captureMode); root.put("capture_mode", captureMode);
root.put("resolution", resolution); root.put("resolution", resolution);
root.put("network_samples", networkSamples); root.put("network_samples", networkSamples);
root.put("codec_renegotiation_count", codecRenegotiationCount);
JSONArray journalEvents = journal.copyEvents(); JSONArray journalEvents = journal.copyEvents();
if (journalEvents.length() > 0) { if (journalEvents.length() > 0) {

View File

@@ -202,6 +202,17 @@
android:textSize="12sp" android:textSize="12sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_codec_hardening" />
<Spinner
android:id="@+id/spinner_sender_codec_hardening"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -282,6 +293,28 @@
android:id="@+id/spinner_receiver_play_audio" android:id="@+id/spinner_receiver_play_audio"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_jitter_buffer" />
<Spinner
android:id="@+id/spinner_receiver_jitter_buffer"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_codec_hardening" />
<Spinner
android:id="@+id/spinner_receiver_codec_hardening"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -91,6 +91,14 @@
<string name="manual_connect_missing">Enter host and port</string> <string name="manual_connect_missing">Enter host and port</string>
<string name="playing_resolution">Playing %1$dx%2$d</string> <string name="playing_resolution">Playing %1$dx%2$d</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="label_jitter_buffer">Jitter buffer</string>
<string name="label_codec_hardening">Codec hardening</string>
<string name="jitter_mode_simple">Simple (legacy)</string>
<string name="jitter_mode_fifo">FIFO</string>
<string name="jitter_mode_lifo">LIFO</string>
<string name="jitter_mode_smart">Smart (adaptive)</string>
<string name="codec_hardening_strict">Strict (native only)</string>
<string name="codec_hardening_allow_stub">Allow stub fallback</string>
<string name="label_receiver_audio">Play incoming cast audio on this device</string> <string name="label_receiver_audio">Play incoming cast audio on this device</string>
<string name="start_receiver">Start listening (keeps running in background)</string> <string name="start_receiver">Start listening (keeps running in background)</string>
<string name="playback_buffering">Connected — waiting for video…</string> <string name="playback_buffering">Connected — waiting for video…</string>

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -35,6 +35,7 @@
typedef struct { typedef struct {
unsigned long magic; unsigned long magic;
vpx_codec_ctx_t codec; vpx_codec_ctx_t codec;
vpx_codec_enc_cfg_t cfg;
vpx_image_t img; vpx_image_t img;
int width; int width;
int height; int height;
@@ -209,6 +210,7 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderCrea
(*env)->ReleaseStringUTFChars(env, mime, mime_utf); (*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L; return 0L;
} }
ctx->cfg = cfg;
/* VP8 and VP9 both use VP8E_SET_CPUUSED (see libvpx tests / vp9_cx_iface.c). */ /* VP8 and VP9 both use VP8E_SET_CPUUSED (see libvpx tests / vp9_cx_iface.c). */
vpx_codec_control(&ctx->codec, VP8E_SET_CPUUSED, 8); vpx_codec_control(&ctx->codec, VP8E_SET_CPUUSED, 8);
@@ -338,6 +340,28 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxRequestKeyf
#endif #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 JNIEXPORT jlong JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCreate( Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCreate(
JNIEnv *env, jclass clazz, jstring mime) { JNIEnv *env, jclass clazz, jstring mime) {