Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type Encoder struct {
bandwidth Bandwidth
maxBandwidth Bandwidth
silkDCBlockMem float32
stereoWidth int
}

// EncoderOption configures an Encoder during construction.
Expand Down Expand Up @@ -249,6 +250,7 @@ func NewEncoder(opts ...EncoderOption) (*Encoder, error) {
lossRate: 0,
bandwidth: BandwidthAuto,
maxBandwidth: BandwidthFullband,
stereoWidth: stereoWidthFull,
}

for _, opt := range opts {
Expand Down Expand Up @@ -384,6 +386,7 @@ func (e *Encoder) EncodeFloat32(in []float32, out []byte) (int, error) {
}

channels := splitChannels(in, e.channels, frameSamples)
e.narrowStereo(channels)

frameBytes := e.frameBytes()
if frameBytes <= 0 || frameBytes > maxOpusFrameSize {
Expand Down Expand Up @@ -564,3 +567,84 @@ func (e *Encoder) frameBytes() int {
func (e *Encoder) frameSampleCount() int {
return int(int64(celtSampleRate) * frame20msNS / 1000000000)
}

const (
// stereoWidthFull is Q14 unity: the image is left alone.
stereoWidthFull = 1 << 14
// Below stereoWidthMinRate the image is collapsed to mono; above
// stereoWidthMaxRate it is untouched. In between it narrows gradually.
stereoWidthMinRate = 16000
stereoWidthMaxRate = 32000
)

// equivalentRate expresses the configured bitrate as the rate an ideal encoder
// would need for the same quality, which is what libopus compares against its
// stereo-width and mode thresholds (compute_equiv_rate, src/opus_encoder.c).
// The frame-rate term is a no-op here because every frame is 20 ms.
func (e *Encoder) equivalentRate() int {
equiv := e.bitrate
// CBR costs about 8%.
if !e.vbr {
equiv -= equiv / 12
}
equiv = equiv * (90 + e.complexity) / 100
// Below complexity 5 CELT drops the pitch filter, worth about 10%.
if e.complexity < silkComplexityInterpolationThreshold+1 {
equiv = equiv * 9 / 10
}

return equiv
}

// stereoWidthQ14 returns how much of the stereo image to keep, in Q14.
// Mirrors the schedule in libopus opus_encode_native.
func stereoWidthQ14(equivRate int) int {
switch {
case equivRate > stereoWidthMaxRate:
return stereoWidthFull
case equivRate < stereoWidthMinRate:
return 0
default:
return stereoWidthFull - 2048*(stereoWidthMaxRate-equivRate)/(equivRate-14000)
}
}

// applyStereoFade narrows the stereo image toward mono by scaling the side
// signal, crossfading from the previous frame's width across the MDCT overlap
// so the change does not land as a step. Mirrors libopus stereo_fade
// (src/opus_encoder.c). At low bitrates the side channel is not worth its bits,
// and narrowing it beats letting the allocator starve both channels.
func applyStereoFade(left, right []float32, prevWidth, width float32, window []float32) {
g1 := 1 - prevWidth
g2 := 1 - width
overlap := min(len(window), len(left))
for i := range overlap {
w := window[i] * window[i]
g := w*g2 + (1-w)*g1
diff := 0.5 * (left[i] - right[i]) * g
left[i] -= diff
right[i] += diff
}
for i := overlap; i < len(left); i++ {
diff := 0.5 * (left[i] - right[i]) * g2
left[i] -= diff
right[i] += diff
}
}

// narrowStereo applies the low-bitrate stereo width reduction to the split
// channels and advances the width state.
func (e *Encoder) narrowStereo(channels [][]float32) {
if len(channels) != 2 {
return
}
width := stereoWidthQ14(e.equivalentRate())
if e.stereoWidth < stereoWidthFull || width < stereoWidthFull {
applyStereoFade(
channels[0], channels[1],
float32(e.stereoWidth)/stereoWidthFull, float32(width)/stereoWidthFull,
celt.OverlapWindow(),
)
}
e.stereoWidth = width
}
48 changes: 48 additions & 0 deletions encoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -861,3 +861,51 @@ func freqEnergy(samples []float32, freq float64) float64 {

return math.Sqrt(re*re+im*im) / float64(len(samples))
}

func TestStereoWidthQ14Schedule(t *testing.T) {
// Above 32 kb/s the image is untouched, below 16 kb/s it collapses to mono,
// and in between it narrows monotonically.
assert.Equal(t, stereoWidthFull, stereoWidthQ14(33000))
assert.Equal(t, 0, stereoWidthQ14(15000))

prev := 0
for rate := stereoWidthMinRate; rate <= stereoWidthMaxRate; rate += 250 {
got := stereoWidthQ14(rate)
assert.GreaterOrEqual(t, got, prev, "width must not narrow as the rate grows: rate=%d", rate)
assert.LessOrEqual(t, got, stereoWidthFull, "rate=%d", rate)
prev = got
}
}

func TestApplyStereoFadeCollapsesToMono(t *testing.T) {
// Width zero on both ends means every sample pair becomes its own average.
left := []float32{1, 1, 1, 1}
right := []float32{-1, -1, -1, -1}
applyStereoFade(left, right, 0, 0, nil)
for i := range left {
assert.InDelta(t, 0, left[i], 1e-6, "sample %d", i)
assert.InDelta(t, 0, right[i], 1e-6, "sample %d", i)
}
}

func TestApplyStereoFadeFullWidthIsIdentity(t *testing.T) {
left := []float32{0.5, -0.25, 0.75, 0}
right := []float32{-0.5, 0.25, 0, 0.75}
wantL := append([]float32(nil), left...)
wantR := append([]float32(nil), right...)
applyStereoFade(left, right, 1, 1, nil)
assert.Equal(t, wantL, left)
assert.Equal(t, wantR, right)
}

func TestApplyStereoFadeCrossfadesOverOverlap(t *testing.T) {
// Coming from full width down to mono, the first sample keeps the old
// width and the tail past the overlap is fully narrowed.
window := []float32{0, 1}
left := []float32{1, 1, 1}
right := []float32{-1, -1, -1}
applyStereoFade(left, right, 1, 0, window)
assert.InDelta(t, 1, left[0], 1e-6, "overlap starts at the previous width")
assert.InDelta(t, 0, left[1], 1e-6, "overlap ends at the new width")
assert.InDelta(t, 0, left[2], 1e-6, "past the overlap the new width applies")
}
7 changes: 7 additions & 0 deletions internal/celt/synthesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,10 @@ func minFloat32(a, b float32) float32 {

return b
}

// OverlapWindow returns the MDCT overlap window (celt_mode->window in libopus).
// The encoder-side gain crossfades in src/opus_encoder.c are shaped by it, and
// those run before the CELT layer sees the samples.
func OverlapWindow() []float32 {
return celtWindow120[:]
}
Loading