|
|
|
@@ -10,15 +10,22 @@ import ( |
|
|
|
) |
|
|
|
|
|
|
|
type Runtime struct { |
|
|
|
sink *audio.StreamSource |
|
|
|
source Source |
|
|
|
started atomic.Bool |
|
|
|
onTitle func(string) |
|
|
|
sink *audio.StreamSource |
|
|
|
source Source |
|
|
|
started atomic.Bool |
|
|
|
onTitle func(string) |
|
|
|
prebuffer time.Duration |
|
|
|
|
|
|
|
ctx context.Context |
|
|
|
cancel context.CancelFunc |
|
|
|
wg sync.WaitGroup |
|
|
|
|
|
|
|
work *frameBuffer |
|
|
|
workSampleRate int |
|
|
|
prebufferFrames int |
|
|
|
gateOpen bool |
|
|
|
seenChunk bool |
|
|
|
|
|
|
|
mu sync.RWMutex |
|
|
|
active SourceDescriptor |
|
|
|
stats RuntimeStats |
|
|
|
@@ -32,10 +39,40 @@ func WithStreamTitleHandler(handler func(string)) RuntimeOption { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func WithPrebuffer(d time.Duration) RuntimeOption { |
|
|
|
return func(r *Runtime) { |
|
|
|
if d < 0 { |
|
|
|
d = 0 |
|
|
|
} |
|
|
|
r.prebuffer = d |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func WithPrebufferMs(ms int) RuntimeOption { |
|
|
|
return func(r *Runtime) { |
|
|
|
if ms < 0 { |
|
|
|
ms = 0 |
|
|
|
} |
|
|
|
r.prebuffer = time.Duration(ms) * time.Millisecond |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Runtime { |
|
|
|
sampleRate := 44100 |
|
|
|
capacity := 1024 |
|
|
|
if sink != nil { |
|
|
|
if sink.SampleRate > 0 { |
|
|
|
sampleRate = sink.SampleRate |
|
|
|
} |
|
|
|
if sinkCap := sink.Stats().Capacity; sinkCap > 0 { |
|
|
|
capacity = sinkCap * 2 |
|
|
|
} |
|
|
|
} |
|
|
|
r := &Runtime{ |
|
|
|
sink: sink, |
|
|
|
source: src, |
|
|
|
sink: sink, |
|
|
|
source: src, |
|
|
|
work: newFrameBuffer(capacity), |
|
|
|
workSampleRate: sampleRate, |
|
|
|
stats: RuntimeStats{ |
|
|
|
State: "idle", |
|
|
|
}, |
|
|
|
@@ -45,6 +82,17 @@ func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Ru |
|
|
|
opt(r) |
|
|
|
} |
|
|
|
} |
|
|
|
if r.workSampleRate > 0 && r.prebuffer > 0 { |
|
|
|
r.prebufferFrames = int(r.prebuffer.Seconds() * float64(r.workSampleRate)) |
|
|
|
} |
|
|
|
minCapacity := 256 |
|
|
|
if r.prebufferFrames > 0 && minCapacity < r.prebufferFrames*2 { |
|
|
|
minCapacity = r.prebufferFrames * 2 |
|
|
|
} |
|
|
|
if r.work == nil || r.work.capacity() < minCapacity { |
|
|
|
r.work = newFrameBuffer(minCapacity) |
|
|
|
} |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
return r |
|
|
|
} |
|
|
|
|
|
|
|
@@ -69,6 +117,12 @@ func (r *Runtime) Start(ctx context.Context) error { |
|
|
|
r.mu.Lock() |
|
|
|
r.active = r.source.Descriptor() |
|
|
|
r.stats.State = "starting" |
|
|
|
r.stats.Prebuffering = false |
|
|
|
r.stats.WriteBlocked = false |
|
|
|
r.gateOpen = false |
|
|
|
r.seenChunk = false |
|
|
|
r.work.reset() |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
r.mu.Unlock() |
|
|
|
if err := r.source.Start(r.ctx); err != nil { |
|
|
|
r.started.Store(false) |
|
|
|
@@ -102,12 +156,11 @@ func (r *Runtime) Stop() error { |
|
|
|
|
|
|
|
func (r *Runtime) run() { |
|
|
|
defer r.wg.Done() |
|
|
|
r.mu.Lock() |
|
|
|
r.stats.State = "running" |
|
|
|
r.mu.Unlock() |
|
|
|
|
|
|
|
ch := r.source.Chunks() |
|
|
|
errCh := r.source.Errors() |
|
|
|
ticker := time.NewTicker(10 * time.Millisecond) |
|
|
|
defer ticker.Stop() |
|
|
|
var titleCh <-chan string |
|
|
|
if src, ok := r.source.(StreamTitleSource); ok && r.onTitle != nil { |
|
|
|
titleCh = src.StreamTitleUpdates() |
|
|
|
@@ -126,15 +179,19 @@ func (r *Runtime) run() { |
|
|
|
} |
|
|
|
r.mu.Lock() |
|
|
|
r.stats.State = "degraded" |
|
|
|
r.stats.Prebuffering = false |
|
|
|
r.mu.Unlock() |
|
|
|
case chunk, ok := <-ch: |
|
|
|
if !ok { |
|
|
|
r.mu.Lock() |
|
|
|
r.stats.State = "stopped" |
|
|
|
r.stats.Prebuffering = false |
|
|
|
r.mu.Unlock() |
|
|
|
return |
|
|
|
} |
|
|
|
r.handleChunk(chunk) |
|
|
|
case <-ticker.C: |
|
|
|
r.drainWorkingBuffer() |
|
|
|
case title, ok := <-titleCh: |
|
|
|
if !ok { |
|
|
|
titleCh = nil |
|
|
|
@@ -146,6 +203,10 @@ func (r *Runtime) run() { |
|
|
|
} |
|
|
|
|
|
|
|
func (r *Runtime) handleChunk(chunk PCMChunk) { |
|
|
|
r.mu.Lock() |
|
|
|
r.seenChunk = true |
|
|
|
r.mu.Unlock() |
|
|
|
|
|
|
|
frames, err := ChunkToFrames(chunk) |
|
|
|
if err != nil { |
|
|
|
r.mu.Lock() |
|
|
|
@@ -156,7 +217,7 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { |
|
|
|
} |
|
|
|
dropped := uint64(0) |
|
|
|
for _, frame := range frames { |
|
|
|
if !r.sink.WriteFrame(frame) { |
|
|
|
if !r.work.push(frame) { |
|
|
|
dropped++ |
|
|
|
} |
|
|
|
} |
|
|
|
@@ -167,11 +228,87 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { |
|
|
|
if chunk.Channels > 0 { |
|
|
|
r.active.Channels = chunk.Channels |
|
|
|
} |
|
|
|
r.stats.State = "running" |
|
|
|
r.stats.LastChunkAt = time.Now() |
|
|
|
r.stats.DroppedFrames += dropped |
|
|
|
r.stats.WriteBlocked = dropped > 0 |
|
|
|
if dropped > 0 { |
|
|
|
r.stats.State = "degraded" |
|
|
|
} |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
r.mu.Unlock() |
|
|
|
r.drainWorkingBuffer() |
|
|
|
} |
|
|
|
|
|
|
|
func (r *Runtime) drainWorkingBuffer() { |
|
|
|
r.mu.Lock() |
|
|
|
defer r.mu.Unlock() |
|
|
|
if r.sink == nil { |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
return |
|
|
|
} |
|
|
|
bufferedFrames := r.work.available() |
|
|
|
if !r.gateOpen { |
|
|
|
switch { |
|
|
|
case bufferedFrames == 0: |
|
|
|
if r.stats.State == "degraded" { |
|
|
|
// Keep degraded visible until fresh audio recovers runtime. |
|
|
|
} else if !r.seenChunk { |
|
|
|
r.stats.State = "starting" |
|
|
|
} else if r.stats.State != "degraded" { |
|
|
|
r.stats.State = "running" |
|
|
|
} |
|
|
|
r.stats.Prebuffering = false |
|
|
|
r.stats.WriteBlocked = false |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
return |
|
|
|
case r.prebufferFrames > 0 && bufferedFrames < r.prebufferFrames: |
|
|
|
r.stats.State = "prebuffering" |
|
|
|
r.stats.Prebuffering = true |
|
|
|
r.stats.WriteBlocked = false |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
return |
|
|
|
default: |
|
|
|
r.gateOpen = true |
|
|
|
} |
|
|
|
} |
|
|
|
writeBlocked := false |
|
|
|
for r.work.available() > 0 { |
|
|
|
frame, ok := r.work.peek() |
|
|
|
if !ok { |
|
|
|
break |
|
|
|
} |
|
|
|
if !r.sink.WriteFrame(frame) { |
|
|
|
writeBlocked = true |
|
|
|
break |
|
|
|
} |
|
|
|
r.work.pop() |
|
|
|
} |
|
|
|
if r.work.available() == 0 && r.prebufferFrames > 0 { |
|
|
|
// Re-arm the gate after dry-out to rebuild margin before resuming. |
|
|
|
r.gateOpen = false |
|
|
|
} |
|
|
|
r.stats.Prebuffering = false |
|
|
|
r.stats.WriteBlocked = writeBlocked |
|
|
|
if writeBlocked { |
|
|
|
r.stats.State = "degraded" |
|
|
|
} else { |
|
|
|
r.stats.State = "running" |
|
|
|
} |
|
|
|
r.updateBufferedStatsLocked() |
|
|
|
} |
|
|
|
|
|
|
|
func (r *Runtime) updateBufferedStatsLocked() { |
|
|
|
available := r.work.available() |
|
|
|
capacity := r.work.capacity() |
|
|
|
buffered := 0.0 |
|
|
|
if capacity > 0 { |
|
|
|
buffered = float64(available) / float64(capacity) |
|
|
|
} |
|
|
|
bufferedSeconds := 0.0 |
|
|
|
if r.workSampleRate > 0 { |
|
|
|
bufferedSeconds = float64(available) / float64(r.workSampleRate) |
|
|
|
} |
|
|
|
r.stats.Buffered = buffered |
|
|
|
r.stats.BufferedSeconds = bufferedSeconds |
|
|
|
} |
|
|
|
|
|
|
|
func (r *Runtime) Stats() Stats { |
|
|
|
@@ -184,9 +321,65 @@ func (r *Runtime) Stats() Stats { |
|
|
|
if r.source != nil { |
|
|
|
sourceStats = r.source.Stats() |
|
|
|
} |
|
|
|
if sourceStats.BufferedSeconds < runtimeStats.BufferedSeconds { |
|
|
|
sourceStats.BufferedSeconds = runtimeStats.BufferedSeconds |
|
|
|
} |
|
|
|
return Stats{ |
|
|
|
Active: active, |
|
|
|
Source: sourceStats, |
|
|
|
Runtime: runtimeStats, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
type frameBuffer struct { |
|
|
|
frames []audio.Frame |
|
|
|
head int |
|
|
|
len int |
|
|
|
} |
|
|
|
|
|
|
|
func newFrameBuffer(capacity int) *frameBuffer { |
|
|
|
if capacity < 1 { |
|
|
|
capacity = 1 |
|
|
|
} |
|
|
|
return &frameBuffer{frames: make([]audio.Frame, capacity)} |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) capacity() int { |
|
|
|
return len(b.frames) |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) available() int { |
|
|
|
return b.len |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) reset() { |
|
|
|
b.head = 0 |
|
|
|
b.len = 0 |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) push(frame audio.Frame) bool { |
|
|
|
if b.len >= len(b.frames) { |
|
|
|
return false |
|
|
|
} |
|
|
|
idx := (b.head + b.len) % len(b.frames) |
|
|
|
b.frames[idx] = frame |
|
|
|
b.len++ |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) peek() (audio.Frame, bool) { |
|
|
|
if b.len == 0 { |
|
|
|
return audio.Frame{}, false |
|
|
|
} |
|
|
|
return b.frames[b.head], true |
|
|
|
} |
|
|
|
|
|
|
|
func (b *frameBuffer) pop() (audio.Frame, bool) { |
|
|
|
if b.len == 0 { |
|
|
|
return audio.Frame{}, false |
|
|
|
} |
|
|
|
frame := b.frames[b.head] |
|
|
|
b.head = (b.head + 1) % len(b.frames) |
|
|
|
b.len-- |
|
|
|
return frame, true |
|
|
|
} |