From 5cb364d742e0649f72fdf326d9096396a9bbb5f4 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:00:08 +0200 Subject: [PATCH 01/40] ingest: add phase1 runtime skeleton and conversion model --- internal/config/config.go | 72 ++++++++++++++++ internal/ingest/convert.go | 45 ++++++++++ internal/ingest/convert_test.go | 55 ++++++++++++ internal/ingest/factory.go | 23 +++++ internal/ingest/runtime.go | 147 ++++++++++++++++++++++++++++++++ internal/ingest/source.go | 12 +++ internal/ingest/stats.go | 35 ++++++++ internal/ingest/types.go | 26 ++++++ 8 files changed, 415 insertions(+) create mode 100644 internal/ingest/convert.go create mode 100644 internal/ingest/convert_test.go create mode 100644 internal/ingest/factory.go create mode 100644 internal/ingest/runtime.go create mode 100644 internal/ingest/source.go create mode 100644 internal/ingest/stats.go create mode 100644 internal/ingest/types.go diff --git a/internal/config/config.go b/internal/config/config.go index 2eaa227..37ca0ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { Backend BackendConfig `json:"backend"` Control ControlConfig `json:"control"` Runtime RuntimeConfig `json:"runtime"` + Ingest IngestConfig `json:"ingest"` } type AudioConfig struct { @@ -68,6 +69,33 @@ type RuntimeConfig struct { FrameQueueCapacity int `json:"frameQueueCapacity"` } +type IngestConfig struct { + Kind string `json:"kind"` + PrebufferMs int `json:"prebufferMs"` + StallTimeoutMs int `json:"stallTimeoutMs"` + Reconnect IngestReconnectConfig `json:"reconnect"` + Stdin IngestPCMConfig `json:"stdin"` + HTTPRaw IngestPCMConfig `json:"httpRaw"` + Icecast IngestIcecastConfig `json:"icecast"` +} + +type IngestReconnectConfig struct { + Enabled bool `json:"enabled"` + InitialBackoffMs int `json:"initialBackoffMs"` + MaxBackoffMs int `json:"maxBackoffMs"` +} + +type IngestPCMConfig struct { + SampleRateHz int `json:"sampleRateHz"` + Channels int `json:"channels"` + Format string `json:"format"` +} + +type IngestIcecastConfig struct { + URL string `json:"url"` + Decoder string `json:"decoder"` +} + func Default() Config { return Config{ Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, @@ -89,6 +117,29 @@ func Default() Config { Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, Runtime: RuntimeConfig{FrameQueueCapacity: 3}, + Ingest: IngestConfig{ + Kind: "none", + PrebufferMs: 1500, + StallTimeoutMs: 3000, + Reconnect: IngestReconnectConfig{ + Enabled: true, + InitialBackoffMs: 1000, + MaxBackoffMs: 15000, + }, + Stdin: IngestPCMConfig{ + SampleRateHz: 44100, + Channels: 2, + Format: "s16le", + }, + HTTPRaw: IngestPCMConfig{ + SampleRateHz: 44100, + Channels: 2, + Format: "s16le", + }, + Icecast: IngestIcecastConfig{ + Decoder: "native", + }, + }, } } @@ -174,6 +225,27 @@ func (c Config) Validate() error { if c.Runtime.FrameQueueCapacity <= 0 { return fmt.Errorf("runtime.frameQueueCapacity must be > 0") } + if c.Ingest.Kind == "" { + c.Ingest.Kind = "none" + } + if c.Ingest.PrebufferMs < 0 { + return fmt.Errorf("ingest.prebufferMs must be >= 0") + } + if c.Ingest.StallTimeoutMs < 0 { + return fmt.Errorf("ingest.stallTimeoutMs must be >= 0") + } + if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 { + return fmt.Errorf("ingest.reconnect backoff must be >= 0") + } + if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs { + return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs") + } + if c.Ingest.Stdin.SampleRateHz < 0 || c.Ingest.HTTPRaw.SampleRateHz < 0 { + return fmt.Errorf("ingest pcm sampleRateHz must be >= 0") + } + if c.Ingest.Stdin.Channels < 0 || c.Ingest.HTTPRaw.Channels < 0 { + return fmt.Errorf("ingest pcm channels must be >= 0") + } // Fail-loud PI validation if c.RDS.Enabled { if _, err := ParsePI(c.RDS.PI); err != nil { diff --git a/internal/ingest/convert.go b/internal/ingest/convert.go new file mode 100644 index 0000000..3dbd1b3 --- /dev/null +++ b/internal/ingest/convert.go @@ -0,0 +1,45 @@ +package ingest + +import ( + "fmt" + "math" + + "github.com/jan/fm-rds-tx/internal/audio" +) + +const int32AbsMax = 2147483648.0 + +func ChunkToFrames(chunk PCMChunk) ([]audio.Frame, error) { + if chunk.Channels != 1 && chunk.Channels != 2 { + return nil, fmt.Errorf("unsupported channel count: %d", chunk.Channels) + } + if chunk.Channels <= 0 { + return nil, fmt.Errorf("invalid channel count: %d", chunk.Channels) + } + if len(chunk.Samples)%chunk.Channels != 0 { + return nil, fmt.Errorf("invalid interleaved sample count: %d for channels=%d", len(chunk.Samples), chunk.Channels) + } + + frames := make([]audio.Frame, len(chunk.Samples)/chunk.Channels) + switch chunk.Channels { + case 1: + for i := range frames { + s := normalizePCM(chunk.Samples[i]) + frames[i] = audio.NewFrame(s, s) + } + case 2: + for i := range frames { + off := i * 2 + l := normalizePCM(chunk.Samples[off]) + r := normalizePCM(chunk.Samples[off+1]) + frames[i] = audio.NewFrame(l, r) + } + } + return frames, nil +} + +func normalizePCM(v int32) audio.Sample { + norm := float64(v) / int32AbsMax + norm = math.Max(float64(audio.SampleMin), math.Min(float64(audio.SampleMax), norm)) + return audio.Sample(norm) +} diff --git a/internal/ingest/convert_test.go b/internal/ingest/convert_test.go new file mode 100644 index 0000000..086325f --- /dev/null +++ b/internal/ingest/convert_test.go @@ -0,0 +1,55 @@ +package ingest + +import "testing" + +func TestChunkToFramesMonoDuplicate(t *testing.T) { + frames, err := ChunkToFrames(PCMChunk{ + Channels: 1, + Samples: []int32{2147483647, -2147483648}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(frames) != 2 { + t.Fatalf("expected 2 frames, got %d", len(frames)) + } + if frames[0].L != frames[0].R { + t.Fatalf("expected mono duplication, got L=%v R=%v", frames[0].L, frames[0].R) + } + if frames[1].L != frames[1].R { + t.Fatalf("expected mono duplication, got L=%v R=%v", frames[1].L, frames[1].R) + } +} + +func TestChunkToFramesStereoPassThrough(t *testing.T) { + frames, err := ChunkToFrames(PCMChunk{ + Channels: 2, + Samples: []int32{100, 200, -300, -400}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(frames) != 2 { + t.Fatalf("expected 2 frames, got %d", len(frames)) + } + if !(frames[0].L < frames[0].R) { + t.Fatalf("expected left < right for first frame, got %v >= %v", frames[0].L, frames[0].R) + } + if !(frames[1].L > frames[1].R) { + t.Fatalf("expected left > right for second frame, got %v <= %v", frames[1].L, frames[1].R) + } +} + +func TestChunkToFramesRejectsUnsupportedChannels(t *testing.T) { + _, err := ChunkToFrames(PCMChunk{Channels: 3, Samples: []int32{1, 2, 3}}) + if err == nil { + t.Fatal("expected error for unsupported channels") + } +} + +func TestChunkToFramesRejectsInvalidInterleaving(t *testing.T) { + _, err := ChunkToFrames(PCMChunk{Channels: 2, Samples: []int32{1, 2, 3}}) + if err == nil { + t.Fatal("expected error for invalid interleaving") + } +} diff --git a/internal/ingest/factory.go b/internal/ingest/factory.go new file mode 100644 index 0000000..d3a65e7 --- /dev/null +++ b/internal/ingest/factory.go @@ -0,0 +1,23 @@ +package ingest + +import ( + "fmt" + "io" + "net/http" + + "github.com/jan/fm-rds-tx/internal/config" +) + +type FactoryDeps struct { + Stdin io.Reader + HTTP *http.Client +} + +func BuildSource(cfg config.Config, deps FactoryDeps) (Source, error) { + switch cfg.Ingest.Kind { + case "", "none": + return nil, nil + default: + return nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) + } +} diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go new file mode 100644 index 0000000..27c7db5 --- /dev/null +++ b/internal/ingest/runtime.go @@ -0,0 +1,147 @@ +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, + } +} diff --git a/internal/ingest/source.go b/internal/ingest/source.go new file mode 100644 index 0000000..d851ed3 --- /dev/null +++ b/internal/ingest/source.go @@ -0,0 +1,12 @@ +package ingest + +import "context" + +type Source interface { + Descriptor() SourceDescriptor + Start(ctx context.Context) error + Stop() error + Chunks() <-chan PCMChunk + Errors() <-chan error + Stats() SourceStats +} diff --git a/internal/ingest/stats.go b/internal/ingest/stats.go new file mode 100644 index 0000000..fb135c0 --- /dev/null +++ b/internal/ingest/stats.go @@ -0,0 +1,35 @@ +package ingest + +import "time" + +type SourceStats struct { + State string `json:"state"` + Connected bool `json:"connected"` + LastChunkAt time.Time `json:"lastChunkAt,omitempty"` + ChunksIn uint64 `json:"chunksIn"` + SamplesIn uint64 `json:"samplesIn"` + BufferedSeconds float64 `json:"bufferedSeconds"` + Overflows uint64 `json:"overflows"` + Underruns uint64 `json:"underruns"` + Reconnects uint64 `json:"reconnects"` + Discontinuities uint64 `json:"discontinuities"` + TransportLoss uint64 `json:"transportLoss"` + Reorders uint64 `json:"reorders"` + JitterDepth int `json:"jitterDepth"` + LastError string `json:"lastError,omitempty"` +} + +type RuntimeStats struct { + State string `json:"state"` + Prebuffering bool `json:"prebuffering"` + LastChunkAt time.Time `json:"lastChunkAt,omitempty"` + DroppedFrames uint64 `json:"droppedFrames"` + ConvertErrors uint64 `json:"convertErrors"` + WriteBlocked bool `json:"writeBlocked"` +} + +type Stats struct { + Active SourceDescriptor `json:"active"` + Source SourceStats `json:"source"` + Runtime RuntimeStats `json:"runtime"` +} diff --git a/internal/ingest/types.go b/internal/ingest/types.go new file mode 100644 index 0000000..bae4801 --- /dev/null +++ b/internal/ingest/types.go @@ -0,0 +1,26 @@ +package ingest + +import "time" + +// PCMChunk is the ingest-internal normalized PCM unit before TX conversion. +// Samples are interleaved per channel. +type PCMChunk struct { + Samples []int32 + Channels int + SampleRateHz int + Sequence uint64 + Timestamp time.Time + SourceID string + Discontinuity bool +} + +type SourceDescriptor struct { + ID string `json:"id"` + Kind string `json:"kind"` + Family string `json:"family"` + Transport string `json:"transport"` + Codec string `json:"codec"` + Channels int `json:"channels"` + SampleRateHz int `json:"sampleRateHz"` + Detail string `json:"detail,omitempty"` +} From 8d02c57348ae40d3d1133058c84c119bd9114cb7 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:01:30 +0200 Subject: [PATCH 02/40] ingest: add stdin PCM source adapter --- internal/ingest/adapters/stdinpcm/source.go | 180 ++++++++++++++++++ .../ingest/adapters/stdinpcm/source_test.go | 33 ++++ 2 files changed, 213 insertions(+) create mode 100644 internal/ingest/adapters/stdinpcm/source.go create mode 100644 internal/ingest/adapters/stdinpcm/source_test.go diff --git a/internal/ingest/adapters/stdinpcm/source.go b/internal/ingest/adapters/stdinpcm/source.go new file mode 100644 index 0000000..5785928 --- /dev/null +++ b/internal/ingest/adapters/stdinpcm/source.go @@ -0,0 +1,180 @@ +package stdinpcm + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type Source struct { + id string + reader io.Reader + sampleRate int + channels int + chunkFrames int + + chunks chan ingest.PCMChunk + errs chan error + + cancel context.CancelFunc + wg sync.WaitGroup + + state atomic.Value // string + chunksIn atomic.Uint64 + samplesIn atomic.Uint64 + discontinuities atomic.Uint64 + lastChunkAtUnix atomic.Int64 + lastError atomic.Value // string +} + +func New(id string, reader io.Reader, sampleRate, channels, chunkFrames int) *Source { + if id == "" { + id = "stdin" + } + if sampleRate <= 0 { + sampleRate = 44100 + } + if channels <= 0 { + channels = 2 + } + if chunkFrames <= 0 { + chunkFrames = 1024 + } + + s := &Source{ + id: id, + reader: reader, + sampleRate: sampleRate, + channels: channels, + chunkFrames: chunkFrames, + chunks: make(chan ingest.PCMChunk, 8), + errs: make(chan error, 4), + } + s.state.Store("idle") + return s +} + +func (s *Source) Descriptor() ingest.SourceDescriptor { + return ingest.SourceDescriptor{ + ID: s.id, + Kind: "stdin-pcm", + Family: "raw", + Transport: "stdin", + Codec: "pcm_s16le", + Channels: s.channels, + SampleRateHz: s.sampleRate, + Detail: "S16LE interleaved PCM via stdin", + } +} + +func (s *Source) Start(ctx context.Context) error { + if s.reader == nil { + return fmt.Errorf("stdin source reader is nil") + } + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.state.Store("running") + + s.wg.Add(1) + go s.readLoop(runCtx) + return nil +} + +func (s *Source) Stop() error { + if s.cancel != nil { + s.cancel() + } + s.wg.Wait() + s.state.Store("stopped") + return nil +} + +func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } +func (s *Source) Errors() <-chan error { return s.errs } + +func (s *Source) Stats() ingest.SourceStats { + state, _ := s.state.Load().(string) + last := s.lastChunkAtUnix.Load() + errStr, _ := s.lastError.Load().(string) + var lastChunkAt time.Time + if last > 0 { + lastChunkAt = time.Unix(0, last) + } + return ingest.SourceStats{ + State: state, + Connected: state == "running", + LastChunkAt: lastChunkAt, + ChunksIn: s.chunksIn.Load(), + SamplesIn: s.samplesIn.Load(), + Discontinuities: s.discontinuities.Load(), + LastError: errStr, + } +} + +func (s *Source) readLoop(ctx context.Context) { + defer s.wg.Done() + defer close(s.chunks) + + frameBytes := s.channels * 2 + buf := make([]byte, s.chunkFrames*frameBytes) + seq := uint64(0) + + for { + select { + case <-ctx.Done(): + return + default: + } + + n, err := io.ReadAtLeast(s.reader, buf, frameBytes) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + if n > 0 { + s.emitChunk(buf[:n], seq) + } + s.state.Store("stopped") + return + } + wrapped := fmt.Errorf("stdin read: %w", err) + s.lastError.Store(wrapped.Error()) + s.state.Store("failed") + select { + case s.errs <- wrapped: + default: + } + return + } + s.emitChunk(buf[:n], seq) + seq++ + } +} + +func (s *Source) emitChunk(data []byte, seq uint64) { + samples := make([]int32, 0, len(data)/2) + for i := 0; i+1 < len(data); i += 2 { + v := int16(binary.LittleEndian.Uint16(data[i : i+2])) + samples = append(samples, int32(v)<<16) + } + chunk := ingest.PCMChunk{ + Samples: samples, + Channels: s.channels, + SampleRateHz: s.sampleRate, + Sequence: seq, + Timestamp: time.Now(), + SourceID: s.id, + } + s.chunksIn.Add(1) + s.samplesIn.Add(uint64(len(samples))) + s.lastChunkAtUnix.Store(time.Now().UnixNano()) + select { + case s.chunks <- chunk: + default: + s.discontinuities.Add(1) + } +} diff --git a/internal/ingest/adapters/stdinpcm/source_test.go b/internal/ingest/adapters/stdinpcm/source_test.go new file mode 100644 index 0000000..7331d99 --- /dev/null +++ b/internal/ingest/adapters/stdinpcm/source_test.go @@ -0,0 +1,33 @@ +package stdinpcm + +import ( + "bytes" + "context" + "testing" + "time" +) + +func TestSourceReadsPCMChunks(t *testing.T) { + // Two stereo frames (S16LE): [0,0] and [32767,-32768] + raw := []byte{ + 0x00, 0x00, 0x00, 0x00, + 0xff, 0x7f, 0x00, 0x80, + } + src := New("stdin-test", bytes.NewReader(raw), 44100, 2, 2) + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + select { + case chunk := <-src.Chunks(): + if chunk.Channels != 2 { + t.Fatalf("channels=%d", chunk.Channels) + } + if len(chunk.Samples) != 4 { + t.Fatalf("samples=%d want 4", len(chunk.Samples)) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for chunk") + } +} From 4ad70d4ae441f65cf381334d9cad9ef70fbc3d9c Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:07:04 +0200 Subject: [PATCH 03/40] ingest: rewire tx/control to runtime and http raw adapter --- cmd/fmrtx/main.go | 111 +++++++++++------ internal/control/control.go | 58 +++++++-- internal/control/control_test.go | 31 +++-- internal/ingest/adapters/httpraw/source.go | 133 +++++++++++++++++++++ internal/ingest/runtime.go | 15 ++- 5 files changed, 285 insertions(+), 63 deletions(-) create mode 100644 internal/ingest/adapters/httpraw/source.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 05472da..fbd8578 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -15,6 +15,9 @@ import ( cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" "github.com/jan/fm-rds-tx/internal/platform" "github.com/jan/fm-rds-tx/internal/platform/plutosdr" "github.com/jan/fm-rds-tx/internal/platform/soapysdr" @@ -36,7 +39,6 @@ func main() { audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream") flag.Parse() - // --- list-devices (SoapySDR) --- if *listDevices { devices, err := soapysdr.Enumerate() if err != nil { @@ -60,13 +62,12 @@ func main() { log.Fatalf("load config: %v", err) } - // --- print-config --- if *printConfig { preemph := "off" if cfg.FM.PreEmphasisTauUS > 0 { - preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS) + preemph = fmt.Sprintf("%.0fus", cfg.FM.PreEmphasisTauUS) } - fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n", + fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=+-%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n", cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress, @@ -74,7 +75,6 @@ func main() { return } - // --- dry-run --- if *dryRun { frame := drypkg.Generate(cfg) if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { @@ -86,7 +86,6 @@ func main() { return } - // --- simulate --- if *simulate { summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) if err != nil { @@ -96,28 +95,24 @@ func main() { return } - // --- TX mode --- if *txMode { driver := selectDriver(cfg) if driver == nil { - log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") + log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)") } runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) return } - // --- default: HTTP only --- srv := ctrlpkg.NewServer(cfg) server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) log.Fatal(server.ListenAndServe()) } -// selectDriver picks the best available driver based on config and build tags. func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { kind := cfg.Backend.Kind - // Explicit PlutoSDR if kind == "pluto" || kind == "plutosdr" { if plutosdr.Available() { return plutosdr.NewPlutoDriver() @@ -125,7 +120,6 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError()) } - // Explicit SoapySDR if kind == "soapy" || kind == "soapysdr" { if soapysdr.Available() { return soapysdr.NewNativeDriver() @@ -133,7 +127,6 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { log.Printf("warning: backend=%s but soapy driver not available", kind) } - // Auto-detect: prefer PlutoSDR, fall back to SoapySDR if plutosdr.Available() { log.Println("auto-selected: pluto-iio driver") return plutosdr.NewPlutoDriver() @@ -150,14 +143,11 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Configure driver - // OutputDrive controls composite signal level, NOT hardware gain. - // Hardware TX gain is always 0 dB (max power). Use external attenuator for power control. soapyCfg := platform.SoapyConfig{ Driver: cfg.Backend.Driver, Device: cfg.Backend.Device, CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, - GainDB: 0, // 0 dB = max TX power on PlutoSDR + GainDB: 0, DeviceArgs: map[string]string{}, } if cfg.Backend.URI != "" { @@ -181,42 +171,45 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate) } - // Engine engine := apppkg.NewEngine(cfg, driver) + cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) - // Live audio stream source (optional) var streamSrc *audio.StreamSource - if audioStdin || audioHTTP { - // Buffer: 2 seconds at input rate — enough to absorb jitter - bufferFrames := audioRate * 2 + var ingestRuntime *ingest.Runtime + var ingress ctrlpkg.AudioIngress + if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { + rate := ingestSampleRate(cfg) + bufferFrames := rate * 2 if bufferFrames <= 0 { bufferFrames = 1 } - streamSrc = audio.NewStreamSource(bufferFrames, audioRate) + streamSrc = audio.NewStreamSource(bufferFrames, rate) engine.SetStreamSource(streamSrc) - if audioStdin { - go func() { - log.Printf("audio: reading S16LE stereo PCM from stdin at %d Hz", audioRate) - if err := audio.IngestReader(os.Stdin, streamSrc); err != nil { - log.Printf("audio: stdin ingest ended: %v", err) - } else { - log.Println("audio: stdin EOF") - } - }() + source, sourceIngress, err := buildPhase1Source(cfg) + if err != nil { + log.Fatalf("ingest source: %v", err) } - if audioHTTP { - log.Printf("audio: HTTP ingest enabled on /audio/stream (rate=%dHz, buffer=%d frames)", audioRate, streamSrc.Stats().Capacity) + ingestRuntime = ingest.NewRuntime(streamSrc, source) + if err := ingestRuntime.Start(ctx); err != nil { + log.Fatalf("ingest start: %v", err) } + ingress = sourceIngress + log.Printf("ingest: kind=%s rate=%dHz buffer=%d frames", cfg.Ingest.Kind, rate, streamSrc.Stats().Capacity) } - // Control plane srv := ctrlpkg.NewServer(cfg) srv.SetDriver(driver) srv.SetTXController(&txBridge{engine: engine}) if streamSrc != nil { srv.SetStreamSource(streamSrc) } + if ingress != nil { + srv.SetAudioIngress(ingress) + } + if ingestRuntime != nil { + srv.SetIngestRuntime(ingestRuntime) + } if autoStart { log.Println("TX: auto-start enabled") @@ -225,7 +218,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a } log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate()) } else { - log.Println("TX ready (idle) — POST /tx/start to begin") + log.Println("TX ready (idle) - POST /tx/start to begin") } ctrlServer := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) @@ -242,10 +235,56 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Printf("received %s, shutting down...", sig) _ = engine.Stop(ctx) + if ingestRuntime != nil { + _ = ingestRuntime.Stop() + } _ = driver.Close(ctx) log.Println("shutdown complete") } +func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, audioHTTP bool) cfgpkg.Config { + if audioRate > 0 { + cfg.Ingest.Stdin.SampleRateHz = audioRate + cfg.Ingest.HTTPRaw.SampleRateHz = audioRate + } + if audioStdin && audioHTTP { + log.Printf("audio: both --audio-stdin and --audio-http set; using ingest kind=stdin") + } + if audioStdin { + cfg.Ingest.Kind = "stdin" + } + if audioHTTP && !audioStdin { + cfg.Ingest.Kind = "http-raw" + } + return cfg +} + +func ingestSampleRate(cfg cfgpkg.Config) int { + switch cfg.Ingest.Kind { + case "stdin", "stdin-pcm": + return cfg.Ingest.Stdin.SampleRateHz + case "http-raw": + return cfg.Ingest.HTTPRaw.SampleRateHz + default: + return 44100 + } +} + +func buildPhase1Source(cfg cfgpkg.Config) (ingest.Source, ctrlpkg.AudioIngress, error) { + switch cfg.Ingest.Kind { + case "stdin", "stdin-pcm": + src := stdinpcm.New("stdin-main", os.Stdin, cfg.Ingest.Stdin.SampleRateHz, cfg.Ingest.Stdin.Channels, 1024) + return src, nil, nil + case "http-raw": + src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels) + return src, src, nil + case "", "none": + return nil, nil, nil + default: + return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) + } +} + type txBridge struct{ engine *apppkg.Engine } func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) } diff --git a/internal/control/control.go b/internal/control/control.go index 381a637..1b93a05 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -14,6 +14,7 @@ import ( "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" + "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/platform" ) @@ -46,12 +47,22 @@ type LivePatch struct { } type Server struct { - mu sync.RWMutex - cfg config.Config - tx TXController - drv platform.SoapyDriver // optional, for runtime stats - streamSrc *audio.StreamSource // optional, for live audio ingest - audit auditCounters + mu sync.RWMutex + cfg config.Config + tx TXController + drv platform.SoapyDriver // optional, for runtime stats + streamSrc *audio.StreamSource // optional, for live audio ring stats + audioIngress AudioIngress // optional, for /audio/stream + ingestRt IngestRuntime // optional, for /runtime ingest stats + audit auditCounters +} + +type AudioIngress interface { + WritePCM16(data []byte) (int, error) +} + +type IngestRuntime interface { + Stats() ingest.Stats } type auditEvent string @@ -196,6 +207,18 @@ func (s *Server) SetStreamSource(src *audio.StreamSource) { s.mu.Unlock() } +func (s *Server) SetAudioIngress(ingress AudioIngress) { + s.mu.Lock() + s.audioIngress = ingress + s.mu.Unlock() +} + +func (s *Server) SetIngestRuntime(rt IngestRuntime) { + s.mu.Lock() + s.ingestRt = rt + s.mu.Unlock() +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleUI) @@ -268,6 +291,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { drv := s.drv tx := s.tx stream := s.streamSrc + ingestRt := s.ingestRt s.mu.RUnlock() result := map[string]any{} @@ -280,6 +304,9 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { if stream != nil { result["audioStream"] = stream.Stats() } + if ingestRt != nil { + result["ingest"] = ingestRt.Stats() + } result["controlAudit"] = s.auditSnapshot() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) @@ -311,8 +338,9 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) // handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes // it into the live audio ring buffer. Use with: -// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw -// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream +// +// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw +// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { s.recordAudit(auditMethodNotAllowed) @@ -325,11 +353,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { return } s.mu.RLock() - stream := s.streamSrc + ingress := s.audioIngress s.mu.RUnlock() - if stream == nil { - http.Error(w, "audio stream not configured (use --audio-stdin or --audio-http)", http.StatusServiceUnavailable) + if ingress == nil { + http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable) return } @@ -341,7 +369,12 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { for { n, err := r.Body.Read(buf) if n > 0 { - totalFrames += stream.WritePCM(buf[:n]) + written, writeErr := ingress.WritePCM16(buf[:n]) + totalFrames += written + if writeErr != nil { + http.Error(w, writeErr.Error(), http.StatusServiceUnavailable) + return + } } if err != nil { if err == io.EOF { @@ -362,7 +395,6 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "frames": totalFrames, - "stats": stream.Stats(), }) } diff --git a/internal/control/control_test.go b/internal/control/control_test.go index e25ea07..176ff7a 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -9,7 +9,6 @@ import ( "strings" "testing" - "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/output" ) @@ -317,8 +316,8 @@ func TestAudioStreamRequiresSource(t *testing.T) { func TestAudioStreamPushesPCM(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) - stream := audio.NewStreamSource(256, 44100) - srv.SetStreamSource(stream) + ingress := &fakeAudioIngress{} + srv.SetAudioIngress(ingress) pcm := []byte{0, 0, 0, 0} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) @@ -338,12 +337,8 @@ func TestAudioStreamPushesPCM(t *testing.T) { if frames != 1 { t.Fatalf("expected 1 frame, got %v", frames) } - stats, ok := body["stats"].(map[string]any) - if !ok { - t.Fatalf("missing stats: %v", body["stats"]) - } - if avail, _ := stats["available"].(float64); avail < 1 { - t.Fatalf("expected stats.available >= 1, got %v", avail) + if ingress.totalFrames != 1 { + t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames) } } @@ -360,7 +355,7 @@ func TestAudioStreamRejectsNonPost(t *testing.T) { func TestAudioStreamRejectsMissingContentType(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) - srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) srv.Handler().ServeHTTP(rec, req) @@ -375,7 +370,7 @@ func TestAudioStreamRejectsMissingContentType(t *testing.T) { func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) - srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) req.Header.Set("Content-Type", "text/plain") @@ -397,7 +392,7 @@ func TestAudioStreamRejectsBodyTooLarge(t *testing.T) { limit := int(audioStreamBodyLimit) body := make([]byte, limit+1) srv := NewServer(cfgpkg.Default()) - srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/octet-stream") @@ -524,7 +519,7 @@ func TestControlAuditTracksMethodNotAllowed(t *testing.T) { func TestControlAuditTracksUnsupportedMediaType(t *testing.T) { srv := NewServer(cfgpkg.Default()) - srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) srv.Handler().ServeHTTP(rec, req) @@ -605,6 +600,16 @@ type fakeTXController struct { stats map[string]any } +type fakeAudioIngress struct { + totalFrames int +} + +func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) { + frames := len(data) / 4 + f.totalFrames += frames + return frames, nil +} + func (f *fakeTXController) StartTX() error { return nil } func (f *fakeTXController) StopTX() error { return nil } func (f *fakeTXController) TXStats() map[string]any { diff --git a/internal/ingest/adapters/httpraw/source.go b/internal/ingest/adapters/httpraw/source.go new file mode 100644 index 0000000..e9ba054 --- /dev/null +++ b/internal/ingest/adapters/httpraw/source.go @@ -0,0 +1,133 @@ +package httpraw + +import ( + "context" + "encoding/binary" + "fmt" + "sync/atomic" + "time" + + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type Source struct { + id string + sampleRate int + channels int + + chunks chan ingest.PCMChunk + errs chan error + + sequence atomic.Uint64 + state atomic.Value // string + chunksIn atomic.Uint64 + samplesIn atomic.Uint64 + discontinuities atomic.Uint64 + lastChunkAtUnix atomic.Int64 + lastError atomic.Value // string +} + +func New(id string, sampleRate, channels int) *Source { + if id == "" { + id = "http-raw" + } + if sampleRate <= 0 { + sampleRate = 44100 + } + if channels <= 0 { + channels = 2 + } + s := &Source{ + id: id, + sampleRate: sampleRate, + channels: channels, + chunks: make(chan ingest.PCMChunk, 32), + errs: make(chan error, 8), + } + s.state.Store("idle") + return s +} + +func (s *Source) Descriptor() ingest.SourceDescriptor { + return ingest.SourceDescriptor{ + ID: s.id, + Kind: "http-raw", + Family: "raw", + Transport: "http", + Codec: "pcm_s16le", + Channels: s.channels, + SampleRateHz: s.sampleRate, + Detail: "HTTP push /audio/stream", + } +} + +func (s *Source) Start(_ context.Context) error { + s.state.Store("running") + return nil +} + +func (s *Source) Stop() error { + s.state.Store("stopped") + return nil +} + +func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } +func (s *Source) Errors() <-chan error { return s.errs } + +func (s *Source) Stats() ingest.SourceStats { + state, _ := s.state.Load().(string) + last := s.lastChunkAtUnix.Load() + errStr, _ := s.lastError.Load().(string) + var lastChunkAt time.Time + if last > 0 { + lastChunkAt = time.Unix(0, last) + } + return ingest.SourceStats{ + State: state, + Connected: state == "running", + LastChunkAt: lastChunkAt, + ChunksIn: s.chunksIn.Load(), + SamplesIn: s.samplesIn.Load(), + Discontinuities: s.discontinuities.Load(), + LastError: errStr, + } +} + +func (s *Source) WritePCM16(data []byte) (int, error) { + if s.channels != 1 && s.channels != 2 { + return 0, fmt.Errorf("unsupported configured channels: %d", s.channels) + } + if len(data) == 0 { + return 0, nil + } + frameBytes := s.channels * 2 + usable := len(data) - (len(data) % frameBytes) + if usable == 0 { + return 0, nil + } + samples := make([]int32, 0, usable/2) + for i := 0; i+1 < usable; i += 2 { + v := int16(binary.LittleEndian.Uint16(data[i : i+2])) + samples = append(samples, int32(v)<<16) + } + seq := s.sequence.Add(1) - 1 + chunk := ingest.PCMChunk{ + Samples: samples, + Channels: s.channels, + SampleRateHz: s.sampleRate, + Sequence: seq, + Timestamp: time.Now(), + SourceID: s.id, + } + select { + case s.chunks <- chunk: + default: + s.discontinuities.Add(1) + return 0, fmt.Errorf("http raw ingress overflow") + } + frames := usable / frameBytes + s.chunksIn.Add(1) + s.samplesIn.Add(uint64(len(samples))) + s.lastChunkAtUnix.Store(time.Now().UnixNano()) + return frames, nil +} diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index 27c7db5..df0048c 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -34,6 +34,12 @@ func NewRuntime(sink *audio.StreamSource, src Source) *Runtime { } func (r *Runtime) Start(ctx context.Context) error { + if r.sink == nil { + r.mu.Lock() + r.stats.State = "failed" + r.mu.Unlock() + return nil + } if r.source == nil { r.mu.Lock() r.stats.State = "idle" @@ -91,7 +97,11 @@ func (r *Runtime) run() { select { case <-r.ctx.Done(): return - case err := <-errCh: + case err, ok := <-errCh: + if !ok { + errCh = nil + continue + } if err == nil { continue } @@ -100,6 +110,9 @@ func (r *Runtime) run() { r.mu.Unlock() case chunk, ok := <-ch: if !ok { + r.mu.Lock() + r.stats.State = "stopped" + r.mu.Unlock() return } r.handleChunk(chunk) From 4e522faaa9b5ec63d1085b16436a1af159611070 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:08:59 +0200 Subject: [PATCH 04/40] ingest: add icecast adapter and decoder layer skeleton --- cmd/fmrtx/main.go | 10 + internal/config/config.go | 3 + internal/ingest/adapters/icecast/reconnect.go | 31 +++ .../ingest/adapters/icecast/reconnect_test.go | 26 +++ internal/ingest/adapters/icecast/source.go | 204 ++++++++++++++++++ internal/ingest/decoder/aac/decoder.go | 20 ++ internal/ingest/decoder/decoder.go | 66 ++++++ internal/ingest/decoder/decoder_test.go | 42 ++++ internal/ingest/decoder/fallback/ffmpeg.go | 20 ++ internal/ingest/decoder/mp3/decoder.go | 20 ++ internal/ingest/decoder/oggvorbis/decoder.go | 20 ++ 11 files changed, 462 insertions(+) create mode 100644 internal/ingest/adapters/icecast/reconnect.go create mode 100644 internal/ingest/adapters/icecast/reconnect_test.go create mode 100644 internal/ingest/adapters/icecast/source.go create mode 100644 internal/ingest/decoder/aac/decoder.go create mode 100644 internal/ingest/decoder/decoder.go create mode 100644 internal/ingest/decoder/decoder_test.go create mode 100644 internal/ingest/decoder/fallback/ffmpeg.go create mode 100644 internal/ingest/decoder/mp3/decoder.go create mode 100644 internal/ingest/decoder/oggvorbis/decoder.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index fbd8578..5631bca 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -17,6 +17,7 @@ import ( drypkg "github.com/jan/fm-rds-tx/internal/dryrun" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" "github.com/jan/fm-rds-tx/internal/platform" "github.com/jan/fm-rds-tx/internal/platform/plutosdr" @@ -265,6 +266,8 @@ func ingestSampleRate(cfg cfgpkg.Config) int { return cfg.Ingest.Stdin.SampleRateHz case "http-raw": return cfg.Ingest.HTTPRaw.SampleRateHz + case "icecast": + return 44100 default: return 44100 } @@ -278,6 +281,13 @@ func buildPhase1Source(cfg cfgpkg.Config) (ingest.Source, ctrlpkg.AudioIngress, case "http-raw": src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels) return src, src, nil + case "icecast": + src := icecast.New("icecast-main", cfg.Ingest.Icecast.URL, nil, icecast.ReconnectConfig{ + Enabled: cfg.Ingest.Reconnect.Enabled, + InitialBackoffMs: cfg.Ingest.Reconnect.InitialBackoffMs, + MaxBackoffMs: cfg.Ingest.Reconnect.MaxBackoffMs, + }) + return src, nil, nil case "", "none": return nil, nil, nil default: diff --git a/internal/config/config.go b/internal/config/config.go index 37ca0ae..5ea3506 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -246,6 +246,9 @@ func (c Config) Validate() error { if c.Ingest.Stdin.Channels < 0 || c.Ingest.HTTPRaw.Channels < 0 { return fmt.Errorf("ingest pcm channels must be >= 0") } + if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { + return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") + } // Fail-loud PI validation if c.RDS.Enabled { if _, err := ParsePI(c.RDS.PI); err != nil { diff --git a/internal/ingest/adapters/icecast/reconnect.go b/internal/ingest/adapters/icecast/reconnect.go new file mode 100644 index 0000000..44fe2c2 --- /dev/null +++ b/internal/ingest/adapters/icecast/reconnect.go @@ -0,0 +1,31 @@ +package icecast + +import "time" + +type ReconnectConfig struct { + Enabled bool + InitialBackoffMs int + MaxBackoffMs int +} + +func (c ReconnectConfig) nextBackoff(attempt int) time.Duration { + if !c.Enabled { + return 0 + } + initial := c.InitialBackoffMs + if initial <= 0 { + initial = 1000 + } + max := c.MaxBackoffMs + if max <= 0 { + max = 15000 + } + d := time.Duration(initial) * time.Millisecond + for i := 1; i < attempt; i++ { + d *= 2 + if d >= time.Duration(max)*time.Millisecond { + return time.Duration(max) * time.Millisecond + } + } + return d +} diff --git a/internal/ingest/adapters/icecast/reconnect_test.go b/internal/ingest/adapters/icecast/reconnect_test.go new file mode 100644 index 0000000..16f961d --- /dev/null +++ b/internal/ingest/adapters/icecast/reconnect_test.go @@ -0,0 +1,26 @@ +package icecast + +import ( + "testing" + "time" +) + +func TestNextBackoff(t *testing.T) { + cfg := ReconnectConfig{ + Enabled: true, + InitialBackoffMs: 1000, + MaxBackoffMs: 5000, + } + if got := cfg.nextBackoff(1); got != 1*time.Second { + t.Fatalf("attempt1 got %s", got) + } + if got := cfg.nextBackoff(2); got != 2*time.Second { + t.Fatalf("attempt2 got %s", got) + } + if got := cfg.nextBackoff(3); got != 4*time.Second { + t.Fatalf("attempt3 got %s", got) + } + if got := cfg.nextBackoff(4); got != 5*time.Second { + t.Fatalf("attempt4 got %s", got) + } +} diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go new file mode 100644 index 0000000..319ed9a --- /dev/null +++ b/internal/ingest/adapters/icecast/source.go @@ -0,0 +1,204 @@ +package icecast + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" + "github.com/jan/fm-rds-tx/internal/ingest/decoder/aac" + "github.com/jan/fm-rds-tx/internal/ingest/decoder/mp3" + "github.com/jan/fm-rds-tx/internal/ingest/decoder/oggvorbis" +) + +type Source struct { + id string + url string + + client *http.Client + decReg *decoder.Registry + reconn ReconnectConfig + + chunks chan ingest.PCMChunk + errs chan error + + cancel context.CancelFunc + wg sync.WaitGroup + + state atomic.Value // string + connected atomic.Bool + chunksIn atomic.Uint64 + samplesIn atomic.Uint64 + reconnects atomic.Uint64 + discontinuities atomic.Uint64 + lastChunkAtUnix atomic.Int64 + lastError atomic.Value // string +} + +func New(id, url string, client *http.Client, reconn ReconnectConfig) *Source { + if id == "" { + id = "icecast-main" + } + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + s := &Source{ + id: id, + url: strings.TrimSpace(url), + client: client, + reconn: reconn, + chunks: make(chan ingest.PCMChunk, 64), + errs: make(chan error, 8), + decReg: defaultRegistry(), + } + s.state.Store("idle") + return s +} + +func defaultRegistry() *decoder.Registry { + r := decoder.NewRegistry() + r.Register("mp3", func() decoder.Decoder { return mp3.New() }) + r.Register("oggvorbis", func() decoder.Decoder { return oggvorbis.New() }) + r.Register("aac", func() decoder.Decoder { return aac.New() }) + return r +} + +func (s *Source) Descriptor() ingest.SourceDescriptor { + return ingest.SourceDescriptor{ + ID: s.id, + Kind: "icecast", + Family: "streaming", + Transport: "http", + Codec: "auto", + Detail: s.url, + } +} + +func (s *Source) Start(ctx context.Context) error { + if s.url == "" { + return fmt.Errorf("icecast url is required") + } + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.state.Store("connecting") + s.wg.Add(1) + go s.loop(runCtx) + return nil +} + +func (s *Source) Stop() error { + if s.cancel != nil { + s.cancel() + } + s.wg.Wait() + s.state.Store("stopped") + return nil +} + +func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } +func (s *Source) Errors() <-chan error { return s.errs } + +func (s *Source) Stats() ingest.SourceStats { + state, _ := s.state.Load().(string) + last := s.lastChunkAtUnix.Load() + errStr, _ := s.lastError.Load().(string) + var lastChunkAt time.Time + if last > 0 { + lastChunkAt = time.Unix(0, last) + } + return ingest.SourceStats{ + State: state, + Connected: s.connected.Load(), + LastChunkAt: lastChunkAt, + ChunksIn: s.chunksIn.Load(), + SamplesIn: s.samplesIn.Load(), + Reconnects: s.reconnects.Load(), + Discontinuities: s.discontinuities.Load(), + LastError: errStr, + } +} + +func (s *Source) loop(ctx context.Context) { + defer s.wg.Done() + defer close(s.chunks) + attempt := 0 + for { + select { + case <-ctx.Done(): + return + default: + } + + s.state.Store("connecting") + err := s.connectAndRun(ctx) + if err == nil || ctx.Err() != nil { + return + } + s.connected.Store(false) + s.lastError.Store(err.Error()) + select { + case s.errs <- err: + default: + } + s.state.Store("reconnecting") + attempt++ + s.reconnects.Add(1) + backoff := s.reconn.nextBackoff(attempt) + if backoff <= 0 { + s.state.Store("failed") + return + } + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + } +} + +func (s *Source) connectAndRun(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) + if err != nil { + return err + } + req.Header.Set("Icy-MetaData", "0") + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("icecast connect: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("icecast status: %s", resp.Status) + } + s.connected.Store(true) + s.state.Store("buffering") + + dec, err := s.decReg.SelectByContentType(resp.Header.Get("Content-Type")) + if err != nil { + return fmt.Errorf("icecast decoder select: %w", err) + } + s.state.Store("running") + return dec.DecodeStream(ctx, resp.Body, decoder.StreamMeta{ + ContentType: resp.Header.Get("Content-Type"), + SourceID: s.id, + }, s.emitChunk) +} + +func (s *Source) emitChunk(chunk ingest.PCMChunk) error { + select { + case s.chunks <- chunk: + default: + s.discontinuities.Add(1) + return io.ErrShortBuffer + } + s.chunksIn.Add(1) + s.samplesIn.Add(uint64(len(chunk.Samples))) + s.lastChunkAtUnix.Store(time.Now().UnixNano()) + return nil +} diff --git a/internal/ingest/decoder/aac/decoder.go b/internal/ingest/decoder/aac/decoder.go new file mode 100644 index 0000000..ec3816c --- /dev/null +++ b/internal/ingest/decoder/aac/decoder.go @@ -0,0 +1,20 @@ +package aac + +import ( + "context" + "fmt" + "io" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +type Decoder struct{} + +func New() *Decoder { return &Decoder{} } + +func (d *Decoder) Name() string { return "aac-native" } + +func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + return fmt.Errorf("%w: aac native decoder not wired yet", decoder.ErrUnsupported) +} diff --git a/internal/ingest/decoder/decoder.go b/internal/ingest/decoder/decoder.go new file mode 100644 index 0000000..a8f2689 --- /dev/null +++ b/internal/ingest/decoder/decoder.go @@ -0,0 +1,66 @@ +package decoder + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/jan/fm-rds-tx/internal/ingest" +) + +var ErrUnsupported = fmt.Errorf("decoder unsupported") + +type StreamMeta struct { + ContentType string + SampleRateHz int + Channels int + SourceID string +} + +type Decoder interface { + Name() string + DecodeStream(ctx context.Context, r io.Reader, meta StreamMeta, emit func(ingest.PCMChunk) error) error +} + +type Builder func() Decoder + +type Registry struct { + byName map[string]Builder +} + +func NewRegistry() *Registry { + return &Registry{byName: map[string]Builder{}} +} + +func (r *Registry) Register(name string, builder Builder) { + if r == nil || builder == nil { + return + } + r.byName[strings.ToLower(strings.TrimSpace(name))] = builder +} + +func (r *Registry) Create(name string) (Decoder, error) { + if r == nil { + return nil, fmt.Errorf("%w: registry nil", ErrUnsupported) + } + builder, ok := r.byName[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnsupported, name) + } + return builder(), nil +} + +func (r *Registry) SelectByContentType(contentType string) (Decoder, error) { + ct := strings.ToLower(strings.TrimSpace(contentType)) + switch { + case strings.Contains(ct, "mpeg"), strings.Contains(ct, "mp3"): + return r.Create("mp3") + case strings.Contains(ct, "ogg"), strings.Contains(ct, "vorbis"): + return r.Create("oggvorbis") + case strings.Contains(ct, "aac"), strings.Contains(ct, "adts"): + return r.Create("aac") + default: + return nil, fmt.Errorf("%w: content-type=%s", ErrUnsupported, contentType) + } +} diff --git a/internal/ingest/decoder/decoder_test.go b/internal/ingest/decoder/decoder_test.go new file mode 100644 index 0000000..b304d79 --- /dev/null +++ b/internal/ingest/decoder/decoder_test.go @@ -0,0 +1,42 @@ +package decoder + +import ( + "context" + "io" + "testing" + + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type fakeDecoder struct{ name string } + +func (d *fakeDecoder) Name() string { return d.name } + +func (d *fakeDecoder) DecodeStream(_ context.Context, _ io.Reader, _ StreamMeta, _ func(ingest.PCMChunk) error) error { + return nil +} + +func TestRegistrySelectByContentType(t *testing.T) { + r := NewRegistry() + r.Register("mp3", func() Decoder { return &fakeDecoder{name: "mp3"} }) + r.Register("oggvorbis", func() Decoder { return &fakeDecoder{name: "ogg"} }) + r.Register("aac", func() Decoder { return &fakeDecoder{name: "aac"} }) + + tests := []struct { + ct string + want string + }{ + {"audio/mpeg", "mp3"}, + {"application/ogg", "ogg"}, + {"audio/aac", "aac"}, + } + for _, tt := range tests { + dec, err := r.SelectByContentType(tt.ct) + if err != nil { + t.Fatalf("content-type %s: %v", tt.ct, err) + } + if dec.Name() != tt.want { + t.Fatalf("content-type %s: got %s want %s", tt.ct, dec.Name(), tt.want) + } + } +} diff --git a/internal/ingest/decoder/fallback/ffmpeg.go b/internal/ingest/decoder/fallback/ffmpeg.go new file mode 100644 index 0000000..a2fb3ee --- /dev/null +++ b/internal/ingest/decoder/fallback/ffmpeg.go @@ -0,0 +1,20 @@ +package fallback + +import ( + "context" + "fmt" + "io" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +type FFmpegDecoder struct{} + +func NewFFmpeg() *FFmpegDecoder { return &FFmpegDecoder{} } + +func (d *FFmpegDecoder) Name() string { return "ffmpeg-fallback" } + +func (d *FFmpegDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + return fmt.Errorf("%w: ffmpeg fallback decoder not wired yet", decoder.ErrUnsupported) +} diff --git a/internal/ingest/decoder/mp3/decoder.go b/internal/ingest/decoder/mp3/decoder.go new file mode 100644 index 0000000..93e5c79 --- /dev/null +++ b/internal/ingest/decoder/mp3/decoder.go @@ -0,0 +1,20 @@ +package mp3 + +import ( + "context" + "fmt" + "io" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +type Decoder struct{} + +func New() *Decoder { return &Decoder{} } + +func (d *Decoder) Name() string { return "mp3-native" } + +func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + return fmt.Errorf("%w: mp3 native decoder not wired yet", decoder.ErrUnsupported) +} diff --git a/internal/ingest/decoder/oggvorbis/decoder.go b/internal/ingest/decoder/oggvorbis/decoder.go new file mode 100644 index 0000000..0f7affa --- /dev/null +++ b/internal/ingest/decoder/oggvorbis/decoder.go @@ -0,0 +1,20 @@ +package oggvorbis + +import ( + "context" + "fmt" + "io" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +type Decoder struct{} + +func New() *Decoder { return &Decoder{} } + +func (d *Decoder) Name() string { return "oggvorbis-native" } + +func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + return fmt.Errorf("%w: ogg/vorbis native decoder not wired yet", decoder.ErrUnsupported) +} From 2673787c8884ddf734f4eb650e36d5a20f75d6e1 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:09:44 +0200 Subject: [PATCH 05/40] ingest: add runtime and control ingest stats tests --- internal/control/control_test.go | 39 ++++++++++++++++++++++ internal/ingest/runtime_test.go | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/ingest/runtime_test.go diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 176ff7a..b2a2752 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -10,6 +10,7 @@ import ( "testing" cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/output" ) @@ -176,6 +177,36 @@ func TestRuntimeWithoutDriver(t *testing.T) { } } +func TestRuntimeIncludesIngestStats(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetIngestRuntime(&fakeIngestRuntime{ + stats: ingest.Stats{ + Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"}, + Runtime: ingest.RuntimeStats{State: "running"}, + }, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal runtime: %v", err) + } + ingest, ok := body["ingest"].(map[string]any) + if !ok { + t.Fatalf("expected ingest stats, got %T", body["ingest"]) + } + active, ok := ingest["active"].(map[string]any) + if !ok { + t.Fatalf("expected ingest.active map, got %T", ingest["active"]) + } + if active["id"] != "stdin-main" { + t.Fatalf("unexpected ingest active id: %v", active["id"]) + } +} + func TestRuntimeReportsFaultHistory(t *testing.T) { srv := NewServer(cfgpkg.Default()) history := []map[string]any{ @@ -604,12 +635,20 @@ type fakeAudioIngress struct { totalFrames int } +type fakeIngestRuntime struct { + stats ingest.Stats +} + func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) { frames := len(data) / 4 f.totalFrames += frames return frames, nil } +func (f *fakeIngestRuntime) Stats() ingest.Stats { + return f.stats +} + func (f *fakeTXController) StartTX() error { return nil } func (f *fakeTXController) StopTX() error { return nil } func (f *fakeTXController) TXStats() map[string]any { diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go new file mode 100644 index 0000000..a3df6e7 --- /dev/null +++ b/internal/ingest/runtime_test.go @@ -0,0 +1,56 @@ +package ingest + +import ( + "context" + "testing" + "time" + + "github.com/jan/fm-rds-tx/internal/audio" +) + +type fakeSource struct { + desc SourceDescriptor + chunks chan PCMChunk + errs chan error + stats SourceStats +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + desc: SourceDescriptor{ID: "fake", Kind: "stdin-pcm"}, + chunks: make(chan PCMChunk, 4), + errs: make(chan error, 1), + stats: SourceStats{State: "running", Connected: true}, + } +} + +func (s *fakeSource) Descriptor() SourceDescriptor { return s.desc } +func (s *fakeSource) Start(context.Context) error { return nil } +func (s *fakeSource) Stop() error { close(s.chunks); return nil } +func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks } +func (s *fakeSource) Errors() <-chan error { return s.errs } +func (s *fakeSource) Stats() SourceStats { return s.stats } + +func TestRuntimeWritesFramesToStreamSink(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + rt := NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 44100, + Samples: []int32{1000 << 16, -1000 << 16}, + } + + deadline := time.Now().Add(1 * time.Second) + for sink.Available() < 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if sink.Available() < 1 { + t.Fatal("expected at least one frame in sink") + } +} From 58676ba6e2a1bc1f16328c4cf27b285b1252fb77 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:21:52 +0200 Subject: [PATCH 06/40] ingest: centralize source factory and wire icecast decoder fallback --- internal/ingest/adapters/icecast/source.go | 109 +++++++++++--- .../ingest/adapters/icecast/source_test.go | 107 +++++++++++++ internal/ingest/decoder/fallback/ffmpeg.go | 141 +++++++++++++++++- internal/ingest/factory.go | 23 --- internal/ingest/factory/factory.go | 76 ++++++++++ internal/ingest/factory/factory_test.go | 102 +++++++++++++ 6 files changed, 516 insertions(+), 42 deletions(-) create mode 100644 internal/ingest/adapters/icecast/source_test.go delete mode 100644 internal/ingest/factory.go create mode 100644 internal/ingest/factory/factory.go create mode 100644 internal/ingest/factory/factory_test.go diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 319ed9a..93c01f0 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -2,6 +2,7 @@ package icecast import ( "context" + "errors" "fmt" "io" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" "github.com/jan/fm-rds-tx/internal/ingest/decoder/aac" + "github.com/jan/fm-rds-tx/internal/ingest/decoder/fallback" "github.com/jan/fm-rds-tx/internal/ingest/decoder/mp3" "github.com/jan/fm-rds-tx/internal/ingest/decoder/oggvorbis" ) @@ -25,6 +27,8 @@ type Source struct { decReg *decoder.Registry reconn ReconnectConfig + decoderPreference string + chunks chan ingest.PCMChunk errs chan error @@ -41,7 +45,23 @@ type Source struct { lastError atomic.Value // string } -func New(id, url string, client *http.Client, reconn ReconnectConfig) *Source { +type Option func(*Source) + +func WithDecoderPreference(pref string) Option { + return func(s *Source) { + s.decoderPreference = normalizeDecoderPreference(pref) + } +} + +func WithDecoderRegistry(reg *decoder.Registry) Option { + return func(s *Source) { + if reg != nil { + s.decReg = reg + } + } +} + +func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Option) *Source { if id == "" { id = "icecast-main" } @@ -49,14 +69,21 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig) *Source { client = &http.Client{Timeout: 20 * time.Second} } s := &Source{ - id: id, - url: strings.TrimSpace(url), - client: client, - reconn: reconn, - chunks: make(chan ingest.PCMChunk, 64), - errs: make(chan error, 8), - decReg: defaultRegistry(), + id: id, + url: strings.TrimSpace(url), + client: client, + reconn: reconn, + chunks: make(chan ingest.PCMChunk, 64), + errs: make(chan error, 8), + decReg: defaultRegistry(), + decoderPreference: "auto", + } + for _, opt := range opts { + if opt != nil { + opt(s) + } } + s.decoderPreference = normalizeDecoderPreference(s.decoderPreference) s.state.Store("idle") return s } @@ -66,6 +93,7 @@ func defaultRegistry() *decoder.Registry { r.Register("mp3", func() decoder.Decoder { return mp3.New() }) r.Register("oggvorbis", func() decoder.Decoder { return oggvorbis.New() }) r.Register("aac", func() decoder.Decoder { return aac.New() }) + r.Register("ffmpeg", func() decoder.Decoder { return fallback.NewFFmpeg() }) return r } @@ -75,7 +103,7 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { Kind: "icecast", Family: "streaming", Transport: "http", - Codec: "auto", + Codec: s.decoderPreference, Detail: s.url, } } @@ -179,15 +207,13 @@ func (s *Source) connectAndRun(ctx context.Context) error { s.connected.Store(true) s.state.Store("buffering") - dec, err := s.decReg.SelectByContentType(resp.Header.Get("Content-Type")) - if err != nil { - return fmt.Errorf("icecast decoder select: %w", err) - } s.state.Store("running") - return dec.DecodeStream(ctx, resp.Body, decoder.StreamMeta{ - ContentType: resp.Header.Get("Content-Type"), - SourceID: s.id, - }, s.emitChunk) + return s.decodeWithPreference(ctx, resp.Body, decoder.StreamMeta{ + ContentType: resp.Header.Get("Content-Type"), + SourceID: s.id, + SampleRateHz: 44100, + Channels: 2, + }) } func (s *Source) emitChunk(chunk ingest.PCMChunk) error { @@ -202,3 +228,52 @@ func (s *Source) emitChunk(chunk ingest.PCMChunk) error { s.lastChunkAtUnix.Store(time.Now().UnixNano()) return nil } + +func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, meta decoder.StreamMeta) error { + mode := normalizeDecoderPreference(s.decoderPreference) + switch mode { + case "ffmpeg": + return s.decodeNamed(ctx, "ffmpeg", stream, meta) + case "native": + native, err := s.decReg.SelectByContentType(meta.ContentType) + if err != nil { + return fmt.Errorf("icecast native decoder select: %w", err) + } + return native.DecodeStream(ctx, stream, meta, s.emitChunk) + case "auto": + native, err := s.decReg.SelectByContentType(meta.ContentType) + if err == nil { + if err := native.DecodeStream(ctx, stream, meta, s.emitChunk); err == nil { + return nil + } else if !errors.Is(err, decoder.ErrUnsupported) { + return err + } + } else if !errors.Is(err, decoder.ErrUnsupported) { + return fmt.Errorf("icecast decoder select: %w", err) + } + return s.decodeNamed(ctx, "ffmpeg", stream, meta) + default: + return fmt.Errorf("unsupported icecast decoder mode: %s", mode) + } +} + +func (s *Source) decodeNamed(ctx context.Context, name string, stream io.Reader, meta decoder.StreamMeta) error { + dec, err := s.decReg.Create(name) + if err != nil { + return fmt.Errorf("icecast decoder=%s unavailable: %w", name, err) + } + return dec.DecodeStream(ctx, stream, meta, s.emitChunk) +} + +func normalizeDecoderPreference(pref string) string { + switch strings.ToLower(strings.TrimSpace(pref)) { + case "", "auto": + return "auto" + case "native": + return "native" + case "ffmpeg", "fallback": + return "ffmpeg" + default: + return strings.ToLower(strings.TrimSpace(pref)) + } +} diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go new file mode 100644 index 0000000..3786d90 --- /dev/null +++ b/internal/ingest/adapters/icecast/source_test.go @@ -0,0 +1,107 @@ +package icecast + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +type testDecoder struct { + name string + err error + called int +} + +func (d *testDecoder) Name() string { return d.name } + +func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + d.called++ + return d.err +} + +func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) { + native := &testDecoder{name: "native", err: decoder.ErrUnsupported} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if native.called != 1 { + t.Fatalf("native called %d times", native.called) + } + if fallback.called != 1 { + t.Fatalf("fallback called %d times", fallback.called) + } +} + +func TestDecodeWithPreferenceNativeDoesNotFallback(t *testing.T) { + nativeErr := errors.New("decode failed") + native := &testDecoder{name: "native", err: nativeErr} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("native"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "ice-test", + }) + if !errors.Is(err, nativeErr) { + t.Fatalf("expected native error, got %v", err) + } + if fallback.called != 0 { + t.Fatalf("fallback should not be called, got %d", fallback.called) + } +} + +func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) { + native := &testDecoder{name: "native"} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("ffmpeg"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if native.called != 0 { + t.Fatalf("native should not be called in ffmpeg mode, got %d", native.called) + } + if fallback.called != 1 { + t.Fatalf("fallback called %d times", fallback.called) + } +} diff --git a/internal/ingest/decoder/fallback/ffmpeg.go b/internal/ingest/decoder/fallback/ffmpeg.go index a2fb3ee..6dc4198 100644 --- a/internal/ingest/decoder/fallback/ffmpeg.go +++ b/internal/ingest/decoder/fallback/ffmpeg.go @@ -2,8 +2,14 @@ package fallback import ( "context" + "encoding/binary" + "errors" "fmt" "io" + "os/exec" + "strings" + "sync" + "time" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" @@ -15,6 +21,137 @@ func NewFFmpeg() *FFmpegDecoder { return &FFmpegDecoder{} } func (d *FFmpegDecoder) Name() string { return "ffmpeg-fallback" } -func (d *FFmpegDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { - return fmt.Errorf("%w: ffmpeg fallback decoder not wired yet", decoder.ErrUnsupported) +func (d *FFmpegDecoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error { + if r == nil { + return fmt.Errorf("%w: ffmpeg decoder stream reader is nil", decoder.ErrUnsupported) + } + if emit == nil { + return fmt.Errorf("%w: ffmpeg decoder emit callback is nil", decoder.ErrUnsupported) + } + + sampleRate := meta.SampleRateHz + if sampleRate <= 0 { + sampleRate = 44100 + } + channels := meta.Channels + if channels <= 0 { + channels = 2 + } + + cmd := exec.CommandContext(ctx, + "ffmpeg", + "-hide_banner", "-loglevel", "error", + "-i", "pipe:0", + "-f", "s16le", + "-acodec", "pcm_s16le", + "-ac", fmt.Sprintf("%d", channels), + "-ar", fmt.Sprintf("%d", sampleRate), + "pipe:1", + ) + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("ffmpeg stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("ffmpeg stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("ffmpeg stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + if errorsIsNotFound(err) { + return fmt.Errorf("%w: ffmpeg executable not found in PATH", decoder.ErrUnsupported) + } + return fmt.Errorf("ffmpeg start: %w", err) + } + + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, copyErr := io.Copy(stdin, r) + _ = stdin.Close() + if copyErr != nil && ctx.Err() == nil { + errCh <- fmt.Errorf("ffmpeg stdin copy: %w", copyErr) + } + }() + + stderrData, _ := io.ReadAll(stderr) + readErr := d.readPCM(ctx, stdout, sampleRate, channels, meta.SourceID, emit) + waitErr := cmd.Wait() + wg.Wait() + close(errCh) + + for e := range errCh { + if e != nil { + return e + } + } + if readErr != nil { + return readErr + } + if waitErr != nil && ctx.Err() == nil { + msg := strings.TrimSpace(string(stderrData)) + if msg != "" { + return fmt.Errorf("ffmpeg decode: %w (%s)", waitErr, msg) + } + return fmt.Errorf("ffmpeg decode: %w", waitErr) + } + return nil +} + +func (d *FFmpegDecoder) readPCM(ctx context.Context, r io.Reader, sampleRate, channels int, sourceID string, emit func(ingest.PCMChunk) error) error { + const chunkFrames = 1024 + frameBytes := channels * 2 + buf := make([]byte, chunkFrames*frameBytes) + seq := uint64(0) + for { + select { + case <-ctx.Done(): + return nil + default: + } + n, err := io.ReadAtLeast(r, buf, frameBytes) + if err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + if n > 0 { + if emitErr := emitPCM(buf[:n], seq, sampleRate, channels, sourceID, emit); emitErr != nil { + return emitErr + } + } + return nil + } + return fmt.Errorf("ffmpeg read pcm: %w", err) + } + if emitErr := emitPCM(buf[:n], seq, sampleRate, channels, sourceID, emit); emitErr != nil { + return emitErr + } + seq++ + } +} + +func emitPCM(data []byte, seq uint64, sampleRate, channels int, sourceID string, emit func(ingest.PCMChunk) error) error { + samples := make([]int32, 0, len(data)/2) + for i := 0; i+1 < len(data); i += 2 { + v := int16(binary.LittleEndian.Uint16(data[i : i+2])) + samples = append(samples, int32(v)<<16) + } + return emit(ingest.PCMChunk{ + Samples: samples, + Channels: channels, + SampleRateHz: sampleRate, + Sequence: seq, + Timestamp: time.Now(), + SourceID: sourceID, + }) +} + +func errorsIsNotFound(err error) bool { + var execErr *exec.Error + return err != nil && (errors.As(err, &execErr) || strings.Contains(strings.ToLower(err.Error()), "executable file not found")) } diff --git a/internal/ingest/factory.go b/internal/ingest/factory.go deleted file mode 100644 index d3a65e7..0000000 --- a/internal/ingest/factory.go +++ /dev/null @@ -1,23 +0,0 @@ -package ingest - -import ( - "fmt" - "io" - "net/http" - - "github.com/jan/fm-rds-tx/internal/config" -) - -type FactoryDeps struct { - Stdin io.Reader - HTTP *http.Client -} - -func BuildSource(cfg config.Config, deps FactoryDeps) (Source, error) { - switch cfg.Ingest.Kind { - case "", "none": - return nil, nil - default: - return nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) - } -} diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go new file mode 100644 index 0000000..5a46905 --- /dev/null +++ b/internal/ingest/factory/factory.go @@ -0,0 +1,76 @@ +package factory + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" +) + +type Deps struct { + Stdin io.Reader + HTTP *http.Client +} + +type AudioIngress interface { + WritePCM16(data []byte) (int, error) +} + +func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, error) { + switch normalizeIngestKind(cfg.Ingest.Kind) { + case "", "none": + return nil, nil, nil + case "stdin", "stdin-pcm": + reader := deps.Stdin + if reader == nil { + reader = os.Stdin + } + src := stdinpcm.New("stdin-main", reader, cfg.Ingest.Stdin.SampleRateHz, cfg.Ingest.Stdin.Channels, 1024) + return src, nil, nil + case "http-raw": + src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels) + return src, src, nil + case "icecast": + src := icecast.New( + "icecast-main", + cfg.Ingest.Icecast.URL, + deps.HTTP, + icecast.ReconnectConfig{ + Enabled: cfg.Ingest.Reconnect.Enabled, + InitialBackoffMs: cfg.Ingest.Reconnect.InitialBackoffMs, + MaxBackoffMs: cfg.Ingest.Reconnect.MaxBackoffMs, + }, + icecast.WithDecoderPreference(cfg.Ingest.Icecast.Decoder), + ) + return src, nil, nil + default: + return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) + } +} + +func SampleRateForKind(cfg config.Config) int { + switch normalizeIngestKind(cfg.Ingest.Kind) { + case "stdin", "stdin-pcm": + if cfg.Ingest.Stdin.SampleRateHz > 0 { + return cfg.Ingest.Stdin.SampleRateHz + } + case "http-raw": + if cfg.Ingest.HTTPRaw.SampleRateHz > 0 { + return cfg.Ingest.HTTPRaw.SampleRateHz + } + case "icecast": + return 44100 + } + return 44100 +} + +func normalizeIngestKind(kind string) string { + return strings.ToLower(strings.TrimSpace(kind)) +} diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go new file mode 100644 index 0000000..d443b2d --- /dev/null +++ b/internal/ingest/factory/factory_test.go @@ -0,0 +1,102 @@ +package factory + +import ( + "bytes" + "testing" + + "github.com/jan/fm-rds-tx/internal/config" +) + +func TestBuildSourceNone(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "none" + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src != nil || ingress != nil { + t.Fatalf("expected nil source and ingress for kind=none") + } +} + +func TestBuildSourceHTTPRawProvidesIngress(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "http-raw" + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source") + } + if ingress == nil { + t.Fatalf("expected ingress for http-raw") + } +} + +func TestBuildSourceStdin(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "stdin" + src, ingress, err := BuildSource(cfg, Deps{Stdin: bytes.NewReader(nil)}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source") + } + if ingress != nil { + t.Fatalf("expected no ingress for stdin") + } + if got := src.Descriptor().Kind; got != "stdin-pcm" { + t.Fatalf("source kind=%s", got) + } +} + +func TestBuildSourceIcecastUsesDecoderPreference(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "http://localhost:8000/stream" + cfg.Ingest.Icecast.Decoder = "ffmpeg" + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source") + } + if ingress != nil { + t.Fatalf("expected no ingress for icecast") + } + if got := src.Descriptor().Codec; got != "ffmpeg" { + t.Fatalf("codec=%s want ffmpeg", got) + } +} + +func TestBuildSourceUnsupportedKind(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "nope" + _, _, err := BuildSource(cfg, Deps{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestSampleRateForKind(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "stdin" + cfg.Ingest.Stdin.SampleRateHz = 48000 + if got := SampleRateForKind(cfg); got != 48000 { + t.Fatalf("stdin sample rate=%d", got) + } + + cfg.Ingest.Kind = "http-raw" + cfg.Ingest.HTTPRaw.SampleRateHz = 32000 + if got := SampleRateForKind(cfg); got != 32000 { + t.Fatalf("http-raw sample rate=%d", got) + } + + cfg.Ingest.Kind = "icecast" + if got := SampleRateForKind(cfg); got != 44100 { + t.Fatalf("icecast sample rate=%d", got) + } +} From 8794d484cd9f95e3dad79c9cfd55737c8dfebd7c Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 08:21:58 +0200 Subject: [PATCH 07/40] ingest: use unified factory in main and harden ingest config validation --- cmd/fmrtx/main.go | 43 ++------------------------- internal/config/config.go | 32 +++++++++++++++----- internal/config/config_test.go | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 48 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 5631bca..203b2e2 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -16,9 +16,7 @@ import ( ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" "github.com/jan/fm-rds-tx/internal/ingest" - "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" - "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" - "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" + ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" "github.com/jan/fm-rds-tx/internal/platform" "github.com/jan/fm-rds-tx/internal/platform/plutosdr" "github.com/jan/fm-rds-tx/internal/platform/soapysdr" @@ -179,7 +177,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a var ingestRuntime *ingest.Runtime var ingress ctrlpkg.AudioIngress if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { - rate := ingestSampleRate(cfg) + rate := ingestfactory.SampleRateForKind(cfg) bufferFrames := rate * 2 if bufferFrames <= 0 { bufferFrames = 1 @@ -187,7 +185,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a streamSrc = audio.NewStreamSource(bufferFrames, rate) engine.SetStreamSource(streamSrc) - source, sourceIngress, err := buildPhase1Source(cfg) + source, sourceIngress, err := ingestfactory.BuildSource(cfg, ingestfactory.Deps{Stdin: os.Stdin}) if err != nil { log.Fatalf("ingest source: %v", err) } @@ -260,41 +258,6 @@ func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, au return cfg } -func ingestSampleRate(cfg cfgpkg.Config) int { - switch cfg.Ingest.Kind { - case "stdin", "stdin-pcm": - return cfg.Ingest.Stdin.SampleRateHz - case "http-raw": - return cfg.Ingest.HTTPRaw.SampleRateHz - case "icecast": - return 44100 - default: - return 44100 - } -} - -func buildPhase1Source(cfg cfgpkg.Config) (ingest.Source, ctrlpkg.AudioIngress, error) { - switch cfg.Ingest.Kind { - case "stdin", "stdin-pcm": - src := stdinpcm.New("stdin-main", os.Stdin, cfg.Ingest.Stdin.SampleRateHz, cfg.Ingest.Stdin.Channels, 1024) - return src, nil, nil - case "http-raw": - src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels) - return src, src, nil - case "icecast": - src := icecast.New("icecast-main", cfg.Ingest.Icecast.URL, nil, icecast.ReconnectConfig{ - Enabled: cfg.Ingest.Reconnect.Enabled, - InitialBackoffMs: cfg.Ingest.Reconnect.InitialBackoffMs, - MaxBackoffMs: cfg.Ingest.Reconnect.MaxBackoffMs, - }) - return src, nil, nil - case "", "none": - return nil, nil, nil - default: - return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) - } -} - type txBridge struct{ engine *apppkg.Engine } func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) } diff --git a/internal/config/config.go b/internal/config/config.go index 5ea3506..2dff082 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -137,7 +137,7 @@ func Default() Config { Format: "s16le", }, Icecast: IngestIcecastConfig{ - Decoder: "native", + Decoder: "auto", }, }, } @@ -207,9 +207,6 @@ func (c Config) Validate() error { if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { return fmt.Errorf("fm.limiterCeiling out of range") } - if c.FM.MpxGain == 0 { - c.FM.MpxGain = 1.0 - } // default if omitted from JSON if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { return fmt.Errorf("fm.mpxGain out of range (0.1..5)") } @@ -228,6 +225,11 @@ func (c Config) Validate() error { if c.Ingest.Kind == "" { c.Ingest.Kind = "none" } + switch strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) { + case "none", "stdin", "stdin-pcm", "http-raw", "icecast": + default: + return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) + } if c.Ingest.PrebufferMs < 0 { return fmt.Errorf("ingest.prebufferMs must be >= 0") } @@ -237,18 +239,32 @@ func (c Config) Validate() error { if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 { return fmt.Errorf("ingest.reconnect backoff must be >= 0") } + if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 { + return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled") + } + if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 { + return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled") + } if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs { return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs") } - if c.Ingest.Stdin.SampleRateHz < 0 || c.Ingest.HTTPRaw.SampleRateHz < 0 { - return fmt.Errorf("ingest pcm sampleRateHz must be >= 0") + if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 { + return fmt.Errorf("ingest pcm sampleRateHz must be > 0") } - if c.Ingest.Stdin.Channels < 0 || c.Ingest.HTTPRaw.Channels < 0 { - return fmt.Errorf("ingest pcm channels must be >= 0") + if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) { + return fmt.Errorf("ingest pcm channels must be 1 or 2") + } + if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" { + return fmt.Errorf("ingest pcm format must be s16le") } if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") } + switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) { + case "", "auto", "native", "ffmpeg", "fallback": + default: + return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) + } // Fail-loud PI validation if c.RDS.Enabled { if _, err := ParsePI(c.RDS.PI); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 079d9c0..7236eff 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -123,3 +123,56 @@ func TestEffectiveDeviceRate(t *testing.T) { t.Fatal("expected 912000") } } + +func TestValidateRejectsUnsupportedIngestKind(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "unsupported" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error") + } +} + +func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { + cfg := Default() + cfg.Ingest.Stdin.SampleRateHz = 0 + if err := cfg.Validate(); err == nil { + t.Fatal("expected sampleRate error") + } + + cfg = Default() + cfg.Ingest.HTTPRaw.Channels = 6 + if err := cfg.Validate(); err == nil { + t.Fatal("expected channels error") + } + + cfg = Default() + cfg.Ingest.Stdin.Format = "f32le" + if err := cfg.Validate(); err == nil { + t.Fatal("expected format error") + } +} + +func TestValidateRejectsUnsupportedIcecastDecoder(t *testing.T) { + cfg := Default() + cfg.Ingest.Icecast.Decoder = "mystery" + if err := cfg.Validate(); err == nil { + t.Fatal("expected decoder error") + } +} + +func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { + cfg := Default() + cfg.Ingest.Reconnect.Enabled = true + cfg.Ingest.Reconnect.InitialBackoffMs = 0 + if err := cfg.Validate(); err == nil { + t.Fatal("expected reconnect backoff error") + } +} + +func TestValidateRejectsZeroMpxGain(t *testing.T) { + cfg := Default() + cfg.FM.MpxGain = 0 + if err := cfg.Validate(); err == nil { + t.Fatal("expected mpxGain error") + } +} From 51db52f934307bab1f90b64577c59f28be2ee3b4 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 12:00:44 +0200 Subject: [PATCH 08/40] docs(ingest): align phase-1 status and decoder fallback semantics --- README.md | 696 ++++++----- docs/API.md | 843 ++++++------- docs/audio-ingest-implementation-plan.md | 1097 +++++++++++++++++ docs/audio-ingest-rework.md | 267 ++++ docs/config.sample.json | 27 + internal/config/config_test.go | 8 + internal/control/control.go | 4 +- internal/ingest/adapters/icecast/source.go | 2 + .../ingest/adapters/icecast/source_test.go | 29 + 9 files changed, 2215 insertions(+), 758 deletions(-) create mode 100644 docs/audio-ingest-implementation-plan.md create mode 100644 docs/audio-ingest-rework.md diff --git a/README.md b/README.md index 9fac785..f03528e 100644 --- a/README.md +++ b/README.md @@ -1,340 +1,356 @@ -# fm-rds-tx - -Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and SoapySDR-compatible TX devices. - -## Status - -**Current status:** `v0.9.0` — runtime hardening milestone - -What is already in place: -- complete DSP chain: audio -> pre-emphasis -> stereo encoding -> RDS -> MPX -> limiter -> FM modulation -- real hardware TX paths for PlutoSDR / SoapySDR backends -- continuous TX engine with runtime telemetry -- dry-run, offline generation, and simulated TX modes -- HTTP control plane with live config patching and runtime/status endpoints -- browser UI on `/` -- live audio ingestion via stdin or HTTP stream input - -Current engineering focus: -- merge/release stabilization after runtime hardening -- deferred hardware-in-the-loop / RF validation work -- deferred device-aware capability / calibration work -- deferred signal self-monitoring work - -For the active runtime-hardening track, see: -- `docs/pro-runtime-hardening-workboard.md` - -## Signal path - -```text -Audio Source -> PreEmphasis(50us/75us/off) -> StereoEncoder(19k + 38k DSB-SC) --> RDS(57k BPSK) -> MPX Combiner -> Limiter -> FM Modulator(+/-75kHz) --> optional split-rate FM upsampling -> SDR backend -> RF output -``` - -For deeper DSP details, see: -- `docs/DSP-CHAIN.md` - -## Prerequisites - -### Go -- Go version from `go.mod` (currently Go 1.22) - -### Native SDR dependencies -Depending on backend, native libraries are required: - -- **SoapySDR backend** - - build with `-tags soapy` - - requires SoapySDR native library (`SoapySDR.dll` / `libSoapySDR.so` / `libSoapySDR.dylib`) - - on Windows, PothosSDR is the expected setup - -- **Pluto backend** - - uses native `libiio` - - Windows expects `libiio.dll` - - Linux build/runtime expects `pkg-config` + `libiio` - -### Hardware / legal -- validate RF output, deviation, filtering, and power with proper measurement equipment -- use only within applicable legal and regulatory constraints - -## Quick start - -## Build - -```powershell -# Build CLI tools without hardware-specific build tags: -go build ./cmd/fmrtx -go build ./cmd/offline - -# Build fmrtx with SoapySDR support: -go build -tags soapy ./cmd/fmrtx -``` - -## Quick verification - -```powershell -# Print effective config -go run ./cmd/fmrtx -print-config - -# Run tests -go test ./... - -# Basic dry-run summary -go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json -``` - -For additional build/test commands, see: -- `docs/README.md` - -## Common usage flows - -### 1) List available SDR devices - -```powershell -.\fmrtx.exe --list-devices -``` - -### 2) Dry-run / config verification - -```powershell -.\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json - -# Write dry-run JSON to stdout -.\fmrtx.exe --dry-run --dry-output - -``` - -### 3) Offline IQ/composite generation - -```powershell -go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32 - -# Optional output rate override -go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 -output-rate 228000 -``` - -### 4) Simulated transmit path - -```powershell -go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms -``` - -### 5) Real TX with config file - -```powershell -# Start TX service with manual start over HTTP -.\fmrtx.exe --tx --config docs/config.plutosdr.json - -# Start and begin transmitting immediately -.\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json -``` - -### 6) Live audio via stdin - -```powershell -ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json -``` - -### 7) Custom audio input rate - -```powershell -ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 48000 --config docs/config.plutosdr.json -``` - -### 8) HTTP audio ingest - -Start the control plane with `--audio-http` to accept raw PCM pushes on `/audio/stream` and feed them into the live encoder: - -Set `Content-Type` to `application/octet-stream` (or `audio/L16`) when posting audio data: - -```powershell -ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://localhost:8088/audio/stream -``` - -## CLI overview - -## `fmrtx` -Important runtime modes and flags include: -- `--tx` -- `--tx-auto-start` -- `--dry-run` -- `--dry-output ` -- `--simulate-tx` -- `--simulate-output ` -- `--simulate-duration ` -- `--config ` -- `--print-config` -- `--list-devices` -- `--audio-stdin` -- `--audio-rate ` -- `--audio-http` - -## `offline` -Useful flags include: -- `-duration ` -- `-output ` -- `-output-rate ` - -If the README is too high-level for the exact CLI surface, check: -- `cmd/fmrtx/main.go` -- `cmd/offline/main.go` - -## HTTP control plane - -Base URL: `http://{listenAddress}` (default typically `127.0.0.1:8088`) - -Security note: -- keep the control plane bound locally unless you intentionally place it behind a trusted and hardened access layer - -### Main endpoints - -```text -GET / browser UI -GET /healthz health check -GET /status current config/status snapshot -GET /runtime live engine / driver / audio telemetry -GET /config full config -POST /config patch config / live updates -GET /dry-run synthetic frame summary -POST /tx/start start transmission -POST /tx/stop stop transmission -POST /audio/stream push raw S16LE stereo PCM into live stream buffer (Content-Type: application/octet-stream or audio/L16 required) -``` - -### What the control plane covers -- TX start / stop -- runtime status and driver telemetry -- config inspection -- live patching of selected parameters -- dry-run inspection -- browser-accessible control UI -- optional HTTP audio ingest (enable with `--audio-http`) - -### Live config notes -`POST /config` supports live updates for selected fields such as: -- frequency -- stereo enable/disable -- pilot / RDS injection levels -- RDS enable/disable -- limiter settings -- PS / RadioText - -Some parameters are saved but not live-applied and require restart. - -For the full API contract, examples, live-patch semantics, and `/audio/stream` details, see: -- `docs/API.md` - -## Configuration - -Sample configs: -- `docs/config.sample.json` -- `docs/config.plutosdr.json` -- `docs/config.orangepi-pluto-soapy.json` - -Important config areas include: -- `fm.*` -- `rds.*` -- `audio.*` -- `backend.*` -- `control.*` - -Examples of relevant fields you may want to inspect: -- `fm.outputDrive` -- `fm.mpxGain` -- `fm.bs412Enabled` -- `fm.bs412ThresholdDBr` -- `fm.fmModulationEnabled` -- `backend.kind` -- `backend.driver` -- `backend.deviceArgs` -- `backend.uri` -- `backend.deviceSampleRateHz` -- `backend.outputPath` -- `control.listenAddress` - -For deeper config/API behavior, refer to: -- `internal/config/config.go` -- `docs/API.md` -- `docs/config.sample.json` - -## Development and testing - -Useful commands: - -```powershell -go test ./... -go run ./cmd/fmrtx -print-config -go run ./cmd/fmrtx -config docs/config.sample.json -go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json -go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms -go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 -``` - -See also: -- `docs/README.md` - -## PlutoSDR / backend notes - -- PlutoSDR commonly runs with a device-side sample rate above composite rate, so split-rate mode may be used automatically -- SoapySDR backend is suitable for Soapy-compatible TX hardware -- backend/device settings are selected through config rather than hardcoded paths -- runtime telemetry should be used to inspect effective TX state during operation - -## Repository layout - -```text -cmd/ - fmrtx/ main CLI - offline/ offline generator -internal/ - app/ TX engine + runtime state - audio/ audio input, resampling, tone generation, stream buffering - config/ config schema and validation - control/ HTTP control plane + browser UI - dryrun/ dry-run JSON summaries - dsp/ DSP primitives - mpx/ MPX combiner - offline/ full offline composite generation - output/ output/backend abstractions - platform/ backend abstractions and device/runtime stats - platform/soapysdr/ CGO SoapySDR binding - platform/plutosdr/ Pluto/libiio backend code - rds/ RDS encoder - stereo/ stereo encoder -docs/ - API.md - DSP-CHAIN.md - README.md - config.sample.json - config.plutosdr.json - config.orangepi-pluto-soapy.json - pro-runtime-hardening-workboard.md -scripts/ -examples/ -``` - -## Planning / workboard - -For the current runtime-hardening / professionalization track, see: -- `docs/pro-runtime-hardening-workboard.md` - -This is the living workboard for: -- status tracking -- confirmed findings -- open technical decisions -- verification notes -- implementation progress - -## Release / project docs - -Additional project docs: -- `CHANGELOG.md` -- `RELEASE.md` -- `docs/README.md` -- `docs/API.md` -- `docs/DSP-CHAIN.md` -- `docs/NOTES.md` - -## Legal note - -This project is intended only for lawful use within relevant license and regulatory constraints. -RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment. +# fm-rds-tx + +Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and SoapySDR-compatible TX devices. + +## Status + +**Current status:** `v0.9.0` — runtime hardening milestone + +What is already in place: +- complete DSP chain: audio -> pre-emphasis -> stereo encoding -> RDS -> MPX -> limiter -> FM modulation +- real hardware TX paths for PlutoSDR / SoapySDR backends +- continuous TX engine with runtime telemetry +- dry-run, offline generation, and simulated TX modes +- HTTP control plane with live config patching and runtime/status endpoints +- browser UI on `/` +- ingest runtime in front of TX stream sink, plus shared source/runtime stats +- ingest source factory for `stdin`, `http-raw`, and `icecast` +- Icecast source adapter with reconnect and decoder selection (`auto`/`native`/`ffmpeg`) +- decoder layer with explicit ffmpeg fallback path + +Current engineering focus: +- merge/release stabilization after runtime hardening +- deferred hardware-in-the-loop / RF validation work +- deferred device-aware capability / calibration work +- deferred signal self-monitoring work +- finish native Icecast decoder wiring (`mp3`/`oggvorbis`/`aac` are placeholders; ffmpeg fallback is the currently functional decode path) + +For the active runtime-hardening track, see: +- `docs/pro-runtime-hardening-workboard.md` + +## Signal path + +```text +Audio Source -> PreEmphasis(50us/75us/off) -> StereoEncoder(19k + 38k DSB-SC) +-> RDS(57k BPSK) -> MPX Combiner -> Limiter -> FM Modulator(+/-75kHz) +-> optional split-rate FM upsampling -> SDR backend -> RF output +``` + +For deeper DSP details, see: +- `docs/DSP-CHAIN.md` + +## Prerequisites + +### Go +- Go version from `go.mod` (currently Go 1.22) + +### Native SDR dependencies +Depending on backend, native libraries are required: + +- **SoapySDR backend** + - build with `-tags soapy` + - requires SoapySDR native library (`SoapySDR.dll` / `libSoapySDR.so` / `libSoapySDR.dylib`) + - on Windows, PothosSDR is the expected setup + +- **Pluto backend** + - uses native `libiio` + - Windows expects `libiio.dll` + - Linux build/runtime expects `pkg-config` + `libiio` + +### Hardware / legal +- validate RF output, deviation, filtering, and power with proper measurement equipment +- use only within applicable legal and regulatory constraints + +## Quick start + +## Build + +```powershell +# Build CLI tools without hardware-specific build tags: +go build ./cmd/fmrtx +go build ./cmd/offline + +# Build fmrtx with SoapySDR support: +go build -tags soapy ./cmd/fmrtx +``` + +## Quick verification + +```powershell +# Print effective config +go run ./cmd/fmrtx -print-config + +# Run tests +go test ./... + +# Basic dry-run summary +go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json +``` + +For additional build/test commands, see: +- `docs/README.md` + +## Common usage flows + +### 1) List available SDR devices + +```powershell +.\fmrtx.exe --list-devices +``` + +### 2) Dry-run / config verification + +```powershell +.\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json + +# Write dry-run JSON to stdout +.\fmrtx.exe --dry-run --dry-output - +``` + +### 3) Offline IQ/composite generation + +```powershell +go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32 + +# Optional output rate override +go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 -output-rate 228000 +``` + +### 4) Simulated transmit path + +```powershell +go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms +``` + +### 5) Real TX with config file + +```powershell +# Start TX service with manual start over HTTP +.\fmrtx.exe --tx --config docs/config.plutosdr.json + +# Start and begin transmitting immediately +.\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json +``` + +### 6) Live audio via stdin + +```powershell +ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json +``` + +### 7) Custom audio input rate + +```powershell +ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 48000 --config docs/config.plutosdr.json +``` + +### 8) HTTP audio ingest + +Start the control plane with `--audio-http` to accept raw PCM pushes on `/audio/stream` and feed them into the live encoder: + +Set `Content-Type` to `application/octet-stream` (or `audio/L16`) when posting audio data: + +```powershell +ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://localhost:8088/audio/stream +``` + +### 9) Icecast ingest via config + +Use `ingest.kind = "icecast"` and set `ingest.icecast.url` in config. + +Decoder semantics in Phase 1: +- `ingest.icecast.decoder = "auto"`: try native by content-type, fallback to ffmpeg on unsupported paths +- `ingest.icecast.decoder = "native"`: native only, no fallback +- `ingest.icecast.decoder = "ffmpeg"` (or `fallback`): ffmpeg only + +Current implementation note: native codec packages exist but are placeholders; practical decode today is ffmpeg fallback. + +## CLI overview + +## `fmrtx` +Important runtime modes and flags include: +- `--tx` +- `--tx-auto-start` +- `--dry-run` +- `--dry-output ` +- `--simulate-tx` +- `--simulate-output ` +- `--simulate-duration ` +- `--config ` +- `--print-config` +- `--list-devices` +- `--audio-stdin` +- `--audio-rate ` +- `--audio-http` + +## `offline` +Useful flags include: +- `-duration ` +- `-output ` +- `-output-rate ` + +If the README is too high-level for the exact CLI surface, check: +- `cmd/fmrtx/main.go` +- `cmd/offline/main.go` + +## HTTP control plane + +Base URL: `http://{listenAddress}` (default typically `127.0.0.1:8088`) + +Security note: +- keep the control plane bound locally unless you intentionally place it behind a trusted and hardened access layer + +### Main endpoints + +```text +GET / browser UI +GET /healthz health check +GET /status current config/status snapshot +GET /runtime live engine / driver / audio telemetry +GET /config full config +POST /config patch config / live updates +GET /dry-run synthetic frame summary +POST /tx/start start transmission +POST /tx/stop stop transmission +POST /audio/stream push raw S16LE stereo PCM into live stream buffer (Content-Type: application/octet-stream or audio/L16 required) +``` + +### What the control plane covers +- TX start / stop +- runtime status and driver telemetry +- config inspection +- live patching of selected parameters +- dry-run inspection +- browser-accessible control UI +- optional HTTP audio ingest (enable with `--audio-http`) + +### Live config notes +`POST /config` supports live updates for selected fields such as: +- frequency +- stereo enable/disable +- pilot / RDS injection levels +- RDS enable/disable +- limiter settings +- PS / RadioText + +Some parameters are saved but not live-applied and require restart. + +For the full API contract, examples, live-patch semantics, and `/audio/stream` details, see: +- `docs/API.md` + +## Configuration + +Sample configs: +- `docs/config.sample.json` +- `docs/config.plutosdr.json` +- `docs/config.orangepi-pluto-soapy.json` + +Important config areas include: +- `fm.*` +- `rds.*` +- `audio.*` +- `backend.*` +- `control.*` +- `ingest.*` + +Examples of relevant fields you may want to inspect: +- `fm.outputDrive` +- `fm.mpxGain` +- `fm.bs412Enabled` +- `fm.bs412ThresholdDBr` +- `fm.fmModulationEnabled` +- `backend.kind` +- `backend.driver` +- `backend.deviceArgs` +- `backend.uri` +- `backend.deviceSampleRateHz` +- `backend.outputPath` +- `control.listenAddress` + +For deeper config/API behavior, refer to: +- `internal/config/config.go` +- `docs/API.md` +- `docs/config.sample.json` + +## Development and testing + +Useful commands: + +```powershell +go test ./... +go run ./cmd/fmrtx -print-config +go run ./cmd/fmrtx -config docs/config.sample.json +go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json +go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms +go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 +``` + +See also: +- `docs/README.md` + +## PlutoSDR / backend notes + +- PlutoSDR commonly runs with a device-side sample rate above composite rate, so split-rate mode may be used automatically +- SoapySDR backend is suitable for Soapy-compatible TX hardware +- backend/device settings are selected through config rather than hardcoded paths +- runtime telemetry should be used to inspect effective TX state during operation + +## Repository layout + +```text +cmd/ + fmrtx/ main CLI + offline/ offline generator +internal/ + app/ TX engine + runtime state + audio/ audio input, resampling, tone generation, stream buffering + config/ config schema and validation + control/ HTTP control plane + browser UI + dryrun/ dry-run JSON summaries + dsp/ DSP primitives + mpx/ MPX combiner + offline/ full offline composite generation + output/ output/backend abstractions + platform/ backend abstractions and device/runtime stats + platform/soapysdr/ CGO SoapySDR binding + platform/plutosdr/ Pluto/libiio backend code + rds/ RDS encoder + stereo/ stereo encoder +docs/ + API.md + DSP-CHAIN.md + README.md + config.sample.json + config.plutosdr.json + config.orangepi-pluto-soapy.json + pro-runtime-hardening-workboard.md +scripts/ +examples/ +``` + +## Planning / workboard + +For the current runtime-hardening / professionalization track, see: +- `docs/pro-runtime-hardening-workboard.md` + +This is the living workboard for: +- status tracking +- confirmed findings +- open technical decisions +- verification notes +- implementation progress + +## Release / project docs + +Additional project docs: +- `CHANGELOG.md` +- `RELEASE.md` +- `docs/README.md` +- `docs/API.md` +- `docs/DSP-CHAIN.md` +- `docs/NOTES.md` + +## Legal note + +This project is intended only for lawful use within relevant license and regulatory constraints. +RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment. diff --git a/docs/API.md b/docs/API.md index 58d3ac1..fa9a6d8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,416 +1,427 @@ -# fm-rds-tx HTTP Control API - -Base URL: `http://{listenAddress}` (default `127.0.0.1:8088`) - ---- - -## Endpoints - -### `GET /healthz` - -Health check. - -**Response:** -```json -{"ok": true} -``` - -This endpoint is a simple liveness signal — it does not include runtime-state data or audit counters. Use it for readiness/liveness probes. - - ---- - -### `GET /status` - -Current transmitter status (read-only snapshot). Runtime indicator, alert, and queue stats from the running TX controller are mirrored here for quick health checks. - -**Response:** -```json -{ - "service": "fm-rds-tx", - "backend": "pluto", - "frequencyMHz": 100.0, - "stereoEnabled": true, - "rdsEnabled": true, - "preEmphasisTauUS": 50, - "limiterEnabled": true, - "fmModulationEnabled": true, - "runtimeIndicator": "normal", - "runtimeAlert": "", - "queue": { - "capacity": 3, - "depth": 1, - "fillLevel": 0.33, - "health": "low" - } -} -``` - -`runtimeIndicator` is derived from the engine queue health plus any late buffers observed in the last 5 seconds and can be "normal", "degraded", or "queueCritical". - -`runtimeState` mirrors the same runtime-state machine string that `/runtime` exposes as `engine.state` when a TX controller is active, so quick health checks reuse the same terminology. - -`runtimeAlert` surfaces a short reason (e.g. "queue health low" or "late buffers") when the indicator is not "normal", but late-buffer alerts expire after a few seconds once cycle times settle so the signal doesn't stay stuck on degraded. The cumulative `lateBuffers` counter returned by `/runtime` still shows how many late cycles have occurred since start for post-mortem diagnosis. - - ---- - -### `GET /runtime` - -Live engine and driver telemetry. Only populated when TX is active. - -**Response:** -```json -{ - "engine": { - "state": "running", - "runtimeStateDurationSeconds": 12.4, - "appliedFrequencyMHz": 100.0, - "chunksProduced": 12345, - "totalSamples": 1408950000, - "underruns": 0, - "lastError": "", - "uptimeSeconds": 3614.2, - "faultCount": 2, - "lastFault": { - "time": "2026-04-06T00:00:00Z", - "reason": "queueCritical", - "severity": "faulted", - "message": "queue health critical for 5 checks" - }, - "faultHistory": [ - { - "time": "2026-04-06T00:00:00Z", - "reason": "queueCritical", - "severity": "faulted", - "message": "queue health critical for 5 checks" - } - ], - "transitionHistory": [ - { - "time": "2026-04-06T00:00:00Z", - "from": "running", - "to": "degraded", - "severity": "warn" - } - ] - }, - "driver": { - "txEnabled": true, - "streamActive": true, - "framesWritten": 12345, - "samplesWritten": 1408950000, - "underruns": 0, - "underrunStreak": 0, - "maxUnderrunStreak": 0, - "effectiveSampleRateHz": 2280000 - }, - "controlAudit": { - "methodNotAllowed": 0, - "unsupportedMediaType": 0, - "bodyTooLarge": 0, - "unexpectedBody": 0 - } -} -``` -`engine.state` spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions. - -`runtimeStateDurationSeconds` sagt, wie viele Sekunden die Engine bereits im aktuellen Runtime-Zustand verweilt. So erkennt man schnell, ob `muted`/`degraded` zu lange dauern oder ob ein Übergang gerade frisch begonnen hat. - -`transitionHistory` liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können. - -`engine.appliedFrequencyMHz` meldet die zuletzt tatsächlich getunte Frequenz auf der Hardware, sodass man sie mit dem gewünschten `/config`-Wert vergleichen und ausstehende Live-Updates sofort entdecken kann. - -`driver.underrunStreak` reports how many consecutive reads returned silence, and `driver.maxUnderrunStreak` captures the longest such run since the engine started. Together they help differentiate short glitches from persistent underrun storms and can be plotted alongside queue health sparkline telemetry. - -`lastFault.reason` kann jetzt auch `writeTimeout` lauten, wenn der Treiber Schreibaufrufe wiederholt verweigert oder blockiert. Die Control-Plane hebt solche Driver-Faults hervor, damit man Blockaden im Writer-Pfad ohne Log-Search sieht. - -`controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload. - - ---- - -### `POST /runtime/fault/reset` - -Manually acknowledge a `faulted` runtime state so the supervisor can re-enter the recovery path (the engine moves back to `degraded` once the reset succeeds). - -**Response:** -```json -{"ok": true} -``` - -**Errors:** -- `405 Method Not Allowed` if the request is not a POST -- `503 Service Unavailable` when no TX controller is attached (`--tx` mode not active) -- `409 Conflict` when the engine is not currently faulted or the reset was rejected (e.g. still throttled) - ---- - -### `GET /config` - -Full current configuration (all fields, including non-patchable). - -**Response:** Complete `Config` JSON object. - ---- - -### `POST /config` - -**Live parameter update.** Changes are applied to the running TX engine immediately — no restart required. Only include fields you want to change (PATCH semantics). - -The control snapshot (GET /config) only reflects new values once they pass validation and, if the TX engine is running, after the live update succeeded. That keeps the API from reporting desired values that were rejected or still pending. - -**Request body:** JSON with any subset of patchable fields. - -**Content-Type:** `application/json` (charset parameters allowed). Requests without it are rejected with 415 Unsupported Media Type. - -**Response:** -```json -{"ok": true, "live": true} -``` - -`"live": true` = changes were forwarded to the running engine. -`"live": false` = engine not active, changes saved for next start. - -#### Patchable fields — DSP (applied within ~50ms) - -| Field | Type | Range | Description | -|---|---|---|---| -| `frequencyMHz` | float | 65–110 | TX center frequency. Tunes hardware LO live. | -| `outputDrive` | float | 0–10 | Composite output level multiplier (empfohlen 1..4). | -| `stereoEnabled` | bool | | Enable/disable stereo (pilot + 38kHz subcarrier). | -| `pilotLevel` | float | 0–0.2 | 19 kHz pilot injection level. | -| `rdsInjection` | float | 0–0.15 | 57 kHz RDS subcarrier injection level. | -| `rdsEnabled` | bool | | Enable/disable RDS subcarrier. | -| `limiterEnabled` | bool | | Enable/disable MPX peak limiter. | -| `limiterCeiling` | float | 0–2 | Limiter ceiling (max composite amplitude). | - -#### Patchable fields — RDS text (applied within ~88ms) - -| Field | Type | Max length | Description | -|---|---|---|---| -| `ps` | string | 8 chars | Program Service name (station name on receiver display). | -| `radioText` | string | 64 chars | RadioText message (scrolling text on receiver). | - -When `radioText` is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display. - -#### Patchable fields — other (saved, not live-applied) - -| Field | Type | Description | -|---|---|---| -| `toneLeftHz` | float | Left tone frequency (test generator). | -| `toneRightHz` | float | Right tone frequency (test generator). | -| `toneAmplitude` | float | Test tone amplitude (0–1). | -| `preEmphasisTauUS` | float | Pre-emphasis time constant. **Requires restart.** | - -#### Examples - -```bash -# Tune to 99.5 MHz -curl -X POST localhost:8088/config -d '{"frequencyMHz": 99.5}' - -# Switch to mono -curl -X POST localhost:8088/config -d '{"stereoEnabled": false}' - -# Update now-playing text -curl -X POST localhost:8088/config \ - -d '{"ps": "MYRADIO", "radioText": "Artist - Song Title"}' - -# Reduce power + disable limiter -curl -X POST localhost:8088/config \ - -d '{"outputDrive": 0.8, "limiterEnabled": false}' - -# Full update -curl -X POST localhost:8088/config -d '{ - "frequencyMHz": 101.3, - "outputDrive": 2.2, - "stereoEnabled": true, - "pilotLevel": 0.041, - "rdsInjection": 0.021, - "rdsEnabled": true, - "limiterEnabled": true, - "limiterCeiling": 1.0, - "ps": "PIRATE", - "radioText": "Broadcasting from the attic" -}' -``` - -#### Error handling - -Invalid values return `400 Bad Request` with a descriptive message: -```bash -curl -X POST localhost:8088/config -d '{"frequencyMHz": 200}' -# → 400: frequencyMHz out of range (65-110) -``` - ---- - -### `POST /tx/start` - -Start transmission. Requires `--tx` mode with hardware. - -**Response:** -```json -{"ok": true, "action": "started"} -``` - -**Errors:** -- `405` if not POST -- `503` if no TX controller (not in `--tx` mode) -- `409` if already running - ---- - -### `POST /tx/stop` - -Stop transmission. - -**Response:** -```json -{"ok": true, "action": "stopped"} -``` - ---- - -### `GET /dry-run` - -Generate a synthetic frame summary without hardware. Useful for config verification. - -**Response:** `FrameSummary` JSON with mode, rates, source info, preview samples. - ---- - -## Live update architecture - -All live updates are **lock-free** in the DSP path: - -| What | Mechanism | Latency | -|---|---|---| -| DSP params | `atomic.Pointer[LiveParams]` loaded once per chunk | ≤ 50ms | -| RDS text | `atomic.Value` in encoder, read at group boundary | ≤ 88ms | -| TX frequency | `atomic.Pointer` in engine, `driver.Tune()` between chunks | ≤ 50ms | - -No mutex, no channel, no allocation in the real-time path. The HTTP goroutine writes atomics, the DSP goroutine reads them. - -## Parameters that require restart - -These cannot be hot-reloaded (they affect DSP pipeline structure): - -- `compositeRateHz` — changes sample rate of entire DSP chain -- `deviceSampleRateHz` — changes hardware rate / upsampler ratio -- `maxDeviationHz` — changes FM modulator scaling -- `preEmphasisTauUS` — changes filter coefficients -- `rds.pi` / `rds.pty` — rarely change, baked into encoder init -- `audio.inputPath` — audio source selection -- `backend.kind` / `backend.device` — hardware selection - ---- - -### `POST /audio/stream` - -Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at the configured `--audio-rate` (default 44100 Hz). - -Requires `--audio-stdin`, `--audio-http`, or another configured stream source to feed the buffer. - -**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. Requests larger than 512 MiB are rejected with `413 Request Entity Too Large`. - -**Response:** -```json -{ - "ok": true, - "frames": 4096, - "stats": { - "available": 12000, - "capacity": 131072, - "buffered": 0.09, - "bufferedDurationSeconds": 0.27, - "highWatermark": 15000, - "highWatermarkDurationSeconds": 0.34, - "written": 890000, - "underruns": 0, - "overflows": 0 - } -} -``` - -**Example:** -```bash -# Push a file -ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ - curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://pluto:8088/audio/stream -``` - -**Errors:** -- `405` if not POST -- `415` if Content-Type is missing or unsupported (must be `application/octet-stream` or `audio/L16`) -- `413` if the upload body exceeds the 512 MiB limit -- `503` if no audio stream configured - ---- - -## Audio Streaming - -### Stdin pipe (primary method) - -Pipe any audio source through ffmpeg into the transmitter: - -```bash -# Internet radio stream -ffmpeg -i "http://stream.example.com/radio.mp3" -f s16le -ar 44100 -ac 2 - | \ - fmrtx --tx --tx-auto-start --audio-stdin --config config.json - -# Local music file -ffmpeg -i music.flac -f s16le -ar 44100 -ac 2 - | \ - fmrtx --tx --tx-auto-start --audio-stdin - -# Playlist (ffmpeg concat) -ffmpeg -f concat -i playlist.txt -f s16le -ar 44100 -ac 2 - | \ - fmrtx --tx --tx-auto-start --audio-stdin - -# PulseAudio / ALSA capture (Linux) -parecord --format=s16le --rate=44100 --channels=2 - | \ - fmrtx --tx --tx-auto-start --audio-stdin - -# Custom sample rate (e.g. 48kHz source) -ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \ - fmrtx --tx --tx-auto-start --audio-stdin --audio-rate 48000 -``` - -### HTTP audio push - -Push audio from a remote machine via the HTTP API. Run the server with `--audio-http` (and typically `--tx`/`--tx-auto-start`) so the `/audio/stream` endpoint is available. - -```bash -# From another machine on the network -ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | \ - curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://pluto-host:8088/audio/stream -``` - -### Audio buffer - -The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in `GET /runtime` under `audioStream`: - -```json -{ - "audioStream": { - "available": 12000, - "capacity": 131072, - "buffered": 0.09, - "bufferedDurationSeconds": 0.27, - "highWatermark": 15000, - "highWatermarkDurationSeconds": 0.34, - "written": 890000, - "underruns": 0, - "overflows": 0 - } -} -``` - -- **underruns**: DSP consumed faster than audio arrived (silence inserted) -- **overflows**: Audio arrived faster than DSP consumed (data dropped) -- **buffered**: Fill ratio (0.0 = empty, 1.0 = full) -- **bufferedDurationSeconds**: Approximate seconds of audio queued in the buffer (`available` frames divided by the sample rate) -- **highWatermark**: Highest observed buffer occupancy (frames) since the buffer was created -- **highWatermarkDurationSeconds**: Equivalent peak time (`highWatermark` frames divided by the sample rate) - -When no audio is streaming, the transmitter falls back to the configured tone generator or silence. +# fm-rds-tx HTTP Control API + +Base URL: `http://{listenAddress}` (default `127.0.0.1:8088`) + +--- + +## Endpoints + +### `GET /healthz` + +Health check. + +**Response:** +```json +{"ok": true} +``` + +This endpoint is a simple liveness signal — it does not include runtime-state data or audit counters. Use it for readiness/liveness probes. + + +--- + +### `GET /status` + +Current transmitter status (read-only snapshot). Runtime indicator, alert, and queue stats from the running TX controller are mirrored here for quick health checks. + +**Response:** +```json +{ + "service": "fm-rds-tx", + "backend": "pluto", + "frequencyMHz": 100.0, + "stereoEnabled": true, + "rdsEnabled": true, + "preEmphasisTauUS": 50, + "limiterEnabled": true, + "fmModulationEnabled": true, + "runtimeIndicator": "normal", + "runtimeAlert": "", + "queue": { + "capacity": 3, + "depth": 1, + "fillLevel": 0.33, + "health": "low" + } +} +``` + +`runtimeIndicator` is derived from the engine queue health plus any late buffers observed in the last 5 seconds and can be "normal", "degraded", or "queueCritical". + +`runtimeState` mirrors the same runtime-state machine string that `/runtime` exposes as `engine.state` when a TX controller is active, so quick health checks reuse the same terminology. + +`runtimeAlert` surfaces a short reason (e.g. "queue health low" or "late buffers") when the indicator is not "normal", but late-buffer alerts expire after a few seconds once cycle times settle so the signal doesn't stay stuck on degraded. The cumulative `lateBuffers` counter returned by `/runtime` still shows how many late cycles have occurred since start for post-mortem diagnosis. + + +--- + +### `GET /runtime` + +Live engine and driver telemetry. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under `ingest`. + +**Response:** +```json +{ + "engine": { + "state": "running", + "runtimeStateDurationSeconds": 12.4, + "appliedFrequencyMHz": 100.0, + "chunksProduced": 12345, + "totalSamples": 1408950000, + "underruns": 0, + "lastError": "", + "uptimeSeconds": 3614.2, + "faultCount": 2, + "lastFault": { + "time": "2026-04-06T00:00:00Z", + "reason": "queueCritical", + "severity": "faulted", + "message": "queue health critical for 5 checks" + }, + "faultHistory": [ + { + "time": "2026-04-06T00:00:00Z", + "reason": "queueCritical", + "severity": "faulted", + "message": "queue health critical for 5 checks" + } + ], + "transitionHistory": [ + { + "time": "2026-04-06T00:00:00Z", + "from": "running", + "to": "degraded", + "severity": "warn" + } + ] + }, + "driver": { + "txEnabled": true, + "streamActive": true, + "framesWritten": 12345, + "samplesWritten": 1408950000, + "underruns": 0, + "underrunStreak": 0, + "maxUnderrunStreak": 0, + "effectiveSampleRateHz": 2280000 + }, + "controlAudit": { + "methodNotAllowed": 0, + "unsupportedMediaType": 0, + "bodyTooLarge": 0, + "unexpectedBody": 0 + }, + "ingest": { + "active": { + "id": "icecast-main", + "kind": "icecast", + "family": "streaming", + "transport": "http", + "codec": "auto", + "detail": "http://example.invalid/stream" + }, + "source": { + "state": "running", + "connected": true, + "chunksIn": 123, + "samplesIn": 251904 + }, + "runtime": { + "state": "running", + "droppedFrames": 0, + "convertErrors": 0, + "writeBlocked": false + } + } +} +``` +`engine.state` spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions. + +`runtimeStateDurationSeconds` sagt, wie viele Sekunden die Engine bereits im aktuellen Runtime-Zustand verweilt. So erkennt man schnell, ob `muted`/`degraded` zu lange dauern oder ob ein Übergang gerade frisch begonnen hat. + +`transitionHistory` liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können. + +`engine.appliedFrequencyMHz` meldet die zuletzt tatsächlich getunte Frequenz auf der Hardware, sodass man sie mit dem gewünschten `/config`-Wert vergleichen und ausstehende Live-Updates sofort entdecken kann. + +`driver.underrunStreak` reports how many consecutive reads returned silence, and `driver.maxUnderrunStreak` captures the longest such run since the engine started. Together they help differentiate short glitches from persistent underrun storms and can be plotted alongside queue health sparkline telemetry. + +`lastFault.reason` kann jetzt auch `writeTimeout` lauten, wenn der Treiber Schreibaufrufe wiederholt verweigert oder blockiert. Die Control-Plane hebt solche Driver-Faults hervor, damit man Blockaden im Writer-Pfad ohne Log-Search sieht. + +`controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload. + + +--- + +### `POST /runtime/fault/reset` + +Manually acknowledge a `faulted` runtime state so the supervisor can re-enter the recovery path (the engine moves back to `degraded` once the reset succeeds). + +**Response:** +```json +{"ok": true} +``` + +**Errors:** +- `405 Method Not Allowed` if the request is not a POST +- `503 Service Unavailable` when no TX controller is attached (`--tx` mode not active) +- `409 Conflict` when the engine is not currently faulted or the reset was rejected (e.g. still throttled) + +--- + +### `GET /config` + +Full current configuration (all fields, including non-patchable). + +**Response:** Complete `Config` JSON object. + +--- + +### `POST /config` + +**Live parameter update.** Changes are applied to the running TX engine immediately — no restart required. Only include fields you want to change (PATCH semantics). + +The control snapshot (GET /config) only reflects new values once they pass validation and, if the TX engine is running, after the live update succeeded. That keeps the API from reporting desired values that were rejected or still pending. + +**Request body:** JSON with any subset of patchable fields. + +**Content-Type:** `application/json` (charset parameters allowed). Requests without it are rejected with 415 Unsupported Media Type. + +**Response:** +```json +{"ok": true, "live": true} +``` + +`"live": true` = changes were forwarded to the running engine. +`"live": false` = engine not active, changes saved for next start. + +#### Patchable fields — DSP (applied within ~50ms) + +| Field | Type | Range | Description | +|---|---|---|---| +| `frequencyMHz` | float | 65–110 | TX center frequency. Tunes hardware LO live. | +| `outputDrive` | float | 0–10 | Composite output level multiplier (empfohlen 1..4). | +| `stereoEnabled` | bool | | Enable/disable stereo (pilot + 38kHz subcarrier). | +| `pilotLevel` | float | 0–0.2 | 19 kHz pilot injection level. | +| `rdsInjection` | float | 0–0.15 | 57 kHz RDS subcarrier injection level. | +| `rdsEnabled` | bool | | Enable/disable RDS subcarrier. | +| `limiterEnabled` | bool | | Enable/disable MPX peak limiter. | +| `limiterCeiling` | float | 0–2 | Limiter ceiling (max composite amplitude). | + +#### Patchable fields — RDS text (applied within ~88ms) + +| Field | Type | Max length | Description | +|---|---|---|---| +| `ps` | string | 8 chars | Program Service name (station name on receiver display). | +| `radioText` | string | 64 chars | RadioText message (scrolling text on receiver). | + +When `radioText` is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display. + +#### Patchable fields — other (saved, not live-applied) + +| Field | Type | Description | +|---|---|---| +| `toneLeftHz` | float | Left tone frequency (test generator). | +| `toneRightHz` | float | Right tone frequency (test generator). | +| `toneAmplitude` | float | Test tone amplitude (0–1). | +| `preEmphasisTauUS` | float | Pre-emphasis time constant. **Requires restart.** | + +#### Examples + +```bash +# Tune to 99.5 MHz +curl -X POST localhost:8088/config -d '{"frequencyMHz": 99.5}' + +# Switch to mono +curl -X POST localhost:8088/config -d '{"stereoEnabled": false}' + +# Update now-playing text +curl -X POST localhost:8088/config \ + -d '{"ps": "MYRADIO", "radioText": "Artist - Song Title"}' + +# Reduce power + disable limiter +curl -X POST localhost:8088/config \ + -d '{"outputDrive": 0.8, "limiterEnabled": false}' + +# Full update +curl -X POST localhost:8088/config -d '{ + "frequencyMHz": 101.3, + "outputDrive": 2.2, + "stereoEnabled": true, + "pilotLevel": 0.041, + "rdsInjection": 0.021, + "rdsEnabled": true, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "ps": "PIRATE", + "radioText": "Broadcasting from the attic" +}' +``` + +#### Error handling + +Invalid values return `400 Bad Request` with a descriptive message: +```bash +curl -X POST localhost:8088/config -d '{"frequencyMHz": 200}' +# → 400: frequencyMHz out of range (65-110) +``` + +--- + +### `POST /tx/start` + +Start transmission. Requires `--tx` mode with hardware. + +**Response:** +```json +{"ok": true, "action": "started"} +``` + +**Errors:** +- `405` if not POST +- `503` if no TX controller (not in `--tx` mode) +- `409` if already running + +--- + +### `POST /tx/stop` + +Stop transmission. + +**Response:** +```json +{"ok": true, "action": "stopped"} +``` + +--- + +### `GET /dry-run` + +Generate a synthetic frame summary without hardware. Useful for config verification. + +**Response:** `FrameSummary` JSON with mode, rates, source info, preview samples. + +--- + +## Live update architecture + +All live updates are **lock-free** in the DSP path: + +| What | Mechanism | Latency | +|---|---|---| +| DSP params | `atomic.Pointer[LiveParams]` loaded once per chunk | ≤ 50ms | +| RDS text | `atomic.Value` in encoder, read at group boundary | ≤ 88ms | +| TX frequency | `atomic.Pointer` in engine, `driver.Tune()` between chunks | ≤ 50ms | + +No mutex, no channel, no allocation in the real-time path. The HTTP goroutine writes atomics, the DSP goroutine reads them. + +## Parameters that require restart + +These cannot be hot-reloaded (they affect DSP pipeline structure): + +- `compositeRateHz` — changes sample rate of entire DSP chain +- `deviceSampleRateHz` — changes hardware rate / upsampler ratio +- `maxDeviationHz` — changes FM modulator scaling +- `preEmphasisTauUS` — changes filter coefficients +- `rds.pi` / `rds.pty` — rarely change, baked into encoder init +- `audio.inputPath` — audio source selection +- `backend.kind` / `backend.device` — hardware selection + +--- + +### `POST /audio/stream` + +Push raw audio data into the ingest `http-raw` source. Format: **S16LE PCM** (`ingest.httpRaw.format`), currently validated as `s16le`, with channels/sample-rate from ingest config. + +Requires HTTP ingest wiring (typically `--audio-http`, which maps ingest kind to `http-raw`). + +**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. Requests larger than 512 MiB are rejected with `413 Request Entity Too Large`. + +**Response:** +```json +{ + "ok": true, + "frames": 4096 +} +``` + +**Example:** +```bash +# Push a file +ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://pluto:8088/audio/stream +``` + +**Errors:** +- `405` if not POST +- `415` if Content-Type is missing or unsupported (must be `application/octet-stream` or `audio/L16`) +- `413` if the upload body exceeds the 512 MiB limit +- `503` if HTTP raw ingest is not configured + +--- + +## Audio Streaming + +### Stdin pipe (primary method) + +Pipe any audio source through ffmpeg into the transmitter: + +```bash +# Internet radio stream +ffmpeg -i "http://stream.example.com/radio.mp3" -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --config config.json + +# Local music file +ffmpeg -i music.flac -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Playlist (ffmpeg concat) +ffmpeg -f concat -i playlist.txt -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# PulseAudio / ALSA capture (Linux) +parecord --format=s16le --rate=44100 --channels=2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Custom sample rate (e.g. 48kHz source) +ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --audio-rate 48000 +``` + +### HTTP audio push + +Push audio from a remote machine via the HTTP API. Run the server with `--audio-http` (and typically `--tx`/`--tx-auto-start`) so the `/audio/stream` endpoint is available. + +```bash +# From another machine on the network +ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://pluto-host:8088/audio/stream +``` + +### Audio buffer + +The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in `GET /runtime` under `audioStream`: + +```json +{ + "audioStream": { + "available": 12000, + "capacity": 131072, + "buffered": 0.09, + "bufferedDurationSeconds": 0.27, + "highWatermark": 15000, + "highWatermarkDurationSeconds": 0.34, + "written": 890000, + "underruns": 0, + "overflows": 0 + } +} +``` + +- **underruns**: DSP consumed faster than audio arrived (silence inserted) +- **overflows**: Audio arrived faster than DSP consumed (data dropped) +- **buffered**: Fill ratio (0.0 = empty, 1.0 = full) +- **bufferedDurationSeconds**: Approximate seconds of audio queued in the buffer (`available` frames divided by the sample rate) +- **highWatermark**: Highest observed buffer occupancy (frames) since the buffer was created +- **highWatermarkDurationSeconds**: Equivalent peak time (`highWatermark` frames divided by the sample rate) + +When no audio is streaming, the transmitter falls back to the configured tone generator or silence. diff --git a/docs/audio-ingest-implementation-plan.md b/docs/audio-ingest-implementation-plan.md new file mode 100644 index 0000000..fe5a19c --- /dev/null +++ b/docs/audio-ingest-implementation-plan.md @@ -0,0 +1,1097 @@ +# Audio Ingest Implementation Plan + +Status: Phase-1 fixup snapshot (2026-04-07) +Owner: Jan +Scope: `fm-rds-tx` +Related: `docs/audio-ingest-rework.md` + +## Goal + +Build a first-class audio ingest subsystem that supports multiple source families without pushing transport-specific logic into the FM TX engine or DSP path. + +This plan starts with a pragmatic integration strategy: + +- keep the existing TX/DSP pipeline stable +- introduce a new `internal/ingest` runtime in front of it +- preserve `audio.StreamSource` as the immediate TX-facing sink for now +- bring **Icecast ingest into Phase 1**, alongside stdin/raw HTTP ingest +- treat **native decoding as a first-class goal from the start**, not a late add-on + +The key architectural principle is: + +> Source-family specifics live in source adapters. Shared buffering, health, lifecycle, conversion, and handoff to TX live in a common ingest runtime. + +## Actual Phase-1 status (2026-04-07) + +Implemented: +- `internal/ingest` runtime in front of `audio.StreamSource` +- ingest source factory and config mapping for `stdin`, `http-raw`, `icecast` +- stdin and HTTP raw adapters feeding shared runtime +- runtime and source stats exposed via `/runtime` as `ingest.*` +- Icecast source adapter with reconnect/backoff and decoder preference modes (`auto`, `native`, `ffmpeg`) +- decoder registry and explicit ffmpeg fallback decoder implementation + +Still open on purpose: +- native `mp3`, `oggvorbis`, `aac` decoder packages are placeholders (`ErrUnsupported`) +- real decode path for Icecast is currently ffmpeg fallback +- no AoIP/SRT ingest integration into shared runtime yet +- no multi-source orchestration/failover policy yet + +--- + +## Non-goals for the first implementation wave + +The first wave should **not** attempt to solve everything at once. + +Out of scope initially: + +- full multi-source orchestration with seamless hot failover +- exhaustive native decoding support for every compressed format and edge case +- replacing the existing `offline.Generator` source contract +- redesigning the TX runtime state machine +- broad UI redesign +- a universal media graph framework + +We want a clean, incremental path, not a big-bang rewrite. + +--- + +## Current state of the codebase + +The repository already has most of the TX-side hooks needed for a proper ingest subsystem: + +- `cmd/fmrtx/main.go` + - creates `audio.StreamSource` + - wires it into the engine via `engine.SetStreamSource(...)` + - starts stdin and `/audio/stream` ingest paths directly +- `internal/app/engine.go` + - accepts a stream source via `SetStreamSource(...)` + - wraps it in `audio.NewStreamResampler(...)` + - injects it upstream of DSP via `generator.SetExternalSource(...)` +- `internal/audio/stream.go` + - provides a TX-facing SPSC ring buffer + - provides a simple `StreamResampler` + - tracks underruns, overflows, buffering, high watermark +- `internal/offline/generator.go` + - already cleanly accepts an external audio source +- `aoiprxkit/` + - already contains useful RTP/AES67/SAP/SRT receive-side primitives and stats + +This means the right move is **not** to redesign the FM core, but to formalize the missing ingest layer in front of the existing TX path. + +--- + +## Target architecture + +## Layers + +### 1. Source adapters + +Each adapter owns family-specific behavior, for example: + +- process control for ffmpeg-based adapters +- reconnect loops for Icecast +- RTP depacketization and jitter buffering for AoIP +- protocol-specific metadata and health signals + +Examples: + +- stdin PCM +- HTTP raw PCM +- Icecast stream +- RTP/AES67 +- SRT +- future ffmpeg-backed generic URL/file ingest + +### 2. Decoder layer + +A dedicated decoder layer sits between transport/session adapters and the shared ingest runtime. + +Responsibilities: + +- decode compressed audio streams into normalized PCM chunks +- keep codec-specific logic out of Icecast and other source adapters +- allow multiple decoder implementations behind a common interface +- prefer native Go decoders where they are stable and good enough +- allow an ffmpeg-backed fallback only as an implementation detail, not as the architecture + +Examples: + +- MP3 +- Ogg/Vorbis +- AAC/ADTS where practical +- later: Opus or other codecs as needed + +Initial decoder priority should be: + +1. MP3 +2. Ogg/Vorbis +3. AAC/ADTS +4. Opus later if a concrete source requirement justifies it + +### 3. Shared ingest runtime + +A common ingest runtime sits between decoders/source adapters and TX. + +Responsibilities: + +- source lifecycle +- prebuffering policy +- normalized source state +- family-neutral telemetry +- format conversion into TX-facing audio frames +- writing into the existing `audio.StreamSource` +- later: failover/orchestration + +### 4. Existing TX path + +The TX side stays mostly unchanged: + +- `audio.StreamSource` +- `audio.StreamResampler` +- `Engine.SetStreamSource(...)` +- `offline.Generator.SetExternalSource(...)` +- FM/DSP chain + +The TX engine should not know whether input came from stdin, Icecast, SRT, RTP, or something else. + +--- + +## Why Icecast is in Phase 1 + +Icecast should be introduced early, not postponed. + +Reasons: + +- it exercises a real long-running network stream rather than one-shot raw pushes +- it forces lifecycle design immediately: connecting, connected, stalled, reconnecting, failed +- it forces buffering and liveness behavior to be designed properly +- it prevents the ingest layer from being accidentally overfit to only raw PCM push workflows +- it reflects an important real-world ingest path for FM rebroadcast/transcoding scenarios +- it forces the project to define a real decoder boundary early + +Early Icecast support **should aim for native decoding where practical**. + +Initial Icecast strategy should therefore be: + +- separate transport/runtime concerns from decoding concerns +- define a decoder interface from the beginning +- prefer native Go decoders for common formats where mature libraries exist +- keep an ffmpeg-backed decoder only as fallback or temporary compatibility path +- keep the ingest runtime and source adapter interfaces clean enough that decoder implementation can evolve without redesigning the whole ingest subsystem + +--- + +## Phase plan + +## Phase 1: create the ingest runtime and ship first adapters + +### Deliverables + +- new `internal/ingest` package +- a decoder abstraction as part of the ingest subsystem +- a shared ingest runtime in front of `audio.StreamSource` +- adapters for: + - stdin PCM + - raw HTTP PCM + - Icecast stream +- decoder boundary with preference/fallback policy in place +- explicit Phase-1 codec prioritization: MP3 first, Ogg/Vorbis second, AAC/ADTS third +- runtime and source stats exposed in control API +- command/config plumbing for selecting an ingest source + +### Phase 1 boundary + +At the end of Phase 1: + +- TX still consumes through `audio.StreamSource` +- DSP path is unchanged +- source families are no longer wired directly into `cmd/fmrtx/main.go` +- Icecast works with reconnect + observable runtime state +- decoder selection/fallback behavior is explicit and test-covered +- native decoder implementations remain a follow-up item + +--- + +## Phase 2: integrate structured network audio families + +### Deliverables + +- adapters backed by `aoiprxkit` +- RTP/AES67 ingest +- SRT ingest +- shared source stats mapped into ingest runtime stats + +### Notes + +- family-specific jitter/packet handling stays inside adapter/family code +- TX side continues to see normalized stereo frames only + +--- + +## Phase 3: source selection, fallback, and richer policy + +### Deliverables + +- primary/fallback source model +- failure policy +- source switching policy +- improved operator telemetry +- optional source prioritization and warm standby + +This phase should only start once single-source ingest is stable. + +--- + +## New package structure + +Proposed initial layout: + +```text +internal/ + ingest/ + types.go + source.go + runtime.go + convert.go + stats.go + factory.go + decoder/ + decoder.go + mp3/ + decoder.go + aac/ + decoder.go + oggvorbis/ + decoder.go + fallback/ + ffmpeg.go + adapters/ + stdinpcm/ + source.go + httpraw/ + source.go + icecast/ + source.go + reconnect.go +``` + +Later additions: + +```text +internal/ingest/adapters/ + aoip/ + srt/ + ffmpeg/ +``` + +Notes: + +- codec-specific logic should live under `internal/ingest/decoder/` +- ffmpeg, if retained at all, should live under an explicit fallback package +- keep source-family code out of `internal/app` and `internal/offline` + +--- + +## Core interfaces + +These are design targets, not fixed signatures. + +## Normalized ingest-side frame model + +The ingest layer needs a family-neutral PCM representation before converting into the TX-facing `audio.Frame` stream. + +Proposed shape: + +```go +type PCMChunk struct { + Samples []int32 + Channels int + SampleRateHz int + Sequence uint64 + Timestamp time.Time + SourceID string + Discontinuity bool +} +``` + +Rationale: + +- expressive enough for RTP/AES67/SRT/decoded Icecast output +- allows transport metadata to be preserved long enough for runtime logic and stats +- avoids forcing all adapters into the same byte-stream assumption + +Future extension points if needed: + +- `Codec string` +- `ClockDomain string` +- `BitDepth int` +- `PTS time.Duration` + +--- + +## Source descriptor + +```go +type SourceDescriptor struct { + ID string + Kind string + Family string + Transport string + Codec string + Channels int + SampleRateHz int + Detail string +} +``` + +Examples: + +- `Kind=stdin-pcm`, `Family=raw`, `Transport=stdin` +- `Kind=http-raw`, `Family=raw`, `Transport=http` +- `Kind=icecast`, `Family=streaming`, `Transport=http` +- later `Kind=aes67`, `Family=aoip`, `Transport=rtp` + +--- + +## Source interface + +Two patterns are reasonable: + +- channel-based delivery +- sink/callback-based delivery + +For the first implementation, channel-based is usually easier to reason about. + +```go +type Source interface { + Descriptor() SourceDescriptor + Start(ctx context.Context) error + Stop() error + Chunks() <-chan PCMChunk + Errors() <-chan error + Stats() SourceStats +} +``` + +Alternative callback model is acceptable if it reduces allocations or simplifies integration. + +Important constraint: + +- the source adapter owns family-specific I/O +- the ingest runtime owns shared buffering/handoff policy + +--- + +## Shared source stats + +```go +type SourceStats struct { + State string + Connected bool + LastChunkAt time.Time + ChunksIn uint64 + SamplesIn uint64 + BufferedSeconds float64 + Overflows uint64 + Underruns uint64 + Reconnects uint64 + Discontinuities uint64 + TransportLoss uint64 + Reorders uint64 + JitterDepth int + LastError string +} +``` + +Not every source will populate every field. + +That is okay. + +The common runtime should expose a stable superset and leave unsupported fields at zero/default. + +--- + +## Shared ingest runtime + +## Responsibilities + +The runtime is the main missing abstraction in the current codebase. + +Responsibilities: + +- own exactly one active source in Phase 1 +- start/stop the source cleanly +- receive normalized `PCMChunk`s +- convert them into TX-facing stereo frames +- write them into `audio.StreamSource` +- enforce prebuffering policy where relevant +- expose common ingest state and health +- detect stalls/reconnects/discontinuities + +## Non-responsibilities + +The runtime should **not**: + +- parse RTP +- manage ffmpeg stderr parsing for generic protocol details +- implement protocol-specific jitter buffering directly +- manipulate FM/DSP runtime states directly + +It reports ingest health; TX remains responsible for TX health. + +--- + +## TX-facing sink strategy + +For now, keep this path: + +- ingest runtime writes into `audio.StreamSource` +- `Engine.SetStreamSource(...)` remains unchanged +- `audio.StreamResampler` remains the final rate adaptation step into composite/DSP rate + +This minimizes risk. + +It also keeps future refactors optional instead of mandatory. + +--- + +## Conversion policy + +A shared conversion layer is required between `PCMChunk` and `audio.StreamSource`. + +## Initial policy + +- accept mono or stereo only in Phase 1 if that keeps implementation smaller +- mono input is duplicated to stereo +- stereo input is mapped directly L/R +- channels > 2 are rejected initially unless a simple, explicit downmix policy is added +- normalize to the existing `audio.Sample` range `[-1, +1]` +- clipping should be explicit and measured, not silent and invisible + +## Why a dedicated conversion layer matters + +Without it, each source adapter will start doing its own ad hoc format mapping. +That is exactly what the new ingest subsystem is supposed to prevent. + +--- + +## Icecast adapter design + +## Scope for the first Icecast implementation + +The first version needs to support a robust operator-visible ingest path **and** establish the decoder boundary correctly. + +It does not need to support every codec/container combination from day one, but it should not assume ffmpeg as the architectural default. + +## Recommended structure + +### Transport/lifecycle layer + +Responsibilities: + +- connect to Icecast URL over HTTP +- validate response +- track connection state +- reconnect with backoff +- observe stalls / EOF / disconnects +- surface metadata and errors + +Implementation guidance: + +- prefer a Go library or a thin wrapper around the standard Go HTTP client for Icecast transport/session handling +- do not hand-roll unnecessary low-level protocol machinery when existing libraries or the standard client already cover it well +- keep transport/session concerns isolated from decoder logic and ingest runtime logic + +### Decode layer + +Preferred initial option: + +- use native Go decoders for the first targeted formats where mature libraries exist +- decode compressed stream data into PCM chunks behind a decoder interface +- prioritize MP3 first and Ogg/Vorbis second because they are likely to give the best early return for Icecast support +- evaluate AAC/ADTS next once the decoder boundary and streaming behavior are stable + +Fallback option: + +- keep an ffmpeg-backed decoder implementation available only as fallback/compatibility path + +This keeps the first release practical while preserving architecture. + +The key is to avoid letting “ffmpeg exists” collapse the whole ingest abstraction. + +Meaning: + +- Icecast adapter uses a transport/session client layer plus a decoder interface +- transport/session handling should preferably come from a Go library or a thin wrapper around the standard HTTP client +- decoder choice can be native Go or fallback ffmpeg +- Icecast remains an adapter in `internal/ingest/adapters/icecast` +- runtime still sees a normal source + +## Expected Icecast states + +At minimum: + +- `idle` +- `connecting` +- `buffering` +- `running` +- `stalled` +- `reconnecting` +- `failed` +- `stopped` + +These should be visible via runtime stats and eventually UI. + +--- + +## stdin PCM adapter + +Purpose: + +- preserve current CLI-based piping workflows +- move direct ingest logic out of `cmd/fmrtx/main.go` + +Responsibilities: + +- read S16LE stereo PCM from stdin +- emit `PCMChunk`s or equivalent normalized blocks +- expose simple source stats + +This adapter should be intentionally boring. + +--- + +## raw HTTP PCM adapter + +Purpose: + +- preserve current `/audio/stream` functionality +- move it behind the shared ingest runtime instead of writing directly to `audio.StreamSource` + +There are two reasonable implementation paths: + +### Option A: keep `/audio/stream` as a push endpoint owned by control server + +- control server accepts request body +- forwards PCM blocks into an ingest-owned writer/sink +- ingest runtime still owns buffering/health + +### Option B: implement an explicit push source abstraction + +- source adapter exposes a writable sink +- control plane writes into that sink + +For Phase 1, Option A is probably the fastest path. + +But the important part is: + +- control server should no longer push directly into TX buffer +- it should push into the ingest subsystem + +--- + +## Runtime stats model + +Add a top-level ingest section to `/runtime`. + +Proposed shape: + +```json +{ + "ingest": { + "active": { + "id": "icecast-main", + "kind": "icecast", + "state": "running", + "sampleRateHz": 44100, + "channels": 2, + "bufferedSeconds": 1.4, + "reconnects": 1, + "lastError": "" + }, + "runtime": { + "state": "running", + "prebuffering": false, + "lastChunkAt": "...", + "droppedFrames": 0, + "convertErrors": 0, + "writeBlocked": false + } + } +} +``` + +This should sit alongside: + +- driver stats +- engine stats +- audio stream stats +- control audit stats + +Initially, `audioStream` may remain exposed for debugging, but `ingest` should become the operator-facing abstraction. + +--- + +## Config shape evolution + +Do not overload existing `audio.*` forever. + +The current `audio` config primarily models file/tone/test input assumptions. + +Introduce a new config subtree for ingest. + +## Proposed shape + +```json +{ + "ingest": { + "kind": "icecast", + "prebufferMs": 1500, + "stallTimeoutMs": 3000, + "reconnect": { + "enabled": true, + "initialBackoffMs": 1000, + "maxBackoffMs": 15000 + }, + "stdin": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "httpRaw": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "icecast": { + "url": "http://...", + "decoder": "ffmpeg" + } + } +} +``` + +Notes: + +- keep current flags working initially for backward compatibility +- map them internally into the new ingest config +- do not force config migration immediately + +--- + +## CLI evolution + +Current flags: + +- `--audio-stdin` +- `--audio-rate` +- `--audio-http` + +These can stay temporarily, but should become compatibility shims. + +Possible future direction: + +- `--ingest stdin` +- `--ingest http-raw` +- `--ingest icecast` +- `--icecast-url ...` + +The exact CLI can wait, but internal structure should already assume a source factory. + +--- + +## File-by-file implementation plan + +## 1. Add new ingest package skeleton + +Create: + +- `internal/ingest/types.go` +- `internal/ingest/source.go` +- `internal/ingest/runtime.go` +- `internal/ingest/convert.go` +- `internal/ingest/stats.go` +- `internal/ingest/factory.go` + +### Acceptance + +- package compiles +- no behavior change yet + +--- + +## 2. Implement stdin adapter + +Create: + +- `internal/ingest/adapters/stdinpcm/source.go` + +Responsibilities: + +- read stdin PCM +- emit normalized chunks +- report basic stats + +### Acceptance + +- reproduces current `--audio-stdin` behavior through ingest runtime +- TX still works unchanged downstream + +--- + +## 3. Implement shared ingest runtime with `audio.StreamSource` sink + +Runtime should: + +- own source start/stop +- convert PCM chunks to `audio.Frame`s +- write into `audio.StreamSource` +- track runtime state and counters + +### Acceptance + +- stdin path works end-to-end +- engine remains unchanged except wiring +- `/runtime` can expose ingest stats + +--- + +## 4. Rewire `cmd/fmrtx/main.go` + +Replace direct source-specific logic with: + +- source selection +- ingest runtime creation +- runtime start/stop +- existing engine wiring + +### Important + +Remove direct writes like: + +- stdin goroutine writing directly into `audio.StreamSource` +- HTTP handler writing directly into `audio.StreamSource` + +They should now pass through ingest runtime abstractions. + +### Acceptance + +- codepath is cleaner +- source-family logic no longer lives in main + +--- + +## 5. Rework raw HTTP ingest to target ingest runtime + +Modify control layer so `/audio/stream` targets ingest subsystem rather than TX ring directly. + +Likely affected file: + +- `internal/control/control.go` + +### Acceptance + +- `/audio/stream` still works +- stats reflect ingest runtime, not just raw ring buffer + +--- + +## 6. Implement decoder layer and Icecast adapter + +Create: + +- `internal/ingest/decoder/decoder.go` +- `internal/ingest/decoder/mp3/decoder.go` +- `internal/ingest/decoder/aac/decoder.go` +- `internal/ingest/decoder/oggvorbis/decoder.go` +- optional fallback: `internal/ingest/decoder/fallback/ffmpeg.go` +- `internal/ingest/adapters/icecast/source.go` +- `internal/ingest/adapters/icecast/reconnect.go` + +### Responsibilities + +- decoder interface turns compressed audio into PCM chunks +- native decoder implementations cover the initial target formats where stable libraries exist +- Icecast adapter handles HTTP connect/reconnect/lifecycle +- Icecast transport/session handling should use a Go library or a thin wrapper around the standard HTTP client where appropriate +- Icecast adapter selects and drives a decoder +- emit PCM chunks +- expose state transitions and errors + +### Acceptance + +- long-running Icecast ingest works +- native decoding is used for the initial supported formats +- disconnect/reconnect is observable and recovers automatically +- fallback path is explicit, not architectural default +- TX path remains stable + +--- + +## 7. Add ingest stats to control API + +Likely affected files: + +- `internal/control/control.go` +- possibly UI if runtime page surfaces ingest info + +### Acceptance + +- `/runtime` shows ingest state +- operator can tell whether source is connecting/running/stalled/reconnecting + +--- + +## 8. Introduce ingest config structure + +Likely affected file: + +- `internal/config/config.go` + +### Strategy + +- add new config subtree without breaking old flags immediately +- map legacy flag combinations into new config internally + +### Acceptance + +- existing flows still work +- new ingest configs can select Icecast cleanly + +--- + +## Testing plan + +## Unit tests + +### `internal/ingest/convert.go` + +Test: + +- mono to stereo duplication +- stereo pass-through +- unsupported channel counts +- clipping/normalization behavior +- chunk boundary correctness + +### stdin adapter + +Test: + +- reads PCM correctly +- emits expected sample counts +- EOF handling + +### ingest runtime + +Test: + +- source start/stop lifecycle +- writes converted frames into sink +- prebuffer behavior +- stall detection +- source error propagation + +### Icecast adapter + +Use test HTTP server where possible. + +Test: + +- connect success +- reconnect after disconnect +- state transitions +- decoder failure handling +- backoff behavior + +--- + +## Integration tests + +### TX path with ingest runtime + +Test: + +- ingest runtime feeding `audio.StreamSource` +- engine consumes without regression +- runtime stats remain coherent + +### `/audio/stream` + +Test: + +- POST still works +- control path now targets ingest layer + +### Icecast smoke test + +Even if partly gated or environment-specific, define a repeatable smoke path. + +--- + +## Operational telemetry requirements + +At minimum, operators should be able to answer these questions: + +- what source is active? +- what family is it? +- is it connected? +- how much audio is buffered? +- when did we last receive audio? +- are we reconnecting? +- what was the last ingest error? +- are stalls/discontinuities happening? + +If those are not visible, ingest debugging will be painful. + +--- + +## Risks and mitigations + +## Risk 1: pushing too much complexity into Phase 1 + +Mitigation: + +- keep one active source only +- preserve `audio.StreamSource` +- avoid failover until the single-source path is stable + +## Risk 2: decode strategy pollutes architecture + +Mitigation: + +- isolate codec logic behind a decoder interface +- prefer native Go decoders for the initial supported formats +- if ffmpeg is retained, keep it in an explicit fallback decoder package +- do not let decode mechanism define runtime abstractions + +## Risk 3: duplicated buffering causing latency confusion + +Mitigation: + +- document each buffering layer clearly +- expose ingest buffered seconds separately from TX ring stats +- keep prebuffer policy explicit + +## Risk 4: unclear ownership of resampling + +Mitigation: + +- keep transport/family decode at native source rate +- keep final TX-facing adaptation centralized near current `StreamResampler` +- do not add ad hoc resamplers in every adapter unless protocol-specific needs require it + +## Risk 5: channel/format sprawl too early + +Mitigation: + +- define a strict Phase 1 acceptance matrix +- only support the combinations we actually test + +--- + +## Recommended Phase 1 acceptance matrix + +### stdin PCM + +- format: S16LE +- channels: 2 +- sample rates: 44100, 48000 + +### raw HTTP PCM + +- format: S16LE +- channels: 2 +- sample rates: 44100, 48000 + +### Icecast + +- one known-good stream path +- reconnect behavior verified +- native decoding works for at least MP3 in Phase 1 +- ideally native decoding also works for Ogg/Vorbis in Phase 1 +- AAC/ADTS can enter Phase 1 only if the chosen decoder and stream behavior are solid enough +- decoded output normalized into stereo frames + +Optional but useful: + +- mono handling for at least one ingest path + +--- + +## Suggested implementation order + +1. add ingest package skeleton +2. implement conversion helpers +3. implement stdin adapter +4. implement ingest runtime writing into `audio.StreamSource` +5. rewire `cmd/fmrtx/main.go` to use runtime for stdin +6. route `/audio/stream` into ingest runtime +7. expose ingest stats in `/runtime` +8. implement decoder layer with native codec support for initial target formats, in this order: + - MP3 + - Ogg/Vorbis + - AAC/ADTS if stable enough +9. implement Icecast adapter with reconnect + decoder selection +10. add ingest config subtree and compatibility mapping +11. polish tests, docs, and operator-facing runtime fields + +This order gives a narrow vertical slice early, then extends it. + +--- + +## Concrete code touch points + +### New files + +- `internal/ingest/types.go` +- `internal/ingest/source.go` +- `internal/ingest/runtime.go` +- `internal/ingest/convert.go` +- `internal/ingest/stats.go` +- `internal/ingest/factory.go` +- `internal/ingest/decoder/decoder.go` +- `internal/ingest/decoder/mp3/decoder.go` +- `internal/ingest/decoder/aac/decoder.go` +- `internal/ingest/decoder/oggvorbis/decoder.go` +- optional fallback: `internal/ingest/decoder/fallback/ffmpeg.go` +- `internal/ingest/adapters/stdinpcm/source.go` +- `internal/ingest/adapters/icecast/source.go` +- `internal/ingest/adapters/icecast/reconnect.go` + +### Existing files likely to change + +- `cmd/fmrtx/main.go` +- `internal/control/control.go` +- `internal/config/config.go` +- possibly `internal/app/engine.go` only for wiring or runtime exposure, not architectural overhaul + +### Existing files that should stay mostly untouched + +- `internal/offline/generator.go` +- most DSP files +- output/backend implementations + +--- + +## Final design stance + +The new ingest subsystem should be treated as a first-class runtime boundary, not as a pile of helper functions. + +The repository already has the correct TX-side seam: + +- external source +- stream buffer +- final resampler +- engine/DSP separation + +So the implementation should respect that and formalize the missing upstream ingest layer. + +The most important practical decisions in this plan are: + +- **Icecast enters in Phase 1** +- **native decoding is a first-class target from the start** +- fallback decoding is allowed only as an explicit compatibility path, provided the architecture stays clean + +That gives us a realistic ingest design early without destabilizing the FM core. diff --git a/docs/audio-ingest-rework.md b/docs/audio-ingest-rework.md new file mode 100644 index 0000000..6e4e6f5 --- /dev/null +++ b/docs/audio-ingest-rework.md @@ -0,0 +1,267 @@ +# Audio Ingest Rework + +## Hinweis zum Stand (2026-04-07) +Dieses Dokument beschreibt das Zielbild. Der aktuelle Ist-Stand in Phase 1 ist: +- shared ingest runtime + unified source factory sind implementiert +- `stdin`, `http-raw`, `icecast` Adapter sind implementiert +- Icecast Decoder-Layer + ffmpeg fallback sind implementiert +- native Decoder `mp3` / `oggvorbis` / `aac` sind noch Platzhalter +- funktionaler Decode-Pfad heute: ffmpeg fallback + +## Ziel +`fm-rds-tx` soll mittelfristig mehrere Audio-Ingest-Pfade sauber unterstützen, ohne den bestehenden `ffmpeg`-Pfad kaputt zu machen. + +Die strategische Richtung ist daher **nicht** „ffmpeg sofort ersetzen“, sondern: + +- bestehenden `ffmpeg`-Pfad als universellen Fallback behalten +- native Ingest-Familien daneben aufbauen +- alle Pfade auf eine gemeinsame interne PCM-/Audio-Source-Abstraktion führen +- neue native Pfade schrittweise produktionsreif machen + +## Leitprinzipien +1. **Kein Big-Bang-Rewrite** – Bestehendes bleibt lauffähig. +2. **Native Pfade zuerst dort, wo sie klaren Mehrwert bringen**. +3. **Go-Libraries bevorzugen** – Decoder/Protocol-Handling einkaufen statt neu erfinden. +4. **Ein gemeinsames Ingest-Modell** – unabhängig von Quelle oder Protokoll. +5. **Control Plane / Runtime / Telemetrie von Decoder-Details trennen**. + +## Zielbild: drei Ingest-Familien + +### 1. FFmpeg Family +Bestehender universeller Adapter. + +**Rolle:** +- Fallback +- Legacy-Kompatibilität +- exotische oder seltene Formate +- schneller pragmatischer Pfad für Quellen, die nativ noch nicht unterstützt werden + +**Wichtig:** +- bleibt vorerst erhalten +- wird nicht „rausoptimiert“, sondern architektonisch nachrangig +- sollte in der Runtime als eigener Ingest-Typ sichtbar sein + +### 2. AoIP Family +Für professionelle / broadcast-nahe Audioquellen. + +**Ziel-Protokolle / Modi:** +- RTP multicast +- AES67-lite +- SDP +- SAP +- später: NMOS IS-04 / IS-05 +- später: SRT framed PCM + +**Basis:** +- `aoiprxkit` + +**Rolle:** +- deterministische LAN-Audiozuführung +- Broadcast-/AoIP-Umgebungen +- spätere professionelle Discovery/Activation + +### 3. Streaming Family +Für klassische Internet-/HTTP-/Radio-Streamingquellen. + +**Ziel-Protokolle / Modi:** +- HTTP audio streams +- Icecast / Shoutcast +- ICY metadata +- MP3 +- AAC / HE-AAC (je nach verfügbarer Lib) +- später ggf. Opus + +**Rolle:** +- Webradio / Online-Streams +- Metadatenübernahme +- native Alternative zu `ffmpeg` für die häufigsten Streaming-Fälle + +**Wichtig:** +Diese Familie sollte **nicht** in `aoiprxkit` gepresst werden. AoIP und Streaming sind konzeptionell verschieden genug, dass getrennte Package-Bereiche sinnvoll sind. + +## Gemeinsame interne Abstraktion +Alle Ingest-Familien sollen auf dieselbe interne PCM-Einspeisung münden. + +### Ziel +Unabhängig davon, ob Samples von: +- `ffmpeg` +- RTP/AES67 +- Icecast/MP3 +- SRT framed PCM + +kommen, soll der Rest der Sende-/RDS-/Runtime-Logik immer dieselbe Audioquelle sehen. + +### Grobe Zielverantwortung +Eine Quelle soll idealerweise liefern können: +- PCM-Samples +- Sample-Rate +- Kanalzahl +- Source-Label / Source-Type +- Laufzeitstatus / Health +- Basisstatistiken +- optional Metadaten (z. B. ICY title) + +### Wichtige Designregel +**Decoder/Protocol-Layer** und **Sender-Runtime** nicht vermischen. + +Das Ingest-System soll: +- Audio empfangen / decodieren / normieren +- Health / Stats liefern +- Audio in die bestehende Audio-Pipeline schieben + +Die Sender-Runtime soll: +- Quellen starten/stoppen +- aktive Quelle verwalten +- Fehler/Fallback/Status darstellen +- UI/Control-Plane bedienen + +## Einordnung von `aoiprxkit` + +## Was `aoiprxkit` heute schon gut abdeckt +- RTP multicast RX +- L24-Decoding +- Jitter/Reorder +- statische SDP-Auswertung +- SAP-Listener +- Stream-Finder per SDP `s=` Name +- Basis-Stats +- Live-Metering +- NMOS-/SRT-Grundgerüst + +## Was `aoiprxkit` heute noch nicht vollständig als Produkt ist +- keine voll integrierte `fm-rds-tx`-Runtime-Anbindung +- SRT-Pfad eher Scaffold als fertig produktionsreif +- NMOS eher vorbereitend als vollständig integriert +- noch kein gemeinsames Source-Management mit anderen Ingest-Familien + +## Konsequenz +`aoiprxkit` ist **integrationswürdig**, aber aktuell noch eher ein Modul/Baukasten als direktes Hauptsystem. + +## Empfohlene Package-/Modul-Richtung in `fm-rds-tx` +Dies ist ein Zielbild, kein harter Sofort-Umbau. + +### Kandidaten +- `internal/audioingest` + - gemeinsame Interfaces / gemeinsame Typen / gemeinsame Runtime-Adapter +- `internal/audioingest/ffmpeg` + - bestehender ffmpeg-basierter Pfad +- `internal/audioingest/aoip` + - Adapter zwischen `aoiprxkit` und `fm-rds-tx` +- `internal/audioingest/streaming` + - HTTP/Icecast/Shoutcast/ICY + Decoder-Libs + +Optional später: +- `internal/audioingest/shared` + - Resampling, channel mapping, sample normalization, metadata structs + +## Konfigurationszielbild +Die Runtime sollte einen expliziten Ingest-Typ kennen. + +Beispielhaft: + +```yaml +input: + kind: ffmpeg | aoip-rtp | aoip-sap | aoip-srt | stream-http +``` + +Später können pro Familie Unterstrukturen folgen. + +Beispielhaft: + +```yaml +input: + kind: aoip-rtp + aoip: + multicastGroup: 239.69.0.1 + port: 5004 + payloadType: 97 + sampleRateHz: 48000 + channels: 2 +``` + +oder + +```yaml +input: + kind: stream-http + streaming: + url: https://example.org/live.mp3 + icyMeta: true +``` + +## Runtime-Zielbild +Die Runtime sollte Quellen einheitlich behandeln können: +- initialisieren +- starten +- stoppen +- Status abfragen +- Health/Stats lesen +- Audio in denselben bestehenden Ringbuffer / Audio-Input-Pfad drücken + +## Telemetrie / UI +Die Control Plane sollte mittelfristig ingest-bezogen sichtbar machen: +- aktiver Ingest-Typ +- Source-Label +- Transport / Codec / Sample-Rate / Channels +- Fehlerzustand +- Puffer-/Jitter-/Underrun-relevante Daten +- optional Metadata (z. B. StreamTitle) + +Wichtig ist hier eine Trennung zwischen: +- **Audio ingest health** +- **TX/runtime health** + +## Empfohlene Umsetzungsreihenfolge + +### Phase 1 – Architektur sauberziehen +- gemeinsames Ingest-Zielbild festziehen +- bestehende Audio-Input-Andockpunkte in `fm-rds-tx` dokumentieren +- entscheiden, welche internen Interfaces nötig sind + +### Phase 2 – AoIP MVP +- `aoiprxkit` nicht blind verschieben, sondern zuerst als Adapter anbinden +- erster nativer Ingest-Modus: statischer RTP/AES67-lite Pfad +- PCM-Frames in bestehende Audio-Pipeline einspeisen +- Runtime-/Health-/Status sichtbar machen + +### Phase 3 – SDP / SAP Discovery +- statische SDP-Unterstützung +- optional SAP Listener + Session-Auswahl +- Discovery klar von Audio-Transport trennen + +### Phase 4 – Streaming MVP +- neuer nativer HTTP/Icecast/Shoutcast-Pfad +- bewährte Go-Libs für Decoder und ICY nutzen +- erstes Ziel: häufige Webradio-Fälle ohne `ffmpeg` + +### Phase 5 – Vereinheitlichung / Telemetrie +- gemeinsame Ingest-Stats +- gemeinsame Statusmodelle +- UI/Control-Plane-Integration +- Quellwechsel / Fehlermeldungen / Health States + +### Phase 6 – Erweiterte Pfade +- SRT sauber produktionsfähig machen +- NMOS weiter integrieren +- später ggf. Opus / weitere Streaming-Codecs + +## Was explizit vermieden werden soll +- `ffmpeg` sofort herausreissen +- AoIP und Web-Streaming in denselben unscharfen Package-Topf werfen +- Decoder / Demux / Protocol-Layer unnötig selbst neu bauen +- Discovery-Logik eng mit der PCM-Pipeline verheiraten +- UI bauen, bevor Runtime-Modelle sauber stehen + +## Erste konkrete Bauschritte ab jetzt +1. bestehenden Audio-Input-Pfad in `fm-rds-tx` analysieren +2. kleinste gemeinsame Ingest-Abstraktion definieren +3. `aoiprxkit`-RTP als ersten nativen Adapter integrieren +4. danach Streaming-Familie planen und anbinden + +## Kurzfazit +`ffmpeg` bleibt vorerst als nützlicher Universalpfad erhalten. +Die Zukunft liegt aber in zwei nativen Familien: +- **AoIP** für professionelle/broadcast-nahe Zuführung +- **Streaming** für HTTP/Icecast/Shoutcast/ICY + Standardcodecs + +Beide sollen sauber über eine gemeinsame interne Audio-Ingest-Schicht in `fm-rds-tx` zusammenlaufen. diff --git a/docs/config.sample.json b/docs/config.sample.json index 1bb4e71..700304d 100644 --- a/docs/config.sample.json +++ b/docs/config.sample.json @@ -34,5 +34,32 @@ }, "control": { "listenAddress": "127.0.0.1:8088" + }, + "runtime": { + "frameQueueCapacity": 3 + }, + "ingest": { + "kind": "none", + "prebufferMs": 1500, + "stallTimeoutMs": 3000, + "reconnect": { + "enabled": true, + "initialBackoffMs": 1000, + "maxBackoffMs": 15000 + }, + "stdin": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "httpRaw": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "icecast": { + "url": "", + "decoder": "auto" + } } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7236eff..031fbcb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -160,6 +160,14 @@ func TestValidateRejectsUnsupportedIcecastDecoder(t *testing.T) { } } +func TestValidateAcceptsIcecastDecoderFallbackAlias(t *testing.T) { + cfg := Default() + cfg.Ingest.Icecast.Decoder = "fallback" + if err := cfg.Validate(); err != nil { + t.Fatalf("expected fallback alias to be accepted: %v", err) + } +} + func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { cfg := Default() cfg.Ingest.Reconnect.Enabled = true diff --git a/internal/control/control.go b/internal/control/control.go index 1b93a05..006c726 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -336,8 +336,8 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } -// handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes -// it into the live audio ring buffer. Use with: +// handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes +// it into the configured ingest http-raw source. Use with: // // curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 93c01f0..0106eff 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -241,6 +241,8 @@ func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, met } return native.DecodeStream(ctx, stream, meta, s.emitChunk) case "auto": + // Phase-1 policy: try native decoder first, then fall back to ffmpeg + // only when native selection/decode reports "unsupported". native, err := s.decReg.SelectByContentType(meta.ContentType) if err == nil { if err := native.DecodeStream(ctx, stream, meta, s.emitChunk); err == nil { diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 3786d90..ce7798a 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -105,3 +105,32 @@ func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) { t.Fatalf("fallback called %d times", fallback.called) } } + +func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) { + fallback := &testDecoder{name: "ffmpeg"} + reg := decoder.NewRegistry() + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "application/octet-stream", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if fallback.called != 1 { + t.Fatalf("fallback called %d times", fallback.called) + } +} + +func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { + src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback")) + if got := src.Descriptor().Codec; got != "ffmpeg" { + t.Fatalf("codec=%s want ffmpeg", got) + } +} From 70dd4ab8b8a5010383daeb0b666b42f57f7b7b2b Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 14:17:54 +0200 Subject: [PATCH 09/40] ingest: add native mp3 decoder --- go.mod | 2 + go.sum | 4 + internal/go.mod | 2 + internal/go.sum | 4 + internal/ingest/decoder/mp3/decoder.go | 74 +++++++++++++++++- internal/ingest/decoder/mp3/decoder_test.go | 60 ++++++++++++++ .../decoder/mp3/testdata/tone_44k_stereo.mp3 | Bin 0 -> 3386 bytes 7 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 internal/go.sum create mode 100644 internal/ingest/decoder/mp3/decoder_test.go create mode 100644 internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3 diff --git a/go.mod b/go.mod index 57c4f25..68ef787 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,6 @@ go 1.22 require github.com/jan/fm-rds-tx/internal v0.0.0 +require github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + replace github.com/jan/fm-rds-tx/internal => ./internal diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa80656 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/go.mod b/internal/go.mod index 003cad7..8357e61 100644 --- a/internal/go.mod +++ b/internal/go.mod @@ -1,3 +1,5 @@ module github.com/jan/fm-rds-tx/internal go 1.21 + +require github.com/hajimehoshi/go-mp3 v0.3.4 diff --git a/internal/go.sum b/internal/go.sum new file mode 100644 index 0000000..fa80656 --- /dev/null +++ b/internal/go.sum @@ -0,0 +1,4 @@ +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/ingest/decoder/mp3/decoder.go b/internal/ingest/decoder/mp3/decoder.go index 93e5c79..2c7d46e 100644 --- a/internal/ingest/decoder/mp3/decoder.go +++ b/internal/ingest/decoder/mp3/decoder.go @@ -2,9 +2,12 @@ package mp3 import ( "context" + "encoding/binary" "fmt" "io" + "time" + gomp3 "github.com/hajimehoshi/go-mp3" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" ) @@ -15,6 +18,73 @@ func New() *Decoder { return &Decoder{} } func (d *Decoder) Name() string { return "mp3-native" } -func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { - return fmt.Errorf("%w: mp3 native decoder not wired yet", decoder.ErrUnsupported) +func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error { + if r == nil { + return fmt.Errorf("%w: mp3 decoder stream reader is nil", decoder.ErrUnsupported) + } + if emit == nil { + return fmt.Errorf("%w: mp3 decoder emit callback is nil", decoder.ErrUnsupported) + } + + dec, err := gomp3.NewDecoder(r) + if err != nil { + return fmt.Errorf("%w: mp3 decoder init: %v", decoder.ErrUnsupported, err) + } + + const channels = 2 // go-mp3 always decodes to stereo s16le + sampleRate := dec.SampleRate() + if sampleRate <= 0 { + if meta.SampleRateHz > 0 { + sampleRate = meta.SampleRateHz + } else { + sampleRate = 44100 + } + } + + const chunkFrames = 1024 + const frameBytes = channels * 2 + buf := make([]byte, chunkFrames*frameBytes) + seq := uint64(0) + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + n, readErr := io.ReadAtLeast(dec, buf, frameBytes) + if readErr != nil { + if readErr == io.EOF || readErr == io.ErrUnexpectedEOF { + if n > 0 { + if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil { + return err + } + } + return nil + } + return fmt.Errorf("mp3 decoder read pcm: %w", readErr) + } + + if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil { + return err + } + seq++ + } +} + +func emitChunk(data []byte, seq uint64, sampleRate int, sourceID string, emit func(ingest.PCMChunk) error) error { + samples := make([]int32, 0, len(data)/2) + for i := 0; i+1 < len(data); i += 2 { + v := int16(binary.LittleEndian.Uint16(data[i : i+2])) + samples = append(samples, int32(v)<<16) + } + return emit(ingest.PCMChunk{ + Samples: samples, + Channels: 2, + SampleRateHz: sampleRate, + Sequence: seq, + Timestamp: time.Now(), + SourceID: sourceID, + }) } diff --git a/internal/ingest/decoder/mp3/decoder_test.go b/internal/ingest/decoder/mp3/decoder_test.go new file mode 100644 index 0000000..bdc752b --- /dev/null +++ b/internal/ingest/decoder/mp3/decoder_test.go @@ -0,0 +1,60 @@ +package mp3 + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +func TestDecodeStream(t *testing.T) { + tonePath := filepath.Join("testdata", "tone_44k_stereo.mp3") + data, err := os.ReadFile(tonePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + var chunks []ingest.PCMChunk + d := New() + err = d.DecodeStream(context.Background(), bytes.NewReader(data), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "mp3-test", + }, func(c ingest.PCMChunk) error { + chunks = append(chunks, c) + return nil + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(chunks) == 0 { + t.Fatal("expected chunks") + } + if chunks[0].Channels != 2 { + t.Fatalf("channels=%d want 2", chunks[0].Channels) + } + if chunks[0].SampleRateHz != 44100 { + t.Fatalf("sampleRate=%d want 44100", chunks[0].SampleRateHz) + } + if len(chunks[0].Samples) == 0 { + t.Fatal("expected samples in first chunk") + } +} + +func TestDecodeStreamNilReader(t *testing.T) { + err := New().DecodeStream(context.Background(), nil, decoder.StreamMeta{}, func(ingest.PCMChunk) error { return nil }) + if !errors.Is(err, decoder.ErrUnsupported) { + t.Fatalf("expected unsupported, got %v", err) + } +} + +func TestDecodeStreamNilEmit(t *testing.T) { + err := New().DecodeStream(context.Background(), bytes.NewReader([]byte("not-mp3")), decoder.StreamMeta{}, nil) + if !errors.Is(err, decoder.ErrUnsupported) { + t.Fatalf("expected unsupported, got %v", err) + } +} diff --git a/internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3 b/internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8ad9de3483ce8f368af5ceb3110ea872db92a7d3 GIT binary patch literal 3386 zcmeH}dr%YC9)Qp8hIaz;3PA;fgfyi{mPjd3z$k)(0TBcOqHX{~K%NRp3nd8zM4<&l zG(uZQK`NktBBIjpP(cu|0`kya46j(2dV&f~Xd&d%BM z?Qi$EZCr~3i>%)6@Xty=HDa;ZWywF^S5;wrKt5QEPJEl@1lvK?<>tX0UM1%-`RkB$oC&AXfOdIv?B(>H1=-W(mfQB_j_ucs zW7w7Hl`)%NosiQ>Cp^6A6tma_zI?d;B+%+w)06=%15}8K=OVRuE?k16LQiz5s$Nc1 zq9BM%>`X#YTgaMI!|AD1bD+i!ty> zbXUaIih}FtGpl*P0D2fre zK=2C4y_xw%!L>)G8W+6F#2X5WhU`+Zj2e0)diO2_r(f9Tf4c8drE+jEEiVn+ikNZGD!SeRTQ+V z_n7*ueB>oY5>YBwqBhuSS3gQ5GvrnK%36yXmbJ*}=qwb${Sp_-DTj%x0VnSJ!RI6L zU+1OwZ&%%m+TW?xmg|&C6$cazJ`v3m4?8EG+Dhc)_T}1H7Wpv>mHNusS}LtX-toid zK5%&&5}4bRSHN2D;j(t|qviW=Ap;03giNzw18qpP^)~H{#Vw^TQt($74dvy`WjS6= z1={&3g#gMl!Wm?qAumu`M- z^znk3s!R6&$sR9}F#mGaNWeTOMwkE*Y=yu#m?|j8)S(h=lPHzr>d%Xk5(2R^@avd_ z^33nJVO)E27}e3>9=H7Yvu5e92WD?wOuAww1_ zTFYBQ^YX{5?$$Uk8jRye8CVLVIeHJo+SwS?L1UO*2yRxpP}i59p`Aevw8eaL%Djy% z(%m#_4=C&1@>#bhI$uT>H=NW((m&RVani20xL+LY{Eyfn6BkgE1I}Y60vz@}*oB!x z=9pn>ET*RHhp8u)a1wMV*-F2?6Py? zo9hO7$u7KsAz544je|xbpI3>)s^`~OE}ZK}&h|Z@id$B&(%!>44G36ANw6z3tmJ0&@t71Xgf%QVpS2uLOmZ$8iZ)s zveSr%Yn3WoW;=_{_th|XX z`!4!<<+7a=(v_l*sYfH+cokvRgF}J$=ej@f-&1$~z3n%Bij#PG3zNrQ?22$A%)NP3 zB@2F-O6N~y(fN|-N9^L$x(f+wX86)0e`|;g08mHF7~`l&@t5w&29mTVB(X0_gvVw`Ba0S{=)QBEG1euAvPUB8WN5S}H% z2=sLj1cCQIv9=gb-YRkk69I%tf{zLo4avZ>L0)U!?hT3~-XA%nXWOqn@Cv9Z45bPH z&YMi3?AF|t{Fw%a%~cWmt4M`<0* zH-jU)Pj4;K_sD8rw@WJHM-|3)bYBu)mQSf0~vnSmgS)r}8uM9hmCPDdU9jp8r zvxHuQwcBgs-Idf3=WAGHTGjPR2ZDulz^0~|YwiyI+Me=`TgSIO{98lsm`?9BZPCzk opsIbqJ^E9D;>{qCTL8d!N8WUJp@ Date: Tue, 7 Apr 2026 14:48:00 +0200 Subject: [PATCH 10/40] ingest: add native ogg vorbis decoder --- go.mod | 6 +- go.sum | 4 + internal/go.mod | 7 +- internal/go.sum | 4 + .../ingest/adapters/icecast/source_test.go | 28 ++++++ internal/ingest/decoder/oggvorbis/decoder.go | 87 +++++++++++++++++- .../ingest/decoder/oggvorbis/decoder_test.go | 60 ++++++++++++ .../oggvorbis/testdata/tone_44k_stereo.ogg | Bin 0 -> 4653 bytes 8 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 internal/ingest/decoder/oggvorbis/decoder_test.go create mode 100644 internal/ingest/decoder/oggvorbis/testdata/tone_44k_stereo.ogg diff --git a/go.mod b/go.mod index 68ef787..d553bb4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,10 @@ go 1.22 require github.com/jan/fm-rds-tx/internal v0.0.0 -require github.com/hajimehoshi/go-mp3 v0.3.4 // indirect +require ( + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/jfreymuth/oggvorbis v1.0.5 // indirect + github.com/jfreymuth/vorbis v1.0.2 // indirect +) replace github.com/jan/fm-rds-tx/internal => ./internal diff --git a/go.sum b/go.sum index fa80656..a67c282 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/go.mod b/internal/go.mod index 8357e61..89df427 100644 --- a/internal/go.mod +++ b/internal/go.mod @@ -2,4 +2,9 @@ module github.com/jan/fm-rds-tx/internal go 1.21 -require github.com/hajimehoshi/go-mp3 v0.3.4 +require ( + github.com/hajimehoshi/go-mp3 v0.3.4 + github.com/jfreymuth/oggvorbis v1.0.5 +) + +require github.com/jfreymuth/vorbis v1.0.2 // indirect diff --git a/internal/go.sum b/internal/go.sum index fa80656..a67c282 100644 --- a/internal/go.sum +++ b/internal/go.sum @@ -1,4 +1,8 @@ github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index ce7798a..84b4572 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -128,6 +128,34 @@ func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) { } } +func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) { + ogg := &testDecoder{name: "oggvorbis"} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("oggvorbis", func() decoder.Decoder { return ogg }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/ogg", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ogg.called != 1 { + t.Fatalf("ogg decoder called %d times", ogg.called) + } + if fallback.called != 0 { + t.Fatalf("fallback should not be called, got %d", fallback.called) + } +} + func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback")) if got := src.Descriptor().Codec; got != "ffmpeg" { diff --git a/internal/ingest/decoder/oggvorbis/decoder.go b/internal/ingest/decoder/oggvorbis/decoder.go index 0f7affa..c3de4da 100644 --- a/internal/ingest/decoder/oggvorbis/decoder.go +++ b/internal/ingest/decoder/oggvorbis/decoder.go @@ -4,9 +4,12 @@ import ( "context" "fmt" "io" + "math" + "time" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" + libvorbis "github.com/jfreymuth/oggvorbis" ) type Decoder struct{} @@ -15,6 +18,86 @@ func New() *Decoder { return &Decoder{} } func (d *Decoder) Name() string { return "oggvorbis-native" } -func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { - return fmt.Errorf("%w: ogg/vorbis native decoder not wired yet", decoder.ErrUnsupported) +func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error { + if r == nil { + return fmt.Errorf("%w: ogg/vorbis decoder stream reader is nil", decoder.ErrUnsupported) + } + if emit == nil { + return fmt.Errorf("%w: ogg/vorbis decoder emit callback is nil", decoder.ErrUnsupported) + } + + dec, err := libvorbis.NewReader(r) + if err != nil { + return fmt.Errorf("%w: ogg/vorbis decoder init: %v", decoder.ErrUnsupported, err) + } + + channels := dec.Channels() + if channels <= 0 { + if meta.Channels > 0 { + channels = meta.Channels + } else { + return fmt.Errorf("%w: ogg/vorbis decoder invalid channel count", decoder.ErrUnsupported) + } + } + + sampleRate := dec.SampleRate() + if sampleRate <= 0 { + if meta.SampleRateHz > 0 { + sampleRate = meta.SampleRateHz + } else { + sampleRate = 44100 + } + } + + const chunkFrames = 1024 + buf := make([]float32, chunkFrames*channels) + seq := uint64(0) + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + n, readErr := dec.Read(buf) + if n > 0 { + chunk := ingest.PCMChunk{ + Samples: float32ToPCM32(buf[:n]), + Channels: channels, + SampleRateHz: sampleRate, + Sequence: seq, + Timestamp: time.Now(), + SourceID: meta.SourceID, + } + if err := emit(chunk); err != nil { + return err + } + seq++ + } + + if readErr != nil { + if readErr == io.EOF { + return nil + } + return fmt.Errorf("ogg/vorbis decoder read pcm: %w", readErr) + } + } +} + +func float32ToPCM32(in []float32) []int32 { + out := make([]int32, len(in)) + for i, sample := range in { + if sample > 1 { + sample = 1 + } else if sample < -1 { + sample = -1 + } + if sample == -1 { + out[i] = math.MinInt32 + continue + } + out[i] = int32(sample * math.MaxInt32) + } + return out } diff --git a/internal/ingest/decoder/oggvorbis/decoder_test.go b/internal/ingest/decoder/oggvorbis/decoder_test.go new file mode 100644 index 0000000..f1f5d5a --- /dev/null +++ b/internal/ingest/decoder/oggvorbis/decoder_test.go @@ -0,0 +1,60 @@ +package oggvorbis + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/decoder" +) + +func TestDecodeStream(t *testing.T) { + tonePath := filepath.Join("testdata", "tone_44k_stereo.ogg") + data, err := os.ReadFile(tonePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + var chunks []ingest.PCMChunk + d := New() + err = d.DecodeStream(context.Background(), bytes.NewReader(data), decoder.StreamMeta{ + ContentType: "audio/ogg", + SourceID: "ogg-test", + }, func(c ingest.PCMChunk) error { + chunks = append(chunks, c) + return nil + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(chunks) == 0 { + t.Fatal("expected chunks") + } + if chunks[0].Channels != 2 { + t.Fatalf("channels=%d want 2", chunks[0].Channels) + } + if chunks[0].SampleRateHz != 44100 { + t.Fatalf("sampleRate=%d want 44100", chunks[0].SampleRateHz) + } + if len(chunks[0].Samples) == 0 { + t.Fatal("expected samples in first chunk") + } +} + +func TestDecodeStreamNilReader(t *testing.T) { + err := New().DecodeStream(context.Background(), nil, decoder.StreamMeta{}, func(ingest.PCMChunk) error { return nil }) + if !errors.Is(err, decoder.ErrUnsupported) { + t.Fatalf("expected unsupported, got %v", err) + } +} + +func TestDecodeStreamNilEmit(t *testing.T) { + err := New().DecodeStream(context.Background(), bytes.NewReader([]byte("not-ogg")), decoder.StreamMeta{}, nil) + if !errors.Is(err, decoder.ErrUnsupported) { + t.Fatalf("expected unsupported, got %v", err) + } +} diff --git a/internal/ingest/decoder/oggvorbis/testdata/tone_44k_stereo.ogg b/internal/ingest/decoder/oggvorbis/testdata/tone_44k_stereo.ogg new file mode 100644 index 0000000000000000000000000000000000000000..f4176aea98d0f881c012fbc748a56447c661be44 GIT binary patch literal 4653 zcmeHKeND`}rD#jFDpruX+Sc}HwO#hU1hnoyJ$u~0_UxQ_@7D8L-;}b17$10_Gu@?;F?bh zu8DK5hR`2luZ=%fZaUfxxdDG!>Kp4`4xT5N7tG_~^0VN{d_8?rMrPhZ;H3jEBm`&D zbF$OyY6K_ymnmMnf`EhwF;rB}5{t@5i}{ITeTQk{3mfnHikEp5(L$E3>Sn6S{OfDP zC97CFdduVpS9n`gK+)<%WPdGlVWL43un?#c0rX|#eVBOiB@tu+S45tn9p;6)X}%48 zMAJ$Hi5T>a(zVmX?PkqE0SGhZN5*PMf>D~bgUiwtb@JMMw8Z%=ebFsm*F}x1xWuf9 zn*Yd8yIxe_V;_SdiY(KRL|s(v5)sURBxt8-yG4zDT8@A`OH1cxc%w3eYU)_JI+hFO z*Xn}05W<9Ul+&AOXZ}Vz(?WA4PgOV}L_lPai`3OemfngqFcb!+t=@je%I$8o)exI{ zBto%qZj#NEDK@@{=nzRefzq*QsF0%~q^VVQZ? zQJ|cfvrElOTY!rEq)=A56z@~vrfjdVlXqxUUU;+d^G{5by(fW+Id$h+74}rbCT9Z~ z)+@wz?n?cdP3|AbqA&4C^9#8ndN|5+_R~~}$NEOI>WvG?%<|1LX@DJY2P7o>7gJ=B zldrTA|DDvlv+M&kMN&>vW1T*vn|tJ(?X}?U!6WXkx&?P)ivycGW%0T52NO5m6O5QO zAFW8**nCtbWm}9g7aM+bz+O!9%_xh*EvJ>R4^-v3a2oA!(s9=y6!Rmk&tt~3`U9=U zgBj-uHE1AY{y@A$o}YQyl&|=B`>HF&$q%<*h59HzHPMu*c&^%)4u3fvHoJWx>{C?L zIl(Byh)pgN9Ey~b2-|*(YGo|#XPocn#ysH1crA>hNCx=wKJVnXS90TC&K#JZ9RG5% zd`0d`Q|_v^{GsS?B@#~gh8D!0t0;??g4e8&Fygv z>vIf?gXrvBDcvAc+eyy`ni5KF9 zy7Q-+Nj;9CCZ`_Fh=@Wc&HUoQ^+HVY3=4jb#wwQIu%h!1QmnD^#(B?JrV)|6)~ZZa ze_P0tS8x)%pRr;nwZ~$U+d0QylC*PHds|(fv3^fMWTq>uG^)O5c zq0-?*$l4N3OdUcg3Ns4={fm|72lPZG4v5Es0U^UkQJ9?g=Ub79)ElNAAwWa`VHQ5S z!=m`K&1wkz4k!*GlB9(j?IkfcWSi&e_;@^|6jnDBR%eR`!g~1ry6W|2Ow#jGbVUQp z%E2UZKqdA%4!fqI^Cl|Zd60<24#=%EO;H^HIgzfR$qH7&VknY6zWkCr+YGiKS<1Fr z$9@chfd=4+g8Zep;hnl{Gv57O$Aj=bel*1DOZhhn>$1#X0geq!5(jobGC!HFgUMOP zj~a?|$5Nhv(3KsI@D#^pbx9F@8dB4jz@eS)7ScdT zzaW@WF4F~%RPELWpQ=z@h+_MupA(GK8})crB$M98QzN#;miHKCQs`SQWiz!G1OXOf zKZtlClwd=9f7fl|9FYxf83Euw=zHQ6 zRO2vxMMNmu&y~y1VB+VckgCx`N?weKUHr918QJ-{?eB>qVau1b4B`pZpfCc@sH9Cw zy*;{}bNwwo zfm$kHEDiSQ5lpEy4kmG{RqkST^U1`7u_~i6N!Q#+31PD>gK1a?G&fp~(zTqXPE*oG z5ji=21KV_^2y1II$n?Ra8=Ci8$`d!ZEqX*>6&QRe388fwM-*N(^@BN8lwD3feyyGe zFrn+?bit6sL6XZz*9U{g;T}_)p@&TYBa{7&w(Qq-w~k>*&b45kZ2>i@zcE(K2G7w#A@QY(0(|8@sr#M(G}9h2G7vJh%A~Aj zxp20HTK|deev#tsNNSpi$SzCyJN z761`Xgw*OXgdB-ZFQMbt(G3;0#eoAWSO~&qE1`tLu|j z3*RTq8@+kR(Vyr~7IEEc)I?g9@`Tr>f{v-nd)b_PI@pCQ5Pc7OZZ-1o=sTC1k(Hy| zQt+l)TcR(6;0J|@-h^8zQy3o3S5bZarqgE3oJIGaJ%<4y+J3pFs}YfC&zA7$OeETF z&&12VHhqNOp7H)}vG)-&H(Rtwv^e6(xkT2;b%)Q^bB!t`;acE{dfnq+*PKpYbpQ4T zkuZl3M@Em7oO<~A@Voc84t|Y$54^o^JoDm=Z|Z|Mqn^(<#d903zW(u0ZcJtdD%sXy z8?%FlI(RfK+`(6`<&6esbp2egU4(6oi;LT=9Q|c&bmxg*e|^*UwreaRED&l)M(>nC zRHJtIYva}XTVESr_|wgp4$4oV7w>QZ9fF0x@=QdlThoLAp4SpRFq<;;H?Zk z^mQ%VH`O~x69$%!6~B_|GHHPMb4Ml5X&V9qb!!4A4B(gD$)cQJ^Mjw3KI3+BH!;m$bdguJ)- zAOCEB^2_~t>G-A&A7de^c0f}zCyr<$!borzv6tbSb*Scgmk{A5a0ur7+!0+7=6>|a zZ}&#ts$bLYynBlF@3<53QiPBOcA|}r6)$+~dr9bxj$ggE;fy<~A=H!+P7|uv&-iXB zas2&+Q`35aXtxxnAA5c=&Mg1ogvxb_^xd6=NBb6$JLsLE8RpcleACzYe0e0}mOe)@ zkaOP5JTucb+iO>3<(vaKU(U&1ERjrKn`AvPy#GlhbNKVFb=FrE1R=RnlDp-th1d3e z_Bbj(;KRY;tH;*rx8AP|yEe4;y@B7xWIFiNc!s(eUX}dp7 bRh2HiTlXrlBH9p5&neY7gvDZ^N Date: Tue, 7 Apr 2026 16:08:56 +0200 Subject: [PATCH 11/40] ingest: harden icecast decoder fallback handling --- internal/ingest/adapters/icecast/source.go | 24 ++- .../ingest/adapters/icecast/source_test.go | 142 ++++++++++++++++++ internal/ingest/decoder/decoder_test.go | 12 ++ 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 0106eff..3601d80 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -1,6 +1,7 @@ package icecast import ( + "bytes" "context" "errors" "fmt" @@ -245,11 +246,15 @@ func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, met // only when native selection/decode reports "unsupported". native, err := s.decReg.SelectByContentType(meta.ContentType) if err == nil { - if err := native.DecodeStream(ctx, stream, meta, s.emitChunk); err == nil { + captured := &capturingReader{r: stream} + if err := native.DecodeStream(ctx, captured, meta, s.emitChunk); err == nil { return nil } else if !errors.Is(err, decoder.ErrUnsupported) { return err } + // Native decode can consume stream bytes before returning "unsupported". + // Reconstruct a full reader for fallback: consumed prefix + remaining stream. + stream = io.MultiReader(bytes.NewReader(captured.Bytes()), stream) } else if !errors.Is(err, decoder.ErrUnsupported) { return fmt.Errorf("icecast decoder select: %w", err) } @@ -259,6 +264,23 @@ func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, met } } +type capturingReader struct { + r io.Reader + buf bytes.Buffer +} + +func (r *capturingReader) Read(p []byte) (int, error) { + n, err := r.r.Read(p) + if n > 0 { + _, _ = r.buf.Write(p[:n]) + } + return n, err +} + +func (r *capturingReader) Bytes() []byte { + return r.buf.Bytes() +} + func (s *Source) decodeNamed(ctx context.Context, name string, stream io.Reader, meta decoder.StreamMeta) error { dec, err := s.decReg.Create(name) if err != nil { diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 84b4572..8171ebe 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -24,6 +24,38 @@ func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.Str return d.err } +type consumingUnsupportedDecoder struct { + n int + called int +} + +func (d *consumingUnsupportedDecoder) Name() string { return "native-consuming-unsupported" } + +func (d *consumingUnsupportedDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + d.called++ + buf := make([]byte, d.n) + _, _ = io.ReadFull(r, buf) + return decoder.ErrUnsupported +} + +type captureStreamDecoder struct { + name string + called int + payload []byte +} + +func (d *captureStreamDecoder) Name() string { return d.name } + +func (d *captureStreamDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + d.called++ + data, err := io.ReadAll(r) + if err != nil { + return err + } + d.payload = data + return nil +} + func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) { native := &testDecoder{name: "native", err: decoder.ErrUnsupported} fallback := &testDecoder{name: "ffmpeg"} @@ -156,6 +188,116 @@ func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) { } } +func TestDecodeWithPreferenceAutoUsesMP3NativeForMPEGContentType(t *testing.T) { + mp3Native := &testDecoder{name: "mp3"} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return mp3Native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/mpeg; charset=utf-8", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if mp3Native.called != 1 { + t.Fatalf("mp3 native decoder called %d times", mp3Native.called) + } + if fallback.called != 0 { + t.Fatalf("fallback should not be called, got %d", fallback.called) + } +} + +func TestDecodeWithPreferenceAutoNativeErrorDoesNotFallback(t *testing.T) { + nativeErr := errors.New("native hard failure") + mp3Native := &testDecoder{name: "mp3", err: nativeErr} + fallback := &testDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return mp3Native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "ice-test", + }) + if !errors.Is(err, nativeErr) { + t.Fatalf("expected native error, got %v", err) + } + if fallback.called != 0 { + t.Fatalf("fallback should not be called on native hard error, got %d", fallback.called) + } +} + +func TestDecodeWithPreferenceAutoFallbackSeesFullStreamAfterNativeConsumesPrefix(t *testing.T) { + const consumed = 4 + input := []byte("0123456789abcdef") + + native := &consumingUnsupportedDecoder{n: consumed} + fallback := &captureStreamDecoder{name: "ffmpeg"} + + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return native }) + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(input), decoder.StreamMeta{ + ContentType: "audio/mpeg", + SourceID: "ice-test", + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if native.called != 1 { + t.Fatalf("native called %d times", native.called) + } + if fallback.called != 1 { + t.Fatalf("fallback called %d times", fallback.called) + } + if !bytes.Equal(fallback.payload, input) { + t.Fatalf("fallback payload mismatch: got %q want %q", string(fallback.payload), string(input)) + } +} + +func TestDecodeWithPreferenceNativeUnsupportedContentTypeFailsWithoutFallback(t *testing.T) { + fallback := &testDecoder{name: "ffmpeg"} + reg := decoder.NewRegistry() + reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) + + src := New("ice-test", "http://example", nil, ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("native"), + ) + + err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ + ContentType: "application/octet-stream", + SourceID: "ice-test", + }) + if err == nil { + t.Fatal("expected native-mode select error for unsupported content-type") + } + if fallback.called != 0 { + t.Fatalf("fallback should not be called in native mode, got %d", fallback.called) + } +} + func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback")) if got := src.Descriptor().Codec; got != "ffmpeg" { diff --git a/internal/ingest/decoder/decoder_test.go b/internal/ingest/decoder/decoder_test.go index b304d79..7e724bc 100644 --- a/internal/ingest/decoder/decoder_test.go +++ b/internal/ingest/decoder/decoder_test.go @@ -2,6 +2,7 @@ package decoder import ( "context" + "errors" "io" "testing" @@ -27,8 +28,11 @@ func TestRegistrySelectByContentType(t *testing.T) { want string }{ {"audio/mpeg", "mp3"}, + {"audio/mpeg; charset=utf-8", "mp3"}, {"application/ogg", "ogg"}, + {"audio/ogg;codecs=vorbis", "ogg"}, {"audio/aac", "aac"}, + {"audio/aacp", "aac"}, } for _, tt := range tests { dec, err := r.SelectByContentType(tt.ct) @@ -40,3 +44,11 @@ func TestRegistrySelectByContentType(t *testing.T) { } } } + +func TestRegistrySelectByContentTypeUnsupported(t *testing.T) { + r := NewRegistry() + _, err := r.SelectByContentType("application/octet-stream") + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("expected ErrUnsupported, got %v", err) + } +} From a2e36cab153433cd9a4b3f03b826554b07efd168 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 16:14:53 +0200 Subject: [PATCH 12/40] ingest: share decoder pcm helpers --- internal/ingest/decoder/helpers.go | 58 ++++++++++++++++++++ internal/ingest/decoder/mp3/decoder.go | 31 +++-------- internal/ingest/decoder/oggvorbis/decoder.go | 43 +++------------ 3 files changed, 74 insertions(+), 58 deletions(-) create mode 100644 internal/ingest/decoder/helpers.go diff --git a/internal/ingest/decoder/helpers.go b/internal/ingest/decoder/helpers.go new file mode 100644 index 0000000..4443038 --- /dev/null +++ b/internal/ingest/decoder/helpers.go @@ -0,0 +1,58 @@ +package decoder + +import ( + "encoding/binary" + "math" + "time" + + "github.com/jan/fm-rds-tx/internal/ingest" +) + +const defaultSampleRateHz = 44100 + +func ResolveSampleRate(decodedSampleRateHz int, meta StreamMeta) int { + if decodedSampleRateHz > 0 { + return decodedSampleRateHz + } + if meta.SampleRateHz > 0 { + return meta.SampleRateHz + } + return defaultSampleRateHz +} + +func BuildChunk(samples []int32, channels, sampleRateHz int, seq uint64, sourceID string) ingest.PCMChunk { + return ingest.PCMChunk{ + Samples: samples, + Channels: channels, + SampleRateHz: sampleRateHz, + Sequence: seq, + Timestamp: time.Now(), + SourceID: sourceID, + } +} + +func PCM16LEToPCM32(in []byte) []int32 { + out := make([]int32, 0, len(in)/2) + for i := 0; i+1 < len(in); i += 2 { + v := int16(binary.LittleEndian.Uint16(in[i : i+2])) + out = append(out, int32(v)<<16) + } + return out +} + +func Float32ToPCM32(in []float32) []int32 { + out := make([]int32, len(in)) + for i, sample := range in { + if sample > 1 { + sample = 1 + } else if sample < -1 { + sample = -1 + } + if sample == -1 { + out[i] = math.MinInt32 + continue + } + out[i] = int32(sample * math.MaxInt32) + } + return out +} diff --git a/internal/ingest/decoder/mp3/decoder.go b/internal/ingest/decoder/mp3/decoder.go index 2c7d46e..4d2a71e 100644 --- a/internal/ingest/decoder/mp3/decoder.go +++ b/internal/ingest/decoder/mp3/decoder.go @@ -2,10 +2,8 @@ package mp3 import ( "context" - "encoding/binary" "fmt" "io" - "time" gomp3 "github.com/hajimehoshi/go-mp3" "github.com/jan/fm-rds-tx/internal/ingest" @@ -32,14 +30,7 @@ func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.St } const channels = 2 // go-mp3 always decodes to stereo s16le - sampleRate := dec.SampleRate() - if sampleRate <= 0 { - if meta.SampleRateHz > 0 { - sampleRate = meta.SampleRateHz - } else { - sampleRate = 44100 - } - } + sampleRate := decoder.ResolveSampleRate(dec.SampleRate(), meta) const chunkFrames = 1024 const frameBytes = channels * 2 @@ -74,17 +65,11 @@ func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.St } func emitChunk(data []byte, seq uint64, sampleRate int, sourceID string, emit func(ingest.PCMChunk) error) error { - samples := make([]int32, 0, len(data)/2) - for i := 0; i+1 < len(data); i += 2 { - v := int16(binary.LittleEndian.Uint16(data[i : i+2])) - samples = append(samples, int32(v)<<16) - } - return emit(ingest.PCMChunk{ - Samples: samples, - Channels: 2, - SampleRateHz: sampleRate, - Sequence: seq, - Timestamp: time.Now(), - SourceID: sourceID, - }) + return emit(decoder.BuildChunk( + decoder.PCM16LEToPCM32(data), + 2, + sampleRate, + seq, + sourceID, + )) } diff --git a/internal/ingest/decoder/oggvorbis/decoder.go b/internal/ingest/decoder/oggvorbis/decoder.go index c3de4da..ef1dca0 100644 --- a/internal/ingest/decoder/oggvorbis/decoder.go +++ b/internal/ingest/decoder/oggvorbis/decoder.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "io" - "math" - "time" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" @@ -40,14 +38,7 @@ func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.St } } - sampleRate := dec.SampleRate() - if sampleRate <= 0 { - if meta.SampleRateHz > 0 { - sampleRate = meta.SampleRateHz - } else { - sampleRate = 44100 - } - } + sampleRate := decoder.ResolveSampleRate(dec.SampleRate(), meta) const chunkFrames = 1024 buf := make([]float32, chunkFrames*channels) @@ -62,14 +53,13 @@ func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.St n, readErr := dec.Read(buf) if n > 0 { - chunk := ingest.PCMChunk{ - Samples: float32ToPCM32(buf[:n]), - Channels: channels, - SampleRateHz: sampleRate, - Sequence: seq, - Timestamp: time.Now(), - SourceID: meta.SourceID, - } + chunk := decoder.BuildChunk( + decoder.Float32ToPCM32(buf[:n]), + channels, + sampleRate, + seq, + meta.SourceID, + ) if err := emit(chunk); err != nil { return err } @@ -84,20 +74,3 @@ func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.St } } } - -func float32ToPCM32(in []float32) []int32 { - out := make([]int32, len(in)) - for i, sample := range in { - if sample > 1 { - sample = 1 - } else if sample < -1 { - sample = -1 - } - if sample == -1 { - out[i] = math.MinInt32 - continue - } - out[i] = int32(sample * math.MaxInt32) - } - return out -} From 5354ca54a144e90dcad37681ecd0ff1473ce4d53 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 16:26:14 +0200 Subject: [PATCH 13/40] ingest: harden runtime and source recovery paths --- internal/ingest/adapters/icecast/source.go | 14 +- .../ingest/adapters/icecast/source_test.go | 152 ++++++++++++++++++ internal/ingest/runtime.go | 1 + internal/ingest/runtime_test.go | 66 +++++++- 4 files changed, 230 insertions(+), 3 deletions(-) diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 3601d80..1da52c9 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -46,6 +46,8 @@ type Source struct { lastError atomic.Value // string } +var errStreamEnded = errors.New("icecast stream ended") + type Option func(*Source) func WithDecoderPreference(pref string) Option { @@ -115,6 +117,7 @@ func (s *Source) Start(ctx context.Context) error { } runCtx, cancel := context.WithCancel(ctx) s.cancel = cancel + s.lastError.Store("") s.state.Store("connecting") s.wg.Add(1) go s.loop(runCtx) @@ -156,6 +159,7 @@ func (s *Source) Stats() ingest.SourceStats { func (s *Source) loop(ctx context.Context) { defer s.wg.Done() defer close(s.chunks) + defer close(s.errs) attempt := 0 for { select { @@ -166,7 +170,13 @@ func (s *Source) loop(ctx context.Context) { s.state.Store("connecting") err := s.connectAndRun(ctx) - if err == nil || ctx.Err() != nil { + if ctx.Err() != nil { + return + } + if err == nil { + err = errStreamEnded + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } s.connected.Store(false) @@ -207,7 +217,7 @@ func (s *Source) connectAndRun(ctx context.Context) error { } s.connected.Store(true) s.state.Store("buffering") - + s.lastError.Store("") s.state.Store("running") return s.decodeWithPreference(ctx, resp.Body, decoder.StreamMeta{ ContentType: resp.Header.Get("Content-Type"), diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 8171ebe..568110c 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -5,7 +5,13 @@ import ( "context" "errors" "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/decoder" @@ -304,3 +310,149 @@ func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { t.Fatalf("codec=%s want ffmpeg", got) } } + +type scriptedLoopDecoder struct { + mu sync.Mutex + actions []decodeAction + calls int + totalBytesRead int +} + +type decodeAction struct { + err error + blockUntilStop bool +} + +func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" } + +func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + + d.mu.Lock() + d.calls++ + d.totalBytesRead += len(data) + callIdx := d.calls - 1 + action := decodeAction{} + if callIdx < len(d.actions) { + action = d.actions[callIdx] + } + d.mu.Unlock() + + if action.blockUntilStop { + <-ctx.Done() + return nil + } + return action.err +} + +func (d *scriptedLoopDecoder) callCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.calls +} + +func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) { + var requests atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("test-stream")) + })) + defer srv.Close() + + dec := &scriptedLoopDecoder{ + actions: []decodeAction{ + {}, // first connection ends cleanly (EOS-like) + {blockUntilStop: true}, + }, + } + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return dec }) + reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) + + src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{ + Enabled: true, + InitialBackoffMs: 1, + MaxBackoffMs: 1, + }, WithDecoderRegistry(reg), WithDecoderPreference("auto")) + + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS") + + stats := src.Stats() + if stats.Reconnects < 1 { + t.Fatalf("reconnects=%d want >=1", stats.Reconnects) + } + if got := requests.Load(); got < 2 { + t.Fatalf("requests=%d want >=2", got) + } +} + +func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) { + const boom = "decoder boom" + var requests atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("test-stream")) + })) + defer srv.Close() + + dec := &scriptedLoopDecoder{ + actions: []decodeAction{ + {err: errors.New(boom)}, // first attempt fails + {blockUntilStop: true}, // second attempt recovers and stays running + }, + } + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return dec }) + reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) + + src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{ + Enabled: true, + InitialBackoffMs: 1, + MaxBackoffMs: 1, + }, WithDecoderRegistry(reg), WithDecoderPreference("auto")) + + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + select { + case err := <-src.Errors(): + if err == nil || !strings.Contains(err.Error(), boom) { + t.Fatalf("error=%v want contains %q", err, boom) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for source error reporting") + } + + waitForCondition(t, func() bool { + st := src.Stats() + return dec.callCount() >= 2 && st.LastError == "" + }, "lastError cleared after successful reconnect") + + if got := requests.Load(); got < 2 { + t.Fatalf("requests=%d want >=2", got) + } +} + +func waitForCondition(t *testing.T, cond func() bool, label string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for condition: %s", label) +} diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index df0048c..fa3ad64 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -136,6 +136,7 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { } } r.mu.Lock() + r.stats.State = "running" r.stats.LastChunkAt = time.Now() r.stats.DroppedFrames += dropped r.stats.WriteBlocked = dropped > 0 diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index a3df6e7..6167c20 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -2,6 +2,8 @@ package ingest import ( "context" + "errors" + "sync" "testing" "time" @@ -13,6 +15,7 @@ type fakeSource struct { chunks chan PCMChunk errs chan error stats SourceStats + once sync.Once } func newFakeSource() *fakeSource { @@ -26,7 +29,7 @@ func newFakeSource() *fakeSource { func (s *fakeSource) Descriptor() SourceDescriptor { return s.desc } func (s *fakeSource) Start(context.Context) error { return nil } -func (s *fakeSource) Stop() error { close(s.chunks); return nil } +func (s *fakeSource) Stop() error { s.once.Do(func() { close(s.chunks) }); return nil } func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks } func (s *fakeSource) Errors() <-chan error { return s.errs } func (s *fakeSource) Stats() SourceStats { return s.stats } @@ -54,3 +57,64 @@ func TestRuntimeWritesFramesToStreamSink(t *testing.T) { t.Fatal("expected at least one frame in sink") } } + +func TestRuntimeRecoversToRunningAfterSourceError(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + rt := NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.errs <- errors.New("decode transient failure") + waitForRuntimeState(t, rt, "degraded") + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 44100, + Samples: []int32{500 << 16, -500 << 16}, + } + waitForRuntimeState(t, rt, "running") +} + +func TestRuntimeRecoversToRunningAfterConvertError(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + rt := NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + // Invalid stereo chunk: odd sample count causes conversion error. + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 44100, + Samples: []int32{100 << 16}, + } + waitForRuntimeState(t, rt, "degraded") + + if got := rt.Stats().Runtime.ConvertErrors; got != 1 { + t.Fatalf("convertErrors=%d want 1", got) + } + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 44100, + Samples: []int32{300 << 16, -300 << 16}, + } + waitForRuntimeState(t, rt, "running") +} + +func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { + t.Helper() + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + if got := rt.Stats().Runtime.State; got == want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for runtime state %q; last=%q", want, rt.Stats().Runtime.State) +} From f30e749ffbfe78f5e41184dafe632c7c71fc044c Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 16:45:58 +0200 Subject: [PATCH 14/40] ingest: add integration smoke coverage --- cmd/fmrtx/main.go | 8 ++- cmd/fmrtx/main_test.go | 22 ++++++ internal/ingest/factory/factory_test.go | 15 ++++ internal/ingest/factory/ingest_smoke_test.go | 76 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 internal/ingest/factory/ingest_smoke_test.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 203b2e2..080a89c 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -7,6 +7,7 @@ import ( "log" "os" "os/signal" + "strings" "syscall" "time" @@ -176,7 +177,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a var streamSrc *audio.StreamSource var ingestRuntime *ingest.Runtime var ingress ctrlpkg.AudioIngress - if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { + if ingestEnabled(cfg.Ingest.Kind) { rate := ingestfactory.SampleRateForKind(cfg) bufferFrames := rate * 2 if bufferFrames <= 0 { @@ -241,6 +242,11 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Println("shutdown complete") } +func ingestEnabled(kind string) bool { + normalized := strings.ToLower(strings.TrimSpace(kind)) + return normalized != "" && normalized != "none" +} + func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, audioHTTP bool) cfgpkg.Config { if audioRate > 0 { cfg.Ingest.Stdin.SampleRateHz = audioRate diff --git a/cmd/fmrtx/main_test.go b/cmd/fmrtx/main_test.go index f8d7cbc..5200d2d 100644 --- a/cmd/fmrtx/main_test.go +++ b/cmd/fmrtx/main_test.go @@ -9,6 +9,28 @@ import ( "github.com/jan/fm-rds-tx/internal/platform" ) +func TestIngestEnabled(t *testing.T) { + tests := []struct { + name string + kind string + want bool + }{ + {name: "empty", kind: "", want: false}, + {name: "none", kind: "none", want: false}, + {name: "none uppercase and spaces", kind: " NONE ", want: false}, + {name: "stdin", kind: "stdin", want: true}, + {name: "http raw uppercase", kind: " HTTP-RAW ", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ingestEnabled(tc.kind); got != tc.want { + t.Fatalf("ingestEnabled(%q)=%v want %v", tc.kind, got, tc.want) + } + }) + } +} + func TestTxBridgeExportsQueueStats(t *testing.T) { cfg := cfgpkg.Default() driver := platform.NewSimulatedDriver(nil) diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index d443b2d..f75a5e1 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -34,6 +34,21 @@ func TestBuildSourceHTTPRawProvidesIngress(t *testing.T) { } } +func TestBuildSourceKindIsNormalized(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = " HTTP-RAW " + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil || ingress == nil { + t.Fatalf("expected source and ingress for normalized http-raw kind") + } + if got := src.Descriptor().Kind; got != "http-raw" { + t.Fatalf("source kind=%q want http-raw", got) + } +} + func TestBuildSourceStdin(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "stdin" diff --git a/internal/ingest/factory/ingest_smoke_test.go b/internal/ingest/factory/ingest_smoke_test.go new file mode 100644 index 0000000..1cecd3d --- /dev/null +++ b/internal/ingest/factory/ingest_smoke_test.go @@ -0,0 +1,76 @@ +package factory + +import ( + "context" + "testing" + "time" + + "github.com/jan/fm-rds-tx/internal/audio" + "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "http-raw" + cfg.Ingest.HTTPRaw.SampleRateHz = 44100 + cfg.Ingest.HTTPRaw.Channels = 2 + + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil || ingress == nil { + t.Fatalf("expected source and ingress for kind=http-raw") + } + + sink := audio.NewStreamSource(128, cfg.Ingest.HTTPRaw.SampleRateHz) + rt := ingest.NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("runtime start: %v", err) + } + defer rt.Stop() + + // Two stereo frames: L1,R1,L2,R2 (S16LE). + frames, err := ingress.WritePCM16([]byte{ + 0xE8, 0x03, 0x18, 0xFC, + 0xD0, 0x07, 0x30, 0xF8, + }) + if err != nil { + t.Fatalf("write pcm16: %v", err) + } + if frames != 2 { + t.Fatalf("frames=%d want 2", frames) + } + + waitForSinkFrames(t, sink, 2) + + stats := rt.Stats() + if stats.Active.Kind != "http-raw" { + t.Fatalf("active kind=%q want http-raw", stats.Active.Kind) + } + if stats.Source.ChunksIn != 1 { + t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn) + } + if stats.Source.SamplesIn != 4 { + t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn) + } + if stats.Runtime.State != "running" { + t.Fatalf("runtime state=%q want running", stats.Runtime.State) + } + if stats.Runtime.LastChunkAt.IsZero() { + t.Fatalf("runtime lastChunkAt should be set") + } +} + +func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { + t.Helper() + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + if sink.Available() >= minFrames { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames) +} From 42d74c866586a4682a40176fecb57fb97cee5953 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 17:22:51 +0200 Subject: [PATCH 15/40] ingest: improve runtime observability coverage --- internal/control/control.go | 4 +- internal/control/control_test.go | 96 +++++++++++++++++++++++++++++++- internal/ingest/runtime_test.go | 52 +++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 006c726..131c473 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -299,7 +299,9 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { result["driver"] = drv.Stats() } if tx != nil { - result["engine"] = tx.TXStats() + if stats := tx.TXStats(); stats != nil { + result["engine"] = stats + } } if stream != nil { result["audioStream"] = stream.Stats() diff --git a/internal/control/control_test.go b/internal/control/control_test.go index b2a2752..a59dcbf 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -175,6 +175,16 @@ func TestRuntimeWithoutDriver(t *testing.T) { if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal runtime: %v", err) + } + if _, ok := body["ingest"]; ok { + t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured") + } + if _, ok := body["engine"]; ok { + t.Fatalf("expected engine payload to be absent when tx controller is not configured") + } } func TestRuntimeIncludesIngestStats(t *testing.T) { @@ -207,6 +217,82 @@ func TestRuntimeIncludesIngestStats(t *testing.T) { } } +func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetIngestRuntime(&fakeIngestRuntime{ + stats: ingest.Stats{ + Active: ingest.SourceDescriptor{ID: "icecast-main", Kind: "icecast"}, + Source: ingest.SourceStats{ + State: "reconnecting", + Connected: false, + Reconnects: 3, + LastError: "dial tcp timeout", + }, + Runtime: ingest.RuntimeStats{ + State: "degraded", + ConvertErrors: 2, + WriteBlocked: true, + }, + }, + }) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal runtime: %v", err) + } + ingestPayload, ok := body["ingest"].(map[string]any) + if !ok { + t.Fatalf("expected ingest payload map, got %T", body["ingest"]) + } + source, ok := ingestPayload["source"].(map[string]any) + if !ok { + t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"]) + } + if source["state"] != "reconnecting" { + t.Fatalf("source state mismatch: got %v", source["state"]) + } + if source["reconnects"] != float64(3) { + t.Fatalf("source reconnects mismatch: got %v", source["reconnects"]) + } + if source["lastError"] != "dial tcp timeout" { + t.Fatalf("source lastError mismatch: got %v", source["lastError"]) + } + runtimePayload, ok := ingestPayload["runtime"].(map[string]any) + if !ok { + t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"]) + } + if runtimePayload["state"] != "degraded" { + t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"]) + } + if runtimePayload["convertErrors"] != float64(2) { + t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"]) + } + if runtimePayload["writeBlocked"] != true { + t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"]) + } +} + +func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetTXController(&fakeTXController{returnNilStats: true}) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal runtime: %v", err) + } + if _, ok := body["engine"]; ok { + t.Fatalf("expected engine field to be omitted when TXStats returns nil") + } +} + func TestRuntimeReportsFaultHistory(t *testing.T) { srv := NewServer(cfgpkg.Default()) history := []map[string]any{ @@ -626,9 +712,10 @@ func newConfigPostRequest(body []byte) *http.Request { } type fakeTXController struct { - updateErr error - resetErr error - stats map[string]any + updateErr error + resetErr error + stats map[string]any + returnNilStats bool } type fakeAudioIngress struct { @@ -652,6 +739,9 @@ func (f *fakeIngestRuntime) Stats() ingest.Stats { func (f *fakeTXController) StartTX() error { return nil } func (f *fakeTXController) StopTX() error { return nil } func (f *fakeTXController) TXStats() map[string]any { + if f.returnNilStats { + return nil + } if f.stats != nil { return f.stats } diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index 6167c20..ee82678 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -107,6 +107,58 @@ func TestRuntimeRecoversToRunningAfterConvertError(t *testing.T) { waitForRuntimeState(t, rt, "running") } +func TestRuntimeWithMissingSourceStaysIdleAndReturnsZeroSourceStats(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + rt := NewRuntime(sink, nil) + + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + stats := rt.Stats() + if stats.Runtime.State != "idle" { + t.Fatalf("runtime state=%q want idle", stats.Runtime.State) + } + if stats.Active.ID != "" || stats.Active.Kind != "" { + t.Fatalf("expected empty active descriptor, got %+v", stats.Active) + } + if stats.Source.State != "" { + t.Fatalf("expected zero-value source stats, got state=%q", stats.Source.State) + } +} + +func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + src.desc = SourceDescriptor{ID: "icecast-primary", Kind: "icecast"} + src.stats = SourceStats{ + State: "reconnecting", + Connected: false, + Reconnects: 4, + LastError: "stream ended", + } + rt := NewRuntime(sink, src) + + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + waitForRuntimeState(t, rt, "running") + + stats := rt.Stats() + if stats.Active.ID != "icecast-primary" { + t.Fatalf("active id=%q want icecast-primary", stats.Active.ID) + } + if stats.Active.Kind != "icecast" { + t.Fatalf("active kind=%q want icecast", stats.Active.Kind) + } + if stats.Source.Reconnects != 4 { + t.Fatalf("source reconnects=%d want 4", stats.Source.Reconnects) + } + if stats.Source.LastError != "stream ended" { + t.Fatalf("source lastError=%q want stream ended", stats.Source.LastError) + } +} + func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { t.Helper() deadline := time.Now().Add(1 * time.Second) From 180c0197fd2edc423d3ed87857605989afa2e193 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 17:38:02 +0200 Subject: [PATCH 16/40] ingest: relay icecast stream titles into rds radiotext --- cmd/fmrtx/main.go | 29 ++++- internal/config/config.go | 20 +++- internal/config/config_test.go | 8 ++ internal/ingest/adapters/icecast/icy.go | 109 ++++++++++++++++++ internal/ingest/adapters/icecast/icy_test.go | 77 +++++++++++++ internal/ingest/adapters/icecast/radiotext.go | 106 +++++++++++++++++ .../ingest/adapters/icecast/radiotext_test.go | 65 +++++++++++ internal/ingest/adapters/icecast/source.go | 38 +++++- .../ingest/adapters/icecast/source_test.go | 52 +++++++++ internal/ingest/runtime.go | 29 ++++- internal/ingest/runtime_test.go | 31 ++++- internal/ingest/source.go | 6 + internal/ingest/stats.go | 4 + 13 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 internal/ingest/adapters/icecast/icy.go create mode 100644 internal/ingest/adapters/icecast/icy_test.go create mode 100644 internal/ingest/adapters/icecast/radiotext.go create mode 100644 internal/ingest/adapters/icecast/radiotext_test.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 080a89c..6617414 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -17,6 +17,7 @@ import ( ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" "github.com/jan/fm-rds-tx/internal/platform" "github.com/jan/fm-rds-tx/internal/platform/plutosdr" @@ -190,7 +191,33 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a if err != nil { log.Fatalf("ingest source: %v", err) } - ingestRuntime = ingest.NewRuntime(streamSrc, source) + runtimeOpts := []ingest.RuntimeOption{} + if cfg.Ingest.Icecast.RadioText.Enabled { + relay := icecast.NewRadioTextRelay( + icecast.RadioTextOptions{ + Enabled: true, + Prefix: cfg.Ingest.Icecast.RadioText.Prefix, + MaxLen: cfg.Ingest.Icecast.RadioText.MaxLen, + OnlyOnChange: cfg.Ingest.Icecast.RadioText.OnlyOnChange, + }, + cfg.RDS.RadioText, + func(rt string) error { + return engine.UpdateConfig(apppkg.LiveConfigUpdate{RadioText: &rt}) + }, + ) + runtimeOpts = append(runtimeOpts, ingest.WithStreamTitleHandler(func(streamTitle string) { + if err := relay.HandleStreamTitle(streamTitle); err != nil { + log.Printf("ingest: failed to forward StreamTitle to RDS RadioText: %v", err) + } + })) + log.Printf( + "ingest: ICY StreamTitle->RDS enabled (maxLen=%d onlyOnChange=%t prefix=%q)", + cfg.Ingest.Icecast.RadioText.MaxLen, + cfg.Ingest.Icecast.RadioText.OnlyOnChange, + cfg.Ingest.Icecast.RadioText.Prefix, + ) + } + ingestRuntime = ingest.NewRuntime(streamSrc, source, runtimeOpts...) if err := ingestRuntime.Start(ctx); err != nil { log.Fatalf("ingest start: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 2dff082..7a8b56b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -92,8 +92,16 @@ type IngestPCMConfig struct { } type IngestIcecastConfig struct { - URL string `json:"url"` - Decoder string `json:"decoder"` + URL string `json:"url"` + Decoder string `json:"decoder"` + RadioText IngestIcecastRadioTextConfig `json:"radioText"` +} + +type IngestIcecastRadioTextConfig struct { + Enabled bool `json:"enabled"` + Prefix string `json:"prefix"` + MaxLen int `json:"maxLen"` + OnlyOnChange bool `json:"onlyOnChange"` } func Default() Config { @@ -138,6 +146,11 @@ func Default() Config { }, Icecast: IngestIcecastConfig{ Decoder: "auto", + RadioText: IngestIcecastRadioTextConfig{ + Enabled: false, + MaxLen: 64, + OnlyOnChange: true, + }, }, }, } @@ -265,6 +278,9 @@ func (c Config) Validate() error { default: return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) } + if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 { + return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)") + } // Fail-loud PI validation if c.RDS.Enabled { if _, err := ParsePI(c.RDS.PI); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 031fbcb..affdbb7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -168,6 +168,14 @@ func TestValidateAcceptsIcecastDecoderFallbackAlias(t *testing.T) { } } +func TestValidateRejectsIcecastRadioTextMaxLenOutOfRange(t *testing.T) { + cfg := Default() + cfg.Ingest.Icecast.RadioText.MaxLen = 65 + if err := cfg.Validate(); err == nil { + t.Fatal("expected maxLen error") + } +} + func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { cfg := Default() cfg.Ingest.Reconnect.Enabled = true diff --git a/internal/ingest/adapters/icecast/icy.go b/internal/ingest/adapters/icecast/icy.go new file mode 100644 index 0000000..5a69d43 --- /dev/null +++ b/internal/ingest/adapters/icecast/icy.go @@ -0,0 +1,109 @@ +package icecast + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" +) + +type icyMetadata struct { + StreamTitle string +} + +type icyReader struct { + r io.Reader + metaInt int + audioLeft int + onMetadata func(icyMetadata) +} + +func newICYReader(r io.Reader, metaInt int, onMetadata func(icyMetadata)) io.Reader { + if r == nil || metaInt <= 0 { + return r + } + return &icyReader{ + r: r, + metaInt: metaInt, + audioLeft: metaInt, + onMetadata: onMetadata, + } +} + +func (r *icyReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + for { + if r.audioLeft == 0 { + if err := r.readMetadataBlock(); err != nil { + return 0, err + } + r.audioLeft = r.metaInt + continue + } + want := len(p) + if want > r.audioLeft { + want = r.audioLeft + } + n, err := r.r.Read(p[:want]) + if n > 0 { + r.audioLeft -= n + return n, nil + } + if err != nil { + return 0, err + } + } +} + +func (r *icyReader) readMetadataBlock() error { + var lenBuf [1]byte + if _, err := io.ReadFull(r.r, lenBuf[:]); err != nil { + return err + } + blockLen := int(lenBuf[0]) * 16 + if blockLen == 0 { + return nil + } + block := make([]byte, blockLen) + if _, err := io.ReadFull(r.r, block); err != nil { + return err + } + if r.onMetadata != nil { + r.onMetadata(parseICYMetadata(block)) + } + return nil +} + +func parseICYMetadata(block []byte) icyMetadata { + raw := strings.TrimRight(string(bytes.Trim(block, "\x00")), "\x00") + meta := icyMetadata{} + for _, field := range strings.Split(raw, ";") { + field = strings.TrimSpace(field) + if !strings.HasPrefix(field, "StreamTitle=") { + continue + } + v := strings.TrimPrefix(field, "StreamTitle=") + v = strings.TrimSpace(v) + if len(v) >= 2 && ((v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"')) { + v = v[1 : len(v)-1] + } + meta.StreamTitle = v + break + } + return meta +} + +func parseICYMetaInt(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid icy-metaint: %q", raw) + } + return n, nil +} diff --git a/internal/ingest/adapters/icecast/icy_test.go b/internal/ingest/adapters/icecast/icy_test.go new file mode 100644 index 0000000..63a0798 --- /dev/null +++ b/internal/ingest/adapters/icecast/icy_test.go @@ -0,0 +1,77 @@ +package icecast + +import ( + "bytes" + "io" + "testing" +) + +func TestParseICYMetadataExtractsStreamTitle(t *testing.T) { + meta := parseICYMetadata([]byte("StreamTitle='Artist - Track';StreamUrl='';")) + if meta.StreamTitle != "Artist - Track" { + t.Fatalf("streamTitle=%q want %q", meta.StreamTitle, "Artist - Track") + } +} + +func TestICYReaderStripsMetadataAndEmitsTitle(t *testing.T) { + block := buildICYMetadataBlock("StreamTitle='Unit Test';") + wire := append([]byte("ABCD"), byte(len(block)/16)) + wire = append(wire, block...) + wire = append(wire, []byte("EFGH")...) + + var got icyMetadata + r := newICYReader(bytes.NewReader(wire), 4, func(meta icyMetadata) { + got = meta + }) + + audio, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(audio) != "ABCDEFGH" { + t.Fatalf("audio=%q want %q", string(audio), "ABCDEFGH") + } + if got.StreamTitle != "Unit Test" { + t.Fatalf("streamTitle=%q want %q", got.StreamTitle, "Unit Test") + } +} + +func TestParseICYMetaInt(t *testing.T) { + tests := []struct { + name string + in string + want int + wantErr bool + }{ + {name: "empty", in: "", want: 0}, + {name: "valid", in: "16000", want: 16000}, + {name: "invalid", in: "x", wantErr: true}, + {name: "negative", in: "-1", wantErr: true}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got, err := parseICYMetaInt(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tc.in) + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != tc.want { + t.Fatalf("got=%d want %d", got, tc.want) + } + }) + } +} + +func buildICYMetadataBlock(raw string) []byte { + b := []byte(raw) + if rem := len(b) % 16; rem != 0 { + b = append(b, bytes.Repeat([]byte{0x00}, 16-rem)...) + } + return b +} diff --git a/internal/ingest/adapters/icecast/radiotext.go b/internal/ingest/adapters/icecast/radiotext.go new file mode 100644 index 0000000..ea1aee8 --- /dev/null +++ b/internal/ingest/adapters/icecast/radiotext.go @@ -0,0 +1,106 @@ +package icecast + +import ( + "strings" + "sync" +) + +type RadioTextOptions struct { + Enabled bool + Prefix string + MaxLen int + OnlyOnChange bool +} + +func mapStreamTitleToRadioText(streamTitle string, opts RadioTextOptions) string { + if !opts.Enabled { + return "" + } + maxLen := opts.MaxLen + if maxLen <= 0 || maxLen > 64 { + maxLen = 64 + } + title := sanitizeASCII(streamTitle) + if title == "" { + return "" + } + prefixRaw := opts.Prefix + prefixHadTrailingSpace := strings.TrimRight(prefixRaw, " \t\r\n") != prefixRaw + prefix := sanitizeASCII(opts.Prefix) + if prefix != "" && prefixHadTrailingSpace { + prefix += " " + } + rt := title + if prefix != "" { + rt = prefix + title + } + if len(rt) > maxLen { + rt = strings.TrimSpace(rt[:maxLen]) + } + return rt +} + +func sanitizeASCII(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var b strings.Builder + b.Grow(len(raw)) + prevSpace := true + for _, r := range raw { + switch r { + case '\n', '\r', '\t': + r = ' ' + } + if r < 0x20 || r == 0x7f || r > 0x7e { + continue + } + if r == ' ' { + if prevSpace { + continue + } + prevSpace = true + b.WriteByte(' ') + continue + } + prevSpace = false + b.WriteByte(byte(r)) + } + return strings.TrimSpace(b.String()) +} + +type RadioTextRelay struct { + opts RadioTextOptions + apply func(string) error + mu sync.Mutex + lastRT string +} + +func NewRadioTextRelay(opts RadioTextOptions, initialRT string, apply func(string) error) *RadioTextRelay { + return &RadioTextRelay{ + opts: opts, + apply: apply, + lastRT: sanitizeASCII(initialRT), + } +} + +func (r *RadioTextRelay) HandleStreamTitle(streamTitle string) error { + if r == nil || r.apply == nil { + return nil + } + next := mapStreamTitleToRadioText(streamTitle, r.opts) + if next == "" { + return nil + } + r.mu.Lock() + skip := r.opts.OnlyOnChange && next == r.lastRT + if !skip { + r.lastRT = next + } + r.mu.Unlock() + if skip { + return nil + } + return r.apply(next) +} diff --git a/internal/ingest/adapters/icecast/radiotext_test.go b/internal/ingest/adapters/icecast/radiotext_test.go new file mode 100644 index 0000000..b62df6f --- /dev/null +++ b/internal/ingest/adapters/icecast/radiotext_test.go @@ -0,0 +1,65 @@ +package icecast + +import "testing" + +func TestMapStreamTitleToRadioTextSanitizeAndTruncate(t *testing.T) { + got := mapStreamTitleToRadioText(" Artist\t-\nSong \u2603 ", RadioTextOptions{ + Enabled: true, + Prefix: "Now: ", + MaxLen: 13, + }) + if got != "Now: Artist -" { + t.Fatalf("mapped=%q want %q", got, "Now: Artist -") + } +} + +func TestMapStreamTitleToRadioTextDisabledReturnsEmpty(t *testing.T) { + got := mapStreamTitleToRadioText("Artist - Song", RadioTextOptions{Enabled: false}) + if got != "" { + t.Fatalf("mapped=%q want empty", got) + } +} + +func TestRadioTextRelayOnlyOnChange(t *testing.T) { + calls := 0 + last := "" + relay := NewRadioTextRelay(RadioTextOptions{ + Enabled: true, + OnlyOnChange: true, + }, "", func(rt string) error { + calls++ + last = rt + return nil + }) + + if err := relay.HandleStreamTitle("Artist - Song"); err != nil { + t.Fatalf("first handle: %v", err) + } + if err := relay.HandleStreamTitle("Artist - Song"); err != nil { + t.Fatalf("second handle: %v", err) + } + if calls != 1 { + t.Fatalf("calls=%d want 1", calls) + } + if last != "Artist - Song" { + t.Fatalf("last=%q want %q", last, "Artist - Song") + } +} + +func TestRadioTextRelayInitialSuppressesSameUpdate(t *testing.T) { + calls := 0 + relay := NewRadioTextRelay(RadioTextOptions{ + Enabled: true, + OnlyOnChange: true, + }, "Station default", func(string) error { + calls++ + return nil + }) + + if err := relay.HandleStreamTitle("Station default"); err != nil { + t.Fatalf("handle: %v", err) + } + if calls != 0 { + t.Fatalf("calls=%d want 0", calls) + } +} diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 1da52c9..028722f 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -32,6 +32,7 @@ type Source struct { chunks chan ingest.PCMChunk errs chan error + title chan string cancel context.CancelFunc wg sync.WaitGroup @@ -43,7 +44,11 @@ type Source struct { reconnects atomic.Uint64 discontinuities atomic.Uint64 lastChunkAtUnix atomic.Int64 + lastMetaAtUnix atomic.Int64 + metadataUpdates atomic.Uint64 + icyMetaInt atomic.Int64 lastError atomic.Value // string + streamTitle atomic.Value // string } var errStreamEnded = errors.New("icecast stream ended") @@ -78,6 +83,7 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op reconn: reconn, chunks: make(chan ingest.PCMChunk, 64), errs: make(chan error, 8), + title: make(chan string, 16), decReg: defaultRegistry(), decoderPreference: "auto", } @@ -88,6 +94,7 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op } s.decoderPreference = normalizeDecoderPreference(s.decoderPreference) s.state.Store("idle") + s.streamTitle.Store("") return s } @@ -135,19 +142,32 @@ func (s *Source) Stop() error { func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } func (s *Source) Errors() <-chan error { return s.errs } +func (s *Source) StreamTitleUpdates() <-chan string { + return s.title +} func (s *Source) Stats() ingest.SourceStats { state, _ := s.state.Load().(string) last := s.lastChunkAtUnix.Load() + lastMeta := s.lastMetaAtUnix.Load() errStr, _ := s.lastError.Load().(string) + streamTitle, _ := s.streamTitle.Load().(string) var lastChunkAt time.Time + var lastMetaAt time.Time if last > 0 { lastChunkAt = time.Unix(0, last) } + if lastMeta > 0 { + lastMetaAt = time.Unix(0, lastMeta) + } return ingest.SourceStats{ State: state, Connected: s.connected.Load(), LastChunkAt: lastChunkAt, + LastMetaAt: lastMetaAt, + StreamTitle: streamTitle, + MetadataUpdates: s.metadataUpdates.Load(), + IcyMetaInt: int(s.icyMetaInt.Load()), ChunksIn: s.chunksIn.Load(), SamplesIn: s.samplesIn.Load(), Reconnects: s.reconnects.Load(), @@ -160,6 +180,7 @@ func (s *Source) loop(ctx context.Context) { defer s.wg.Done() defer close(s.chunks) defer close(s.errs) + defer close(s.title) attempt := 0 for { select { @@ -206,7 +227,7 @@ func (s *Source) connectAndRun(ctx context.Context) error { if err != nil { return err } - req.Header.Set("Icy-MetaData", "0") + req.Header.Set("Icy-MetaData", "1") resp, err := s.client.Do(req) if err != nil { return fmt.Errorf("icecast connect: %w", err) @@ -218,8 +239,11 @@ func (s *Source) connectAndRun(ctx context.Context) error { s.connected.Store(true) s.state.Store("buffering") s.lastError.Store("") + icyMetaInt, _ := parseICYMetaInt(resp.Header.Get("icy-metaint")) + s.icyMetaInt.Store(int64(icyMetaInt)) + stream := newICYReader(resp.Body, icyMetaInt, s.onMetadata) s.state.Store("running") - return s.decodeWithPreference(ctx, resp.Body, decoder.StreamMeta{ + return s.decodeWithPreference(ctx, stream, decoder.StreamMeta{ ContentType: resp.Header.Get("Content-Type"), SourceID: s.id, SampleRateHz: 44100, @@ -227,6 +251,16 @@ func (s *Source) connectAndRun(ctx context.Context) error { }) } +func (s *Source) onMetadata(meta icyMetadata) { + s.streamTitle.Store(meta.StreamTitle) + s.metadataUpdates.Add(1) + s.lastMetaAtUnix.Store(time.Now().UnixNano()) + select { + case s.title <- meta.StreamTitle: + default: + } +} + func (s *Source) emitChunk(chunk ingest.PCMChunk) error { select { case s.chunks <- chunk: diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 568110c..162ea89 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -311,6 +311,58 @@ func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { } } +func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) { + const ( + audioPrefix = "ABCD" + audioSuffix = "EFGH" + title = "Artist - Track" + ) + var reqIcyHeader atomic.Value + reqIcyHeader.Store("") + + metadata := buildICYMetadataBlock("StreamTitle='" + title + "';") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqIcyHeader.Store(r.Header.Get("Icy-Metadata")) + w.Header().Set("Content-Type", "audio/mpeg") + w.Header().Set("icy-metaint", "4") + _, _ = w.Write([]byte(audioPrefix)) + _, _ = w.Write([]byte{byte(len(metadata) / 16)}) + _, _ = w.Write(metadata) + _, _ = w.Write([]byte(audioSuffix)) + })) + defer srv.Close() + + native := &captureStreamDecoder{name: "mp3"} + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return native }) + reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) + + src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{}, + WithDecoderRegistry(reg), + WithDecoderPreference("auto"), + ) + + if err := src.connectAndRun(context.Background()); err != nil { + t.Fatalf("connectAndRun: %v", err) + } + if got := reqIcyHeader.Load().(string); got != "1" { + t.Fatalf("Icy-Metadata header=%q want 1", got) + } + if got := string(native.payload); got != audioPrefix+audioSuffix { + t.Fatalf("decoded payload=%q want %q", got, audioPrefix+audioSuffix) + } + stats := src.Stats() + if stats.StreamTitle != title { + t.Fatalf("streamTitle=%q want %q", stats.StreamTitle, title) + } + if stats.MetadataUpdates < 1 { + t.Fatalf("metadataUpdates=%d want >=1", stats.MetadataUpdates) + } + if stats.IcyMetaInt != 4 { + t.Fatalf("icyMetaInt=%d want 4", stats.IcyMetaInt) + } +} + type scriptedLoopDecoder struct { mu sync.Mutex actions []decodeAction diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index fa3ad64..6b9e1ef 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -13,6 +13,7 @@ type Runtime struct { sink *audio.StreamSource source Source started atomic.Bool + onTitle func(string) ctx context.Context cancel context.CancelFunc @@ -23,14 +24,28 @@ type Runtime struct { stats RuntimeStats } -func NewRuntime(sink *audio.StreamSource, src Source) *Runtime { - return &Runtime{ +type RuntimeOption func(*Runtime) + +func WithStreamTitleHandler(handler func(string)) RuntimeOption { + return func(r *Runtime) { + r.onTitle = handler + } +} + +func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Runtime { + r := &Runtime{ sink: sink, source: src, stats: RuntimeStats{ State: "idle", }, } + for _, opt := range opts { + if opt != nil { + opt(r) + } + } + return r } func (r *Runtime) Start(ctx context.Context) error { @@ -93,6 +108,10 @@ func (r *Runtime) run() { ch := r.source.Chunks() errCh := r.source.Errors() + var titleCh <-chan string + if src, ok := r.source.(StreamTitleSource); ok && r.onTitle != nil { + titleCh = src.StreamTitleUpdates() + } for { select { case <-r.ctx.Done(): @@ -116,6 +135,12 @@ func (r *Runtime) run() { return } r.handleChunk(chunk) + case title, ok := <-titleCh: + if !ok { + titleCh = nil + continue + } + r.onTitle(title) } } } diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index ee82678..48cfcb3 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -14,6 +14,7 @@ type fakeSource struct { desc SourceDescriptor chunks chan PCMChunk errs chan error + title chan string stats SourceStats once sync.Once } @@ -23,6 +24,7 @@ func newFakeSource() *fakeSource { desc: SourceDescriptor{ID: "fake", Kind: "stdin-pcm"}, chunks: make(chan PCMChunk, 4), errs: make(chan error, 1), + title: make(chan string, 4), stats: SourceStats{State: "running", Connected: true}, } } @@ -32,7 +34,10 @@ func (s *fakeSource) Start(context.Context) error { return nil } func (s *fakeSource) Stop() error { s.once.Do(func() { close(s.chunks) }); return nil } func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks } func (s *fakeSource) Errors() <-chan error { return s.errs } -func (s *fakeSource) Stats() SourceStats { return s.stats } +func (s *fakeSource) StreamTitleUpdates() <-chan string { + return s.title +} +func (s *fakeSource) Stats() SourceStats { return s.stats } func TestRuntimeWritesFramesToStreamSink(t *testing.T) { sink := audio.NewStreamSource(128, 44100) @@ -159,6 +164,30 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) } } +func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + got := make(chan string, 1) + rt := NewRuntime(sink, src, WithStreamTitleHandler(func(title string) { + got <- title + })) + + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.title <- "Artist - Song" + select { + case title := <-got: + if title != "Artist - Song" { + t.Fatalf("title=%q want %q", title, "Artist - Song") + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for forwarded title") + } +} + func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { t.Helper() deadline := time.Now().Add(1 * time.Second) diff --git a/internal/ingest/source.go b/internal/ingest/source.go index d851ed3..d4bca57 100644 --- a/internal/ingest/source.go +++ b/internal/ingest/source.go @@ -10,3 +10,9 @@ type Source interface { Errors() <-chan error Stats() SourceStats } + +// StreamTitleSource is an optional extension for sources that expose +// title/metadata updates (for example ICY StreamTitle). +type StreamTitleSource interface { + StreamTitleUpdates() <-chan string +} diff --git a/internal/ingest/stats.go b/internal/ingest/stats.go index fb135c0..55f44a4 100644 --- a/internal/ingest/stats.go +++ b/internal/ingest/stats.go @@ -6,6 +6,10 @@ type SourceStats struct { State string `json:"state"` Connected bool `json:"connected"` LastChunkAt time.Time `json:"lastChunkAt,omitempty"` + LastMetaAt time.Time `json:"lastMetaAt,omitempty"` + StreamTitle string `json:"streamTitle,omitempty"` + MetadataUpdates uint64 `json:"metadataUpdates,omitempty"` + IcyMetaInt int `json:"icyMetaInt,omitempty"` ChunksIn uint64 `json:"chunksIn"` SamplesIn uint64 `json:"samplesIn"` BufferedSeconds float64 `json:"bufferedSeconds"` From f9f695eb4a8e439531628f84cc61e1eaa19a51fc Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 19:11:49 +0200 Subject: [PATCH 17/40] ingest: add srt source support via aoiprxkit --- aoiprxkit/README.md | 90 ++++++ aoiprxkit/cmd/demo/main.go | 93 ++++++ aoiprxkit/config.go | 66 +++++ aoiprxkit/docs/INTEGRATION.md | 39 +++ aoiprxkit/docs/PROTOCOLS.md | 26 ++ aoiprxkit/go.mod | 3 + aoiprxkit/jitter.go | 58 ++++ aoiprxkit/jitter_test.go | 34 +++ aoiprxkit/meter.go | 127 +++++++++ aoiprxkit/meter_server.go | 205 ++++++++++++++ aoiprxkit/nmos/is05.go | 62 ++++ aoiprxkit/nmos/models.go | 39 +++ aoiprxkit/nmos/query.go | 56 ++++ aoiprxkit/pcm.go | 50 ++++ aoiprxkit/pcm_test.go | 39 +++ aoiprxkit/receiver.go | 194 +++++++++++++ aoiprxkit/rtp.go | 68 +++++ aoiprxkit/rtp_test.go | 22 ++ aoiprxkit/sap.go | 115 ++++++++ aoiprxkit/sap_listener.go | 150 ++++++++++ aoiprxkit/sap_test.go | 28 ++ aoiprxkit/sdp.go | 116 ++++++++ aoiprxkit/sdp_test.go | 22 ++ aoiprxkit/srt.go | 70 +++++ aoiprxkit/srt_gosrt.go.example | 13 + aoiprxkit/srt_profile.md | 13 + aoiprxkit/srt_stub.go | 15 + aoiprxkit/srt_test.go | 58 ++++ aoiprxkit/stats.go | 53 ++++ aoiprxkit/stream_finder.go | 137 +++++++++ aoiprxkit/stream_proto.go | 81 ++++++ aoiprxkit/stream_proto_test.go | 34 +++ aoiprxkit/stream_receiver.go | 114 ++++++++ aoiprxkit/stream_receiver_test.go | 56 ++++ go.mod | 3 + internal/config/config.go | 34 ++- internal/config/config_test.go | 33 +++ internal/go.mod | 5 +- internal/ingest/adapters/srt/source.go | 283 +++++++++++++++++++ internal/ingest/adapters/srt/source_test.go | 109 +++++++ internal/ingest/factory/factory.go | 24 +- internal/ingest/factory/factory_test.go | 29 ++ internal/ingest/factory/ingest_smoke_test.go | 57 ++++ 43 files changed, 2917 insertions(+), 6 deletions(-) create mode 100644 aoiprxkit/README.md create mode 100644 aoiprxkit/cmd/demo/main.go create mode 100644 aoiprxkit/config.go create mode 100644 aoiprxkit/docs/INTEGRATION.md create mode 100644 aoiprxkit/docs/PROTOCOLS.md create mode 100644 aoiprxkit/go.mod create mode 100644 aoiprxkit/jitter.go create mode 100644 aoiprxkit/jitter_test.go create mode 100644 aoiprxkit/meter.go create mode 100644 aoiprxkit/meter_server.go create mode 100644 aoiprxkit/nmos/is05.go create mode 100644 aoiprxkit/nmos/models.go create mode 100644 aoiprxkit/nmos/query.go create mode 100644 aoiprxkit/pcm.go create mode 100644 aoiprxkit/pcm_test.go create mode 100644 aoiprxkit/receiver.go create mode 100644 aoiprxkit/rtp.go create mode 100644 aoiprxkit/rtp_test.go create mode 100644 aoiprxkit/sap.go create mode 100644 aoiprxkit/sap_listener.go create mode 100644 aoiprxkit/sap_test.go create mode 100644 aoiprxkit/sdp.go create mode 100644 aoiprxkit/sdp_test.go create mode 100644 aoiprxkit/srt.go create mode 100644 aoiprxkit/srt_gosrt.go.example create mode 100644 aoiprxkit/srt_profile.md create mode 100644 aoiprxkit/srt_stub.go create mode 100644 aoiprxkit/srt_test.go create mode 100644 aoiprxkit/stats.go create mode 100644 aoiprxkit/stream_finder.go create mode 100644 aoiprxkit/stream_proto.go create mode 100644 aoiprxkit/stream_proto_test.go create mode 100644 aoiprxkit/stream_receiver.go create mode 100644 aoiprxkit/stream_receiver_test.go create mode 100644 internal/ingest/adapters/srt/source.go create mode 100644 internal/ingest/adapters/srt/source_test.go diff --git a/aoiprxkit/README.md b/aoiprxkit/README.md new file mode 100644 index 0000000..1616809 --- /dev/null +++ b/aoiprxkit/README.md @@ -0,0 +1,90 @@ +# aoiprxkit + +Standalone Go module for adding professional AoIP receive capabilities step by step. + +This package covers the roadmap up to **Phase 4** with a **Go-native target architecture**: + +1. **AES67 RX-lite** +2. **static SDP loading + optional SAP listener** +3. **stream discovery by SAP/SDP session name** +4. **live browser metering over HTTP/WebSocket** +5. **NMOS IS-04 / IS-05 client scaffolding** +6. **SRT WAN ingest via native transport adapter + framed PCM profile** + +## Included components + +### Core RTP / AES67-lite receiver +- IPv4 multicast RTP join +- static config or config derived from SDP +- `L24` decoding +- small jitter / reorder buffer +- PCM frame callback +- runtime counters + +### SDP support +- minimal parser for: + - `c=` + - `m=audio` + - `a=rtpmap` + - `a=ptime` +- conversion helper from parsed SDP to receiver config + +### SAP listener +- optional listener for SAP announcements +- default SAP group/port support +- `application/sdp` extraction +- callback with parsed session details + +### NMOS scaffolding +- lightweight Query API client +- lightweight Connection API client +- helpers for receiver-side staged activation payloads + +### SRT WAN bridge (reworked) +- no `ffmpeg.exe` dependency in the default package path +- generic stream receiver for framed PCM +- SRT receiver abstraction with injectable transport opener +- default build ships a clear stub for the transport layer +- intended production path: wire a **pure-Go SRT transport** (for example a `gosrt` opener) in the target repo + +## Framed WAN audio profile + +The package now assumes a deliberately narrow WAN ingest profile: + +- transport: SRT +- payload framing: custom framed stream defined in `stream_proto.go` +- codec today: PCM `S32LE` +- codec reserved for later: Opus + +This keeps the stack deterministic and avoids generic container / demux complexity. + +## Deliberate non-goals +- no full AES67 compliance claim +- no PTP discipline +- no full SAP session cache +- no bundled gosrt implementation in this zip +- no ST 2110-30 sender/receiver implementation +- no NMOS Node/Registry server implementation + +## Why it is built like this +The goal is not to overbuild a broadcast plant in one step. +The goal is to provide a **repo-addable module** that gives a realistic progression: + +- start with known multicast audio +- add discovery +- add control-plane interoperability +- add WAN ingest without external EXE dependencies as the default design target + +## Suggested integration order + +1. integrate the core receiver into your existing audio input abstraction +2. allow config-by-SDP +3. enable optional SAP auto-discovery +4. add NMOS registry/query support +5. wire a native SRT opener in your target repo + +## Added in this build + +- `StreamFinder` for exact matching by SDP `s=` session name +- `LiveMeter` for per-channel RMS / Peak / Latest values +- `MeterServer` with `/`, `/healthz`, `/api/meter` and `/ws/live` diff --git a/aoiprxkit/cmd/demo/main.go b/aoiprxkit/cmd/demo/main.go new file mode 100644 index 0000000..ccb9cd6 --- /dev/null +++ b/aoiprxkit/cmd/demo/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os/signal" + "syscall" + "time" + + "aoiprxkit" +) + +func main() { + mode := flag.String("mode", "rtp", "rtp|sap") + group := flag.String("group", "239.69.0.1", "IPv4 multicast group") + port := flag.Int("port", 5004, "UDP port") + iface := flag.String("iface", "", "network interface name") + pt := flag.Int("pt", 97, "expected RTP payload type") + rate := flag.Int("rate", 48000, "sample rate") + ch := flag.Int("ch", 2, "channels") + flag.Parse() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + switch *mode { + case "sap": + listener, err := aoiprxkit.NewSAPListener(aoiprxkit.SAPListenerConfig{ + Group: aoiprxkit.DefaultSAPGroup, + Port: aoiprxkit.DefaultSAPPort, + InterfaceName: *iface, + ReadBuffer: 1 << 20, + }, func(a aoiprxkit.SAPAnnouncement) { + fmt.Printf("SAP session: name=%q group=%s port=%d pt=%d encoding=%s rate=%d ch=%d delete=%v\n", + a.ParsedSDP.SessionName, + a.ParsedSDP.MulticastGroup, + a.ParsedSDP.Port, + a.ParsedSDP.PayloadType, + a.ParsedSDP.Encoding, + a.ParsedSDP.SampleRateHz, + a.ParsedSDP.Channels, + a.Delete, + ) + }) + if err != nil { + log.Fatal(err) + } + if err := listener.Start(ctx); err != nil { + log.Fatal(err) + } + defer listener.Stop() + <-ctx.Done() + + default: + cfg := aoiprxkit.DefaultConfig() + cfg.MulticastGroup = *group + cfg.Port = *port + cfg.InterfaceName = *iface + cfg.PayloadType = uint8(*pt) + cfg.SampleRateHz = *rate + cfg.Channels = *ch + + var packets uint64 + rx, err := aoiprxkit.NewReceiver(cfg, func(frame aoiprxkit.PCMFrame) { + packets++ + if packets%100 == 0 { + fmt.Printf("delivered packet seq=%d ts=%d samples=%d source=%s\n", frame.SequenceNumber, frame.Timestamp, len(frame.Samples), frame.Source) + } + }) + if err != nil { + log.Fatal(err) + } + if err := rx.Start(ctx); err != nil { + log.Fatal(err) + } + defer rx.Stop() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + fmt.Println("stopping") + return + case <-ticker.C: + fmt.Printf("stats: %+v\n", rx.Stats()) + } + } + } +} diff --git a/aoiprxkit/config.go b/aoiprxkit/config.go new file mode 100644 index 0000000..9d44640 --- /dev/null +++ b/aoiprxkit/config.go @@ -0,0 +1,66 @@ +package aoiprxkit + +import ( + "errors" + "fmt" + "net" + "time" +) + +// Config defines a pragmatic RX-only subset for statically configured AES67-style RTP audio. +// It is intentionally narrower than full AES67. +type Config struct { + MulticastGroup string + Port int + InterfaceName string + PayloadType uint8 + SampleRateHz int + Channels int + Encoding string + PacketTime time.Duration + JitterDepthPackets int + ReadBufferBytes int +} + +func DefaultConfig() Config { + return Config{ + MulticastGroup: "239.69.0.1", + Port: 5004, + PayloadType: 97, + SampleRateHz: 48000, + Channels: 2, + Encoding: "L24", + PacketTime: time.Millisecond, + JitterDepthPackets: 8, + ReadBufferBytes: 1 << 20, + } +} + +func (c Config) Validate() error { + if ip := net.ParseIP(c.MulticastGroup); ip == nil || ip.To4() == nil { + return fmt.Errorf("invalid IPv4 multicast group: %q", c.MulticastGroup) + } + ip := net.ParseIP(c.MulticastGroup).To4() + if ip[0] < 224 || ip[0] > 239 { + return fmt.Errorf("multicast group must be IPv4 multicast: %q", c.MulticastGroup) + } + if c.Port < 1 || c.Port > 65535 { + return errors.New("port must be 1..65535") + } + if c.SampleRateHz <= 0 { + return errors.New("sample rate must be > 0") + } + if c.Channels < 1 || c.Channels > 2 { + return errors.New("channels must be 1 or 2") + } + if c.Encoding != "L24" { + return fmt.Errorf("unsupported encoding %q: only L24 is currently supported", c.Encoding) + } + if c.PacketTime <= 0 { + return errors.New("packet time must be > 0") + } + if c.JitterDepthPackets < 1 { + return errors.New("jitter depth must be >= 1") + } + return nil +} diff --git a/aoiprxkit/docs/INTEGRATION.md b/aoiprxkit/docs/INTEGRATION.md new file mode 100644 index 0000000..6c69be8 --- /dev/null +++ b/aoiprxkit/docs/INTEGRATION.md @@ -0,0 +1,39 @@ +# Integration notes + +## Existing FM / DSP project +The intended integration pattern is: + +- your application decides which input mode is active +- this module delivers decoded PCM frames +- your application writes those samples into its existing audio ring buffer or live source abstraction + +## Recommended first integration +Use only: + +- `Config` +- `NewReceiver` +- `Start` +- `Stop` + +and a callback like: + +```go +rx, _ := aoiprxkit.NewReceiver(cfg, func(frame aoiprxkit.PCMFrame) { + audioInput.PushInt32(frame.Samples, frame.SampleRateHz, frame.Channels) +}) +``` + +## SRT integration pattern +The WAN side is now split into two layers: + +1. `SRTReceiver` / `StreamReceiver` +2. a transport opener that returns an `io.ReadCloser` + +That means your target repo can later add a native `gosrt` opener without changing the PCM handling path. + +## Later additions +- derive config from SDP +- attach a SAP listener to discover sessions +- query NMOS registry for streams/receivers +- activate receiver transport with IS-05 +- use a native SRT opener for WAN delivery into the same audio input path diff --git a/aoiprxkit/docs/PROTOCOLS.md b/aoiprxkit/docs/PROTOCOLS.md new file mode 100644 index 0000000..f6acd08 --- /dev/null +++ b/aoiprxkit/docs/PROTOCOLS.md @@ -0,0 +1,26 @@ +# Protocol matrix + +## LAN +### RTP multicast + SDP +Good first step for known sources. + +### SAP +Useful for lightweight multicast session discovery. + +### NMOS IS-04 / IS-05 +Adds discovery, registry and connection management. +Recommended when integrating into professional IP media environments. + +## WAN +### SRT +Current Phase-4 target. +This package now expects a narrow framed-PCM profile over SRT instead of a generic FFmpeg sidecar path. + +### RIST +Not implemented here. +Reasonable future Phase-5 candidate for broadcast-heavy WAN environments. + +## Later / optional +### ST 2110-30 +Not implemented here. +Reasonable future path once AES67 + NMOS + WAN ingest are stable. diff --git a/aoiprxkit/go.mod b/aoiprxkit/go.mod new file mode 100644 index 0000000..79b749d --- /dev/null +++ b/aoiprxkit/go.mod @@ -0,0 +1,3 @@ +module aoiprxkit + +go 1.22 diff --git a/aoiprxkit/jitter.go b/aoiprxkit/jitter.go new file mode 100644 index 0000000..3b24d5c --- /dev/null +++ b/aoiprxkit/jitter.go @@ -0,0 +1,58 @@ +package aoiprxkit + +type jitterBuffer struct { + started bool + expected uint16 + maxDepth int + packets map[uint16]RTPPacket +} + +func newJitterBuffer(maxDepth int) *jitterBuffer { + return &jitterBuffer{maxDepth: maxDepth, packets: make(map[uint16]RTPPacket)} +} + +func (j *jitterBuffer) push(pkt RTPPacket) (ready []RTPPacket, lateDrop bool, gapLoss uint64, reorder bool) { + if !j.started { + j.started = true + j.expected = pkt.SequenceNumber + } + if seqDistance(pkt.SequenceNumber, j.expected) < 0 { + return nil, true, 0, false + } + if _, exists := j.packets[pkt.SequenceNumber]; !exists { + j.packets[pkt.SequenceNumber] = pkt + if pkt.SequenceNumber != j.expected { + reorder = true + } + } + for { + pkt, ok := j.packets[j.expected] + if !ok { + break + } + ready = append(ready, pkt) + delete(j.packets, j.expected) + j.expected++ + } + for len(j.packets) > j.maxDepth { + if _, ok := j.packets[j.expected]; ok { + break + } + j.expected++ + gapLoss++ + for { + pkt, ok := j.packets[j.expected] + if !ok { + break + } + ready = append(ready, pkt) + delete(j.packets, j.expected) + j.expected++ + } + } + return ready, false, gapLoss, reorder +} + +func seqDistance(a, b uint16) int { + return int(int16(a - b)) +} diff --git a/aoiprxkit/jitter_test.go b/aoiprxkit/jitter_test.go new file mode 100644 index 0000000..6788791 --- /dev/null +++ b/aoiprxkit/jitter_test.go @@ -0,0 +1,34 @@ +package aoiprxkit + +import "testing" + +func TestJitterBufferReordersAndReleases(t *testing.T) { + jb := newJitterBuffer(8) + p100 := RTPPacket{SequenceNumber: 100} + p102 := RTPPacket{SequenceNumber: 102} + p101 := RTPPacket{SequenceNumber: 101} + + ready, late, gap, reorder := jb.push(p100) + if late || gap != 0 || reorder { + t.Fatalf("unexpected state on first push") + } + if len(ready) != 1 || ready[0].SequenceNumber != 100 { + t.Fatalf("unexpected ready on first push: %+v", ready) + } + + ready, late, gap, reorder = jb.push(p102) + if late || gap != 0 || !reorder { + t.Fatalf("expected reorder on out-of-order push") + } + if len(ready) != 0 { + t.Fatalf("unexpected ready before missing packet arrives: %+v", ready) + } + + ready, late, gap, reorder = jb.push(p101) + if late || gap != 0 { + t.Fatalf("unexpected late/gap after completing sequence") + } + if len(ready) != 2 || ready[0].SequenceNumber != 101 || ready[1].SequenceNumber != 102 { + t.Fatalf("unexpected ready after sequence repair: %+v", ready) + } +} diff --git a/aoiprxkit/meter.go b/aoiprxkit/meter.go new file mode 100644 index 0000000..21e64fe --- /dev/null +++ b/aoiprxkit/meter.go @@ -0,0 +1,127 @@ +package aoiprxkit + +import ( + "math" + "sync" + "time" +) + +type ChannelMeter struct { + RMS float64 `json:"rms"` + Peak float64 `json:"peak"` + Latest float64 `json:"latest"` +} + +type MeterSnapshot struct { + UpdatedAt string `json:"updatedAt"` + Source string `json:"source"` + SampleRateHz int `json:"sampleRateHz"` + Channels int `json:"channels"` + Meters []ChannelMeter `json:"meters"` +} + +// LiveMeter consumes PCM frames and publishes simple per-channel level data. +type LiveMeter struct { + mu sync.RWMutex + latest MeterSnapshot + subs map[chan MeterSnapshot]struct{} +} + +func NewLiveMeter() *LiveMeter { + return &LiveMeter{subs: make(map[chan MeterSnapshot]struct{})} +} + +func (m *LiveMeter) Consume(frame PCMFrame) { + if frame.Channels <= 0 || len(frame.Samples) == 0 { + return + } + meters := make([]ChannelMeter, frame.Channels) + fullScale := detectFullScale(frame.Samples) + sums := make([]float64, frame.Channels) + counts := make([]int, frame.Channels) + + for i, sample := range frame.Samples { + ch := i % frame.Channels + norm := float64(sample) / fullScale + abs := math.Abs(norm) + if abs > meters[ch].Peak { + meters[ch].Peak = abs + } + meters[ch].Latest = norm + sums[ch] += norm * norm + counts[ch]++ + } + for ch := range meters { + if counts[ch] > 0 { + meters[ch].RMS = math.Sqrt(sums[ch] / float64(counts[ch])) + } + } + + snap := MeterSnapshot{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano), + Source: frame.Source, + SampleRateHz: frame.SampleRateHz, + Channels: frame.Channels, + Meters: meters, + } + + m.mu.Lock() + m.latest = snap + subs := make([]chan MeterSnapshot, 0, len(m.subs)) + for ch := range m.subs { + subs = append(subs, ch) + } + m.mu.Unlock() + + for _, ch := range subs { + select { + case ch <- snap: + default: + } + } +} + +func detectFullScale(samples []int32) float64 { + var maxAbs int64 + for _, s := range samples { + v := int64(s) + if v < 0 { + v = -v + } + if v > maxAbs { + maxAbs = v + } + } + if maxAbs <= 8388608 { + return 8388608.0 + } + return 2147483648.0 +} + +func (m *LiveMeter) Snapshot() MeterSnapshot { + m.mu.RLock() + defer m.mu.RUnlock() + return m.latest +} + +func (m *LiveMeter) Subscribe() (<-chan MeterSnapshot, func()) { + ch := make(chan MeterSnapshot, 8) + m.mu.Lock() + m.subs[ch] = struct{}{} + latest := m.latest + m.mu.Unlock() + + if latest.UpdatedAt != "" { + ch <- latest + } + + unsubscribe := func() { + m.mu.Lock() + if _, ok := m.subs[ch]; ok { + delete(m.subs, ch) + close(ch) + } + m.mu.Unlock() + } + return ch, unsubscribe +} diff --git a/aoiprxkit/meter_server.go b/aoiprxkit/meter_server.go new file mode 100644 index 0000000..8326e1f --- /dev/null +++ b/aoiprxkit/meter_server.go @@ -0,0 +1,205 @@ +package aoiprxkit + +import ( + "bufio" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "time" +) + +type MeterServer struct { + meter *LiveMeter + srv *http.Server +} + +func NewMeterServer(listenAddress string, meter *LiveMeter) *MeterServer { + if meter == nil { + meter = NewLiveMeter() + } + ms := &MeterServer{meter: meter} + mux := http.NewServeMux() + mux.HandleFunc("/", ms.handleIndex) + mux.HandleFunc("/healthz", ms.handleHealth) + mux.HandleFunc("/api/meter", ms.handleSnapshot) + mux.HandleFunc("/ws/live", ms.handleWS) + ms.srv = &http.Server{ + Addr: listenAddress, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + return ms +} + +func (m *MeterServer) Meter() *LiveMeter { return m.meter } + +func (m *MeterServer) Start() error { + go func() { + _ = m.srv.ListenAndServe() + }() + return nil +} + +func (m *MeterServer) Shutdown(ctx context.Context) error { + return m.srv.Shutdown(ctx) +} + +func (m *MeterServer) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} + +func (m *MeterServer) handleSnapshot(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m.meter.Snapshot()) +} + +func (m *MeterServer) handleIndex(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, meterIndexHTML) +} + +func (m *MeterServer) handleWS(w http.ResponseWriter, r *http.Request) { + if !headerContainsToken(r.Header, "Connection", "Upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "upgrade required", http.StatusUpgradeRequired) + return + } + key := strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key")) + if key == "" { + http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + conn, rw, err := hj.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + accept := computeWebSocketAccept(key) + _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + _, _ = rw.WriteString("Upgrade: websocket\r\n") + _, _ = rw.WriteString("Connection: Upgrade\r\n") + _, _ = rw.WriteString("Sec-WebSocket-Accept: " + accept + "\r\n\r\n") + _ = rw.Flush() + + ch, unsubscribe := m.meter.Subscribe() + defer unsubscribe() + defer conn.Close() + + _ = conn.SetDeadline(time.Time{}) + for snap := range ch { + payload, err := json.Marshal(snap) + if err != nil { + return + } + if err := writeWebSocketTextFrame(conn, payload); err != nil { + return + } + } +} + +func headerContainsToken(h http.Header, key, token string) bool { + for _, v := range h.Values(key) { + parts := strings.Split(v, ",") + for _, part := range parts { + if strings.EqualFold(strings.TrimSpace(part), token) { + return true + } + } + } + return false +} + +func computeWebSocketAccept(key string) string { + const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + sum := sha1.Sum([]byte(key + magic)) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +func writeWebSocketTextFrame(conn net.Conn, payload []byte) error { + bw := bufio.NewWriter(conn) + header := []byte{0x81} + switch { + case len(payload) < 126: + header = append(header, byte(len(payload))) + case len(payload) <= 65535: + header = append(header, 126, byte(len(payload)>>8), byte(len(payload))) + default: + header = append(header, 127, + byte(uint64(len(payload))>>56), byte(uint64(len(payload))>>48), byte(uint64(len(payload))>>40), byte(uint64(len(payload))>>32), + byte(uint64(len(payload))>>24), byte(uint64(len(payload))>>16), byte(uint64(len(payload))>>8), byte(uint64(len(payload))), + ) + } + if _, err := bw.Write(header); err != nil { + return err + } + if _, err := bw.Write(payload); err != nil { + return err + } + return bw.Flush() +} + +const meterIndexHTML = ` + + + + + aoiprxkit meter + + + +

aoiprxkit live meter

+
waiting for frames…
+
+ + +` diff --git a/aoiprxkit/nmos/is05.go b/aoiprxkit/nmos/is05.go new file mode 100644 index 0000000..2de600e --- /dev/null +++ b/aoiprxkit/nmos/is05.go @@ -0,0 +1,62 @@ +package nmos + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type ConnectionClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewConnectionClient(baseURL string) *ConnectionClient { + return &ConnectionClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *ConnectionClient) StageReceiver(ctx context.Context, receiverID string, reqBody StagedReceiverRequest) error { + body, err := json.Marshal(reqBody) + if err != nil { + return err + } + url := fmt.Sprintf("%s/x-nmos/connection/v1.1/receivers/%s/staged", c.BaseURL, receiverID) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("NMOS IS-05 stage receiver returned %s", resp.Status) + } + return nil +} + +func BuildRTPReceiverStagedRequest(senderID *string, sdp string) StagedReceiverRequest { + transportFile := map[string]string{ + "data": sdp, + "type": "application/sdp", + } + return StagedReceiverRequest{ + MasterEnable: true, + Activation: Activation{ + Mode: "activate_immediate", + }, + SenderID: senderID, + TransportFile: transportFile, + } +} diff --git a/aoiprxkit/nmos/models.go b/aoiprxkit/nmos/models.go new file mode 100644 index 0000000..6c9adde --- /dev/null +++ b/aoiprxkit/nmos/models.go @@ -0,0 +1,39 @@ +package nmos + +type Resource struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` +} + +type Sender struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + Transport string `json:"transport,omitempty"` + DeviceID string `json:"device_id,omitempty"` + ManifestURL string `json:"manifest_href,omitempty"` + Subscription any `json:"subscription,omitempty"` + InterfaceBinds []string `json:"interface_bindings,omitempty"` +} + +type Receiver struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Transport string `json:"transport,omitempty"` + Format string `json:"format,omitempty"` +} + +type Activation struct { + Mode string `json:"mode"` + RequestedTime string `json:"requested_time,omitempty"` +} + +type StagedReceiverRequest struct { + MasterEnable bool `json:"master_enable"` + Activation Activation `json:"activation"` + SenderID *string `json:"sender_id,omitempty"` + TransportFile map[string]string `json:"transport_file,omitempty"` + TransportParams []map[string]any `json:"transport_params,omitempty"` +} diff --git a/aoiprxkit/nmos/query.go b/aoiprxkit/nmos/query.go new file mode 100644 index 0000000..6c6a8e9 --- /dev/null +++ b/aoiprxkit/nmos/query.go @@ -0,0 +1,56 @@ +package nmos + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type QueryClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewQueryClient(baseURL string) *QueryClient { + return &QueryClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *QueryClient) GetSenders(ctx context.Context) ([]Sender, error) { + var out []Sender + if err := c.getJSON(ctx, "/x-nmos/query/v1.3/senders", &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *QueryClient) GetReceivers(ctx context.Context) ([]Receiver, error) { + var out []Receiver + if err := c.getJSON(ctx, "/x-nmos/query/v1.3/receivers", &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *QueryClient) getJSON(ctx context.Context, path string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("NMOS query %s returned %s", path, resp.Status) + } + return json.NewDecoder(resp.Body).Decode(target) +} diff --git a/aoiprxkit/pcm.go b/aoiprxkit/pcm.go new file mode 100644 index 0000000..cf8ccda --- /dev/null +++ b/aoiprxkit/pcm.go @@ -0,0 +1,50 @@ +package aoiprxkit + +import ( + "encoding/binary" + "fmt" +) + +// DecodeL24BE decodes signed 24-bit big-endian PCM into int32 samples sign-extended to 32 bits. +func DecodeL24BE(payload []byte, channels int) ([]int32, error) { + if channels < 1 { + return nil, fmt.Errorf("invalid channels: %d", channels) + } + if len(payload)%3 != 0 { + return nil, fmt.Errorf("payload length %d is not divisible by 3", len(payload)) + } + totalSamples := len(payload) / 3 + if totalSamples%channels != 0 { + return nil, fmt.Errorf("payload sample count %d is not divisible by channels %d", totalSamples, channels) + } + out := make([]int32, totalSamples) + j := 0 + for i := 0; i < len(payload); i += 3 { + v := int32(payload[i])<<16 | int32(payload[i+1])<<8 | int32(payload[i+2]) + if v&0x800000 != 0 { + v |= ^int32(0xFFFFFF) + } + out[j] = v + j++ + } + return out, nil +} + +// DecodeS32LE decodes signed 32-bit little-endian PCM into int32 samples. +func DecodeS32LE(payload []byte, channels int) ([]int32, error) { + if channels < 1 { + return nil, fmt.Errorf("invalid channels: %d", channels) + } + if len(payload)%4 != 0 { + return nil, fmt.Errorf("payload length %d is not divisible by 4", len(payload)) + } + totalSamples := len(payload) / 4 + if totalSamples%channels != 0 { + return nil, fmt.Errorf("payload sample count %d is not divisible by channels %d", totalSamples, channels) + } + out := make([]int32, totalSamples) + for i := 0; i < totalSamples; i++ { + out[i] = int32(binary.LittleEndian.Uint32(payload[i*4 : i*4+4])) + } + return out, nil +} diff --git a/aoiprxkit/pcm_test.go b/aoiprxkit/pcm_test.go new file mode 100644 index 0000000..431b99c --- /dev/null +++ b/aoiprxkit/pcm_test.go @@ -0,0 +1,39 @@ +package aoiprxkit + +import "testing" + +func TestDecodeL24BE(t *testing.T) { + payload := []byte{ + 0x7f, 0xff, 0xff, + 0x80, 0x00, 0x00, + 0x00, 0x00, 0x01, + 0xff, 0xff, 0xff, + } + got, err := DecodeL24BE(payload, 2) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + want := []int32{8388607, -8388608, 1, -1} + if len(got) != len(want) { + t.Fatalf("len mismatch: got=%d want=%d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("sample %d mismatch: got=%d want=%d", i, got[i], want[i]) + } + } +} + +func TestDecodeS32LE(t *testing.T) { + payload := []byte{ + 0x01, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, + } + got, err := DecodeS32LE(payload, 1) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 2 || got[0] != 1 || got[1] != -1 { + t.Fatalf("unexpected samples: %+v", got) + } +} diff --git a/aoiprxkit/receiver.go b/aoiprxkit/receiver.go new file mode 100644 index 0000000..7ea6a52 --- /dev/null +++ b/aoiprxkit/receiver.go @@ -0,0 +1,194 @@ +package aoiprxkit + +import ( + "context" + "fmt" + "net" + "sync" + "time" +) + +type PCMFrame struct { + SequenceNumber uint16 + Timestamp uint32 + SampleRateHz int + Channels int + Samples []int32 // interleaved + ReceivedAt time.Time + Source string +} + +type FrameHandler func(frame PCMFrame) + +type Receiver struct { + cfg Config + onFrame FrameHandler + + mu sync.Mutex + conn *net.UDPConn + cancel context.CancelFunc + done chan struct{} + doneOnce sync.Once + stats statsAtomic +} + +func NewReceiver(cfg Config, onFrame FrameHandler) (*Receiver, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if onFrame == nil { + return nil, fmt.Errorf("onFrame must not be nil") + } + return &Receiver{ + cfg: cfg, + onFrame: onFrame, + done: make(chan struct{}), + }, nil +} + +func (r *Receiver) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.conn != nil { + return fmt.Errorf("receiver already started") + } + + group := net.ParseIP(r.cfg.MulticastGroup) + ifi, err := resolveMulticastInterface(r.cfg.InterfaceName) + if err != nil { + return err + } + + addr := &net.UDPAddr{IP: group, Port: r.cfg.Port} + conn, err := net.ListenMulticastUDP("udp4", ifi, addr) + if err != nil { + return fmt.Errorf("listen multicast UDP: %w", err) + } + if r.cfg.ReadBufferBytes > 0 { + _ = conn.SetReadBuffer(r.cfg.ReadBufferBytes) + } + + cctx, cancel := context.WithCancel(ctx) + r.conn = conn + r.cancel = cancel + r.done = make(chan struct{}) + r.doneOnce = sync.Once{} + go r.loop(cctx) + return nil +} + +func (r *Receiver) Stop() error { + r.mu.Lock() + if r.conn == nil { + r.mu.Unlock() + return nil + } + conn := r.conn + cancel := r.cancel + done := r.done + r.conn = nil + r.cancel = nil + r.mu.Unlock() + + if cancel != nil { + cancel() + } + _ = conn.Close() + <-done + return nil +} + +func (r *Receiver) Stats() Stats { + return r.stats.snapshot() +} + +func (r *Receiver) loop(ctx context.Context) { + defer r.doneOnce.Do(func() { close(r.done) }) + + jb := newJitterBuffer(r.cfg.JitterDepthPackets) + buf := make([]byte, 64*1024) + + for { + select { + case <-ctx.Done(): + return + default: + } + + _ = r.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, _, err := r.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + return + } + + r.stats.packetsReceived.Add(1) + if n < 12 { + r.stats.packetsShort.Add(1) + continue + } + + pkt, err := ParseRTPPacket(buf[:n]) + if err != nil { + r.stats.packetsShort.Add(1) + continue + } + r.stats.packetsParsed.Add(1) + + if pkt.PayloadType != r.cfg.PayloadType { + r.stats.packetsWrongPT.Add(1) + continue + } + + ready, lateDrop, gapLoss, reorder := jb.push(pkt) + if lateDrop { + r.stats.packetsLateDrop.Add(1) + continue + } + if gapLoss > 0 { + r.stats.packetsGapLoss.Add(gapLoss) + } + if reorder { + r.stats.jitterReorders.Add(1) + } + + for _, rp := range ready { + samples, err := DecodeL24BE(rp.Payload, r.cfg.Channels) + if err != nil { + r.stats.decodeErrors.Add(1) + continue + } + frame := PCMFrame{ + SequenceNumber: rp.SequenceNumber, + Timestamp: rp.Timestamp, + SampleRateHz: r.cfg.SampleRateHz, + Channels: r.cfg.Channels, + Samples: samples, + ReceivedAt: time.Now(), + Source: fmt.Sprintf("rtp://%s:%d", r.cfg.MulticastGroup, r.cfg.Port), + } + r.onFrame(frame) + r.stats.packetsDelivered.Add(1) + r.stats.samplesDelivered.Add(uint64(len(samples))) + if r.cfg.Channels > 0 { + r.stats.framesDelivered.Add(uint64(len(samples) / r.cfg.Channels)) + } + r.stats.lastSequence.Store(uint32(rp.SequenceNumber)) + r.stats.sequenceValid.Store(1) + } + } +} + +func resolveMulticastInterface(name string) (*net.Interface, error) { + if name == "" { + return nil, nil + } + ifi, err := net.InterfaceByName(name) + if err != nil { + return nil, fmt.Errorf("resolve interface %q: %w", name, err) + } + return ifi, nil +} diff --git a/aoiprxkit/rtp.go b/aoiprxkit/rtp.go new file mode 100644 index 0000000..b2dc104 --- /dev/null +++ b/aoiprxkit/rtp.go @@ -0,0 +1,68 @@ +package aoiprxkit + +import ( + "encoding/binary" + "errors" +) + +type RTPPacket struct { + Version uint8 + Padding bool + Extension bool + CSRCCount uint8 + Marker bool + PayloadType uint8 + SequenceNumber uint16 + Timestamp uint32 + SSRC uint32 + Payload []byte +} + +func ParseRTPPacket(buf []byte) (RTPPacket, error) { + if len(buf) < 12 { + return RTPPacket{}, errors.New("RTP packet too short") + } + b0 := buf[0] + b1 := buf[1] + p := RTPPacket{ + Version: b0 >> 6, + Padding: (b0 & 0x20) != 0, + Extension: (b0 & 0x10) != 0, + CSRCCount: b0 & 0x0F, + Marker: (b1 & 0x80) != 0, + PayloadType: b1 & 0x7F, + SequenceNumber: binary.BigEndian.Uint16(buf[2:4]), + Timestamp: binary.BigEndian.Uint32(buf[4:8]), + SSRC: binary.BigEndian.Uint32(buf[8:12]), + } + if p.Version != 2 { + return RTPPacket{}, errors.New("unsupported RTP version") + } + headerLen := 12 + int(p.CSRCCount)*4 + if len(buf) < headerLen { + return RTPPacket{}, errors.New("RTP packet too short for CSRC list") + } + if p.Extension { + if len(buf) < headerLen+4 { + return RTPPacket{}, errors.New("RTP packet too short for extension") + } + extLenWords := int(binary.BigEndian.Uint16(buf[headerLen+2 : headerLen+4])) + headerLen += 4 + extLenWords*4 + if len(buf) < headerLen { + return RTPPacket{}, errors.New("RTP packet too short for full extension") + } + } + payload := buf[headerLen:] + if p.Padding { + if len(payload) == 0 { + return RTPPacket{}, errors.New("RTP packet has invalid padding") + } + padLen := int(payload[len(payload)-1]) + if padLen <= 0 || padLen > len(payload) { + return RTPPacket{}, errors.New("RTP packet has invalid pad length") + } + payload = payload[:len(payload)-padLen] + } + p.Payload = payload + return p, nil +} diff --git a/aoiprxkit/rtp_test.go b/aoiprxkit/rtp_test.go new file mode 100644 index 0000000..9d12778 --- /dev/null +++ b/aoiprxkit/rtp_test.go @@ -0,0 +1,22 @@ +package aoiprxkit + +import "testing" + +func TestParseRTPPacket(t *testing.T) { + buf := []byte{ + 0x80, 0x61, 0x12, 0x34, + 0x00, 0x00, 0x00, 0x05, + 0x11, 0x22, 0x33, 0x44, + 0x01, 0x02, 0x03, + } + p, err := ParseRTPPacket(buf) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if p.Version != 2 || p.PayloadType != 97 || p.SequenceNumber != 0x1234 || p.Timestamp != 5 || p.SSRC != 0x11223344 { + t.Fatalf("unexpected packet: %+v", p) + } + if len(p.Payload) != 3 || p.Payload[0] != 1 || p.Payload[2] != 3 { + t.Fatalf("unexpected payload: %v", p.Payload) + } +} diff --git a/aoiprxkit/sap.go b/aoiprxkit/sap.go new file mode 100644 index 0000000..2a533fb --- /dev/null +++ b/aoiprxkit/sap.go @@ -0,0 +1,115 @@ +package aoiprxkit + +import ( + "encoding/binary" + "fmt" + "net" +) + +const ( + DefaultSAPGroup = "224.2.127.254" + DefaultSAPPort = 9875 +) + +type SAPPacket struct { + Version uint8 + AddressTypeIPv6 bool + IsDelete bool + Encrypted bool + Compressed bool + AuthLenWords uint8 + MessageIDHash uint16 + OriginSource net.IP + PayloadType string + Payload []byte +} + +type SAPAnnouncement struct { + ReceivedAt string `json:"receivedAt"` + SourceAddr string `json:"sourceAddr"` + MessageID uint16 `json:"messageIdHash"` + Delete bool `json:"delete"` + PayloadType string `json:"payloadType"` + SDP string `json:"sdp"` + ParsedSDP SDPInfo `json:"parsedSdp"` +} + +func ParseSAPPacket(buf []byte) (SAPPacket, error) { + if len(buf) < 8 { + return SAPPacket{}, fmt.Errorf("SAP packet too short") + } + + b0 := buf[0] + version := b0 >> 5 + if version != 1 { + return SAPPacket{}, fmt.Errorf("unsupported SAP version %d", version) + } + + addrTypeIPv6 := (b0 & 0x10) != 0 + isDelete := (b0 & 0x04) != 0 + encrypted := (b0 & 0x02) != 0 + compressed := (b0 & 0x01) != 0 + authLenWords := buf[1] + msgID := binary.BigEndian.Uint16(buf[2:4]) + + hdrLen := 4 + var origin net.IP + if addrTypeIPv6 { + if len(buf) < hdrLen+16 { + return SAPPacket{}, fmt.Errorf("SAP packet too short for IPv6 source") + } + origin = net.IP(buf[hdrLen : hdrLen+16]) + hdrLen += 16 + } else { + if len(buf) < hdrLen+4 { + return SAPPacket{}, fmt.Errorf("SAP packet too short for IPv4 source") + } + origin = net.IP(buf[hdrLen : hdrLen+4]) + hdrLen += 4 + } + + authBytes := int(authLenWords) * 4 + if len(buf) < hdrLen+authBytes { + return SAPPacket{}, fmt.Errorf("SAP packet too short for auth section") + } + hdrLen += authBytes + + if encrypted || compressed { + return SAPPacket{}, fmt.Errorf("encrypted/compressed SAP payloads are not supported") + } + + payloadType := "application/sdp" + payloadStart := hdrLen + + if len(buf) > payloadStart && !(len(buf)-payloadStart >= 4 && string(buf[payloadStart:payloadStart+4]) == "v=0\n" || len(buf)-payloadStart >= 5 && string(buf[payloadStart:payloadStart+5]) == "v=0\r\n") { + nul := -1 + for i := payloadStart; i < len(buf); i++ { + if buf[i] == 0 { + nul = i + break + } + } + if nul == -1 { + return SAPPacket{}, fmt.Errorf("SAP payload type missing NUL terminator") + } + payloadType = string(buf[payloadStart:nul]) + payloadStart = nul + 1 + } + + if payloadStart > len(buf) { + return SAPPacket{}, fmt.Errorf("invalid SAP payload start") + } + + return SAPPacket{ + Version: version, + AddressTypeIPv6: addrTypeIPv6, + IsDelete: isDelete, + Encrypted: encrypted, + Compressed: compressed, + AuthLenWords: authLenWords, + MessageIDHash: msgID, + OriginSource: origin, + PayloadType: payloadType, + Payload: append([]byte(nil), buf[payloadStart:]...), + }, nil +} diff --git a/aoiprxkit/sap_listener.go b/aoiprxkit/sap_listener.go new file mode 100644 index 0000000..6afe96a --- /dev/null +++ b/aoiprxkit/sap_listener.go @@ -0,0 +1,150 @@ +package aoiprxkit + +import ( + "context" + "fmt" + "net" + "sync" + "time" +) + +type SAPListenerConfig struct { + Group string + Port int + InterfaceName string + ReadBuffer int +} + +func DefaultSAPListenerConfig() SAPListenerConfig { + return SAPListenerConfig{ + Group: DefaultSAPGroup, + Port: DefaultSAPPort, + ReadBuffer: 1 << 20, + } +} + +type SAPHandler func(announcement SAPAnnouncement) + +type SAPListener struct { + cfg SAPListenerConfig + onPacket SAPHandler + + mu sync.Mutex + conn *net.UDPConn + cancel context.CancelFunc + done chan struct{} + doneOnce sync.Once +} + +func NewSAPListener(cfg SAPListenerConfig, onPacket SAPHandler) (*SAPListener, error) { + if cfg.Group == "" { + cfg.Group = DefaultSAPGroup + } + if cfg.Port == 0 { + cfg.Port = DefaultSAPPort + } + if onPacket == nil { + return nil, fmt.Errorf("onPacket must not be nil") + } + if net.ParseIP(cfg.Group) == nil { + return nil, fmt.Errorf("invalid SAP group: %q", cfg.Group) + } + return &SAPListener{ + cfg: cfg, + onPacket: onPacket, + done: make(chan struct{}), + }, nil +} + +func (l *SAPListener) Start(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.conn != nil { + return fmt.Errorf("SAP listener already started") + } + + ifi, err := resolveMulticastInterface(l.cfg.InterfaceName) + if err != nil { + return err + } + group := net.ParseIP(l.cfg.Group) + addr := &net.UDPAddr{IP: group, Port: l.cfg.Port} + conn, err := net.ListenMulticastUDP("udp4", ifi, addr) + if err != nil { + return fmt.Errorf("listen SAP multicast UDP: %w", err) + } + if l.cfg.ReadBuffer > 0 { + _ = conn.SetReadBuffer(l.cfg.ReadBuffer) + } + + cctx, cancel := context.WithCancel(ctx) + l.conn = conn + l.cancel = cancel + l.done = make(chan struct{}) + l.doneOnce = sync.Once{} + go l.loop(cctx) + return nil +} + +func (l *SAPListener) Stop() error { + l.mu.Lock() + if l.conn == nil { + l.mu.Unlock() + return nil + } + conn := l.conn + cancel := l.cancel + done := l.done + l.conn = nil + l.cancel = nil + l.mu.Unlock() + + if cancel != nil { + cancel() + } + _ = conn.Close() + <-done + return nil +} + +func (l *SAPListener) loop(ctx context.Context) { + defer l.doneOnce.Do(func() { close(l.done) }) + + buf := make([]byte, 64*1024) + for { + select { + case <-ctx.Done(): + return + default: + } + _ = l.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, src, err := l.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + return + } + pkt, err := ParseSAPPacket(buf[:n]) + if err != nil { + continue + } + if pkt.PayloadType != "application/sdp" { + continue + } + sdp := string(pkt.Payload) + info, err := ParseMinimalSDP(sdp) + if err != nil { + continue + } + l.onPacket(SAPAnnouncement{ + ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), + SourceAddr: src.String(), + MessageID: pkt.MessageIDHash, + Delete: pkt.IsDelete, + PayloadType: pkt.PayloadType, + SDP: sdp, + ParsedSDP: info, + }) + } +} diff --git a/aoiprxkit/sap_test.go b/aoiprxkit/sap_test.go new file mode 100644 index 0000000..534ab26 --- /dev/null +++ b/aoiprxkit/sap_test.go @@ -0,0 +1,28 @@ +package aoiprxkit + +import "testing" + +func TestParseSAPPacket(t *testing.T) { + payload := []byte("application/sdp\x00v=0\n" + + "o=- 1 1 IN IP4 192.168.1.10\n" + + "s=Test\n" + + "c=IN IP4 239.69.0.1/32\n" + + "t=0 0\n" + + "m=audio 5004 RTP/AVP 97\n" + + "a=rtpmap:97 L24/48000/2\n") + pkt := []byte{ + 0x20, // V=1, IPv4, announce, no enc/compress + 0x00, // auth len + 0x12, 0x34, + 192, 168, 1, 50, + } + pkt = append(pkt, payload...) + + got, err := ParseSAPPacket(pkt) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got.Version != 1 || got.MessageIDHash != 0x1234 || got.PayloadType != "application/sdp" || got.OriginSource.String() != "192.168.1.50" { + t.Fatalf("unexpected SAP packet: %+v", got) + } +} diff --git a/aoiprxkit/sdp.go b/aoiprxkit/sdp.go new file mode 100644 index 0000000..95f83f9 --- /dev/null +++ b/aoiprxkit/sdp.go @@ -0,0 +1,116 @@ +package aoiprxkit + +import ( + "fmt" + "net" + "strconv" + "strings" + "time" +) + +type SDPInfo struct { + SessionName string + Origin string + MulticastGroup string + Port int + PayloadType uint8 + Encoding string + SampleRateHz int + Channels int + PacketTimeMS int +} + +// ParseMinimalSDP extracts the multicast address, port and one rtpmap line. +// It is deliberately small and not a full SDP parser. +func ParseMinimalSDP(s string) (SDPInfo, error) { + var out SDPInfo + lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") + for _, raw := range lines { + line := strings.TrimSpace(raw) + switch { + case strings.HasPrefix(line, "s="): + out.SessionName = strings.TrimPrefix(line, "s=") + + case strings.HasPrefix(line, "o="): + out.Origin = strings.TrimPrefix(line, "o=") + + case strings.HasPrefix(line, "c=IN IP4 "): + rest := strings.TrimPrefix(line, "c=IN IP4 ") + host := strings.Split(rest, "/")[0] + if net.ParseIP(host) == nil { + return out, fmt.Errorf("invalid multicast host in c=: %q", host) + } + out.MulticastGroup = host + + case strings.HasPrefix(line, "m=audio "): + fields := strings.Fields(line) + if len(fields) < 4 { + return out, fmt.Errorf("invalid m=audio line") + } + port, err := strconv.Atoi(fields[1]) + if err != nil { + return out, fmt.Errorf("invalid audio port: %w", err) + } + pt, err := strconv.Atoi(fields[3]) + if err != nil { + return out, fmt.Errorf("invalid payload type: %w", err) + } + out.Port = port + out.PayloadType = uint8(pt) + + case strings.HasPrefix(line, "a=rtpmap:"): + rest := strings.TrimPrefix(line, "a=rtpmap:") + parts := strings.Fields(rest) + if len(parts) != 2 { + return out, fmt.Errorf("invalid rtpmap line") + } + pt, err := strconv.Atoi(parts[0]) + if err != nil { + return out, fmt.Errorf("invalid rtpmap payload type: %w", err) + } + codecParts := strings.Split(parts[1], "/") + if len(codecParts) < 2 { + return out, fmt.Errorf("invalid rtpmap codec tuple") + } + sr, err := strconv.Atoi(codecParts[1]) + if err != nil { + return out, fmt.Errorf("invalid rtpmap sample rate: %w", err) + } + ch := 1 + if len(codecParts) >= 3 { + ch, err = strconv.Atoi(codecParts[2]) + if err != nil { + return out, fmt.Errorf("invalid rtpmap channel count: %w", err) + } + } + out.PayloadType = uint8(pt) + out.Encoding = codecParts[0] + out.SampleRateHz = sr + out.Channels = ch + + case strings.HasPrefix(line, "a=ptime:"): + ms, err := strconv.Atoi(strings.TrimPrefix(line, "a=ptime:")) + if err == nil { + out.PacketTimeMS = ms + } + } + } + if out.MulticastGroup == "" || out.Port == 0 || out.Encoding == "" || out.SampleRateHz == 0 { + return out, fmt.Errorf("incomplete SDP: %+v", out) + } + return out, nil +} + +func ConfigFromSDP(base Config, info SDPInfo) (Config, error) { + cfg := base + cfg.MulticastGroup = info.MulticastGroup + cfg.Port = info.Port + cfg.PayloadType = info.PayloadType + cfg.SampleRateHz = info.SampleRateHz + cfg.Channels = info.Channels + cfg.Encoding = info.Encoding + if info.PacketTimeMS > 0 { + cfg.PacketTime = time.Duration(info.PacketTimeMS) * time.Millisecond + } + return cfg, cfg.Validate() +} diff --git a/aoiprxkit/sdp_test.go b/aoiprxkit/sdp_test.go new file mode 100644 index 0000000..52379bf --- /dev/null +++ b/aoiprxkit/sdp_test.go @@ -0,0 +1,22 @@ +package aoiprxkit + +import "testing" + +func TestParseMinimalSDP(t *testing.T) { + sdp := `v=0 +o=- 1 1 IN IP4 192.168.1.10 +s=Test +c=IN IP4 239.69.0.1/32 +t=0 0 +m=audio 5004 RTP/AVP 97 +a=rtpmap:97 L24/48000/2 +a=ptime:1 +` + got, err := ParseMinimalSDP(sdp) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got.MulticastGroup != "239.69.0.1" || got.Port != 5004 || got.PayloadType != 97 || got.Encoding != "L24" || got.SampleRateHz != 48000 || got.Channels != 2 || got.PacketTimeMS != 1 { + t.Fatalf("unexpected parsed SDP: %+v", got) + } +} diff --git a/aoiprxkit/srt.go b/aoiprxkit/srt.go new file mode 100644 index 0000000..eec9c52 --- /dev/null +++ b/aoiprxkit/srt.go @@ -0,0 +1,70 @@ +package aoiprxkit + +import ( + "context" + "fmt" + "io" +) + +type SRTConfig struct { + URL string + Mode string + SampleRateHz int + Channels int + SourceLabel string +} + +func DefaultSRTConfig() SRTConfig { + return SRTConfig{ + SampleRateHz: 48000, + Channels: 2, + Mode: "listener", + } +} + +func (c SRTConfig) Validate() error { + if c.URL == "" { + return fmt.Errorf("SRT URL must not be empty") + } + if c.SampleRateHz <= 0 { + return fmt.Errorf("SampleRateHz must be > 0") + } + if c.Channels < 1 || c.Channels > 2 { + return fmt.Errorf("Channels must be 1 or 2") + } + return nil +} + +type SRTConnOpener func(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error) + +type SRTReceiver struct { + cfg SRTConfig + streamRx *StreamReceiver +} + +func NewSRTReceiver(cfg SRTConfig, onFrame FrameHandler) (*SRTReceiver, error) { + return NewSRTReceiverWithOpener(cfg, defaultSRTConnOpener, onFrame) +} + +func NewSRTReceiverWithOpener(cfg SRTConfig, opener SRTConnOpener, onFrame FrameHandler) (*SRTReceiver, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if opener == nil { + return nil, fmt.Errorf("SRT opener must not be nil") + } + src := cfg.SourceLabel + if src == "" { + src = cfg.URL + } + streamRx, err := NewStreamReceiver(StreamReceiverConfig{SourceLabel: src}, func(ctx context.Context) (io.ReadCloser, error) { + return opener(ctx, cfg) + }, onFrame) + if err != nil { + return nil, err + } + return &SRTReceiver{cfg: cfg, streamRx: streamRx}, nil +} + +func (r *SRTReceiver) Start(ctx context.Context) error { return r.streamRx.Start(ctx) } +func (r *SRTReceiver) Stop() error { return r.streamRx.Stop() } diff --git a/aoiprxkit/srt_gosrt.go.example b/aoiprxkit/srt_gosrt.go.example new file mode 100644 index 0000000..c40a1b8 --- /dev/null +++ b/aoiprxkit/srt_gosrt.go.example @@ -0,0 +1,13 @@ +// Example only. Rename to srt_gosrt.go in your target repo and wire it to github.com/datarhei/gosrt once that dependency is available. +//go:build gosrt + +package aoiprxkit + +// This file is intentionally left as a non-compiling example placeholder in the package zip. +// Reason: the current environment cannot fetch external Go modules, and the exact gosrt API +// should be verified against the version you vendor or pin in your target repository. +// +// Expected job of the real implementation: +// - parse cfg.URL +// - open a gosrt listener/caller depending on cfg.Mode +// - return an io.ReadCloser that yields framed PCM packets defined by stream_proto.go diff --git a/aoiprxkit/srt_profile.md b/aoiprxkit/srt_profile.md new file mode 100644 index 0000000..0a5dd5e --- /dev/null +++ b/aoiprxkit/srt_profile.md @@ -0,0 +1,13 @@ +# SRT framed-PCM profile + +This module now assumes a deliberately narrow WAN profile: + +- transport: SRT +- payload framing: custom framed stream defined in `stream_proto.go` +- codec today: PCM S32LE +- codec reserved for later: Opus + +Rationale: +- keep the Go stack small and deterministic +- avoid generic container/demux complexity +- make WAN ingest compatible with the same `PCMFrame` callback used by RTP/AES67-lite diff --git a/aoiprxkit/srt_stub.go b/aoiprxkit/srt_stub.go new file mode 100644 index 0000000..4fffbec --- /dev/null +++ b/aoiprxkit/srt_stub.go @@ -0,0 +1,15 @@ +//go:build !gosrt + +package aoiprxkit + +import ( + "context" + "fmt" + "io" +) + +func defaultSRTConnOpener(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error) { + _ = ctx + _ = cfg + return nil, fmt.Errorf("native SRT transport is not linked in this build: provide a custom opener or add a gosrt-backed opener in your target repo") +} diff --git a/aoiprxkit/srt_test.go b/aoiprxkit/srt_test.go new file mode 100644 index 0000000..3adda79 --- /dev/null +++ b/aoiprxkit/srt_test.go @@ -0,0 +1,58 @@ +package aoiprxkit + +import ( + "bytes" + "context" + "io" + "testing" + "time" +) + +type readCloser struct{ io.Reader } + +func (r readCloser) Close() error { return nil } + +func TestSRTReceiverWithCustomOpener(t *testing.T) { + var stream bytes.Buffer + samples := []int32{1, 2, 3, 4} + if err := WritePCM32Packet(&stream, 2, 48000, 2, 1, 480, samples); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + + got := make(chan PCMFrame, 1) + rx, err := NewSRTReceiverWithOpener(SRTConfig{ + URL: "srt://example:9000?mode=listener", + SampleRateHz: 48000, + Channels: 2, + }, func(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error) { + _ = ctx + _ = cfg + return readCloser{Reader: bytes.NewReader(stream.Bytes())}, nil + }, func(frame PCMFrame) { + select { + case got <- frame: + default: + } + }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } + if err := rx.Start(context.Background()); err != nil { + t.Fatalf("unexpected start error: %v", err) + } + defer rx.Stop() + + select { + case frame := <-got: + if len(frame.Samples) != len(samples) { + t.Fatalf("unexpected sample len: %d", len(frame.Samples)) + } + for i := range samples { + if frame.Samples[i] != samples[i] { + t.Fatalf("sample %d mismatch: got=%d want=%d", i, frame.Samples[i], samples[i]) + } + } + case <-time.After(500 * time.Millisecond): + t.Fatalf("timeout waiting for frame") + } +} diff --git a/aoiprxkit/stats.go b/aoiprxkit/stats.go new file mode 100644 index 0000000..4e0ff45 --- /dev/null +++ b/aoiprxkit/stats.go @@ -0,0 +1,53 @@ +package aoiprxkit + +import "sync/atomic" + +type Stats struct { + PacketsReceived uint64 `json:"packetsReceived"` + PacketsParsed uint64 `json:"packetsParsed"` + PacketsDelivered uint64 `json:"packetsDelivered"` + PacketsLateDrop uint64 `json:"packetsLateDrop"` + PacketsGapLoss uint64 `json:"packetsGapLoss"` + PacketsWrongPT uint64 `json:"packetsWrongPayloadType"` + PacketsShort uint64 `json:"packetsTooShort"` + JitterReorders uint64 `json:"jitterReorders"` + DecodeErrors uint64 `json:"decodeErrors"` + SamplesDelivered uint64 `json:"samplesDelivered"` + FramesDelivered uint64 `json:"framesDelivered"` + LastSequence uint32 `json:"lastSequence"` + SequenceValid uint32 `json:"sequenceValid"` +} + +type statsAtomic struct { + packetsReceived atomic.Uint64 + packetsParsed atomic.Uint64 + packetsDelivered atomic.Uint64 + packetsLateDrop atomic.Uint64 + packetsGapLoss atomic.Uint64 + packetsWrongPT atomic.Uint64 + packetsShort atomic.Uint64 + jitterReorders atomic.Uint64 + decodeErrors atomic.Uint64 + samplesDelivered atomic.Uint64 + framesDelivered atomic.Uint64 + lastSequence atomic.Uint32 + sequenceValid atomic.Uint32 +} + +func (s *statsAtomic) snapshot() Stats { + return Stats{ + PacketsReceived: s.packetsReceived.Load(), + PacketsParsed: s.packetsParsed.Load(), + PacketsDelivered: s.packetsDelivered.Load(), + PacketsLateDrop: s.packetsLateDrop.Load(), + PacketsGapLoss: s.packetsGapLoss.Load(), + PacketsWrongPT: s.packetsWrongPT.Load(), + PacketsShort: s.packetsShort.Load(), + JitterReorders: s.jitterReorders.Load(), + DecodeErrors: s.decodeErrors.Load(), + SamplesDelivered: s.samplesDelivered.Load(), + FramesDelivered: s.framesDelivered.Load(), + LastSequence: s.lastSequence.Load(), + SequenceValid: s.sequenceValid.Load(), + } +} diff --git a/aoiprxkit/stream_finder.go b/aoiprxkit/stream_finder.go new file mode 100644 index 0000000..af2f614 --- /dev/null +++ b/aoiprxkit/stream_finder.go @@ -0,0 +1,137 @@ +package aoiprxkit + +import ( + "context" + "fmt" + "sync" + "time" +) + +// StreamFinder keeps a live in-memory view of SAP/SDP announcements +// and can wait for sessions by their SDP "s=" session name. +type StreamFinder struct { + listener *SAPListener + + mu sync.Mutex + sessions map[string]SAPAnnouncement + waiters map[string][]chan SAPAnnouncement +} + +func NewStreamFinder(cfg SAPListenerConfig) (*StreamFinder, error) { + sf := &StreamFinder{ + sessions: make(map[string]SAPAnnouncement), + waiters: make(map[string][]chan SAPAnnouncement), + } + listener, err := NewSAPListener(cfg, sf.handleAnnouncement) + if err != nil { + return nil, err + } + sf.listener = listener + return sf, nil +} + +func (s *StreamFinder) Start(ctx context.Context) error { + return s.listener.Start(ctx) +} + +func (s *StreamFinder) Stop() error { + return s.listener.Stop() +} + +func (s *StreamFinder) handleAnnouncement(a SAPAnnouncement) { + name := a.ParsedSDP.SessionName + if name == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + if a.Delete { + delete(s.sessions, name) + return + } + + s.sessions[name] = a + if waiters := s.waiters[name]; len(waiters) > 0 { + delete(s.waiters, name) + for _, ch := range waiters { + select { + case ch <- a: + default: + } + close(ch) + } + } +} + +func (s *StreamFinder) FindByStreamName(name string) (SAPAnnouncement, bool) { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.sessions[name] + return a, ok +} + +func (s *StreamFinder) WaitForStreamName(ctx context.Context, name string) (SAPAnnouncement, error) { + if name == "" { + return SAPAnnouncement{}, fmt.Errorf("stream name must not be empty") + } + + s.mu.Lock() + if a, ok := s.sessions[name]; ok { + s.mu.Unlock() + return a, nil + } + ch := make(chan SAPAnnouncement, 1) + s.waiters[name] = append(s.waiters[name], ch) + s.mu.Unlock() + + select { + case <-ctx.Done(): + s.mu.Lock() + waiters := s.waiters[name] + kept := waiters[:0] + for _, w := range waiters { + if w != ch { + kept = append(kept, w) + } + } + if len(kept) == 0 { + delete(s.waiters, name) + } else { + s.waiters[name] = kept + } + s.mu.Unlock() + return SAPAnnouncement{}, ctx.Err() + case a := <-ch: + return a, nil + } +} + +func (s *StreamFinder) WaitConfigByStreamName(ctx context.Context, base Config, name string) (Config, SAPAnnouncement, error) { + a, err := s.WaitForStreamName(ctx, name) + if err != nil { + return Config{}, SAPAnnouncement{}, err + } + cfg, err := ConfigFromSDP(base, a.ParsedSDP) + if err != nil { + return Config{}, SAPAnnouncement{}, err + } + return cfg, a, nil +} + +func (s *StreamFinder) Snapshot() []SAPAnnouncement { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]SAPAnnouncement, 0, len(s.sessions)) + for _, v := range s.sessions { + out = append(out, v) + } + return out +} + +func (s *StreamFinder) WaitForStreamNameTimeout(name string, timeout time.Duration) (SAPAnnouncement, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return s.WaitForStreamName(ctx, name) +} diff --git a/aoiprxkit/stream_proto.go b/aoiprxkit/stream_proto.go new file mode 100644 index 0000000..271323e --- /dev/null +++ b/aoiprxkit/stream_proto.go @@ -0,0 +1,81 @@ +package aoiprxkit + +import ( + "encoding/binary" + "fmt" + "io" +) + +const ( + streamMagic = "ARX1" + + StreamCodecPCM_S32LE uint8 = 1 + StreamCodecOpus uint8 = 2 // reserved for later phases +) + +type StreamHeader struct { + Codec uint8 + Channels uint8 + SampleRateHz uint32 + FrameSamples uint32 + Sequence uint32 + Timestamp uint64 + PayloadBytes uint32 +} + +func ReadStreamHeader(r io.Reader) (StreamHeader, error) { + var raw [26]byte + if _, err := io.ReadFull(r, raw[:]); err != nil { + return StreamHeader{}, err + } + if string(raw[0:4]) != streamMagic { + return StreamHeader{}, fmt.Errorf("invalid stream magic %q", string(raw[0:4])) + } + h := StreamHeader{ + Codec: raw[4], + Channels: raw[5], + SampleRateHz: binary.BigEndian.Uint32(raw[6:10]), + FrameSamples: binary.BigEndian.Uint32(raw[10:14]), + Sequence: binary.BigEndian.Uint32(raw[14:18]), + Timestamp: binary.BigEndian.Uint64(raw[18:26]), + } + var payloadLenRaw [4]byte + if _, err := io.ReadFull(r, payloadLenRaw[:]); err != nil { + return StreamHeader{}, err + } + h.PayloadBytes = binary.BigEndian.Uint32(payloadLenRaw[:]) + return h, nil +} + +func WritePCM32Packet(w io.Writer, channels int, sampleRateHz int, frameSamples int, sequence uint32, timestamp uint64, samples []int32) error { + if channels < 1 || channels > 2 { + return fmt.Errorf("channels must be 1 or 2") + } + if frameSamples < 0 { + return fmt.Errorf("frameSamples must be >= 0") + } + if len(samples) != frameSamples*channels { + return fmt.Errorf("sample length mismatch: got=%d want=%d", len(samples), frameSamples*channels) + } + + payloadBytes := len(samples) * 4 + var hdr [30]byte + copy(hdr[0:4], []byte(streamMagic)) + hdr[4] = StreamCodecPCM_S32LE + hdr[5] = byte(channels) + binary.BigEndian.PutUint32(hdr[6:10], uint32(sampleRateHz)) + binary.BigEndian.PutUint32(hdr[10:14], uint32(frameSamples)) + binary.BigEndian.PutUint32(hdr[14:18], sequence) + binary.BigEndian.PutUint64(hdr[18:26], timestamp) + binary.BigEndian.PutUint32(hdr[26:30], uint32(payloadBytes)) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + + payload := make([]byte, payloadBytes) + for i, s := range samples { + binary.LittleEndian.PutUint32(payload[i*4:i*4+4], uint32(s)) + } + _, err := w.Write(payload) + return err +} diff --git a/aoiprxkit/stream_proto_test.go b/aoiprxkit/stream_proto_test.go new file mode 100644 index 0000000..6a1a8ef --- /dev/null +++ b/aoiprxkit/stream_proto_test.go @@ -0,0 +1,34 @@ +package aoiprxkit + +import ( + "bytes" + "testing" +) + +func TestWriteAndReadPCM32Packet(t *testing.T) { + var buf bytes.Buffer + samples := []int32{1, -1, 10, -10} + if err := WritePCM32Packet(&buf, 2, 48000, 2, 7, 1234, samples); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + hdr, err := ReadStreamHeader(&buf) + if err != nil { + t.Fatalf("unexpected read header error: %v", err) + } + if hdr.Codec != StreamCodecPCM_S32LE || hdr.Channels != 2 || hdr.SampleRateHz != 48000 || hdr.FrameSamples != 2 || hdr.Sequence != 7 || hdr.Timestamp != 1234 || hdr.PayloadBytes != 16 { + t.Fatalf("unexpected header: %+v", hdr) + } + payload := make([]byte, hdr.PayloadBytes) + if _, err := buf.Read(payload); err != nil { + t.Fatalf("unexpected payload read error: %v", err) + } + got, err := DecodeS32LE(payload, 2) + if err != nil { + t.Fatalf("unexpected decode error: %v", err) + } + for i := range samples { + if got[i] != samples[i] { + t.Fatalf("sample %d mismatch: got=%d want=%d", i, got[i], samples[i]) + } + } +} diff --git a/aoiprxkit/stream_receiver.go b/aoiprxkit/stream_receiver.go new file mode 100644 index 0000000..f9b5257 --- /dev/null +++ b/aoiprxkit/stream_receiver.go @@ -0,0 +1,114 @@ +package aoiprxkit + +import ( + "context" + "fmt" + "io" + "sync" + "time" +) + +type StreamReceiverConfig struct { + SourceLabel string +} + +type StreamReceiver struct { + cfg StreamReceiverConfig + opener func(context.Context) (io.ReadCloser, error) + onFrame FrameHandler + + mu sync.Mutex + rc io.ReadCloser + cancel context.CancelFunc + done chan struct{} +} + +func NewStreamReceiver(cfg StreamReceiverConfig, opener func(context.Context) (io.ReadCloser, error), onFrame FrameHandler) (*StreamReceiver, error) { + if opener == nil { + return nil, fmt.Errorf("opener must not be nil") + } + if onFrame == nil { + return nil, fmt.Errorf("onFrame must not be nil") + } + return &StreamReceiver{cfg: cfg, opener: opener, onFrame: onFrame, done: make(chan struct{})}, nil +} + +func (r *StreamReceiver) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.rc != nil { + return fmt.Errorf("stream receiver already started") + } + cctx, cancel := context.WithCancel(ctx) + rc, err := r.opener(cctx) + if err != nil { + cancel() + return err + } + r.rc = rc + r.cancel = cancel + r.done = make(chan struct{}) + go r.loop(cctx, rc) + return nil +} + +func (r *StreamReceiver) Stop() error { + r.mu.Lock() + rc := r.rc + cancel := r.cancel + done := r.done + r.rc = nil + r.cancel = nil + r.mu.Unlock() + + if cancel != nil { + cancel() + } + if rc != nil { + _ = rc.Close() + } + if done != nil { + <-done + } + return nil +} + +func (r *StreamReceiver) loop(ctx context.Context, rc io.ReadCloser) { + defer close(r.done) + for { + select { + case <-ctx.Done(): + return + default: + } + hdr, err := ReadStreamHeader(rc) + if err != nil { + return + } + payload := make([]byte, hdr.PayloadBytes) + if _, err := io.ReadFull(rc, payload); err != nil { + return + } + switch hdr.Codec { + case StreamCodecPCM_S32LE: + samples, err := DecodeS32LE(payload, int(hdr.Channels)) + if err != nil { + continue + } + r.onFrame(PCMFrame{ + SequenceNumber: uint16(hdr.Sequence & 0xffff), + Timestamp: uint32(hdr.Timestamp & 0xffffffff), + SampleRateHz: int(hdr.SampleRateHz), + Channels: int(hdr.Channels), + Samples: samples, + ReceivedAt: time.Now(), + Source: r.cfg.SourceLabel, + }) + case StreamCodecOpus: + // Reserved for later phase. Not decoded in this module revision. + continue + default: + continue + } + } +} diff --git a/aoiprxkit/stream_receiver_test.go b/aoiprxkit/stream_receiver_test.go new file mode 100644 index 0000000..f7fa087 --- /dev/null +++ b/aoiprxkit/stream_receiver_test.go @@ -0,0 +1,56 @@ +package aoiprxkit + +import ( + "bytes" + "context" + "io" + "testing" + "time" +) + +type nopCloser struct{ io.Reader } + +func (n nopCloser) Close() error { return nil } + +func TestStreamReceiverPCM(t *testing.T) { + var buf bytes.Buffer + samples := []int32{100, -100, 200, -200} + if err := WritePCM32Packet(&buf, 2, 48000, 2, 55, 999, samples); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + + got := make(chan PCMFrame, 1) + rx, err := NewStreamReceiver(StreamReceiverConfig{SourceLabel: "test-source"}, func(ctx context.Context) (io.ReadCloser, error) { + _ = ctx + return nopCloser{Reader: bytes.NewReader(buf.Bytes())}, nil + }, func(frame PCMFrame) { + select { + case got <- frame: + default: + } + }) + if err != nil { + t.Fatalf("unexpected constructor error: %v", err) + } + if err := rx.Start(context.Background()); err != nil { + t.Fatalf("unexpected start error: %v", err) + } + defer rx.Stop() + + select { + case frame := <-got: + if frame.SampleRateHz != 48000 || frame.Channels != 2 || frame.Source != "test-source" { + t.Fatalf("unexpected frame meta: %+v", frame) + } + if len(frame.Samples) != len(samples) { + t.Fatalf("unexpected sample len: %d", len(frame.Samples)) + } + for i := range samples { + if frame.Samples[i] != samples[i] { + t.Fatalf("sample %d mismatch: got=%d want=%d", i, frame.Samples[i], samples[i]) + } + } + case <-time.After(500 * time.Millisecond): + t.Fatalf("timeout waiting for frame") + } +} diff --git a/go.mod b/go.mod index d553bb4..3f45ac5 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,12 @@ go 1.22 require github.com/jan/fm-rds-tx/internal v0.0.0 require ( + aoiprxkit v0.0.0 // indirect github.com/hajimehoshi/go-mp3 v0.3.4 // indirect github.com/jfreymuth/oggvorbis v1.0.5 // indirect github.com/jfreymuth/vorbis v1.0.2 // indirect ) replace github.com/jan/fm-rds-tx/internal => ./internal + +replace aoiprxkit => ./aoiprxkit diff --git a/internal/config/config.go b/internal/config/config.go index 7a8b56b..eba5ecb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,6 +77,7 @@ type IngestConfig struct { Stdin IngestPCMConfig `json:"stdin"` HTTPRaw IngestPCMConfig `json:"httpRaw"` Icecast IngestIcecastConfig `json:"icecast"` + SRT IngestSRTConfig `json:"srt"` } type IngestReconnectConfig struct { @@ -104,6 +105,13 @@ type IngestIcecastRadioTextConfig struct { OnlyOnChange bool `json:"onlyOnChange"` } +type IngestSRTConfig struct { + URL string `json:"url"` + Mode string `json:"mode"` + SampleRateHz int `json:"sampleRateHz"` + Channels int `json:"channels"` +} + func Default() Config { return Config{ Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, @@ -152,6 +160,11 @@ func Default() Config { OnlyOnChange: true, }, }, + SRT: IngestSRTConfig{ + Mode: "listener", + SampleRateHz: 48000, + Channels: 2, + }, }, } } @@ -238,8 +251,9 @@ func (c Config) Validate() error { if c.Ingest.Kind == "" { c.Ingest.Kind = "none" } - switch strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) { - case "none", "stdin", "stdin-pcm", "http-raw", "icecast": + ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) + switch ingestKind { + case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt": default: return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) } @@ -270,9 +284,23 @@ func (c Config) Validate() error { if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" { return fmt.Errorf("ingest pcm format must be s16le") } - if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { + if ingestKind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") } + if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" { + return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt") + } + switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) { + case "", "listener", "caller", "rendezvous": + default: + return fmt.Errorf("ingest.srt.mode unsupported: %s", c.Ingest.SRT.Mode) + } + if c.Ingest.SRT.SampleRateHz <= 0 { + return fmt.Errorf("ingest.srt.sampleRateHz must be > 0") + } + if c.Ingest.SRT.Channels != 1 && c.Ingest.SRT.Channels != 2 { + return fmt.Errorf("ingest.srt.channels must be 1 or 2") + } switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) { case "", "auto", "native", "ffmpeg", "fallback": default: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index affdbb7..6002e54 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -132,6 +132,39 @@ func TestValidateRejectsUnsupportedIngestKind(t *testing.T) { } } +func TestValidateRejectsInvalidSRTConfig(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected srt url error") + } + + cfg = Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000" + cfg.Ingest.SRT.Mode = "invalid" + if err := cfg.Validate(); err == nil { + t.Fatal("expected srt mode error") + } + + cfg = Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000" + cfg.Ingest.SRT.SampleRateHz = 0 + if err := cfg.Validate(); err == nil { + t.Fatal("expected srt sample rate error") + } + + cfg = Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000" + cfg.Ingest.SRT.Channels = 3 + if err := cfg.Validate(); err == nil { + t.Fatal("expected srt channels error") + } +} + func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { cfg := Default() cfg.Ingest.Stdin.SampleRateHz = 0 diff --git a/internal/go.mod b/internal/go.mod index 89df427..350d8f2 100644 --- a/internal/go.mod +++ b/internal/go.mod @@ -1,10 +1,13 @@ module github.com/jan/fm-rds-tx/internal -go 1.21 +go 1.22 require ( + aoiprxkit v0.0.0 github.com/hajimehoshi/go-mp3 v0.3.4 github.com/jfreymuth/oggvorbis v1.0.5 ) require github.com/jfreymuth/vorbis v1.0.2 // indirect + +replace aoiprxkit => ../aoiprxkit diff --git a/internal/ingest/adapters/srt/source.go b/internal/ingest/adapters/srt/source.go new file mode 100644 index 0000000..327e1ae --- /dev/null +++ b/internal/ingest/adapters/srt/source.go @@ -0,0 +1,283 @@ +package srt + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + "aoiprxkit" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type Option func(*Source) + +func WithConnOpener(opener aoiprxkit.SRTConnOpener) Option { + return func(s *Source) { + if opener != nil { + s.opener = opener + } + } +} + +type Source struct { + id string + cfg aoiprxkit.SRTConfig + + opener aoiprxkit.SRTConnOpener + + chunks chan ingest.PCMChunk + errs chan error + + cancel context.CancelFunc + wg sync.WaitGroup + + mu sync.Mutex + rx *aoiprxkit.SRTReceiver + started atomic.Bool + closeOnce sync.Once + + state atomic.Value // string + connected atomic.Bool + chunksIn atomic.Uint64 + samplesIn atomic.Uint64 + overflows atomic.Uint64 + discontinuities atomic.Uint64 + transportLoss atomic.Uint64 + reorders atomic.Uint64 + lastChunkAtUnix atomic.Int64 + lastError atomic.Value // string + nextSeq atomic.Uint64 + + seqMu sync.Mutex + lastFrame uint16 + lastHasVal bool +} + +func New(id string, cfg aoiprxkit.SRTConfig, opts ...Option) *Source { + if id == "" { + id = "srt-main" + } + if cfg.Mode == "" { + cfg.Mode = "listener" + } + if cfg.SampleRateHz <= 0 { + cfg.SampleRateHz = 48000 + } + if cfg.Channels <= 0 { + cfg.Channels = 2 + } + + s := &Source{ + id: id, + cfg: cfg, + chunks: make(chan ingest.PCMChunk, 64), + errs: make(chan error, 8), + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + s.state.Store("idle") + s.lastError.Store("") + return s +} + +func (s *Source) Descriptor() ingest.SourceDescriptor { + return ingest.SourceDescriptor{ + ID: s.id, + Kind: "srt", + Family: "aoip", + Transport: "srt", + Codec: "pcm_s32le", + Channels: s.cfg.Channels, + SampleRateHz: s.cfg.SampleRateHz, + Detail: s.cfg.URL, + } +} + +func (s *Source) Start(ctx context.Context) error { + if !s.started.CompareAndSwap(false, true) { + return nil + } + + var ( + rx *aoiprxkit.SRTReceiver + err error + ) + if s.opener != nil { + rx, err = aoiprxkit.NewSRTReceiverWithOpener(s.cfg, s.opener, s.handleFrame) + } else { + rx, err = aoiprxkit.NewSRTReceiver(s.cfg, s.handleFrame) + } + if err != nil { + s.started.Store(false) + s.connected.Store(false) + s.state.Store("failed") + s.setError(err) + return err + } + + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.mu.Lock() + s.rx = rx + s.mu.Unlock() + s.lastError.Store("") + s.connected.Store(false) + s.state.Store("connecting") + + if err := rx.Start(runCtx); err != nil { + s.started.Store(false) + s.connected.Store(false) + s.state.Store("failed") + s.setError(err) + return err + } + s.connected.Store(true) + s.state.Store("running") + + s.wg.Add(1) + go func() { + defer s.wg.Done() + <-runCtx.Done() + _ = s.stopReceiver() + s.connected.Store(false) + s.closeChannels() + }() + return nil +} + +func (s *Source) Stop() error { + if !s.started.CompareAndSwap(true, false) { + return nil + } + if s.cancel != nil { + s.cancel() + } + if err := s.stopReceiver(); err != nil { + s.setError(err) + s.state.Store("failed") + } + s.wg.Wait() + s.connected.Store(false) + state, _ := s.state.Load().(string) + if state != "failed" { + s.state.Store("stopped") + } + return nil +} + +func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } +func (s *Source) Errors() <-chan error { return s.errs } + +func (s *Source) Stats() ingest.SourceStats { + state, _ := s.state.Load().(string) + last := s.lastChunkAtUnix.Load() + errStr, _ := s.lastError.Load().(string) + var lastChunkAt time.Time + if last > 0 { + lastChunkAt = time.Unix(0, last) + } + return ingest.SourceStats{ + State: state, + Connected: s.connected.Load(), + LastChunkAt: lastChunkAt, + ChunksIn: s.chunksIn.Load(), + SamplesIn: s.samplesIn.Load(), + Overflows: s.overflows.Load(), + Discontinuities: s.discontinuities.Load(), + TransportLoss: s.transportLoss.Load(), + Reorders: s.reorders.Load(), + LastError: errStr, + } +} + +func (s *Source) handleFrame(frame aoiprxkit.PCMFrame) { + if !s.started.Load() { + return + } + + discontinuity := false + s.seqMu.Lock() + if s.lastHasVal { + expected := s.lastFrame + 1 + if frame.SequenceNumber != expected { + discontinuity = true + delta := int16(frame.SequenceNumber - expected) + if delta > 0 { + s.transportLoss.Add(uint64(delta)) + } else { + s.reorders.Add(1) + } + } + } + s.lastFrame = frame.SequenceNumber + s.lastHasVal = true + s.seqMu.Unlock() + + chunk := ingest.PCMChunk{ + Samples: append([]int32(nil), frame.Samples...), + Channels: frame.Channels, + SampleRateHz: frame.SampleRateHz, + Sequence: s.nextSeq.Add(1) - 1, + Timestamp: frame.ReceivedAt, + SourceID: s.id, + Discontinuity: discontinuity, + } + + s.chunksIn.Add(1) + s.samplesIn.Add(uint64(len(chunk.Samples))) + s.lastChunkAtUnix.Store(time.Now().UnixNano()) + if discontinuity { + s.discontinuities.Add(1) + } + + select { + case s.chunks <- chunk: + default: + s.overflows.Add(1) + s.discontinuities.Add(1) + s.setError(io.ErrShortBuffer) + s.emitError(fmt.Errorf("srt chunk buffer overflow")) + } +} + +func (s *Source) stopReceiver() error { + s.mu.Lock() + rx := s.rx + s.rx = nil + s.mu.Unlock() + if rx == nil { + return nil + } + return rx.Stop() +} + +func (s *Source) closeChannels() { + s.closeOnce.Do(func() { + close(s.chunks) + close(s.errs) + }) +} + +func (s *Source) setError(err error) { + if err == nil { + return + } + s.lastError.Store(err.Error()) + s.emitError(err) +} + +func (s *Source) emitError(err error) { + if err == nil { + return + } + select { + case s.errs <- err: + default: + } +} diff --git a/internal/ingest/adapters/srt/source_test.go b/internal/ingest/adapters/srt/source_test.go new file mode 100644 index 0000000..a4527b1 --- /dev/null +++ b/internal/ingest/adapters/srt/source_test.go @@ -0,0 +1,109 @@ +package srt + +import ( + "bytes" + "context" + "io" + "testing" + "time" + + "aoiprxkit" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type readCloser struct{ io.Reader } + +func (r readCloser) Close() error { return nil } + +func TestSourceEmitsChunksFromSRTFrames(t *testing.T) { + var stream bytes.Buffer + if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 10, 100, []int32{1, 2, 3, 4}); err != nil { + t.Fatalf("write packet 1: %v", err) + } + if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 12, 200, []int32{5, 6, 7, 8}); err != nil { + t.Fatalf("write packet 2: %v", err) + } + + src := New("srt-test", aoiprxkit.SRTConfig{ + URL: "srt://127.0.0.1:9000?mode=listener", + Mode: "listener", + SampleRateHz: 48000, + Channels: 2, + }, WithConnOpener(func(ctx context.Context, cfg aoiprxkit.SRTConfig) (io.ReadCloser, error) { + _ = ctx + _ = cfg + return readCloser{Reader: bytes.NewReader(stream.Bytes())}, nil + })) + + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + chunk1 := readChunk(t, src.Chunks()) + if chunk1.SourceID != "srt-test" { + t.Fatalf("source id=%q want srt-test", chunk1.SourceID) + } + if chunk1.Channels != 2 || chunk1.SampleRateHz != 48000 { + t.Fatalf("shape=%d/%d", chunk1.Channels, chunk1.SampleRateHz) + } + if chunk1.Discontinuity { + t.Fatalf("first chunk should not be discontinuity") + } + assertSamples(t, chunk1.Samples, []int32{1, 2, 3, 4}) + + chunk2 := readChunk(t, src.Chunks()) + if !chunk2.Discontinuity { + t.Fatalf("second chunk should be marked discontinuity on seq gap") + } + assertSamples(t, chunk2.Samples, []int32{5, 6, 7, 8}) + + stats := src.Stats() + if stats.State != "running" { + t.Fatalf("state=%q want running", stats.State) + } + if !stats.Connected { + t.Fatalf("connected=false want true") + } + if stats.ChunksIn != 2 { + t.Fatalf("chunksIn=%d want 2", stats.ChunksIn) + } + if stats.SamplesIn != 8 { + t.Fatalf("samplesIn=%d want 8", stats.SamplesIn) + } + if stats.TransportLoss != 1 { + t.Fatalf("transportLoss=%d want 1", stats.TransportLoss) + } + if stats.Discontinuities < 1 { + t.Fatalf("discontinuities=%d want >=1", stats.Discontinuities) + } + if stats.LastChunkAt.IsZero() { + t.Fatalf("lastChunkAt should be set") + } +} + +func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk { + t.Helper() + select { + case chunk, ok := <-ch: + if !ok { + t.Fatal("chunk channel closed") + } + return chunk + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for chunk") + return ingest.PCMChunk{} + } +} + +func assertSamples(t *testing.T, got, want []int32) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("sample len=%d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("sample[%d]=%d want %d", i, got[i], want[i]) + } + } +} diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go index 5a46905..62146fa 100644 --- a/internal/ingest/factory/factory.go +++ b/internal/ingest/factory/factory.go @@ -7,16 +7,19 @@ import ( "os" "strings" + "aoiprxkit" "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/srt" "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" ) type Deps struct { - Stdin io.Reader - HTTP *http.Client + Stdin io.Reader + HTTP *http.Client + SRTOpener aoiprxkit.SRTConnOpener } type AudioIngress interface { @@ -50,6 +53,19 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err icecast.WithDecoderPreference(cfg.Ingest.Icecast.Decoder), ) return src, nil, nil + case "srt": + srtCfg := aoiprxkit.SRTConfig{ + URL: cfg.Ingest.SRT.URL, + Mode: cfg.Ingest.SRT.Mode, + SampleRateHz: cfg.Ingest.SRT.SampleRateHz, + Channels: cfg.Ingest.SRT.Channels, + } + opts := []srt.Option{} + if deps.SRTOpener != nil { + opts = append(opts, srt.WithConnOpener(deps.SRTOpener)) + } + src := srt.New("srt-main", srtCfg, opts...) + return src, nil, nil default: return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) } @@ -67,6 +83,10 @@ func SampleRateForKind(cfg config.Config) int { } case "icecast": return 44100 + case "srt": + if cfg.Ingest.SRT.SampleRateHz > 0 { + return cfg.Ingest.SRT.SampleRateHz + } } return 44100 } diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index f75a5e1..5b147cd 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -87,6 +87,29 @@ func TestBuildSourceIcecastUsesDecoderPreference(t *testing.T) { } } +func TestBuildSourceSRT(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000?mode=listener" + cfg.Ingest.SRT.Mode = "listener" + cfg.Ingest.SRT.SampleRateHz = 48000 + cfg.Ingest.SRT.Channels = 2 + + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source") + } + if ingress != nil { + t.Fatalf("expected no ingress for srt") + } + if got := src.Descriptor().Kind; got != "srt" { + t.Fatalf("source kind=%s", got) + } +} + func TestBuildSourceUnsupportedKind(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "nope" @@ -114,4 +137,10 @@ func TestSampleRateForKind(t *testing.T) { if got := SampleRateForKind(cfg); got != 44100 { t.Fatalf("icecast sample rate=%d", got) } + + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.SampleRateHz = 48000 + if got := SampleRateForKind(cfg); got != 48000 { + t.Fatalf("srt sample rate=%d", got) + } } diff --git a/internal/ingest/factory/ingest_smoke_test.go b/internal/ingest/factory/ingest_smoke_test.go index 1cecd3d..e9aed73 100644 --- a/internal/ingest/factory/ingest_smoke_test.go +++ b/internal/ingest/factory/ingest_smoke_test.go @@ -1,15 +1,22 @@ package factory import ( + "bytes" "context" + "io" "testing" "time" + "aoiprxkit" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" ) +type streamReadCloser struct{ io.Reader } + +func (r streamReadCloser) Close() error { return nil } + func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "http-raw" @@ -63,6 +70,56 @@ func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { } } +func TestSRTFactoryToRuntimeSmoke(t *testing.T) { + var stream bytes.Buffer + if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 1, 480, []int32{11, -11, 22, -22}); err != nil { + t.Fatalf("write packet: %v", err) + } + + cfg := config.Default() + cfg.Ingest.Kind = "srt" + cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000?mode=listener" + cfg.Ingest.SRT.SampleRateHz = 48000 + cfg.Ingest.SRT.Channels = 2 + + src, ingress, err := BuildSource(cfg, Deps{ + SRTOpener: func(ctx context.Context, srtCfg aoiprxkit.SRTConfig) (io.ReadCloser, error) { + _ = ctx + _ = srtCfg + return streamReadCloser{Reader: bytes.NewReader(stream.Bytes())}, nil + }, + }) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source for kind=srt") + } + if ingress != nil { + t.Fatalf("expected no ingress for kind=srt") + } + + sink := audio.NewStreamSource(128, cfg.Ingest.SRT.SampleRateHz) + rt := ingest.NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("runtime start: %v", err) + } + defer rt.Stop() + + waitForSinkFrames(t, sink, 2) + + stats := rt.Stats() + if stats.Active.Kind != "srt" { + t.Fatalf("active kind=%q want srt", stats.Active.Kind) + } + if stats.Source.ChunksIn != 1 { + t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn) + } + if stats.Source.SamplesIn != 4 { + t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn) + } +} + func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { t.Helper() deadline := time.Now().Add(1 * time.Second) From 96a8bdaf94f4777b15e0a3ece4d011da87cbcfa9 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 19:35:24 +0200 Subject: [PATCH 18/40] ingest: add aoip aes67 source support --- internal/config/config.go | 63 +++- internal/config/config_test.go | 38 +++ internal/ingest/adapters/aoip/source.go | 306 +++++++++++++++++++ internal/ingest/adapters/aoip/source_test.go | 127 ++++++++ internal/ingest/factory/factory.go | 84 ++++- internal/ingest/factory/factory_test.go | 52 ++++ internal/ingest/factory/ingest_smoke_test.go | 75 +++++ 7 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 internal/ingest/adapters/aoip/source.go create mode 100644 internal/ingest/adapters/aoip/source_test.go diff --git a/internal/config/config.go b/internal/config/config.go index eba5ecb..d890d4c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,7 @@ type IngestConfig struct { HTTPRaw IngestPCMConfig `json:"httpRaw"` Icecast IngestIcecastConfig `json:"icecast"` SRT IngestSRTConfig `json:"srt"` + AES67 IngestAES67Config `json:"aes67"` } type IngestReconnectConfig struct { @@ -112,6 +113,21 @@ type IngestSRTConfig struct { Channels int `json:"channels"` } +type IngestAES67Config struct { + SDPPath string `json:"sdpPath"` + SDP string `json:"sdp"` + MulticastGroup string `json:"multicastGroup"` + Port int `json:"port"` + InterfaceName string `json:"interfaceName"` + PayloadType int `json:"payloadType"` + SampleRateHz int `json:"sampleRateHz"` + Channels int `json:"channels"` + Encoding string `json:"encoding"` + PacketTimeMs int `json:"packetTimeMs"` + JitterDepthPackets int `json:"jitterDepthPackets"` + ReadBufferBytes int `json:"readBufferBytes"` +} + func Default() Config { return Config{ Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, @@ -165,6 +181,15 @@ func Default() Config { SampleRateHz: 48000, Channels: 2, }, + AES67: IngestAES67Config{ + PayloadType: 97, + SampleRateHz: 48000, + Channels: 2, + Encoding: "L24", + PacketTimeMs: 1, + JitterDepthPackets: 8, + ReadBufferBytes: 1 << 20, + }, }, } } @@ -253,7 +278,7 @@ func (c Config) Validate() error { } ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) switch ingestKind { - case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt": + case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt", "aes67", "aoip", "aoip-rtp": default: return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) } @@ -290,6 +315,42 @@ func (c Config) Validate() error { if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" { return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt") } + if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" { + hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != "" + hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != "" + if hasSDP && hasSDPPath { + return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive") + } + if !hasSDP && !hasSDPPath { + if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" { + return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind) + } + if c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535 { + return fmt.Errorf("ingest.aes67.port must be 1..65535") + } + } + if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 { + return fmt.Errorf("ingest.aes67.payloadType must be 0..127") + } + if c.Ingest.AES67.SampleRateHz <= 0 { + return fmt.Errorf("ingest.aes67.sampleRateHz must be > 0") + } + if c.Ingest.AES67.Channels != 1 && c.Ingest.AES67.Channels != 2 { + return fmt.Errorf("ingest.aes67.channels must be 1 or 2") + } + if strings.ToUpper(strings.TrimSpace(c.Ingest.AES67.Encoding)) != "L24" { + return fmt.Errorf("ingest.aes67.encoding must be L24") + } + if c.Ingest.AES67.PacketTimeMs <= 0 { + return fmt.Errorf("ingest.aes67.packetTimeMs must be > 0") + } + if c.Ingest.AES67.JitterDepthPackets < 1 { + return fmt.Errorf("ingest.aes67.jitterDepthPackets must be >= 1") + } + if c.Ingest.AES67.ReadBufferBytes < 0 { + return fmt.Errorf("ingest.aes67.readBufferBytes must be >= 0") + } + } switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) { case "", "listener", "caller", "rendezvous": default: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6002e54..3376cc8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -165,6 +165,44 @@ func TestValidateRejectsInvalidSRTConfig(t *testing.T) { } } +func TestValidateRejectsInvalidAES67Config(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected aes67 multicast group error") + } + + cfg = Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "239.10.20.30" + cfg.Ingest.AES67.Port = 5004 + cfg.Ingest.AES67.Encoding = "L16" + if err := cfg.Validate(); err == nil { + t.Fatal("expected aes67 encoding error") + } + + cfg = Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "239.10.20.30" + cfg.Ingest.AES67.Port = 5004 + cfg.Ingest.AES67.SDP = "v=0" + cfg.Ingest.AES67.SDPPath = "stream.sdp" + if err := cfg.Validate(); err == nil { + t.Fatal("expected mutually exclusive sdp/sdpPath error") + } +} + +func TestValidateAcceptsAES67WithSDPOnly(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.SDP = "v=0\r\ns=demo\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\n" + if err := cfg.Validate(); err != nil { + t.Fatalf("expected aes67 with SDP to validate: %v", err) + } +} + func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { cfg := Default() cfg.Ingest.Stdin.SampleRateHz = 0 diff --git a/internal/ingest/adapters/aoip/source.go b/internal/ingest/adapters/aoip/source.go new file mode 100644 index 0000000..3d24346 --- /dev/null +++ b/internal/ingest/adapters/aoip/source.go @@ -0,0 +1,306 @@ +package aoip + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + "aoiprxkit" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type ReceiverClient interface { + Start(ctx context.Context) error + Stop() error + Stats() aoiprxkit.Stats +} + +type ReceiverFactory func(cfg aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error) + +type Option func(*Source) + +func WithReceiverFactory(factory ReceiverFactory) Option { + return func(s *Source) { + if factory != nil { + s.factory = factory + } + } +} + +type Source struct { + id string + cfg aoiprxkit.Config + + factory ReceiverFactory + + chunks chan ingest.PCMChunk + errs chan error + + cancel context.CancelFunc + wg sync.WaitGroup + + mu sync.Mutex + rx ReceiverClient + started atomic.Bool + closeOnce sync.Once + + state atomic.Value // string + connected atomic.Bool + chunksIn atomic.Uint64 + samplesIn atomic.Uint64 + overflows atomic.Uint64 + discontinuities atomic.Uint64 + transportLoss atomic.Uint64 + reorders atomic.Uint64 + lastChunkAtUnix atomic.Int64 + lastError atomic.Value // string + nextSeq atomic.Uint64 + + seqMu sync.Mutex + lastFrame uint16 + lastHasVal bool +} + +func New(id string, cfg aoiprxkit.Config, opts ...Option) *Source { + if id == "" { + id = "aes67-main" + } + if cfg.MulticastGroup == "" { + cfg = aoiprxkit.DefaultConfig() + } + s := &Source{ + id: id, + cfg: cfg, + factory: newReceiverAdapter, + chunks: make(chan ingest.PCMChunk, 64), + errs: make(chan error, 8), + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + s.state.Store("idle") + s.lastError.Store("") + return s +} + +func (s *Source) Descriptor() ingest.SourceDescriptor { + return ingest.SourceDescriptor{ + ID: s.id, + Kind: "aes67", + Family: "aoip", + Transport: "rtp", + Codec: "l24", + Channels: s.cfg.Channels, + SampleRateHz: s.cfg.SampleRateHz, + Detail: fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port), + } +} + +func (s *Source) Start(ctx context.Context) error { + if !s.started.CompareAndSwap(false, true) { + return nil + } + + rx, err := s.factory(s.cfg, s.handleFrame) + if err != nil { + s.started.Store(false) + s.connected.Store(false) + s.state.Store("failed") + s.setError(err) + return err + } + + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.mu.Lock() + s.rx = rx + s.mu.Unlock() + s.lastError.Store("") + s.connected.Store(false) + s.state.Store("connecting") + + if err := rx.Start(runCtx); err != nil { + s.started.Store(false) + s.connected.Store(false) + s.state.Store("failed") + s.setError(err) + return err + } + s.connected.Store(true) + s.state.Store("running") + + s.wg.Add(1) + go func() { + defer s.wg.Done() + <-runCtx.Done() + _ = s.stopReceiver() + s.connected.Store(false) + s.closeChannels() + }() + return nil +} + +func (s *Source) Stop() error { + if !s.started.CompareAndSwap(true, false) { + return nil + } + if s.cancel != nil { + s.cancel() + } + if err := s.stopReceiver(); err != nil { + s.setError(err) + s.state.Store("failed") + } + s.wg.Wait() + s.connected.Store(false) + state, _ := s.state.Load().(string) + if state != "failed" { + s.state.Store("stopped") + } + return nil +} + +func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks } +func (s *Source) Errors() <-chan error { return s.errs } + +func (s *Source) Stats() ingest.SourceStats { + state, _ := s.state.Load().(string) + last := s.lastChunkAtUnix.Load() + errStr, _ := s.lastError.Load().(string) + var lastChunkAt time.Time + if last > 0 { + lastChunkAt = time.Unix(0, last) + } + var rxStats aoiprxkit.Stats + s.mu.Lock() + rx := s.rx + s.mu.Unlock() + if rx != nil { + rxStats = rx.Stats() + } + transportLoss := s.transportLoss.Load() + if rxStats.PacketsGapLoss > transportLoss { + transportLoss = rxStats.PacketsGapLoss + } + reorders := s.reorders.Load() + if rxStats.JitterReorders > reorders { + reorders = rxStats.JitterReorders + } + return ingest.SourceStats{ + State: state, + Connected: s.connected.Load(), + LastChunkAt: lastChunkAt, + ChunksIn: s.chunksIn.Load(), + SamplesIn: s.samplesIn.Load(), + Overflows: s.overflows.Load(), + Underruns: rxStats.PacketsLateDrop, + Discontinuities: s.discontinuities.Load() + rxStats.PacketsLateDrop, + TransportLoss: transportLoss, + Reorders: reorders, + JitterDepth: s.cfg.JitterDepthPackets, + LastError: errStr, + } +} + +func (s *Source) handleFrame(frame aoiprxkit.PCMFrame) { + if !s.started.Load() { + return + } + + discontinuity := false + s.seqMu.Lock() + if s.lastHasVal { + expected := s.lastFrame + 1 + if frame.SequenceNumber != expected { + discontinuity = true + delta := int16(frame.SequenceNumber - expected) + if delta > 0 { + s.transportLoss.Add(uint64(delta)) + } else { + s.reorders.Add(1) + } + } + } + s.lastFrame = frame.SequenceNumber + s.lastHasVal = true + s.seqMu.Unlock() + + chunk := ingest.PCMChunk{ + Samples: append([]int32(nil), frame.Samples...), + Channels: frame.Channels, + SampleRateHz: frame.SampleRateHz, + Sequence: s.nextSeq.Add(1) - 1, + Timestamp: frame.ReceivedAt, + SourceID: s.id, + Discontinuity: discontinuity, + } + + s.chunksIn.Add(1) + s.samplesIn.Add(uint64(len(chunk.Samples))) + s.lastChunkAtUnix.Store(time.Now().UnixNano()) + if discontinuity { + s.discontinuities.Add(1) + } + + select { + case s.chunks <- chunk: + default: + s.overflows.Add(1) + s.discontinuities.Add(1) + s.setError(io.ErrShortBuffer) + s.emitError(fmt.Errorf("aes67 chunk buffer overflow")) + } +} + +func (s *Source) stopReceiver() error { + s.mu.Lock() + rx := s.rx + s.rx = nil + s.mu.Unlock() + if rx == nil { + return nil + } + return rx.Stop() +} + +func (s *Source) closeChannels() { + s.closeOnce.Do(func() { + close(s.chunks) + close(s.errs) + }) +} + +func (s *Source) setError(err error) { + if err == nil { + return + } + s.lastError.Store(err.Error()) + s.emitError(err) +} + +func (s *Source) emitError(err error) { + if err == nil { + return + } + select { + case s.errs <- err: + default: + } +} + +type receiverAdapter struct { + *aoiprxkit.Receiver +} + +func newReceiverAdapter(cfg aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error) { + rx, err := aoiprxkit.NewReceiver(cfg, onFrame) + if err != nil { + return nil, err + } + return &receiverAdapter{Receiver: rx}, nil +} diff --git a/internal/ingest/adapters/aoip/source_test.go b/internal/ingest/adapters/aoip/source_test.go new file mode 100644 index 0000000..434f6cf --- /dev/null +++ b/internal/ingest/adapters/aoip/source_test.go @@ -0,0 +1,127 @@ +package aoip + +import ( + "context" + "testing" + "time" + + "aoiprxkit" + "github.com/jan/fm-rds-tx/internal/ingest" +) + +type stubReceiver struct { + onStart func() + onStop func() + stats aoiprxkit.Stats +} + +func (r *stubReceiver) Start(context.Context) error { + if r.onStart != nil { + r.onStart() + } + return nil +} + +func (r *stubReceiver) Stop() error { + if r.onStop != nil { + r.onStop() + } + return nil +} + +func (r *stubReceiver) Stats() aoiprxkit.Stats { + return r.stats +} + +func TestSourceEmitsChunksAndMapsStats(t *testing.T) { + var handler aoiprxkit.FrameHandler + rx := &stubReceiver{ + stats: aoiprxkit.Stats{ + PacketsGapLoss: 1, + PacketsLateDrop: 2, + JitterReorders: 1, + }, + } + src := New("aes67-test", aoiprxkit.Config{ + MulticastGroup: "239.10.20.30", + Port: 5004, + PayloadType: 97, + SampleRateHz: 48000, + Channels: 2, + Encoding: "L24", + PacketTime: time.Millisecond, + JitterDepthPackets: 6, + }, WithReceiverFactory(func(_ aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error) { + handler = onFrame + return rx, nil + })) + + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + handler(aoiprxkit.PCMFrame{ + SequenceNumber: 100, + SampleRateHz: 48000, + Channels: 2, + Samples: []int32{1, -1, 2, -2}, + ReceivedAt: time.Now(), + }) + handler(aoiprxkit.PCMFrame{ + SequenceNumber: 102, + SampleRateHz: 48000, + Channels: 2, + Samples: []int32{3, -3, 4, -4}, + ReceivedAt: time.Now(), + }) + + chunk1 := readChunk(t, src.Chunks()) + if chunk1.Discontinuity { + t.Fatalf("first chunk should not be discontinuity") + } + chunk2 := readChunk(t, src.Chunks()) + if !chunk2.Discontinuity { + t.Fatalf("second chunk should be discontinuity on sequence gap") + } + + stats := src.Stats() + if stats.State != "running" { + t.Fatalf("state=%q want running", stats.State) + } + if !stats.Connected { + t.Fatalf("connected=false want true") + } + if stats.ChunksIn != 2 { + t.Fatalf("chunksIn=%d want 2", stats.ChunksIn) + } + if stats.SamplesIn != 8 { + t.Fatalf("samplesIn=%d want 8", stats.SamplesIn) + } + if stats.TransportLoss != 1 { + t.Fatalf("transportLoss=%d want 1", stats.TransportLoss) + } + if stats.Reorders != 1 { + t.Fatalf("reorders=%d want 1", stats.Reorders) + } + if stats.Underruns != 2 { + t.Fatalf("underruns=%d want 2", stats.Underruns) + } + if stats.JitterDepth != 6 { + t.Fatalf("jitterDepth=%d want 6", stats.JitterDepth) + } +} + +func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk { + t.Helper() + select { + case chunk, ok := <-ch: + if !ok { + t.Fatal("chunk channel closed") + } + return chunk + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for chunk") + return ingest.PCMChunk{} + } +} diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go index 62146fa..85fd34a 100644 --- a/internal/ingest/factory/factory.go +++ b/internal/ingest/factory/factory.go @@ -5,11 +5,14 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" + "time" "aoiprxkit" "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" + "github.com/jan/fm-rds-tx/internal/ingest/adapters/aoip" "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" "github.com/jan/fm-rds-tx/internal/ingest/adapters/srt" @@ -17,9 +20,10 @@ import ( ) type Deps struct { - Stdin io.Reader - HTTP *http.Client - SRTOpener aoiprxkit.SRTConnOpener + Stdin io.Reader + HTTP *http.Client + SRTOpener aoiprxkit.SRTConnOpener + AES67ReceiverFactory aoip.ReceiverFactory } type AudioIngress interface { @@ -66,6 +70,17 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err } src := srt.New("srt-main", srtCfg, opts...) return src, nil, nil + case "aes67", "aoip", "aoip-rtp": + aoipCfg, err := buildAES67Config(cfg) + if err != nil { + return nil, nil, err + } + opts := []aoip.Option{} + if deps.AES67ReceiverFactory != nil { + opts = append(opts, aoip.WithReceiverFactory(deps.AES67ReceiverFactory)) + } + src := aoip.New("aes67-main", aoipCfg, opts...) + return src, nil, nil default: return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) } @@ -87,6 +102,10 @@ func SampleRateForKind(cfg config.Config) int { if cfg.Ingest.SRT.SampleRateHz > 0 { return cfg.Ingest.SRT.SampleRateHz } + case "aes67", "aoip", "aoip-rtp": + if cfg.Ingest.AES67.SampleRateHz > 0 { + return cfg.Ingest.AES67.SampleRateHz + } } return 44100 } @@ -94,3 +113,62 @@ func SampleRateForKind(cfg config.Config) int { func normalizeIngestKind(kind string) string { return strings.ToLower(strings.TrimSpace(kind)) } + +func buildAES67Config(cfg config.Config) (aoiprxkit.Config, error) { + base := aoiprxkit.DefaultConfig() + ing := cfg.Ingest.AES67 + if strings.TrimSpace(ing.InterfaceName) != "" { + base.InterfaceName = strings.TrimSpace(ing.InterfaceName) + } + if ing.PayloadType >= 0 { + base.PayloadType = uint8(ing.PayloadType) + } + if ing.SampleRateHz > 0 { + base.SampleRateHz = ing.SampleRateHz + } + if ing.Channels > 0 { + base.Channels = ing.Channels + } + if strings.TrimSpace(ing.Encoding) != "" { + base.Encoding = strings.ToUpper(strings.TrimSpace(ing.Encoding)) + } + if ing.PacketTimeMs > 0 { + base.PacketTime = time.Duration(ing.PacketTimeMs) * time.Millisecond + } + if ing.JitterDepthPackets > 0 { + base.JitterDepthPackets = ing.JitterDepthPackets + } + if ing.ReadBufferBytes > 0 { + base.ReadBufferBytes = ing.ReadBufferBytes + } + + sdpText := strings.TrimSpace(ing.SDP) + if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" { + data, err := os.ReadFile(filepath.Clean(ing.SDPPath)) + if err != nil { + return aoiprxkit.Config{}, fmt.Errorf("read ingest.aes67.sdpPath: %w", err) + } + sdpText = string(data) + } + if sdpText != "" { + info, err := aoiprxkit.ParseMinimalSDP(sdpText) + if err != nil { + return aoiprxkit.Config{}, fmt.Errorf("parse ingest.aes67 SDP: %w", err) + } + parsed, err := aoiprxkit.ConfigFromSDP(base, info) + if err != nil { + return aoiprxkit.Config{}, fmt.Errorf("map ingest.aes67 SDP: %w", err) + } + return parsed, nil + } + if strings.TrimSpace(ing.MulticastGroup) != "" { + base.MulticastGroup = strings.TrimSpace(ing.MulticastGroup) + } + if ing.Port > 0 { + base.Port = ing.Port + } + if err := base.Validate(); err != nil { + return aoiprxkit.Config{}, err + } + return base, nil +} diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index 5b147cd..2f43e13 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -110,6 +110,52 @@ func TestBuildSourceSRT(t *testing.T) { } } +func TestBuildSourceAES67(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "239.69.10.20" + cfg.Ingest.AES67.Port = 5008 + cfg.Ingest.AES67.PayloadType = 98 + cfg.Ingest.AES67.SampleRateHz = 48000 + cfg.Ingest.AES67.Channels = 2 + cfg.Ingest.AES67.Encoding = "L24" + cfg.Ingest.AES67.PacketTimeMs = 1 + cfg.Ingest.AES67.JitterDepthPackets = 6 + + src, ingress, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source") + } + if ingress != nil { + t.Fatalf("expected no ingress for aes67") + } + if got := src.Descriptor().Kind; got != "aes67" { + t.Fatalf("source kind=%s", got) + } +} + +func TestBuildSourceAES67FromInlineSDP(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.SDP = "v=0\r\ns=demo\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\na=ptime:1\r\n" + + src, _, err := BuildSource(cfg, Deps{}) + if err != nil { + t.Fatalf("build source: %v", err) + } + desc := src.Descriptor() + if desc.Transport != "rtp" { + t.Fatalf("transport=%q want rtp", desc.Transport) + } + if desc.SampleRateHz != 48000 || desc.Channels != 2 { + t.Fatalf("shape=%d/%d", desc.SampleRateHz, desc.Channels) + } +} + func TestBuildSourceUnsupportedKind(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "nope" @@ -143,4 +189,10 @@ func TestSampleRateForKind(t *testing.T) { if got := SampleRateForKind(cfg); got != 48000 { t.Fatalf("srt sample rate=%d", got) } + + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.SampleRateHz = 32000 + if got := SampleRateForKind(cfg); got != 32000 { + t.Fatalf("aes67 sample rate=%d", got) + } } diff --git a/internal/ingest/factory/ingest_smoke_test.go b/internal/ingest/factory/ingest_smoke_test.go index e9aed73..b67bb69 100644 --- a/internal/ingest/factory/ingest_smoke_test.go +++ b/internal/ingest/factory/ingest_smoke_test.go @@ -11,12 +11,29 @@ import ( "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" + aoipad "github.com/jan/fm-rds-tx/internal/ingest/adapters/aoip" ) type streamReadCloser struct{ io.Reader } func (r streamReadCloser) Close() error { return nil } +type stubAES67Receiver struct { + onStart func() +} + +func (r *stubAES67Receiver) Start(context.Context) error { + if r.onStart != nil { + r.onStart() + } + return nil +} + +func (r *stubAES67Receiver) Stop() error { return nil } +func (r *stubAES67Receiver) Stats() aoiprxkit.Stats { + return aoiprxkit.Stats{} +} + func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "http-raw" @@ -120,6 +137,64 @@ func TestSRTFactoryToRuntimeSmoke(t *testing.T) { } } +func TestAES67FactoryToRuntimeSmoke(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "239.10.20.30" + cfg.Ingest.AES67.Port = 5004 + cfg.Ingest.AES67.SampleRateHz = 48000 + cfg.Ingest.AES67.Channels = 2 + cfg.Ingest.AES67.Encoding = "L24" + cfg.Ingest.AES67.PacketTimeMs = 1 + + var frameHandler aoiprxkit.FrameHandler + src, ingress, err := BuildSource(cfg, Deps{ + AES67ReceiverFactory: func(_ aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (aoipad.ReceiverClient, error) { + frameHandler = onFrame + return &stubAES67Receiver{ + onStart: func() { + frameHandler(aoiprxkit.PCMFrame{ + SequenceNumber: 1, + SampleRateHz: 48000, + Channels: 2, + Samples: []int32{7, -7, 9, -9}, + ReceivedAt: time.Now(), + }) + }, + }, nil + }, + }) + if err != nil { + t.Fatalf("build source: %v", err) + } + if src == nil { + t.Fatalf("expected source for kind=aes67") + } + if ingress != nil { + t.Fatalf("expected no ingress for kind=aes67") + } + + sink := audio.NewStreamSource(128, cfg.Ingest.AES67.SampleRateHz) + rt := ingest.NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("runtime start: %v", err) + } + defer rt.Stop() + + waitForSinkFrames(t, sink, 2) + + stats := rt.Stats() + if stats.Active.Kind != "aes67" { + t.Fatalf("active kind=%q want aes67", stats.Active.Kind) + } + if stats.Source.ChunksIn != 1 { + t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn) + } + if stats.Source.SamplesIn != 4 { + t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn) + } +} + func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { t.Helper() deadline := time.Now().Add(1 * time.Second) From 6d37e8c831ddf4c6fe1e869dbe55322b423f21b6 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 19:43:22 +0200 Subject: [PATCH 19/40] ingest: add aes67 sap discovery support --- internal/config/config.go | 54 ++++++--- internal/config/config_test.go | 35 ++++++ internal/ingest/adapters/aoip/source.go | 13 ++- internal/ingest/adapters/aoip/source_test.go | 14 +++ internal/ingest/factory/factory.go | 114 ++++++++++++++++--- internal/ingest/factory/factory_test.go | 55 +++++++++ 6 files changed, 256 insertions(+), 29 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d890d4c..2d469ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -114,18 +114,28 @@ type IngestSRTConfig struct { } type IngestAES67Config struct { - SDPPath string `json:"sdpPath"` - SDP string `json:"sdp"` - MulticastGroup string `json:"multicastGroup"` - Port int `json:"port"` - InterfaceName string `json:"interfaceName"` - PayloadType int `json:"payloadType"` - SampleRateHz int `json:"sampleRateHz"` - Channels int `json:"channels"` - Encoding string `json:"encoding"` - PacketTimeMs int `json:"packetTimeMs"` - JitterDepthPackets int `json:"jitterDepthPackets"` - ReadBufferBytes int `json:"readBufferBytes"` + SDPPath string `json:"sdpPath"` + SDP string `json:"sdp"` + Discovery IngestAES67DiscoveryConfig `json:"discovery"` + MulticastGroup string `json:"multicastGroup"` + Port int `json:"port"` + InterfaceName string `json:"interfaceName"` + PayloadType int `json:"payloadType"` + SampleRateHz int `json:"sampleRateHz"` + Channels int `json:"channels"` + Encoding string `json:"encoding"` + PacketTimeMs int `json:"packetTimeMs"` + JitterDepthPackets int `json:"jitterDepthPackets"` + ReadBufferBytes int `json:"readBufferBytes"` +} + +type IngestAES67DiscoveryConfig struct { + Enabled bool `json:"enabled"` + StreamName string `json:"streamName"` + TimeoutMs int `json:"timeoutMs"` + InterfaceName string `json:"interfaceName"` + SAPGroup string `json:"sapGroup"` + SAPPort int `json:"sapPort"` } func Default() Config { @@ -182,6 +192,9 @@ func Default() Config { Channels: 2, }, AES67: IngestAES67Config{ + Discovery: IngestAES67DiscoveryConfig{ + TimeoutMs: 3000, + }, PayloadType: 97, SampleRateHz: 48000, Channels: 2, @@ -318,17 +331,30 @@ func (c Config) Validate() error { if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" { hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != "" hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != "" + discoveryEnabled := c.Ingest.AES67.Discovery.Enabled || strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) != "" if hasSDP && hasSDPPath { return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive") } if !hasSDP && !hasSDPPath { - if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" { + if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" && !discoveryEnabled { return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind) } - if c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535 { + if (c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535) && !discoveryEnabled { return fmt.Errorf("ingest.aes67.port must be 1..65535") } } + if c.Ingest.AES67.Discovery.TimeoutMs < 0 { + return fmt.Errorf("ingest.aes67.discovery.timeoutMs must be >= 0") + } + if c.Ingest.AES67.Discovery.SAPPort < 0 || c.Ingest.AES67.Discovery.SAPPort > 65535 { + return fmt.Errorf("ingest.aes67.discovery.sapPort must be 0..65535") + } + if discoveryEnabled && strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) == "" { + return fmt.Errorf("ingest.aes67.discovery.streamName is required when discovery is enabled") + } + if discoveryEnabled && c.Ingest.AES67.Port > 65535 { + return fmt.Errorf("ingest.aes67.port must be 1..65535") + } if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 { return fmt.Errorf("ingest.aes67.payloadType must be 0..127") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3376cc8..0c0cd5a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -203,6 +203,41 @@ func TestValidateAcceptsAES67WithSDPOnly(t *testing.T) { } } +func TestValidateAcceptsAES67WithDiscoveryOnly(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.Port = 0 + cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN" + if err := cfg.Validate(); err != nil { + t.Fatalf("expected aes67 discovery config to validate: %v", err) + } +} + +func TestValidateRejectsAES67DiscoveryWithoutStreamName(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.Port = 0 + cfg.Ingest.AES67.Discovery.Enabled = true + cfg.Ingest.AES67.Discovery.StreamName = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected discovery streamName validation error") + } +} + +func TestValidateRejectsAES67DiscoverySAPPortOutOfRange(t *testing.T) { + cfg := Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.Port = 0 + cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN" + cfg.Ingest.AES67.Discovery.SAPPort = 70000 + if err := cfg.Validate(); err == nil { + t.Fatal("expected discovery sapPort validation error") + } +} + func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { cfg := Default() cfg.Ingest.Stdin.SampleRateHz = 0 diff --git a/internal/ingest/adapters/aoip/source.go b/internal/ingest/adapters/aoip/source.go index 3d24346..4324461 100644 --- a/internal/ingest/adapters/aoip/source.go +++ b/internal/ingest/adapters/aoip/source.go @@ -30,11 +30,18 @@ func WithReceiverFactory(factory ReceiverFactory) Option { } } +func WithDetail(detail string) Option { + return func(s *Source) { + s.detail = detail + } +} + type Source struct { id string cfg aoiprxkit.Config factory ReceiverFactory + detail string chunks chan ingest.PCMChunk errs chan error @@ -89,6 +96,10 @@ func New(id string, cfg aoiprxkit.Config, opts ...Option) *Source { } func (s *Source) Descriptor() ingest.SourceDescriptor { + detail := s.detail + if detail == "" { + detail = fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port) + } return ingest.SourceDescriptor{ ID: s.id, Kind: "aes67", @@ -97,7 +108,7 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { Codec: "l24", Channels: s.cfg.Channels, SampleRateHz: s.cfg.SampleRateHz, - Detail: fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port), + Detail: detail, } } diff --git a/internal/ingest/adapters/aoip/source_test.go b/internal/ingest/adapters/aoip/source_test.go index 434f6cf..068ac23 100644 --- a/internal/ingest/adapters/aoip/source_test.go +++ b/internal/ingest/adapters/aoip/source_test.go @@ -112,6 +112,20 @@ func TestSourceEmitsChunksAndMapsStats(t *testing.T) { } } +func TestSourceDescriptorSupportsDetailOverride(t *testing.T) { + src := New("aes67-test", aoiprxkit.Config{ + MulticastGroup: "239.10.20.30", + Port: 5004, + SampleRateHz: 48000, + Channels: 2, + }, WithDetail("rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)")) + + desc := src.Descriptor() + if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" { + t.Fatalf("detail=%q", desc.Detail) + } +} + func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk { t.Helper() select { diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go index 85fd34a..8ee79c8 100644 --- a/internal/ingest/factory/factory.go +++ b/internal/ingest/factory/factory.go @@ -1,6 +1,7 @@ package factory import ( + "context" "fmt" "io" "net/http" @@ -24,12 +25,23 @@ type Deps struct { HTTP *http.Client SRTOpener aoiprxkit.SRTConnOpener AES67ReceiverFactory aoip.ReceiverFactory + AES67Discover AES67DiscoverFunc } type AudioIngress interface { WritePCM16(data []byte) (int, error) } +type AES67DiscoverRequest struct { + StreamName string + Timeout time.Duration + InterfaceName string + SAPGroup string + SAPPort int +} + +type AES67DiscoverFunc func(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) + func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, error) { switch normalizeIngestKind(cfg.Ingest.Kind) { case "", "none": @@ -71,7 +83,7 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err src := srt.New("srt-main", srtCfg, opts...) return src, nil, nil case "aes67", "aoip", "aoip-rtp": - aoipCfg, err := buildAES67Config(cfg) + aoipCfg, detail, err := buildAES67Config(cfg, deps) if err != nil { return nil, nil, err } @@ -79,6 +91,9 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err if deps.AES67ReceiverFactory != nil { opts = append(opts, aoip.WithReceiverFactory(deps.AES67ReceiverFactory)) } + if detail != "" { + opts = append(opts, aoip.WithDetail(detail)) + } src := aoip.New("aes67-main", aoipCfg, opts...) return src, nil, nil default: @@ -114,7 +129,7 @@ func normalizeIngestKind(kind string) string { return strings.ToLower(strings.TrimSpace(kind)) } -func buildAES67Config(cfg config.Config) (aoiprxkit.Config, error) { +func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, error) { base := aoiprxkit.DefaultConfig() ing := cfg.Ingest.AES67 if strings.TrimSpace(ing.InterfaceName) != "" { @@ -142,24 +157,25 @@ func buildAES67Config(cfg config.Config) (aoiprxkit.Config, error) { base.ReadBufferBytes = ing.ReadBufferBytes } - sdpText := strings.TrimSpace(ing.SDP) - if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" { - data, err := os.ReadFile(filepath.Clean(ing.SDPPath)) - if err != nil { - return aoiprxkit.Config{}, fmt.Errorf("read ingest.aes67.sdpPath: %w", err) - } - sdpText = string(data) + sdpText, discoveredStreamName, err := resolveAES67SDP(ing, deps) + if err != nil { + return aoiprxkit.Config{}, "", err } + if sdpText != "" { info, err := aoiprxkit.ParseMinimalSDP(sdpText) if err != nil { - return aoiprxkit.Config{}, fmt.Errorf("parse ingest.aes67 SDP: %w", err) + return aoiprxkit.Config{}, "", fmt.Errorf("parse ingest.aes67 SDP: %w", err) } parsed, err := aoiprxkit.ConfigFromSDP(base, info) if err != nil { - return aoiprxkit.Config{}, fmt.Errorf("map ingest.aes67 SDP: %w", err) + return aoiprxkit.Config{}, "", fmt.Errorf("map ingest.aes67 SDP: %w", err) + } + detail := "" + if discoveredStreamName != "" { + detail = fmt.Sprintf("rtp://%s:%d (SAP s=%s)", parsed.MulticastGroup, parsed.Port, discoveredStreamName) } - return parsed, nil + return parsed, detail, nil } if strings.TrimSpace(ing.MulticastGroup) != "" { base.MulticastGroup = strings.TrimSpace(ing.MulticastGroup) @@ -168,7 +184,77 @@ func buildAES67Config(cfg config.Config) (aoiprxkit.Config, error) { base.Port = ing.Port } if err := base.Validate(); err != nil { - return aoiprxkit.Config{}, err + return aoiprxkit.Config{}, "", err } - return base, nil + return base, "", nil +} + +func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, error) { + sdpText := strings.TrimSpace(ing.SDP) + if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" { + data, err := os.ReadFile(filepath.Clean(ing.SDPPath)) + if err != nil { + return "", "", fmt.Errorf("read ingest.aes67.sdpPath: %w", err) + } + sdpText = string(data) + } + if sdpText != "" { + return sdpText, "", nil + } + + discoveryEnabled := ing.Discovery.Enabled || strings.TrimSpace(ing.Discovery.StreamName) != "" + if !discoveryEnabled { + return "", "", nil + } + timeout := time.Duration(ing.Discovery.TimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = 3 * time.Second + } + req := AES67DiscoverRequest{ + StreamName: strings.TrimSpace(ing.Discovery.StreamName), + Timeout: timeout, + InterfaceName: strings.TrimSpace(ing.Discovery.InterfaceName), + SAPGroup: strings.TrimSpace(ing.Discovery.SAPGroup), + SAPPort: ing.Discovery.SAPPort, + } + discover := deps.AES67Discover + if discover == nil { + discover = discoverAES67ViaSAP + } + announcement, err := discover(context.Background(), req) + if err != nil { + return "", "", fmt.Errorf("discover ingest.aes67 stream %q via SAP: %w", req.StreamName, err) + } + if strings.TrimSpace(announcement.SDP) == "" { + return "", "", fmt.Errorf("discover ingest.aes67 stream %q via SAP: empty SDP payload", req.StreamName) + } + return announcement.SDP, req.StreamName, nil +} + +func discoverAES67ViaSAP(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) { + if req.StreamName == "" { + return aoiprxkit.SAPAnnouncement{}, fmt.Errorf("stream name must not be empty") + } + listenerCfg := aoiprxkit.DefaultSAPListenerConfig() + if req.InterfaceName != "" { + listenerCfg.InterfaceName = req.InterfaceName + } + if req.SAPGroup != "" { + listenerCfg.Group = req.SAPGroup + } + if req.SAPPort > 0 { + listenerCfg.Port = req.SAPPort + } + sf, err := aoiprxkit.NewStreamFinder(listenerCfg) + if err != nil { + return aoiprxkit.SAPAnnouncement{}, err + } + if err := sf.Start(ctx); err != nil { + return aoiprxkit.SAPAnnouncement{}, err + } + defer sf.Stop() + + waitCtx, cancel := context.WithTimeout(ctx, req.Timeout) + defer cancel() + return sf.WaitForStreamName(waitCtx, req.StreamName) } diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index 2f43e13..a506d9f 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -2,7 +2,12 @@ package factory import ( "bytes" + "context" + "errors" "testing" + "time" + + "aoiprxkit" "github.com/jan/fm-rds-tx/internal/config" ) @@ -156,6 +161,56 @@ func TestBuildSourceAES67FromInlineSDP(t *testing.T) { } } +func TestBuildSourceAES67WithDiscovery(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.Port = 0 + cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN" + cfg.Ingest.AES67.Discovery.TimeoutMs = 1500 + + var gotReq AES67DiscoverRequest + src, _, err := BuildSource(cfg, Deps{ + AES67Discover: func(_ context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) { + gotReq = req + return aoiprxkit.SAPAnnouncement{ + SDP: "v=0\r\ns=AES67-MAIN\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\na=ptime:1\r\n", + }, nil + }, + }) + if err != nil { + t.Fatalf("build source: %v", err) + } + if gotReq.StreamName != "AES67-MAIN" { + t.Fatalf("discovery streamName=%q want AES67-MAIN", gotReq.StreamName) + } + if gotReq.Timeout != 1500*time.Millisecond { + t.Fatalf("discovery timeout=%s want 1500ms", gotReq.Timeout) + } + desc := src.Descriptor() + if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" { + t.Fatalf("descriptor detail=%q", desc.Detail) + } +} + +func TestBuildSourceAES67DiscoveryError(t *testing.T) { + cfg := config.Default() + cfg.Ingest.Kind = "aes67" + cfg.Ingest.AES67.MulticastGroup = "" + cfg.Ingest.AES67.Port = 0 + cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN" + + _, _, err := BuildSource(cfg, Deps{ + AES67Discover: func(_ context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) { + _ = req + return aoiprxkit.SAPAnnouncement{}, errors.New("timeout") + }, + }) + if err == nil { + t.Fatalf("expected discovery error") + } +} + func TestBuildSourceUnsupportedKind(t *testing.T) { cfg := config.Default() cfg.Ingest.Kind = "nope" From aa26330147eea71955f115facc939085f7f155ab Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 19:53:10 +0200 Subject: [PATCH 20/40] ingest: expose source origin in runtime details --- internal/control/control_test.go | 23 ++++++- internal/ingest/adapters/aoip/source.go | 20 ++++++ internal/ingest/adapters/aoip/source_test.go | 15 ++++- internal/ingest/adapters/icecast/source.go | 20 ++++++ .../ingest/adapters/icecast/source_test.go | 14 +++++ internal/ingest/adapters/srt/source.go | 22 +++++++ internal/ingest/adapters/srt/source_test.go | 14 +++++ internal/ingest/factory/factory.go | 62 ++++++++++++++----- internal/ingest/factory/factory_test.go | 18 ++++++ internal/ingest/types.go | 27 +++++--- 10 files changed, 208 insertions(+), 27 deletions(-) diff --git a/internal/control/control_test.go b/internal/control/control_test.go index a59dcbf..11fa3a8 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -221,7 +221,14 @@ func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetIngestRuntime(&fakeIngestRuntime{ stats: ingest.Stats{ - Active: ingest.SourceDescriptor{ID: "icecast-main", Kind: "icecast"}, + Active: ingest.SourceDescriptor{ + ID: "icecast-main", + Kind: "icecast", + Origin: &ingest.SourceOrigin{ + Kind: "url", + Endpoint: "http://example.org/live", + }, + }, Source: ingest.SourceStats{ State: "reconnecting", Connected: false, @@ -261,6 +268,20 @@ func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) { if source["lastError"] != "dial tcp timeout" { t.Fatalf("source lastError mismatch: got %v", source["lastError"]) } + active, ok := ingestPayload["active"].(map[string]any) + if !ok { + t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"]) + } + origin, ok := active["origin"].(map[string]any) + if !ok { + t.Fatalf("expected ingest.active.origin map, got %T", active["origin"]) + } + if origin["kind"] != "url" { + t.Fatalf("origin kind mismatch: got %v", origin["kind"]) + } + if origin["endpoint"] != "http://example.org/live" { + t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"]) + } runtimePayload, ok := ingestPayload["runtime"].(map[string]any) if !ok { t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"]) diff --git a/internal/ingest/adapters/aoip/source.go b/internal/ingest/adapters/aoip/source.go index 4324461..24e30f1 100644 --- a/internal/ingest/adapters/aoip/source.go +++ b/internal/ingest/adapters/aoip/source.go @@ -36,12 +36,20 @@ func WithDetail(detail string) Option { } } +func WithOrigin(origin ingest.SourceOrigin) Option { + return func(s *Source) { + clone := origin + s.origin = &clone + } +} + type Source struct { id string cfg aoiprxkit.Config factory ReceiverFactory detail string + origin *ingest.SourceOrigin chunks chan ingest.PCMChunk errs chan error @@ -100,6 +108,17 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { if detail == "" { detail = fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port) } + origin := s.origin + if origin == nil { + origin = &ingest.SourceOrigin{ + Kind: "manual", + } + } + if origin.Endpoint == "" { + copyOrigin := *origin + copyOrigin.Endpoint = fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port) + origin = ©Origin + } return ingest.SourceDescriptor{ ID: s.id, Kind: "aes67", @@ -109,6 +128,7 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { Channels: s.cfg.Channels, SampleRateHz: s.cfg.SampleRateHz, Detail: detail, + Origin: origin, } } diff --git a/internal/ingest/adapters/aoip/source_test.go b/internal/ingest/adapters/aoip/source_test.go index 068ac23..b3462ae 100644 --- a/internal/ingest/adapters/aoip/source_test.go +++ b/internal/ingest/adapters/aoip/source_test.go @@ -118,12 +118,25 @@ func TestSourceDescriptorSupportsDetailOverride(t *testing.T) { Port: 5004, SampleRateHz: 48000, Channels: 2, - }, WithDetail("rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)")) + }, WithDetail("rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)"), WithOrigin(ingest.SourceOrigin{ + Kind: "sap-discovery", + StreamName: "AES67-MAIN", + Endpoint: "rtp://239.10.20.30:5004", + })) desc := src.Descriptor() if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" { t.Fatalf("detail=%q", desc.Detail) } + if desc.Origin == nil { + t.Fatalf("expected descriptor origin") + } + if desc.Origin.Kind != "sap-discovery" { + t.Fatalf("origin kind=%q", desc.Origin.Kind) + } + if desc.Origin.StreamName != "AES67-MAIN" { + t.Fatalf("origin streamName=%q", desc.Origin.StreamName) + } } func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk { diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 028722f..784891d 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "sync" "sync/atomic" @@ -115,6 +116,10 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { Transport: "http", Codec: s.decoderPreference, Detail: s.url, + Origin: &ingest.SourceOrigin{ + Kind: "url", + Endpoint: redactURL(s.url), + }, } } @@ -345,3 +350,18 @@ func normalizeDecoderPreference(pref string) string { return strings.ToLower(strings.TrimSpace(pref)) } } + +func redactURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + u, err := url.Parse(trimmed) + if err != nil || u.Host == "" { + return trimmed + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 162ea89..9984269 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -311,6 +311,20 @@ func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { } } +func TestDescriptorOriginRedactsCredentialsAndQuery(t *testing.T) { + src := New("ice-test", "http://user:secret@example.org:8000/live.mp3?token=abc", nil, ReconnectConfig{}) + desc := src.Descriptor() + if desc.Origin == nil { + t.Fatalf("expected descriptor origin") + } + if desc.Origin.Kind != "url" { + t.Fatalf("origin kind=%q want url", desc.Origin.Kind) + } + if desc.Origin.Endpoint != "http://example.org:8000/live.mp3" { + t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint) + } +} + func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) { const ( audioPrefix = "ABCD" diff --git a/internal/ingest/adapters/srt/source.go b/internal/ingest/adapters/srt/source.go index 327e1ae..af3685d 100644 --- a/internal/ingest/adapters/srt/source.go +++ b/internal/ingest/adapters/srt/source.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "io" + "net/url" + "strings" "sync" "sync/atomic" "time" @@ -96,6 +98,11 @@ func (s *Source) Descriptor() ingest.SourceDescriptor { Channels: s.cfg.Channels, SampleRateHz: s.cfg.SampleRateHz, Detail: s.cfg.URL, + Origin: &ingest.SourceOrigin{ + Kind: "url", + Endpoint: redactURL(s.cfg.URL), + Mode: strings.TrimSpace(s.cfg.Mode), + }, } } @@ -281,3 +288,18 @@ func (s *Source) emitError(err error) { default: } } + +func redactURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + u, err := url.Parse(trimmed) + if err != nil || u.Host == "" { + return trimmed + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/internal/ingest/adapters/srt/source_test.go b/internal/ingest/adapters/srt/source_test.go index a4527b1..5393cd4 100644 --- a/internal/ingest/adapters/srt/source_test.go +++ b/internal/ingest/adapters/srt/source_test.go @@ -35,6 +35,20 @@ func TestSourceEmitsChunksFromSRTFrames(t *testing.T) { return readCloser{Reader: bytes.NewReader(stream.Bytes())}, nil })) + desc := src.Descriptor() + if desc.Origin == nil { + t.Fatalf("expected descriptor origin") + } + if desc.Origin.Kind != "url" { + t.Fatalf("origin kind=%q want url", desc.Origin.Kind) + } + if desc.Origin.Endpoint != "srt://127.0.0.1:9000" { + t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint) + } + if desc.Origin.Mode != "listener" { + t.Fatalf("origin mode=%q want listener", desc.Origin.Mode) + } + if err := src.Start(context.Background()); err != nil { t.Fatalf("start: %v", err) } diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go index 8ee79c8..5f8696c 100644 --- a/internal/ingest/factory/factory.go +++ b/internal/ingest/factory/factory.go @@ -83,7 +83,7 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err src := srt.New("srt-main", srtCfg, opts...) return src, nil, nil case "aes67", "aoip", "aoip-rtp": - aoipCfg, detail, err := buildAES67Config(cfg, deps) + aoipCfg, detail, origin, err := buildAES67Config(cfg, deps) if err != nil { return nil, nil, err } @@ -94,6 +94,9 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err if detail != "" { opts = append(opts, aoip.WithDetail(detail)) } + if origin != nil { + opts = append(opts, aoip.WithOrigin(*origin)) + } src := aoip.New("aes67-main", aoipCfg, opts...) return src, nil, nil default: @@ -129,7 +132,7 @@ func normalizeIngestKind(kind string) string { return strings.ToLower(strings.TrimSpace(kind)) } -func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, error) { +func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, *ingest.SourceOrigin, error) { base := aoiprxkit.DefaultConfig() ing := cfg.Ingest.AES67 if strings.TrimSpace(ing.InterfaceName) != "" { @@ -157,25 +160,32 @@ func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, e base.ReadBufferBytes = ing.ReadBufferBytes } - sdpText, discoveredStreamName, err := resolveAES67SDP(ing, deps) + sdpText, discoveredStreamName, origin, err := resolveAES67SDP(ing, deps) if err != nil { - return aoiprxkit.Config{}, "", err + return aoiprxkit.Config{}, "", nil, err } if sdpText != "" { info, err := aoiprxkit.ParseMinimalSDP(sdpText) if err != nil { - return aoiprxkit.Config{}, "", fmt.Errorf("parse ingest.aes67 SDP: %w", err) + return aoiprxkit.Config{}, "", nil, fmt.Errorf("parse ingest.aes67 SDP: %w", err) } parsed, err := aoiprxkit.ConfigFromSDP(base, info) if err != nil { - return aoiprxkit.Config{}, "", fmt.Errorf("map ingest.aes67 SDP: %w", err) + return aoiprxkit.Config{}, "", nil, fmt.Errorf("map ingest.aes67 SDP: %w", err) } detail := "" + endpoint := fmt.Sprintf("rtp://%s:%d", parsed.MulticastGroup, parsed.Port) if discoveredStreamName != "" { detail = fmt.Sprintf("rtp://%s:%d (SAP s=%s)", parsed.MulticastGroup, parsed.Port, discoveredStreamName) } - return parsed, detail, nil + if origin == nil { + origin = &ingest.SourceOrigin{} + } + if origin.Endpoint == "" { + origin.Endpoint = endpoint + } + return parsed, detail, origin, nil } if strings.TrimSpace(ing.MulticastGroup) != "" { base.MulticastGroup = strings.TrimSpace(ing.MulticastGroup) @@ -184,27 +194,42 @@ func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, e base.Port = ing.Port } if err := base.Validate(); err != nil { - return aoiprxkit.Config{}, "", err + return aoiprxkit.Config{}, "", nil, err + } + if origin == nil { + origin = &ingest.SourceOrigin{Kind: "manual"} + } + if origin.Endpoint == "" { + origin.Endpoint = fmt.Sprintf("rtp://%s:%d", base.MulticastGroup, base.Port) } - return base, "", nil + return base, "", origin, nil } -func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, error) { +func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, *ingest.SourceOrigin, error) { sdpText := strings.TrimSpace(ing.SDP) if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" { - data, err := os.ReadFile(filepath.Clean(ing.SDPPath)) + sdpPath := filepath.Clean(ing.SDPPath) + data, err := os.ReadFile(sdpPath) if err != nil { - return "", "", fmt.Errorf("read ingest.aes67.sdpPath: %w", err) + return "", "", nil, fmt.Errorf("read ingest.aes67.sdpPath: %w", err) } sdpText = string(data) + return sdpText, "", &ingest.SourceOrigin{ + Kind: "sdp-file", + SDPPath: sdpPath, + }, nil } if sdpText != "" { - return sdpText, "", nil + return sdpText, "", &ingest.SourceOrigin{ + Kind: "sdp-inline", + }, nil } discoveryEnabled := ing.Discovery.Enabled || strings.TrimSpace(ing.Discovery.StreamName) != "" if !discoveryEnabled { - return "", "", nil + return "", "", &ingest.SourceOrigin{ + Kind: "manual", + }, nil } timeout := time.Duration(ing.Discovery.TimeoutMs) * time.Millisecond if timeout <= 0 { @@ -223,12 +248,15 @@ func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, e } announcement, err := discover(context.Background(), req) if err != nil { - return "", "", fmt.Errorf("discover ingest.aes67 stream %q via SAP: %w", req.StreamName, err) + return "", "", nil, fmt.Errorf("discover ingest.aes67 stream %q via SAP: %w", req.StreamName, err) } if strings.TrimSpace(announcement.SDP) == "" { - return "", "", fmt.Errorf("discover ingest.aes67 stream %q via SAP: empty SDP payload", req.StreamName) + return "", "", nil, fmt.Errorf("discover ingest.aes67 stream %q via SAP: empty SDP payload", req.StreamName) } - return announcement.SDP, req.StreamName, nil + return announcement.SDP, req.StreamName, &ingest.SourceOrigin{ + Kind: "sap-discovery", + StreamName: req.StreamName, + }, nil } func discoverAES67ViaSAP(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) { diff --git a/internal/ingest/factory/factory_test.go b/internal/ingest/factory/factory_test.go index a506d9f..4b703dd 100644 --- a/internal/ingest/factory/factory_test.go +++ b/internal/ingest/factory/factory_test.go @@ -90,6 +90,9 @@ func TestBuildSourceIcecastUsesDecoderPreference(t *testing.T) { if got := src.Descriptor().Codec; got != "ffmpeg" { t.Fatalf("codec=%s want ffmpeg", got) } + if got := src.Descriptor().Origin; got == nil || got.Kind != "url" { + t.Fatalf("expected icecast origin kind url, got %+v", got) + } } func TestBuildSourceSRT(t *testing.T) { @@ -113,6 +116,9 @@ func TestBuildSourceSRT(t *testing.T) { if got := src.Descriptor().Kind; got != "srt" { t.Fatalf("source kind=%s", got) } + if got := src.Descriptor().Origin; got == nil || got.Kind != "url" || got.Mode != "listener" { + t.Fatalf("expected srt origin url/listener, got %+v", got) + } } func TestBuildSourceAES67(t *testing.T) { @@ -159,6 +165,12 @@ func TestBuildSourceAES67FromInlineSDP(t *testing.T) { if desc.SampleRateHz != 48000 || desc.Channels != 2 { t.Fatalf("shape=%d/%d", desc.SampleRateHz, desc.Channels) } + if desc.Origin == nil || desc.Origin.Kind != "sdp-inline" { + t.Fatalf("origin=%+v want sdp-inline", desc.Origin) + } + if desc.Origin.Endpoint != "rtp://239.10.20.30:5004" { + t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint) + } } func TestBuildSourceAES67WithDiscovery(t *testing.T) { @@ -191,6 +203,12 @@ func TestBuildSourceAES67WithDiscovery(t *testing.T) { if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" { t.Fatalf("descriptor detail=%q", desc.Detail) } + if desc.Origin == nil || desc.Origin.Kind != "sap-discovery" { + t.Fatalf("origin=%+v want sap-discovery", desc.Origin) + } + if desc.Origin.StreamName != "AES67-MAIN" { + t.Fatalf("origin streamName=%q", desc.Origin.StreamName) + } } func TestBuildSourceAES67DiscoveryError(t *testing.T) { diff --git a/internal/ingest/types.go b/internal/ingest/types.go index bae4801..92d95eb 100644 --- a/internal/ingest/types.go +++ b/internal/ingest/types.go @@ -15,12 +15,23 @@ type PCMChunk struct { } type SourceDescriptor struct { - ID string `json:"id"` - Kind string `json:"kind"` - Family string `json:"family"` - Transport string `json:"transport"` - Codec string `json:"codec"` - Channels int `json:"channels"` - SampleRateHz int `json:"sampleRateHz"` - Detail string `json:"detail,omitempty"` + ID string `json:"id"` + Kind string `json:"kind"` + Family string `json:"family"` + Transport string `json:"transport"` + Codec string `json:"codec"` + Channels int `json:"channels"` + SampleRateHz int `json:"sampleRateHz"` + Detail string `json:"detail,omitempty"` + Origin *SourceOrigin `json:"origin,omitempty"` +} + +// SourceOrigin describes where an ingest source definition came from and +// which endpoint it resolved to, so control/runtime can show provenance. +type SourceOrigin struct { + Kind string `json:"kind,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Mode string `json:"mode,omitempty"` + StreamName string `json:"streamName,omitempty"` + SDPPath string `json:"sdpPath,omitempty"` } From 6cafbdd3923b616ff28c348a37642f17d0025303 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 20:19:47 +0200 Subject: [PATCH 21/40] control: add web ingest config save and hard reload --- cmd/fmrtx/main.go | 19 +- internal/config/config.go | 15 + internal/control/control.go | 82 ++++++ internal/control/control_test.go | 111 ++++++++ internal/control/ui.html | 465 ++++++++++++++++++++++++++++++- 5 files changed, 689 insertions(+), 3 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 6617414..700913f 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -101,11 +101,12 @@ func main() { if driver == nil { log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)") } - runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) + runTXMode(cfg, *configPath, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) return } srv := ctrlpkg.NewServer(cfg) + configureControlPlanePersistence(srv, *configPath) server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) log.Fatal(server.ListenAndServe()) @@ -140,7 +141,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { return nil } -func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) { +func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -226,6 +227,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a } srv := ctrlpkg.NewServer(cfg) + configureControlPlanePersistence(srv, configPath) srv.SetDriver(driver) srv.SetTXController(&txBridge{engine: engine}) if streamSrc != nil { @@ -269,6 +271,19 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Println("shutdown complete") } +func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string) { + if strings.TrimSpace(configPath) == "" { + return + } + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + srv.SetHardReload(func() { + log.Printf("control: hard reload requested after config save, exiting process") + os.Exit(0) + }) +} + func ingestEnabled(kind string) bool { normalized := strings.ToLower(strings.TrimSpace(kind)) return normalized != "" && normalized != "none" diff --git a/internal/config/config.go b/internal/config/config.go index 2d469ca..45f64bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -237,6 +237,21 @@ func Load(path string) (Config, error) { return cfg, cfg.Validate() } +func Save(path string, cfg Config) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("config path is required") + } + if err := cfg.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + func (c Config) Validate() error { if c.Audio.Gain < 0 || c.Audio.Gain > 4 { return fmt.Errorf("audio.gain out of range") diff --git a/internal/control/control.go b/internal/control/control.go index 131c473..1e9bd9d 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" @@ -54,6 +55,8 @@ type Server struct { streamSrc *audio.StreamSource // optional, for live audio ring stats audioIngress AudioIngress // optional, for /audio/stream ingestRt IngestRuntime // optional, for /runtime ingest stats + saveConfig func(config.Config) error + hardReload func() audit auditCounters } @@ -125,6 +128,10 @@ type ConfigPatch struct { LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` } +type IngestSaveRequest struct { + Ingest config.IngestConfig `json:"ingest"` +} + func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } @@ -219,6 +226,18 @@ func (s *Server) SetIngestRuntime(rt IngestRuntime) { s.mu.Unlock() } +func (s *Server) SetConfigSaver(save func(config.Config) error) { + s.mu.Lock() + s.saveConfig = save + s.mu.Unlock() +} + +func (s *Server) SetHardReload(fn func()) { + s.mu.Lock() + s.hardReload = fn + s.mu.Unlock() +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleUI) @@ -226,6 +245,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/dry-run", s.handleDryRun) mux.HandleFunc("/config", s.handleConfig) + mux.HandleFunc("/config/ingest/save", s.handleIngestSave) mux.HandleFunc("/runtime", s.handleRuntime) mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) mux.HandleFunc("/tx/start", s.handleTXStart) @@ -561,3 +581,65 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } + +func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !isJSONContentType(r) { + s.recordAudit(auditUnsupportedMediaType) + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes) + + var req IngestSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + statusCode := http.StatusBadRequest + if strings.Contains(err.Error(), "http: request body too large") { + statusCode = http.StatusRequestEntityTooLarge + s.recordAudit(auditBodyTooLarge) + } + http.Error(w, err.Error(), statusCode) + return + } + + s.mu.Lock() + next := s.cfg + next.Ingest = req.Ingest + if err := next.Validate(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + save := s.saveConfig + reload := s.hardReload + if save == nil { + s.mu.Unlock() + http.Error(w, "config save is not configured (start with --config )", http.StatusServiceUnavailable) + return + } + if err := save(next); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.cfg = next + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + reloadScheduled := reload != nil + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "saved": true, + "reloadScheduled": reloadScheduled, + }) + if reloadScheduled { + go func(fn func()) { + time.Sleep(250 * time.Millisecond) + fn() + }(reload) + } +} diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 11fa3a8..6134ca2 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -6,8 +6,11 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" @@ -168,6 +171,108 @@ func TestConfigPatchRejectsNonJSONContentType(t *testing.T) { } } +func TestIngestSavePersistsAndSchedulesReload(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + + dir := t.TempDir() + configPath := filepath.Join(dir, "saved.json") + reloadDone := make(chan struct{}, 1) + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + srv.SetHardReload(func() { + select { + case reloadDone <- struct{}{}: + default: + } + }) + + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "srt" + nextIngest.PrebufferMs = 1000 + nextIngest.StallTimeoutMs = 2500 + nextIngest.Reconnect.Enabled = true + nextIngest.Reconnect.InitialBackoffMs = 500 + nextIngest.Reconnect.MaxBackoffMs = 5000 + nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusOK { + t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) + } + select { + case <-reloadDone: + case <-time.After(2 * time.Second): + t.Fatal("expected hard reload callback") + } + saved, err := cfgpkg.Load(configPath) + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if saved.Ingest.Kind != "srt" { + t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind) + } + if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" { + t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL) + } +} + +func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + rec := httptest.NewRecorder() + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "icecast" + nextIngest.Icecast.URL = "https://example.invalid/live" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIngestSaveUsesValidationErrors(t *testing.T) { + cfg := cfgpkg.Default() + cfg.Ingest.Kind = "icecast" + cfg.Ingest.Icecast.URL = "https://example.invalid/live" + srv := NewServer(cfg) + dir := t.TempDir() + configPath := filepath.Join(dir, "saved.json") + srv.SetConfigSaver(func(next cfgpkg.Config) error { + return cfgpkg.Save(configPath, next) + }) + rec := httptest.NewRecorder() + nextIngest := cfgpkg.Default().Ingest + nextIngest.Kind = "srt" + nextIngest.SRT.URL = "" + body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") { + t.Fatalf("expected existing validation error, got %q", rec.Body.String()) + } + if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected no config file to be written, stat err=%v", err) + } +} + func TestRuntimeWithoutDriver(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() @@ -732,6 +837,12 @@ func newConfigPostRequest(body []byte) *http.Request { return req } +func newIngestSavePostRequest(body []byte) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + type fakeTXController struct { updateErr error resetErr error diff --git a/internal/control/ui.html b/internal/control/ui.html index 792962b..f6f5784 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -587,7 +587,7 @@ input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); transform: scale(1.06); } -input[type="number"], input[type="text"] { +input[type="number"], input[type="text"], select { background: #fff; border: 1px solid var(--border); border-radius: 6px; @@ -601,6 +601,10 @@ input[type="number"] { text-align: right; } input[type="text"] { width: 100%; } +select { + min-width: 140px; + width: 100%; +} input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,77,157,.12); @@ -737,6 +741,39 @@ input.input-error { flex-wrap: wrap; margin-top: 14px; } + +.ingest-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ingest-grid .ctrl-row { + align-items: flex-start; + padding: 0; + border-bottom: none; +} + +.ingest-grid .ctrl-label-wrap { + min-width: 0; + gap: 4px; +} + +.ingest-group { + margin-top: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface2); +} + +.ingest-group-title { + margin-bottom: 8px; + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: .08em; +} .apply-btn { background: var(--accent); border-color: transparent; @@ -975,6 +1012,7 @@ input.input-error { .ctrl-row { flex-direction: column; align-items: stretch; } .ctrl-label-wrap { min-width: auto; } .ctrl-input { flex-wrap: wrap; } + .ingest-grid { grid-template-columns: 1fr; } input[type="number"] { width: 100%; text-align: left; } .actions-row, .tx-actions { flex-direction: column; } .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } @@ -1244,6 +1282,230 @@ input.input-error { +
+
+
+

Ingest Config

+
Saved config
+ +
+
+
Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.
+ +
+
+
+ Ingest Kind +
+
+ +
+
+ +
+
+ Prebuffer + ms +
+
+ +
+
+ +
+
+ Stall Timeout + ms +
+
+ +
+
+ +
+
+ Reconnect +
+
+ +
+
+ +
+
+ Backoff Initial + ms +
+
+ +
+
+ +
+
+ Backoff Max + ms +
+
+ +
+
+
+ +
+
Icecast
+
+
+
URL
+
+
+
+
Decoder
+
+ +
+
+
+
RadioText Relay
+
+ +
+
+
+
RT Prefix
+
+
+
+
RT MaxLen
+
+
+
+
RT Only On Change
+
+ +
+
+
+
+ +
+
SRT
+
+
+
URL
+
+
+
+
Mode
+
+ +
+
+
+
Sample Rate
+
+
+
+
Channels
+
+
+
+
+ +
+
AES67
+
+
+
SDP Path
+
+
+
+
SDP Inline
+
+
+
+
Multicast Group
+
+
+
+
Port
+
+
+
+
Payload Type
+
+
+
+
Sample Rate
+
+
+
+
Channels
+
+
+
+
Encoding
+
+
+
+
Packet Time
+
+
+
+
Jitter Depth
+
+
+
+
Read Buffer
+
+
+
+
Discovery
+
+
+
+
Discovery Name
+
+
+
+
Discovery Timeout
+
+
+
+
SAP Group
+
+
+
+
SAP Port
+
+
+
+
+ +
+
+ + +
+
+
+
@@ -1437,6 +1699,10 @@ const state = { toggleBusy: {}, pollersStarted: false, mobilePanelsApplied: false, + ingestDraft: null, + ingestDirty: false, + ingestSaving: false, + ingestError: '', charts: { audio: [], underruns: [], @@ -1462,6 +1728,68 @@ function nearlyEqual(a, b, eps = 1e-9) { function nowTs() { return Date.now(); } +function deepClone(obj) { + return JSON.parse(JSON.stringify(obj ?? {})); +} + +function getPathValue(obj, path) { + if (!obj) return undefined; + const parts = String(path || '').split('.'); + let cur = obj; + for (const part of parts) { + if (!part) continue; + if (cur == null || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + return cur; +} + +function setPathValue(obj, path, value) { + const parts = String(path || '').split('.'); + let cur = obj; + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (!part) continue; + if (i === parts.length - 1) { + cur[part] = value; + return; + } + if (!cur[part] || typeof cur[part] !== 'object') cur[part] = {}; + cur = cur[part]; + } +} + +function ingestFromServer() { + return state.server.config?.ingest || {}; +} + +function updateIngestDirtyFromServer() { + const serverRaw = ingestFromServer(); + const draftRaw = state.ingestDraft || {}; + state.ingestDirty = JSON.stringify(draftRaw) !== JSON.stringify(serverRaw); +} + +function syncIngestDraftFromConfig(force = false) { + if (!state.server.config) return; + if (!state.ingestDraft || force || !state.ingestDirty) { + state.ingestDraft = deepClone(ingestFromServer()); + state.ingestError = ''; + } + updateIngestDirtyFromServer(); +} + +function ingestFieldValue(path) { + return getPathValue(state.ingestDraft || {}, path); +} + +function setIngestField(path, value) { + if (!state.ingestDraft) state.ingestDraft = deepClone(ingestFromServer()); + setPathValue(state.ingestDraft, path, value); + updateIngestDirtyFromServer(); + state.ingestError = ''; + render(); +} + function serverValue(key) { const cfg = state.server.config; if (!cfg) return undefined; @@ -1578,6 +1906,7 @@ async function loadConfig({ silent = false } = {}) { state.server.config = cfg; state.server.configOk = true; state.server.lastConfigAt = nowTs(); + syncIngestDraftFromConfig(); syncFreqPresetIndex(cfg.fm?.frequencyMHz); setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected'); render(); @@ -1790,6 +2119,52 @@ function resetSection(section) { toast('Draft reset', 'info'); } +async function saveIngestConfig() { + if (state.ingestSaving) return; + if (!state.ingestDirty) { + toast('No ingest changes to save', 'info'); + return; + } + if (!state.ingestDraft) { + toast('Ingest draft not ready yet', 'warn'); + return; + } + state.ingestSaving = true; + state.ingestError = ''; + beginRequest(); + render(); + try { + const result = await api('/config/ingest/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ingest: state.ingestDraft }), + }); + state.ingestDirty = false; + toast(result.reloadScheduled ? 'Ingest saved, hard reload scheduled' : 'Ingest saved', 'ok'); + log('INGEST save accepted' + (result.reloadScheduled ? ' [hard-reload]' : ''), 'ok'); + if (result.reloadScheduled) { + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } catch (error) { + state.ingestError = error.message; + toast(error.message, 'err'); + log('INGEST save failed: ' + error.message, 'err'); + } finally { + state.ingestSaving = false; + endRequest(); + render(); + } +} + +function resetIngestDraft() { + syncIngestDraftFromConfig(true); + toast('Ingest draft reset', 'info'); + log('INGEST draft reset', 'warn'); + render(); +} + async function setToggle(key, nextValue) { if (state.toggleBusy[key]) return; state.toggleBusy[key] = true; @@ -1920,6 +2295,21 @@ function syncDirtyInput(id, key, transform = (v) => v) { el.classList.toggle('input-error', !!state.errors[key]); } +function syncIngestInput(id, path, transform = (v) => v) { + const el = $(id); + if (!el) return; + const value = transform(ingestFieldValue(path)); + const asString = value == null ? '' : String(value); + const isFocused = document.activeElement === el; + if (!isFocused && el.value !== asString) el.value = asString; +} + +function syncIngestCheckbox(id, path) { + const el = $(id); + if (!el) return; + el.checked = !!ingestFieldValue(path); +} + function renderFieldErrors() { renderFieldError('freq-error', state.errors.frequencyMHz); renderFieldError('ps-error', state.errors.ps); @@ -2075,6 +2465,41 @@ function render() { syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0'); syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? '')); syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? '')); + syncIngestInput('ing-kind', 'kind', (v) => String(v ?? 'none')); + syncIngestInput('ing-prebuffer', 'prebufferMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestInput('ing-stall-timeout', 'stallTimeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestCheckbox('ing-reconnect-enabled', 'reconnect.enabled'); + syncIngestInput('ing-reconnect-initial', 'reconnect.initialBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + syncIngestInput('ing-reconnect-max', 'reconnect.maxBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); + + syncIngestInput('ing-icecast-url', 'icecast.url', (v) => String(v ?? '')); + syncIngestInput('ing-icecast-decoder', 'icecast.decoder', (v) => String(v ?? 'auto')); + syncIngestCheckbox('ing-icecast-rt-enabled', 'icecast.radioText.enabled'); + syncIngestInput('ing-icecast-rt-prefix', 'icecast.radioText.prefix', (v) => String(v ?? '')); + syncIngestInput('ing-icecast-rt-maxlen', 'icecast.radioText.maxLen', (v) => Number.isFinite(Number(v)) ? Number(v) : 64); + syncIngestCheckbox('ing-icecast-rt-only-change', 'icecast.radioText.onlyOnChange'); + + syncIngestInput('ing-srt-url', 'srt.url', (v) => String(v ?? '')); + syncIngestInput('ing-srt-mode', 'srt.mode', (v) => String(v ?? 'listener')); + syncIngestInput('ing-srt-rate', 'srt.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); + syncIngestInput('ing-srt-channels', 'srt.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); + + syncIngestInput('ing-aes67-sdppath', 'aes67.sdpPath', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-sdp', 'aes67.sdp', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-group', 'aes67.multicastGroup', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-port', 'aes67.port', (v) => Number.isFinite(Number(v)) ? Number(v) : 5004); + syncIngestInput('ing-aes67-pt', 'aes67.payloadType', (v) => Number.isFinite(Number(v)) ? Number(v) : 97); + syncIngestInput('ing-aes67-rate', 'aes67.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); + syncIngestInput('ing-aes67-channels', 'aes67.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); + syncIngestInput('ing-aes67-encoding', 'aes67.encoding', (v) => String(v ?? 'L24')); + syncIngestInput('ing-aes67-ptime', 'aes67.packetTimeMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 1); + syncIngestInput('ing-aes67-jitter', 'aes67.jitterDepthPackets', (v) => Number.isFinite(Number(v)) ? Number(v) : 8); + syncIngestInput('ing-aes67-readbuf', 'aes67.readBufferBytes', (v) => Number.isFinite(Number(v)) ? Number(v) : 1048576); + syncIngestCheckbox('ing-aes67-discovery-enabled', 'aes67.discovery.enabled'); + syncIngestInput('ing-aes67-discovery-name', 'aes67.discovery.streamName', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-discovery-timeout', 'aes67.discovery.timeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 3000); + syncIngestInput('ing-aes67-discovery-group', 'aes67.discovery.sapGroup', (v) => String(v ?? '')); + syncIngestInput('ing-aes67-discovery-port', 'aes67.discovery.sapPort', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? ''); const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? ''); @@ -2093,9 +2518,20 @@ function render() { const rdsDirty = isDirtySection('rds'); $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds'); $('rds-reset').disabled = !rdsDirty; + const ingestKind = String(ingestFieldValue('kind') || 'none').toLowerCase(); + $('ing-group-icecast').style.display = ingestKind === 'icecast' ? '' : 'none'; + $('ing-group-srt').style.display = ingestKind === 'srt' ? '' : 'none'; + $('ing-group-aes67').style.display = ingestKind === 'aes67' ? '' : 'none'; + $('ingest-save-reload').disabled = !state.ingestDirty || state.ingestSaving || !state.server.configOk; + $('ingest-save-reload').textContent = state.ingestSaving ? 'Saving...' : 'Save + Hard Reload'; + $('ingest-reset').disabled = !state.ingestDirty || state.ingestSaving; + const ingestErr = $('ingest-error'); + ingestErr.textContent = state.ingestError || ''; + ingestErr.classList.toggle('show', !!state.ingestError); updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable')); updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); + updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config')); updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); @@ -2532,6 +2968,33 @@ function bindInputs() { $('rds-apply').addEventListener('click', () => applySection('rds')); $('freq-reset').addEventListener('click', () => resetSection('freq')); $('rds-reset').addEventListener('click', () => resetSection('rds')); + $('ingest-save-reload').addEventListener('click', () => saveIngestConfig()); + $('ingest-reset').addEventListener('click', () => resetIngestDraft()); + + document.querySelectorAll('[data-ingest-path]').forEach((el) => { + const path = el.dataset.ingestPath; + const tag = (el.tagName || '').toLowerCase(); + const type = String(el.getAttribute('type') || '').toLowerCase(); + const isCheckbox = type === 'checkbox'; + const isNumber = type === 'number'; + const evt = isCheckbox ? 'change' : 'input'; + el.addEventListener(evt, () => { + if (isCheckbox) { + setIngestField(path, !!el.checked); + return; + } + if (isNumber) { + const n = Number(el.value); + setIngestField(path, Number.isFinite(n) ? Math.trunc(n) : 0); + return; + } + if (tag === 'select') { + setIngestField(path, String(el.value || '')); + return; + } + setIngestField(path, String(el.value || '')); + }); + }); $('btn-start').addEventListener('click', () => txAction('start')); $('btn-stop').addEventListener('click', () => txAction('stop')); From 6a23b6c3130e6493fce16af92efaa4f1ca8c578e Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 20:28:19 +0200 Subject: [PATCH 22/40] control: move ingest configuration into dedicated tab --- internal/control/ui.html | 183 +++++++++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 46 deletions(-) diff --git a/internal/control/ui.html b/internal/control/ui.html index f6f5784..f63de10 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -774,6 +774,15 @@ input.input-error { text-transform: uppercase; letter-spacing: .08em; } +.ingest-summary-card .sidebar-section { + margin: 0; + padding: 0; + border: none; +} +.ingest-summary-kv .v { + font-family: var(--mono); + font-size: 11px; +} .apply-btn { background: var(--accent); border-color: transparent; @@ -1046,6 +1055,7 @@ input.input-error {
+
@@ -1282,6 +1292,76 @@ input.input-error {
+ +
+
+
+

Shortcuts

+
keyboard
+ +
+
+
Fast control reference. Shortcuts stay out of the main operator path.
+
+
+
Start TXt
+
Stop TXShiftt
+
Refreshr
+
+
+
Next Freq Preset]
+
Prev Freq Preset[
+
Apply DraftEnter
+
+
+
+
+ + +
+
+

Danger Zone

+
tx control
+ +
+
+
Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.
+
+ + + + +
+
+ Reset Fault moves the runtime back to DEGRADED while the queue settles before running again. +
+
+
+ + +
+ + + + +
+
+
+ +
@@ -1506,52 +1586,6 @@ input.input-error {
-
-
-
-
-

Shortcuts

-
keyboard
- -
-
-
Fast control reference. Shortcuts stay out of the main operator path.
-
-
-
Start TXt
-
Stop TXShiftt
-
Refreshr
-
-
-
Next Freq Preset]
-
Prev Freq Preset[
-
Apply DraftEnter
-
-
-
-
- - -
-
-

Danger Zone

-
tx control
- -
-
-
Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.
-
- - - - -
-
- Reset Fault moves the runtime back to DEGRADED while the queue settles before running again. -
-
-
-
@@ -2271,6 +2305,18 @@ function ageString(ts) { return h + 'h ago'; } +function ageFromTimestamp(value) { + if (!value) return '--'; + if (typeof value === 'number') return ageString(value); + const ts = Date.parse(String(value)); + if (Number.isNaN(ts)) return '--'; + return ageString(ts); +} + +function joinSummaryParts(parts) { + return parts.filter((part) => String(part || '').trim() !== '').join(' · '); +} + function updateText(id, text) { const el = $(id); if (el && el.textContent !== String(text)) el.textContent = text; @@ -2409,6 +2455,11 @@ function render() { const engine = runtime.engine || {}; const driver = runtime.driver || {}; const audioStream = runtime.audioStream || null; + const hasIngestRuntime = !!runtime.ingest; + const ingest = runtime.ingest || {}; + const ingestActive = ingest.active || {}; + const ingestSource = ingest.source || {}; + const ingestRuntime = ingest.runtime || {}; const appliedRaw = engine.appliedFrequencyMHz; const appliedFreq = Number.isFinite(Number(appliedRaw)) ? Number(appliedRaw) : null; @@ -2533,6 +2584,45 @@ function render() { updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config')); + const configuredKind = String(cfg.ingest?.kind || ingestFieldValue('kind') || 'none').toLowerCase(); + const activeKind = String(ingestActive.kind || configuredKind || 'none').toLowerCase(); + const runtimeStateLabel = String(ingestRuntime.state || '').toLowerCase(); + const sourceStateLabel = String(ingestSource.state || '').toLowerCase(); + const stateSummary = hasIngestRuntime ? (joinSummaryParts([ + runtimeStateLabel ? runtimeStateLabel.toUpperCase() : '', + sourceStateLabel ? `source ${sourceStateLabel.toUpperCase()}` : '', + ingestRuntime.prebuffering ? 'PREBUFFERING' : '', + ingestRuntime.writeBlocked ? 'WRITE-BLOCKED' : '', + ]) || '--') : '--'; + const sourceSummary = joinSummaryParts([ + activeKind || 'none', + ingestActive.transport || '', + ingestActive.codec || '', + Number.isFinite(Number(ingestActive.sampleRateHz)) ? `${Number(ingestActive.sampleRateHz)} Hz` : '', + Number.isFinite(Number(ingestActive.channels)) ? `${Number(ingestActive.channels)} ch` : '', + ]) || '--'; + const signalSummary = hasIngestRuntime ? (joinSummaryParts([ + ingestSource.connected ? 'connected' : 'disconnected', + Number.isFinite(Number(ingestSource.bufferedSeconds)) ? `${Number(ingestSource.bufferedSeconds).toFixed(2)}s buffered` : '', + Number.isFinite(Number(ingestSource.reconnects)) ? `${Number(ingestSource.reconnects)} reconnects` : '', + ]) || '--') : '--'; + const detailSummary = hasIngestRuntime ? (ingestSource.streamTitle || ingestActive.detail || ingestSource.lastError || '--') : '--'; + const origin = ingestActive.origin || {}; + const originSummary = hasIngestRuntime ? (joinSummaryParts([ + origin.kind || '', + origin.endpoint || '', + origin.streamName || '', + origin.mode || '', + origin.sdpPath || '', + ]) || '--') : '--'; + const lastChunkSummary = hasIngestRuntime ? ageFromTimestamp(ingestRuntime.lastChunkAt || ingestSource.lastChunkAt) : '--'; + updateText('ingest-summary-state', stateSummary); + updateText('ingest-summary-source', sourceSummary); + updateText('ingest-summary-signal', signalSummary); + updateText('ingest-summary-detail', detailSummary); + updateText('ingest-summary-origin', originSummary); + updateText('ingest-summary-last', lastChunkSummary); + updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off'); @@ -3147,3 +3237,4 @@ init(); + From faf1aed47290c07869c04f2b659ab8a80394231e Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 20:39:52 +0200 Subject: [PATCH 23/40] ingest: reconnect icecast streams and propagate chunk metadata --- internal/ingest/adapters/icecast/source.go | 7 ++- .../ingest/adapters/icecast/source_test.go | 51 +++++++++++++++++++ internal/ingest/runtime.go | 6 +++ internal/ingest/runtime_test.go | 31 +++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 784891d..2970e82 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -75,7 +75,9 @@ func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Op id = "icecast-main" } if client == nil { - client = &http.Client{Timeout: 20 * time.Second} + // Streaming responses are long-lived; a global client timeout would + // terminate the body read after a fixed duration. + client = &http.Client{} } s := &Source{ id: id, @@ -202,9 +204,6 @@ func (s *Source) loop(ctx context.Context) { if err == nil { err = errStreamEnded } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return - } s.connected.Store(false) s.lastError.Store(err.Error()) select { diff --git a/internal/ingest/adapters/icecast/source_test.go b/internal/ingest/adapters/icecast/source_test.go index 9984269..3e378bb 100644 --- a/internal/ingest/adapters/icecast/source_test.go +++ b/internal/ingest/adapters/icecast/source_test.go @@ -511,6 +511,57 @@ func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) { } } +func TestNewWithoutClientUsesStreamingSafeHTTPClient(t *testing.T) { + src := New("ice-test", "http://example", nil, ReconnectConfig{}) + if src.client == nil { + t.Fatal("expected default http client") + } + if src.client.Timeout != 0 { + t.Fatalf("client timeout=%v want 0 for streaming", src.client.Timeout) + } +} + +func TestSourceReconnectsAfterDeadlineExceededError(t *testing.T) { + var requests atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("test-stream")) + })) + defer srv.Close() + + dec := &scriptedLoopDecoder{ + actions: []decodeAction{ + {err: context.DeadlineExceeded}, // first attempt fails transiently + {blockUntilStop: true}, // second attempt recovers and stays running + }, + } + reg := decoder.NewRegistry() + reg.Register("mp3", func() decoder.Decoder { return dec }) + reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} }) + + src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{ + Enabled: true, + InitialBackoffMs: 1, + MaxBackoffMs: 1, + }, WithDecoderRegistry(reg), WithDecoderPreference("auto")) + + if err := src.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer src.Stop() + + waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after deadline exceeded") + + stats := src.Stats() + if stats.Reconnects < 1 { + t.Fatalf("reconnects=%d want >=1", stats.Reconnects) + } + if got := requests.Load(); got < 2 { + t.Fatalf("requests=%d want >=2", got) + } +} + func waitForCondition(t *testing.T, cond func() bool, label string) { t.Helper() deadline := time.Now().Add(2 * time.Second) diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index 6b9e1ef..a6741b8 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -161,6 +161,12 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { } } r.mu.Lock() + if chunk.SampleRateHz > 0 { + r.active.SampleRateHz = chunk.SampleRateHz + } + if chunk.Channels > 0 { + r.active.Channels = chunk.Channels + } r.stats.State = "running" r.stats.LastChunkAt = time.Now() r.stats.DroppedFrames += dropped diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index 48cfcb3..1fc7c36 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -164,6 +164,37 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) } } +func TestRuntimeUpdatesActiveDescriptorFromChunkMetadata(t *testing.T) { + sink := audio.NewStreamSource(128, 44100) + src := newFakeSource() + src.desc = SourceDescriptor{ + ID: "icecast-primary", + Kind: "icecast", + Channels: 0, + SampleRateHz: 0, + } + rt := NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 48000, + Samples: []int32{100 << 16, -100 << 16}, + } + + waitForRuntimeState(t, rt, "running") + stats := rt.Stats() + if stats.Active.SampleRateHz != 48000 { + t.Fatalf("active sampleRateHz=%d want 48000", stats.Active.SampleRateHz) + } + if stats.Active.Channels != 2 { + t.Fatalf("active channels=%d want 2", stats.Active.Channels) + } +} + func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) { sink := audio.NewStreamSource(128, 44100) src := newFakeSource() From 33b9640ef02134fc3ac823d564e93f42d45d9fd3 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 21:06:42 +0200 Subject: [PATCH 24/40] ingest: add runtime working buffer and prebuffer gate --- internal/ingest/runtime.go | 217 ++++++++++++++++++++++++++++++-- internal/ingest/runtime_test.go | 109 +++++++++++++++- internal/ingest/stats.go | 14 ++- 3 files changed, 321 insertions(+), 19 deletions(-) diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index a6741b8..46f45d0 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -10,15 +10,22 @@ import ( ) type Runtime struct { - sink *audio.StreamSource - source Source - started atomic.Bool - onTitle func(string) + sink *audio.StreamSource + source Source + started atomic.Bool + onTitle func(string) + prebuffer time.Duration ctx context.Context cancel context.CancelFunc wg sync.WaitGroup + work *frameBuffer + workSampleRate int + prebufferFrames int + gateOpen bool + seenChunk bool + mu sync.RWMutex active SourceDescriptor stats RuntimeStats @@ -32,10 +39,40 @@ func WithStreamTitleHandler(handler func(string)) RuntimeOption { } } +func WithPrebuffer(d time.Duration) RuntimeOption { + return func(r *Runtime) { + if d < 0 { + d = 0 + } + r.prebuffer = d + } +} + +func WithPrebufferMs(ms int) RuntimeOption { + return func(r *Runtime) { + if ms < 0 { + ms = 0 + } + r.prebuffer = time.Duration(ms) * time.Millisecond + } +} + func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Runtime { + sampleRate := 44100 + capacity := 1024 + if sink != nil { + if sink.SampleRate > 0 { + sampleRate = sink.SampleRate + } + if sinkCap := sink.Stats().Capacity; sinkCap > 0 { + capacity = sinkCap * 2 + } + } r := &Runtime{ - sink: sink, - source: src, + sink: sink, + source: src, + work: newFrameBuffer(capacity), + workSampleRate: sampleRate, stats: RuntimeStats{ State: "idle", }, @@ -45,6 +82,17 @@ func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Ru opt(r) } } + if r.workSampleRate > 0 && r.prebuffer > 0 { + r.prebufferFrames = int(r.prebuffer.Seconds() * float64(r.workSampleRate)) + } + minCapacity := 256 + if r.prebufferFrames > 0 && minCapacity < r.prebufferFrames*2 { + minCapacity = r.prebufferFrames * 2 + } + if r.work == nil || r.work.capacity() < minCapacity { + r.work = newFrameBuffer(minCapacity) + } + r.updateBufferedStatsLocked() return r } @@ -69,6 +117,12 @@ func (r *Runtime) Start(ctx context.Context) error { r.mu.Lock() r.active = r.source.Descriptor() r.stats.State = "starting" + r.stats.Prebuffering = false + r.stats.WriteBlocked = false + r.gateOpen = false + r.seenChunk = false + r.work.reset() + r.updateBufferedStatsLocked() r.mu.Unlock() if err := r.source.Start(r.ctx); err != nil { r.started.Store(false) @@ -102,12 +156,11 @@ func (r *Runtime) Stop() error { 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() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() var titleCh <-chan string if src, ok := r.source.(StreamTitleSource); ok && r.onTitle != nil { titleCh = src.StreamTitleUpdates() @@ -126,15 +179,19 @@ func (r *Runtime) run() { } r.mu.Lock() r.stats.State = "degraded" + r.stats.Prebuffering = false r.mu.Unlock() case chunk, ok := <-ch: if !ok { r.mu.Lock() r.stats.State = "stopped" + r.stats.Prebuffering = false r.mu.Unlock() return } r.handleChunk(chunk) + case <-ticker.C: + r.drainWorkingBuffer() case title, ok := <-titleCh: if !ok { titleCh = nil @@ -146,6 +203,10 @@ func (r *Runtime) run() { } func (r *Runtime) handleChunk(chunk PCMChunk) { + r.mu.Lock() + r.seenChunk = true + r.mu.Unlock() + frames, err := ChunkToFrames(chunk) if err != nil { r.mu.Lock() @@ -156,7 +217,7 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { } dropped := uint64(0) for _, frame := range frames { - if !r.sink.WriteFrame(frame) { + if !r.work.push(frame) { dropped++ } } @@ -167,11 +228,87 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { if chunk.Channels > 0 { r.active.Channels = chunk.Channels } - r.stats.State = "running" r.stats.LastChunkAt = time.Now() r.stats.DroppedFrames += dropped - r.stats.WriteBlocked = dropped > 0 + if dropped > 0 { + r.stats.State = "degraded" + } + r.updateBufferedStatsLocked() r.mu.Unlock() + r.drainWorkingBuffer() +} + +func (r *Runtime) drainWorkingBuffer() { + r.mu.Lock() + defer r.mu.Unlock() + if r.sink == nil { + r.updateBufferedStatsLocked() + return + } + bufferedFrames := r.work.available() + if !r.gateOpen { + switch { + case bufferedFrames == 0: + if r.stats.State == "degraded" { + // Keep degraded visible until fresh audio recovers runtime. + } else if !r.seenChunk { + r.stats.State = "starting" + } else if r.stats.State != "degraded" { + r.stats.State = "running" + } + r.stats.Prebuffering = false + r.stats.WriteBlocked = false + r.updateBufferedStatsLocked() + return + case r.prebufferFrames > 0 && bufferedFrames < r.prebufferFrames: + r.stats.State = "prebuffering" + r.stats.Prebuffering = true + r.stats.WriteBlocked = false + r.updateBufferedStatsLocked() + return + default: + r.gateOpen = true + } + } + writeBlocked := false + for r.work.available() > 0 { + frame, ok := r.work.peek() + if !ok { + break + } + if !r.sink.WriteFrame(frame) { + writeBlocked = true + break + } + r.work.pop() + } + if r.work.available() == 0 && r.prebufferFrames > 0 { + // Re-arm the gate after dry-out to rebuild margin before resuming. + r.gateOpen = false + } + r.stats.Prebuffering = false + r.stats.WriteBlocked = writeBlocked + if writeBlocked { + r.stats.State = "degraded" + } else { + r.stats.State = "running" + } + r.updateBufferedStatsLocked() +} + +func (r *Runtime) updateBufferedStatsLocked() { + available := r.work.available() + capacity := r.work.capacity() + buffered := 0.0 + if capacity > 0 { + buffered = float64(available) / float64(capacity) + } + bufferedSeconds := 0.0 + if r.workSampleRate > 0 { + bufferedSeconds = float64(available) / float64(r.workSampleRate) + } + r.stats.Buffered = buffered + r.stats.BufferedSeconds = bufferedSeconds } func (r *Runtime) Stats() Stats { @@ -184,9 +321,65 @@ func (r *Runtime) Stats() Stats { if r.source != nil { sourceStats = r.source.Stats() } + if sourceStats.BufferedSeconds < runtimeStats.BufferedSeconds { + sourceStats.BufferedSeconds = runtimeStats.BufferedSeconds + } return Stats{ Active: active, Source: sourceStats, Runtime: runtimeStats, } } + +type frameBuffer struct { + frames []audio.Frame + head int + len int +} + +func newFrameBuffer(capacity int) *frameBuffer { + if capacity < 1 { + capacity = 1 + } + return &frameBuffer{frames: make([]audio.Frame, capacity)} +} + +func (b *frameBuffer) capacity() int { + return len(b.frames) +} + +func (b *frameBuffer) available() int { + return b.len +} + +func (b *frameBuffer) reset() { + b.head = 0 + b.len = 0 +} + +func (b *frameBuffer) push(frame audio.Frame) bool { + if b.len >= len(b.frames) { + return false + } + idx := (b.head + b.len) % len(b.frames) + b.frames[idx] = frame + b.len++ + return true +} + +func (b *frameBuffer) peek() (audio.Frame, bool) { + if b.len == 0 { + return audio.Frame{}, false + } + return b.frames[b.head], true +} + +func (b *frameBuffer) pop() (audio.Frame, bool) { + if b.len == 0 { + return audio.Frame{}, false + } + frame := b.frames[b.head] + b.head = (b.head + 1) % len(b.frames) + b.len-- + return frame, true +} diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index 1fc7c36..b8f65a9 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -147,7 +147,6 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) t.Fatalf("start: %v", err) } defer rt.Stop() - waitForRuntimeState(t, rt, "running") stats := rt.Stats() if stats.Active.ID != "icecast-primary" { @@ -164,6 +163,94 @@ func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) } } +func TestRuntimePrebufferGateAppliesBeforeSinkWrites(t *testing.T) { + sink := audio.NewStreamSource(512, 1000) + src := newFakeSource() + rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond)) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 1000, + Samples: stereoSamples(80, 100), + } + + time.Sleep(30 * time.Millisecond) + if sink.Available() != 0 { + t.Fatalf("sink available=%d want 0 while prebuffering", sink.Available()) + } + stats := rt.Stats() + if stats.Runtime.State != "prebuffering" || !stats.Runtime.Prebuffering { + t.Fatalf("runtime state=%q prebuffering=%t", stats.Runtime.State, stats.Runtime.Prebuffering) + } + if stats.Runtime.BufferedSeconds <= 0 { + t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds) + } + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 1000, + Samples: stereoSamples(40, 120), + } + waitForSinkFrames(t, sink, 1) + waitForRuntimeState(t, rt, "running") + if got := rt.Stats().Runtime.Prebuffering; got { + t.Fatalf("runtime prebuffering=%t want false", got) + } +} + +func TestRuntimeWriteBlockedRetainsWorkingBuffer(t *testing.T) { + sink := audio.NewStreamSource(1, 1000) + src := newFakeSource() + rt := NewRuntime(sink, src) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 1000, + Samples: stereoSamples(4, 200), + } + waitForRuntimeState(t, rt, "degraded") + stats := rt.Stats() + if !stats.Runtime.WriteBlocked { + t.Fatalf("runtime writeBlocked=%t want true", stats.Runtime.WriteBlocked) + } + if stats.Runtime.BufferedSeconds <= 0 { + t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds) + } + if stats.Runtime.DroppedFrames != 0 { + t.Fatalf("runtime droppedFrames=%d want 0", stats.Runtime.DroppedFrames) + } +} + +func TestRuntimeStatsSourceBufferedSecondsIncludesWorkingBuffer(t *testing.T) { + sink := audio.NewStreamSource(32, 1000) + src := newFakeSource() + src.stats = SourceStats{State: "running", Connected: true, BufferedSeconds: 0} + rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond)) + if err := rt.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer rt.Stop() + + src.chunks <- PCMChunk{ + Channels: 2, + SampleRateHz: 1000, + Samples: stereoSamples(50, 300), + } + time.Sleep(20 * time.Millisecond) + stats := rt.Stats() + if stats.Source.BufferedSeconds <= 0 { + t.Fatalf("source bufferedSeconds=%f want > 0", stats.Source.BufferedSeconds) + } +} + func TestRuntimeUpdatesActiveDescriptorFromChunkMetadata(t *testing.T) { sink := audio.NewStreamSource(128, 44100) src := newFakeSource() @@ -230,3 +317,23 @@ func waitForRuntimeState(t *testing.T, rt *Runtime, want string) { } t.Fatalf("timeout waiting for runtime state %q; last=%q", want, rt.Stats().Runtime.State) } + +func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { + t.Helper() + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + if sink.Available() >= minFrames { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames) +} + +func stereoSamples(frames int, v int32) []int32 { + out := make([]int32, 0, frames*2) + for i := 0; i < frames; i++ { + out = append(out, v<<16, -v<<16) + } + return out +} diff --git a/internal/ingest/stats.go b/internal/ingest/stats.go index 55f44a4..19e9886 100644 --- a/internal/ingest/stats.go +++ b/internal/ingest/stats.go @@ -24,12 +24,14 @@ type SourceStats struct { } type RuntimeStats struct { - State string `json:"state"` - Prebuffering bool `json:"prebuffering"` - LastChunkAt time.Time `json:"lastChunkAt,omitempty"` - DroppedFrames uint64 `json:"droppedFrames"` - ConvertErrors uint64 `json:"convertErrors"` - WriteBlocked bool `json:"writeBlocked"` + State string `json:"state"` + Prebuffering bool `json:"prebuffering"` + Buffered float64 `json:"buffered"` + BufferedSeconds float64 `json:"bufferedSeconds"` + LastChunkAt time.Time `json:"lastChunkAt,omitempty"` + DroppedFrames uint64 `json:"droppedFrames"` + ConvertErrors uint64 `json:"convertErrors"` + WriteBlocked bool `json:"writeBlocked"` } type Stats struct { From 04afc1a551c8d8d03f5df323fe3de7616dab07eb Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 21:32:46 +0200 Subject: [PATCH 25/40] ingest: wire prebuffer into runtime and pace sink drain --- cmd/fmrtx/main.go | 1 + internal/ingest/runtime.go | 77 ++++++++++++++++++++++++++++++++- internal/ingest/runtime_test.go | 68 +++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 700913f..5354f08 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -193,6 +193,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver log.Fatalf("ingest source: %v", err) } runtimeOpts := []ingest.RuntimeOption{} + runtimeOpts = append(runtimeOpts, ingest.WithPrebufferMs(cfg.Ingest.PrebufferMs)) if cfg.Ingest.Icecast.RadioText.Enabled { relay := icecast.NewRadioTextRelay( icecast.RadioTextOptions{ diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index 46f45d0..5c6167d 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -25,6 +25,8 @@ type Runtime struct { prebufferFrames int gateOpen bool seenChunk bool + lastDrainAt time.Time + drainAllowance float64 mu sync.RWMutex active SourceDescriptor @@ -121,6 +123,8 @@ func (r *Runtime) Start(ctx context.Context) error { r.stats.WriteBlocked = false r.gateOpen = false r.seenChunk = false + r.lastDrainAt = time.Now() + r.drainAllowance = 0 r.work.reset() r.updateBufferedStatsLocked() r.mu.Unlock() @@ -241,7 +245,9 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { func (r *Runtime) drainWorkingBuffer() { r.mu.Lock() defer r.mu.Unlock() + now := time.Now() if r.sink == nil { + r.resetDrainPacerLocked(now) r.updateBufferedStatsLocked() return } @@ -258,20 +264,25 @@ func (r *Runtime) drainWorkingBuffer() { } r.stats.Prebuffering = false r.stats.WriteBlocked = false + r.resetDrainPacerLocked(now) r.updateBufferedStatsLocked() return case r.prebufferFrames > 0 && bufferedFrames < r.prebufferFrames: r.stats.State = "prebuffering" r.stats.Prebuffering = true r.stats.WriteBlocked = false + r.resetDrainPacerLocked(now) r.updateBufferedStatsLocked() return default: r.gateOpen = true + r.resetDrainPacerLocked(now) } } writeBlocked := false - for r.work.available() > 0 { + limit := r.pacedDrainLimitLocked(now, bufferedFrames) + written := 0 + for written < limit && r.work.available() > 0 { frame, ok := r.work.peek() if !ok { break @@ -281,10 +292,18 @@ func (r *Runtime) drainWorkingBuffer() { break } r.work.pop() + written++ + } + if written > 0 { + r.drainAllowance -= float64(written) + if r.drainAllowance < 0 { + r.drainAllowance = 0 + } } if r.work.available() == 0 && r.prebufferFrames > 0 { // Re-arm the gate after dry-out to rebuild margin before resuming. r.gateOpen = false + r.resetDrainPacerLocked(now) } r.stats.Prebuffering = false r.stats.WriteBlocked = writeBlocked @@ -296,6 +315,62 @@ func (r *Runtime) drainWorkingBuffer() { r.updateBufferedStatsLocked() } +func (r *Runtime) pacedDrainLimitLocked(now time.Time, bufferedFrames int) int { + if bufferedFrames <= 0 { + return 0 + } + rate := r.workSampleRate + if r.sink != nil && r.sink.SampleRate > 0 { + rate = r.sink.SampleRate + } + if rate <= 0 { + return bufferedFrames + } + if !r.lastDrainAt.IsZero() { + elapsed := now.Sub(r.lastDrainAt) + if elapsed > 0 { + r.drainAllowance += elapsed.Seconds() * float64(rate) + } + } + r.lastDrainAt = now + maxAllowance := maxInt(1, rate/5) // cap accumulated credit at 200 ms + if r.drainAllowance > float64(maxAllowance) { + r.drainAllowance = float64(maxAllowance) + } + limit := int(r.drainAllowance) + if limit <= 0 { + return 0 + } + maxBurst := maxInt(1, rate/50) // max 20 ms worth of frames per drain call + if limit > maxBurst { + limit = maxBurst + } + sinkStats := r.sink.Stats() + headroom := sinkStats.Capacity - sinkStats.Available + if headroom < 0 { + headroom = 0 + } + if limit > headroom { + limit = headroom + } + if limit > bufferedFrames { + limit = bufferedFrames + } + return limit +} + +func (r *Runtime) resetDrainPacerLocked(now time.Time) { + r.lastDrainAt = now + r.drainAllowance = 0 +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + func (r *Runtime) updateBufferedStatsLocked() { available := r.work.available() capacity := r.work.capacity() diff --git a/internal/ingest/runtime_test.go b/internal/ingest/runtime_test.go index b8f65a9..0b351e4 100644 --- a/internal/ingest/runtime_test.go +++ b/internal/ingest/runtime_test.go @@ -216,10 +216,11 @@ func TestRuntimeWriteBlockedRetainsWorkingBuffer(t *testing.T) { SampleRateHz: 1000, Samples: stereoSamples(4, 200), } - waitForRuntimeState(t, rt, "degraded") + waitForSinkFrames(t, sink, 1) + waitForRuntimeState(t, rt, "running") stats := rt.Stats() - if !stats.Runtime.WriteBlocked { - t.Fatalf("runtime writeBlocked=%t want true", stats.Runtime.WriteBlocked) + if stats.Runtime.WriteBlocked { + t.Fatalf("runtime writeBlocked=%t want false", stats.Runtime.WriteBlocked) } if stats.Runtime.BufferedSeconds <= 0 { t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds) @@ -227,6 +228,67 @@ func TestRuntimeWriteBlockedRetainsWorkingBuffer(t *testing.T) { if stats.Runtime.DroppedFrames != 0 { t.Fatalf("runtime droppedFrames=%d want 0", stats.Runtime.DroppedFrames) } + if got := sink.Stats().Overflows; got != 0 { + t.Fatalf("sink overflows=%d want 0", got) + } +} + +func TestRuntimeDrainWorkingBufferIsBurstBounded(t *testing.T) { + sink := audio.NewStreamSource(64, 1000) + rt := NewRuntime(sink, nil) + + rt.gateOpen = true + for i := 0; i < 40; i++ { + if !rt.work.push(audio.NewFrame(0.1, -0.1)) { + t.Fatalf("failed to seed work frame %d", i) + } + } + rt.lastDrainAt = time.Now().Add(-time.Second) + + rt.drainWorkingBuffer() + + if got := sink.Available(); got != 20 { + t.Fatalf("sink available=%d want 20 (20ms burst at 1kHz)", got) + } + if got := rt.work.available(); got != 20 { + t.Fatalf("work available=%d want 20", got) + } + if got := rt.Stats().Runtime.WriteBlocked; got { + t.Fatalf("runtime writeBlocked=%t want false", got) + } +} + +func TestRuntimeDrainWorkingBufferHonorsSinkHeadroom(t *testing.T) { + sink := audio.NewStreamSource(64, 1000) + rt := NewRuntime(sink, nil) + + for i := 0; i < 63; i++ { + if !sink.WriteFrame(audio.NewFrame(0.2, -0.2)) { + t.Fatalf("failed to seed sink frame %d", i) + } + } + rt.gateOpen = true + for i := 0; i < 8; i++ { + if !rt.work.push(audio.NewFrame(0.3, -0.3)) { + t.Fatalf("failed to seed work frame %d", i) + } + } + rt.lastDrainAt = time.Now().Add(-time.Second) + + rt.drainWorkingBuffer() + + if got := sink.Available(); got != 64 { + t.Fatalf("sink available=%d want 64", got) + } + if got := rt.work.available(); got != 7 { + t.Fatalf("work available=%d want 7", got) + } + if got := sink.Stats().Overflows; got != 0 { + t.Fatalf("sink overflows=%d want 0", got) + } + if got := rt.Stats().Runtime.WriteBlocked; got { + t.Fatalf("runtime writeBlocked=%t want false", got) + } } func TestRuntimeStatsSourceBufferedSecondsIncludesWorkingBuffer(t *testing.T) { From 14ad4a7b010da4f0d40c23e0aa44638da32bfd06 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 21:38:05 +0200 Subject: [PATCH 26/40] ingest: sync sink sample rate with decoded stream --- internal/audio/stream.go | 126 ++++++++++++++++++++++++++----------- internal/ingest/runtime.go | 29 ++++++++- 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/internal/audio/stream.go b/internal/audio/stream.go index 6366f93..8ca9584 100644 --- a/internal/audio/stream.go +++ b/internal/audio/stream.go @@ -12,19 +12,31 @@ import ( // goroutine reads them via NextFrame(). Returns silence on underrun. // // Zero allocations in steady state. No mutex in the read or write path. +// +// SampleRate is the nominal input sample rate. It may be updated at runtime +// via SetSampleRate once the actual decoded rate is known (e.g. when the first +// PCM chunk arrives from a compressed stream). Reads and writes to the sample +// rate are atomic so they are safe across goroutines. type StreamSource struct { - ring []Frame - size int - mask int // size-1, for fast modulo (size must be power of 2) + ring []Frame + size int + mask int // size-1, for fast modulo (size must be power of 2) + + // SampleRate is kept as a plain int for backward compatibility with code + // that reads it before any goroutine races are possible (construction, + // logging). All hot-path code uses the atomic below. SampleRate int + sampleRateAtomic atomic.Int32 + writePos atomic.Int64 readPos atomic.Int64 Underruns atomic.Uint64 Overflows atomic.Uint64 Written atomic.Uint64 - highWatermark atomic.Int64 + + highWatermark atomic.Int64 underrunStreak atomic.Uint64 maxUnderrunStreak atomic.Uint64 } @@ -37,12 +49,29 @@ func NewStreamSource(capacity, sampleRate int) *StreamSource { for size < capacity { size <<= 1 } - return &StreamSource{ + s := &StreamSource{ ring: make([]Frame, size), size: size, mask: size - 1, SampleRate: sampleRate, } + s.sampleRateAtomic.Store(int32(sampleRate)) + return s +} + +// SetSampleRate updates the sample rate atomically. Safe to call from any +// goroutine, including while the DSP goroutine is consuming frames via +// StreamResampler. The change takes effect on the very next NextFrame() call. +// Also updates the public SampleRate field for non-concurrent readers. +func (s *StreamSource) SetSampleRate(hz int) { + s.SampleRate = hz + s.sampleRateAtomic.Store(int32(hz)) +} + +// GetSampleRate returns the current sample rate via atomic load. Use this +// in hot paths / cross-goroutine reads instead of .SampleRate directly. +func (s *StreamSource) GetSampleRate() int { + return int(s.sampleRateAtomic.Load()) } // WriteFrame pushes a single frame into the ring buffer. @@ -124,40 +153,41 @@ func (s *StreamSource) Stats() StreamStats { currentStreak := int(s.underrunStreak.Load()) maxStreak := int(s.maxUnderrunStreak.Load()) return StreamStats{ - Available: available, - Capacity: s.size, - Buffered: buffered, - BufferedDurationSeconds: s.bufferedDurationSeconds(available), - HighWatermark: highWatermark, + Available: available, + Capacity: s.size, + Buffered: buffered, + BufferedDurationSeconds: s.bufferedDurationSeconds(available), + HighWatermark: highWatermark, HighWatermarkDurationSeconds: s.bufferedDurationSeconds(highWatermark), - Written: s.Written.Load(), - Underruns: s.Underruns.Load(), - Overflows: s.Overflows.Load(), - UnderrunStreak: currentStreak, - MaxUnderrunStreak: maxStreak, + Written: s.Written.Load(), + Underruns: s.Underruns.Load(), + Overflows: s.Overflows.Load(), + UnderrunStreak: currentStreak, + MaxUnderrunStreak: maxStreak, } } // StreamStats exposes runtime telemetry for the stream buffer. type StreamStats struct { - Available int `json:"available"` - Capacity int `json:"capacity"` - Buffered float64 `json:"buffered"` - BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"` - HighWatermark int `json:"highWatermark"` + Available int `json:"available"` + Capacity int `json:"capacity"` + Buffered float64 `json:"buffered"` + BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"` + HighWatermark int `json:"highWatermark"` HighWatermarkDurationSeconds float64 `json:"highWatermarkDurationSeconds"` - Written uint64 `json:"written"` - Underruns uint64 `json:"underruns"` - Overflows uint64 `json:"overflows"` - UnderrunStreak int `json:"underrunStreak"` - MaxUnderrunStreak int `json:"maxUnderrunStreak"` + Written uint64 `json:"written"` + Underruns uint64 `json:"underruns"` + Overflows uint64 `json:"overflows"` + UnderrunStreak int `json:"underrunStreak"` + MaxUnderrunStreak int `json:"maxUnderrunStreak"` } func (s *StreamSource) bufferedDurationSeconds(available int) float64 { - if s.SampleRate <= 0 { + rate := s.GetSampleRate() + if rate <= 0 { return 0 } - return float64(available) / float64(s.SampleRate) + return float64(available) / float64(rate) } func (s *StreamSource) updateHighWatermark() { @@ -195,33 +225,53 @@ func (s *StreamSource) resetUnderrunStreak() { // StreamResampler wraps a StreamSource and rate-converts from the stream's // native sample rate to the target output rate using linear interpolation. // Consumes input frames on demand — no buffering beyond the ring buffer. +// +// The input rate is read atomically from src on every NextFrame() call so +// that a SetSampleRate() from the ingest goroutine takes effect immediately, +// without any additional synchronisation. The pos accumulator is not reset +// on a rate change: this may produce a single glitch-free transient at the +// moment the rate is corrected, which is far preferable to playing the whole +// stream at the wrong pitch. type StreamResampler struct { - src *StreamSource - ratio float64 // inputRate / outputRate (< 1 when upsampling) - pos float64 - prev Frame - curr Frame + src *StreamSource + outputRate float64 // target composite rate, fixed for the lifetime of the resampler + pos float64 + prev Frame + curr Frame } // NewStreamResampler creates a streaming resampler. +// outputRate is the fixed DSP composite rate. The input rate is taken from +// src.GetSampleRate() dynamically, so it will automatically track any +// subsequent SetSampleRate() call. func NewStreamResampler(src *StreamSource, outputRate float64) *StreamResampler { - if src == nil || outputRate <= 0 || src.SampleRate <= 0 { - return &StreamResampler{src: src, ratio: 1.0} + if src == nil || outputRate <= 0 { + return &StreamResampler{src: src, outputRate: outputRate} } return &StreamResampler{ - src: src, - ratio: float64(src.SampleRate) / outputRate, + src: src, + outputRate: outputRate, } } // NextFrame returns the next interpolated frame at the output rate. // Implements the frameSource interface. +// The input/output ratio is recomputed on every call from the atomic sample +// rate so that runtime rate corrections via SetSampleRate are race-free. func (r *StreamResampler) NextFrame() Frame { if r.src == nil { return NewFrame(0, 0) } - // Consume input samples as the fractional position advances + // Compute ratio atomically so we see any SetSampleRate update immediately. + ratio := 1.0 + if r.outputRate > 0 { + if inputRate := r.src.GetSampleRate(); inputRate > 0 { + ratio = float64(inputRate) / r.outputRate + } + } + + // Consume input samples as the fractional position advances. for r.pos >= 1.0 { r.prev = r.curr r.curr = r.src.ReadFrame() @@ -231,7 +281,7 @@ func (r *StreamResampler) NextFrame() Frame { frac := r.pos l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac ri := float64(r.prev.R)*(1-frac) + float64(r.curr.R)*frac - r.pos += r.ratio + r.pos += ratio return NewFrame(Sample(l), Sample(ri)) } diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index 5c6167d..ec19e3d 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -209,6 +209,28 @@ func (r *Runtime) run() { func (r *Runtime) handleChunk(chunk PCMChunk) { r.mu.Lock() r.seenChunk = true + + // Propagate the actual decoded sample rate to the sink and pacer the + // first time (or whenever) it differs from our working rate. This fixes + // the two-part rate-mismatch bug that appears when a native decoder + // (e.g. go-mp3) decodes a 48000 Hz stream while the StreamSource and + // StreamResampler were initialised assuming 44100 Hz: + // + // 1. The pacer (pacedDrainLimitLocked) was draining at the wrong rate, + // causing the work buffer to overflow → glitches. + // 2. The StreamResampler ratio (inputRate/outputRate) was computed from + // the stale sink.SampleRate, so every frame was played at the wrong + // pitch → audio too slow (44100/48000 ≈ 91.9 % speed). + // + // SetSampleRate writes atomically, so the StreamResampler's NextFrame() + // picks up the corrected ratio without any additional locking. + if chunk.SampleRateHz > 0 && chunk.SampleRateHz != r.workSampleRate { + r.workSampleRate = chunk.SampleRateHz + if r.sink != nil { + r.sink.SetSampleRate(chunk.SampleRateHz) + } + } + r.mu.Unlock() frames, err := ChunkToFrames(chunk) @@ -319,9 +341,12 @@ func (r *Runtime) pacedDrainLimitLocked(now time.Time, bufferedFrames int) int { if bufferedFrames <= 0 { return 0 } + // Use workSampleRate which is kept in sync with sink.SampleRate via + // handleChunk. This ensures the pacer drains at the actual decoded rate + // rather than the initial (potentially wrong) configured rate. rate := r.workSampleRate - if r.sink != nil && r.sink.SampleRate > 0 { - rate = r.sink.SampleRate + if r.sink != nil && r.sink.GetSampleRate() > 0 { + rate = r.sink.GetSampleRate() } if rate <= 0 { return bufferedFrames From d29e9d45a300e1c4fd725af8ef44ae9e9ea02f63 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 21:50:37 +0200 Subject: [PATCH 27/40] engine: require sustained late writes before degrading runtime --- internal/app/engine.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/app/engine.go b/internal/app/engine.go index 8348b52..c25537b 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -113,13 +113,14 @@ type RuntimeTransition struct { } const ( - lateBufferIndicatorWindow = 5 * time.Second - writeLateTolerance = 1 * time.Millisecond + lateBufferIndicatorWindow = 2 * time.Second + writeLateTolerance = 10 * time.Millisecond queueCriticalStreakThreshold = 3 queueMutedStreakThreshold = queueCriticalStreakThreshold * 2 queueMutedRecoveryThreshold = queueCriticalStreakThreshold queueFaultedStreakThreshold = queueCriticalStreakThreshold faultRepeatWindow = 1 * time.Second + lateBufferStreakThreshold = 3 // consecutive late writes required before alerting faultHistoryCapacity = 8 runtimeTransitionHistoryCapacity = 8 ) @@ -150,6 +151,7 @@ type Engine struct { underruns atomic.Uint64 lateBuffers atomic.Uint64 lateBufferAlertAt atomic.Uint64 + lateBufferStreak atomic.Uint64 // consecutive late writes; reset on clean write criticalStreak atomic.Uint64 mutedRecoveryStreak atomic.Uint64 mutedFaultStreak atomic.Uint64 @@ -604,12 +606,23 @@ func (e *Engine) writerLoop(ctx context.Context) { lateOver := writeDur - e.chunkDuration if lateOver > writeLateTolerance { + streak := e.lateBufferStreak.Add(1) late := e.lateBuffers.Add(1) - e.lateBufferAlertAt.Store(uint64(time.Now().UnixNano())) + // Only arm the alert window once the streak threshold is reached. + // Isolated OS-scheduling or USB jitter spikes (single late writes) + // are normal on a loaded system and must not trigger degraded state. + // This mirrors the queue-health streak logic. + if streak >= lateBufferStreakThreshold { + e.lateBufferAlertAt.Store(uint64(time.Now().UnixNano())) + } if late <= 5 || late%20 == 0 { - log.Printf("TX LATE: write=%s budget=%s over=%s tolerance=%s queueResidence=%s pipeline=%s", - writeDur, e.chunkDuration, lateOver, writeLateTolerance, queueResidence, pipelineLatency) + log.Printf("TX LATE [streak=%d]: write=%s budget=%s over=%s tolerance=%s queueResidence=%s pipeline=%s", + streak, writeDur, e.chunkDuration, lateOver, writeLateTolerance, queueResidence, pipelineLatency) } + } else { + // Clean write — reset the consecutive streak so isolated spikes + // never accumulate toward the threshold. + e.lateBufferStreak.Store(0) } if err != nil { From 97c6e9b6a0c49cfa128a2cdeac2f3f09145e81ff Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:06:32 +0200 Subject: [PATCH 28/40] ingest: fix source defaults and discovery context handling --- internal/control/server.go | 23 +++++++++++++---------- internal/ingest/factory/factory.go | 22 ++++++++++++++-------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/internal/control/server.go b/internal/control/server.go index 9fcd5cd..fe538b1 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -8,20 +8,23 @@ import ( ) const ( - defaultReadTimeout = 5 * time.Second - defaultWriteTimeout = 10 * time.Second - defaultIdleTimeout = 60 * time.Second - defaultMaxHeaderBytes = 1 << 20 // 1 MiB + defaultReadHeaderTimeout = 5 * time.Second + defaultIdleTimeout = 60 * time.Second + defaultMaxHeaderBytes = 1 << 20 // 1 MiB ) // NewHTTPServer returns a configured HTTP server for the control plane. +// +// WriteTimeout is intentionally not set: /audio/stream accepts long-lived +// POST bodies (continuous PCM push) that would be cut off by a global write +// deadline. Individual endpoints are protected by MaxBytesReader limits. +// ReadHeaderTimeout guards against slow-header attacks. func NewHTTPServer(cfg config.Config, handler http.Handler) *http.Server { return &http.Server{ - Addr: cfg.Control.ListenAddress, - Handler: handler, - ReadTimeout: defaultReadTimeout, - WriteTimeout: defaultWriteTimeout, - IdleTimeout: defaultIdleTimeout, - MaxHeaderBytes: defaultMaxHeaderBytes, + Addr: cfg.Control.ListenAddress, + Handler: handler, + ReadHeaderTimeout: defaultReadHeaderTimeout, + IdleTimeout: defaultIdleTimeout, + MaxHeaderBytes: defaultMaxHeaderBytes, } } diff --git a/internal/ingest/factory/factory.go b/internal/ingest/factory/factory.go index 5f8696c..223e9db 100644 --- a/internal/ingest/factory/factory.go +++ b/internal/ingest/factory/factory.go @@ -42,7 +42,7 @@ type AES67DiscoverRequest struct { type AES67DiscoverFunc func(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) -func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, error) { +func BuildSource(ctx context.Context, cfg config.Config, deps Deps) (ingest.Source, AudioIngress, error) { switch normalizeIngestKind(cfg.Ingest.Kind) { case "", "none": return nil, nil, nil @@ -83,7 +83,7 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err src := srt.New("srt-main", srtCfg, opts...) return src, nil, nil case "aes67", "aoip", "aoip-rtp": - aoipCfg, detail, origin, err := buildAES67Config(cfg, deps) + aoipCfg, detail, origin, err := buildAES67Config(ctx, cfg, deps) if err != nil { return nil, nil, err } @@ -115,7 +115,10 @@ func SampleRateForKind(cfg config.Config) int { return cfg.Ingest.HTTPRaw.SampleRateHz } case "icecast": - return 44100 + // 48000 Hz is the most common rate for modern Icecast streams. + // The ingest runtime will auto-correct to the actual decoded rate + // after the first PCM chunk arrives (see runtime.go handleChunk). + return 48000 case "srt": if cfg.Ingest.SRT.SampleRateHz > 0 { return cfg.Ingest.SRT.SampleRateHz @@ -125,14 +128,17 @@ func SampleRateForKind(cfg config.Config) int { return cfg.Ingest.AES67.SampleRateHz } } - return 44100 + // Default to 48000 Hz: the correct rate for professional sources + // (SRT, AES67) and modern streams. The ingest runtime corrects this + // dynamically from the first decoded chunk for compressed sources. + return 48000 } func normalizeIngestKind(kind string) string { return strings.ToLower(strings.TrimSpace(kind)) } -func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, *ingest.SourceOrigin, error) { +func buildAES67Config(ctx context.Context, cfg config.Config, deps Deps) (aoiprxkit.Config, string, *ingest.SourceOrigin, error) { base := aoiprxkit.DefaultConfig() ing := cfg.Ingest.AES67 if strings.TrimSpace(ing.InterfaceName) != "" { @@ -160,7 +166,7 @@ func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, * base.ReadBufferBytes = ing.ReadBufferBytes } - sdpText, discoveredStreamName, origin, err := resolveAES67SDP(ing, deps) + sdpText, discoveredStreamName, origin, err := resolveAES67SDP(ctx, ing, deps) if err != nil { return aoiprxkit.Config{}, "", nil, err } @@ -205,7 +211,7 @@ func buildAES67Config(cfg config.Config, deps Deps) (aoiprxkit.Config, string, * return base, "", origin, nil } -func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, *ingest.SourceOrigin, error) { +func resolveAES67SDP(ctx context.Context, ing config.IngestAES67Config, deps Deps) (string, string, *ingest.SourceOrigin, error) { sdpText := strings.TrimSpace(ing.SDP) if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" { sdpPath := filepath.Clean(ing.SDPPath) @@ -246,7 +252,7 @@ func resolveAES67SDP(ing config.IngestAES67Config, deps Deps) (string, string, * if discover == nil { discover = discoverAES67ViaSAP } - announcement, err := discover(context.Background(), req) + announcement, err := discover(ctx, req) if err != nil { return "", "", nil, fmt.Errorf("discover ingest.aes67 stream %q via SAP: %w", req.StreamName, err) } From 14bdbbabeae851beb270f0edba84749d44e80099 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:06:49 +0200 Subject: [PATCH 29/40] ingest: harden adapter metadata and shutdown handling --- internal/ingest/adapters/icecast/icy.go | 42 +++++++++++++++++++-- internal/ingest/adapters/stdinpcm/source.go | 1 + 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/internal/ingest/adapters/icecast/icy.go b/internal/ingest/adapters/icecast/icy.go index 5a69d43..138db16 100644 --- a/internal/ingest/adapters/icecast/icy.go +++ b/internal/ingest/adapters/icecast/icy.go @@ -77,18 +77,31 @@ func (r *icyReader) readMetadataBlock() error { return nil } +// parseICYMetadata parses the ICY inline metadata block. +// +// ICY metadata is a semicolon-delimited key=value format where values are +// single-quoted strings. A naive strings.Split(raw, ";") breaks when the +// StreamTitle itself contains semicolons (e.g. "Artist - Title; Live Edit"). +// This parser is quote-aware: it only splits on semicolons that appear +// outside of single-quoted value strings. func parseICYMetadata(block []byte) icyMetadata { raw := strings.TrimRight(string(bytes.Trim(block, "\x00")), "\x00") meta := icyMetadata{} - for _, field := range strings.Split(raw, ";") { + + fields := splitICYFields(raw) + for _, field := range fields { field = strings.TrimSpace(field) if !strings.HasPrefix(field, "StreamTitle=") { continue } v := strings.TrimPrefix(field, "StreamTitle=") v = strings.TrimSpace(v) - if len(v) >= 2 && ((v[0] == '\'' && v[len(v)-1] == '\'') || (v[0] == '"' && v[len(v)-1] == '"')) { - v = v[1 : len(v)-1] + // Strip enclosing single or double quotes. + if len(v) >= 2 { + if (v[0] == '\'' && v[len(v)-1] == '\'') || + (v[0] == '"' && v[len(v)-1] == '"') { + v = v[1 : len(v)-1] + } } meta.StreamTitle = v break @@ -96,6 +109,29 @@ func parseICYMetadata(block []byte) icyMetadata { return meta } +// splitICYFields splits an ICY metadata string on semicolons that appear +// outside of single-quoted value strings. Semicolons inside quotes (e.g. +// StreamTitle='Artist - Song; Live';) are preserved as part of the value. +func splitICYFields(s string) []string { + var fields []string + inQuote := false + start := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\'' { + inQuote = !inQuote + } + if c == ';' && !inQuote { + fields = append(fields, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + fields = append(fields, s[start:]) + } + return fields +} + func parseICYMetaInt(raw string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/ingest/adapters/stdinpcm/source.go b/internal/ingest/adapters/stdinpcm/source.go index 5785928..104b66b 100644 --- a/internal/ingest/adapters/stdinpcm/source.go +++ b/internal/ingest/adapters/stdinpcm/source.go @@ -119,6 +119,7 @@ func (s *Source) Stats() ingest.SourceStats { func (s *Source) readLoop(ctx context.Context) { defer s.wg.Done() + defer close(s.errs) defer close(s.chunks) frameBytes := s.channels * 2 From 1f49bdd14442252e03bd95def03a1d3fa6c99823 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:07:04 +0200 Subject: [PATCH 30/40] runtime: tighten queue, generator, and late-write semantics --- internal/app/engine.go | 2 +- internal/offline/generator.go | 23 +++++++++++++++++++---- internal/output/frame_queue.go | 25 ++++++++++++++----------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/internal/app/engine.go b/internal/app/engine.go index c25537b..b4a7707 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -194,7 +194,7 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) { } resampler := audio.NewStreamResampler(src, compositeRate) e.generator.SetExternalSource(resampler) - log.Printf("engine: live audio stream — %d Hz → %.0f Hz (buffer %d frames)", + log.Printf("engine: live audio stream wired — initial %d Hz → %.0f Hz composite (buffer %d frames); actual decoded rate auto-corrects on first chunk", src.SampleRate, compositeRate, src.Stats().Capacity) } diff --git a/internal/offline/generator.go b/internal/offline/generator.go index dd6afde..6598087 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -120,8 +120,15 @@ func NewGenerator(cfg cfgpkg.Config) *Generator { // SetExternalSource sets a live audio source (e.g. StreamResampler) that // takes priority over WAV/tone sources. Must be called before the first -// GenerateFrame() call (i.e. before init). +// GenerateFrame() call; calling it after init() has no effect because +// g.source is already wired to the old source. func (g *Generator) SetExternalSource(src frameSource) { + if g.initialized { + // init() already called sourceFor() and wired g.source. Updating + // g.externalSource here would have no effect on the live DSP chain. + // This is a programming error — log loudly rather than silently break. + panic("generator: SetExternalSource called after GenerateFrame; call it before the engine starts") + } g.externalSource = src } @@ -189,12 +196,14 @@ func (g *Generator) init() { g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) // BS.412 MPX power limiter (EU/CH requirement for licensed FM) if g.cfg.FM.BS412Enabled { - chunkSec := 0.05 // 50ms chunks (matches engine default) + // chunkSec is not known at init time (Engine.chunkDuration may differ). + // Pass 0 here; GenerateFrame computes the actual chunk duration from + // the real sample count and updates BS.412 accordingly. g.bs412 = dsp.NewBS412Limiter( g.cfg.FM.BS412ThresholdDBr, g.cfg.FM.PilotLevel, g.cfg.FM.RDSInjection, - chunkSec, + 0, ) } if g.cfg.FM.FMModulationEnabled { @@ -360,8 +369,14 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame } } - // BS.412: feed this chunk's average audio power for next chunk's gain calculation + // BS.412: feed this chunk's actual duration and average audio power for + // the next chunk's gain calculation. Using the real sample count avoids + // the error that occurred when chunkSec was hardcoded to 0.05 — any + // SetChunkDuration() call from the engine would silently miscalibrate + // the ITU-R BS.412 power measurement window. if g.bs412 != nil && samples > 0 { + chunkSec := float64(samples) / g.sampleRate + g.bs412.UpdateChunkDuration(chunkSec) g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) } diff --git a/internal/output/frame_queue.go b/internal/output/frame_queue.go index e3db114..0443eec 100644 --- a/internal/output/frame_queue.go +++ b/internal/output/frame_queue.go @@ -80,22 +80,19 @@ func (q *FrameQueue) Capacity() int { } // FillLevel reports the current occupancy as a fraction of capacity. +// Uses len(ch) directly for accuracy: updateDepth() is called after the +// channel operation, so q.depth can lag by one frame transiently. func (q *FrameQueue) FillLevel() float64 { - q.mu.Lock() - depth := q.depth - q.mu.Unlock() if q.capacity == 0 { return 0 } - return float64(depth) / float64(q.capacity) + return float64(len(q.ch)) / float64(q.capacity) } // Depth returns the current number of frames in the queue. +// Uses len(ch) directly for accuracy (see FillLevel). func (q *FrameQueue) Depth() int { - q.mu.Lock() - depth := q.depth - q.mu.Unlock() - return depth + return len(q.ch) } // Stats returns a snapshot of the queue metrics. @@ -104,7 +101,7 @@ func (q *FrameQueue) Stats() QueueStats { fill := q.fillLevelLocked() stats := QueueStats{ Capacity: q.capacity, - Depth: q.depth, + Depth: len(q.ch), FillLevel: fill, Health: queueHealthFromFill(fill), HighWaterMark: q.highWaterMark, @@ -128,11 +125,15 @@ func (q *FrameQueue) Push(ctx context.Context, frame *CompositeFrame) error { return ErrFrameQueueClosed } + // BUG-05 fix: increment depth BEFORE the channel send so that Stats() + // never reports fill=0 while a frame is in the channel awaiting receive. + // On context cancellation, undo the increment. + q.updateDepth(+1) select { case q.ch <- frame: - q.updateDepth(+1) return nil case <-ctx.Done(): + q.updateDepth(-1) q.recordPushTimeout() return ctx.Err() } @@ -211,7 +212,9 @@ func (q *FrameQueue) fillLevelLocked() float64 { if q.capacity == 0 { return 0 } - return float64(q.depth) / float64(q.capacity) + // Use len(ch) rather than q.depth: depth is updated after the channel + // operation, so it can be off by one during the Push/Pop window. + return float64(len(q.ch)) / float64(q.capacity) } func (q *FrameQueue) recordPushTimeout() { From b0964e71dc84d36a6e43e38ce15174a2600ebdb3 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:07:19 +0200 Subject: [PATCH 31/40] rds: support explicit text clearing and symbol bootstrap --- internal/rds/encoder.go | 69 +++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index fbe7b93..266e572 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -94,8 +94,17 @@ type Encoder struct { // Live-updatable text — written by control API, read at group boundaries. // Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz). - livePS atomic.Value // string - liveRT atomic.Value // string + // pendingText.set distinguishes "no pending update" from "update to empty string" + // so that PS/RT can be explicitly cleared via UpdateText. + livePS atomic.Value // pendingText + liveRT atomic.Value // pendingText +} + +// pendingText carries a pending text update for PS or RT. +// set=false means no update is pending; set=true means apply val (even if empty). +type pendingText struct { + val string + set bool } func NewEncoder(cfg RDSConfig) (*Encoder, error) { @@ -163,16 +172,29 @@ func (e *Encoder) Reset() { // UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers, // applied at the next RDS group boundary by the DSP goroutine. -// Pass empty string to leave a field unchanged. +// +// Pass empty string to leave a field unchanged. To explicitly clear a field +// (set PS to 8 spaces, or RT to empty), use ClearPS/ClearRT instead. func (e *Encoder) UpdateText(ps, rt string) { if ps != "" { - e.livePS.Store(normalizePS(ps)) + e.livePS.Store(pendingText{val: normalizePS(ps), set: true}) } if rt != "" { - e.liveRT.Store(normalizeRT(rt)) + e.liveRT.Store(pendingText{val: normalizeRT(rt), set: true}) } } +// ClearPS resets the Program Service name to 8 spaces at the next group boundary. +func (e *Encoder) ClearPS() { + e.livePS.Store(pendingText{val: normalizePS(""), set: true}) +} + +// ClearRT resets RadioText to an empty string at the next group boundary. +// Per RDS spec, an empty RT causes receivers to clear their display. +func (e *Encoder) ClearRT() { + e.liveRT.Store(pendingText{val: "", set: true}) +} + // NextSample returns the next RDS subcarrier sample at the configured rate. // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier // for phase-locked operation in a stereo MPX chain. @@ -192,15 +214,15 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { // Apply live text updates at group boundaries (~88ms at 228kHz). // Atomics are consumed (cleared) after reading to prevent // re-applying the same text every group and toggling A/B flag. - if ps, ok := e.livePS.Load().(string); ok && ps != "" { - e.scheduler.cfg.PS = ps - e.livePS.Store("") // consumed + if pt, ok := e.livePS.Load().(pendingText); ok && pt.set { + e.scheduler.cfg.PS = pt.val + e.livePS.Store(pendingText{}) // consumed } - if rt, ok := e.liveRT.Load().(string); ok && rt != "" { - e.scheduler.cfg.RT = rt + if pt, ok := e.liveRT.Load().(pendingText); ok && pt.set { + e.scheduler.cfg.RT = pt.val e.scheduler.rtIdx = 0 // restart RT transmission for new text e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec - e.liveRT.Store("") // consumed + e.liveRT.Store(pendingText{}) // consumed } e.getRDSGroup() e.bitPos = 0 @@ -240,12 +262,27 @@ func (e *Encoder) Generate(n int) []float64 { out := make([]float64, n); for i := range out { out[i] = e.NextSample() }; return out } func (e *Encoder) Symbol() float64 { - if e.bitPos >= bitsPerGroup { return -1 } - sym := 1.0; if e.bitBuffer[e.bitPos] == 0 { sym = -1.0 } + // Populate the bit buffer on first call (bitPos starts at bitsPerGroup + // after NewEncoder/Reset, so the guard below would return -1 immediately + // without this bootstrap step). + if e.bitPos >= bitsPerGroup { + e.getRDSGroup() + e.bitPos = 0 + } + sym := 1.0 + if e.bitBuffer[e.bitPos] == 0 { + sym = -1.0 + } e.sampleCount++ - if e.sampleCount >= e.spb { e.sampleCount = 0; e.bitPos++ - if e.bitPos >= bitsPerGroup { e.getRDSGroup(); e.bitPos = 0 } - }; return sym + if e.sampleCount >= e.spb { + e.sampleCount = 0 + e.bitPos++ + if e.bitPos >= bitsPerGroup { + e.getRDSGroup() + e.bitPos = 0 + } + } + return sym } func (e *Encoder) getRDSGroup() { From aae93051a0fc5d0e9ae7e7fe4ff115c6984e2ee2 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:15:36 +0200 Subject: [PATCH 32/40] control: fix request-body checks and stream timeout wiring --- cmd/fmrtx/main.go | 2 +- internal/control/control.go | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 5354f08..98a8e88 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -188,7 +188,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver streamSrc = audio.NewStreamSource(bufferFrames, rate) engine.SetStreamSource(streamSrc) - source, sourceIngress, err := ingestfactory.BuildSource(cfg, ingestfactory.Deps{Stdin: os.Stdin}) + source, sourceIngress, err := ingestfactory.BuildSource(ctx, cfg, ingestfactory.Deps{Stdin: os.Stdin}) if err != nil { log.Fatalf("ingest source: %v", err) } diff --git a/internal/control/control.go b/internal/control/control.go index 1e9bd9d..3618463 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -340,7 +340,7 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !s.rejectBody(w, r) { + if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected return } s.mu.RLock() @@ -383,6 +383,14 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { return } + // BUG-10 fix: /audio/stream is a long-lived streaming endpoint. + // The global HTTP server ReadTimeout (5s) and WriteTimeout (10s) would + // kill connections mid-stream. Disable them per-request via ResponseController + // (requires Go 1.20+, confirmed Go 1.22). + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Time{}) + _ = rc.SetWriteDeadline(time.Time{}) + r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit) // Read body in chunks and push to ring buffer @@ -426,7 +434,7 @@ func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !s.rejectBody(w, r) { + if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected return } s.mu.RLock() @@ -450,7 +458,7 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !s.rejectBody(w, r) { + if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected return } s.mu.RLock() From 8a8e9da6f2965d2f13d141eedd9e710bef1f7c34 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:15:50 +0200 Subject: [PATCH 33/40] dsp: support dynamic bs412 chunk duration updates --- internal/dsp/bs412.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/dsp/bs412.go b/internal/dsp/bs412.go index 527c709..98bb2ea 100644 --- a/internal/dsp/bs412.go +++ b/internal/dsp/bs412.go @@ -72,6 +72,35 @@ func NewBS412Limiter(thresholdDBr, pilotLevel, rdsInjection, chunkDurationSec fl } } +// UpdateChunkDuration reconfigures the limiter for a new chunk size. +// Call this from GenerateFrame when the actual chunk duration is known +// (computed as samples/sampleRate) to avoid calibration errors if the +// engine's chunk duration differs from the value passed to NewBS412Limiter. +// Safe to call on every chunk; no-ops when duration has not changed. +func (l *BS412Limiter) UpdateChunkDuration(chunkSec float64) { + if chunkSec <= 0 { + return + } + windowSec := 60.0 + newBufLen := int(math.Ceil(windowSec / chunkSec)) + if newBufLen < 10 { + newBufLen = 10 + } + if newBufLen == len(l.powerBuf) { + return // no change + } + // Resize buffer — drop history to avoid stale power readings from the + // old window size distorting the rolling average. + l.powerBuf = make([]float64, newBufLen) + l.bufIdx = 0 + l.bufFull = false + l.powerSum = 0 + attackTC := 2.0 / chunkSec + releaseTC := 5.0 / chunkSec + l.attackCoeff = 1.0 - math.Exp(-1.0/attackTC) + l.releaseCoeff = 1.0 - math.Exp(-1.0/releaseTC) +} + // ProcessChunk measures the audio power of a chunk and returns the // gain factor to apply to the audio composite for BS.412 compliance. // Call once per chunk with the average audio power of that chunk. From e5e9217f9953fe948e071c1a1ce0778494fbcf36 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:16:06 +0200 Subject: [PATCH 34/40] icecast: cap fallback capture buffer growth --- internal/ingest/adapters/icecast/source.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/ingest/adapters/icecast/source.go b/internal/ingest/adapters/icecast/source.go index 2970e82..52c825f 100644 --- a/internal/ingest/adapters/icecast/source.go +++ b/internal/ingest/adapters/icecast/source.go @@ -312,12 +312,25 @@ func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, met } } +// maxCaptureBytes caps the amount of stream data buffered while the native +// decoder is deciding whether it can handle the format. Without a cap, a +// decoder that reads extensively before returning ErrUnsupported could grow +// this buffer unboundedly on a corrupt or adversarial stream. +const maxCaptureBytes = 1 << 20 // 1 MiB + +// errCaptureLimitExceeded is returned by capturingReader when the buffer cap +// is hit. The caller should treat it like ErrUnsupported and fall back. +var errCaptureLimitExceeded = errors.New("capture buffer limit exceeded") + type capturingReader struct { r io.Reader buf bytes.Buffer } func (r *capturingReader) Read(p []byte) (int, error) { + if r.buf.Len() >= maxCaptureBytes { + return 0, errCaptureLimitExceeded + } n, err := r.r.Read(p) if n > 0 { _, _ = r.buf.Write(p[:n]) From 8a32572093eb8bf62d8801fbd1ce102ec28227de Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:19:13 +0200 Subject: [PATCH 35/40] ingest: log decoded sample-rate corrections --- internal/ingest/runtime.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ingest/runtime.go b/internal/ingest/runtime.go index ec19e3d..5c4e117 100644 --- a/internal/ingest/runtime.go +++ b/internal/ingest/runtime.go @@ -2,6 +2,7 @@ package ingest import ( "context" + "log" "sync" "sync/atomic" "time" @@ -225,10 +226,12 @@ func (r *Runtime) handleChunk(chunk PCMChunk) { // SetSampleRate writes atomically, so the StreamResampler's NextFrame() // picks up the corrected ratio without any additional locking. if chunk.SampleRateHz > 0 && chunk.SampleRateHz != r.workSampleRate { + prev := r.workSampleRate r.workSampleRate = chunk.SampleRateHz if r.sink != nil { r.sink.SetSampleRate(chunk.SampleRateHz) } + log.Printf("ingest: actual decoded sample rate %d Hz (was %d Hz) — resampler and pacer updated", chunk.SampleRateHz, prev) } r.mu.Unlock() From 9f7875b8cf186349260120d0772918bf5576862f Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 7 Apr 2026 22:27:49 +0200 Subject: [PATCH 36/40] docs: record ingest rework status and refresh plutosdr example config --- docs/audio-ingest-rework.md | 29 ++++++++++++++ docs/config.plutosdr.json | 80 ++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/audio-ingest-rework.md b/docs/audio-ingest-rework.md index 6e4e6f5..0f0249a 100644 --- a/docs/audio-ingest-rework.md +++ b/docs/audio-ingest-rework.md @@ -11,6 +11,35 @@ Dieses Dokument beschreibt das Zielbild. Der aktuelle Ist-Stand in Phase 1 ist: ## Ziel `fm-rds-tx` soll mittelfristig mehrere Audio-Ingest-Pfade sauber unterstützen, ohne den bestehenden `ffmpeg`-Pfad kaputt zu machen. +## Einordnung des Phase-1-Ergebnisses +Mit Phase 1 wurde die Audio-Zuführung erstmals als eigenständiges Subsystem vor den bestehenden TX-/DSP-Pfad gezogen. +Die bestehende Sendekette bleibt weitgehend unangetastet; neue Ingest-Quellen laufen stattdessen über eine gemeinsame Runtime-Schicht, die Lifecycle, Formatwandlung und Basistelemetrie bündelt und weiterhin in den bestehenden `audio.StreamSource` einspeist. + +Konkret umfasst dieser Stand: +- gemeinsame Ingest-Runtime +- zentrale Source-Auswahl für `stdin`, `http-raw` und `icecast` +- Umstellung von `/audio/stream` auf den Ingest-Pfad +- Runtime-/Source-Stats im Control-Plane-Output +- Icecast-Adapter mit Reconnect-/Decoder-Policy +- Decoder-Layer mit explizitem Fallback-Pfad + +Wichtig ist die ehrliche Abgrenzung: +Die Decoder-Architektur ist vorhanden, aber native Decoder für `mp3`, `oggvorbis` und `aac` sind aktuell noch Platzhalter. Praktisch funktionsfähig ist der Icecast-Pfad derzeit vor allem über den expliziten `ffmpeg`-Fallback. Das ist für Phase 1 akzeptabel, weil die strukturelle Trennung jetzt sauber steht und spätere native Decoder nicht mehr die Runtime-Architektur verbiegen müssen. + +## Warum das ein sinnvoller Abschluss von Phase 1 ist +Phase 1 hatte nicht das Ziel, sämtliche Transport- und Codecvarianten produktionsreif abzuschliessen. Ziel war vielmehr, die bisher punktuellen Audio-Eingänge in ein gemeinsames, erweiterbares Modell zu überführen. Genau das ist erreicht: +- die TX-Schicht kennt keine Source-Familien mehr direkt +- HTTP- und CLI-Ingest hängen nicht mehr als Sonderfälle im Startcode +- Icecast ist als echter Source-Typ modelliert +- Decoder-Fallback ist explizit statt implizit +- die Control Plane kann Ingest-Zustand sichtbar machen + +## Nächster sinnvoller Schritt +Der nächste Block ist nicht noch mehr Runtime-Umbau, sondern gezielte inhaltliche Vervollständigung: +1. echte native Decoder für MP3 und Ogg/Vorbis +2. danach AAC/ADTS, sofern Bibliothekslage und Streaming-Verhalten sauber genug sind +3. erst danach zusätzliche Familien wie AoIP/SRT in die gemeinsame Runtime ziehen + Die strategische Richtung ist daher **nicht** „ffmpeg sofort ersetzen“, sondern: - bestehenden `ffmpeg`-Pfad als universellen Fallback behalten diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 1cb9738..2a9939c 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -1,7 +1,7 @@ { "audio": { "inputPath": "", - "gain": 1.0, + "gain": 1, "toneLeftHz": 400, "toneRightHz": 2000, "toneAmplitude": 0.3 @@ -14,31 +14,89 @@ "pty": 0 }, "fm": { - "bs412Enabled": true, - "bs412ThresholdDBr": 0, - "frequencyMHz": 100.0, + "frequencyMHz": 102.8, "stereoEnabled": true, "pilotLevel": 0.09, "rdsInjection": 0.04, "preEmphasisTauUS": 50, - "outputDrive": 1.0, - "mpxGain": 1.0, + "outputDrive": 1, "compositeRateHz": 228000, "maxDeviationHz": 75000, "limiterEnabled": true, - "limiterCeiling": 1.0, - "fmModulationEnabled": true + "limiterCeiling": 1, + "fmModulationEnabled": true, + "mpxGain": 1, + "bs412Enabled": true, + "bs412ThresholdDBr": 0 }, "backend": { "kind": "pluto", "device": "usb:", - "driver": "", - "uri": "", - "deviceArgs": {}, "outputPath": "", "deviceSampleRateHz": 2280000 }, "control": { "listenAddress": "127.0.0.1:8088" + }, + "runtime": { + "frameQueueCapacity": 3 + }, + "ingest": { + "kind": "icecast", + "prebufferMs": 1500, + "stallTimeoutMs": 3000, + "reconnect": { + "enabled": true, + "initialBackoffMs": 1000, + "maxBackoffMs": 15000 + }, + "stdin": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "httpRaw": { + "sampleRateHz": 44100, + "channels": 2, + "format": "s16le" + }, + "icecast": { + "url": "http://192.168.1.40:8000/stream", + "decoder": "native", + "radioText": { + "enabled": true, + "prefix": "", + "maxLen": 64, + "onlyOnChange": true + } + }, + "srt": { + "url": "", + "mode": "listener", + "sampleRateHz": 48000, + "channels": 2 + }, + "aes67": { + "sdpPath": "", + "sdp": "", + "discovery": { + "enabled": false, + "streamName": "", + "timeoutMs": 3000, + "interfaceName": "", + "sapGroup": "", + "sapPort": 0 + }, + "multicastGroup": "", + "port": 0, + "interfaceName": "", + "payloadType": 97, + "sampleRateHz": 48000, + "channels": 2, + "encoding": "L24", + "packetTimeMs": 1, + "jitterDepthPackets": 8, + "readBufferBytes": 1048576 + } } } From e35d9f8064976293a5d92ccd96d703739fcf6964 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 08:22:26 +0200 Subject: [PATCH 37/40] control: stabilize and polish control ui --- internal/control/control.go | 52 +- internal/control/ui.html | 4033 ++++++++--------------------------- 2 files changed, 967 insertions(+), 3118 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 3618463..1672874 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -112,20 +112,26 @@ func isJSONContentType(r *http.Request) bool { } type ConfigPatch struct { - FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` - OutputDrive *float64 `json:"outputDrive,omitempty"` - StereoEnabled *bool `json:"stereoEnabled,omitempty"` - PilotLevel *float64 `json:"pilotLevel,omitempty"` - RDSInjection *float64 `json:"rdsInjection,omitempty"` - RDSEnabled *bool `json:"rdsEnabled,omitempty"` - ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` - ToneRightHz *float64 `json:"toneRightHz,omitempty"` - ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` - PS *string `json:"ps,omitempty"` - RadioText *string `json:"radioText,omitempty"` - PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"` - LimiterEnabled *bool `json:"limiterEnabled,omitempty"` - LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` + FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` + OutputDrive *float64 `json:"outputDrive,omitempty"` + StereoEnabled *bool `json:"stereoEnabled,omitempty"` + PilotLevel *float64 `json:"pilotLevel,omitempty"` + RDSInjection *float64 `json:"rdsInjection,omitempty"` + RDSEnabled *bool `json:"rdsEnabled,omitempty"` + ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` + ToneRightHz *float64 `json:"toneRightHz,omitempty"` + ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` + PS *string `json:"ps,omitempty"` + RadioText *string `json:"radioText,omitempty"` + PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"` + LimiterEnabled *bool `json:"limiterEnabled,omitempty"` + LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` + AudioGain *float64 `json:"audioGain,omitempty"` + PI *string `json:"pi,omitempty"` + PTY *int `json:"pty,omitempty"` + BS412Enabled *bool `json:"bs412Enabled,omitempty"` + BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` + MpxGain *float64 `json:"mpxGain,omitempty"` } type IngestSaveRequest struct { @@ -528,12 +534,21 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if patch.ToneAmplitude != nil { next.Audio.ToneAmplitude = *patch.ToneAmplitude } + if patch.AudioGain != nil { + next.Audio.Gain = *patch.AudioGain + } if patch.PS != nil { next.RDS.PS = *patch.PS } if patch.RadioText != nil { next.RDS.RadioText = *patch.RadioText } + if patch.PI != nil { + next.RDS.PI = *patch.PI + } + if patch.PTY != nil { + next.RDS.PTY = *patch.PTY + } if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS } @@ -555,6 +570,15 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if patch.RDSInjection != nil { next.FM.RDSInjection = *patch.RDSInjection } + if patch.BS412Enabled != nil { + next.FM.BS412Enabled = *patch.BS412Enabled + } + if patch.BS412ThresholdDBr != nil { + next.FM.BS412ThresholdDBr = *patch.BS412ThresholdDBr + } + if patch.MpxGain != nil { + next.FM.MpxGain = *patch.MpxGain + } if err := next.Validate(); err != nil { s.mu.Unlock() http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/internal/control/ui.html b/internal/control/ui.html index f63de10..ccb6dc3 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -6,1067 +6,258 @@ fm-rds-tx
-
-
-

FM-RDS-TX Control Plane

-
Overview first, controls second, diagnostics when needed.
-
-
Backend--
-
ModeControl Plane
-
Live Config--
-
-
-
-
-
connecting
+
+
+

FM-RDS-TX Control Plane

+
Operate confidently: tune fast, inspect state instantly, diagnose only when needed.
+
+
Backend--
+
ModeControl Plane
+
Live Config--
- - -
- - - - - +
+
+
connecting
- -
-
-
-
+
+
+ + + + + + +
+
+ +
+
+
-
+
Carrier
---.-MHz
@@ -1074,535 +265,372 @@ input.input-error { Desired: --
-
-
IDLE
-
Awaiting runtime data
+
Waiting for runtime telemetry
-
-
-
Chunks
-
--
-
-
-
Samples
-
--
-
-
-
Underruns
-
--
-
-
-
Uptime
-
--
-
-
-
Rate
-
--
-
+
Chunks
--
+
Samples
--
+
Underruns
--
+
Uptime
--
+
Rate
--
-
-
-
-
Audio Buffer
-
--
-
-
- -
-
-
-
Stream Health
-
--
-
-
- -
-
-
-
TX Activity
-
--
-
-
- -
+
Audio Buffer
--
+
Stream Health
--
+
TX Activity
--
- - -
-
- +
+
-
+
+
+
-
-
-
+ +
+
+
+
-
-
-

Frequency

-
Live-tunable
- -
+

Frequency

Live + Saved
-
Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.
- +
Tune without restarting — when TX is running, the change takes effect at the next chunk boundary (~50ms). The desired frequency is also written into config.
- - - - - + + + + +
-
-
- TX Freq - Valid range 65–110 MHz -
-
- - - MHz -
+
TX Freq65–110 MHz
+
MHzlive
-
- - -
+
- - -
-
-
-

Switches

-
Live
- -
+ +
+

Audio & Drive

Mixed Apply Modes
-
These switches apply immediately and show a busy state while the request is in flight.
- -
-
-
Stereo
-
19 kHz pilot + 38 kHz DSB-SC
-
-
-
-
--
-
+
Output Drive and Limiter Ceiling apply live. Pre-emphasis and Input Gain are saved to config and require TX restart to affect the DSP path.
+
+
Output Drive0 – 10
+
--live
- -
-
-
RDS
-
57 kHz subcarrier encoder
-
-
-
-
--
-
+
+
Limiter Ceiling0.5 – 2.0
+
--live
- -
-
-
Limiter
-
MPX peak protection
-
-
-
-
--
-
+
+
Pre-emphasisRegion standard
+
restart
+
+
Input Gain0 – 4
+
--restart
+
+
+
Live fields update immediately. Restart-tagged fields become effective after TX restart.
- - -
-
-
-

RDS Text

-
PS + RT
- -
+ +
+

Test Tones

Diagnostic
-
Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.
- -
- - - - +
Tone settings are saved to config for the generator path. They do not hot-apply to a running TX engine; restart TX after saving to hear the change. Set amplitude to 0 to disable.
+
+
Left (Hz)
+
Hzrestart
- -
-
- Program Service (PS) - -
0/8
-
-
-
- RadioText (RT) - -
0/64
-
-
+
+
Right (Hz)
+
Hzrestart
-
- - +
+
Amplitude0 – 1.0
+
--restart
+
- -
-
-
-
-

Shortcuts

-
keyboard
- -
+
+
+ +
+

Switches

Live
-
Fast control reference. Shortcuts stay out of the main operator path.
-
-
-
Start TXt
-
Stop TXShiftt
-
Refreshr
-
-
-
Next Freq Preset]
-
Prev Freq Preset[
-
Apply DraftEnter
-
+
+
Stereo
19 kHz pilot + 38 kHz DSB-SC
+
--
+
+
+
Limiter
MPX peak protection
+
--
- - -
-
-

Danger Zone

-
tx control
- -
+ +
+

MPX Compliance

BS.412
-
Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.
-
- - - - +
ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes require TX restart.
+
+
BS.412 Limiter
60 s rolling power window  restart
+
--
-
- Reset Fault moves the runtime back to DEGRADED while the queue settles before running again. +
+
ThresholddBr — 0 = standard
+
--dBrrestart
+
+
+
MPX GainHardware calibration
+
--restart
+
+
Compliance changes are persisted to config and require TX restart before they affect the modulation chain.
- - + +
+

Danger Zone

emergency
+
+
+
Reset Fault moves the runtime back to DEGRADED while the queue settles.
-
- - -
-
-
-
+ +
+
+
+ +
+

Station Identity

Restart required
-
Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.
- -
-
-
- Ingest Kind -
-
- -
-
- -
-
- Prebuffer - ms -
-
- -
-
- -
-
- Stall Timeout - ms -
-
- -
-
- -
-
- Reconnect -
-
- -
-
- -
-
- Backoff Initial - ms -
-
- -
-
- -
-
- Backoff Max - ms -
-
- -
-
+
RDS enable applies live. PI and PTY are saved to config and take effect after the next TX restart.
+
+
Enable RDS57 kHz subcarrier
+
--
live
- -
-
Icecast
-
-
-
URL
-
-
-
-
Decoder
-
- -
-
-
-
RadioText Relay
-
- -
-
-
-
RT Prefix
-
-
-
-
RT MaxLen
-
-
-
-
RT Only On Change
-
- -
+
+
PI CodeProgramme Identifier (hex)
+
+
+ +
0x----
+ restart
+
- -
-
SRT
-
-
-
URL
-
-
-
-
Mode
-
- -
-
-
-
Sample Rate
-
-
-
-
Channels
-
-
+
+
Programme TypePTY 0–31
+
+ + restart
- -
-
AES67
-
-
-
SDP Path
-
-
-
-
SDP Inline
-
-
-
-
Multicast Group
-
-
-
-
Port
-
-
-
-
Payload Type
-
-
-
-
Sample Rate
-
-
-
-
Channels
-
-
-
-
Encoding
-
-
-
-
Packet Time
-
-
-
-
Jitter Depth
-
-
-
-
Read Buffer
-
-
-
-
Discovery
-
-
-
-
Discovery Name
-
-
-
-
Discovery Timeout
-
-
-
-
SAP Group
-
-
-
-
SAP Port
-
-
+
+
Identity settings persist immediately in config, but PI / PTY changes appear on-air only after TX restart.
+
+
+ +
+

On-Air Text

Live + Saved
+
+
PS and RadioText apply at the next RDS group boundary (~88ms). Edits stay local until you apply, then update the live encoder and config snapshot together.
+
+ + + + +
+
+
+
Program Service (PS)live
+ +
0/8
+
+
+
+
RadioText (RT)live
+ +
0/64
+
- -
-
- - +
+
+
+
+
+ +
+

Injection Levels

Live + Saved
+
+
Fixed percentages of ±75 kHz deviation. ITU standard: pilot 9%, RDS 4%. When TX is running, changes hot-apply; they are also written back into config.
+
+
Pilot Level19 kHz, 0–20%
+
--%devlive
+
+
+
RDS Injection57 kHz, 0–15%
+
--%devlive
+
- - + + -
- -
-
-
- +
+
+ + +
+
+ +
+

Ingest Config

Saved + Hard Reload
+
+
Changes are saved to the config file and take effect only after a hard reload of the service.
+
+
Kind
+
Prebufferms
+
Reconnect
+
Backoff Initialms
+
Backoff Maxms
+
+
+
Icecast
+
+
URL
+
Decoder
+
RT Relay
+
RT Prefix
+
RT MaxLen
+
Only On Change
+
+
+
+
SRT
+
+
URL
+
Mode
+
Sample Rate
+
Channels
+
+
+
+
AES67
+
+
Multicast Group
+
Port
+
SDP Path
+
Sample Rate
+
Channels
+
Encoding
+
Jitter Depth
+
Discovery
+
Stream Name
+
+
+
+
+
Ingest changes are not hot-applied. Saving writes config and schedules a hard service reload.
+
+
+
+
+ + +
+
+
+ - + -
-
+
+
+
-
-

Transition History

-
recent state shifts
- -
-
-
Keeps runtime escalations visible without scrolling the activity log.
-
-
No transitions yet.
-
-
+

Transition History

state shifts
+
No state transitions recorded yet.
- -
-
-

Fault History

-
recent faults
- -
-
-
Recent fault events for quick ops situational awareness.
-
-
No faults yet.
-
-
-
- - -
-
-
- -
-
-
-
-
-

Activity Log

-
recent events
- -
-
-
- -
-
No events yet.
-
-
-
+

Fault History

recent faults
+
No faults recorded yet.
-
+
-
- +
+ + +
+
+
+

Activity Log

recent events
+
+
+
No activity recorded yet.
+
+
+
+
+ +
- - From 9a3a37e31c398fb31b952839ca5876aa2ded2af9 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:24:58 +0200 Subject: [PATCH 38/40] control: improve runtime visibility and stop UX --- cmd/fmrtx/main.go | 2 ++ internal/app/engine.go | 8 ++++++++ internal/control/control.go | 9 ++++----- internal/control/ui.html | 34 ++++++++++++++++++++++++++++++---- internal/rds/encoder.go | 6 ++++++ 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 98a8e88..fdde1e4 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -330,6 +330,8 @@ func (b *txBridge) TXStats() map[string]any { "runtimeIndicator": s.RuntimeIndicator, "runtimeAlert": s.RuntimeAlert, "appliedFrequencyMHz": s.AppliedFrequencyMHz, + "activePS": s.ActivePS, + "activeRadioText": s.ActiveRadioText, "degradedTransitions": s.DegradedTransitions, "mutedTransitions": s.MutedTransitions, "faultedTransitions": s.FaultedTransitions, diff --git a/internal/app/engine.go b/internal/app/engine.go index b4a7707..39b9a9f 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -88,6 +88,8 @@ type EngineStats struct { RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` RuntimeAlert string `json:"runtimeAlert,omitempty"` AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` + ActivePS string `json:"activePS,omitempty"` + ActiveRadioText string `json:"activeRadioText,omitempty"` LastFault *FaultEvent `json:"lastFault,omitempty"` DegradedTransitions uint64 `json:"degradedTransitions"` MutedTransitions uint64 `json:"mutedTransitions"` @@ -429,6 +431,10 @@ func (e *Engine) Stats() EngineStats { hasRecentLateBuffers := e.hasRecentLateBuffers() ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) lastFault := e.lastFaultEvent() + activePS, activeRT := "", "" + if enc := e.generator.RDSEncoder(); enc != nil { + activePS, activeRT = enc.CurrentText() + } return EngineStats{ State: string(e.currentRuntimeState()), RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(), @@ -448,6 +454,8 @@ func (e *Engine) Stats() EngineStats { RuntimeIndicator: ri, RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), AppliedFrequencyMHz: e.appliedFrequencyMHz(), + ActivePS: activePS, + ActiveRadioText: activeRT, LastFault: lastFault, DegradedTransitions: e.degradedTransitions.Load(), MutedTransitions: e.mutedTransitions.Load(), diff --git a/internal/control/control.go b/internal/control/control.go index 1672874..0af6741 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -474,12 +474,11 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) { http.Error(w, "tx controller not available", http.StatusServiceUnavailable) return } - if err := tx.StopTX(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + go func() { + _ = tx.StopTX() + }() w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"}) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"}) } func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/control/ui.html b/internal/control/ui.html index ccb6dc3..aa64eaa 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -821,7 +821,29 @@ async function sendPatch(patch,{ok='Applied',clearKeys=[]}={}){beginReq();try{co async function applySection(sec){if(secErrors(sec)){toast('Fix validation errors first','warn');return;}const patch=secPatch(sec),keys=Object.keys(patch);if(!keys.length){toast('No changes','info');return;}const msg=sec==='freq'?'Frequency updated':sec==='rds'?'RDS text updated':'Applied';await sendPatch(patch,{ok:msg,clearKeys:keys});} function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');} async function applyCfgSection(sec){if(sec==='rds-id'&&S.cfgErrors?.pi){toast('Fix validation errors first','warn');return;}const patch=cfgPatch(sec);if(!Object.keys(patch).length){toast('No changes','info');return;}const hasR=cfgHasRestart(sec);beginReq();try{const res=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>delete S.cfgDraft[k]);S.cfgDirty[sec]=false;if(sec==='rds-id'&&S.cfgErrors)delete S.cfgErrors.pi;toast(hasR?'Saved (restart required)':'Applied live','ok');log('CFG '+sec+' '+JSON.stringify(patch)+(hasR?' [restart]':' [live]'),'ok');await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);return res;}catch(e){toast(e.message,'err');log('CFG failed: '+e.message,'err');throw e;}finally{endReq();}} -async function txAction(action){if(S.txBusy)return;S.txBusy=true;render();beginReq();try{await api(`/tx/${action}`,{method:'POST'});toast(action==='start'?'TX started':'TX stopped','ok');log('TX '+action,'ok');await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);}catch(e){toast(e.message,'err');log('TX '+action+' failed: '+e.message,'err');}finally{S.txBusy=false;endReq();render();}} +async function txAction(action){ + if(S.txBusy)return; + S.txBusy=true; + if(action==='stop'&&S.server.runtime){ + S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'}; + } + render(); + beginReq(); + try{ + log('TX '+action+' requested','info'); + await api(`/tx/${action}`,{method:'POST'}); + toast(action==='start'?'TX started':'TX stop requested','ok'); + log('TX '+action+' accepted','ok'); + await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]); + }catch(e){ + toast(e.message,'err'); + log('TX '+action+' failed: '+e.message,'err'); + }finally{ + S.txBusy=false; + endReq(); + render(); + } +} async function resetFault(){if(S.faultBusy)return;S.faultBusy=true;render();beginReq();try{await api('/runtime/fault/reset',{method:'POST'});toast('Fault reset','ok');log('Fault reset','ok');await loadRuntime({silent:true});}catch(e){toast(e.message,'err');log('Fault reset failed: '+e.message,'err');}finally{S.faultBusy=false;endReq();render();}} async function setToggle(key,val){if(S.toggleBusy[key])return;S.toggleBusy[key]=true;render();try{await sendPatch({[key]:val},{ok:key.replace(/Enabled$/,'')+' '+(val?'enabled':'disabled')});}finally{S.toggleBusy[key]=false;render();}} async function saveIngest(){if(S.ingestSaving)return;if(!S.ingestDirty){toast('No changes','info');return;}S.ingestSaving=true;S.ingestError='';beginReq();render();try{const res=await api('/config/ingest/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ingest:S.ingestDraft})});S.ingestDirty=false;toast(res.reloadScheduled?'Saved, reloading…':'Saved','ok');log('Ingest saved'+(res.reloadScheduled?' [reload]':''),'ok');if(res.reloadScheduled)setTimeout(()=>location.reload(),1500);}catch(e){S.ingestError=e.message;toast(e.message,'err');log('Ingest save failed: '+e.message,'err');}finally{S.ingestSaving=false;endReq();render();}} @@ -856,7 +878,9 @@ function _render(){ $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase(); $('tx-state').className='tx-state '+(S.txBusy?'working':txSt); setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s'); - const startDis=S.txBusy||txSt==='running',stopDis=S.txBusy||['idle','stopped',''].includes(txSt); + const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping']; + const startDis=S.txBusy||txSt==='running'; + const stopDis=S.txBusy||!canStopStates.includes(txSt); $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0; // ── Overview: meters + sparklines @@ -908,7 +932,7 @@ function _render(){ setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required'); $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance']; // Danger - $('danger-stop').disabled=stopDis;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} + $('danger-stop').disabled=S.txBusy;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} const rh=$('reset-hint');if(rh){const sn=normState(eng.state);rh.textContent=sn==='faulted'?'Faulted: reset moves runtime back to DEGRADED.':sn==='muted'||sn==='degraded'?'Reset Fault holds at DEGRADED until queue recovers.':'Manual fault reset drops to DEGRADED while queue recovers.';} // ── RDS tab @@ -934,8 +958,10 @@ function _render(){ syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%'); const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty; // Status card + const activePS=String(eng.activePS||cfg.rds?.ps||'').trim(); + const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim(); setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi)); - setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',cfg.rds?.ps||'--');setText('rds-stat-rt',cfg.rds?.radioText||'--'); + setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--'); setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection)); // ── Ingest tab diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index 266e572..b4e3fa6 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -195,6 +195,12 @@ func (e *Encoder) ClearRT() { e.liveRT.Store(pendingText{val: "", set: true}) } +// CurrentText returns the currently active PS and RT from the encoder scheduler. +// It reflects the last text applied at an RDS group boundary. +func (e *Encoder) CurrentText() (ps, rt string) { + return e.scheduler.cfg.PS, e.scheduler.cfg.RT +} + // NextSample returns the next RDS subcarrier sample at the configured rate. // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier // for phase-locked operation in a stereo MPX chain. From 1d683d1d8f38928949a282b51847259aa0a20948 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:29:53 +0200 Subject: [PATCH 39/40] control: fix body rejection guard for empty POST requests rejectBody() returns true when the request body is acceptable and false when a body must be rejected. The TX and fault-reset handlers treated the return value the wrong way around and returned early on valid empty POST requests. This prevented actions like /tx/stop from running in the normal no-body case. Update the handlers to only abort when rejectBody() reports an actual rejection, so empty POST control actions proceed as intended. --- internal/control/control.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 0af6741..e4cf0f5 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -155,12 +155,15 @@ func hasRequestBody(r *http.Request) bool { } func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool { + // Returns true when the request has an unexpected body and the error response + // has already been written — callers should return immediately in that case. + // Returns false when there is no body (happy path — request should proceed). if !hasRequestBody(r) { - return true + return false } s.recordAudit(auditUnexpectedBody) http.Error(w, noBodyErrMsg, http.StatusBadRequest) - return false + return true } func (s *Server) recordAudit(evt auditEvent) { From ffd6f4bcfe05066b6742e51d207be0cdf59dd4f9 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:57:45 +0200 Subject: [PATCH 40/40] control: make tone and gain updates truly live Wire tone frequency, tone amplitude, and audio gain through the live control path so the UI's live-update behavior matches the engine. This changes the generator live params to carry tone and gain values, propagates them through Engine.UpdateConfig and txBridge.UpdateConfig, and extends the control-plane patch types accordingly. It also refines the control API behavior: - avoid holding the server config mutex across tx.UpdateConfig() - report live=true only when a request contains at least one genuinely live-applicable field Together these fixes align the UI semantics with the actual runtime behavior and remove a lock hazard in the config update path. --- cmd/fmrtx/main.go | 4 ++++ internal/app/engine.go | 27 ++++++++++++++++++++++++ internal/control/control.go | 39 ++++++++++++++++++++++++++++------- internal/offline/generator.go | 32 ++++++++++++++++++++++++++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index fdde1e4..c3e371d 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -353,6 +353,10 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { LimiterCeiling: lp.LimiterCeiling, PS: lp.PS, RadioText: lp.RadioText, + ToneLeftHz: lp.ToneLeftHz, + ToneRightHz: lp.ToneRightHz, + ToneAmplitude: lp.ToneAmplitude, + AudioGain: lp.AudioGain, }) } diff --git a/internal/app/engine.go b/internal/app/engine.go index 39b9a9f..a1f2e71 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -277,6 +277,11 @@ type LiveConfigUpdate struct { LimiterCeiling *float64 PS *string RadioText *string + // Tone and gain: live-patchable without engine restart. + ToneLeftHz *float64 + ToneRightHz *float64 + ToneAmplitude *float64 + AudioGain *float64 } // UpdateConfig applies live parameter changes without restarting the engine. @@ -310,6 +315,16 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { return fmt.Errorf("limiterCeiling out of range (0-2)") } } + if u.ToneAmplitude != nil { + if *u.ToneAmplitude < 0 || *u.ToneAmplitude > 1 { + return fmt.Errorf("toneAmplitude out of range (0-1)") + } + } + if u.AudioGain != nil { + if *u.AudioGain < 0 || *u.AudioGain > 4 { + return fmt.Errorf("audioGain out of range (0-4)") + } + } // --- Frequency: store for run loop to apply via driver.Tune() --- if u.FrequencyMHz != nil { @@ -357,6 +372,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { if u.LimiterCeiling != nil { next.LimiterCeiling = *u.LimiterCeiling } + if u.ToneLeftHz != nil { + next.ToneLeftHz = *u.ToneLeftHz + } + if u.ToneRightHz != nil { + next.ToneRightHz = *u.ToneRightHz + } + if u.ToneAmplitude != nil { + next.ToneAmplitude = *u.ToneAmplitude + } + if u.AudioGain != nil { + next.AudioGain = *u.AudioGain + } e.generator.UpdateLive(next) return nil diff --git a/internal/control/control.go b/internal/control/control.go index e4cf0f5..ef67f30 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -45,6 +45,10 @@ type LivePatch struct { LimiterCeiling *float64 PS *string RadioText *string + ToneLeftHz *float64 + ToneRightHz *float64 + ToneAmplitude *float64 + AudioGain *float64 } type Server struct { @@ -597,18 +601,37 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { LimiterCeiling: patch.LimiterCeiling, PS: patch.PS, RadioText: patch.RadioText, - } + ToneLeftHz: patch.ToneLeftHz, + ToneRightHz: patch.ToneRightHz, + ToneAmplitude: patch.ToneAmplitude, + AudioGain: patch.AudioGain, + } + // NEU-02 fix: determine whether any live-patchable fields are present, + // then release the lock before calling UpdateConfig to avoid holding + // s.mu across a potentially blocking engine call. tx := s.tx - if tx != nil { + hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil || + patch.StereoEnabled != nil || patch.PilotLevel != nil || + patch.RDSInjection != nil || patch.RDSEnabled != nil || + patch.LimiterEnabled != nil || patch.LimiterCeiling != nil || + patch.PS != nil || patch.RadioText != nil || + patch.ToneLeftHz != nil || patch.ToneRightHz != nil || + patch.ToneAmplitude != nil || patch.AudioGain != nil + s.cfg = next + s.mu.Unlock() + // Apply live fields to running engine outside the lock. + var updateErr error + if tx != nil && hasLiveFields { if err := tx.UpdateConfig(lp); err != nil { - s.mu.Unlock() - http.Error(w, err.Error(), http.StatusBadRequest) - return + updateErr = err } } - s.cfg = next - live := tx != nil - s.mu.Unlock() + if updateErr != nil { + http.Error(w, updateErr.Error(), http.StatusBadRequest) + return + } + // NEU-03 fix: report live=true only when live-patchable fields were applied. + live := tx != nil && hasLiveFields w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) default: diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 6598087..b55c63a 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -32,6 +32,11 @@ type LiveParams struct { LimiterEnabled bool LimiterCeiling float64 MpxGain float64 // hardware calibration factor for composite output + // Tone + gain: live-patchable without DSP chain reinit. + ToneLeftHz float64 + ToneRightHz float64 + ToneAmplitude float64 + AudioGain float64 } // PreEmphasizedSource wraps an audio source and applies pre-emphasis. @@ -112,6 +117,10 @@ type Generator struct { // Optional external audio source (e.g. StreamResampler for live audio). // When set, takes priority over WAV/tones in sourceFor(). externalSource frameSource + + // Tone source reference — non-nil when a ToneSource is the active audio input. + // Allows live-updating tone parameters via LiveParams each chunk. + toneSource *audio.ToneSource } func NewGenerator(cfg cfgpkg.Config) *Generator { @@ -227,6 +236,10 @@ func (g *Generator) init() { LimiterEnabled: g.cfg.FM.LimiterEnabled, LimiterCeiling: ceiling, MpxGain: g.cfg.FM.MpxGain, + ToneLeftHz: g.cfg.Audio.ToneLeftHz, + ToneRightHz: g.cfg.Audio.ToneRightHz, + ToneAmplitude: g.cfg.Audio.ToneAmplitude, + AudioGain: g.cfg.Audio.Gain, }) g.initialized = true @@ -240,9 +253,13 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} } - return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} + ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) + g.toneSource = ts + return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} } - return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} + ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) + g.toneSource = ts + return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} } func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { @@ -272,6 +289,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } + // Apply live tone and gain updates each chunk. GenerateFrame runs on a + // single goroutine so these field writes are safe without additional locking. + if g.toneSource != nil { + g.toneSource.LeftFreq = lp.ToneLeftHz + g.toneSource.RightFreq = lp.ToneRightHz + g.toneSource.Amplitude = lp.ToneAmplitude + } + if g.source != nil { + g.source.gain = lp.AudioGain + } + // Broadcast clip-filter-clip FM MPX signal chain: // // Audio L/R → PreEmphasis