Skip to content

Commit

Permalink
Change AudioStream to []float64
Browse files Browse the repository at this point in the history
  • Loading branch information
elgopher committed Dec 22, 2022
1 parent 60ccce6 commit c8fd637
Show file tree
Hide file tree
Showing 4 changed files with 199 additions and 22 deletions.
11 changes: 6 additions & 5 deletions audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ type Note struct {
Volume byte // 0 - quiet. 255 - loudest. 255 values is way too much!
}

// Read method is used by back-end to read generated audio stream and play it back to the user. The sample rate is 44100,
// 16 bit depth and stereo (2 audio channels).
// Read method is used by back-end to read generated audio stream and play it back to the user. The sample rate is 44100
// and mono.
//
// Read is (usually) executed concurrently with main game loop. Back-end could decide about buffer size, although
// the higher the size the higher the lag. Usually the buffer is 8KB, which is 46ms of audio.
func (s *AudioSystem) Read(p []byte) (n int, err error) {
// the higher the size the higher the lag. Usually the buffer is 2KB, which is roughly 46ms of audio (if the slice is converted
// to PCM signed 16bit integer with two channels - stereo).
func (s *AudioSystem) Read(p []float64) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
Expand All @@ -88,7 +89,7 @@ func (s *AudioSystem) Read(p []byte) (n int, err error) {
}

for i := 0; i < len(p); i++ {
p[i] = byte(i)
p[i] = float64(i)
}
}

Expand Down
14 changes: 7 additions & 7 deletions audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ func TestAudioSystem_Read(t *testing.T) {
})

t.Run("should clear the buffer with 0 when no channels are used", func(t *testing.T) {
buffer := []byte{1, 2, 3, 4}
buffer := []float64{1, 2, 3, 4}
n, err := pi.Audio().Read(buffer)
require.NotZero(t, n)
require.NoError(t, err)
expected := make([]byte, n)
expected := make([]float64, n)
assert.Equal(t, expected, buffer)
})
}
Expand All @@ -41,7 +41,7 @@ func TestAudio(t *testing.T) {
audio.Set(0, pi.SoundEffect{})
_ = audio.Get(0)
audio.Play(0, 0, 0, 0)
_, err := audio.Read(make([]byte, 8192))
_, err := audio.Read(make([]float64, 8192))
require.NoError(t, err)
audio.Reset()
wg.Done()
Expand Down Expand Up @@ -80,10 +80,10 @@ func TestAudioSystem_Play(t *testing.T) {
// when
audio.Play(0, 0, 0, 1)
// then
buffer := make([]byte, 1500)
buffer := make([]float64, 1500)
_, err := audio.Read(buffer)
require.NoError(t, err)
assert.NotEqual(t, make([]byte, 1500), buffer)
assert.NotEqual(t, make([]float64, 1500), buffer)
})

t.Run("should not generate audio stream when channel is higher than max", func(t *testing.T) {
Expand All @@ -93,9 +93,9 @@ func TestAudioSystem_Play(t *testing.T) {
// when
audio.Play(0, 4, 0, 1)
// then
buffer := make([]byte, 1500)
buffer := make([]float64, 1500)
_, err := audio.Read(buffer)
require.NoError(t, err)
assert.Equal(t, make([]byte, 1500), buffer)
assert.Equal(t, make([]float64, 1500), buffer)
})
}
73 changes: 63 additions & 10 deletions ebitengine/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (

const (
audioSampleRate = 44100
channelCount = 2 // stereo
uint16Bytes = 2
sampleLen = channelCount * uint16Bytes
)

// AudioStream is an abstraction used by ebitengine back-end to consume audio stream generated by the game. The stream
Expand All @@ -19,18 +22,12 @@ const (
var AudioStream interface {
// Read reads generated audio into p buffer.
//
// The format is:
// [data] = [sample 0] [sample 1] [sample 2] ...
// [sample *] = [channel left] [channel right] ...
// [channel *] = [bits 0-15]
//
// Sample rate must be 44100, channel count 2 (stereo) and bit depth 16.
//
// Byte ordering is little endian.
// The audio stream is mono (one channel). Each sample is a single float64 from range [-1,1].
// Values outside the range will be clamped before playing back to the user. Sample rate is 44100.
//
// See [io.Reader] for documentation how to implement this method.
// When error is returned then game stops with the error.
Read(p []byte) (n int, err error)
Read(p []float64) (n int, err error)
}

func startAudio() (stop func(), _ error) {
Expand All @@ -39,7 +36,7 @@ func startAudio() (stop func(), _ error) {
}

audioCtx := audio.NewContext(audioSampleRate)
player, err := audioCtx.NewPlayer(AudioStream)
player, err := audioCtx.NewPlayer(&audioStreamReader{})
if err != nil {
return func() {}, err
}
Expand All @@ -49,3 +46,59 @@ func startAudio() (stop func(), _ error) {
_ = player.Close()
}, nil
}

// audioStreamReader reads floats from AudioStream and convert them to Ebitengine format -
// linear PCM (signed 16bits little endian, 2 channel stereo).
type audioStreamReader struct {
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
}

func (a *audioStreamReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}

if a.remainingBytes > 0 {
n := copy(p, a.singleSample[sampleLen-a.remainingBytes:])
a.remainingBytes = 0
return n, nil
}

if a.singleSample == nil {
a.singleSample = make([]byte, sampleLen)
}

samples := len(p) / sampleLen
if len(p)%sampleLen != 0 {
samples += 1
a.remainingBytes = sampleLen - len(p)%sampleLen
}

a.ensureFloatBufferIsBigEnough(samples)

bytesRead := 0

n, err := AudioStream.Read(a.floatBuffer[:samples])
for i := 0; i < n; i++ {
floatSample := pi.Mid(a.floatBuffer[i], -1, 1)
sample := int16(floatSample * 0x7FFF) // actually the full int16 range is -0x8000 to 0x7FFF (therefore -0x8000 will never be returned)

a.singleSample[0] = byte(sample)
a.singleSample[1] = byte(sample >> 8)
copy(a.singleSample[2:], a.singleSample[:2]) // copy left to right channel

copiedBytes := copy(p, a.singleSample)
p = p[copiedBytes:]
bytesRead += copiedBytes
}

return bytesRead, err
}

func (a *audioStreamReader) ensureFloatBufferIsBigEnough(size int) {
if size > len(a.floatBuffer) {
a.floatBuffer = make([]float64, size)
}
}
123 changes: 123 additions & 0 deletions ebitengine/audio_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// (c) 2022 Jacek Olszak
// This code is licensed under MIT license (see LICENSE for details)

package ebitengine //nolint:testpackage

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAudioStreamReader_Read(t *testing.T) {
t.Run("should read 0 samples when buffer is empty", func(t *testing.T) {
reader := &audioStreamReader{}
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) {
AudioStream = &fakeAudioStream{buffer: []float64{-1, 0, 1, -0.5, 0.5}}
reader := &audioStreamReader{}
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) {
AudioStream = &fakeAudioStream{buffer: []float64{1, -0.5, 0.5}}
reader := &audioStreamReader{}
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) {
AudioStream = &fakeAudioStream{buffer: []float64{-2, 2}}
reader := &audioStreamReader{}
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) {
AudioStream = &fakeAudioStream{buffer: []float64{1, -1}}
reader := &audioStreamReader{}
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) {
fakeStream := fakeAudioStream{buffer: []float64{0, 1, -1, 0}}
AudioStream = &fakeStream
reader := &audioStreamReader{}
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, fakeStream.buffer, 1, "one float in the buffer should still be available for reading")
})
}

type fakeAudioStream struct {
buffer []float64
}

func (s *fakeAudioStream) Read(p []float64) (n int, err error) {
n = copy(p, s.buffer)
s.buffer = s.buffer[n:]
return n, nil
}

0 comments on commit c8fd637

Please sign in to comment.