@@ -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.
16271719type 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.
16521744func (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.
16601755func (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.
18191934type 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