Skip to content

Commit 6d6364b

Browse files
committed
feat(transcode): shared per-input decoder hub — one decode pass fans PCM to N encoders
1 parent fe4e265 commit 6d6364b

2 files changed

Lines changed: 352 additions & 82 deletions

File tree

relay/decoder_hub.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package relay
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/DatanoiseTV/tinyice/logger"
13+
)
14+
15+
// readerDecoder adapts an io.Reader producing PCM bytes into the
16+
// PCMDecoder interface that NewLinearResampler / EncodeMP3 / EncodeOpus
17+
// expect — those callers want a SampleRate() method alongside Read.
18+
// The shim is here (next to the hub) because the hub is the only
19+
// producer of "raw PCM with a known sample rate but no decoder
20+
// object behind it".
21+
type readerDecoder struct {
22+
r io.Reader
23+
sr int
24+
}
25+
26+
func (d *readerDecoder) Read(p []byte) (int, error) { return d.r.Read(p) }
27+
func (d *readerDecoder) SampleRate() int { return d.sr }
28+
29+
// pcmMountSuffix is appended to an input mount to name the internal
30+
// shared-decoder PCM stream, e.g. "/electronica" -> "/electronica/_pcm".
31+
// The internal stream is invisible to listeners and the public API; it
32+
// only fans S16LE stereo PCM from one decoder to N transcoder
33+
// encoders.
34+
const pcmMountSuffix = "/_pcm"
35+
36+
// pcmStream holds the side-channel info we attach to a PCM-fanout
37+
// Stream beyond what Stream itself carries: native sample rate +
38+
// pump lifecycle. Tracked in DecoderHub.streams.
39+
type pcmStream struct {
40+
mu sync.Mutex
41+
42+
// stream is the internal Stream holding PCM bytes.
43+
stream *Stream
44+
45+
// sampleRate is the native rate produced by the source decoder
46+
// (48000 for Opus, 44100 for most MP3 / Vorbis sources, etc.).
47+
sampleRate int
48+
49+
// ready is closed once the decoder has been opened and the
50+
// sample rate is known. Subscribers wait on this before reading.
51+
ready chan struct{}
52+
53+
// initErr captures a fatal start-up error so subscribers don't
54+
// block forever waiting for ready.
55+
initErr error
56+
57+
// cancel stops the pump goroutine. Set when the pump starts.
58+
cancel context.CancelFunc
59+
60+
// refcount tracks how many transcoders currently hold this
61+
// shared decoder. When it drops to zero the pump is cancelled
62+
// and the entry is removed.
63+
refcount int
64+
}
65+
66+
// DecoderHub owns the shared-decoder pumps keyed by input mount. The
67+
// TranscoderManager owns one DecoderHub. It deliberately holds no
68+
// per-Source state — everything it needs comes from the relay.
69+
type DecoderHub struct {
70+
mu sync.Mutex
71+
relay *Relay
72+
streams map[string]*pcmStream // key: input mount
73+
}
74+
75+
// NewDecoderHub builds a DecoderHub bound to one relay.
76+
func NewDecoderHub(r *Relay) *DecoderHub {
77+
return &DecoderHub{
78+
relay: r,
79+
streams: map[string]*pcmStream{},
80+
}
81+
}
82+
83+
// Acquire returns a PCM reader (S16LE stereo) for the given input
84+
// mount, the native sample rate, and a release function. The first
85+
// caller for a given mount triggers the pump goroutine that reads
86+
// the input, decodes, and broadcasts PCM bytes; subsequent callers
87+
// just attach a new subscriber to the existing PCM stream.
88+
//
89+
// ctx bounds the subscriber's read loop, NOT the pump. The pump
90+
// outlives any single transcoder so the next subscriber can attach
91+
// without re-paying the decoder warm-up cost.
92+
//
93+
// Caller MUST call release() when its transcoder loop exits, even
94+
// on error; otherwise the pump never shuts down.
95+
func (h *DecoderHub) Acquire(
96+
ctx context.Context, inputMount, subID string,
97+
) (io.Reader, int, func(), error) {
98+
h.mu.Lock()
99+
ps, ok := h.streams[inputMount]
100+
if !ok {
101+
ps = &pcmStream{ready: make(chan struct{})}
102+
h.streams[inputMount] = ps
103+
ps.refcount = 1
104+
h.mu.Unlock()
105+
// Start the pump outside the hub lock to avoid blocking
106+
// other Acquire calls while we open the input + decoder.
107+
go h.runPump(inputMount, ps)
108+
} else {
109+
ps.refcount++
110+
h.mu.Unlock()
111+
}
112+
113+
// Wait for the pump to publish its sample rate (or fail).
114+
select {
115+
case <-ps.ready:
116+
// fall through
117+
case <-ctx.Done():
118+
h.release(inputMount)
119+
return nil, 0, func() {}, ctx.Err()
120+
}
121+
if ps.initErr != nil {
122+
h.release(inputMount)
123+
return nil, 0, func() {}, ps.initErr
124+
}
125+
126+
// Subscribe to the internal PCM stream. SubscribeInternal so the
127+
// subscriber doesn't show up in dashboard listener counts.
128+
pcmS := ps.stream
129+
offset, signal := pcmS.SubscribeInternal(subID, 0)
130+
reader := NewStreamReader(pcmS.Buffer, offset, signal, ctx, subID)
131+
132+
released := false
133+
release := func() {
134+
if released {
135+
return
136+
}
137+
released = true
138+
pcmS.Unsubscribe(subID)
139+
h.release(inputMount)
140+
}
141+
return reader, ps.sampleRate, release, nil
142+
}
143+
144+
// release decrements refcount; when it hits zero the pump is
145+
// cancelled and the entry is removed so the next Acquire starts a
146+
// fresh decoder.
147+
func (h *DecoderHub) release(inputMount string) {
148+
h.mu.Lock()
149+
defer h.mu.Unlock()
150+
ps, ok := h.streams[inputMount]
151+
if !ok {
152+
return
153+
}
154+
ps.refcount--
155+
if ps.refcount > 0 {
156+
return
157+
}
158+
if ps.cancel != nil {
159+
ps.cancel()
160+
}
161+
delete(h.streams, inputMount)
162+
}
163+
164+
// runPump opens the input + decoder for one inputMount, then pumps
165+
// PCM into the internal /_pcm stream's buffer. Exits when:
166+
// - the per-pump context is cancelled (release brings refcount to 0)
167+
// - the input source disconnects and the decoder hits EOF
168+
// - the decoder errors mid-stream
169+
//
170+
// On exit the pump signals ready (with initErr if it never reached
171+
// the read loop) and closes the internal PCM stream so subscribers
172+
// see EOF.
173+
func (h *DecoderHub) runPump(inputMount string, ps *pcmStream) {
174+
pumpCtx, cancel := context.WithCancel(context.Background())
175+
ps.cancel = cancel
176+
defer func() {
177+
// Make sure ready is signalled in every exit path so
178+
// Acquire callers don't block forever on a failed start.
179+
select {
180+
case <-ps.ready:
181+
default:
182+
close(ps.ready)
183+
}
184+
}()
185+
186+
// 1. Wait for the input stream to exist. Up to 30 s — if the
187+
// operator started the transcoders before the source, we
188+
// wait briefly; otherwise we mark initErr and exit so
189+
// subscribers retry instead of hanging.
190+
var input *Stream
191+
{
192+
deadline := contextDeadline(30)
193+
tick := time.NewTicker(500 * time.Millisecond)
194+
defer tick.Stop()
195+
wait:
196+
for {
197+
if s, ok := h.relay.GetStream(inputMount); ok {
198+
input = s
199+
break wait
200+
}
201+
select {
202+
case <-pumpCtx.Done():
203+
ps.initErr = pumpCtx.Err()
204+
return
205+
case <-deadline:
206+
ps.initErr = fmt.Errorf("decoder hub: input mount %q not found", inputMount)
207+
return
208+
case <-tick.C:
209+
}
210+
}
211+
}
212+
213+
// 2. Subscribe to the input. Same burst we used in
214+
// performTranscode (256 KiB) so strict Opus / FLAC decoders
215+
// have enough warm-up bytes.
216+
subID := fmt.Sprintf("decoder-hub-%s", strings.TrimPrefix(inputMount, "/"))
217+
offset, signal := input.SubscribeInternal(subID, 256*1024)
218+
defer input.Unsubscribe(subID)
219+
220+
input.mu.RLock()
221+
isOgg := input.IsOggStream || strings.Contains(strings.ToLower(input.ContentType), "ogg") ||
222+
strings.Contains(strings.ToLower(input.ContentType), "opus") ||
223+
strings.Contains(strings.ToLower(input.ContentType), "vorbis") ||
224+
strings.Contains(strings.ToLower(input.ContentType), "flac")
225+
var headBytes []byte
226+
if len(input.OggHead) > 0 {
227+
headBytes = append(headBytes, input.OggHead...)
228+
}
229+
input.mu.RUnlock()
230+
231+
var reader io.Reader
232+
if isOgg {
233+
aligned := input.Buffer.FindNextPageBoundaryLocked(offset)
234+
if aligned < input.Buffer.Head {
235+
offset = aligned
236+
}
237+
live := NewStreamReader(input.Buffer, offset, signal, pumpCtx, subID).WithOggSync(input)
238+
if len(headBytes) > 0 {
239+
reader = io.MultiReader(bytes.NewReader(headBytes), live)
240+
} else {
241+
logger.L.Warnw("decoder hub: input has no captured Ogg headers; decoder may fail until source reconnects",
242+
"input", inputMount)
243+
reader = live
244+
}
245+
} else {
246+
reader = NewStreamReader(input.Buffer, offset, signal, pumpCtx, subID).WithOggSync(input)
247+
}
248+
249+
decoder, err := OpenDecoder(reader)
250+
if err != nil {
251+
ps.initErr = fmt.Errorf("decoder hub: open: %w", err)
252+
return
253+
}
254+
255+
// 3. Spin up the internal PCM stream. Use the relay's
256+
// GetOrCreateStream so the lifecycle matches the rest of
257+
// the system (snapshot, kick-all, etc.).
258+
pcmMount := inputMount + pcmMountSuffix
259+
pcmS := h.relay.GetOrCreateStream(pcmMount)
260+
pcmS.mu.Lock()
261+
pcmS.Name = "PCM hub for " + inputMount
262+
pcmS.ContentType = "audio/raw-s16le"
263+
pcmS.IsTranscoded = true
264+
pcmS.Visible = false // don't surface to public APIs / dashboards
265+
pcmS.Public = false
266+
pcmS.mu.Unlock()
267+
ps.stream = pcmS
268+
ps.sampleRate = decoder.SampleRate()
269+
270+
// 4. Signal Acquire callers that the rate is published and they
271+
// can subscribe.
272+
close(ps.ready)
273+
logger.L.Infow("decoder hub: pump up",
274+
"input", inputMount, "rate", ps.sampleRate)
275+
276+
// 5. Pump loop. ReadFull a chunk of PCM, broadcast to the PCM
277+
// stream. Block on EOF / errors and exit cleanly.
278+
const chunkBytes = 8192 // 2048 stereo s16 samples = ~21ms @ 48kHz / ~23ms @ 44.1kHz
279+
buf := make([]byte, chunkBytes)
280+
for {
281+
select {
282+
case <-pumpCtx.Done():
283+
logger.L.Infow("decoder hub: pump cancelled", "input", inputMount)
284+
return
285+
default:
286+
}
287+
n, err := io.ReadFull(decoder, buf)
288+
if err != nil {
289+
logger.L.Infow("decoder hub: pump exiting", "input", inputMount, "reason", err.Error())
290+
return
291+
}
292+
pcmS.Broadcast(buf[:n], h.relay)
293+
}
294+
}
295+
296+
// contextDeadline returns a channel that fires after the given number
297+
// of seconds; small wrapper so the runPump retry loop reads cleanly.
298+
func contextDeadline(seconds int) <-chan struct{} {
299+
ch := make(chan struct{})
300+
go func() {
301+
t := time.NewTimer(time.Duration(seconds) * time.Second)
302+
defer t.Stop()
303+
<-t.C
304+
close(ch)
305+
}()
306+
return ch
307+
}

0 commit comments

Comments
 (0)