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
380 lines
9.4 KiB
Go
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)
|
|
}
|