🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

141
internal/live/source.go Normal file
View File

@@ -0,0 +1,141 @@
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
}