Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Audio System #77

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 101 additions & 7 deletions audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,124 @@

package pi

import "sync"

const channelCount = 4
const maxChannelNo = channelCount - 1

// Audio returns AudioSystem object.
func Audio() *AudioSystem {
return audio
}

var audio = &AudioSystem{}
var audio = &AudioSystem{
effects: make([]SoundEffect, 256),
}

// AudioSystem provides methods for generating audio stream.
// AudioSystem contains 256 sound effects, starting at 0, ending at 255.
// AudioSystem has 4 channels. Each channel is able to play one sound effect
// at a time.
//
// AudioSystem is safe to use in a concurrent manner.
type AudioSystem struct{}
type AudioSystem struct {
mutex sync.Mutex
effects []SoundEffect
effectsPlayed [4]effectPlayed
}

type effectPlayed struct {
playing bool
effect byte
currentNote byte
noteStart byte
noteEnd byte
}

// Set sets the SoundEffect with given number.
func (s *AudioSystem) Set(number byte, e SoundEffect) {
s.mutex.Lock()
defer s.mutex.Unlock()

s.effects[number] = e
}

// Get returns a copy of SoundEffect with given number. Modifying the returned SoundEffect
// will not alter AudioSystem. After making changes you must run [AudioSystem.Set] to update
// the sound effect.
func (s *AudioSystem) Get(number byte) SoundEffect {
s.mutex.Lock()
defer s.mutex.Unlock()

return s.effects[number]
}

type SoundEffect struct {
Speed byte // 1 is the fastest (~8.33ms). For 120 the length of one note becomes 1 second. 0 means SoundEffect takes no time.
Notes [32]Note
}

type Note struct {
Pitch byte
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
}

// generate silence for now
s.mutex.Lock()
defer s.mutex.Unlock()

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

for _, e := range s.effectsPlayed {
if !e.playing {
continue
}

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

return len(p), nil
}

func (s *AudioSystem) Play(soundEffect, channelNo, noteStart, noteEnd byte) {
s.mutex.Lock()
defer s.mutex.Unlock()

if channelNo > maxChannelNo {
return
}

s.effectsPlayed[channelNo] = effectPlayed{
playing: true,
effect: soundEffect,
currentNote: 0,
noteStart: 0,
noteEnd: 0,
}
}

func (s *AudioSystem) Reset() {
s.mutex.Lock()
defer s.mutex.Unlock()

s.effects = make([]SoundEffect, 256)
s.effectsPlayed = [4]effectPlayed{}
}

func Sfx(soundEffect byte) {
audio.Play(soundEffect, 0, 0, 31)
}
75 changes: 73 additions & 2 deletions audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package pi_test

import (
"sync"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -20,11 +21,81 @@ 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)
})
}

func TestAudio(t *testing.T) {
t.Run("should not data race when run with -race flag", func(t *testing.T) {
audio := pi.Audio()
const goroutineCount = 100
var wg sync.WaitGroup
wg.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
audio.Set(0, pi.SoundEffect{})
_ = audio.Get(0)
audio.Play(0, 0, 0, 0)
_, err := audio.Read(make([]float64, 8192))
require.NoError(t, err)
audio.Reset()
wg.Done()
}()
}
wg.Wait()
})
}

func TestAudioSystem_Set(t *testing.T) {
t.Run("should set sound effect", func(t *testing.T) {
given := pi.SoundEffect{Speed: 1}
given.Notes[0] = pi.Note{Pitch: 1, Volume: 2}
given.Notes[31] = pi.Note{Pitch: 3, Volume: 4}
// when
pi.Audio().Set(0, given)
// then
actual := pi.Audio().Get(0)
assert.Equal(t, given, actual)
assert.NotSame(t, given.Notes, actual.Notes)
})
}

func TestAudioSystem_Play(t *testing.T) {
effect := pi.SoundEffect{
Speed: 1, // speed 1 is ~8.333 ms
Notes: [32]pi.Note{
{Pitch: 127, Volume: 255},
},
}

t.Run("should generate audio stream", func(t *testing.T) {
audio := pi.Audio()
audio.Reset()
audio.Set(0, effect)
// when
audio.Play(0, 0, 0, 1)
// then
buffer := make([]float64, 1500)
_, err := audio.Read(buffer)
require.NoError(t, err)
assert.NotEqual(t, make([]float64, 1500), buffer)
})

t.Run("should not generate audio stream when channel is higher than max", func(t *testing.T) {
audio := pi.Audio()
audio.Reset()
audio.Set(0, effect)
// when
audio.Play(0, 4, 0, 1)
// then
buffer := make([]float64, 1500)
_, err := audio.Read(buffer)
require.NoError(t, err)
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)
}
}
Loading