mirror of
git://f0xx.org/ac/ac-mobile-android
synced 2026-07-29 01:47: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:
@@ -18,6 +18,8 @@ import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferConfig;
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferMode;
|
||||
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||
@@ -34,6 +36,10 @@ public final class AppPreferences {
|
||||
private static final String KEY_THEME = "theme_mode";
|
||||
private static final String KEY_LOCALE = "locale_mode";
|
||||
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
|
||||
private static final String KEY_RECEIVER_JITTER_BUFFER_MODE = "receiver_jitter_buffer_mode";
|
||||
private static final String KEY_RECEIVER_JITTER_TARGET_DELAY_MS = "receiver_jitter_target_delay_ms";
|
||||
private static final String KEY_SENDER_CODEC_HARDENING = "sender_codec_hardening";
|
||||
private static final String KEY_RECEIVER_CODEC_HARDENING = "receiver_codec_hardening";
|
||||
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
|
||||
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
||||
private static final String KEY_DEV_STREAM_DUMP = "dev_stream_dump";
|
||||
@@ -155,6 +161,61 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_PLAY_INCOMING_AUDIO, play).apply();
|
||||
}
|
||||
|
||||
public static JitterBufferMode getReceiverJitterBufferMode(Context context) {
|
||||
String name = prefs(context).getString(KEY_RECEIVER_JITTER_BUFFER_MODE,
|
||||
JitterBufferMode.SMART.name());
|
||||
try {
|
||||
return JitterBufferMode.valueOf(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return JitterBufferMode.SMART;
|
||||
}
|
||||
}
|
||||
|
||||
public static void setReceiverJitterBufferMode(Context context, JitterBufferMode mode) {
|
||||
prefs(context).edit().putString(KEY_RECEIVER_JITTER_BUFFER_MODE,
|
||||
mode != null ? mode.name() : JitterBufferMode.SMART.name()).apply();
|
||||
}
|
||||
|
||||
public static int getReceiverJitterTargetDelayMs(Context context) {
|
||||
return prefs(context).getInt(KEY_RECEIVER_JITTER_TARGET_DELAY_MS,
|
||||
JitterBufferConfig.DEFAULT_TARGET_DELAY_MS);
|
||||
}
|
||||
|
||||
public static void setReceiverJitterTargetDelayMs(Context context, int delayMs) {
|
||||
prefs(context).edit().putInt(KEY_RECEIVER_JITTER_TARGET_DELAY_MS,
|
||||
JitterBufferConfig.clampDelay(delayMs)).apply();
|
||||
}
|
||||
|
||||
public static CastSettings.CodecHardening getSenderCodecHardening(Context context) {
|
||||
return readCodecHardening(context, KEY_SENDER_CODEC_HARDENING);
|
||||
}
|
||||
|
||||
public static void setSenderCodecHardening(Context context, CastSettings.CodecHardening mode) {
|
||||
storeCodecHardening(context, KEY_SENDER_CODEC_HARDENING, mode);
|
||||
}
|
||||
|
||||
public static CastSettings.CodecHardening getReceiverCodecHardening(Context context) {
|
||||
return readCodecHardening(context, KEY_RECEIVER_CODEC_HARDENING);
|
||||
}
|
||||
|
||||
public static void setReceiverCodecHardening(Context context, CastSettings.CodecHardening mode) {
|
||||
storeCodecHardening(context, KEY_RECEIVER_CODEC_HARDENING, mode);
|
||||
}
|
||||
|
||||
private static CastSettings.CodecHardening readCodecHardening(Context context, String key) {
|
||||
String name = prefs(context).getString(key, CastSettings.CodecHardening.STRICT.name());
|
||||
try {
|
||||
return CastSettings.CodecHardening.valueOf(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return CastSettings.CodecHardening.STRICT;
|
||||
}
|
||||
}
|
||||
|
||||
private static void storeCodecHardening(Context context, String key, CastSettings.CodecHardening mode) {
|
||||
prefs(context).edit().putString(key,
|
||||
mode != null ? mode.name() : CastSettings.CodecHardening.STRICT.name()).apply();
|
||||
}
|
||||
|
||||
/** Developer: metrics HTML overlay on receiver video (default on). */
|
||||
public static boolean isShowReceiverDebugOverlay(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, true);
|
||||
@@ -293,6 +354,7 @@ public final class AppPreferences {
|
||||
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
|
||||
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
|
||||
}
|
||||
s.setCodecHardening(getSenderCodecHardening(context));
|
||||
clampAlphaTransport(s);
|
||||
return s;
|
||||
}
|
||||
@@ -665,6 +727,7 @@ public final class AppPreferences {
|
||||
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
|
||||
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
|
||||
}
|
||||
s.setCodecHardening(getReceiverCodecHardening(context));
|
||||
clampAlphaTransport(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ public class CastSettings implements Serializable {
|
||||
|
||||
public enum VideoCodec {
|
||||
AUTO, H264, H265, PASSTHROUGH,
|
||||
/** Future software VP8 (UI disabled until integrated). */
|
||||
/** libvpx software VP8 (requires native build). */
|
||||
LIBVPX_VP8,
|
||||
/** Future software VP9 (UI disabled until integrated). */
|
||||
/** libvpx software VP9 (requires native build). */
|
||||
LIBVPX_VP9
|
||||
}
|
||||
|
||||
@@ -97,6 +97,12 @@ public class CastSettings implements Serializable {
|
||||
ADAPTIVE_EXPERIMENTAL
|
||||
}
|
||||
|
||||
/** When STRICT, explicit Opus/Speex/libvpx choices fail without native libs (no AAC/HW stub). */
|
||||
public enum CodecHardening {
|
||||
STRICT,
|
||||
ALLOW_STUB
|
||||
}
|
||||
|
||||
public enum Resolution {
|
||||
FULL(1.0f, 0, 0),
|
||||
THREE_QUARTERS(0.75f, 0, 0),
|
||||
@@ -152,6 +158,8 @@ public class CastSettings implements Serializable {
|
||||
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
|
||||
private StreamProtection streamProtection = StreamProtection.NONE;
|
||||
private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT;
|
||||
/** Local-only (AppPreferences); not on cast-settings wire. */
|
||||
private transient CodecHardening codecHardening = CodecHardening.STRICT;
|
||||
/** Filled after codec handshake (e.g. video/avc). */
|
||||
private transient String negotiatedVideoMime;
|
||||
/** Filled when AUTO (or fallback) resolves against peer audio caps. */
|
||||
@@ -303,6 +311,18 @@ public class CastSettings implements Serializable {
|
||||
? receiverDisplayResolution : ReceiverDisplayResolution.AUTO_FIT;
|
||||
}
|
||||
|
||||
public CodecHardening getCodecHardening() {
|
||||
return codecHardening != null ? codecHardening : CodecHardening.STRICT;
|
||||
}
|
||||
|
||||
public void setCodecHardening(CodecHardening codecHardening) {
|
||||
this.codecHardening = codecHardening != null ? codecHardening : CodecHardening.STRICT;
|
||||
}
|
||||
|
||||
public boolean isCodecHardeningStrict() {
|
||||
return getCodecHardening() == CodecHardening.STRICT;
|
||||
}
|
||||
|
||||
public boolean isCameraCapture() {
|
||||
return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT;
|
||||
}
|
||||
@@ -327,6 +347,7 @@ public class CastSettings implements Serializable {
|
||||
captureMode = other.captureMode;
|
||||
streamProtection = other.streamProtection;
|
||||
receiverDisplayResolution = other.receiverDisplayResolution;
|
||||
codecHardening = other.codecHardening;
|
||||
negotiatedVideoMime = other.negotiatedVideoMime;
|
||||
resolvedAudioCodec = other.resolvedAudioCodec;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferMode;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
import com.foxx.androidcast.ui.SettingsEnumSpinner;
|
||||
import com.foxx.androidcast.ui.SettingsStringArrayAdapter;
|
||||
@@ -111,6 +112,9 @@ public final class CastSettingsBinder {
|
||||
bindStreamProtection(activity, settings);
|
||||
bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
|
||||
bindAudioCodec(activity, settings);
|
||||
bindCodecHardening(activity, R.id.spinner_sender_codec_hardening,
|
||||
AppPreferences.getSenderCodecHardening(activity),
|
||||
AppPreferences::setSenderCodecHardening);
|
||||
}
|
||||
|
||||
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
|
||||
@@ -118,6 +122,9 @@ public final class CastSettingsBinder {
|
||||
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
||||
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||
bindJitterBuffer(activity);
|
||||
bindCodecHardening(activity, R.id.spinner_receiver_codec_hardening,
|
||||
AppPreferences.getReceiverCodecHardening(activity), AppPreferences::setReceiverCodecHardening);
|
||||
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
||||
if (audioPlay != null) {
|
||||
bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
|
||||
@@ -136,6 +143,8 @@ public final class CastSettingsBinder {
|
||||
readAudioSpinner(activity, settings, R.id.spinner_sender_audio);
|
||||
readAudioCodec(activity, settings);
|
||||
settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
|
||||
readCodecHardening(activity, R.id.spinner_sender_codec_hardening, AppPreferences::setSenderCodecHardening);
|
||||
settings.setCodecHardening(AppPreferences.getSenderCodecHardening(activity));
|
||||
AppPreferences.saveSenderDefaults(activity, settings);
|
||||
}
|
||||
|
||||
@@ -144,14 +153,81 @@ public final class CastSettingsBinder {
|
||||
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
||||
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||
readJitterBuffer(activity);
|
||||
readCodecHardening(activity, R.id.spinner_receiver_codec_hardening, AppPreferences::setReceiverCodecHardening);
|
||||
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
||||
if (audioPlay != null) {
|
||||
AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
|
||||
}
|
||||
settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
|
||||
settings.setCodecHardening(AppPreferences.getReceiverCodecHardening(activity));
|
||||
AppPreferences.saveReceiverDefaults(activity, settings);
|
||||
}
|
||||
|
||||
private static void bindJitterBuffer(AppCompatActivity activity) {
|
||||
Spinner spinner = activity.findViewById(R.id.spinner_receiver_jitter_buffer);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
JitterBufferMode[] modes = JitterBufferMode.values();
|
||||
String[] labels = new String[modes.length];
|
||||
for (int i = 0; i < modes.length; i++) {
|
||||
labels[i] = jitterModeLabel(activity, modes[i]);
|
||||
}
|
||||
bindEnumSpinner(spinner, modes, labels, AppPreferences.getReceiverJitterBufferMode(activity),
|
||||
mode -> AppPreferences.setReceiverJitterBufferMode(activity, mode));
|
||||
}
|
||||
|
||||
private static void readJitterBuffer(AppCompatActivity activity) {
|
||||
Spinner spinner = activity.findViewById(R.id.spinner_receiver_jitter_buffer);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
readEnumSpinner(spinner, (JitterBufferMode mode) -> AppPreferences.setReceiverJitterBufferMode(activity, mode));
|
||||
}
|
||||
|
||||
private interface CodecHardeningStore {
|
||||
void store(AppCompatActivity activity, CastSettings.CodecHardening mode);
|
||||
}
|
||||
|
||||
private static void bindCodecHardening(AppCompatActivity activity, int spinnerId,
|
||||
CastSettings.CodecHardening current, CodecHardeningStore store) {
|
||||
Spinner spinner = activity.findViewById(spinnerId);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
CastSettings.CodecHardening[] modes = CastSettings.CodecHardening.values();
|
||||
String[] labels = new String[] {
|
||||
activity.getString(R.string.codec_hardening_strict),
|
||||
activity.getString(R.string.codec_hardening_allow_stub)
|
||||
};
|
||||
bindEnumSpinner(spinner, modes, labels, current,
|
||||
(CastSettings.CodecHardening mode) -> store.store(activity, mode));
|
||||
}
|
||||
|
||||
private static void readCodecHardening(AppCompatActivity activity, int spinnerId,
|
||||
CodecHardeningStore store) {
|
||||
Spinner spinner = activity.findViewById(spinnerId);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
readEnumSpinner(spinner, (CastSettings.CodecHardening mode) -> store.store(activity, mode));
|
||||
}
|
||||
|
||||
private static String jitterModeLabel(AppCompatActivity activity, JitterBufferMode mode) {
|
||||
switch (mode) {
|
||||
case SIMPLE:
|
||||
return activity.getString(R.string.jitter_mode_simple);
|
||||
case FIFO:
|
||||
return activity.getString(R.string.jitter_mode_fifo);
|
||||
case LIFO:
|
||||
return activity.getString(R.string.jitter_mode_lifo);
|
||||
case SMART:
|
||||
default:
|
||||
return activity.getString(R.string.jitter_mode_smart);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId,
|
||||
boolean unused) {
|
||||
Spinner spinner = activity.findViewById(spinnerId);
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.receiver.StreamMetrics;
|
||||
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferStats;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -37,6 +39,15 @@ public final class CastDiagnosticsFormatter {
|
||||
CastSettings localSettings, CastSettings remoteSettings,
|
||||
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
|
||||
CastMtuSampler.Info mtuInfo) {
|
||||
return formatHtml(metrics, transport, videoCodec, streamW, streamH, renderW, renderH, idle,
|
||||
localSettings, remoteSettings, peer, lastLocal, viewZoomPercent, mtuInfo, null);
|
||||
}
|
||||
|
||||
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
|
||||
int streamW, int streamH, int renderW, int renderH, boolean idle,
|
||||
CastSettings localSettings, CastSettings remoteSettings,
|
||||
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
|
||||
CastMtuSampler.Info mtuInfo, ReceiverDiagnosticsExtras receiverExtras) {
|
||||
float inFps = metrics.getInFps();
|
||||
float renderFps = metrics.getRenderFps();
|
||||
int bwInKbps = metrics.getBwInKbps();
|
||||
@@ -100,6 +111,29 @@ public final class CastDiagnosticsFormatter {
|
||||
congestionLevel = Math.max(congestionLevel, lastLocal.congestionLevel);
|
||||
}
|
||||
|
||||
int jitterMs = 0;
|
||||
int protectionOverheadPct = 0;
|
||||
long nackRetransmits = 0;
|
||||
long jitterBufferDrops = 0;
|
||||
if (peer != null) {
|
||||
if (peer.jitterMs > 0) {
|
||||
jitterMs = peer.jitterMs;
|
||||
}
|
||||
protectionOverheadPct = peer.protectionOverheadPercent;
|
||||
nackRetransmits = peer.nackRetransmits;
|
||||
jitterBufferDrops = peer.jitterBufferDrops;
|
||||
}
|
||||
if (lastLocal != null) {
|
||||
jitterMs = Math.max(jitterMs, lastLocal.jitterMs);
|
||||
protectionOverheadPct = Math.max(protectionOverheadPct, lastLocal.protectionOverheadPercent);
|
||||
nackRetransmits = Math.max(nackRetransmits, lastLocal.nackRetransmits);
|
||||
jitterBufferDrops = Math.max(jitterBufferDrops, lastLocal.jitterBufferDrops);
|
||||
}
|
||||
int codecRenegCount = receiverExtras != null ? receiverExtras.codecRenegotiationCount : 0;
|
||||
if (lastLocal != null && lastLocal.codecRenegotiationCount > codecRenegCount) {
|
||||
codecRenegCount = lastLocal.codecRenegotiationCount;
|
||||
}
|
||||
|
||||
boolean audioRequested = remoteSettings != null && remoteSettings.isAudioRequested();
|
||||
List<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("'>")
|
||||
.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);
|
||||
if (viewZoomPercent != 0) {
|
||||
@@ -142,6 +176,9 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
|
||||
suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
|
||||
html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350));
|
||||
if (jitterMs > 0) {
|
||||
html.append(" Jitter: ").append(colored(jitterMs + " ms", jitterMs > 120));
|
||||
}
|
||||
DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing();
|
||||
if (diagPing.enabled && diagPing.pongsRx > 0) {
|
||||
int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs;
|
||||
@@ -158,10 +195,15 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("<br/>");
|
||||
|
||||
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
|
||||
if (protectionOverheadPct > 0) {
|
||||
html.append(" OH: ").append(colored(protectionOverheadPct + "%", protectionOverheadPct > 25));
|
||||
}
|
||||
html.append("<br/>");
|
||||
|
||||
appendJitterBufferSection(html, receiverExtras, jitterBufferDrops);
|
||||
|
||||
appendUdpLossSection(html, peer, lastLocal);
|
||||
appendProtectionSection(html, peer, lastLocal);
|
||||
appendProtectionSection(html, peer, lastLocal, nackRetransmits);
|
||||
|
||||
html.append("Ctrl: ").append(escape(ctrl));
|
||||
html.append("<br/>");
|
||||
@@ -374,7 +416,7 @@ public final class CastDiagnosticsFormatter {
|
||||
}
|
||||
|
||||
private static void appendCodecStackSection(StringBuilder html, CastSettings localSettings,
|
||||
CastSettings remoteSettings) {
|
||||
CastSettings remoteSettings, int codecRenegCount) {
|
||||
CastSettings prefs = remoteSettings != null ? remoteSettings : localSettings;
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>Video stack:</font> ");
|
||||
html.append(escape(CodecSessionRegistry.getVideoBackend().label));
|
||||
@@ -391,6 +433,11 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append(" · pref ");
|
||||
html.append(escape(PassthroughCodecPolicy.displayAudioPreference(
|
||||
prefs != null ? prefs.getAudioCodec() : CodecSessionRegistry.getAudioPreference())));
|
||||
html.append(" · live ").append(escape(CastDiagnosticsLabels.resolvedAudioCodec(prefs)));
|
||||
if (codecRenegCount > 0) {
|
||||
html.append(" · <font color='").append(COLOR_OK).append("'>reneg×")
|
||||
.append(codecRenegCount).append("</font>");
|
||||
}
|
||||
if (prefs != null && prefs.isAudioRequested()) {
|
||||
CastSettings.AudioCodec ac = prefs.getAudioCodec();
|
||||
if (ac == CastSettings.AudioCodec.OPUS) {
|
||||
@@ -412,8 +459,37 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("<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,
|
||||
NetworkStatsSnapshot local) {
|
||||
NetworkStatsSnapshot local, long nackRetransmits) {
|
||||
String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE");
|
||||
String summary = CodecSessionRegistry.getProtectionSummary();
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> ");
|
||||
@@ -432,8 +508,11 @@ public final class CastDiagnosticsFormatter {
|
||||
.append("</font>");
|
||||
}
|
||||
}
|
||||
if (nackReq > 0) {
|
||||
if (nackReq > 0 || nackRetransmits > 0) {
|
||||
html.append("<br/>NACK events=").append(nackReq);
|
||||
if (nackRetransmits > 0) {
|
||||
html.append(" · retx=").append(nackRetransmits);
|
||||
}
|
||||
}
|
||||
html.append("<br/>");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,35 @@ public final class CastDiagnosticsLabels {
|
||||
private CastDiagnosticsLabels() {}
|
||||
|
||||
public static String congestionController() {
|
||||
return "PassThroughNetworkControlPlane (heartbeat stats)";
|
||||
return "AdaptiveNetworkControlPlane (BWE + jitter probe)";
|
||||
}
|
||||
|
||||
public static String jitterBufferMode(com.foxx.androidcast.receiver.buffer.JitterBufferMode mode) {
|
||||
if (mode == null) {
|
||||
return "SMART (default)";
|
||||
}
|
||||
switch (mode) {
|
||||
case SIMPLE:
|
||||
return "Simple (legacy burst-drop)";
|
||||
case FIFO:
|
||||
return "FIFO (delay queue)";
|
||||
case LIFO:
|
||||
return "LIFO (newest first)";
|
||||
case SMART:
|
||||
default:
|
||||
return "Smart (adaptive, payload-aware)";
|
||||
}
|
||||
}
|
||||
|
||||
public static String resolvedAudioCodec(CastSettings settings) {
|
||||
if (settings == null) {
|
||||
return "AAC (default)";
|
||||
}
|
||||
CastSettings.AudioCodec resolved = settings.getResolvedAudioCodec();
|
||||
if (resolved != null) {
|
||||
return audioCodec(resolved) + " (session)";
|
||||
}
|
||||
return audioCodec(settings.getAudioCodec());
|
||||
}
|
||||
|
||||
public static String bitrateMode(CastSettings.BitrateMode mode) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public final class AudioNegotiator {
|
||||
return CastSettings.AudioCodec.AAC;
|
||||
}
|
||||
CastSettings.AudioCodec resolved = negotiate(
|
||||
CastCodecFlags.localAudioMask(),
|
||||
CastCodecFlags.localAudioMask(settings.isCodecHardeningStrict()),
|
||||
peerAudioMask,
|
||||
settings.getAudioCodec(),
|
||||
settings,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,27 @@ import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.media.AudioNegotiator;
|
||||
import com.foxx.androidcast.sender.AudioEncoder;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Creates audio encoder sinks. */
|
||||
public final class AudioCodecFactory {
|
||||
private AudioCodecFactory() {}
|
||||
|
||||
public static AudioEncoderSink createEncoder(CastSettings settings) {
|
||||
public static AudioEncoderSink createEncoder(CastSettings settings) throws IOException {
|
||||
CastSettings.AudioCodec preference = settings != null
|
||||
? AudioNegotiator.effective(settings) : CastSettings.AudioCodec.AAC;
|
||||
return createEncoder(preference);
|
||||
boolean strict = settings != null && settings.isCodecHardeningStrict();
|
||||
return createEncoder(preference, strict);
|
||||
}
|
||||
|
||||
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) {
|
||||
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) throws IOException {
|
||||
return createEncoder(preference, true);
|
||||
}
|
||||
|
||||
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference, boolean strict)
|
||||
throws IOException {
|
||||
CodecSessionRegistry.setAudioPreference(preference);
|
||||
CodecAvailabilityGuard.requireAudio(preference, strict);
|
||||
AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true);
|
||||
switch (backend) {
|
||||
case OPUS_NATIVE:
|
||||
@@ -36,6 +45,9 @@ public final class AudioCodecFactory {
|
||||
case PASSTHROUGH_DEBUG:
|
||||
case OPUS_STUB:
|
||||
case SPEEX_STUB:
|
||||
if (strict) {
|
||||
throw new IOException("Native audio codec required for " + preference.name());
|
||||
}
|
||||
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference);
|
||||
default:
|
||||
return new AudioEncoder();
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,4 +245,15 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
}
|
||||
return MediaFormat.MIMETYPE_VIDEO_VP8;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateBitrate(int bitrateBps) {
|
||||
if (bitrateBps <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (nativeHandle.get() != 0L) {
|
||||
return NativeCodecBridge.vpxSetBitrate(nativeHandle.get(), bitrateBps / 1000);
|
||||
}
|
||||
return hwFallback.updateBitrate(bitrateBps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ final class PassthroughVideoEncoderSink implements VideoEncoderSink {
|
||||
delegate.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateBitrate(int bitrateBps) {
|
||||
return delegate.updateBitrate(bitrateBps);
|
||||
}
|
||||
|
||||
CastSettings.VideoCodec getPreference() {
|
||||
return preference;
|
||||
}
|
||||
|
||||
@@ -15,17 +15,28 @@ import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
import com.foxx.androidcast.sender.VideoEncoder;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Creates video encoder sinks; libvpx used when native library is linked. */
|
||||
public final class VideoCodecFactory {
|
||||
private VideoCodecFactory() {}
|
||||
|
||||
public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) {
|
||||
public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) throws IOException {
|
||||
return createEncoder(preference, true);
|
||||
}
|
||||
|
||||
public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference, boolean strict)
|
||||
throws IOException {
|
||||
CodecSessionRegistry.setVideoPreference(preference);
|
||||
CodecAvailabilityGuard.requireVideo(preference, strict);
|
||||
if (preference == CastSettings.VideoCodec.LIBVPX_VP8
|
||||
|| preference == CastSettings.VideoCodec.LIBVPX_VP9) {
|
||||
if (NativeCodecBridge.isLibvpxAvailable()) {
|
||||
return new LibvpxVideoEncoderSink(preference);
|
||||
}
|
||||
if (strict) {
|
||||
throw new IOException("libvpx native library required for " + preference.name());
|
||||
}
|
||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null);
|
||||
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
|
||||
}
|
||||
|
||||
@@ -42,4 +42,9 @@ public interface VideoEncoderSink {
|
||||
void queueYuvFrame(byte[] yuv, long ptsUs);
|
||||
|
||||
void stop();
|
||||
|
||||
/** Live bitrate adjustment during an active session (bps). Returns true if applied. */
|
||||
default boolean updateBitrate(int bitrateBps) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,13 @@ public final class NativeCodecBridge {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean vpxSetBitrate(long handle, int bitrateKbps) {
|
||||
if (handle == 0L || bitrateKbps <= 0) {
|
||||
return false;
|
||||
}
|
||||
return nativeVpxSetBitrate(handle, bitrateKbps);
|
||||
}
|
||||
|
||||
/* ── Opus encoder ─────────────────────────────────────────────────── */
|
||||
|
||||
public static long opusEncoderCreate(int sampleRate, int channels, int bitrateKbps) {
|
||||
@@ -199,6 +206,8 @@ public final class NativeCodecBridge {
|
||||
|
||||
private static native void nativeVpxRequestKeyframe(long handle);
|
||||
|
||||
private static native boolean nativeVpxSetBitrate(long handle, int bitrateKbps);
|
||||
|
||||
private static native long nativeVpxDecoderCreate(String mime);
|
||||
|
||||
private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface);
|
||||
|
||||
@@ -16,6 +16,8 @@ import android.media.MediaFormat;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Video/audio codec bitmasks in {@link CastProtoHeader}. */
|
||||
@@ -62,8 +64,22 @@ public final class CastCodecFlags {
|
||||
}
|
||||
|
||||
public static int localAudioMask() {
|
||||
// Advertise Opus/Speex for stub negotiation even when native probes are false.
|
||||
return AUDIO_AAC | AUDIO_PCM | AUDIO_OPUS | AUDIO_SPEEX;
|
||||
return localAudioMask(false);
|
||||
}
|
||||
|
||||
public static int localAudioMask(boolean strictNative) {
|
||||
int mask = AUDIO_AAC | AUDIO_PCM;
|
||||
if (!strictNative || NativeCodecBridge.isOpusAvailable()) {
|
||||
mask |= AUDIO_OPUS;
|
||||
}
|
||||
if (!strictNative || NativeCodecBridge.isSpeexAvailable()) {
|
||||
mask |= AUDIO_SPEEX;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
public static int maskForAudio(CastSettings.AudioCodec codec) {
|
||||
return flagForAudioPreference(codec);
|
||||
}
|
||||
|
||||
public static int localDecoderAudioMask() {
|
||||
@@ -101,8 +117,9 @@ public final class CastCodecFlags {
|
||||
}
|
||||
switch (codec) {
|
||||
case OPUS:
|
||||
return NativeCodecBridge.isOpusAvailable();
|
||||
case SPEEX:
|
||||
return true;
|
||||
return NativeCodecBridge.isSpeexAvailable();
|
||||
case AAC:
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return true;
|
||||
|
||||
@@ -194,16 +194,43 @@ public final class CastProtocol {
|
||||
}
|
||||
|
||||
public static byte[] codecSelectedPayload(String mime) throws IOException {
|
||||
return codecSelectedPayload(mime, null);
|
||||
}
|
||||
|
||||
/** v2: optional audio codec ordinal after video MIME (backward compatible). */
|
||||
public static byte[] codecSelectedPayload(String mime, CastSettings.AudioCodec audio) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||
if (audio != null) {
|
||||
dos.writeInt(audio.ordinal());
|
||||
}
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static String parseCodecSelected(byte[] payload) throws IOException {
|
||||
return parseCodecSelection(payload).videoMime;
|
||||
}
|
||||
|
||||
public static CodecSelection parseCodecSelection(byte[] payload) throws IOException {
|
||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||
return dis.readUTF();
|
||||
String videoMime = dis.readUTF();
|
||||
CastSettings.AudioCodec audio = CastSettings.AudioCodec.AAC;
|
||||
if (dis.available() >= 4) {
|
||||
audio = readEnum(CastSettings.AudioCodec.values(), dis.readInt(), CastSettings.AudioCodec.AAC);
|
||||
}
|
||||
return new CodecSelection(videoMime, audio);
|
||||
}
|
||||
|
||||
public static final class CodecSelection {
|
||||
public final String videoMime;
|
||||
public final CastSettings.AudioCodec audioCodec;
|
||||
|
||||
public CodecSelection(String videoMime, CastSettings.AudioCodec audioCodec) {
|
||||
this.videoMime = videoMime;
|
||||
this.audioCodec = audioCodec != null ? audioCodec : CastSettings.AudioCodec.AAC;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
|
||||
@@ -292,7 +319,7 @@ public final class CastProtocol {
|
||||
return local.getTransport().equals(remote.getTransport());
|
||||
}
|
||||
|
||||
private static final byte NETWORK_STATS_VERSION = 5;
|
||||
private static final byte NETWORK_STATS_VERSION = 6;
|
||||
|
||||
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
@@ -346,6 +373,12 @@ public final class CastProtocol {
|
||||
dos.writeLong(s.fecPacketsDecoded);
|
||||
dos.writeLong(s.fecDecodeFailures);
|
||||
}
|
||||
if (NETWORK_STATS_VERSION >= 6) {
|
||||
dos.writeLong(s.nackRetransmits);
|
||||
dos.writeInt(s.protectionOverheadPercent);
|
||||
dos.writeLong(s.jitterBufferDrops);
|
||||
dos.writeInt(s.codecRenegotiationCount);
|
||||
}
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
@@ -404,6 +437,12 @@ public final class CastProtocol {
|
||||
s.fecPacketsDecoded = dis.readLong();
|
||||
s.fecDecodeFailures = dis.readLong();
|
||||
}
|
||||
if (ver >= 6 && dis.available() > 0) {
|
||||
s.nackRetransmits = dis.readLong();
|
||||
s.protectionOverheadPercent = dis.readInt();
|
||||
s.jitterBufferDrops = dis.readLong();
|
||||
s.codecRenegotiationCount = dis.readInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
|
||||
@@ -12,8 +12,10 @@ package com.foxx.androidcast.network;
|
||||
* Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.media.AudioNegotiator;
|
||||
import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.network.CastCodecFlags;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
@@ -95,7 +97,9 @@ public class CastSession {
|
||||
if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) {
|
||||
receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload);
|
||||
} else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) {
|
||||
negotiated = CastProtocol.parseCodecSelected(msg.payload);
|
||||
CastProtocol.CodecSelection sel = CastProtocol.parseCodecSelection(msg.payload);
|
||||
negotiated = sel.videoMime;
|
||||
settings.setResolvedAudioCodec(sel.audioCodec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +165,12 @@ public class CastSession {
|
||||
recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps);
|
||||
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
||||
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
||||
byte[] selected = CastProtocol.codecSelectedPayload(mime);
|
||||
CastSettings.AudioCodec audio = AudioNegotiator.negotiate(
|
||||
CastCodecFlags.localAudioMask(remote.isCodecHardeningStrict()),
|
||||
CastCodecFlags.localAudioMask(localSettings.isCodecHardeningStrict()),
|
||||
remote.getAudioCodec(), remote, "receiver");
|
||||
remote.setResolvedAudioCodec(audio);
|
||||
byte[] selected = CastProtocol.codecSelectedPayload(mime, audio);
|
||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||
recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||
remote.setNegotiatedVideoMime(mime);
|
||||
|
||||
@@ -183,6 +183,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
}
|
||||
synchronized (lossStats) {
|
||||
lossStats.nackRetransmits++;
|
||||
if (entry.body != null) {
|
||||
lossStats.protectionBytesSent += entry.body.length;
|
||||
}
|
||||
}
|
||||
retransmitCached(entry.wireType, entry.body, messageId);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,17 @@ import java.io.IOException;
|
||||
public class NetworkFeedbackManager {
|
||||
private static final String TAG = "NetworkFeedback";
|
||||
|
||||
public interface AdviceListener {
|
||||
void onBitrateHint(int suggestedBitrateKbps);
|
||||
|
||||
void onTransportSwitchHint(String transport, boolean switchTransport);
|
||||
}
|
||||
|
||||
private final NetworkControlPlane plane;
|
||||
private final CastSession session;
|
||||
private final boolean isSender;
|
||||
private DiagPingSession diagPing;
|
||||
private AdviceListener adviceListener;
|
||||
|
||||
public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) {
|
||||
this.session = session;
|
||||
@@ -57,6 +64,10 @@ public class NetworkFeedbackManager {
|
||||
return diagPing;
|
||||
}
|
||||
|
||||
public void setAdviceListener(AdviceListener listener) {
|
||||
this.adviceListener = listener;
|
||||
}
|
||||
|
||||
/** Call on each stream loop iteration (~500ms). */
|
||||
public void tick() {
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -123,13 +134,20 @@ public class NetworkFeedbackManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook for sender to apply bitrate/transport hints (stub-friendly). */
|
||||
/** Apply bitrate/transport hints to encoders or session policy. */
|
||||
public void applyAdvice(NetworkControlAdvice advice) {
|
||||
if (advice.suggestedBitrateKbps > 0 && isSender) {
|
||||
Log.d(TAG, "Bitrate hint (not applied yet): " + advice.suggestedBitrateKbps);
|
||||
if (advice == null) {
|
||||
return;
|
||||
}
|
||||
if (advice.switchTransport) {
|
||||
Log.d(TAG, "Transport switch hint (not applied yet): " + advice.suggestedTransport);
|
||||
if (advice.suggestedBitrateKbps > 0 && isSender) {
|
||||
if (adviceListener != null) {
|
||||
adviceListener.onBitrateHint(advice.suggestedBitrateKbps);
|
||||
} else {
|
||||
Log.d(TAG, "Bitrate hint: " + advice.suggestedBitrateKbps + " kbps (no listener)");
|
||||
}
|
||||
}
|
||||
if (advice.switchTransport && adviceListener != null) {
|
||||
adviceListener.onTransportSwitchHint(advice.suggestedTransport, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,11 @@ public class NetworkStatsSnapshot {
|
||||
public long fecPacketsDecoded;
|
||||
public long fecDecodeFailures;
|
||||
public long nackStubRequests;
|
||||
/** v6: protection overhead and jitter buffer metrics. */
|
||||
public long nackRetransmits;
|
||||
public int protectionOverheadPercent;
|
||||
public long jitterBufferDrops;
|
||||
public int codecRenegotiationCount;
|
||||
|
||||
public NetworkStatsSnapshot copy() {
|
||||
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
||||
@@ -106,6 +111,10 @@ public class NetworkStatsSnapshot {
|
||||
c.fecPacketsDecoded = fecPacketsDecoded;
|
||||
c.fecDecodeFailures = fecDecodeFailures;
|
||||
c.nackStubRequests = nackStubRequests;
|
||||
c.nackRetransmits = nackRetransmits;
|
||||
c.protectionOverheadPercent = protectionOverheadPercent;
|
||||
c.jitterBufferDrops = jitterBufferDrops;
|
||||
c.codecRenegotiationCount = codecRenegotiationCount;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,11 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsEncoded += shards.length;
|
||||
for (byte[] shard : shards) {
|
||||
if (shard != null) {
|
||||
stats.protectionBytesSent += shard.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards);
|
||||
|
||||
@@ -53,6 +53,10 @@ public final class TransportLossStats {
|
||||
public long fecPacketsStub;
|
||||
public long nackRequestsStub;
|
||||
public long nackRetransmitsStub;
|
||||
public long protectionBytesSent;
|
||||
public long protectionBytesReceived;
|
||||
public long mediaBytesSent;
|
||||
public long mediaBytesReceived;
|
||||
|
||||
public synchronized void addFrom(TransportLossStats other) {
|
||||
if (other == null) {
|
||||
@@ -88,6 +92,10 @@ public final class TransportLossStats {
|
||||
fecPacketsStub += other.fecPacketsStub;
|
||||
nackRequestsStub += other.nackRequestsStub;
|
||||
nackRetransmitsStub += other.nackRetransmitsStub;
|
||||
protectionBytesSent += other.protectionBytesSent;
|
||||
protectionBytesReceived += other.protectionBytesReceived;
|
||||
mediaBytesSent += other.mediaBytesSent;
|
||||
mediaBytesReceived += other.mediaBytesReceived;
|
||||
}
|
||||
|
||||
public synchronized TransportLossStats copy() {
|
||||
@@ -124,6 +132,10 @@ public final class TransportLossStats {
|
||||
c.fecPacketsStub = fecPacketsStub;
|
||||
c.nackRequestsStub = nackRequestsStub;
|
||||
c.nackRetransmitsStub = nackRetransmitsStub;
|
||||
c.protectionBytesSent = protectionBytesSent;
|
||||
c.protectionBytesReceived = protectionBytesReceived;
|
||||
c.mediaBytesSent = mediaBytesSent;
|
||||
c.mediaBytesReceived = mediaBytesReceived;
|
||||
return c;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ public final class TransportLossStatsBridge {
|
||||
s.fecPacketsDecoded = loss.fecPacketsDecoded;
|
||||
s.fecDecodeFailures = loss.fecDecodeFailures;
|
||||
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived;
|
||||
s.nackRetransmits = loss.nackRetransmits;
|
||||
long media = loss.mediaBytesSent + loss.mediaBytesReceived;
|
||||
long prot = loss.protectionBytesSent + loss.protectionBytesReceived;
|
||||
if (media > 0) {
|
||||
s.protectionOverheadPercent = (int) Math.min(100, (prot * 100L) / media);
|
||||
}
|
||||
long lossEvents = loss.estimatedUdpLossEvents();
|
||||
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
||||
s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
||||
import com.foxx.androidcast.diagnostics.CastMtuSampler;
|
||||
import com.foxx.androidcast.diagnostics.ReceiverDiagnosticsExtras;
|
||||
import com.foxx.androidcast.diagnostics.CpuLoadSampler;
|
||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
@@ -47,7 +48,13 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.live.LiveCastControlPlane;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
import com.foxx.androidcast.media.AdaptiveCodecController;
|
||||
import com.foxx.androidcast.media.CodecRenegotiationManager;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferConfig;
|
||||
import com.foxx.androidcast.receiver.buffer.ReceiveJitterPipeline;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
@@ -55,6 +62,7 @@ import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
|
||||
import com.foxx.androidcast.util.CastBackgroundExecutor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
@@ -125,6 +133,9 @@ public class ReceiverCastService extends Service {
|
||||
private long lastStreamConfigMs;
|
||||
private final TransportLossStats transportLoss = new TransportLossStats();
|
||||
private SessionStatsRecorder sessionStatsRecorder;
|
||||
private ReceiveJitterPipeline jitterPipeline;
|
||||
private AdaptiveCodecController adaptiveCodecController;
|
||||
private int codecRenegotiationCount;
|
||||
|
||||
private final Runnable idleWatchdog = new Runnable() {
|
||||
@Override
|
||||
@@ -262,6 +273,29 @@ public class ReceiverCastService extends Service {
|
||||
synchronized (audioDecoderLock) {
|
||||
audioDecoder = buildDefaultAudioDecoder();
|
||||
}
|
||||
JitterBufferConfig jbConfig = new JitterBufferConfig(
|
||||
AppPreferences.getReceiverJitterBufferMode(this),
|
||||
JitterBufferConfig.DEFAULT_MAX_PACKETS,
|
||||
AppPreferences.getReceiverJitterTargetDelayMs(this));
|
||||
jitterPipeline = new ReceiveJitterPipeline(jbConfig);
|
||||
jitterPipeline.setSinks(
|
||||
(ptsUs, keyFrame, data) -> {
|
||||
if (videoDecodeHandler != null) {
|
||||
videoDecodeHandler.post(() -> {
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
(ptsUs, data) -> {
|
||||
synchronized (audioDecoderLock) {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.queueFrame(ptsUs, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
jitterPipeline.start();
|
||||
|
||||
session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
|
||||
@Override
|
||||
@@ -327,7 +361,9 @@ public class ReceiverCastService extends Service {
|
||||
});
|
||||
if (videoDecodeHandler != null) {
|
||||
videoDecodeHandler.post(() -> {
|
||||
if (videoDecoder != null) {
|
||||
if (jitterPipeline != null) {
|
||||
jitterPipeline.enqueueVideo(ptsUs, keyFrame, frameData);
|
||||
} else if (videoDecoder != null) {
|
||||
videoDecoder.queueFrame(ptsUs, keyFrame, frameData);
|
||||
}
|
||||
});
|
||||
@@ -375,9 +411,13 @@ public class ReceiverCastService extends Service {
|
||||
accept = Boolean.TRUE.equals(audioAccepted);
|
||||
}
|
||||
if (accept) {
|
||||
synchronized (audioDecoderLock) {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.queueFrame(ptsUs, data);
|
||||
if (jitterPipeline != null) {
|
||||
jitterPipeline.enqueueAudio(ptsUs, data);
|
||||
} else {
|
||||
synchronized (audioDecoderLock) {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.queueFrame(ptsUs, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,6 +452,16 @@ public class ReceiverCastService extends Service {
|
||||
public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) {
|
||||
mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCodecCaps(java.util.List<String> peerCaps) {
|
||||
mainHandler.post(() -> handlePeerCodecCaps(peerCaps));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
|
||||
mainHandler.post(() -> applyCodecRenegotiation(selection));
|
||||
}
|
||||
}, this::enrichReceiverStats);
|
||||
session.start();
|
||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||
@@ -434,7 +484,10 @@ public class ReceiverCastService extends Service {
|
||||
negotiatedVideoMime = videoMime;
|
||||
/* Replace audio decoder if the negotiated codec is Opus or Speex. */
|
||||
if (remoteSettings != null) {
|
||||
CastSettings.AudioCodec negotiatedAudio = remoteSettings.getAudioCodec();
|
||||
CastSettings.AudioCodec negotiatedAudio = remoteSettings.getResolvedAudioCodec();
|
||||
if (negotiatedAudio == null) {
|
||||
negotiatedAudio = remoteSettings.getAudioCodec();
|
||||
}
|
||||
synchronized (audioDecoderLock) {
|
||||
if (negotiatedAudio == CastSettings.AudioCodec.OPUS
|
||||
&& com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) {
|
||||
@@ -459,6 +512,11 @@ public class ReceiverCastService extends Service {
|
||||
LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
|
||||
settings.getBitrateMode().name().toLowerCase());
|
||||
remoteCastSettings = remoteSettings;
|
||||
if (session != null && session.getActiveSession() != null) {
|
||||
adaptiveCodecController = new AdaptiveCodecController(
|
||||
session.getActiveSession(), false, settings,
|
||||
(sel, local) -> applyCodecRenegFromManager(sel));
|
||||
}
|
||||
streamIdle = false;
|
||||
streamMetrics.reset();
|
||||
lastVideoFrameMs = 0;
|
||||
@@ -722,6 +780,11 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void stopSessionOnly() {
|
||||
if (jitterPipeline != null) {
|
||||
jitterPipeline.stop();
|
||||
jitterPipeline = null;
|
||||
}
|
||||
adaptiveCodecController = null;
|
||||
if (discovery != null) {
|
||||
discovery.stop();
|
||||
discovery = null;
|
||||
@@ -849,9 +912,87 @@ public class ReceiverCastService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
private void applyCodecRenegFromManager(CodecRenegotiationManager.Selection sel) {
|
||||
if (sel == null) {
|
||||
return;
|
||||
}
|
||||
applyCodecRenegotiation(new CastProtocol.CodecSelection(sel.videoMime, sel.audioCodec));
|
||||
}
|
||||
|
||||
private void handlePeerCodecCaps(java.util.List<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) {
|
||||
s.encodeFps = streamMetrics.getDecodeFps();
|
||||
TransportLossStats snap = buildReceiverLossSnapshot();
|
||||
if (s.recvBitrateKbps > 0) {
|
||||
synchronized (snap) {
|
||||
snap.mediaBytesReceived += (long) s.recvBitrateKbps * 125;
|
||||
}
|
||||
}
|
||||
TransportLossStatsBridge.applyTo(s, snap);
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
|
||||
@@ -870,11 +1011,33 @@ public class ReceiverCastService extends Service {
|
||||
if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
|
||||
s.congestionLevel = Math.max(s.congestionLevel, 2);
|
||||
}
|
||||
if (jitterPipeline != null) {
|
||||
s.jitterBufferDrops = jitterPipeline.combinedStats().drops;
|
||||
s.jitterMs = Math.max(s.jitterMs, jitterPipeline.combinedStats().measuredJitterMs);
|
||||
}
|
||||
s.codecRenegotiationCount = codecRenegotiationCount;
|
||||
if (session != null && session.getActiveSession() != null && adaptiveCodecController != null) {
|
||||
adaptiveCodecController.tick(peer, negotiatedVideoMime,
|
||||
remoteCastSettings != null ? remoteCastSettings.getResolvedAudioCodec()
|
||||
: CastSettings.AudioCodec.AAC);
|
||||
}
|
||||
lastLocalStats = s.copy();
|
||||
}
|
||||
|
||||
private String buildDiagnosticsText() {
|
||||
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
|
||||
ReceiverDiagnosticsExtras extras = null;
|
||||
if (jitterPipeline != null) {
|
||||
extras = new ReceiverDiagnosticsExtras(
|
||||
AppPreferences.getReceiverJitterBufferMode(this),
|
||||
jitterPipeline.combinedStats(),
|
||||
codecRenegotiationCount);
|
||||
} else if (codecRenegotiationCount > 0) {
|
||||
extras = new ReceiverDiagnosticsExtras(
|
||||
AppPreferences.getReceiverJitterBufferMode(this),
|
||||
null,
|
||||
codecRenegotiationCount);
|
||||
}
|
||||
return CastDiagnosticsFormatter.formatHtml(
|
||||
streamMetrics,
|
||||
settings.getTransport(),
|
||||
@@ -889,7 +1052,8 @@ public class ReceiverCastService extends Service {
|
||||
peer,
|
||||
lastLocalStats,
|
||||
PlaybackViewState.getZoomPercent(),
|
||||
CastMtuSampler.sample(this));
|
||||
CastMtuSampler.sample(this),
|
||||
extras);
|
||||
}
|
||||
|
||||
private TransportLossStats buildReceiverLossSnapshot() {
|
||||
|
||||
@@ -31,12 +31,14 @@ import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
|
||||
import com.foxx.androidcast.network.UdpCastTransport;
|
||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
@@ -77,6 +79,14 @@ public class ReceiverSession {
|
||||
/** Diagnostic ping-pong sample (developer setting). */
|
||||
default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) {
|
||||
}
|
||||
|
||||
/** Mid-session codec renegotiation (MSG_CODEC_SELECTED). */
|
||||
default void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
|
||||
}
|
||||
|
||||
/** Peer announced codec capabilities mid-session. */
|
||||
default void onCodecCaps(java.util.List<String> peerCaps) {
|
||||
}
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
@@ -166,7 +176,7 @@ public class ReceiverSession {
|
||||
0,
|
||||
DeviceInfo.getPlaceholderId(appContext),
|
||||
CastCodecFlags.localDecoderVideoMask(),
|
||||
CastCodecFlags.localAudioMask()));
|
||||
CastCodecFlags.localAudioMask(localSettings.isCodecHardeningStrict())));
|
||||
session = new CastSession(listenTransport);
|
||||
int listenPort = CastConfig.portForTransport(localSettings.getTransport());
|
||||
session.prepareListen(listenPort);
|
||||
@@ -178,16 +188,13 @@ public class ReceiverSession {
|
||||
}
|
||||
inboundSessionGate.reset();
|
||||
listener.onCastSessionStarting();
|
||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(statsEnricher);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(appContext),
|
||||
listener::onDiagPingSample);
|
||||
}
|
||||
networkFeedback = new NetworkFeedbackManager(session, false, new AdaptiveNetworkControlPlane());
|
||||
PassThroughNetworkControlPlane plane = (PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(statsEnricher);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(appContext),
|
||||
listener::onDiagPingSample);
|
||||
}
|
||||
session.acceptClient();
|
||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||
@@ -289,16 +296,39 @@ public class ReceiverSession {
|
||||
postStatus("Sender stopped casting");
|
||||
listener.onCastEnded();
|
||||
return;
|
||||
case CastProtocol.MSG_CODEC_CAPS:
|
||||
listener.onCodecCaps(CastProtocol.parseCodecCaps(msg.payload));
|
||||
break;
|
||||
case CastProtocol.MSG_CODEC_SELECTED:
|
||||
listener.onCodecRenegotiation(CastProtocol.parseCodecSelection(msg.payload));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CastSession getActiveSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
public void sendMessage(byte type, byte[] payload) throws IOException {
|
||||
if (session != null) {
|
||||
session.send(type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordNetworkBytes(int bytes) {
|
||||
if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
if (networkFeedback == null || bytes <= 0) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane()).recordBytes(bytes);
|
||||
}
|
||||
if (networkFeedback.getPlane() instanceof AdaptiveNetworkControlPlane) {
|
||||
((AdaptiveNetworkControlPlane) networkFeedback.getPlane()).getProbe().recordMediaArrival(now, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isProtocolDesync(IOException e) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,11 @@ public final class CodecCatalog {
|
||||
List<AudioCodecChoice> out = new ArrayList<>();
|
||||
out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0));
|
||||
// Selectable even without native .so — stub path uses AAC + OPUS/SPEEX backend label (validation).
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.OPUS, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.OPUS,
|
||||
isAudioSelectable(CastSettings.AudioCodec.OPUS, settings), settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.AAC, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX,
|
||||
isAudioSelectable(CastSettings.AudioCodec.SPEEX, settings), settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.PASSTHROUGH_DEBUG, true, settings));
|
||||
sortAudioChoices(out);
|
||||
return out;
|
||||
@@ -255,6 +257,21 @@ public final class CodecCatalog {
|
||||
return new AudioCodecChoice(codec, selectable, score);
|
||||
}
|
||||
|
||||
private static boolean isAudioSelectable(CastSettings.AudioCodec codec, CastSettings settings) {
|
||||
boolean strict = settings != null && settings.isCodecHardeningStrict();
|
||||
if (!strict) {
|
||||
return true;
|
||||
}
|
||||
switch (codec) {
|
||||
case OPUS:
|
||||
return NativeCodecBridge.isOpusAvailable();
|
||||
case SPEEX:
|
||||
return NativeCodecBridge.isSpeexAvailable();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void sortVideoChoices(List<VideoCodecChoice> choices) {
|
||||
if (choices.size() <= 2) {
|
||||
return;
|
||||
|
||||
@@ -51,6 +51,9 @@ import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.media.AdaptiveCodecController;
|
||||
import com.foxx.androidcast.media.CodecRenegotiationManager;
|
||||
import com.foxx.androidcast.network.control.AdaptiveNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
@@ -138,6 +141,8 @@ public class ScreenCastService extends Service implements
|
||||
private volatile boolean cameraMode;
|
||||
private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
|
||||
private SessionStatsRecorder sessionStatsRecorder;
|
||||
private AdaptiveCodecController adaptiveCodecController;
|
||||
private int codecRenegotiationCount;
|
||||
|
||||
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
||||
@Override
|
||||
@@ -279,10 +284,21 @@ public class ScreenCastService extends Service implements
|
||||
AudioNegotiator.resolveForSender(settings, primary.target.audioCodecs);
|
||||
final CastSettings captureSettings = settings;
|
||||
updateStatus(getString(R.string.authenticating));
|
||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||
networkFeedback = new NetworkFeedbackManager(session, true, new AdaptiveNetworkControlPlane());
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(this::enrichSenderStats);
|
||||
networkFeedback.setAdviceListener(new NetworkFeedbackManager.AdviceListener() {
|
||||
@Override
|
||||
public void onBitrateHint(int suggestedBitrateKbps) {
|
||||
applyLiveVideoBitrate(suggestedBitrateKbps * 1000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTransportSwitchHint(String transport, boolean switchTransport) {
|
||||
Log.d(TAG, "Transport switch deferred: " + transport);
|
||||
}
|
||||
});
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(this)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(this),
|
||||
@@ -304,6 +320,8 @@ public class ScreenCastService extends Service implements
|
||||
PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)),
|
||||
settings.getBitrateMode().name().toLowerCase());
|
||||
activeSettings = settings;
|
||||
adaptiveCodecController = new AdaptiveCodecController(session, true, settings,
|
||||
(sel, local) -> mainHandler.post(() -> applySenderCodecRenegotiation(sel)));
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
|
||||
@@ -341,6 +359,10 @@ public class ScreenCastService extends Service implements
|
||||
networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
|
||||
} else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) {
|
||||
handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload));
|
||||
} else if (msg.type == CastProtocol.MSG_CODEC_CAPS) {
|
||||
handleSenderCodecCaps(CastProtocol.parseCodecCaps(msg.payload));
|
||||
} else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) {
|
||||
handleSenderCodecSelected(msg.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,7 +548,8 @@ public class ScreenCastService extends Service implements
|
||||
throw new IOException("No negotiated video codec");
|
||||
}
|
||||
|
||||
videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec());
|
||||
videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec(),
|
||||
settings.isCodecHardeningStrict());
|
||||
CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription(
|
||||
settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec()));
|
||||
VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime);
|
||||
@@ -717,6 +740,11 @@ public class ScreenCastService extends Service implements
|
||||
s.droppedPFrames = sendPump.getDroppedPFrames();
|
||||
}
|
||||
TransportLossStats snap = buildSenderLossSnapshot();
|
||||
if (s.sendBitrateKbps > 0) {
|
||||
synchronized (snap) {
|
||||
snap.mediaBytesSent += (long) s.sendBitrateKbps * 125;
|
||||
}
|
||||
}
|
||||
TransportLossStatsBridge.applyTo(s, snap);
|
||||
if (sessionStatsRecorder != null) {
|
||||
sessionStatsRecorder.sampleNetwork(s, getPeerStats());
|
||||
@@ -744,6 +772,12 @@ public class ScreenCastService extends Service implements
|
||||
if (qDepth > 20) {
|
||||
s.congestionLevel = Math.max(s.congestionLevel, 2);
|
||||
}
|
||||
if (networkFeedback != null && networkFeedback.getPlane() instanceof AdaptiveNetworkControlPlane) {
|
||||
AdaptiveNetworkControlPlane adaptive = (AdaptiveNetworkControlPlane) networkFeedback.getPlane();
|
||||
s.protectionOverheadPercent = adaptive.getProtectionOverheadPercent();
|
||||
s.jitterMs = Math.max(s.jitterMs, adaptive.getProbe().getJitterMs());
|
||||
}
|
||||
s.codecRenegotiationCount = codecRenegotiationCount;
|
||||
}
|
||||
|
||||
private void scheduleTuningTick() {
|
||||
@@ -767,9 +801,14 @@ public class ScreenCastService extends Service implements
|
||||
runOnProjectionThread(() -> videoEncoder.requestKeyframe());
|
||||
}
|
||||
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
|
||||
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
|
||||
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps");
|
||||
applyLiveVideoBitrate(next.videoBitrate);
|
||||
}
|
||||
effectiveParams = next;
|
||||
if (adaptiveCodecController != null && activeSettings != null) {
|
||||
adaptiveCodecController.tick(peer, activeSettings.getNegotiatedVideoMime(),
|
||||
AudioNegotiator.effective(activeSettings));
|
||||
}
|
||||
maybeAdaptCaptureSize(peer);
|
||||
String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
|
||||
CastSendPump pumpForStats = sendPump;
|
||||
@@ -791,6 +830,67 @@ public class ScreenCastService extends Service implements
|
||||
scheduleCaptureSizeUpdate(size, "adaptive");
|
||||
}
|
||||
|
||||
private void applyLiveVideoBitrate(int bitrateBps) {
|
||||
if (bitrateBps <= 0 || videoEncoder == null) {
|
||||
return;
|
||||
}
|
||||
runOnProjectionThread(() -> {
|
||||
if (videoEncoder != null && videoEncoder.updateBitrate(bitrateBps)) {
|
||||
Log.d(TAG, "Live bitrate applied: " + (bitrateBps / 1000) + " kbps");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleSenderCodecCaps(java.util.List<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) {
|
||||
Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
|
||||
if (Looper.myLooper() == handler.getLooper()) {
|
||||
@@ -1172,7 +1272,8 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
CastSettings.VideoCodec vPref = activeSettings != null
|
||||
? activeSettings.getVideoCodec() : CastSettings.VideoCodec.AUTO;
|
||||
videoEncoder = VideoCodecFactory.createEncoder(vPref);
|
||||
videoEncoder = VideoCodecFactory.createEncoder(vPref,
|
||||
activeSettings != null && activeSettings.isCodecHardeningStrict());
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
|
||||
if (tryGles) {
|
||||
|
||||
@@ -60,6 +60,22 @@ public class VideoEncoder implements VideoEncoderSink {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateBitrate(int bitrateBps) {
|
||||
if (codec == null || !running || bitrateBps <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Bundle params = new Bundle();
|
||||
params.putInt(MediaFormat.KEY_BIT_RATE, bitrateBps);
|
||||
codec.setParameters(params);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "updateBitrate: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback) throws IOException {
|
||||
return prepareInternal(width, height, mime, params, callback, false);
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionMode;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.receiver.buffer.JitterBufferStats;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -40,6 +41,7 @@ public final class SessionStatsRecorder {
|
||||
private String resolution = "";
|
||||
private int clientCount = 1;
|
||||
private int disruptCount;
|
||||
private int codecRenegotiationCount;
|
||||
private int idleTimeouts;
|
||||
private int networkSamples;
|
||||
|
||||
@@ -126,6 +128,20 @@ public final class SessionStatsRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
public void noteCodecRenegotiation(String videoMime, String audioMime) {
|
||||
setCodecs(videoMime, audioMime);
|
||||
codecRenegotiationCount++;
|
||||
}
|
||||
|
||||
public void noteJitterBuffer(JitterBufferStats stats) {
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (lossTotals) {
|
||||
lossTotals.recvAudioQueueDrops += stats.drops;
|
||||
}
|
||||
}
|
||||
|
||||
public void setCaptureMode(String mode) {
|
||||
captureMode = mode != null ? mode : "";
|
||||
}
|
||||
@@ -237,6 +253,12 @@ public final class SessionStatsRecorder {
|
||||
if (s.congestionLevel > 0) {
|
||||
pt.put("congestion_level", s.congestionLevel);
|
||||
}
|
||||
if (s.jitterMs > 0) {
|
||||
pt.put("jitter_ms", s.jitterMs);
|
||||
}
|
||||
if (s.protectionOverheadPercent > 0) {
|
||||
pt.put("protection_overhead_pct", s.protectionOverheadPercent);
|
||||
}
|
||||
samples.put(pt);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
@@ -296,6 +318,7 @@ public final class SessionStatsRecorder {
|
||||
root.put("capture_mode", captureMode);
|
||||
root.put("resolution", resolution);
|
||||
root.put("network_samples", networkSamples);
|
||||
root.put("codec_renegotiation_count", codecRenegotiationCount);
|
||||
|
||||
JSONArray journalEvents = journal.copyEvents();
|
||||
if (journalEvents.length() > 0) {
|
||||
|
||||
@@ -202,6 +202,17 @@
|
||||
android:textSize="12sp"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -282,6 +293,28 @@
|
||||
android:id="@+id/spinner_receiver_play_audio"
|
||||
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_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>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
@@ -91,6 +91,14 @@
|
||||
<string name="manual_connect_missing">Enter host and port</string>
|
||||
<string name="playing_resolution">Playing %1$dx%2$d</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="start_receiver">Start listening (keeps running in background)</string>
|
||||
<string name="playback_buffering">Connected — waiting for video…</string>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
typedef struct {
|
||||
unsigned long magic;
|
||||
vpx_codec_ctx_t codec;
|
||||
vpx_codec_enc_cfg_t cfg;
|
||||
vpx_image_t img;
|
||||
int width;
|
||||
int height;
|
||||
@@ -209,6 +210,7 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderCrea
|
||||
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
|
||||
return 0L;
|
||||
}
|
||||
ctx->cfg = cfg;
|
||||
/* VP8 and VP9 both use VP8E_SET_CPUUSED (see libvpx tests / vp9_cx_iface.c). */
|
||||
vpx_codec_control(&ctx->codec, VP8E_SET_CPUUSED, 8);
|
||||
|
||||
@@ -338,6 +340,28 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxRequestKeyf
|
||||
#endif
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxSetBitrate(
|
||||
JNIEnv *env, jclass clazz, jlong handle, jint bitrateKbps) {
|
||||
(void) env;
|
||||
(void) clazz;
|
||||
#if !ANDROIDCAST_HAVE_LIBVPX
|
||||
(void) handle;
|
||||
(void) bitrateKbps;
|
||||
return JNI_FALSE;
|
||||
#else
|
||||
VpxEncoderCtx *ctx = enc_from_handle(handle);
|
||||
if (ctx == NULL || bitrateKbps <= 0) {
|
||||
return JNI_FALSE;
|
||||
}
|
||||
ctx->cfg.rc_target_bitrate = bitrateKbps;
|
||||
if (vpx_codec_enc_config_set(&ctx->codec, &ctx->cfg) != VPX_CODEC_OK) {
|
||||
return JNI_FALSE;
|
||||
}
|
||||
return JNI_TRUE;
|
||||
#endif
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCreate(
|
||||
JNIEnv *env, jclass clazz, jstring mime) {
|
||||
|
||||
Reference in New Issue
Block a user