diff --git a/docs/API.md b/docs/API.md index a81a0e6..7ebc4b7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -299,6 +299,8 @@ Requires `--audio-stdin`, `--audio-http`, or another configured stream source to "capacity": 131072, "buffered": 0.09, "bufferedDurationSeconds": 0.27, + "highWatermark": 15000, + "highWatermarkDurationSeconds": 0.34, "written": 890000, "underruns": 0, "overflows": 0 @@ -368,6 +370,8 @@ The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buff "capacity": 131072, "buffered": 0.09, "bufferedDurationSeconds": 0.27, + "highWatermark": 15000, + "highWatermarkDurationSeconds": 0.34, "written": 890000, "underruns": 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) - **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) +- **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. diff --git a/internal/audio/stream.go b/internal/audio/stream.go index 14ceac1..09f6de3 100644 --- a/internal/audio/stream.go +++ b/internal/audio/stream.go @@ -24,6 +24,7 @@ type StreamSource struct { Underruns atomic.Uint64 Overflows atomic.Uint64 Written atomic.Uint64 + highWatermark atomic.Int64 } // 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.writePos.Add(1) s.Written.Add(1) + s.updateHighWatermark() return true } @@ -114,11 +116,14 @@ func (s *StreamSource) Stats() StreamStats { if s.size > 0 { buffered = float64(available) / float64(s.size) } + highWatermark := int(s.highWatermark.Load()) return StreamStats{ Available: available, Capacity: s.size, Buffered: buffered, BufferedDurationSeconds: s.bufferedDurationSeconds(available), + HighWatermark: highWatermark, + HighWatermarkDurationSeconds: s.bufferedDurationSeconds(highWatermark), Written: s.Written.Load(), Underruns: s.Underruns.Load(), Overflows: s.Overflows.Load(), @@ -131,6 +136,8 @@ type StreamStats struct { Capacity int `json:"capacity"` Buffered float64 `json:"buffered"` BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"` + HighWatermark int `json:"highWatermark"` + HighWatermarkDurationSeconds float64 `json:"highWatermarkDurationSeconds"` Written uint64 `json:"written"` Underruns uint64 `json:"underruns"` Overflows uint64 `json:"overflows"` @@ -143,6 +150,19 @@ func (s *StreamSource) bufferedDurationSeconds(available int) float64 { 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 wraps a StreamSource and rate-converts from the stream's diff --git a/internal/audio/stream_test.go b/internal/audio/stream_test.go index 43fe0ee..2169e09 100644 --- a/internal/audio/stream_test.go +++ b/internal/audio/stream_test.go @@ -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 --- func TestStreamResampler_1to1(t *testing.T) {