Skip to content

feat: Opus, MP4A-LATM and fragmented AAC audio (part of #14) - #52

Merged
compot-dev merged 3 commits into
v2from
feat/audio-formats
Oct 12, 2026
Merged

compot-dev merged 3 commits into
v2from
feat/audio-formats

Conversation

@compot-dev

@compot-dev compot-dev commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #14. G.726 remains: Android has no G.726 decoder, so it would need a software implementation. That stays in #14.

Stacked on #51 (which is stacked on #48): it changes the same AudioManager and uses #51's audio routes. Merge order: #48 → #51 → #52; each base then moves to v2.

⚠️ Do not merge before testing on a device (label awaiting device test). MediaCodec cannot be exercised in JVM tests.

Format Implementation
Opus (RFC 7587) One Opus packet per RTP packet. Decoded by MediaCodec audio/opus, available since Android 5.0. csd-0 is an OpusHead header; csd-1/csd-2 carry the codec delay and the seek pre-roll. Output is 48 kHz
MP4A-LATM (RFC 6416) The StreamMuxConfig from fmtp config= is converted into an AudioSpecificConfig (audioMuxVersion 0, one program, one layer). Sample rate and channel count are taken from it. Each AudioMuxElement is split by its PayloadLengthInfo. cpresent=1 (config in-band) and other layouts are still not supported and are switched off cleanly
AAC split across RTP packets (RFC 3640) Every fragment carries an AU header with the full AU size; the RTP marker bit is set on the last one. The AU is reassembled; a fragment with missing pieces is dropped

AAC, LATM and Opus share one MediaCodec path.

README in all three languages: the formats are listed, and only G.726 remains under "Known limitations".

Tests

AudioFormatsTest, 9 tests:

  • Opus: SDP and the OpusHead layout;
  • LATM: StreamMuxConfig → ASC, a full SDP, unsupported layouts, PayloadLengthInfo splitting;
  • AAC fragments: reassembly from three packets, dropping an incomplete one, whole AUs passing through.

103 JVM tests on this branch (#48 + #51 + #52), all pass. assembleDebug and lintDebug pass.

Device checklist

@compot-dev compot-dev added the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add Opus, MP4A-LATM, and fragmented AAC playback

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Add Opus and out-of-band MP4A-LATM playback through the existing MediaCodec audio path.
• Reassemble AAC access units spanning RTP packets and drop incomplete fragments.
• Add format tests and update three READMEs; G.726 remains unsupported.
Diagram

graph TD
  SDP["SDP metadata"] --> CFG["AudioConfig"] --> RTP["RTP audio"]
  RTP --> AAC["AAC assembly"] --> DEC["MediaCodec decoder"] --> OUT["AudioTrack"]
  RTP --> LATM["LATM splitting"] --> DEC
  RTP --> OPUS["Opus packets"] --> DEC
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Reassemble AAC in the RTP layer
  • ➕ RTP sequence numbers and timestamps could identify packet loss or mismatched fragments.
  • ➖ Broadens the change into generic transport handling and increases integration risk.

Recommendation: Keep format-specific assembly in the audio pipeline and reuse its MediaCodec path: this limits the scope of the new formats. Consider transport-aware loss detection separately if device testing exposes fragment recovery issues; do not merge before playback testing on a device.

Files changed (7) +374 / -19

Enhancement (2) +211 / -12
AudioConfig.javaParse and validate Opus and LATM SDP configuration +87/-1

Parse and validate Opus and LATM SDP configuration

• Recognizes Opus and MP4A-LATM, converts supported out-of-band LATM StreamMuxConfig data into AAC decoder configuration, and derives sample rate and channels. Rejects in-band configuration and unsupported LATM layouts.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java

AudioManager.javaDecode Opus and LATM and reassemble fragmented AAC +124/-11

Decode Opus and LATM and reassemble fragmented AAC

• Routes Opus and LATM payloads through the existing MediaCodec pipeline, adding Opus codec-specific data and LATM payload splitting. Reassembles AAC units across RTP packets, drops incomplete fragments, and resets assembly on reconfiguration.

android/app/src/main/java/com/openipc/decoder/AudioManager.java

Tests (2) +157 / -1
AudioFormatsTest.javaCover new audio formats and AAC fragmentation +156/-0

Cover new audio formats and AAC fragmentation

• Adds nine JVM tests for Opus SDP and header layout, LATM configuration and payload splitting, and complete or incomplete fragmented AAC units.

android/app/src/test/java/com/openipc/decoder/AudioFormatsTest.java

SdpParserTest.javaKeep unsupported-codec coverage after adding Opus +1/-1

Keep unsupported-codec coverage after adding Opus

• Changes the unsupported audio fixture from Opus to G.726, which remains unsupported.

android/app/src/test/java/com/openipc/decoder/SdpParserTest.java

Documentation (3) +6 / -6
README.mdList new audio formats in English +2/-2

List new audio formats in English

• Adds Opus and MP4A-LATM to the supported formats and removes fragmented AAC from the limitations. G.726 remains listed as unsupported.

README.md

README.ru.mdUpdate Russian audio support documentation +2/-2

Update Russian audio support documentation

• Lists Opus and MP4A-LATM as supported and narrows the audio limitation to G.726.

README.ru.md

README.zh-CN.mdUpdate Chinese audio support documentation +2/-2

Update Chinese audio support documentation

• Lists Opus and MP4A-LATM as supported and narrows the audio limitation to G.726.

README.zh-CN.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. G.726 cameras still have no audio 📎 Requirement gap ≡ Correctness
Description
AudioConfig.applyRtpmap() continues to classify G.726 as unsupported and sets its codec to NONE.
Any SDP advertising G.726 therefore reaches the existing unsupported-audio path instead of a
decoder, leaving that camera without playback.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R64-66]

      } else {
-            // MP4A-LATM, OPUS, G726, ... are not supported
+            // G.726 and others are not supported
          codec = NONE;
Evidence
The checklist requires playback of G.726 audio, but the changed code explicitly keeps G.726
unsupported and maps it to NONE; the updated README also states that G.726 remains unsupported.

Support G.726 audio
android/app/src/main/java/com/openipc/decoder/AudioConfig.java[64-66]
README.md[96-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
G.726 remains explicitly unsupported, so cameras that advertise G.726 audio are still dropped instead of being played. Implement a software G.726 decoder and connect it to the audio pipeline, or otherwise provide a working decoder path for supported G.726 variants.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[64-66]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[149-173]
## Recommended Fix
Add G.726 codec recognition and SDP parameter handling, decode G.726 RTP payloads into PCM, and route the decoded samples through the existing audio output path. Add JVM coverage for codec selection and decoder input handling, and device-test playback with a G.726 camera.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. LATM streams fail to produce audio 🐞 Bug ≡ Correctness
Description
splitLatm() reads whole bytes as payload lengths and forwards the following ranges as raw AAC,
without parsing the bit-packed AudioMuxElement or its useSameStreamMux field. When a normal
MP4A-LATM RTP payload arrives, the AAC decoder receives incorrectly sliced or still-framed data
instead of an access unit.
Code

android/app/src/main/java/com/openipc/decoder/AudioManager.java[R434-440]

+        int pos = start;
+        while (pos < end) {
+            int len = 0;
+            int b;
+            do {
+                if (pos >= end) return units;
+                b = data[pos++] & 0xFF;
Evidence
The RTP payload goes directly to the new byte-oriented splitter, whose ranges are queued into an
ordinary AAC decoder with an AudioSpecificConfig; no code removes LATM bit-level framing.

android/app/src/main/java/com/openipc/decoder/AudioManager.java[117-122]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[428-447]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[522-538]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The LATM splitter assumes byte-aligned length prefixes and passes LATM framing to a raw AAC decoder.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[117-122]
## Recommended Fix
Parse the supported AudioMuxElement layout at bit granularity, including useSameStreamMux and PayloadLengthInfo, and queue only extracted AAC access-unit bytes. Add a test using a framed AudioMuxElement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Some LATM frame settings are discarded ✓ Resolved
Description
latmToAsc() reads frameLengthFlag and the optional core-coder dependency but emits a two-byte
AudioSpecificConfig that drops both settings and leaves frameLengthFlag at zero. A StreamMuxConfig
signaling 960-sample frames or a core-coder dependency is accepted and reaches the AAC decoder as
csd-0 with mismatched configuration, potentially causing wrong timing or distorted audio for
960-sample streams.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R163-165]

+        r.bits(1);                                              // frameLengthFlag
+        if (r.bits(1) == 1) r.bits(14);                         // dependsOnCoreCoder
+        if (r.bits(1) != 0) return null;                        // extensionFlag
Evidence
Line 163 consumes frameLengthFlag without retaining it, and lines 172–175 construct only two ASC
bytes; the second byte uses channelConfig << 3, leaving the frameLengthFlag bit at zero. The
parser also consumes the optional core-coder dependency without representing it in those bytes, and
the resulting aacConfig is supplied to the decoder as csd-0.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[155-175]
android/app/src/main/java/com/openipc/decoder/AudioConfig.java[119-132]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[625-631]
android/app/src/main/java/com/openipc/decoder/AudioConfig.java[162-175]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[628-632]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`latmToAsc()` accepts GA-specific frame settings that its generated two-byte AudioSpecificConfig does not retain, including `frameLengthFlag` for 960-sample frames and the optional core-coder dependency.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[162-175]
## Recommended Fix
Either preserve both settings in the AudioSpecificConfig supplied to the decoder or reject StreamMuxConfigs with non-default `frameLengthFlag` or `dependsOnCoreCoder` values. For `frameLengthFlag`, store `int frameLengthFlag = r.bits(1);` and either return null when it is 1 or OR `frameLengthFlag << 2` into the second ASC byte. Add tests for both settings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. A lost first LATM fragment plays as noise ✓ Resolved
Description
LatmAssembler.push() checks for a sequence gap only when pending.size() > 0, so it accepts a
continuation packet as a new start when the first fragment is lost immediately after a marker reset.
At the next marker, it returns the continuation bytes as a complete element, and splitLatm()
interprets audio bytes as payload lengths before passing the resulting slices to offerAac for
MediaCodec.
Code

android/app/src/main/java/com/openipc/decoder/AudioManager.java[R505-509]

+            if (lastSeq >= 0 && seq != ((lastSeq + 1) & 0xFFFF) && pending.size() > 0) {
+                broken = true; // a piece of the current element is missing
+            }
+            lastSeq = seq;
+            if (!broken) pending.write(data, start, end - start);
Evidence
The gap check is guarded by pending.size() > 0, but completing an element at a marker resets
pending at line 512. A lost first fragment immediately after that reset therefore leaves the
following continuation outside the loss condition; the assembler returns those bytes at the next
marker, and lines 158–161 pass them through splitLatm() and offerAac().

android/app/src/main/java/com/openipc/decoder/AudioManager.java[504-515]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[156-164]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[156-163]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[522-537]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A sequence gap after a completed LATM element may mean the next element’s first fragment was lost. `LatmAssembler` currently accepts its continuation as a new start and passes the resulting bytes on for decoding.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[504-515]
## Recommended Fix
On any RTP sequence discontinuity (`lastSeq >= 0 && seq != lastSeq + 1`), set `broken = true` regardless of whether `pending` contains bytes. Discard packets through the next marker, then resume assembly with the following element; if the packet after the gap itself carries the marker, dropping just that packet is also acceptable. Add a unit test that loses the first fragment.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. LATM buffer grows without limit with no marker bit ✓ Resolved
Description
LatmAssembler.push() appends payloads to pending without a size limit and normally clears them
only when an RTP marker arrives. If a camera never sets the marker, or marker packets are lost for a
long stretch, audio remains silent while the buffer grows at the audio bitrate and can exhaust the
device heap.
Code

android/app/src/main/java/com/openipc/decoder/AudioManager.java[R509-510]

+            if (!broken) pending.write(data, start, end - start);
+            if (!marker) return null;
Evidence
pending.write(...) adds each non-broken payload to the persistent buffer, but the packet path has
no size check or size-based reset. The buffer is reset when a marker completes an element or when
the assembler is reset, so bounded individual RTP packets can still accumulate indefinitely without
a marker.

android/app/src/main/java/com/openipc/decoder/AudioManager.java[493-516]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[493-515]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[126-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
LATM payloads accumulate without a limit when RTP markers do not arrive, leaving audio silent and potentially exhausting the device heap.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[493-515]
## Recommended Fix
Set a maximum assembled-element size, such as 64 KiB. Before appending, check whether `pending.size() + (end-start)` would exceed it; if so, reset `pending`, set `broken = true` to skip data until the next marker, and log once. Preserve the existing reset behavior for completed elements.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (6)
6. Lost packets can corrupt the next AAC frame ✓ Resolved
Description
AuAssembler.push() identifies a continuation solely by its declared size and accumulated byte
count, without checking RTP sequence numbers. If a fragment is lost and a later fragmented access
unit has the same declared size, its bytes can complete the stale buffer and be submitted as one
corrupted frame.
Code

android/app/src/main/java/com/openipc/decoder/AudioManager.java[R395-403]

+            if (fragment != null) {
+                // Continuation: one AU header with the full AU size, then the next piece
+                int len = end - pos;
+                if (size != fragment.length || have + len > fragment.length) {
+                    fragment = null; // does not belong to the AU being collected
+                } else {
+                    System.arraycopy(data, pos, fragment, have, len);
+                    have += len;
+                    if (have == fragment.length) {
Evidence
The RTP header remains available to onAudioFrame, but only its marker bit is passed to the
assembler. The assembler appends and emits bytes based on size alone.

android/app/src/main/java/com/openipc/decoder/AudioManager.java[105-115]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[395-409]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The AAC assembler can join bytes from different access units after an RTP packet is lost.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[112-115]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[381-409]
## Recommended Fix
Pass the RTP sequence number to the assembler and discard a pending fragment when the next packet is not the expected successor. Add a packet-loss test with equal-sized access units.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Unsupported LATM layouts stay enabled ✓ Resolved
Description
latmToAsc() discards allStreamsSameTimeFraming and numSubFrames, then returns before checking
the remaining StreamMuxConfig fields, including frameLengthType. Configurations describing
multiple subframes or a different framing mode therefore remain enabled and reach a splitter that
has no handling for those layouts.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R147-150]

+        r.bits(1);                         // allStreamsSameTimeFraming
+        r.bits(6);                         // numSubFrames
+        if (r.bits(4) != 0) return null;  // numProgram + 1 must be 1
+        if (r.bits(3) != 0) return null;  // numLayer + 1 must be 1
Evidence
The configuration parser ignores layout fields and constructs an ASC without reading the
StreamMuxConfig tail; a non-null result leaves LATM enabled, while packet splitting has no
corresponding layout handling.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[119-131]
android/app/src/main/java/com/openipc/decoder/AudioConfig.java[144-163]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-447]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The LATM configuration parser enables layouts that the packet parser cannot interpret.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[144-163]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]
## Recommended Fix
Consume and validate the complete supported StreamMuxConfig, rejecting unsupported timing, subframe and frame-length modes rather than discarding their fields. Test that unsupported layouts disable LATM.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Multichannel LATM audio is misplayed ✓ Resolved
Description
ascInfo() collapses every channel configuration above two to stereo while latmToAsc() retains
the original multichannel value in the decoder configuration. When such a stream decodes to more
than two PCM channels, playback selects a mono channel mask for any channel count other than two and
writes the multichannel bytes to that track.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R169-172]

+        int freqIndex = ((asc[0] & 0x07) << 1) | ((asc[1] & 0x80) >> 7);
+        int channelConfig = (asc[1] & 0x78) >> 3;
+        if (freqIndex >= AAC_RATES.length || channelConfig < 1) return null;
+        return new int[] {AAC_RATES[freqIndex], Math.min(channelConfig, 2)};
Evidence
The parser accepts configurations through seven but reports at most two channels. Decoder output can
update the PCM channel count, whereas AudioTrack construction maps every count other than two to
mono.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[155-172]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[617-626]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[253-271]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
LATM accepts multichannel configurations that the playback track cannot render correctly.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[155-172]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[253-258]
## Recommended Fix
Reject channel configurations above two until playback supports them, or preserve the real channel count and configure an appropriate output channel mask or downmix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Some LATM profiles get conflicting setup ✓ Resolved
Description
latmToAsc() accepts AAC object types one through four and preserves their type in the generated
AudioSpecificConfig, but ensureAacDecoder() sets the AAC profile to LC unconditionally. A stream
declaring AAC Main, SSR or LTP therefore reaches decoder configuration with a profile setting that
conflicts with its supplied AudioSpecificConfig.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R155-157]

+        int channelConfig = r.bits(4);
+        if (r.overrun() || objectType < 1 || objectType > 4 || channelConfig < 1
+                || channelConfig > 7) {
Evidence
The newly generated ASC preserves object types one through four, while the shared raw-AAC decoder
path explicitly requests AAC-LC for every accepted LATM stream.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[151-163]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[532-538]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Accepted LATM AAC object types can conflict with the decoder's forced LC profile.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[151-163]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[532-538]
## Recommended Fix
Either accept only AAC-LC in LATM configuration or configure the decoder profile consistently with the object type encoded in the AudioSpecificConfig.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Invalid LATM rates reach the decoder ✓ Resolved
Description
latmToAsc() rejects frequency index 15 but serializes the reserved indices 13 and 14, for which
ascInfo() has no sampling rate. If SDP supplies either value, finish() leaves LATM enabled after
ascInfo() returns null, and the invalid AudioSpecificConfig is passed to the AAC decoder.
Code

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[R153-155]

+        int freqIndex = r.bits(4);
+        if (freqIndex == 15) return null;  // explicit frequency: not supported
+        int channelConfig = r.bits(4);
Evidence
The conversion accepts indices absent from the rate table; the later null result does not disable
LATM, and decoder setup uses the resulting ASC directly.

android/app/src/main/java/com/openipc/decoder/AudioConfig.java[119-138]
android/app/src/main/java/com/openipc/decoder/AudioConfig.java[153-172]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[532-538]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reserved LATM frequency indices pass configuration validation and reach MediaCodec.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[153-163]
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[119-131]
## Recommended Fix
Reject indices outside the supported AAC sampling-rate table during LATM conversion, and disable LATM if the resulting ASC cannot provide valid rate and channel information.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Split LATM frames are decoded as garbage ✓ Resolved
Description
splitLatm treats every RTP payload as the start of an AudioMuxElement and never looks at the RTP
marker bit. RFC 6416 allows a large AudioMuxElement to be split across several RTP packets, with the
marker set only on the last one. When that happens, the first byte of each continuation packet is
read as a PayloadLengthInfo. Whenever that length happens to fit in the packet, random audio bytes
are sent to MediaCodec as an AU, which causes glitches or decode errors instead of a clean drop.
Code

android/app/src/main/java/com/openipc/decoder/AudioManager.java[R117-121]

+            case AudioConfig.LATM:
+                ensureAacDecoder(c, false);
+                for (int[] u : splitLatm(data, start, end)) {
+                    offerAac(Arrays.copyOfRange(data, u[0], u[0] + u[1]));
+                }
Evidence
The LATM case in onAudioFrame passes each packet straight to splitLatm and does not use the marker
bit, unlike the AAC path, which passes (data[1] & 0x80) != 0 to AuAssembler. splitLatm reads
PayloadLengthInfo from start without checking where the packet sits inside the element. A
continuation fragment begins with payload bytes, so a length value <= the remaining bytes produces a
bogus unit, and the code offers it to the decoder.

android/app/src/main/java/com/openipc/decoder/AudioManager.java[117-122]
android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MP4A-LATM AudioMuxElements may span several RTP packets (RFC 6416; the marker bit is set on the last fragment). `splitLatm` parses each packet on its own, so continuation fragments are read as PayloadLengthInfo and garbage AUs reach MediaCodec.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[117-122]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]
## Recommended Fix
Pass the RTP marker bit into the LATM path. Collect payload bytes in a buffer until a packet with the marker set arrives, run splitLatm on the joined element, then clear the buffer. Clear it in `configure()` as well, in the same place `auAssembler.reset()` is called. As a minimum, drop packets while a non-marker sequence is in progress rather than parsing them as new elements.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +434 to +440
int pos = start;
while (pos < end) {
int len = 0;
int b;
do {
if (pos >= end) return units;
b = data[pos++] & 0xFF;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Latm streams fail to produce audio 🐞 Bug ≡ Correctness

splitLatm() reads whole bytes as payload lengths and forwards the following ranges as raw AAC,
without parsing the bit-packed AudioMuxElement or its useSameStreamMux field. When a normal
MP4A-LATM RTP payload arrives, the AAC decoder receives incorrectly sliced or still-framed data
instead of an access unit.
Agent Prompt
## Issue description
The LATM splitter assumes byte-aligned length prefixes and passes LATM framing to a raw AAC decoder.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[117-122]
## Recommended Fix
Parse the supported AudioMuxElement layout at bit granularity, including useSameStreamMux and PayloadLengthInfo, and queue only extracted AAC access-unit bytes. Add a test using a framed AudioMuxElement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly disagree, partly fixed in 36cbfbd. With out-of-band config (cpresent=0) the AudioMuxElement is called with muxConfigPresent = 0, so there is no useSameStreamMux bit, and with frameLengthType = 0 PayloadLengthInfo is a byte-aligned run of 0xFF bytes plus a final byte, directly followed by the AU. That is what splitLatm() parses. What was missing is the guarantee that the stream really uses this layout: latmToAsc() now validates the whole StreamMuxConfig and disables LATM for anything else (see finding 3).

Comment on lines +395 to +403
if (fragment != null) {
// Continuation: one AU header with the full AU size, then the next piece
int len = end - pos;
if (size != fragment.length || have + len > fragment.length) {
fragment = null; // does not belong to the AU being collected
} else {
System.arraycopy(data, pos, fragment, have, len);
have += len;
if (have == fragment.length) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Lost packets can corrupt the next aac frame 🐞 Bug ≡ Correctness

AuAssembler.push() identifies a continuation solely by its declared size and accumulated byte
count, without checking RTP sequence numbers. If a fragment is lost and a later fragmented access
unit has the same declared size, its bytes can complete the stale buffer and be submitted as one
corrupted frame.
Agent Prompt
## Issue description
The AAC assembler can join bytes from different access units after an RTP packet is lost.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[112-115]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[381-409]
## Recommended Fix
Pass the RTP sequence number to the assembler and discard a pending fragment when the next packet is not the expected successor. Add a packet-loss test with equal-sized access units.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36cbfbd. AuAssembler.push() takes the RTP sequence number and drops the pending fragment when a packet is missing. Test: lossBetweenFragmentsDropsTheStaleAu (equal-sized AUs, packet 101 lost).

Comment on lines +147 to +150
r.bits(1); // allStreamsSameTimeFraming
r.bits(6); // numSubFrames
if (r.bits(4) != 0) return null; // numProgram + 1 must be 1
if (r.bits(3) != 0) return null; // numLayer + 1 must be 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Unsupported latm layouts stay enabled 🐞 Bug ≡ Correctness

latmToAsc() discards allStreamsSameTimeFraming and numSubFrames, then returns before checking
the remaining StreamMuxConfig fields, including frameLengthType. Configurations describing
multiple subframes or a different framing mode therefore remain enabled and reach a splitter that
has no handling for those layouts.
Agent Prompt
## Issue description
The LATM configuration parser enables layouts that the packet parser cannot interpret.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[144-163]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[432-448]
## Recommended Fix
Consume and validate the complete supported StreamMuxConfig, rejecting unsupported timing, subframe and frame-length modes rather than discarding their fields. Test that unsupported layouts disable LATM.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36cbfbd. latmToAsc() now consumes and checks the full StreamMuxConfig: allStreamsSameTimeFraming = 1, numSubFrames = 0, one program and layer, GASpecificConfig without extension, frameLengthType = 0, otherDataPresent = 0, crcCheckPresent = 0. Test: latmRejectsLayoutsTheRtpPathCannotHandle.

Comment on lines +169 to +172
int freqIndex = ((asc[0] & 0x07) << 1) | ((asc[1] & 0x80) >> 7);
int channelConfig = (asc[1] & 0x78) >> 3;
if (freqIndex >= AAC_RATES.length || channelConfig < 1) return null;
return new int[] {AAC_RATES[freqIndex], Math.min(channelConfig, 2)};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Multichannel latm audio is misplayed 🐞 Bug ≡ Correctness

ascInfo() collapses every channel configuration above two to stereo while latmToAsc() retains
the original multichannel value in the decoder configuration. When such a stream decodes to more
than two PCM channels, playback selects a mono channel mask for any channel count other than two and
writes the multichannel bytes to that track.
Agent Prompt
## Issue description
LATM accepts multichannel configurations that the playback track cannot render correctly.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[155-172]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[253-258]
## Recommended Fix
Reject channel configurations above two until playback supports them, or preserve the real channel count and configure an appropriate output channel mask or downmix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36cbfbd. Channel configurations above 2 are rejected in both latmToAsc() and ascInfo() until playback supports more channels.

Comment on lines +155 to +157
int channelConfig = r.bits(4);
if (r.overrun() || objectType < 1 || objectType > 4 || channelConfig < 1
|| channelConfig > 7) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Some latm profiles get conflicting setup 🐞 Bug ≡ Correctness

latmToAsc() accepts AAC object types one through four and preserves their type in the generated
AudioSpecificConfig, but ensureAacDecoder() sets the AAC profile to LC unconditionally. A stream
declaring AAC Main, SSR or LTP therefore reaches decoder configuration with a profile setting that
conflicts with its supplied AudioSpecificConfig.
Agent Prompt
## Issue description
Accepted LATM AAC object types can conflict with the decoder's forced LC profile.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[151-163]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[532-538]
## Recommended Fix
Either accept only AAC-LC in LATM configuration or configure the decoder profile consistently with the object type encoded in the AudioSpecificConfig.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36cbfbd. LATM is accepted only for AAC-LC (object type 2), matching the decoder profile.

Comment thread android/app/src/main/java/com/openipc/decoder/AudioConfig.java
Comment thread android/app/src/main/java/com/openipc/decoder/AudioManager.java
@compot-dev
compot-dev changed the base branch from fix/decoder-lifecycle to feat/quad-audio October 12, 2026 13:05
Part of #14 (G.726 remains):

- Opus (RFC 7587): one Opus packet per RTP packet, decoded by MediaCodec
  "audio/opus" (Android 5.0+) with an OpusHead csd-0 and codec delay /
  seek pre-roll csd-1/csd-2; output is 48 kHz.
- MP4A-LATM (RFC 6416) with out-of-band config: the fmtp StreamMuxConfig
  is turned into an AudioSpecificConfig (audioMuxVersion 0, one program
  and layer), sample rate and channels come from it, and each
  AudioMuxElement is split by its PayloadLengthInfo. cpresent=1 and
  other layouts stay unsupported.
- RFC 3640 AUs spanning several RTP packets are reassembled (AU-size in
  each fragment, RTP marker on the last); a fragment with missing pieces
  is dropped.
- The AAC MediaCodec path is shared by AAC, LATM and Opus.
- README (EN/RU/ZH): formats listed; only G.726 remains in the
  limitations.

Tests: Opus SDP and header, StreamMuxConfig to ASC, LATM SDP, unsupported
LATM layouts, PayloadLengthInfo splitting, fragmented AU reassembly,
dropped incomplete fragment, whole AUs passing through.
- latmToAsc() parses the whole StreamMuxConfig and accepts only the
  layout the RTP path handles: same time framing, one subframe, one
  program and layer, AAC-LC, a standard sampling rate, mono or stereo,
  frameLengthType 0, no other data, no CRC. Anything else disables LATM
  instead of reaching the splitter or MediaCodec.
- ascInfo() rejects more than two channels; finish() disables LATM when
  the ASC gives no valid rate/channels.
- LatmAssembler joins an AudioMuxElement that spans several RTP packets
  (marker on the last) and drops an element with a missing piece.
- AuAssembler tracks RTP sequence numbers: after a loss the pending AAC
  fragment is dropped, so bytes of two AUs are never joined.
@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

Comment on lines 64 to 66
} else {
// MP4A-LATM, OPUS, G726, ... are not supported
// G.726 and others are not supported
codec = NONE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. G.726 cameras still have no audio 📎 Requirement gap ≡ Correctness

AudioConfig.applyRtpmap() continues to classify G.726 as unsupported and sets its codec to NONE.
Any SDP advertising G.726 therefore reaches the existing unsupported-audio path instead of a
decoder, leaving that camera without playback.
Agent Prompt
## Issue description
G.726 remains explicitly unsupported, so cameras that advertise G.726 audio are still dropped instead of being played. Implement a software G.726 decoder and connect it to the audio pipeline, or otherwise provide a working decoder path for supported G.726 variants.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/AudioConfig.java[64-66]
- android/app/src/main/java/com/openipc/decoder/AudioManager.java[149-173]

## Recommended Fix
Add G.726 codec recognition and SDP parameter handling, decode G.726 RTP payloads into PCM, and route the decoded samples through the existing audio output path. Add JVM coverage for codec selection and decoder input handling, and device-test playback with a G.726 camera.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread android/app/src/main/java/com/openipc/decoder/AudioConfig.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/AudioManager.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/AudioManager.java Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6f43336

@compot-dev
compot-dev changed the base branch from feat/quad-audio to v2 October 12, 2026 14:58
… and core-coder configs

- A sequence gap right after a completed element may have lost the next element's
  start, so its continuation is no longer taken for a new element
- Without markers the element buffer grew forever; it is capped at 64 KiB
- frameLengthFlag and dependsOnCoreCoder are not carried by the 2-byte ASC, so such
  StreamMuxConfigs are refused instead of reaching the decoder with a wrong csd-0
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Addressed in 68c8c25:

  • Lost first LATM fragment: fixed. Any sequence gap now marks the element broken, even when nothing is pending yet, and everything up to the next marker is skipped. Test latmLostFirstPieceDropsTheElement.
  • Unbounded buffer without markers: fixed. Elements are capped at 64 KiB; an oversized element is dropped, logged, and assembly resumes after the next marker. Test latmWithoutMarkersStaysBounded.
  • frameLengthFlag / dependsOnCoreCoder discarded: these configs are now refused, the same way as other layouts the 2-byte ASC cannot describe. Cameras send AAC-LC with 1024-sample frames, and many Android AAC decoders do not handle 960 anyway. Covered in latmRejectsLayoutsTheRtpPathCannotHandle.
  • G.726 (requirement gap): out of scope for this PR. Unsupported audio formats: MP4A-LATM, Opus, G.726, fragmented AAC #14 stays open for it, and the README says G.726 is still unsupported.

The 7 older LATM findings were fixed and answered earlier in this thread.

/review

@compot-dev

Copy link
Copy Markdown
Collaborator Author

Re LATM streams fail to produce audio: not a bug, intentional. Only cpresent=0 is accepted (cpresent=1 → codec NONE, test in AudioFormatsTest), and latmToAsc refuses any StreamMuxConfig other than one program, one layer, one subframe, allStreamsSameTimeFraming=1 and frameLengthType=0. For that layout RFC 6416 §6.1 (out-of-band config) gives AudioMuxElement(muxConfigPresent=0), which has no useSameStreamMux bit: the element is just PayloadLengthInfo (bytes of 255 plus a final byte) followed by the payload, so it is byte-aligned and splitLatm extracts the raw access units. The test latmElementAcrossPackets covers a framed element. G.726 stays in #14.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

Device-tested (#53, run 4): Opus ≈48 000 frames/s, AAC (mpeg4-generic) and L16 ≈8 000 frames/s, codec switching on the camera without reconnecting the app. CI is green. The remaining Qodo items are answered above (G.726 → #14; the LATM layout is intentional).

@compot-dev compot-dev removed the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@compot-dev
compot-dev merged commit da986a4 into v2 Oct 12, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/audio-formats branch October 13, 2026 17:15
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: Opus, MP4A-LATM and fragmented AAC audio (part of #14)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant