Skip to content

Commit 12f5ecd

Browse files
committed
perf(hls): bound the live audio queue by bytes, not chunk count
The per-stream PCM queue between the audio router and the HLS feed loop was a 1024-slot channel with no byte bound. With RTSP ingest delivering 32 KiB frames, a stalled consumer could hold 1024 x 32 KiB = 33.5 MB of PCM per stream (349 s of 48 kHz mono audio), none of it servable: a live listener sits ~4 s behind the edge, so everything past the first couple of segments is encoded into segments no player will request. Go sizes the heap from its high-water mark, so a single hiccup that fills the queue raises RSS for the life of the process. Bound the queue by queued PCM bytes instead of by chunk count. Chunk size belongs to the producer (RTSP delivers 32 KiB frames, a directly captured sound card delivers miniaudio's 10 ms period of ~960 bytes, a resampled route delivers something else again), so a chunk-count bound means a different memory ceiling and a different buffered depth for every source. A byte budget gives every producer the same ceiling and the same wall-clock depth. The native path caps at 512 KiB (~5.4 s), dropping the oldest chunks past that; worst case falls from 33.5 MB to 512 KiB plus the 48 KB ring. The FFmpeg path is left byte-unbounded on purpose: it feeds PCM to a separate process that cannot see a gap in what it is handed, and its PDT epoch is computed once, so a dropped chunk would shift the playlist timeline permanently. It keeps the prior 1024-slot behaviour exactly.
1 parent af77b40 commit 12f5ecd

6 files changed

Lines changed: 504 additions & 61 deletions

File tree

internal/api/v2/audio/audio_hls.go

Lines changed: 163 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ const (
112112
// gives the native path's muxer configuration something to be checked
113113
// against rather than a second bare literal.
114114
hlsConsumerChannels = 1
115+
116+
// hlsFeedQueueUnbounded disables an audio feed queue's byte budget, leaving
117+
// the channel's slot count (defaultReadBufferSize) as the only bound.
118+
//
119+
// This is what the FFmpeg path uses, deliberately. Its consumer writes PCM
120+
// into a FIFO that the FFmpeg process reads, and FFmpeg cannot see a gap in
121+
// what it is handed: it encodes whatever arrives as one continuous stream.
122+
// Since pdtOffset is computed once from the first playlist and then applied
123+
// to every later one, every dropped chunk shifts EXT-X-PROGRAM-DATE-TIME
124+
// permanently behind wall-clock time, by the duration dropped. A budget
125+
// tight enough to bite before fifoWriteTimeout (30s) gives up on the stall
126+
// while FFmpeg is still willing to wait it out, and pays for it with a
127+
// timeline that never recovers.
128+
//
129+
// The native path has no such coupling: it hands the muxer each chunk's own
130+
// capture timestamp, so a drop shows up as a timestamp jump the muxer
131+
// accounts for, and the timeline stays true. That is why it can afford
132+
// nativeHLSFeedQueueBytes and this path cannot.
133+
hlsFeedQueueUnbounded = 0
115134
)
116135

117136
// HLSStreamInfo contains information about an active HLS streaming session
@@ -1544,7 +1563,7 @@ func (c *Handler) setupWindowsAudioFeed(ctx context.Context, sourceID string, cm
15441563
}()
15451564
apicore.GetLogger().Debug("Starting audio feed via stdin", logger.String("source_id", privacy.SanitizeRTSPUrl(sourceID)))
15461565

1547-
audioChan, cleanup, err := c.setupAudioCallback(sourceID, c.ffmpegConsumerSampleRate())
1566+
feed, cleanup, err := c.setupAudioCallback(sourceID, c.ffmpegConsumerSampleRate(), hlsFeedQueueUnbounded)
15481567
if err != nil {
15491568
apicore.GetLogger().Error("Error setting up audio callback", logger.Error(err))
15501569
return
@@ -1556,11 +1575,14 @@ func (c *Handler) setupWindowsAudioFeed(ctx context.Context, sourceID string, cm
15561575
case <-ctx.Done():
15571576
apicore.GetLogger().Debug("Audio feed terminated due to context cancellation", logger.String("source_id", privacy.SanitizeRTSPUrl(sourceID)))
15581577
return
1559-
case chunk, ok := <-audioChan:
1578+
case chunk, ok := <-feed.ch:
15601579
if !ok {
15611580
apicore.GetLogger().Debug("Audio channel closed", logger.String("source_id", privacy.SanitizeRTSPUrl(sourceID)))
15621581
return
15631582
}
1583+
// Report the dequeue before anything can return early, so the
1584+
// producer's byte accounting cannot leak on a mid-loop exit.
1585+
feed.release(len(chunk.data))
15641586

15651587
data := chunk.data
15661588
written := 0
@@ -1622,12 +1644,82 @@ type audioChunk struct {
16221644
timestamp time.Time
16231645
}
16241646

1647+
// audioFeed is the per-stream PCM queue between the router, which produces
1648+
// chunks on its dispatch goroutine, and an HLS feed loop, which drains them.
1649+
//
1650+
// The queue is bounded by bytes of queued PCM rather than by chunk count,
1651+
// because chunk size is a property of the producer, not of HLS: the FFmpeg
1652+
// ingest path emits ffmpegBufferSize (32 KiB) per frame, a directly captured
1653+
// sound card emits miniaudio's default 10 ms period (under 1 KiB at 48 kHz
1654+
// mono), and a route whose source rate differs from the consumer rate has a
1655+
// resampler in between that changes the size again. A chunk-count bound
1656+
// therefore means a different memory ceiling and a different amount of buffered
1657+
// audio for every source; one small enough to bound RTSP frames leaves a sound
1658+
// card with a fraction of a second of slack. A byte budget gives every producer
1659+
// the same ceiling and the same wall-clock depth.
1660+
//
1661+
// Chunks leave the queue two ways, and each one has to be accounted for. The
1662+
// producer evicts the oldest to make room: in makeRoom when the byte budget is
1663+
// exceeded, and in Write's overflow arm when the channel's slots fill before the
1664+
// byte budget does (which is the only eviction path when there is no budget).
1665+
// The consumer receives. The invariant is simply that every
1666+
// path removing a chunk from ch calls release(len(chunk.data)) for it; skipping
1667+
// it leaves the producer believing the queue holds bytes that are already gone,
1668+
// and the queue eventually evicts on every write.
1669+
type audioFeed struct {
1670+
ch chan audioChunk
1671+
1672+
// maxBytes is the byte budget. hlsFeedQueueUnbounded disables it and leaves
1673+
// the channel's slot count as the only bound.
1674+
maxBytes int64
1675+
1676+
// queued is the PCM currently sitting in ch. It is only ever advisory:
1677+
// nothing serializes a producer's enqueue against a consumer's release, so
1678+
// it can lag reality by a chunk in either direction. That is bounded and
1679+
// harmless, since one chunk of slack on a multi-second budget changes
1680+
// neither the memory ceiling nor the buffered depth in any way that matters.
1681+
queued atomic.Int64
1682+
}
1683+
1684+
// release reports that a chunk of n bytes has been taken off the queue.
1685+
func (f *audioFeed) release(n int) {
1686+
f.queued.Add(-int64(n))
1687+
}
1688+
1689+
// makeRoom evicts the oldest chunks until n more bytes fit within the byte
1690+
// budget, returning the number of chunks it dropped.
1691+
//
1692+
// Dropping the oldest is the right policy for a live stream: the queue only
1693+
// grows when the encoder falls behind, and audio that far behind the live edge
1694+
// is past the point where any player would still ask for it.
1695+
//
1696+
// It stops early when the queue runs empty, which happens when a single chunk
1697+
// is larger than the whole budget. Refusing that chunk would silence the stream
1698+
// permanently rather than briefly, so it is admitted and the budget is exceeded
1699+
// by that one chunk.
1700+
func (f *audioFeed) makeRoom(n int) int {
1701+
if f.maxBytes <= 0 {
1702+
return 0
1703+
}
1704+
dropped := 0
1705+
for f.queued.Load()+int64(n) > f.maxBytes {
1706+
select {
1707+
case old := <-f.ch:
1708+
f.release(len(old.data))
1709+
dropped++
1710+
default:
1711+
return dropped
1712+
}
1713+
}
1714+
return dropped
1715+
}
1716+
16251717
// hlsConsumer implements audiocore.AudioConsumer for HLS streaming.
1626-
// It forwards audio frames to a channel for encoding.
1718+
// It forwards audio frames to a bounded feed queue (see audioFeed) for encoding.
16271719
type hlsConsumer struct {
16281720
id string
16291721
sourceID string
1630-
ch chan audioChunk
1722+
feed *audioFeed
16311723
rate int
16321724
depth int
16331725
channels int
@@ -1651,51 +1743,69 @@ func (h *hlsConsumer) BitDepth() int { return h.depth }
16511743
// Channels returns the expected channel count.
16521744
func (h *hlsConsumer) Channels() int { return h.channels }
16531745

1654-
// Write delivers audio frame data to the HLS channel.
1746+
// Write delivers audio frame data to the HLS feed queue.
16551747
//
16561748
// The frame data is copied before being sent so the caller (the audio router)
1657-
// may safely reuse or recycle the underlying slice after Write returns. FFmpeg
1658-
// reads asynchronously from the channel, so any retained slice header would
1659-
// race with the caller's buffer reuse.
1749+
// may safely reuse or recycle the underlying slice after Write returns. The
1750+
// feed loop reads asynchronously from the queue, so any retained slice header
1751+
// would race with the caller's buffer reuse.
1752+
//
1753+
// The send never blocks. A feed loop that has fallen behind costs the stream
1754+
// its oldest queued audio, not the router its dispatch goroutine.
16601755
func (h *hlsConsumer) Write(frame audiocore.AudioFrame) error { //nolint:gocritic // hugeParam: signature required by AudioConsumer interface
16611756
if h.closed.Load() {
16621757
return audiocore.ErrConsumerClosed
16631758
}
16641759

1665-
// Copy once up front. Both select arms below need an owned slice.
1760+
// Copy once up front. Every send arm below needs an owned slice.
16661761
chunk := audioChunk{data: slices.Clone(frame.Data), timestamp: frame.Timestamp}
1762+
chunkBytes := int64(len(chunk.data))
1763+
1764+
// Evict down to the byte budget first, so the queue is bounded by the PCM
1765+
// it holds rather than by the slot count of the channel behind it.
1766+
dropped := h.feed.makeRoom(len(chunk.data))
16671767

16681768
select {
1669-
case h.ch <- chunk:
1769+
case h.feed.ch <- chunk:
1770+
h.feed.queued.Add(chunkBytes)
16701771
default:
1671-
// Channel full, drop oldest to make room
1672-
dropped := false
1772+
// Slots exhausted while still inside the byte budget, which is what a
1773+
// stream of chunks far smaller than the budget looks like. Drop the
1774+
// oldest to make room, on the same reasoning as makeRoom.
16731775
select {
1674-
case <-h.ch:
1675-
dropped = true
1776+
case old := <-h.feed.ch:
1777+
h.feed.release(len(old.data))
1778+
dropped++
16761779
default:
16771780
}
1678-
// Non-blocking send - drop if still full
16791781
select {
1680-
case h.ch <- chunk:
1782+
case h.feed.ch <- chunk:
1783+
h.feed.queued.Add(chunkBytes)
16811784
default:
1785+
// Unreachable under the single-producer model (the eviction above,
1786+
// or the consumer draining, leaves a free slot before this retry),
1787+
// but count the loss rather than trust that invariant: if a second
1788+
// writer is ever added, the drop stays honest instead of silently
1789+
// under-reporting.
1790+
dropped++
16821791
}
1792+
}
16831793

1684-
if dropped {
1685-
h.dropMu.Lock()
1686-
h.dropCount++
1687-
now := time.Now()
1688-
if now.Sub(h.lastDropLog) >= hlsDropLogInterval {
1689-
sanitizedID := privacy.SanitizeRTSPUrl(h.sourceID)
1690-
apicore.GetLogger().Warn("HLS audio data dropped: channel full",
1691-
logger.String("source_id", sanitizedID),
1692-
logger.Int64("drops_since_last_log", h.dropCount),
1693-
logger.Int("channel_cap", defaultReadBufferSize))
1694-
h.dropCount = 0
1695-
h.lastDropLog = now
1696-
}
1697-
h.dropMu.Unlock()
1794+
if dropped > 0 {
1795+
h.dropMu.Lock()
1796+
h.dropCount += int64(dropped)
1797+
now := time.Now()
1798+
if now.Sub(h.lastDropLog) >= hlsDropLogInterval {
1799+
sanitizedID := privacy.SanitizeRTSPUrl(h.sourceID)
1800+
apicore.GetLogger().Warn("HLS audio data dropped: feed queue full",
1801+
logger.String("source_id", sanitizedID),
1802+
logger.Int64("drops_since_last_log", h.dropCount),
1803+
logger.Int64("queued_bytes", h.feed.queued.Load()),
1804+
logger.Int64("max_queued_bytes", h.feed.maxBytes))
1805+
h.dropCount = 0
1806+
h.lastDropLog = now
16981807
}
1808+
h.dropMu.Unlock()
16991809
}
17001810
return nil
17011811
}
@@ -1716,11 +1826,16 @@ func (c *Handler) ffmpegConsumerSampleRate() int {
17161826
return hlsDefaultSampleRate
17171827
}
17181828

1719-
// setupAudioCallback sets up the audio callback channel using the AudioRouter.
1829+
// setupAudioCallback sets up the audio feed queue using the AudioRouter.
17201830
// sampleRate is the rate the consumer declares; the router inserts a resampler
1721-
// whenever the source differs from it.
1722-
func (c *Handler) setupAudioCallback(sourceID string, sampleRate int) (audioChan chan audioChunk, cleanup func(), err error) {
1723-
audioChan = make(chan audioChunk, defaultReadBufferSize)
1831+
// whenever the source differs from it. maxQueuedBytes is the queue's byte
1832+
// budget, or hlsFeedQueueUnbounded to leave the channel's slot count as the only
1833+
// bound; see audioFeed for why the bound is in bytes rather than chunks.
1834+
func (c *Handler) setupAudioCallback(sourceID string, sampleRate int, maxQueuedBytes int64) (feed *audioFeed, cleanup func(), err error) {
1835+
feed = &audioFeed{
1836+
ch: make(chan audioChunk, defaultReadBufferSize),
1837+
maxBytes: maxQueuedBytes,
1838+
}
17241839

17251840
// hlsConsumerChannels is what this consumer declares to the router, and the
17261841
// native path derives its muxer sample-frame size from nativeHLSChannels.
@@ -1739,7 +1854,7 @@ func (c *Handler) setupAudioCallback(sourceID string, sampleRate int) (audioChan
17391854
consumer := &hlsConsumer{
17401855
id: consumerID,
17411856
sourceID: sourceID,
1742-
ch: audioChan,
1857+
feed: feed,
17431858
rate: sampleRate,
17441859
depth: conf.BitDepth,
17451860
channels: hlsConsumerChannels,
@@ -1785,7 +1900,7 @@ func (c *Handler) setupAudioCallback(sourceID string, sampleRate int) (audioChan
17851900
apicore.GetLogger().Debug("Removed HLS audio route", logger.String("source_id", privacy.SanitizeRTSPUrl(sourceID)), logger.String("consumer_id", consumerID))
17861901
}
17871902

1788-
return audioChan, cleanup, nil
1903+
return feed, cleanup, nil
17891904
}
17901905

17911906
// writeToFIFO performs a context-aware write to the FIFO pipe.
@@ -1817,10 +1932,10 @@ func writeToFIFO(ctx context.Context, fifo *os.File, data []byte) error {
18171932
// callback is already registered and the FIFO is open when FFmpeg captures its
18181933
// PROGRAM_DATE_TIME epoch.
18191934
type audioFeedResources struct {
1820-
audioChan chan audioChunk // Buffered channel receiving audio chunks from the broadcast callback
1821-
fifo *os.File // FIFO pipe opened for writing to FFmpeg (nil on the native path)
1822-
secFS *securefs.SecureFS // Secure filesystem (nil on the native path; must be closed when done)
1823-
cleanup func() // Releases all resources (unregisters callback, closes FIFO and secFS)
1935+
feed *audioFeed // Feed queue from the router (byte-budgeted on the native path, slot-bounded otherwise)
1936+
fifo *os.File // FIFO pipe opened for writing to FFmpeg (nil on the native path)
1937+
secFS *securefs.SecureFS // Secure filesystem (nil on the native path; must be closed when done)
1938+
cleanup func() // Releases all resources (unregisters callback, closes FIFO and secFS)
18241939
}
18251940

18261941
// prepareAudioFeed initialises the audio callback and opens the FIFO pipe.
@@ -1843,7 +1958,7 @@ func (c *Handler) prepareAudioFeed(sourceID, pipePath string) (*audioFeedResourc
18431958

18441959
// Register the audio callback first so audio chunks start buffering
18451960
// in the channel while we open the FIFO and before FFmpeg starts.
1846-
audioChan, callbackCleanup, err := c.setupAudioCallback(sourceID, c.ffmpegConsumerSampleRate())
1961+
feed, callbackCleanup, err := c.setupAudioCallback(sourceID, c.ffmpegConsumerSampleRate(), hlsFeedQueueUnbounded)
18471962
if err != nil {
18481963
if closeErr := secFS.Close(); closeErr != nil {
18491964
apicore.GetLogger().Error("Failed to close secure filesystem", logger.Error(closeErr))
@@ -1866,9 +1981,9 @@ func (c *Handler) prepareAudioFeed(sourceID, pipePath string) (*audioFeedResourc
18661981
}
18671982

18681983
res := &audioFeedResources{
1869-
audioChan: audioChan,
1870-
fifo: fifo,
1871-
secFS: secFS,
1984+
feed: feed,
1985+
fifo: fifo,
1986+
secFS: secFS,
18721987
cleanup: func() {
18731988
callbackCleanup()
18741989
if closeErr := fifo.Close(); closeErr != nil {
@@ -1915,11 +2030,14 @@ func (c *Handler) runAudioFeedLoop(ctx context.Context, sourceID string, stream
19152030
apicore.GetLogger().Debug("Audio feed stopped due to context cancellation",
19162031
logger.String("source_id", sanitizedID))
19172032
return
1918-
case chunk, ok := <-res.audioChan:
2033+
case chunk, ok := <-res.feed.ch:
19192034
if !ok {
19202035
apicore.GetLogger().Debug("Audio channel closed", logger.String("source_id", sanitizedID))
19212036
return
19222037
}
2038+
// Report the dequeue before anything can return early, so the
2039+
// producer's byte accounting cannot leak on a mid-loop exit.
2040+
res.feed.release(len(chunk.data))
19232041

19242042
data := chunk.data
19252043
writeStart := time.Now()

0 commit comments

Comments
 (0)