Skip to content

mux: fix the volume ramp dividing integers and going NaN - #296

Open
kumagi wants to merge 3 commits into
ebitengine:mainfrom
kumagi:oto-mux-volume-ramp-nan
Open

mux: fix the volume ramp dividing integers and going NaN#296
kumagi wants to merge 3 commits into
ebitengine:mainfrom
kumagi:oto-mux-volume-ramp-nan

Conversation

@kumagi

@kumagi kumagi commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What issue is this addressing?

No issue filed yet. Found during an audit of the mux package.

What type of issue is this addressing?

bug

What this PR does | solves

(*playerImpl).readBufferAndAdd computed the volume ramp denominator as:

rateDenom := float32(n / channelCount) // integer division

n is the number of samples to mix and channelCount the number of channels. When a playing buffer holds fewer samples than channels (n < channelCount, e.g. a stereo player with a single sample left in its buffer — realistic with a small SetBufferSize), n / channelCount is 0, so rateDenom is 0.

During a volume transition (volume != prevVolume), the ramp rate is then:

rate := float32(i/channelCount) / rateDenom // = x/0 = +Inf, and 0/0 = NaN for i=0

volume*Inf + prevVolume*(-Inf) is NaN, so every sample of the ramp becomes NaN. The mixing loop drops non-finite values to protect other players, so the entire ramp is dropped and the player outputs silence until the next SetVolume.

Reproduction

The following test passes on main (demonstrating the bug) and fails without this change:

package mux

import (
	"math"
	"testing"
	"time"
)

type loudReader struct{}

func (loudReader) Read(p []byte) (int, error) {
	for i := range p {
		if i%4 < 2 {
			p[i] = 0xff // 16-bit stereo, 0x7fff = 0.99997
		} else {
			p[i] = 0x7f
		}
	}
	return len(p), nil
}

func TestVolumeRampNaN(t *testing.T) {
	m := New(48000, 2, FormatSignedInt16LE)
	p := m.NewPlayer(loudReader{})
	p.SetBufferSize(4) // one stereo sample
	p.Play()
	deadline := time.Now().Add(5 * time.Second)
	for p.BufferedSize() < 4 {
		if time.Now().After(deadline) {
			t.Fatal("buffer never filled")
		}
		time.Sleep(time.Millisecond)
	}
	p.SetVolume(0.5) // while playing: prevVolume (1) != volume (0.5)
	buf := make([]float32, 2)
	m.ReadFloat32s(buf)
	for i, v := range buf {
		if v == 0 || math.IsNaN(float64(v)) {
			t.Errorf("buf[%d] = %v: sample dropped by NaN ramp", i, v)
		}
	}
}

On main, both samples come out as 0 (dropped). With this PR, they are ±0.49998… as expected.

The fix

Divide floats:

rateDenom := float32(n) / float32(channelCount)

The float32(i/channelCount) numerator is left as is: it is a per-frame index and the integer division there is intentional.

rateDenom was computed as float32(n / channelCount), an integer
division. When a playing buffer had fewer samples left than the number
of channels (e.g. one stereo sample with SetBufferSize), the denominator
became 0 and the ramp rate 0/0 = NaN, so every sample of the ramp was
dropped as non-finite and the player went silent until the next
SetVolume. Divide floats instead.

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we have tests?

…channels

The ramp denominator must be computed with float division, or the ramp
rate becomes NaN when a player holds fewer samples than channels and
the whole ramp is dropped, silencing the player. Add a deterministic
unit test covering stereo and quadrophonic players with a partial
frame left in the buffer, which fails with the integer division.
@kumagi

kumagi commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I added a test as requested: TestVolumeRampWithFewerSamplesThanChannelsDoesNotSilence in internal/mux/mux_test.go. It deterministically exercises readBufferAndAdd (via a small export_test.go helper that primes a player with buffered data and a volume ramp) for a stereo player with a single sample or a partial frame left in its buffer, and for a quadrophonic player with a single sample. Each mixed sample is asserted to be finite and within the expected ramp range, so the test fails with the old integer division (the ramp becomes NaN and the samples are dropped to silence) and passes with the float division.

Comment thread internal/mux/mux_test.go Outdated
@kumagi

kumagi commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I updated the test so the table entries use explicit member names, written across multiple lines, instead of positional literals. The behavior is unchanged: the test still fails with the old float32(n / channelCount) (the ramp becomes NaN and every sample is dropped to silence) and passes with the float division. go build ./..., go vet ./..., gofmt, and go test ./internal/mux/ are all clean.

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