package ingest import ( "context" "sync" "sync/atomic" "time" "github.com/jan/fm-rds-tx/internal/audio" ) type Runtime struct { sink *audio.StreamSource source Source started atomic.Bool ctx context.Context cancel context.CancelFunc wg sync.WaitGroup mu sync.RWMutex active SourceDescriptor stats RuntimeStats } func NewRuntime(sink *audio.StreamSource, src Source) *Runtime { return &Runtime{ sink: sink, source: src, stats: RuntimeStats{ State: "idle", }, } } func (r *Runtime) Start(ctx context.Context) error { if r.source == nil { r.mu.Lock() r.stats.State = "idle" r.mu.Unlock() return nil } if !r.started.CompareAndSwap(false, true) { return nil } r.ctx, r.cancel = context.WithCancel(ctx) r.mu.Lock() r.active = r.source.Descriptor() r.stats.State = "starting" r.mu.Unlock() if err := r.source.Start(r.ctx); err != nil { r.started.Store(false) r.mu.Lock() r.stats.State = "failed" r.mu.Unlock() return err } r.wg.Add(1) go r.run() return nil } func (r *Runtime) Stop() error { if !r.started.CompareAndSwap(true, false) { return nil } if r.cancel != nil { r.cancel() } if r.source != nil { _ = r.source.Stop() } r.wg.Wait() r.mu.Lock() r.stats.State = "stopped" r.mu.Unlock() return nil } func (r *Runtime) run() { defer r.wg.Done() r.mu.Lock() r.stats.State = "running" r.mu.Unlock() ch := r.source.Chunks() errCh := r.source.Errors() for { select { case <-r.ctx.Done(): return case err := <-errCh: if err == nil { continue } r.mu.Lock() r.stats.State = "degraded" r.mu.Unlock() case chunk, ok := <-ch: if !ok { return } r.handleChunk(chunk) } } } func (r *Runtime) handleChunk(chunk PCMChunk) { frames, err := ChunkToFrames(chunk) if err != nil { r.mu.Lock() r.stats.ConvertErrors++ r.stats.State = "degraded" r.mu.Unlock() return } dropped := uint64(0) for _, frame := range frames { if !r.sink.WriteFrame(frame) { dropped++ } } r.mu.Lock() r.stats.LastChunkAt = time.Now() r.stats.DroppedFrames += dropped r.stats.WriteBlocked = dropped > 0 r.mu.Unlock() } func (r *Runtime) Stats() Stats { r.mu.RLock() runtimeStats := r.stats active := r.active r.mu.RUnlock() sourceStats := SourceStats{} if r.source != nil { sourceStats = r.source.Stats() } return Stats{ Active: active, Source: sourceStats, Runtime: runtimeStats, } }