HYBRID + DTX: CELT is encoded then discarded on silent frames in 1.6.1 (regression vs 1.5.2)
Nobody has claimed this yet.
- Dominant language
- C
- Stars
- 3.3k
- Forks
- 808
- PR merge metrics
- No merged PRs in 30d
Description
Summary
We are migrating our voice application from libopus 1.5.2 to 1.6.1. With the same encoder configuration (HYBRID, SWB, DTX + InbandFEC, DRED disabled), 1.6.1 uses noticeably more CPU on prolonged silence / muted input than 1.5.2.
The reason looks straightforward from the source: on each silent HYBRID frame 1.6.1 still runs the full CELT encode and only then calls decide_dtx_mode, which discards the CELT bitstream and emits a 1-byte DTX packet. In 1.5.2 the same frames stopped right after SILK via if (nBytes==0) return 1; and never entered CELT.
This appears to be a side effect of unifying the generalized DTX path across float / fixed / low-complexity builds, not an intentional design decision about CELT. We'd like to confirm with the maintainers before sending a patch.
Repro
opus_demo-equivalent invocation we use (DRED off):
opus_demo voip 48000 1 60000 \
-bandwidth SWB -complexity 5 \
-inbandfec -dtx \
-dec_complexity 0 -loss 100 \
Silence.pcm Silence_out.pcm
- Input: 60 s of digital silence, mono, 48 kHz, 16-bit PCM.
- With this config the encoder selects
MODE_HYBRID. - We measure encoder-side CPU averaged over the run.
| Build (fixed-point) | CPU on silence (relative) | DTX path actually taken |
|---|---|---|
| 1.5.2 | 1.0× | SILK-internal DTX, early return 1 right after silk_Encode |
| 1.6.1 | substantially higher (machine-dependent) | Generalized DTX: SILK + full CELT encode, then drop the packet |
The point is that on these frames 1.6.1's work is a strict superset of 1.5.2's.
Where the change came from
Two commits landed together on 2025-11-12:
ad854445— "Fix activity when no analysis (fixes DRED and DTX)". Adds theVAD_NO_DECISIONfallback that derivesactivityfromsilk_mode.signalTypeaftersilk_Encode, and removes the#ifndef DISABLE_FLOAT_APIguards arounddecide_dtx_modeand the activity/peak-energy block.285ef69e— "Use silence detection even with float_api off". Extendsis_silenceanduseDTX = use_dtx && !is_silenceto theDISABLE_FLOAT_APIbuild.
The stated goal (making generalized DTX work for builds and complexity levels where analysis_info is unavailable) makes sense. The side effect on the encode-on-silence cost just doesn't appear to have been discussed in either commit message.
What the regression looks like in the code
Current src/opus_encoder.c on main (same as 1.6.1):
silk_Encoderuns (around line 2213). For HYBRID at low SILK rate over long silence,useDTXis now gated byis_silence, sosilk_mode.useDTXends up false and SILK does not stop early;nBytes != 0, so the earlyreturn 1just aftersilk_Encodeis not taken.- The full CELT pipeline runs (analysis, prefilter, MDCT, bit allocation, range encoding).
- Only after CELT, around line 2566, the generalized DTX decision is made:
/* DTX decision */
if (st->use_dtx && !st->silk_mode.useDTX)
{
if (decide_dtx_mode(activity, &st->nb_no_activity_ms_Q1,
2*1000*frame_size/st->Fs))
{
st->rangeFinal = 0;
data[0] = gen_toc(st->mode, st->Fs/frame_size,
curr_bandwidth, st->stream_channels);
RESTORE_STACK;
return 1;
}
}
When this triggers, the CELT bitstream produced above is thrown away.
In 1.5.2 fixed-point, silk_mode.useDTX = st->use_dtx; unconditionally, so SILK's own DTX returned nBytes == 0 and the encoder exited before CELT.
Questions for the maintainers
-
Was this extra encode-on-silence cost (full CELT per silent HYBRID frame, only to be dropped) considered when the DTX/activity path was unified in
ad854445/285ef69e? The commit messages don't mention it, so we'd like to confirm whether it's intentional or just a consequence of the refactor. -
Is there a reason
decide_dtx_modemust be called after CELT? From reading the diffs the only hard data dependency is onactivity, which is now finalized right aftersilk_Encode(via theVAD_NO_DECISIONfallback). It looks like the decision could be hoisted to just after that fallback, with an earlyreturn 1short-circuiting the CELT block. -
If you would accept a patch that:
- calls
decide_dtx_modeimmediately after the post-SILKactivityfixup, and - on a positive DTX decision, skips the CELT encode and returns the same 1-byte packet,
are there CELT-internal state concerns we should worry about for the first non-silent frame after a long silence (prefilter memory, MDCT overlap, energy prediction, mode-transition behavior)? We can run subjective and objective tests against the standard testvectors, but it would help to know upfront if there's a known transient artifact this would expose.
- calls
Happy to send a draft patch and benchmark numbers if there's interest.
Thanks!
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/opus_encoder.c around the post-silk activity fixup, the early return, and the generalized DTX decision near line 2566. Reproduce the case with the listed opus_demo command, then inspect commits ad854445 and 285ef69e and relevant encoder tests or standard testvectors. Done means measuring the silence CPU cost and verifying packet output and first non-silent-frame behavior without regressions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- audio-video-rtc
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100