Browse Source

Add high watermark telemetry to stream stats

tags/v0.9.0
Jan Svabenik 1 month ago
parent
commit
1becfa5e0c
3 changed files with 49 additions and 0 deletions
  1. +6
    -0
      docs/API.md
  2. +20
    -0
      internal/audio/stream.go
  3. +23
    -0
      internal/audio/stream_test.go

+ 6
- 0
docs/API.md View File

@@ -299,6 +299,8 @@ Requires `--audio-stdin`, `--audio-http`, or another configured stream source to
"capacity": 131072, "capacity": 131072,
"buffered": 0.09, "buffered": 0.09,
"bufferedDurationSeconds": 0.27, "bufferedDurationSeconds": 0.27,
"highWatermark": 15000,
"highWatermarkDurationSeconds": 0.34,
"written": 890000, "written": 890000,
"underruns": 0, "underruns": 0,
"overflows": 0 "overflows": 0
@@ -368,6 +370,8 @@ The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buff
"capacity": 131072, "capacity": 131072,
"buffered": 0.09, "buffered": 0.09,
"bufferedDurationSeconds": 0.27, "bufferedDurationSeconds": 0.27,
"highWatermark": 15000,
"highWatermarkDurationSeconds": 0.34,
"written": 890000, "written": 890000,
"underruns": 0, "underruns": 0,
"overflows": 0 "overflows": 0
@@ -379,5 +383,7 @@ The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buff
- **overflows**: Audio arrived faster than DSP consumed (data dropped) - **overflows**: Audio arrived faster than DSP consumed (data dropped)
- **buffered**: Fill ratio (0.0 = empty, 1.0 = full) - **buffered**: Fill ratio (0.0 = empty, 1.0 = full)
- **bufferedDurationSeconds**: Approximate seconds of audio queued in the buffer (`available` frames divided by the sample rate) - **bufferedDurationSeconds**: Approximate seconds of audio queued in the buffer (`available` frames divided by the sample rate)
- **highWatermark**: Highest observed buffer occupancy (frames) since the buffer was created
- **highWatermarkDurationSeconds**: Equivalent peak time (`highWatermark` frames divided by the sample rate)


When no audio is streaming, the transmitter falls back to the configured tone generator or silence. When no audio is streaming, the transmitter falls back to the configured tone generator or silence.

+ 20
- 0
internal/audio/stream.go View File

@@ -24,6 +24,7 @@ type StreamSource struct {
Underruns atomic.Uint64 Underruns atomic.Uint64
Overflows atomic.Uint64 Overflows atomic.Uint64
Written atomic.Uint64 Written atomic.Uint64
highWatermark atomic.Int64
} }


// NewStreamSource creates a ring buffer with the given capacity (rounded up // NewStreamSource creates a ring buffer with the given capacity (rounded up
@@ -54,6 +55,7 @@ func (s *StreamSource) WriteFrame(f Frame) bool {
s.ring[int(wp)&s.mask] = f s.ring[int(wp)&s.mask] = f
s.writePos.Add(1) s.writePos.Add(1)
s.Written.Add(1) s.Written.Add(1)
s.updateHighWatermark()
return true return true
} }


@@ -114,11 +116,14 @@ func (s *StreamSource) Stats() StreamStats {
if s.size > 0 { if s.size > 0 {
buffered = float64(available) / float64(s.size) buffered = float64(available) / float64(s.size)
} }
highWatermark := int(s.highWatermark.Load())
return StreamStats{ return StreamStats{
Available: available, Available: available,
Capacity: s.size, Capacity: s.size,
Buffered: buffered, Buffered: buffered,
BufferedDurationSeconds: s.bufferedDurationSeconds(available), BufferedDurationSeconds: s.bufferedDurationSeconds(available),
HighWatermark: highWatermark,
HighWatermarkDurationSeconds: s.bufferedDurationSeconds(highWatermark),
Written: s.Written.Load(), Written: s.Written.Load(),
Underruns: s.Underruns.Load(), Underruns: s.Underruns.Load(),
Overflows: s.Overflows.Load(), Overflows: s.Overflows.Load(),
@@ -131,6 +136,8 @@ type StreamStats struct {
Capacity int `json:"capacity"` Capacity int `json:"capacity"`
Buffered float64 `json:"buffered"` Buffered float64 `json:"buffered"`
BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"` BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"`
HighWatermark int `json:"highWatermark"`
HighWatermarkDurationSeconds float64 `json:"highWatermarkDurationSeconds"`
Written uint64 `json:"written"` Written uint64 `json:"written"`
Underruns uint64 `json:"underruns"` Underruns uint64 `json:"underruns"`
Overflows uint64 `json:"overflows"` Overflows uint64 `json:"overflows"`
@@ -143,6 +150,19 @@ func (s *StreamSource) bufferedDurationSeconds(available int) float64 {
return float64(available) / float64(s.SampleRate) return float64(available) / float64(s.SampleRate)
} }


func (s *StreamSource) updateHighWatermark() {
available := s.Available()
for {
prev := s.highWatermark.Load()
if int64(available) <= prev {
return
}
if s.highWatermark.CompareAndSwap(prev, int64(available)) {
return
}
}
}

// --- StreamResampler --- // --- StreamResampler ---


// StreamResampler wraps a StreamSource and rate-converts from the stream's // StreamResampler wraps a StreamSource and rate-converts from the stream's


+ 23
- 0
internal/audio/stream_test.go View File

@@ -221,6 +221,29 @@ func TestStreamSource_StatsBufferedDuration(t *testing.T) {
} }
} }


func TestStreamSource_StatsHighWatermark(t *testing.T) {
rate := 44100
s := NewStreamSource(64, rate)
for i := 0; i < 12; i++ {
s.WriteFrame(NewFrame(0, 0))
}
for i := 0; i < 5; i++ {
s.ReadFrame()
}
stats := s.Stats()
if stats.HighWatermark != 12 {
t.Fatalf("expected high watermark 12, got %d", stats.HighWatermark)
}
expected := float64(stats.HighWatermark) / float64(rate)
if math.Abs(stats.HighWatermarkDurationSeconds-expected) > 1e-9 {
t.Fatalf("high watermark duration %.9f != %.9f", stats.HighWatermarkDurationSeconds, expected)
}
if stats.HighWatermark < stats.Available {
t.Fatalf("high watermark %d < available %d", stats.HighWatermark, stats.Available)
}
}


// --- StreamResampler tests --- // --- StreamResampler tests ---


func TestStreamResampler_1to1(t *testing.T) { func TestStreamResampler_1to1(t *testing.T) {


Loading…
Cancel
Save