Skip to content

Narrow the stereo image at low bitrate - #201

Merged
thomas-vilte merged 1 commit into
pion:mainfrom
thomas-vilte:feat/stereo-width
Aug 12, 2026
Merged

Narrow the stereo image at low bitrate#201
thomas-vilte merged 1 commit into
pion:mainfrom
thomas-vilte:feat/stereo-width

Conversation

@thomas-vilte

Copy link
Copy Markdown
Member

Description

libopus narrows the stereo image when the bitrate isn't high enough to afford full stereo (src/opus_encoder.c, inside opus_encode_native):

if (equiv_rate > 32000)      st->silk_mode.stereoWidth_Q14 = 16384;
else if (equiv_rate < 16000) st->silk_mode.stereoWidth_Q14 = 0;
else st->silk_mode.stereoWidth_Q14 = 16384 - 2048*(opus_int32)(32000-equiv_rate)/(equiv_rate-14000);
...
stereo_fade(pcm_buf, pcm_buf, g1, g2, celt_mode->overlap, frame_size, st->channels, celt_mode->window, st->Fs);

stereo_fade scales the side signal: at width 0, each pair of samples becomes their average, effectively turning the input into mono. The transition between the previous and new width is weighted by the MDCT window, so a bitrate change doesn't introduce a hard step.

pion didn't have any of this. This PR ports the three pieces: compute_equiv_rate (the part used by the CELT-only path), the stereo-width calculation, and stereo_fade.

This matters at lower bitrates because the side channel doesn't pay for itself. Narrowing the stereo image deliberately is better than letting the allocator spread too few bits across both channels.

How I found it

I was chasing an alloc_trim mismatch that only showed up at 24 and 32 kbps (72.6% and 95.5% agreement, compared to 99.8% from 48 kbps up). The inter-channel correlation term in the trim wasn't matching, and I found that libopus's X value changed with bitrate while pion's didn't: all 400 frames differed at 24 kbps and none did at 96 kbps.

I disabled the prefilter on both sides and ruled out the band boundaries. The raw MDCT was already different on the first frame, which put the problem before CELT itself — and that's where I found the stereo fade.

The numbers line up: with CBR and complexity 5, compute_equiv_rate gives 20900 at 24 kbps and 27867 at 32 kbps, both inside the transition range. At 48 kbps it gives 41800, already above 32000, which is exactly where the disagreement disappeared.

Measurement

Frame-by-frame agreement against opus_demo restricted-celt, 3000 music frames per bitrate:

bitrate trim before trim after transient tf_change pre-filter
24000 72.6% 99.0% 99.8% → 100% 98.5% → 100% 98.1% → 99.7%
32000 95.5% 99.5% 100% 98.9% → 100% 99.3% → 99.7%
48000 99.8% 99.8% 100% 100% 99.7%
96000 99.9% 99.9% 100% 99.7% 99.7%

The effect carries into transient analysis, TF analysis, and the prefilter because all of them operate on the same input signal.

Round-trip SNR against libopus at the same bitrate:

bitrate pion libopus gap
24000 7.0676 7.0540 +0.014
32000 8.4080 8.3973 +0.011
48000 10.1179 10.1027 +0.015
96000 15.0721 15.0736 −0.002

One thing worth noting: the fade changes the signal going into the codec, so SNR against the original naturally drops compared to not applying it. That's inherent to the feature, and libopus does the same. The useful comparison here is against the reference, not against the pre-change encoder.

Tests

Four tests cover the stereo-width scaling (monotonicity and both extremes), full width as an identity operation, zero width collapsing to mono, and the crossfade starting at the old width and ending at the new one.

Reference issue

Part of #34.

@thomas-vilte
thomas-vilte requested a review from FrantaBOT August 11, 2026 23:00
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.55556% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.11%. Comparing base (a38c746) to head (19dacd6).

Files with missing lines Patch % Lines
encoder.go 95.34% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #201      +/-   ##
==========================================
+ Coverage   93.10%   93.11%   +0.01%     
==========================================
  Files          58       58              
  Lines       10263    10308      +45     
==========================================
+ Hits         9555     9598      +43     
- Misses        500      501       +1     
- Partials      208      209       +1     
Flag Coverage Δ
go 93.11% <95.55%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thomas-vilte
thomas-vilte merged commit 01ff3c4 into pion:main Aug 12, 2026
20 checks passed
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.

2 participants