diff --git a/docs/API.md b/docs/API.md index ed4d8b1..a81a0e6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -298,6 +298,7 @@ Requires `--audio-stdin`, `--audio-http`, or another configured stream source to "available": 12000, "capacity": 131072, "buffered": 0.09, + "bufferedDurationSeconds": 0.27, "written": 890000, "underruns": 0, "overflows": 0 @@ -366,6 +367,7 @@ The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buff "available": 12000, "capacity": 131072, "buffered": 0.09, + "bufferedDurationSeconds": 0.27, "written": 890000, "underruns": 0, "overflows": 0 @@ -376,5 +378,6 @@ The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buff - **underruns**: DSP consumed faster than audio arrived (silence inserted) - **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) 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 bf951a8..14ceac1 100644 --- a/internal/audio/stream.go +++ b/internal/audio/stream.go @@ -109,24 +109,38 @@ func (s *StreamSource) Buffered() float64 { // Stats returns diagnostic counters. func (s *StreamSource) Stats() StreamStats { + available := s.Available() + buffered := 0.0 + if s.size > 0 { + buffered = float64(available) / float64(s.size) + } return StreamStats{ - Available: s.Available(), - Capacity: s.size, - Buffered: s.Buffered(), - Written: s.Written.Load(), - Underruns: s.Underruns.Load(), - Overflows: s.Overflows.Load(), + Available: available, + Capacity: s.size, + Buffered: buffered, + BufferedDurationSeconds: s.bufferedDurationSeconds(available), + Written: s.Written.Load(), + Underruns: s.Underruns.Load(), + Overflows: s.Overflows.Load(), } } // StreamStats exposes runtime telemetry for the stream buffer. type StreamStats struct { - Available int `json:"available"` - Capacity int `json:"capacity"` - Buffered float64 `json:"buffered"` - Written uint64 `json:"written"` - Underruns uint64 `json:"underruns"` - Overflows uint64 `json:"overflows"` + Available int `json:"available"` + Capacity int `json:"capacity"` + Buffered float64 `json:"buffered"` + BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"` + Written uint64 `json:"written"` + Underruns uint64 `json:"underruns"` + Overflows uint64 `json:"overflows"` +} + +func (s *StreamSource) bufferedDurationSeconds(available int) float64 { + if s.SampleRate <= 0 { + return 0 + } + return float64(available) / float64(s.SampleRate) } // --- StreamResampler --- diff --git a/internal/audio/stream_test.go b/internal/audio/stream_test.go index cc2820a..43fe0ee 100644 --- a/internal/audio/stream_test.go +++ b/internal/audio/stream_test.go @@ -205,6 +205,22 @@ func TestStreamSource_ConcurrentSPSC(t *testing.T) { } } +func TestStreamSource_StatsBufferedDuration(t *testing.T) { + rate := 48000 + s := NewStreamSource(128, rate) + for i := 0; i < 24; i++ { + s.WriteFrame(NewFrame(0, 0)) + } + stats := s.Stats() + if stats.BufferedDurationSeconds <= 0 { + t.Fatalf("expected buffered duration > 0, got %.6f", stats.BufferedDurationSeconds) + } + expected := float64(stats.Available) / float64(rate) + if math.Abs(stats.BufferedDurationSeconds-expected) > 1e-9 { + t.Fatalf("buffered duration %.9f != expected %.9f", stats.BufferedDurationSeconds, expected) + } +} + // --- StreamResampler tests --- func TestStreamResampler_1to1(t *testing.T) {