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.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] diff --git a/internal/mux/mux_test.go b/internal/mux/mux_test.go index e6f1ef8..51a5e00 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,72 @@ 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. + { + name: "stereo with one sample", + channelCount: 2, + src: signedInt16LEBytes(half), + }, + // A stereo player with a partial frame left in its buffer. + { + 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. + { + 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) + 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