Skip to content

Commit

Permalink
Sound on the ebitengine frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanizag committed Oct 30, 2024
1 parent bd0e4d6 commit 4a8372c
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 2 deletions.
170 changes: 170 additions & 0 deletions frontend/a2ebiten/ebitenSpeaker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package main

import (
"fmt"
"math"
"time"

"github.com/ivanizag/izapple2"

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

const (
samplingHz = 48000
//bufferSize = 1000
// bufferSize/samplingHz will be the max delay of the sound
sampleDurationCycles = 1000000 * izapple2.CPUClockMhz / samplingHz
// each sample on the sound stream is 21.31 cpu cycles approx
maxOutOfSyncMs = 2000
decayLevel = 0.0
)

type ebitenSpeaker struct {
audioContext *audio.Context
audioPlayer *audio.Player

clickChannel chan uint64
pendingClicks []uint64
lastCycle uint64
lastState bool
lastLevel float32
}

func newEbitenSpeaker() *ebitenSpeaker {
var s ebitenSpeaker
s.clickChannel = make(chan uint64, 1000)
s.pendingClicks = make([]uint64, 0, 1000)
s.lastLevel = decayLevel // Mid position to avoid starting clicks.
return &s
}

// Click receives a speaker click. The argument is the CPU cycle when it is generated
func (s *ebitenSpeaker) Click(cycle uint64) {
select {
case s.clickChannel <- cycle:
// Sent
default:
fmt.Printf("Speaker click dropped in channel.\n")
// The channel is full, the click is lost.
}
}

func stateToLevel(state bool) float32 {
if state {
return 1.0
}
return -1.0
}

// Read is io.Reader's Read
func (s *ebitenSpeaker) Read(buf []byte) (n int, err error) {
//Read queued clicks
done := false
for !done {
select {
case cycle := <-s.clickChannel:
s.pendingClicks = append(s.pendingClicks, cycle)
default:
done = true
}
}

// Verify that we are not too long behind
var maxOutOfSyncCyclesFloat = 1000 * izapple2.CPUClockMhz * maxOutOfSyncMs
var maxOutOfSyncCycles = uint64(maxOutOfSyncCyclesFloat)
for _, pc := range s.pendingClicks {
if pc-s.lastCycle > maxOutOfSyncCycles {
// Fast forward
s.lastCycle = pc
fmt.Printf("Speaker fast forward.\n")
}
}

// Build wave
const bytesPerSample = 8 // Two floats32, 4 bytes each, one for each channel
samples := len(buf) / bytesPerSample
//fmt.Printf("smples: %v\n", smples)

if len(s.pendingClicks) > 0 {
fmt.Printf("pendingClicks: %v\n", len(s.pendingClicks))
}
var i, r int
level := s.lastLevel
for p := 0; p < len(s.pendingClicks); p++ {
cycle := s.pendingClicks[p]
if cycle < s.lastCycle {
// Too old, ignore
continue
}

// Fill with samples
level = stateToLevel(s.lastState)
samplesNeeded := int(float64(cycle-s.lastCycle) / sampleDurationCycles)
if samplesNeeded+i > samples {
// Partial fill, to be completed on the next callback
samplesNeeded = samples - i
s.lastCycle = cycle - uint64(float64(samplesNeeded)*sampleDurationCycles)
} else {
s.lastCycle = cycle
s.lastState = !s.lastState
r++ // Remove this pending click
}

for j := 0; j < samplesNeeded; j++ {
putFloat32InBuffer(buf, i, level)
i += 1
}

if i == samples {
// Buffer is complete
break
}
}

// If the buffer is empty lets stop the signal
if i == 0 && level != 0.0 {
level = 0.0
fmt.Printf("Speaker buffer empty, to zero.\n")
}

// Complete the buffer if needed
for b := i; b < samples; b++ {
putFloat32InBuffer(buf, b, level)
}
s.lastLevel = level

// Remove processed clicks, store the rest for later
s.pendingClicks = s.pendingClicks[r:]

return len(buf), nil
}

func putFloat32InBuffer(buf []byte, i int, f float32) {
v := math.Float32bits(f)
buf[i*8] = byte(v)
buf[i*8+1] = byte(v >> 8)
buf[i*8+2] = byte(v >> 16)
buf[i*8+3] = byte(v >> 24)
buf[i*8+4] = byte(v)
buf[i*8+5] = byte(v >> 8)
buf[i*8+6] = byte(v >> 16)
buf[i*8+7] = byte(v >> 24)
}

func (s *ebitenSpeaker) update() error {
if s.audioContext == nil {
s.audioContext = audio.NewContext(samplingHz)
}
if s.audioPlayer == nil {
var err error
s.audioPlayer, err = s.audioContext.NewPlayerF32(s)
if err != nil {
return err
}
//s.audioPlayer.SetVolume(1.0)
s.audioPlayer.SetBufferSize(time.Duration(100) * time.Millisecond)
s.audioPlayer.Play()
}
return nil
}
7 changes: 6 additions & 1 deletion frontend/a2ebiten/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Game struct {
a *izapple2.Apple2
image *ebiten.Image
keyboard *ebitenKeyboard
speaker *ebitenSpeaker

paused bool
title string
Expand All @@ -27,6 +28,7 @@ const (

func (g *Game) Update() error {
g.keyboard.update()
g.speaker.update()

if g.paused != g.a.IsPaused() {
if g.a.IsPaused() {
Expand Down Expand Up @@ -93,7 +95,7 @@ func main() {
}

func ebitenRun(a *izapple2.Apple2) {
ebiten.SetWindowSize(virtualWidth, virtualHeight)
ebiten.SetWindowSize(virtualWidth/2, virtualHeight/2)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)

title := "iz-" + a.Name + " (F1 for help)"
Expand All @@ -104,8 +106,11 @@ func ebitenRun(a *izapple2.Apple2) {
game := &Game{
a: a,
keyboard: newEbitenKeyBoard(a),
speaker: newEbitenSpeaker(),
}

a.SetSpeakerProvider(game.speaker)

if err := ebiten.RunGame(game); err != nil {
fmt.Printf("Error: %v\n", err)
}
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ require (
require (
github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/oto/v3 v3.3.1 // indirect
github.com/ebitengine/purego v0.8.0 // indirect
github.com/jezek/xgb v1.1.1 // indirect
golang.org/x/sync v0.8.0 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
)

require (
Expand Down
5 changes: 4 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0
github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE=
github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
Expand Down Expand Up @@ -136,8 +138,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down

0 comments on commit 4a8372c

Please sign in to comment.