| @@ -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. | ||||
| @@ -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 | ||||
| @@ -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) { | ||||