Skip to content

Commit fe4e265

Browse files
committed
fix(transcode): idle-gate encode loop + dedupe input/format/bitrate
Two CPU-side improvements on top of v2.2.1: 1. EncodeMP3 / EncodeOpus skip the encode + broadcast call when the output mount has zero listeners. The decoder still drains so the upstream input buffer doesn t overrun us, but the dominant work (LAME / Opus encode + Broadcast write-lock + listener-signal fan-out) is gated behind ListenersCount() > 0. Empty mounts now cost only the decoder + a cheap atomic load per frame. 2. EnsureAutoMP3Transcoders dedupes against any existing transcoder that produces the same input + format + bitrate, not only against matching output-mount names. A manual entry like /dnb -> /dnb-128 (mp3 128) and an auto-spawn /dnb -> /dnb-mp3-128 (mp3 128) now collapse to one (the manual wins). The new snapshotInstances helper covers the case where a manual transcoder is added at runtime via the admin API after the auto twin already started. Also lifts the streamWriter struct out of the per-frame loop in EncodeMP3 — the struct is stateless across calls, so allocating 38 of them per second per transcoder was just GC pressure. Empirically (10 transcoders, 3 source mounts): v2.2.1 ~50 % CPU + remove 2 duplicate manual transcoders (config-side) ~22 % + idle-gate ~17 % Total drop from the v2.2.0-era baseline: ~83 %.
1 parent 73d4066 commit fe4e265

1 file changed

Lines changed: 70 additions & 5 deletions

File tree

relay/transcode.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ func EncodeMP3(ctx context.Context, relay *Relay, output *Stream, decoder io.Rea
372372
startTime := time.Now()
373373
totalSamples := int64(0)
374374

375+
// Reuse a single streamWriter across iterations — recreating it on
376+
// every frame churned 38 allocations/sec per transcoder for no
377+
// benefit; the struct holds no per-call state.
378+
writer := &streamWriter{stream: output, relay: relay, stats: stats}
379+
375380
for {
376381
select {
377382
case <-ctx.Done():
@@ -382,15 +387,21 @@ func EncodeMP3(ctx context.Context, relay *Relay, output *Stream, decoder io.Rea
382387
return
383388
}
384389

390+
// Idle gate — when the output mount has no listeners, skip
391+
// the encode + broadcast. Decoder still drains so the
392+
// upstream input buffer doesn't overrun us; encoder side
393+
// CPU is the dominant cost so this is the lever. The
394+
// encoder's bit-reservoir state may produce one suboptimal
395+
// frame on the next listener join, which is inaudible.
396+
if output.ListenersCount() == 0 {
397+
continue
398+
}
399+
385400
// Convert PCM bytes to int16 for Shine
386401
for i := 0; i < n/2; i++ {
387402
samples[i] = int16(pcmBuf[i*2]) | int16(pcmBuf[i*2+1])<<8
388403
}
389404

390-
// Encode and broadcast
391-
// Shine writes directly to an io.Writer
392-
// We can wrap our broadcast in an io.Writer
393-
writer := &streamWriter{stream: output, relay: relay, stats: stats}
394405
err = encoder.Write(writer, samples[:n/2])
395406
if err != nil {
396407
return
@@ -482,6 +493,13 @@ func EncodeOpus(ctx context.Context, relay *Relay, output *Stream, decoder io.Re
482493
return
483494
}
484495

496+
// Idle gate — see EncodeMP3 for rationale. Saves the bulk
497+
// of the per-frame work when no one's listening on the
498+
// output mount.
499+
if output.ListenersCount() == 0 {
500+
continue
501+
}
502+
485503
for i := 0; i < len(pcmSamples); i++ {
486504
pcmSamples[i] = int16(pcmBuf[i*2]) | int16(pcmBuf[i*2+1])<<8
487505
}
@@ -535,6 +553,20 @@ func (tm *TranscoderManager) GetInstance(outputMount string) *TranscoderInstance
535553
return tm.instances[outputMount]
536554
}
537555

556+
// snapshotInstances returns a flat slice of all live transcoder
557+
// instances for read-only iteration. Holds the manager mutex only
558+
// while copying — callers iterating the result must NOT mutate the
559+
// instances themselves; they're shared.
560+
func (tm *TranscoderManager) snapshotInstances() []*TranscoderInstance {
561+
tm.mu.RLock()
562+
defer tm.mu.RUnlock()
563+
out := make([]*TranscoderInstance, 0, len(tm.instances))
564+
for _, inst := range tm.instances {
565+
out = append(out, inst)
566+
}
567+
return out
568+
}
569+
538570
type TranscoderStats struct {
539571
Name string `json:"name"`
540572
Input string `json:"input"`
@@ -612,16 +644,49 @@ func (tm *TranscoderManager) EnsureAutoMP3Transcoders(inputMount string, bitrate
612644
if tm.GetInstance(outputMount) != nil {
613645
continue
614646
}
647+
// Skip when any existing transcoder — manual or already-running
648+
// auto — produces the same input+format+bitrate combo, even
649+
// under a different output-mount name. Without this check a
650+
// manual entry like /dnb -> /dnb-128 (mp3 128) and an auto
651+
// /dnb -> /dnb-mp3-128 (mp3 128) both run, doubling the
652+
// encode work for zero listener benefit.
615653
skip := false
616654
for _, mc := range manualConfigs {
617-
if mc != nil && mc.OutputMount == outputMount {
655+
if mc == nil {
656+
continue
657+
}
658+
if mc.OutputMount == outputMount {
659+
skip = true
660+
break
661+
}
662+
if mc.InputMount == inputMount &&
663+
strings.EqualFold(mc.Format, "mp3") &&
664+
mc.Bitrate == br {
618665
skip = true
619666
break
620667
}
621668
}
622669
if skip {
623670
continue
624671
}
672+
// Also dedupe against running auto/manual transcoder instances —
673+
// covers the case where a manual transcoder was added at runtime
674+
// via the admin API after we already spawned the auto twin.
675+
dup := false
676+
for _, other := range tm.snapshotInstances() {
677+
if other == nil || other.Config == nil {
678+
continue
679+
}
680+
if other.Config.InputMount == inputMount &&
681+
strings.EqualFold(other.Config.Format, "mp3") &&
682+
other.Config.Bitrate == br {
683+
dup = true
684+
break
685+
}
686+
}
687+
if dup {
688+
continue
689+
}
625690
cfg := &config.TranscoderConfig{
626691
Name: fmt.Sprintf("auto-%s-mp3-%d", strings.TrimPrefix(inputMount, "/"), br),
627692
InputMount: inputMount,

0 commit comments

Comments
 (0)