diff --git a/README.md b/README.md
index cb3c2bc8..df41e509 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,6 @@ Shows all configured slots in a 2×2 grid. Each cell has its own connection and
> These are known gaps that we plan to address. Each one has an issue — comments, test results and pull requests are welcome.
>
> - **RTSP over TCP only.** No UDP or multicast — [#7](https://github.com/OpenIPC/decoder/issues/7)
-> - **Simplified RTP handling for video:** no STAP-A/AP aggregation, packets with CSRC or header extensions are dropped, no packet-loss recovery — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **No audio in Quad mode** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **Only 4 camera slots** — [#13](https://github.com/OpenIPC/decoder/issues/13)
> - **Audio formats not supported:** MP4A-LATM, Opus, G.726, and AAC frames split across RTP packets — [#14](https://github.com/OpenIPC/decoder/issues/14)
diff --git a/README.ru.md b/README.ru.md
index 1a05c58a..50208585 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -92,7 +92,6 @@
> Это известные пробелы, которые мы планируем закрыть. На каждый заведён issue — комментарии, результаты проверок и pull request приветствуются.
>
> - **Только RTSP поверх TCP.** UDP и multicast не поддерживаются — [#7](https://github.com/OpenIPC/decoder/issues/7)
-> - **Упрощённая обработка RTP для видео:** нет агрегации STAP-A/AP, пакеты с CSRC или расширением заголовка отбрасываются, потери пакетов не обрабатываются — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **В режиме Quad нет звука** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **Только 4 слота для камер** — [#13](https://github.com/OpenIPC/decoder/issues/13)
> - **Не поддерживаются форматы звука** MP4A-LATM, Opus, G.726, а также AAC-кадры, разбитые на несколько RTP-пакетов — [#14](https://github.com/OpenIPC/decoder/issues/14)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a6f08c21..e1f6bfbd 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -92,7 +92,6 @@
> 以下是我们计划解决的已知不足,每一项都有对应的 issue。欢迎留言、提交测试结果和 pull request。
>
> - **仅支持 RTSP over TCP**,不支持 UDP 和组播 — [#7](https://github.com/OpenIPC/decoder/issues/7)
-> - **视频的 RTP 处理较为简单:** 不支持 STAP-A/AP 聚合包,带 CSRC 或扩展头的包会被丢弃,也不处理丢包 — [#10](https://github.com/OpenIPC/decoder/issues/10)
> - **Quad 模式下没有声音** — [#12](https://github.com/OpenIPC/decoder/issues/12)
> - **仅有 4 个摄像机槽位** — [#13](https://github.com/OpenIPC/decoder/issues/13)
> - **不支持的音频格式:** MP4A-LATM、Opus、G.726,以及跨多个 RTP 包分片的 AAC 帧 — [#14](https://github.com/OpenIPC/decoder/issues/14)
diff --git a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java
index c1b4788c..652905ba 100644
--- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java
+++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java
@@ -1,7 +1,7 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
- * NalAssembler.java — reusable NAL unit reassembler from RTP fragmentation units
+ * NalAssembler.java — H.264/H.265 NAL units from RTP packets (RFC 6184 / RFC 7798)
*
*/
@@ -10,109 +10,194 @@
import android.util.Log;
/**
- * Reassembles NAL units from RTP fragmentation units (FU-A for H.264, FU for H.265).
- * Shared by the main decoder and QuadCell streams. Replaces duplicated buildFrame()/assembleNal().
+ * Turns RTP video packets into Annex B NAL units (with a 4-byte start code). Handles single
+ * NAL unit packets, fragmentation units (FU-A, FU) and aggregation packets (STAP-A, AP),
+ * any RTP header length (CSRC, extension, padding) and packet loss: a fragmented NAL unit
+ * with a missing piece is dropped instead of being passed on corrupted.
*/
class NalAssembler {
private static final String TAG = "OpenIPCDecoder";
- private static final int RTP_FU_H264 = 28;
- private static final int RTP_FU_H265 = 49;
+ private static final int H264_STAP_A = 24;
+ private static final int H264_FU_A = 28;
+ private static final int H265_AP = 48;
+ private static final int H265_FU = 49;
+ private static final int H265_PACI = 50;
+
+ interface Sink {
+ /** A complete NAL unit; the sink owns the frame (recycle it when done). */
+ void onNal(Frame nal);
+ }
private final byte[] nalBuffer;
- private int nalSize;
- private boolean lastCodec;
private final Runnable onCodecSwitch;
private final FramePool framePool;
+ private int nalSize;
+ /** A fragmented NAL unit is being collected in nalBuffer. */
+ private boolean inFragment;
+ /** Skipping the rest of a fragmented NAL unit whose start was not seen (counted once). */
+ private boolean orphan;
+ private int lastSeq = -1;
+ private boolean lastCodec;
+ private int dropped;
+
NalAssembler(int bufferSize, Runnable onCodecSwitch, FramePool framePool) {
this.nalBuffer = new byte[bufferSize];
this.onCodecSwitch = onCodecSwitch;
this.framePool = framePool;
}
- void reset() { nalSize = 0; }
+ void reset() {
+ nalSize = 0;
+ inFragment = false;
+ orphan = false;
+ lastSeq = -1;
+ }
- /** Reassemble one RTP packet into (possibly null) NAL frame. */
- Frame assemble(Frame frame, boolean codecH265) {
- byte[] rx = frame.data();
- int rxSize = frame.length();
- int cp = 12;
- rxSize -= cp;
- if (rxSize <= 0) return null;
+ /** NAL units dropped so far because of packet loss or size (for diagnostics). */
+ int droppedCount() { return dropped; }
+
+ /**
+ * Process one RTP packet ({@code length} bytes at {@code offset}, header included).
+ * Returns false if the RTP header is malformed.
+ */
+ boolean assemble(byte[] buf, int offset, int length, boolean codecH265, Sink sink) {
+ int header = RtspSession.rtpHeaderLength(buf, offset, length);
+ if (header < 0) return false;
+ int end = RtspSession.rtpPayloadEnd(buf, offset, length);
+ if (end < header) return false;
+ int start = offset + header;
+ end += offset;
if (lastCodec != codecH265) {
lastCodec = codecH265;
- nalSize = 0;
+ reset();
onCodecSwitch.run();
}
- int frag = fragment(rx[cp], codecH265);
- if (frag == RTP_FU_H264 || frag == RTP_FU_H265) {
- int minPayload = codecH265 ? 3 : 2;
- if (rxSize < minPayload) return null;
+ int seq = ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF);
+ boolean gap = lastSeq >= 0 && seq != ((lastSeq + 1) & 0xFFFF);
+ lastSeq = seq;
+ if (gap && inFragment) {
+ drop("packet loss inside a fragmented NAL unit");
+ orphan = true; // its remaining pieces belong to the unit just counted
+ }
- int staBit, endBit;
- if (codecH265) {
- staBit = rx[cp + 2] & 0x80;
- endBit = rx[cp + 2] & 0x40;
- } else {
- staBit = rx[cp + 1] & 0x80;
- endBit = rx[cp + 1] & 0x40;
- }
+ int nalHeader = codecH265 ? 2 : 1;
+ if (end - start < nalHeader) return true;
+ int type = fragment(buf[start], codecH265);
+
+ if (codecH265 ? type == H265_FU : type == H264_FU_A) {
+ fragmentUnit(buf, start, end, codecH265, sink);
+ return true;
+ }
+ // Any other packet ends a fragmented NAL unit that did not finish; its remaining
+ // pieces, if any arrive, belong to the unit counted here
+ if (inFragment) {
+ drop("fragmented NAL unit interrupted by another packet");
+ orphan = true;
+ } else {
+ orphan = false;
+ }
+ if (codecH265 ? type == H265_AP : type == H264_STAP_A) {
+ aggregation(buf, start + nalHeader, end, sink);
+ } else if (codecH265 ? type == H265_PACI : (type >= 25 && type <= 29)) {
+ // PACI, STAP-B, MTAP and FU-B are not used by cameras in non-interleaved mode
+ return true;
+ } else {
+ emit(buf, start, end - start, sink);
+ }
+ return true;
+ }
+
+ private void fragmentUnit(byte[] buf, int start, int end, boolean h265, Sink sink) {
+ int payloadHeader = h265 ? 2 : 1;
+ if (end - start <= payloadHeader) return;
+ int fu = buf[start + payloadHeader] & 0xFF;
+ boolean first = (fu & 0x80) != 0;
+ boolean last = (fu & 0x40) != 0;
+ int pos = start + payloadHeader + 1;
- if (staBit != 0) {
- nalSize = 0;
- if (codecH265) {
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 1;
- // 2-byte NAL header: F + FU type + layer-id MSB, then layer-id/TID as sent
- nalBuffer[nalSize++] = (byte)((rx[cp] & 0x81) | ((rx[cp + 2] & 0x3F) << 1));
- nalBuffer[nalSize++] = rx[cp + 1];
- cp += 3; // payload header + FU header
- } else {
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 0;
- nalBuffer[nalSize++] = 1;
- nalBuffer[nalSize++] = (byte)((rx[cp] & 0xE0) | (rx[cp + 1] & 0x1F));
- cp += 2;
- }
- rxSize = frame.length() - cp;
+ if (first) {
+ if (inFragment) drop("fragmented NAL unit without its end");
+ orphan = false;
+ nalSize = 0;
+ inFragment = true;
+ nalBuffer[nalSize++] = 0;
+ nalBuffer[nalSize++] = 0;
+ nalBuffer[nalSize++] = 0;
+ nalBuffer[nalSize++] = 1;
+ if (h265) {
+ // 2-byte NAL header: F + FU type + layer-id MSB, then layer-id/TID as sent
+ nalBuffer[nalSize++] = (byte) ((buf[start] & 0x81) | ((fu & 0x3F) << 1));
+ nalBuffer[nalSize++] = buf[start + 1];
} else {
- cp += codecH265 ? 3 : 2;
- rxSize = frame.length() - cp;
+ nalBuffer[nalSize++] = (byte) ((buf[start] & 0xE0) | (fu & 0x1F));
}
-
- int copyLen = Math.min(rxSize, nalBuffer.length - nalSize);
- if (copyLen > 0) {
- System.arraycopy(rx, cp, nalBuffer, nalSize, copyLen);
- nalSize += copyLen;
+ } else if (!inFragment) {
+ // The start was lost (or we joined mid-unit): nothing to attach this piece to.
+ // Count the lost unit once, not once per remaining piece.
+ if (!orphan) {
+ orphan = true;
+ dropped++;
+ logDrop("fragmented NAL unit without its start");
}
+ if (last) orphan = false;
+ return;
+ }
- if (endBit != 0) {
- Frame out = framePool.obtain(nalSize);
- System.arraycopy(nalBuffer, 0, out.data(), 0, nalSize);
- out.setLength(nalSize);
- nalSize = 0;
- return out;
- }
- return null;
- } else {
+ int len = end - pos;
+ if (nalSize + len > nalBuffer.length) {
+ drop("NAL unit larger than " + nalBuffer.length + " bytes");
+ return;
+ }
+ System.arraycopy(buf, pos, nalBuffer, nalSize, len);
+ nalSize += len;
+
+ if (last) {
+ Frame out = framePool.obtain(nalSize);
+ System.arraycopy(nalBuffer, 0, out.data(), 0, nalSize);
+ out.setLength(nalSize);
nalSize = 0;
- int copyLen = rxSize + 4;
- if (copyLen > nalBuffer.length || copyLen < 5) return null;
- nalBuffer[0] = 0;
- nalBuffer[1] = 0;
- nalBuffer[2] = 0;
- nalBuffer[3] = 1;
- System.arraycopy(rx, cp, nalBuffer, 4, rxSize);
- Frame out = framePool.obtain(copyLen);
- System.arraycopy(nalBuffer, 0, out.data(), 0, copyLen);
- out.setLength(copyLen);
- return out;
+ inFragment = false;
+ sink.onNal(out);
+ }
+ }
+
+ /** STAP-A / AP: a sequence of (16-bit size, NAL unit). */
+ private void aggregation(byte[] buf, int pos, int end, Sink sink) {
+ while (end - pos >= 2) {
+ int size = ((buf[pos] & 0xFF) << 8) | (buf[pos + 1] & 0xFF);
+ pos += 2;
+ if (size == 0 || pos + size > end) break;
+ emit(buf, pos, size, sink);
+ pos += size;
+ }
+ }
+
+ private void emit(byte[] buf, int pos, int size, Sink sink) {
+ Frame out = framePool.obtain(size + 4);
+ byte[] d = out.data();
+ d[0] = 0;
+ d[1] = 0;
+ d[2] = 0;
+ d[3] = 1;
+ System.arraycopy(buf, pos, d, 4, size);
+ out.setLength(size + 4);
+ sink.onNal(out);
+ }
+
+ private void drop(String reason) {
+ nalSize = 0;
+ inFragment = false;
+ dropped++;
+ logDrop(reason);
+ }
+
+ private void logDrop(String reason) {
+ if (dropped <= 3 || dropped % 100 == 0) {
+ Log.w(TAG, "NAL unit dropped (" + dropped + "): " + reason);
}
}
diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
index 4a34b549..031e020e 100644
--- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java
+++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
@@ -42,6 +42,8 @@ class QuadCell implements RtspSession.Callback {
private final BlockingQueue nalQueue = new ArrayBlockingQueue<>(30);
private final NalAssembler nalAssembler;
+ private final NalAssembler.Sink nalSink;
+ private int queueFullDrops;
private int lastUnknownPayload = -1;
private ExecutorService executor;
@@ -61,6 +63,15 @@ interface Listener {
this.userAgent = userAgent;
this.listener = listener;
this.session = new RtspSession(tag, this);
+ this.nalSink = nal -> {
+ if (!nalQueue.offer(nal)) {
+ // Rate-limited: one aggregation packet can carry many NAL units
+ if (queueFullDrops++ % 100 == 0) {
+ Log.w(TAG, tag + " queue full, NAL dropped (" + queueFullDrops + ")");
+ }
+ pool.recycle(nal);
+ }
+ };
this.nalAssembler = new NalAssembler(512 * 1024, () -> {
nalQueue.clear();
synchronized (decoderLock) {
@@ -129,7 +140,14 @@ private void startThreads() {
while (running) {
try {
Frame f = nalQueue.poll(5, TimeUnit.MILLISECONDS);
- if (f != null) decode(f);
+ if (f != null) {
+ try {
+ decode(f);
+ } finally {
+ // The decoder copied the data: give the buffer back to the pool
+ cellFramePool.recycle(f);
+ }
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
@@ -189,8 +207,6 @@ public void onSdp(Sdp sdp) {
@Override
public boolean onPacket(int channel, byte[] buf, int offset, int length) {
if (channel != RtspSession.CHANNEL_VIDEO || length < 12) return false;
- // CSRC and header extensions are not supported for video yet (#10)
- if ((buf[offset] & 0x0F) != 0 || (buf[offset] & 0x10) != 0) return false;
int pt = buf[offset + 1] & 0x7F;
if (pt != videoPt) {
if (pt != lastUnknownPayload) {
@@ -200,18 +216,10 @@ public boolean onPacket(int channel, byte[] buf, int offset, int length) {
return false;
}
- Frame frame = cellFramePool.obtain(length);
- System.arraycopy(buf, offset, frame.data(), 0, length);
- frame.setLength(length);
- Frame output = nalAssembler.assemble(frame, codecH265);
- cellFramePool.recycle(frame);
- if (output != null && !nalQueue.offer(output)) {
- Log.w(TAG, tag + " queue full, dropping frame");
- cellFramePool.recycle(output);
- }
- return true;
+ return nalAssembler.assemble(buf, offset, length, codecH265, nalSink);
}
+
private void decode(Frame buffer) {
if (buffer.length() < 5) return;
diff --git a/android/app/src/main/java/com/openipc/decoder/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java
index 2a7ed1e5..f4086bb1 100644
--- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java
+++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java
@@ -47,6 +47,15 @@ interface Listener {
this.framePool = framePool;
this.nalAssembler = nalAssembler;
this.nalQueue = nalQueue;
+ this.nalSink = nal -> {
+ if (!nalQueue.offer(nal)) {
+ // Rate-limited: one aggregation packet can carry many NAL units
+ if (queueFullDrops++ % 100 == 0) {
+ Log.w(TAG, "Video queue full, NAL dropped (" + queueFullDrops + ")");
+ }
+ framePool.recycle(nal);
+ }
+ };
}
void setListener(Listener cb) { this.listenerCb = cb; }
@@ -93,30 +102,28 @@ public void onSdp(Sdp sdp) {
if (l != null) l.onCodecChanged(codecH265, sdp.audio);
}
+ private final NalAssembler.Sink nalSink;
+ private int queueFullDrops;
+
@Override
public boolean onPacket(int channel, byte[] buf, int offset, int length) {
if (length < 12) return false;
- Frame frame = framePool.obtain(length);
- System.arraycopy(buf, offset, frame.data(), 0, length);
- frame.setLength(length);
-
- boolean usable = false;
if (channel == RtspSession.CHANNEL_VIDEO) {
- usable = processVideo(frame);
- } else if (channel == RtspSession.CHANNEL_AUDIO && audioManager != null) {
+ return processVideo(buf, offset, length);
+ }
+ if (channel == RtspSession.CHANNEL_AUDIO && audioManager != null) {
+ Frame frame = framePool.obtain(length);
+ System.arraycopy(buf, offset, frame.data(), 0, length);
+ frame.setLength(length);
audioManager.onAudioFrame(frame);
+ framePool.recycle(frame);
}
- framePool.recycle(frame);
- return usable;
+ return false;
}
- /** Returns false for packets of the wrong payload type or with unsupported headers. */
- private boolean processVideo(Frame frame) {
- byte[] data = frame.data();
- // CSRC and header extensions are not supported for video yet (#10)
- if ((data[0] & 0x0F) != 0 || (data[0] & 0x10) != 0) return false;
-
- int payload = data[1] & 0x7F;
+ /** Returns false for packets of the wrong payload type or with a malformed header. */
+ private boolean processVideo(byte[] data, int offset, int length) {
+ int payload = data[offset + 1] & 0x7F;
if (payload != videoPt) {
if (payload != lastUnknownPayload) {
lastUnknownPayload = payload;
@@ -125,17 +132,12 @@ private boolean processVideo(Frame frame) {
return false;
}
- updateJitter(data);
-
- Frame output = nalAssembler.assemble(frame, codecH265);
- if (output != null && !nalQueue.offer(output)) {
- Log.w(TAG, "Video queue full, frame dropped");
- framePool.recycle(output);
- }
+ if (!nalAssembler.assemble(data, offset, length, codecH265, nalSink)) return false;
+ updateJitter(data, offset);
return true;
}
- private void updateJitter(byte[] data) {
+ private void updateJitter(byte[] data, int offset) {
long now = SystemClock.elapsedRealtime();
if (now - lastQualityUpdateTime > 1000) {
lastQualityUpdateTime = now;
@@ -146,10 +148,10 @@ private void updateJitter(byte[] data) {
jitterAccumulator = 0;
jitterSampleCount = 0;
}
- long rtpTs = Integer.toUnsignedLong(((data[4] & 0xFF) << 24)
- | ((data[5] & 0xFF) << 16)
- | ((data[6] & 0xFF) << 8)
- | (data[7] & 0xFF));
+ long rtpTs = Integer.toUnsignedLong(((data[offset + 4] & 0xFF) << 24)
+ | ((data[offset + 5] & 0xFF) << 16)
+ | ((data[offset + 6] & 0xFF) << 8)
+ | (data[offset + 7] & 0xFF));
long arrivalNs = System.nanoTime();
if (lastRtpTimestamp >= 0 && lastRtpArrivalNs >= 0) {
long rtpDeltaMs = (rtpTs - lastRtpTimestamp) / 90L;
diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java
index 030f6cd9..2c5da084 100644
--- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java
+++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java
@@ -580,8 +580,14 @@ static int rtpHeaderLength(byte[] data, int offset, int length) {
/** Payload end: packet length minus RTP padding, or -1 if malformed. */
static int rtpPayloadEnd(byte[] data, int length) {
- if ((data[0] & 0x20) == 0) return length;
- int pad = data[length - 1] & 0xFF;
+ return rtpPayloadEnd(data, 0, length);
+ }
+
+ /** Payload end relative to {@code offset}: length minus RTP padding, or -1 if malformed. */
+ static int rtpPayloadEnd(byte[] data, int offset, int length) {
+ if (length < 1) return -1;
+ if ((data[offset] & 0x20) == 0) return length;
+ int pad = data[offset + length - 1] & 0xFF;
return pad <= length ? length - pad : -1;
}
diff --git a/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java
index 0e888747..59777581 100644
--- a/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java
+++ b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java
@@ -1,7 +1,7 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
- * NalAssemblerTest.java — NAL unit reassembly from RTP single NAL and FU packets
+ * NalAssemblerTest.java — NAL units from RTP: single, FU, STAP-A/AP, headers and loss
*
*/
@@ -9,28 +9,21 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
import org.junit.Test;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.List;
public class NalAssemblerTest {
private int codecSwitches;
- private final NalAssembler nal = new NalAssembler(1024 * 1024, () -> codecSwitches++,
- new FramePool(4));
-
- /** RTP packet: 12-byte header followed by the given payload bytes. */
- private static Frame rtp(int... payload) {
- byte[] d = new byte[12 + payload.length];
- d[0] = (byte) 0x80;
- for (int i = 0; i < payload.length; i++) d[12 + i] = (byte) payload[i];
- return new Frame(d, d.length);
- }
-
- private static byte[] bytes(Frame f) {
- return Arrays.copyOf(f.data(), f.length());
- }
+ private final List out = new ArrayList<>();
+ private final NalAssembler.Sink sink = nal -> out.add(Arrays.copyOf(nal.data(), nal.length()));
+ private NalAssembler nal = new NalAssembler(1024 * 1024, () -> codecSwitches++, new FramePool(4));
+ private int seq = 100;
private static byte[] b(int... v) {
byte[] r = new byte[v.length];
@@ -38,47 +31,192 @@ private static byte[] b(int... v) {
return r;
}
+ /** RTP packet with the next sequence number: 12-byte header + payload. */
+ private byte[] rtp(int... payload) {
+ return rtpWithHeader(b(0x80, 96, (seq >> 8) & 0xFF, seq++ & 0xFF, 0, 0, 0, 0, 0, 0, 0, 0),
+ payload);
+ }
+
+ private static byte[] rtpWithHeader(byte[] header, int... payload) {
+ byte[] p = Arrays.copyOf(header, header.length + payload.length);
+ for (int i = 0; i < payload.length; i++) p[header.length + i] = (byte) payload[i];
+ return p;
+ }
+
+ private boolean feed(byte[] packet, boolean h265) {
+ return nal.assemble(packet, 0, packet.length, h265, sink);
+ }
+
@Test
public void h264SingleNal() {
- Frame out = nal.assemble(rtp(0x67, 0x42, 0x00, 0x1F), false);
- assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42, 0x00, 0x1F), bytes(out));
+ feed(rtp(0x67, 0x42, 0x00, 0x1F), false);
+ assertEquals(1, out.size());
+ assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42, 0x00, 0x1F), out.get(0));
}
@Test
public void h264FuA() {
// FU indicator: NRI 3, type 28; FU header: S/E + type 5 (IDR)
- assertNull(nal.assemble(rtp(0x7C, 0x85, 0xA1, 0xA2), false));
- assertNull(nal.assemble(rtp(0x7C, 0x05, 0xB1), false));
- Frame out = nal.assemble(rtp(0x7C, 0x45, 0xC1, 0xC2), false);
- assertArrayEquals(b(0, 0, 0, 1, 0x65, 0xA1, 0xA2, 0xB1, 0xC1, 0xC2), bytes(out));
+ feed(rtp(0x7C, 0x85, 0xA1, 0xA2), false);
+ feed(rtp(0x7C, 0x05, 0xB1), false);
+ assertEquals(0, out.size());
+ feed(rtp(0x7C, 0x45, 0xC1, 0xC2), false);
+ assertArrayEquals(b(0, 0, 0, 1, 0x65, 0xA1, 0xA2, 0xB1, 0xC1, 0xC2), out.get(0));
}
@Test
public void h265SingleNal() {
- Frame out = nal.assemble(rtp(0x40, 0x01, 0x0C, 0x01), true);
- assertArrayEquals(b(0, 0, 0, 1, 0x40, 0x01, 0x0C, 0x01), bytes(out));
+ feed(rtp(0x40, 0x01, 0x0C, 0x01), true);
+ assertArrayEquals(b(0, 0, 0, 1, 0x40, 0x01, 0x0C, 0x01), out.get(0));
}
@Test
public void h265Fu() {
- // Payload header: type 49, layer 0, TID 1 (0x62 0x01); FU header: S/E + type 19 (IDR_W_RADL)
- assertNull(nal.assemble(rtp(0x62, 0x01, 0x93, 0xA1, 0xA2), true));
- assertNull(nal.assemble(rtp(0x62, 0x01, 0x13, 0xB1), true));
- Frame out = nal.assemble(rtp(0x62, 0x01, 0x53, 0xC1), true);
+ // Payload header: type 49, layer 0, TID 1 (0x62 0x01); FU header: S/E + type 19
+ feed(rtp(0x62, 0x01, 0x93, 0xA1, 0xA2), true);
+ feed(rtp(0x62, 0x01, 0x13, 0xB1), true);
+ feed(rtp(0x62, 0x01, 0x53, 0xC1), true);
+ assertEquals(1, out.size());
// NAL header must be 0x26 0x01 (type 19, layer 0, TID 1)
- assertArrayEquals(b(0, 0, 0, 1, 0x26, 0x01, 0xA1, 0xA2, 0xB1, 0xC1), bytes(out));
+ assertArrayEquals(b(0, 0, 0, 1, 0x26, 0x01, 0xA1, 0xA2, 0xB1, 0xC1), out.get(0));
+ }
+
+ @Test
+ public void h264StapA() {
+ // STAP-A (NRI 3, type 24): SPS (3 bytes) + PPS (2 bytes)
+ feed(rtp(0x78, 0x00, 0x03, 0x67, 0x42, 0x1F, 0x00, 0x02, 0x68, 0xCE), false);
+ assertEquals(2, out.size());
+ assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42, 0x1F), out.get(0));
+ assertArrayEquals(b(0, 0, 0, 1, 0x68, 0xCE), out.get(1));
+ }
+
+ @Test
+ public void h265AggregationPacket() {
+ // AP (type 48 -> 0x60 0x01): VPS (3 bytes) + SPS (2 bytes)
+ feed(rtp(0x60, 0x01, 0x00, 0x03, 0x40, 0x01, 0x0C, 0x00, 0x02, 0x42, 0x01), true);
+ assertEquals(2, out.size());
+ assertArrayEquals(b(0, 0, 0, 1, 0x40, 0x01, 0x0C), out.get(0));
+ assertArrayEquals(b(0, 0, 0, 1, 0x42, 0x01), out.get(1));
+ }
+
+ @Test
+ public void truncatedAggregationStopsAtTheBadUnit() {
+ feed(rtp(0x78, 0x00, 0x02, 0x67, 0x42, 0x00, 0x09, 0x68), false);
+ assertEquals(1, out.size());
+ }
+
+ @Test
+ public void csrcExtensionAndPaddingAreSkipped() {
+ // V=2, P=1, X=1, CC=1: 12 + 4 (CSRC) + 4 + 4 (extension, 1 word) header, 2 bytes padding
+ byte[] header = b(0xB1, 96, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
+ 1, 2, 3, 4,
+ 0xBE, 0xDE, 0x00, 0x01, 9, 9, 9, 9);
+ byte[] p = rtpWithHeader(header, 0x67, 0x42, 0x1F, 0x00, 0x02);
+ assertTrue(feed(p, false));
+ assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42, 0x1F), out.get(0));
+ }
+
+ @Test
+ public void malformedHeaderIsRejected() {
+ assertFalse(feed(b(0x40, 96, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0x67), false));
+ // CSRC count larger than the packet
+ assertFalse(feed(b(0x8F, 96, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0x67), false));
+ assertEquals(0, out.size());
+ }
+
+ @Test
+ public void lostMiddleFragmentDropsTheNal() {
+ feed(rtp(0x7C, 0x85, 0xA1), false);
+ seq++; // one packet lost
+ feed(rtp(0x7C, 0x05, 0xB1), false);
+ feed(rtp(0x7C, 0x45, 0xC1), false);
+ assertEquals(0, out.size());
+ assertEquals(1, nal.droppedCount());
+ // The next complete unit is assembled normally
+ feed(rtp(0x7C, 0x85, 0xD1), false);
+ feed(rtp(0x7C, 0x45, 0xD2), false);
+ assertArrayEquals(b(0, 0, 0, 1, 0x65, 0xD1, 0xD2), out.get(0));
+ }
+
+ @Test
+ public void continuationWithoutStartIsIgnoredAndCountedOnce() {
+ feed(rtp(0x7C, 0x05, 0xB1), false);
+ feed(rtp(0x7C, 0x05, 0xB2), false);
+ feed(rtp(0x7C, 0x45, 0xC1), false);
+ assertEquals(0, out.size());
+ assertEquals("one lost unit, not one per piece", 1, nal.droppedCount());
+ // The next orphan unit is a new loss
+ feed(rtp(0x7C, 0x05, 0xD1), false);
+ assertEquals(2, nal.droppedCount());
+ }
+
+ @Test
+ public void otherPacketInterruptsAFragment() {
+ feed(rtp(0x7C, 0x85, 0xA1), false); // FU start
+ feed(rtp(0x41, 0x01), false); // single NAL in between
+ feed(rtp(0x7C, 0x45, 0xA2), false); // continuation must not reuse stale bytes
+ assertEquals(1, out.size());
+ assertArrayEquals(b(0, 0, 0, 1, 0x41, 0x01), out.get(0));
+ assertEquals(1, nal.droppedCount()); // the interrupted unit, counted once
+ }
+
+ @Test
+ public void startWithoutEndIsDroppedOnNextStart() {
+ feed(rtp(0x7C, 0x85, 0xA1), false);
+ feed(rtp(0x7C, 0x85, 0xB1), false); // new start, previous never ended
+ feed(rtp(0x7C, 0x45, 0xB2), false);
+ assertEquals(1, out.size());
+ assertArrayEquals(b(0, 0, 0, 1, 0x65, 0xB1, 0xB2), out.get(0));
+ assertEquals(1, nal.droppedCount());
+ }
+
+ @Test
+ public void lossBetweenSingleNalsIsHarmless() {
+ feed(rtp(0x41, 0x01), false);
+ seq += 5;
+ feed(rtp(0x41, 0x02), false);
+ assertEquals(2, out.size());
+ assertEquals(0, nal.droppedCount());
+ }
+
+ @Test
+ public void sequenceNumberWrapIsNotALoss() {
+ seq = 65535;
+ feed(rtp(0x7C, 0x85, 0xA1), false);
+ feed(rtp(0x7C, 0x45, 0xA2), false); // seq 0
+ assertEquals(1, out.size());
+ assertEquals(0, nal.droppedCount());
+ }
+
+ @Test
+ public void oversizedNalIsDroppedNotTruncated() {
+ nal = new NalAssembler(16, () -> codecSwitches++, new FramePool(4));
+ feed(rtp(0x7C, 0x85, 1, 2, 3, 4, 5, 6, 7, 8), false);
+ feed(rtp(0x7C, 0x45, 1, 2, 3, 4, 5, 6, 7, 8), false);
+ assertEquals(0, out.size());
+ assertEquals(1, nal.droppedCount());
}
@Test
public void codecSwitchResetsAndNotifies() {
- nal.assemble(rtp(0x67, 0x42), false);
+ feed(rtp(0x67, 0x42), false);
assertEquals(0, codecSwitches);
- nal.assemble(rtp(0x40, 0x01, 0x0C), true);
+ feed(rtp(0x40, 0x01, 0x0C), true);
assertEquals(1, codecSwitches);
}
@Test
public void emptyPayloadIsIgnored() {
- assertNull(nal.assemble(rtp(), false));
+ assertTrue(feed(rtp(), false));
+ assertEquals(0, out.size());
+ }
+
+ @Test
+ public void packetAtAnOffset() {
+ byte[] packet = rtp(0x67, 0x42);
+ byte[] buf = new byte[packet.length + 4];
+ System.arraycopy(packet, 0, buf, 4, packet.length);
+ assertTrue(nal.assemble(buf, 4, packet.length, false, sink));
+ assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42), out.get(0));
}
}