diff --git a/docs/API.md b/docs/API.md index 7ebc4b7..bde17a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -95,6 +95,8 @@ Live engine and driver telemetry. Only populated when TX is active. "framesWritten": 12345, "samplesWritten": 1408950000, "underruns": 0, + "underrunStreak": 0, + "maxUnderrunStreak": 0, "effectiveSampleRateHz": 2280000 } } @@ -105,6 +107,9 @@ Live engine and driver telemetry. Only populated when TX is active. `transitionHistory` liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können. +`driver.underrunStreak` reports how many consecutive reads returned silence, and `driver.maxUnderrunStreak` captures the longest such run since the engine started. Together they help differentiate short glitches from persistent underrun storms and can be plotted alongside queue health sparkline telemetry. + + --- ### `POST /runtime/fault/reset` diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index ce14fdf..de45aa1 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -386,12 +386,14 @@ Vollständige Sichtbarkeit auf Runtime, Queue, Writer, Generator, RF-Selbsttests | --- | --- | --- | | 2026-04-06 | High-watermark trend sparkline | Captured audio high-watermark duration history and surface it as a new Health-panel sparkline for queue pressure visibility. | | 2026-04-06 | Queue fill visibility | Added queue fill ratio health line and sparklines to highlight real-time queue pressure alongside high-watermark trends. | +| 2026-04-07 | Underrun streak telemetry | StreamStats now expose current and max underrun streak counters so queue diagnostics can see repeated underruns without touching the metrics stack. | ## WS-04 Verifikation | Datum | Fokus | Ergebnis | | --- | --- | --- | | 2026-04-06 | High-watermark trend sparkline | `go test ./...` plus manual UI check confirm the new sparkline updates with runtime audio stats. | | 2026-04-06 | Queue fill visibility | `go test ./...` plus UI smoke check confirm queue fill stats stay available and the new sparkline/health line react to queue health changes. | +| 2026-04-07 | Underrun streak telemetry | `go test ./internal/audio` confirms the new streak counters plus Stats coverage so the API surfaces the same names. | --- diff --git a/internal/audio/stream.go b/internal/audio/stream.go index 09f6de3..6366f93 100644 --- a/internal/audio/stream.go +++ b/internal/audio/stream.go @@ -25,6 +25,8 @@ type StreamSource struct { Overflows atomic.Uint64 Written atomic.Uint64 highWatermark atomic.Int64 + underrunStreak atomic.Uint64 + maxUnderrunStreak atomic.Uint64 } // NewStreamSource creates a ring buffer with the given capacity (rounded up @@ -87,10 +89,12 @@ func (s *StreamSource) ReadFrame() Frame { wp := s.writePos.Load() if rp >= wp { s.Underruns.Add(1) + s.recordUnderrunStreak() return NewFrame(0, 0) } f := s.ring[int(rp)&s.mask] s.readPos.Add(1) + s.resetUnderrunStreak() return f } @@ -117,6 +121,8 @@ func (s *StreamSource) Stats() StreamStats { buffered = float64(available) / float64(s.size) } highWatermark := int(s.highWatermark.Load()) + currentStreak := int(s.underrunStreak.Load()) + maxStreak := int(s.maxUnderrunStreak.Load()) return StreamStats{ Available: available, Capacity: s.size, @@ -127,6 +133,8 @@ func (s *StreamSource) Stats() StreamStats { Written: s.Written.Load(), Underruns: s.Underruns.Load(), Overflows: s.Overflows.Load(), + UnderrunStreak: currentStreak, + MaxUnderrunStreak: maxStreak, } } @@ -141,6 +149,8 @@ type StreamStats struct { Written uint64 `json:"written"` Underruns uint64 `json:"underruns"` Overflows uint64 `json:"overflows"` + UnderrunStreak int `json:"underrunStreak"` + MaxUnderrunStreak int `json:"maxUnderrunStreak"` } func (s *StreamSource) bufferedDurationSeconds(available int) float64 { @@ -163,6 +173,23 @@ func (s *StreamSource) updateHighWatermark() { } } +func (s *StreamSource) recordUnderrunStreak() { + current := s.underrunStreak.Add(1) + for { + prevMax := s.maxUnderrunStreak.Load() + if current <= prevMax { + return + } + if s.maxUnderrunStreak.CompareAndSwap(prevMax, current) { + return + } + } +} + +func (s *StreamSource) resetUnderrunStreak() { + s.underrunStreak.Store(0) +} + // --- 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 2169e09..6cfac5e 100644 --- a/internal/audio/stream_test.go +++ b/internal/audio/stream_test.go @@ -45,6 +45,36 @@ func TestStreamSource_Underrun(t *testing.T) { if s.Underruns.Load() != 1 { t.Fatalf("expected 1 underrun, got %d", s.Underruns.Load()) } + stats := s.Stats() + if stats.UnderrunStreak != 1 || stats.MaxUnderrunStreak != 1 { + t.Fatalf("unexpected streak: %d/%d", stats.UnderrunStreak, stats.MaxUnderrunStreak) + } +} + +func TestStreamSource_UnderrunStreakTracking(t *testing.T) { + s := NewStreamSource(16, 44100) + for i := 0; i < 3; i++ { + s.ReadFrame() + } + stats := s.Stats() + if stats.UnderrunStreak != 3 { + t.Fatalf("expected streak 3, got %d", stats.UnderrunStreak) + } + if stats.MaxUnderrunStreak != 3 { + t.Fatalf("expected max streak 3, got %d", stats.MaxUnderrunStreak) + } + + if !s.WriteFrame(NewFrame(0, 0)) { + t.Fatal("expected write to succeed") + } + s.ReadFrame() + stats = s.Stats() + if stats.UnderrunStreak != 0 { + t.Fatalf("expected streak reset to 0, got %d", stats.UnderrunStreak) + } + if stats.MaxUnderrunStreak != 3 { + t.Fatalf("expected max streak to stay 3, got %d", stats.MaxUnderrunStreak) + } } func TestStreamSource_Overflow(t *testing.T) {