|
| 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