audio: InfiniteLoop.rewind does not clear the partial-value remainder, desyncing every subsequent lap
Ebitengine Version
v2.10.0-alpha.13 (main @ 4f474b00d)
Operating System
- Windows
- macOS
- Linux
- Android
- iOS
- Web Browsers
Go Version (paste your go version output)
go1.26.7 linux/amd64
What steps will reproduce the problem?
(*InfiniteLoop).rewind() (audio/loop.go:304-312) seeks the source back to lstart and resets i.pos, but it does not clear i.extra — the leftover bytes kept when a Read ended in the middle of a sample value (field comment at audio/loop.go:35-36: "extra is the remainder in the case when the read byte sizes are not multiple of the bit depth"). (*InfiniteLoop).Seek() clears extra; the automatic rewind does not.
Any source that leaves a partial value at the lap seam carries the remainder into the next lap and every sample afterwards is byte-shifted by len(extra). This happens with real streams: a loop range that extends past the source data (source ends mid-sample), or any source that returns a short read followed by (0, nil)/io.EOF right at the seam.
The following test fails on current main, i.e. the second lap is desynced (observed: buf[104] = 1, want 0):
package audio
import (
"io"
"testing"
)
type oneByteReader struct {
data []byte
pos int
}
func (r *oneByteReader) Read(b []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, io.EOF
}
b[0] = r.data[r.pos] // deliver data one byte at a time
r.pos++
return 1, nil
}
func (r *oneByteReader) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
r.pos = int(offset)
case io.SeekCurrent:
r.pos += int(offset)
case io.SeekEnd:
r.pos = len(r.data) + int(offset)
}
return int64(r.pos), nil
}
func TestInfiniteLoopRewindKeepsExtra(t *testing.T) {
// A 103-byte PCM16 source; the declared loop range is 104 bytes, one byte
// past the end of the source, so the lap ends mid-sample at io.EOF.
data := make([]byte, 103)
for i := range data {
data[i] = byte(i)
}
loop := NewInfiniteLoop(&oneByteReader{data: data}, 104)
buf := make([]byte, 208) // two full laps
if _, err := io.ReadFull(loop, buf); err != nil {
t.Fatalf("ReadFull: %v", err)
}
// The pattern is expected to repeat every 104 bytes.
for i := 104; i < 208; i++ {
if want := data[(i-104)%103]; buf[i] != want {
t.Fatalf("lap 2 desynced: buf[%d] = %d, want %d", i, buf[i], want)
}
}
}Save as e.g. audio/loop_repro_test.go and run go test ./audio -run TestInfiniteLoopRewindKeepsExtra.
What is the expected result?
The rewind returns the loop stream to exactly the same state as at construction: the byte sequence produced by the second lap is identical to the first lap, with no carry-over of the partial-value remainder.
What happens instead?
The leftover partial-value byte(s) of lap 1 are prepended to lap 2, and every sample from the loop seam onward is shifted by len(extra) bytes. With real 16-bit stereo music this sounds like a channel swap plus a 1-byte desync for the rest of the game. Nothing reports an error, so the corruption is completely silent.
Anything else you feel useful to add?
Severity rationale: InfiniteLoop is the canonical way to loop BGM in Ebitengine, and partial values at the lap seam occur with everyday inputs — decoded streams whose last value straddles the loop end, sources whose data is shorter than the declared loop length, or any source delivering short reads around the seam (the pattern the loop-seam retry loop at loop.go:171-189 was hardened for). Once desynced, the loop never recovers on its own.
Suggested fix: clear i.extra in rewind(), mirroring what Seek() already does.
Source: hajimehoshi/ebiten