An underrun is audible and nothing counted it. When the ring is empty BufferedStreamer.Stream zeroes the caller's buffer and returns ok, so a run of zeros is spliced into the waveform and the step discontinuity at each edge is a click; a series of short ones is static. That is the one candidate in #135 whose audible signature matches the report. `starved` and `starvedSince` already existed, from #122's stall fix, and could not answer this: they are a *stall* detector, reset by every arriving sample, because their job is to end a track whose source has died and a merely slow source must not be cut short. That reset is exactly what made the audible case invisible -- a hundred 20ms underruns a minute never approach the give-up threshold, so they were invisible to the log, to the UI and to every tier. UnderrunStats counts runs, calls and samples for the life of the streamer. Runs is the number that means something audible: one episode is one pop however many callbacks it spans, and the ratio of runs to calls is what separates clicking from dropping out. Three things about the reporting are deliberate. **Counting is in Stream and reporting is not.** Stream runs on the speaker callback's real-time deadline, so a log line there would allocate, format and write on the exact path whose missed deadline is the defect -- measuring by making it worse. The count is two increments under a lock that was already held; the report is on the 1 Hz position ticker, which only runs while playing. **An unchanged count is not logged**, which is emitStatus' rule one package over. A healthy player is silent, so anything in the log is news and the line appears exactly while it is popping. **It is Info rather than Debug.** The default level is Info and a phone has no convenient way to set YJ_LOG_LEVEL, so a debug line here would be a counter nobody on the affected platform could read -- which is the shape of the bug that made #160 necessary. underrunDelta clamps at zero because the counter belongs to the streamer and the streamer is replaced on every track: a baseline carried across that boundary is the previous track's total subtracted from a fresh zero. The baseline is reset at the load as well; a negative count in a log line reads as a broken instrument and would discredit the measurement this exists to make. This is the instrument, not a fix. What it measures is on the issue.
280 lines
7.2 KiB
Go
280 lines
7.2 KiB
Go
package player
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// An underrun is audible and nothing counted it (#135).
|
|
//
|
|
// The distinction these tests exist for is that `starved` and
|
|
// `underruns` disagree on purpose. `starved` is a stall detector: it is
|
|
// reset by every arriving sample, because its job is to end a track
|
|
// whose source has died and a source that is merely slow must not be
|
|
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
|
|
// door). That reset is exactly what made the audible case invisible --
|
|
// a hundred short underruns a minute never approach the give-up
|
|
// threshold, and each one is a run of zeros spliced into the waveform
|
|
// with a step discontinuity at both edges.
|
|
|
|
// TestAnEmptyRingIsCounted is the measurement itself: silence served
|
|
// for a missing sample is recorded rather than merely tolerated.
|
|
func TestAnEmptyRingIsCounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A source that never produces is the cleanest way to make the
|
|
// ring empty on demand; the stall budget is far longer than the
|
|
// handful of calls below.
|
|
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
|
defer bs.Close()
|
|
|
|
if got := bs.Underruns(); got != (UnderrunStats{}) {
|
|
t.Fatalf("a fresh streamer already reports %+v", got)
|
|
}
|
|
|
|
buf := make([][2]float64, 256)
|
|
|
|
for range 3 {
|
|
if _, ok := bs.Stream(buf); !ok {
|
|
t.Fatal("the stall budget ran out before the test did")
|
|
}
|
|
}
|
|
|
|
got := bs.Underruns()
|
|
|
|
if got.Calls != 3 {
|
|
t.Errorf("Calls = %d, want 3", got.Calls)
|
|
}
|
|
|
|
if got.Samples != int64(3*len(buf)) {
|
|
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
|
|
}
|
|
|
|
// Three consecutive silent calls are one episode, not three. That
|
|
// is the number that means something audible: one interruption is
|
|
// one pop however many callbacks it spans.
|
|
if got.Runs != 1 {
|
|
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
|
|
}
|
|
}
|
|
|
|
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
|
|
// waveform, which is the reason to count it at all.
|
|
func TestSilenceIsWhatIsCounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
|
defer bs.Close()
|
|
|
|
buf := make([][2]float64, 64)
|
|
for i := range buf {
|
|
buf[i] = [2]float64{0.5, 0.5}
|
|
}
|
|
|
|
n, ok := bs.Stream(buf)
|
|
if !ok || n != len(buf) {
|
|
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
|
|
}
|
|
|
|
for i := range buf {
|
|
if buf[i] != ([2]float64{}) {
|
|
t.Fatalf("sample %d is %v, want silence", i, buf[i])
|
|
}
|
|
}
|
|
|
|
if got := bs.Underruns().Samples; got != int64(len(buf)) {
|
|
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
|
|
}
|
|
}
|
|
|
|
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
|
|
// audio arriving between two underruns makes them two, because that is
|
|
// two interruptions and two clicks.
|
|
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A source that yields nothing until it is fed, so the ring can be
|
|
// emptied, filled and emptied again on demand.
|
|
src := &gatedStreamer{}
|
|
|
|
bs := NewBufferedStreamer(src, 1024)
|
|
defer bs.Close()
|
|
|
|
buf := make([][2]float64, 128)
|
|
|
|
starve := func() {
|
|
t.Helper()
|
|
|
|
for range 2 {
|
|
if _, ok := bs.Stream(buf); !ok {
|
|
t.Fatal("the stall budget ran out before the test did")
|
|
}
|
|
}
|
|
}
|
|
|
|
// feed lets exactly one bufferful through and drains it, so the
|
|
// ring is empty again on return. Allowing more would mean the
|
|
// starve() after it drained real audio instead of underrunning,
|
|
// which is what the first version of this test did -- it reported
|
|
// one episode and looked like the counter was wrong.
|
|
feed := func() {
|
|
t.Helper()
|
|
|
|
src.allow(len(buf))
|
|
|
|
// The read-ahead is a goroutine, so wait for real samples
|
|
// rather than assuming they have landed.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
n, ok := bs.Stream(buf)
|
|
if ok && n > 0 && buf[0] != ([2]float64{}) {
|
|
return
|
|
}
|
|
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
t.Fatal("the source never delivered a sample")
|
|
}
|
|
|
|
starve()
|
|
feed()
|
|
starve()
|
|
|
|
if got := bs.Underruns().Runs; got < 2 {
|
|
t.Errorf(
|
|
"Runs = %d, want at least 2 -- audio in between makes two "+
|
|
"episodes, not one",
|
|
got,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestTheStallResetDoesNotClearTheCounter is the regression this file
|
|
// is really about.
|
|
//
|
|
// resetStarvationLocked runs on every arriving sample and on every
|
|
// Flush. If it cleared the cumulative count too, the counter would
|
|
// report zero on exactly the workload it exists to measure -- a stream
|
|
// that underruns repeatedly but always recovers -- which is
|
|
// indistinguishable from healthy playback and is what the code did
|
|
// before #135.
|
|
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
|
defer bs.Close()
|
|
|
|
buf := make([][2]float64, 128)
|
|
|
|
if _, ok := bs.Stream(buf); !ok {
|
|
t.Fatal("the stall budget ran out before the test did")
|
|
}
|
|
|
|
before := bs.Underruns()
|
|
if before.Runs == 0 {
|
|
t.Fatal("nothing was counted, so the reset cannot be tested")
|
|
}
|
|
|
|
// Both of the ways a run is forgotten.
|
|
bs.mu.Lock()
|
|
bs.resetStarvationLocked()
|
|
bs.mu.Unlock()
|
|
|
|
bs.Flush()
|
|
|
|
if got := bs.Underruns(); got != before {
|
|
t.Errorf(
|
|
"forgetting the stall run also discarded the count: %+v, "+
|
|
"want %+v",
|
|
got, before,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
|
|
// the reporting side.
|
|
//
|
|
// The counter belongs to the streamer and the streamer is replaced on
|
|
// every track, so a baseline carried across a track change is the
|
|
// previous track's total subtracted from a fresh zero. The load path
|
|
// resets the baseline, and this clamps as well -- a negative count in a
|
|
// log line reads as a broken instrument, which would discredit the
|
|
// measurement rather than merely mis-state it.
|
|
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
now UnderrunStats
|
|
last UnderrunStats
|
|
want UnderrunStats
|
|
}{
|
|
{
|
|
name: "ordinary progress",
|
|
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
|
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
|
|
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
|
|
},
|
|
{
|
|
name: "nothing happened",
|
|
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
|
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
|
want: UnderrunStats{},
|
|
},
|
|
{
|
|
name: "a new streamer, with a stale baseline",
|
|
now: UnderrunStats{},
|
|
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
|
|
want: UnderrunStats{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := underrunDelta(tt.now, tt.last); got != tt.want {
|
|
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// gatedStreamer produces only what it has been allowed to, and
|
|
// otherwise stalls without ending -- so a test can decide exactly when
|
|
// the ring runs dry.
|
|
type gatedStreamer struct {
|
|
mu sync.Mutex
|
|
remaining int
|
|
}
|
|
|
|
func (g *gatedStreamer) allow(n int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
g.remaining += n
|
|
}
|
|
|
|
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
if g.remaining <= 0 {
|
|
return 0, true
|
|
}
|
|
|
|
n := min(len(samples), g.remaining)
|
|
|
|
for i := range n {
|
|
samples[i] = [2]float64{0.25, 0.25}
|
|
}
|
|
|
|
g.remaining -= n
|
|
|
|
return n, true
|
|
}
|
|
|
|
func (g *gatedStreamer) Err() error { return nil }
|