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