| @@ -95,6 +95,8 @@ Live engine and driver telemetry. Only populated when TX is active. | |||||
| "framesWritten": 12345, | "framesWritten": 12345, | ||||
| "samplesWritten": 1408950000, | "samplesWritten": 1408950000, | ||||
| "underruns": 0, | "underruns": 0, | ||||
| "underrunStreak": 0, | |||||
| "maxUnderrunStreak": 0, | |||||
| "effectiveSampleRateHz": 2280000 | "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. | `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` | ### `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 | 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-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 | ## WS-04 Verifikation | ||||
| | Datum | Fokus | Ergebnis | | | 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 | 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-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 | Overflows atomic.Uint64 | ||||
| Written atomic.Uint64 | Written atomic.Uint64 | ||||
| highWatermark atomic.Int64 | highWatermark atomic.Int64 | ||||
| underrunStreak atomic.Uint64 | |||||
| maxUnderrunStreak atomic.Uint64 | |||||
| } | } | ||||
| // NewStreamSource creates a ring buffer with the given capacity (rounded up | // NewStreamSource creates a ring buffer with the given capacity (rounded up | ||||
| @@ -87,10 +89,12 @@ func (s *StreamSource) ReadFrame() Frame { | |||||
| wp := s.writePos.Load() | wp := s.writePos.Load() | ||||
| if rp >= wp { | if rp >= wp { | ||||
| s.Underruns.Add(1) | s.Underruns.Add(1) | ||||
| s.recordUnderrunStreak() | |||||
| return NewFrame(0, 0) | return NewFrame(0, 0) | ||||
| } | } | ||||
| f := s.ring[int(rp)&s.mask] | f := s.ring[int(rp)&s.mask] | ||||
| s.readPos.Add(1) | s.readPos.Add(1) | ||||
| s.resetUnderrunStreak() | |||||
| return f | return f | ||||
| } | } | ||||
| @@ -117,6 +121,8 @@ func (s *StreamSource) Stats() StreamStats { | |||||
| buffered = float64(available) / float64(s.size) | buffered = float64(available) / float64(s.size) | ||||
| } | } | ||||
| highWatermark := int(s.highWatermark.Load()) | highWatermark := int(s.highWatermark.Load()) | ||||
| currentStreak := int(s.underrunStreak.Load()) | |||||
| maxStreak := int(s.maxUnderrunStreak.Load()) | |||||
| return StreamStats{ | return StreamStats{ | ||||
| Available: available, | Available: available, | ||||
| Capacity: s.size, | Capacity: s.size, | ||||
| @@ -127,6 +133,8 @@ func (s *StreamSource) Stats() StreamStats { | |||||
| Written: s.Written.Load(), | Written: s.Written.Load(), | ||||
| Underruns: s.Underruns.Load(), | Underruns: s.Underruns.Load(), | ||||
| Overflows: s.Overflows.Load(), | Overflows: s.Overflows.Load(), | ||||
| UnderrunStreak: currentStreak, | |||||
| MaxUnderrunStreak: maxStreak, | |||||
| } | } | ||||
| } | } | ||||
| @@ -141,6 +149,8 @@ type StreamStats struct { | |||||
| Written uint64 `json:"written"` | Written uint64 `json:"written"` | ||||
| Underruns uint64 `json:"underruns"` | Underruns uint64 `json:"underruns"` | ||||
| Overflows uint64 `json:"overflows"` | Overflows uint64 `json:"overflows"` | ||||
| UnderrunStreak int `json:"underrunStreak"` | |||||
| MaxUnderrunStreak int `json:"maxUnderrunStreak"` | |||||
| } | } | ||||
| func (s *StreamSource) bufferedDurationSeconds(available int) float64 { | 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 --- | ||||
| // StreamResampler wraps a StreamSource and rate-converts from the stream's | // 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 { | if s.Underruns.Load() != 1 { | ||||
| t.Fatalf("expected 1 underrun, got %d", s.Underruns.Load()) | 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) { | func TestStreamSource_Overflow(t *testing.T) { | ||||