116 lines
3.3 KiB
Go
116 lines
3.3 KiB
Go
// ABOUTME: Per-client buffer tracker for send-ahead pacing
|
|
// ABOUTME: Tracks in-flight bytes and duration to prevent client buffer overflow
|
|
package sendspin
|
|
|
|
const (
|
|
// DefaultMaxBufferDurationUs caps buffer duration at 30 seconds
|
|
// regardless of byte capacity, matching aiosendspin's behavior.
|
|
DefaultMaxBufferDurationUs = 30_000_000
|
|
)
|
|
|
|
// bufferedChunk records one sent chunk's end time, size, and duration.
|
|
type bufferedChunk struct {
|
|
endTimeUs int64
|
|
byteCount int
|
|
durationUs int64
|
|
}
|
|
|
|
// BufferTracker tracks the estimated bytes and duration of audio that a
|
|
// client has buffered (sent but not yet played). The server checks
|
|
// CanSend before each chunk send and skips the client if the buffer is
|
|
// full. PruneConsumed removes chunks whose playback time has passed.
|
|
//
|
|
// All methods are called from the same goroutine (the audio tick loop)
|
|
// so no synchronization is needed.
|
|
type BufferTracker struct {
|
|
ring []bufferedChunk
|
|
head int // index of the oldest entry
|
|
count int // number of valid entries
|
|
|
|
bufferedBytes int
|
|
bufferedDurationUs int64
|
|
|
|
capacityBytes int
|
|
maxDurationUs int64
|
|
}
|
|
|
|
// NewBufferTracker creates a tracker with the given byte capacity and
|
|
// a 30-second duration cap.
|
|
func NewBufferTracker(capacityBytes int) *BufferTracker {
|
|
// Pre-allocate for ~30s of 20ms chunks = 1500 slots.
|
|
// The ring grows if needed but this avoids early resizes.
|
|
initialCap := 1500
|
|
if capacityBytes <= 0 {
|
|
capacityBytes = 1048576 // 1MB default
|
|
}
|
|
return &BufferTracker{
|
|
ring: make([]bufferedChunk, initialCap),
|
|
capacityBytes: capacityBytes,
|
|
maxDurationUs: DefaultMaxBufferDurationUs,
|
|
}
|
|
}
|
|
|
|
// PruneConsumed removes all chunks whose end time has passed. Call this
|
|
// once per tick before CanSend.
|
|
func (t *BufferTracker) PruneConsumed(nowUs int64) {
|
|
for t.count > 0 {
|
|
entry := t.ring[t.head]
|
|
if entry.endTimeUs > nowUs {
|
|
break
|
|
}
|
|
t.bufferedBytes -= entry.byteCount
|
|
t.bufferedDurationUs -= entry.durationUs
|
|
t.head = (t.head + 1) % len(t.ring)
|
|
t.count--
|
|
}
|
|
}
|
|
|
|
// CanSend reports whether a chunk of the given size and duration can be
|
|
// sent without exceeding the client's buffer capacity or the duration cap.
|
|
func (t *BufferTracker) CanSend(byteCount int, durationUs int64) bool {
|
|
if t.bufferedBytes+byteCount > t.capacityBytes {
|
|
return false
|
|
}
|
|
if t.bufferedDurationUs+durationUs > t.maxDurationUs {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Register records a sent chunk. Call this after CanSend returns true
|
|
// and the chunk has been enqueued for transmission.
|
|
func (t *BufferTracker) Register(endTimeUs int64, byteCount int, durationUs int64) {
|
|
if t.count == len(t.ring) {
|
|
t.grow()
|
|
}
|
|
idx := (t.head + t.count) % len(t.ring)
|
|
t.ring[idx] = bufferedChunk{
|
|
endTimeUs: endTimeUs,
|
|
byteCount: byteCount,
|
|
durationUs: durationUs,
|
|
}
|
|
t.count++
|
|
t.bufferedBytes += byteCount
|
|
t.bufferedDurationUs += durationUs
|
|
}
|
|
|
|
// BufferedBytes returns the current estimated bytes in the client's buffer.
|
|
func (t *BufferTracker) BufferedBytes() int {
|
|
return t.bufferedBytes
|
|
}
|
|
|
|
// BufferedDurationUs returns the current estimated duration in the client's buffer.
|
|
func (t *BufferTracker) BufferedDurationUs() int64 {
|
|
return t.bufferedDurationUs
|
|
}
|
|
|
|
func (t *BufferTracker) grow() {
|
|
newCap := len(t.ring) * 2
|
|
newRing := make([]bufferedChunk, newCap)
|
|
for i := 0; i < t.count; i++ {
|
|
newRing[i] = t.ring[(t.head+i)%len(t.ring)]
|
|
}
|
|
t.ring = newRing
|
|
t.head = 0
|
|
}
|