Skip to content

Commit

Permalink
Implement ebitenPlayerSource.Read
Browse files Browse the repository at this point in the history
With this commit ebitengine backend is able to play any sound generated by audio.Synthesizer.
  • Loading branch information
elgopher committed Aug 27, 2023
1 parent 252e0b3 commit 22a6fbe
Show file tree
Hide file tree
Showing 2 changed files with 217 additions and 6 deletions.
69 changes: 63 additions & 6 deletions ebitengine/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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() {
Expand All @@ -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) {
Expand Down
154 changes: 154 additions & 0 deletions ebitengine/audio_test.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 22a6fbe

Please sign in to comment.