From a9285cced79dd406b9256a409e075b1035962826 Mon Sep 17 00:00:00 2001 From: Hiroki KUMAZAKI Date: Fri, 11 Sep 2026 03:00:40 +0900 Subject: [PATCH 1/3] mux: fix the volume ramp dividing integers and going NaN 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. --- internal/mux/mux.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/mux/mux.go b/internal/mux/mux.go index 1ae6165..cfa8244 100644 --- a/internal/mux/mux.go +++ b/internal/mux/mux.go @@ -488,7 +488,10 @@ func (p *playerImpl) readBufferAndAdd(buf []float32) int { volume := float32(p.volume) channelCount := p.mux.channelCount - rateDenom := float32(n / channelCount) + // The division must be done on floats, or a buffer with fewer samples than + // channels makes rateDenom 0 and the ramp rate 0/0, i.e. NaN, silencing the + // whole ramp. + rateDenom := float32(n) / float32(channelCount) src := p.buf[:n*bitDepthInBytes] From 9f13d266bfeb37cd8d9bd373f965d0a8ae4a033c Mon Sep 17 00:00:00 2001 From: Hiroki KUMAZAKI Date: Sat, 12 Sep 2026 23:24:20 +0900 Subject: [PATCH 2/3] mux: add a test for the volume ramp with fewer buffered samples than 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. --- internal/mux/export_test.go | 18 ++++++++++++ internal/mux/mux_test.go | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/internal/mux/export_test.go b/internal/mux/export_test.go index 91e3f3b..92275cd 100644 --- a/internal/mux/export_test.go +++ b/internal/mux/export_test.go @@ -21,3 +21,21 @@ func (p *Player) IsRegistered() bool { _, ok := p.p.mux.players[p.p] return ok } + +// PrimeForMixing puts the player into the playing state with buf as its buffered +// source data and a volume ramp from prevVolume to the current volume, without +// registering the player with the mux. It is used to test the mixing directly. +func (p *Player) PrimeForMixing(buf []byte, prevVolume float64) { + p.p.m.Lock() + defer p.p.m.Unlock() + + p.p.state = playerPlay + p.p.buf = buf + p.p.prevVolume = prevVolume +} + +// ReadBufferAndAdd mixes the buffered source data into buf and returns the number +// of mixed samples. +func (p *Player) ReadBufferAndAdd(buf []float32) int { + return p.p.readBufferAndAdd(buf) +} diff --git a/internal/mux/mux_test.go b/internal/mux/mux_test.go index e6f1ef8..ebe23d6 100644 --- a/internal/mux/mux_test.go +++ b/internal/mux/mux_test.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "math" + "slices" "sync" "sync/atomic" "testing" @@ -635,6 +636,60 @@ func TestInvalidVolumeWhilePlayingIsRecoverable(t *testing.T) { } } +// signedInt16LEBytes returns the little-endian 16-bit representation of the given values. +func signedInt16LEBytes(values ...int16) []byte { + bs := make([]byte, 0, 2*len(values)) + for _, v := range values { + bs = append(bs, byte(v), byte(v>>8)) + } + return bs +} + +// A volume ramp must not be dropped when fewer samples than the channel count +// are buffered. With an integer division for the ramp denominator, the +// denominator became 0 and the whole ramp became NaN, silencing the player. +func TestVolumeRampWithFewerSamplesThanChannelsDoesNotSilence(t *testing.T) { + const half = 1 << 14 + + for _, tc := range []struct { + name string + channelCount int + src []byte + }{ + // A stereo player with a single sample left in its buffer. + {"stereo with one sample", 2, signedInt16LEBytes(half)}, + // A stereo player with a partial frame left in its buffer. + {"stereo with a partial frame", 2, signedInt16LEBytes(half, half, half)[:3]}, + // A quadrophonic player with a single sample left in its buffer. + {"quad with one sample", 4, signedInt16LEBytes(half)}, + } { + t.Run(tc.name, func(t *testing.T) { + m := mux.New(48000, tc.channelCount, mux.FormatSignedInt16LE) + p := newPlayer(t, m, &bytes.Reader{}) + p.SetVolume(0.5) + + // Ramp from the volume 1 to the volume 0.5. + p.PrimeForMixing(slices.Clone(tc.src), 1) + + buf := make([]float32, len(tc.src)/mux.FormatSignedInt16LE.ByteLength()) + if got, want := p.ReadBufferAndAdd(buf), len(buf); got != want { + t.Fatalf("mixed samples: got %d; want %d", got, want) + } + + for i, got := range buf { + if math.IsNaN(float64(got)) { + t.Fatalf("buf[%d]: got NaN; want a finite value", i) + } + // The sample source is 0.5 and the volume is ramped from 1 to 0.5, + // so every mixed sample must lie in [0.25, 0.5]. + if got < 0.25 || got > 0.5 { + t.Errorf("buf[%d]: got %v; want a value in [0.25, 0.5]", i, got) + } + } + }) + } +} + func TestVolumeLargerThanOneAmplifies(t *testing.T) { const sampleCount = 256 From e0c90e60d2327bf876e6442a8010c820984dd1b8 Mon Sep 17 00:00:00 2001 From: Hiroki KUMAZAKI Date: Sun, 13 Sep 2026 01:26:14 +0900 Subject: [PATCH 3/3] internal/mux: use explicit member names for the volume ramp test cases --- internal/mux/mux_test.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/mux/mux_test.go b/internal/mux/mux_test.go index ebe23d6..51a5e00 100644 --- a/internal/mux/mux_test.go +++ b/internal/mux/mux_test.go @@ -657,11 +657,23 @@ func TestVolumeRampWithFewerSamplesThanChannelsDoesNotSilence(t *testing.T) { src []byte }{ // A stereo player with a single sample left in its buffer. - {"stereo with one sample", 2, signedInt16LEBytes(half)}, + { + name: "stereo with one sample", + channelCount: 2, + src: signedInt16LEBytes(half), + }, // A stereo player with a partial frame left in its buffer. - {"stereo with a partial frame", 2, signedInt16LEBytes(half, half, half)[:3]}, + { + name: "stereo with a partial frame", + channelCount: 2, + src: signedInt16LEBytes(half, half, half)[:3], + }, // A quadrophonic player with a single sample left in its buffer. - {"quad with one sample", 4, signedInt16LEBytes(half)}, + { + name: "quad with one sample", + channelCount: 4, + src: signedInt16LEBytes(half), + }, } { t.Run(tc.name, func(t *testing.T) { m := mux.New(48000, tc.channelCount, mux.FormatSignedInt16LE)