diff --git a/frontend/a2ebiten/ebitenSpeaker.go b/frontend/a2ebiten/ebitenSpeaker.go new file mode 100644 index 0000000..17f52b9 --- /dev/null +++ b/frontend/a2ebiten/ebitenSpeaker.go @@ -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 +} diff --git a/frontend/a2ebiten/main.go b/frontend/a2ebiten/main.go index e1b4389..d305156 100644 --- a/frontend/a2ebiten/main.go +++ b/frontend/a2ebiten/main.go @@ -15,6 +15,7 @@ type Game struct { a *izapple2.Apple2 image *ebiten.Image keyboard *ebitenKeyboard + speaker *ebitenSpeaker paused bool title string @@ -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() { @@ -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)" @@ -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) } diff --git a/go.mod b/go.mod index 076c249..858e24e 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 418ba59..09ff594 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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=