From 22a6fbe4de3b840a9a173336455a60185de6aa8d Mon Sep 17 00:00:00 2001 From: Jacek Olszak Date: Sun, 27 Aug 2023 15:08:52 +0200 Subject: [PATCH] Implement ebitenPlayerSource.Read With this commit ebitengine backend is able to play any sound generated by audio.Synthesizer. --- ebitengine/audio.go | 69 ++++++++++++++++-- ebitengine/audio_test.go | 154 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 ebitengine/audio_test.go diff --git a/ebitengine/audio.go b/ebitengine/audio.go index 64437b2..faaae30 100644 --- a/ebitengine/audio.go +++ b/ebitengine/audio.go @@ -10,11 +10,13 @@ import ( ebitenaudio "github.com/hajimehoshi/ebiten/v2/audio" + "github.com/elgopher/pi" "github.com/elgopher/pi/audio" ) const ( audioSampleRate = 44100 + channelCount = 2 // stereo ) // AudioStream is an abstraction used by ebitengine back-end to consume audio stream generated by the game. The stream @@ -48,7 +50,7 @@ func startAudio() (stop func(), _ error) { if err != nil { return stop, fmt.Errorf("problem saving audio state: %w", err) } - synth := audio.Synthesizer{} + synth := &audio.Synthesizer{} if err = synth.Load(state); err != nil { return stop, fmt.Errorf("problem loading audio state: %w", err) } @@ -63,7 +65,7 @@ func startAudio() (stop func(), _ error) { if err != nil { return func() {}, err } - player.SetBufferSize(23 * time.Millisecond) + player.SetBufferSize(60 * time.Millisecond) player.Play() return func() { @@ -77,14 +79,69 @@ func startAudio() (stop func(), _ error) { // Therefore, it can be called concurrently by Ebitegine and the game loop. type ebitenPlayerSource struct { mutex sync.Mutex - audioSystem audio.Synthesizer + audioSystem interface { + audio.AudioSystem + ReadSamples(p []float64) + } + + singleSample []byte // singleSample in Ebitengine format - first two bytes left channel, next two bytes right + remainingBytes int // number of bytes from singleSample still not copied to p + floatBuffer []float64 // reused buffer to avoid allocation on each Read request } // reads floats from AudioStream and convert them to Ebitengine format - // linear PCM (signed 16bits little endian, 2 channel stereo). -func (e *ebitenPlayerSource) Read(p []byte) (n int, err error) { - // silence for now :( - return len(p), nil +func (e *ebitenPlayerSource) Read(p []byte) (int, error) { + const ( + uint16Bytes = 2 + sampleLen = channelCount * uint16Bytes + ) + + if len(p) == 0 { + return 0, nil + } + + if e.remainingBytes > 0 { + n := copy(p, e.singleSample[sampleLen-e.remainingBytes:]) + e.remainingBytes = 0 + return n, nil + } + + if e.singleSample == nil { + e.singleSample = make([]byte, sampleLen) + } + + samples := len(p) / sampleLen + if len(p)%sampleLen != 0 { + samples += 1 + e.remainingBytes = sampleLen - len(p)%sampleLen + } + + e.ensureFloatBufferIsBigEnough(samples) + + bytesRead := 0 + + e.audioSystem.ReadSamples(e.floatBuffer[:samples]) + for i := 0; i < samples; i++ { + floatSample := pi.Mid(e.floatBuffer[i], -1, 1) + sample := int16(floatSample * 0x7FFF) // actually the full int16 range is -0x8000 to 0x7FFF (therefore -0x8000 will never be returned) + + e.singleSample[0] = byte(sample) + e.singleSample[1] = byte(sample >> 8) + copy(e.singleSample[2:], e.singleSample[:2]) // copy left to right channel + + copiedBytes := copy(p, e.singleSample) + p = p[copiedBytes:] + bytesRead += copiedBytes + } + + return bytesRead, nil +} + +func (e *ebitenPlayerSource) ensureFloatBufferIsBigEnough(size int) { + if size > len(e.floatBuffer) { + e.floatBuffer = make([]float64, size) + } } func (e *ebitenPlayerSource) ReadSamples(b []float64) { diff --git a/ebitengine/audio_test.go b/ebitengine/audio_test.go new file mode 100644 index 0000000..2f22d9c --- /dev/null +++ b/ebitengine/audio_test.go @@ -0,0 +1,154 @@ +// (c) 2022-2023 Jacek Olszak +// This code is licensed under MIT license (see LICENSE for details) + +package ebitengine //nolint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elgopher/pi/audio" +) + +func TestEbitenPlayerSource_Read(t *testing.T) { + t.Run("should read 0 samples when buffer is empty", func(t *testing.T) { + reader := &ebitenPlayerSource{} + n, err := reader.Read([]byte{}) + require.NoError(t, err) + assert.Equal(t, n, 0) + }) + + t.Run("should convert floats to linear PCM (signed 16bits little endian, 2 channel stereo).", func(t *testing.T) { + reader := &ebitenPlayerSource{ + audioSystem: &audioSystemMock{buffer: []float64{-1, 0, 1, -0.5, 0.5}}, + } + actual := make([]byte, 20) + // when + n, err := reader.Read(actual) + // then + require.NoError(t, err) + assert.Equal(t, 20, n) + assert.Equal(t, []byte{ + 1, 0x80, // -1, left channel, second byte, 7 bit has sign bit + 1, 0x80, // right channel - copy of left channel + 0, 0, // 0 + 0, 0, // 0 + 0xFF, 0x7F, // 1 + 0xFF, 0x7F, // 1 + 1, 0xC0, // -0.5 + 1, 0xC0, // -0.5 + 0xFF, 0x3F, // 0.5 + 0xFF, 0x3F, // 0.5 + }, actual) + }) + + t.Run("should continue reading stream using bigger buffer than before", func(t *testing.T) { + reader := &ebitenPlayerSource{ + audioSystem: &audioSystemMock{buffer: []float64{1, -0.5, 0.5}}, + } + smallBuffer := make([]byte, 4) + n, err := reader.Read(smallBuffer) + require.Equal(t, 4, n) + require.NoError(t, err) + biggerBuffer := make([]byte, 8) + // when + n, err = reader.Read(biggerBuffer) + // then + assert.Equal(t, 8, n) + require.NoError(t, err) + assert.Equal(t, []byte{ + 1, 0xC0, // -0.5 + 1, 0xC0, // -0.5 + 0xFF, 0x3F, // 0.5 + 0xFF, 0x3F, // 0.5 + }, biggerBuffer) + }) + + t.Run("should clamp float values to [-1,1]", func(t *testing.T) { + reader := &ebitenPlayerSource{ + audioSystem: &audioSystemMock{buffer: []float64{-2, 2}}, + } + actual := make([]byte, 8) + // when + n, err := reader.Read(actual) + // then + require.NoError(t, err) + assert.Equal(t, 8, n) + assert.Equal(t, []byte{ + 1, 0x80, // -2 clamped to -1 + 1, 0x80, + 0xFF, 0x7F, // 2 clamped to 1 + 0xFF, 0x7F, + }, actual) + }) + + t.Run("should convert floats even when buffer is too small", func(t *testing.T) { + reader := &ebitenPlayerSource{ + audioSystem: &audioSystemMock{buffer: []float64{1, -1}}, + } + actual := make([]byte, 8) + + for i := 0; i < 8; i += 2 { + n, err := reader.Read(actual[i : i+2]) + require.NoError(t, err) + assert.Equal(t, 2, n) + } + + assert.Equal(t, []byte{ + 0xFF, 0x7F, // 1 + 0xFF, 0x7F, + 1, 0x80, // -1 + 1, 0x80, + }, actual) + }) + + t.Run("should only read the minimum number of floats", func(t *testing.T) { + mock := &audioSystemMock{buffer: []float64{0, 1, -1, 0}} + reader := &ebitenPlayerSource{ + audioSystem: mock, + } + n, err := reader.Read(make([]byte, 8)) // read 2 samples first + require.NoError(t, err) + require.Equal(t, 8, n) + n, err = reader.Read(make([]byte, 4)) // read 1 sample only + require.NoError(t, err) + require.Equal(t, 4, n) + assert.Len(t, mock.buffer, 1, "one float in the buffer should still be available for reading") + }) +} + +type audioSystemMock struct { + buffer []float64 +} + +func (m *audioSystemMock) ReadSamples(buffer []float64) { + n := copy(buffer, m.buffer) + m.buffer = m.buffer[n:] +} + +func (m *audioSystemMock) Sfx(sfxNo int, channel audio.Channel, offset, length int) {} +func (m *audioSystemMock) Music(patterNo int, fadeMs int, channelMask byte) {} + +func (m *audioSystemMock) Stat() audio.Stat { + return audio.Stat{} +} + +func (m *audioSystemMock) SetSfx(sfxNo int, e audio.SoundEffect) {} +func (m *audioSystemMock) GetSfx(sfxNo int) audio.SoundEffect { + return audio.SoundEffect{} +} + +func (m *audioSystemMock) SetMusic(patternNo int, _ audio.Pattern) {} +func (m *audioSystemMock) GetMusic(patterNo int) audio.Pattern { + return audio.Pattern{} +} + +func (m *audioSystemMock) Save() ([]byte, error) { + return nil, nil +} + +func (m *audioSystemMock) Load(bytes []byte) error { + return nil +}