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