Skip to content

Commit

Permalink
Wait until audio is ready
Browse files Browse the repository at this point in the history
Game loop must wait until audio is initialized to avoid desynchronization between graphics and audio.

On the web audio is initialized only after user action (such as clicking the mouse button). Show the "Play" image button to the user in such case.
  • Loading branch information
elgopher committed Sep 1, 2023
1 parent c725541 commit c096335
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 7 deletions.
21 changes: 16 additions & 5 deletions ebitengine/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var AudioStream interface {
Read(p []byte) (n int, err error)
}

func startAudio() (stop func(), _ error) {
func startAudio() (stop func(), ready <-chan struct{}, _ error) {
if AudioStream == nil {
// In the web back-end, Audio Worklets will be used. In the beginning, state will be stored to binary form
// and sent over the MessageChannel to processor. Each call to System methods will send events to processor
Expand All @@ -48,11 +48,11 @@ func startAudio() (stop func(), _ error) {

state, err := audio.SaveAudio()
if err != nil {
return stop, fmt.Errorf("problem saving audio state: %w", err)
return stop, nil, fmt.Errorf("problem saving audio state: %w", err)
}
synth := &audio.Synthesizer{}
if err = synth.Load(state); err != nil {
return stop, fmt.Errorf("problem loading audio state: %w", err)
return stop, nil, fmt.Errorf("problem loading audio state: %w", err)
}

audioSystem := &ebitenPlayerSource{audioSystem: synth}
Expand All @@ -63,14 +63,25 @@ func startAudio() (stop func(), _ error) {
audioCtx := ebitenaudio.NewContext(audioSampleRate)
player, err := audioCtx.NewPlayer(AudioStream)
if err != nil {
return func() {}, err
return func() {}, nil, err
}
player.SetBufferSize(60 * time.Millisecond)
player.Play()

readyChan := make(chan struct{})
go func() {
for {
if audioCtx.IsReady() {
close(readyChan)
return
}
time.Sleep(time.Millisecond)
}
}()

return func() {
_ = player.Close()
}, nil
}, readyChan, nil
}

// ebitenPlayerSource implements Ebitengine Player source.
Expand Down
12 changes: 10 additions & 2 deletions ebitengine/ebitengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package ebitengine

import (
_ "embed"
"errors"
"fmt"
"math"
Expand All @@ -22,7 +23,7 @@ const tps = 30
// Run opens the window and runs the game loop. It must be
// called from the main thread.
func Run() error {
stopAudio, err := startAudio()
stopAudio, audioReady, err := startAudio()
if err != nil {
return err
}
Expand All @@ -42,7 +43,14 @@ func Run() error {
ebiten.SetWindowFloating(true)
ebiten.SetWindowTitle("Pi Game")

if err := ebiten.RunGame(&game{}); err != nil {
theGame := &game{}

go func() {
<-audioReady
theGame.ready.Store(true)
}()

if err := ebiten.RunGame(theGame); err != nil {
if err == gameStoppedErr {
return nil
}
Expand Down
12 changes: 12 additions & 0 deletions ebitengine/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package ebitengine

import (
_ "embed"
"sync/atomic"
"time"

"github.com/hajimehoshi/ebiten/v2"
Expand All @@ -13,12 +15,17 @@ import (
)

type game struct {
ready atomic.Bool
screenDataRGBA []byte // reused RGBA pixels
screenChanged bool
shouldSkipNextDraw bool
}

func (e *game) Update() error {
if !e.ready.Load() {
return nil
}

updateStartedTime := time.Now()

updateTime()
Expand Down Expand Up @@ -65,6 +72,11 @@ func handleKeyboardShortcuts() {
}

func (e *game) Draw(screen *ebiten.Image) {
if !e.ready.Load() {
e.drawNotReady(screen)
return
}

// Ebitengine executes Draw based on display frequency.
// But the screen is changed at most 30 times per second.
// That's why there is no need to write pixels more often
Expand Down
42 changes: 42 additions & 0 deletions ebitengine/game_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// (c) 2022-2023 Jacek Olszak
// This code is licensed under MIT license (see LICENSE for details)

package ebitengine

import (
"bytes"
_ "embed"
"image/png"

"github.com/hajimehoshi/ebiten/v2"
)

//go:embed "play.png"
var playButton []byte

var playButtonImage *ebiten.Image

func init() {
img, err := png.Decode(bytes.NewReader(playButton))
if err != nil {
panic("decoding play.png failed: " + err.Error())
}
playButtonImage = ebiten.NewImageFromImage(img)
}

// in web browser user action is needed to initialize audio - such as clicking the mouse or hitting the keyboard.
// To inform the user that his action is needed drawNotReady draws a play button.
func (e *game) drawNotReady(screen *ebiten.Image) {
screenSize := screen.Bounds().Max
screenWidth := screenSize.X
screenHeight := screenSize.Y

imageSize := playButtonImage.Bounds()
imageWidth := imageSize.Max.X
imageHeight := imageSize.Max.Y

m := ebiten.GeoM{}
m.Translate(float64(screenWidth-imageWidth)/2.0, float64(screenHeight-imageHeight)/2.0)

screen.DrawImage(playButtonImage, &ebiten.DrawImageOptions{GeoM: m})
}
12 changes: 12 additions & 0 deletions ebitengine/game_notjs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// (c) 2022-2023 Jacek Olszak
// This code is licensed under MIT license (see LICENSE for details)

//go:build !js

package ebitengine

import "github.com/hajimehoshi/ebiten/v2"

// on non-js operating systems, initialization takes only few milliseconds and there is no user action needed.
// Therefore, there is no need to draw anything.
func (e *game) drawNotReady(*ebiten.Image) {}
Binary file added ebitengine/play.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit c096335

Please sign in to comment.