package live import ( "log" "sync" "time" "rpi-sendspin/internal/pipewire" ) // Source is the sendspin AudioSource used by the live server. It wraps // a single pipewire.Source instance and exposes runtime control: // // - SetTarget swaps the captured PipeWire node and recreates the // underlying pw-cat process. // - SetActive(false) makes Read() return (0, nil) on every call so the // sendspin audio engine skips its tick without surfacing an error. // pw-cat is killed in this mode so we don't burn CPU capturing audio // no one is listening to. // // The sample rate / channel count are fixed at construction; v1 of the // live server does not retune between presets. type Source struct { sampleRate int channels int mu sync.Mutex target string active bool pw *pipewire.Source retryAt time.Time // earliest time the next pw-cat start may be attempted failures int // consecutive failures, drives exponential backoff lastError string // last error message; logged once per change to avoid spam } const ( backoffMin = 500 * time.Millisecond backoffMax = 10 * time.Second ) func NewSource(sampleRate, channels int) *Source { return &Source{ sampleRate: sampleRate, channels: channels, } } // SetTarget sets the PipeWire node to capture from. If the target // changes while the source is active, the underlying pw-cat process is // torn down and recreated on the next Read. func (s *Source) SetTarget(target string) { s.mu.Lock() defer s.mu.Unlock() if s.target == target { return } s.target = target s.failures = 0 s.retryAt = time.Time{} s.lastError = "" s.teardownLocked() } // SetActive controls whether Read pulls from pw-cat. When set to false // any running pw-cat is killed and subsequent Reads return (0, nil). func (s *Source) SetActive(active bool) { s.mu.Lock() defer s.mu.Unlock() if s.active == active { return } s.active = active if !active { s.teardownLocked() } } func (s *Source) teardownLocked() { if s.pw != nil { _ = s.pw.Close() s.pw = nil } } func (s *Source) Read(samples []int32) (int, error) { s.mu.Lock() if !s.active || s.target == "" { s.mu.Unlock() return 0, nil } // Backoff: when pw-cat keeps dying (e.g. invalid target), don't // fork-bomb a new process on every 20 ms tick. Return (0, nil) so // the sendspin audio engine skips the tick silently until retryAt. if !s.retryAt.IsZero() && time.Now().Before(s.retryAt) { s.mu.Unlock() return 0, nil } if s.pw == nil { s.pw = pipewire.NewSource(s.target, s.sampleRate, s.channels) } pw := s.pw target := s.target s.mu.Unlock() n, err := pw.Read(samples) if err != nil { s.mu.Lock() s.teardownLocked() s.failures++ backoff := backoffMin << (s.failures - 1) if backoff > backoffMax || backoff <= 0 { backoff = backoffMax } s.retryAt = time.Now().Add(backoff) msg := err.Error() if msg != s.lastError { log.Printf("pw-cat[%s] read error: %v (retry in %s)", target, err, backoff) s.lastError = msg } s.mu.Unlock() return 0, nil } if n > 0 { s.mu.Lock() s.failures = 0 s.lastError = "" s.mu.Unlock() } return n, nil } func (s *Source) SampleRate() int { return s.sampleRate } func (s *Source) Channels() int { return s.channels } func (s *Source) Metadata() (string, string, string) { return "", "", "" } func (s *Source) Close() error { s.mu.Lock() defer s.mu.Unlock() s.teardownLocked() return nil }