Files
hajimehoshi-ebiten/audio/export_test.go
T
dualfroz d22b1a2b09 audio: run the tests waiting on real time on the fake clock of synctest
TestGC, TestSameSourcePlayers, TestDeferredDeviceCreation,
TestPauseAndStopReading, TestPauseAndStopReadingWhilePaused,
TestPositionNotGrowingAfterFinished and
TestPlayerErrorDoesNotStopOtherPlayers now run in a synctest bubble, so
their sleeps and deadlines take no real time.

A bubble must not leave goroutines behind, so ResetContextForTesting
now stops the goroutine updating the players and the dummy players
reading their sources, and waits for them to exit. Before, every test
leaked them.

Updates #3716
2026-10-03 16:06:04 +02:00

380 lines
9.4 KiB
Go

// Copyright 2018 The Ebiten Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package audio
import (
"io"
"sync"
"time"
)
type (
dummyContext struct {
// suspendErr and resumeErr are the errors Suspend and Resume return, to simulate a device
// which fails to suspend or resume.
suspendErr error
resumeErr error
// players are the players created since the context was reset last. ResetContextForTesting
// stops them, so that a player reading an infinite source does not keep reading after its test.
players []*dummyPlayer
mu sync.Mutex
}
dummyPlayer struct {
r io.Reader
playing bool
volume float64
// eof is whether the source has been exhausted by playing through. Like the real players,
// a player which has finished its source refuses to play until Seek resets this state.
eof bool
// drained is whether the simulated device has output the data it had buffered when the
// source was exhausted. See BufferedSize.
drained bool
// readGen is incremented by PauseAndStopReading to stop the goroutine reading r.
readGen int
// readers tracks the goroutines reading r, so that a test can wait for them to stop.
readers sync.WaitGroup
// err is the first non-EOF error the source returned. Like the real players, a player
// whose source failed stops for good and reports the error from Err.
err error
mu sync.Mutex
}
)
func (c *dummyContext) NewPlayer(r io.Reader) player {
p := &dummyPlayer{
r: r,
volume: 1,
}
c.mu.Lock()
defer c.mu.Unlock()
c.players = append(c.players, p)
return p
}
// stopPlayers stops the players' goroutines reading their sources, and waits for them to exit.
func (c *dummyContext) stopPlayers() {
c.mu.Lock()
players := c.players
c.players = nil
c.mu.Unlock()
for _, p := range players {
p.PauseAndStopReading()
p.readers.Wait()
}
}
func (c *dummyContext) MaxBufferSize() int {
return 48000 * channelCount * bitDepthInBytesInt16 / 4
}
func (c *dummyContext) Suspend() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.suspendErr
}
func (c *dummyContext) Resume() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.resumeErr
}
func (c *dummyContext) Err() error {
return nil
}
func (p *dummyPlayer) Pause() {
p.mu.Lock()
p.playing = false
p.mu.Unlock()
}
func (p *dummyPlayer) Play() {
p.mu.Lock()
defer p.mu.Unlock()
if p.eof || p.err != nil {
return
}
p.playing = true
gen := p.readGen
p.readers.Go(func() {
var buf [4096]byte
for {
stopped, err := p.readOnce(gen, buf[:])
if stopped {
return
}
if err != nil {
break
}
time.Sleep(time.Millisecond)
}
p.mu.Lock()
defer p.mu.Unlock()
// The source is exhausted only when it was played through. A paused player would still
// have unplayed data in its buffer in a real player.
if p.playing {
p.eof = true
}
})
}
// readOnce performs one read from the source with the mutex held, so that PauseAndStopReading waits
// for an in-flight read. stopped reports that reading must not continue: PauseAndStopReading was
// called, or the source failed. err is io.EOF when the source is exhausted.
func (p *dummyPlayer) readOnce(gen int, buf []byte) (stopped bool, err error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.readGen != gen {
return true, nil
}
_, err = p.r.Read(buf)
if err != nil && err != io.EOF {
p.err = err
p.playing = false
return true, nil
}
return false, err
}
func (p *dummyPlayer) PauseAndStopReading() {
p.mu.Lock()
p.playing = false
p.readGen++
p.mu.Unlock()
}
func (p *dummyPlayer) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.playing
}
func (p *dummyPlayer) Volume() float64 {
// The volume is not shared with the goroutine Play spawns, but the mutex is held so that the
// test double is uniformly safe like the real player it stands in for.
p.mu.Lock()
defer p.mu.Unlock()
return p.volume
}
func (p *dummyPlayer) SetVolume(volume float64) {
// See Volume for why the mutex is held.
p.mu.Lock()
defer p.mu.Unlock()
p.volume = volume
}
// BufferedSize always reports an empty buffer, as the simulated device consumes the data as soon as
// it is read.
//
// The context calls this exactly once per tick, before it reads the position and checks IsPlaying.
// The player keeps playing for one more tick after its source is exhausted, so that the position
// reaches its final value while the player is still playing, as a real player which still has
// buffered data to output.
func (p *dummyPlayer) BufferedSize() int {
p.mu.Lock()
defer p.mu.Unlock()
if p.eof {
if p.drained {
p.playing = false
}
p.drained = true
}
return 0
}
func (p *dummyPlayer) Err() error {
p.mu.Lock()
defer p.mu.Unlock()
return p.err
}
func (p *dummyPlayer) SetBufferSize(bufferSize int) {
}
func (p *dummyPlayer) Seek(offset int64, whence int) (int64, error) {
// Seeking discards the buffered data and resets the finished state as real players do, so
// that the source can be played again.
p.mu.Lock()
defer p.mu.Unlock()
p.eof = false
p.drained = false
return 0, nil
}
var dummyContextForTesting = &dummyContext{}
func init() {
driverForTesting = dummyContextForTesting
}
// SetSuspendErrorForTesting makes the simulated device fail to suspend with err, or succeed when err
// is nil.
func SetSuspendErrorForTesting(err error) {
c := dummyContextForTesting
c.mu.Lock()
defer c.mu.Unlock()
c.suspendErr = err
}
// SetResumeErrorForTesting makes the simulated device fail to resume with err, or succeed when err is
// nil.
func SetResumeErrorForTesting(err error) {
c := dummyContextForTesting
c.mu.Lock()
defer c.mu.Unlock()
c.resumeErr = err
}
type dummyHook struct {
onSuspend func() error
onResume func() error
updates []func(vmGuest bool) error
}
func (h *dummyHook) OnSuspendAudio(f func() error) {
h.onSuspend = f
}
func (h *dummyHook) OnResumeAudio(f func() error) {
h.onResume = f
}
func (h *dummyHook) AppendHookOnBeforeUpdateWithVMGuestInfo(f func(vmGuest bool) error) {
h.updates = append(h.updates, f)
}
func init() {
hookerForTesting = &dummyHook{}
}
// SuspendForTesting runs the suspend hook the current context registered.
func SuspendForTesting() error {
return hookerForTesting.(*dummyHook).onSuspend()
}
// ResumeForTesting runs the resume hook the current context registered.
func ResumeForTesting() error {
return hookerForTesting.(*dummyHook).onResume()
}
func UpdateForTesting() error {
for _, f := range hookerForTesting.(*dummyHook).updates {
if err := f(false); err != nil {
return err
}
}
return nil
}
func PlayersCountForTesting() int {
c := CurrentContext()
c.m.Lock()
n := len(c.playingPlayers)
c.m.Unlock()
return n
}
func EnsurePlayerForTesting(p *Player) error {
// ensurePlayer accesses player state with its caller holding the mutex, as Play and startIfPending do.
p.p.m.Lock()
defer p.p.m.Unlock()
return p.p.ensurePlayer()
}
func BufferSizeForTesting(p *Player) int {
// initBufferSize is protected by the player mutex and can change when the underlying player is created.
p.p.m.Lock()
defer p.p.m.Unlock()
return p.p.initBufferSize
}
// PlayingButUntrackedForTesting reports whether the player is playing but is not tracked by its
// context as a playing player. This must never be true: an untracked playing player is not
// updated by the context and is not guarded from being garbage-collected.
//
// The player's lock is held across both checks, so that a player which is being started or
// stopped concurrently is not reported.
func PlayingButUntrackedForTesting(p *Player) bool {
pi := p.p
// isPlaying requires its caller to hold the player mutex. Keep it held across both checks.
pi.m.Lock()
defer pi.m.Unlock()
if pi.closed || !pi.isPlaying() {
return false
}
c := pi.context
// The context mutex protects playingPlayers from concurrent additions and removals.
c.m.Lock()
defer c.m.Unlock()
_, ok := c.playingPlayers[pi]
return !ok
}
// ContextCreatedForTesting reports whether the underlying audio device has been created.
func ContextCreatedForTesting() bool {
c := CurrentContext()
if c == nil {
return false
}
return c.playerFactory.currentContext() != nil
}
// ResetContextForTesting discards the current context, so that a test can create a new one.
// ResetContextForTesting waits for the goroutines the context and its players started to exit, so
// that they do not outlive the test.
func ResetContextForTesting() {
theContextLock.Lock()
c := theContext
theContext = nil
theContextLock.Unlock()
if c != nil {
c.m.Lock()
c.disposed = true
c.cond.Broadcast()
c.m.Unlock()
c.updater.Wait()
}
dummyContextForTesting.stopPlayers()
}
func (i *InfiniteLoop) SetNoBlendForTesting(value bool) {
i.noBlendForTesting = value
}
// NewTimeStreamForTesting returns the stream a Player reads from, over a seekable src in signed 16bit
// integer stereo.
func NewTimeStreamForTesting(src io.ReadSeeker, sampleRate int) (io.ReadSeeker, error) {
return newTimeStream(src, true, sampleRate, bitDepthInBytesInt16)
}