Skip to content

fix(android): read volume samples in native byte order - #466

Open
darrenabridge wants to merge 1 commit into
livekit:mainfrom
darrenabridge:fix/android-volume-byte-order
Open

darrenabridge wants to merge 1 commit into
livekit:mainfrom
darrenabridge:fix/android-volume-byte-order

Conversation

@darrenabridge

Copy link
Copy Markdown

Problem

On Android, useTrackVolume reports roughly the same high level (~0.55, about −5 dBFS) for almost any audio, including quiet background noise. Only near-silent frames read low.

BaseVolumeProcessor.onData reads 16-bit PCM from the AudioTrackSink buffer with audioData.getShort() but never sets a byte order. WebRTC hands the sink a JNI direct buffer (NewDirectByteBuffer), which Java treats as big-endian by default. The samples are little-endian, so every sample is byte-swapped. For anything above a couple of LSBs, the swapped values are effectively random across the 16-bit range, and the RMS of uniformly random 16-bit samples is 1/√3 ≈ 0.577 (−4.8 dBFS).

The Android SDK already sets the order explicitly when it reads WebRTC audio buffers, for example buffer.order(ByteOrder.nativeOrder()) in MixerAudioBufferCallback.kt.

Evidence

I logged the raw useTrackVolume value for 20 seconds on a Samsung Galaxy S23 (SM-S911U1) in a normal room, on @livekit/react-native 2.9.8, with no special audio settings. That gave 1,646 readings (converted to dBFS):

dBFS readings
−3 to −6 867
−6 to −9 119
−9 to −12 72
every 3 dB bucket from −12 to −45 24 to 73 each
below −45 37

More than half the readings sit in a single 3 dB band, and none are louder than −3.3 dBFS. Real audio doesn't pile up against a wall like that; byte-swapped samples do.

Fix

Read the samples through audioData.duplicate().order(ByteOrder.nativeOrder()). duplicate() doesn't copy the audio: it gives the processor its own position and byte order on the same memory, so the sink's buffer is left untouched. That's also why mark() and reset() are no longer needed. The 8-bit case is unaffected, and the RMS and normalization math is unchanged.

Scope

  • Only Android's VolumeProcessor. iOS's VolumeAudioRenderer works on float PCM via AVAudioPCMBuffer and isn't affected.
  • FFTAudioAnalyzer, used by useMultibandTrackVolume, also calls inputBuffer.getShort() on the sink buffer without setting an order, so it may have the same issue. I left it out to keep this change small.

Testing

  • The same change, applied as a patch on 2.9.8, compiles and runs in a React Native app on the Galaxy S23.
  • Before/after levels with the patched build. I haven't captured them yet, so this PR is a draft until I add them.
  • Not built through ci/android locally.

Changeset included (patch).

useTrackVolume's Android VolumeProcessor read 16-bit PCM from WebRTC's
AudioTrackSink buffer with getShort() without setting a byte order. The
buffer is a JNI direct buffer, which Java treats as big-endian, while the
samples are little-endian, so every sample was byte-swapped and any
non-trivial audio read as roughly -5 dBFS.

Read through a duplicate() view in native order, which also leaves the
sink's buffer position and order untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
@CLAassistant

CLAassistant commented Sep 28, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@changeset-bot

changeset-bot Bot commented Sep 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 81d6d0e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@livekit/react-native Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@darrenabridge
darrenabridge marked this pull request as ready for review September 28, 2026 04:59

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

2 -> audioData.getShort().toLong()
4 -> audioData.getInt().toLong()
1 -> samples.get().toLong()
2 -> samples.getShort().toLong()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Quiet audio leaves track volume stale

When corrected samples produce zero, onVolumeCalculated emits a reading that useTrackVolume ignores. The track keeps its previous nonzero volume during near-silent frames.

Learn more

The processor emits normalized volume values for each audio buffer. The useTrackVolume listener updates state only when event.volume is truthy, so it discards zero. Correct native-order decoding can turn a low-level frame that previously decoded as nonzero into a zero RMS reading. The listener then preserves an earlier, louder reading.

Example: A 480-frame buffer containing one PCM16 sample of value 1 and otherwise zeros produces a zero RMS after native-order decoding. Before this change, swapping that sample to 256 produced a nonzero reading. If the last displayed volume was 0.5, the new zero event leaves it at 0.5.

Recommended fix: In useTrackVolume, check the event ID and tag independently of the numeric volume; accept zero as a valid reading. Add a transition test from nonzero to zero.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@davidliu

davidliu commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Hi @darrenabridge, can you sign the CLA?

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.

3 participants