| @@ -78,6 +78,7 @@ type IngestConfig struct { | |||||
| HTTPRaw IngestPCMConfig `json:"httpRaw"` | HTTPRaw IngestPCMConfig `json:"httpRaw"` | ||||
| Icecast IngestIcecastConfig `json:"icecast"` | Icecast IngestIcecastConfig `json:"icecast"` | ||||
| SRT IngestSRTConfig `json:"srt"` | SRT IngestSRTConfig `json:"srt"` | ||||
| AES67 IngestAES67Config `json:"aes67"` | |||||
| } | } | ||||
| type IngestReconnectConfig struct { | type IngestReconnectConfig struct { | ||||
| @@ -112,6 +113,21 @@ type IngestSRTConfig struct { | |||||
| Channels int `json:"channels"` | 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 { | func Default() Config { | ||||
| return Config{ | return Config{ | ||||
| Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, | Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, | ||||
| @@ -165,6 +181,15 @@ func Default() Config { | |||||
| SampleRateHz: 48000, | SampleRateHz: 48000, | ||||
| Channels: 2, | 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)) | ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) | ||||
| switch ingestKind { | 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: | default: | ||||
| return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) | 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) == "" { | if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" { | ||||
| return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt") | 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)) { | switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) { | ||||
| case "", "listener", "caller", "rendezvous": | case "", "listener", "caller", "rendezvous": | ||||
| default: | default: | ||||
| @@ -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) { | func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { | ||||
| cfg := Default() | cfg := Default() | ||||
| cfg.Ingest.Stdin.SampleRateHz = 0 | cfg.Ingest.Stdin.SampleRateHz = 0 | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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{} | |||||
| } | |||||
| } | |||||
| @@ -5,11 +5,14 @@ import ( | |||||
| "io" | "io" | ||||
| "net/http" | "net/http" | ||||
| "os" | "os" | ||||
| "path/filepath" | |||||
| "strings" | "strings" | ||||
| "time" | |||||
| "aoiprxkit" | "aoiprxkit" | ||||
| "github.com/jan/fm-rds-tx/internal/config" | "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest" | "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/httpraw" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" | "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/srt" | ||||
| @@ -17,9 +20,10 @@ import ( | |||||
| ) | ) | ||||
| type Deps struct { | 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 { | 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...) | src := srt.New("srt-main", srtCfg, opts...) | ||||
| return src, nil, nil | 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: | default: | ||||
| return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) | 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 { | if cfg.Ingest.SRT.SampleRateHz > 0 { | ||||
| return cfg.Ingest.SRT.SampleRateHz | return cfg.Ingest.SRT.SampleRateHz | ||||
| } | } | ||||
| case "aes67", "aoip", "aoip-rtp": | |||||
| if cfg.Ingest.AES67.SampleRateHz > 0 { | |||||
| return cfg.Ingest.AES67.SampleRateHz | |||||
| } | |||||
| } | } | ||||
| return 44100 | return 44100 | ||||
| } | } | ||||
| @@ -94,3 +113,62 @@ func SampleRateForKind(cfg config.Config) int { | |||||
| func normalizeIngestKind(kind string) string { | func normalizeIngestKind(kind string) string { | ||||
| return strings.ToLower(strings.TrimSpace(kind)) | 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 | |||||
| } | |||||
| @@ -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) { | func TestBuildSourceUnsupportedKind(t *testing.T) { | ||||
| cfg := config.Default() | cfg := config.Default() | ||||
| cfg.Ingest.Kind = "nope" | cfg.Ingest.Kind = "nope" | ||||
| @@ -143,4 +189,10 @@ func TestSampleRateForKind(t *testing.T) { | |||||
| if got := SampleRateForKind(cfg); got != 48000 { | if got := SampleRateForKind(cfg); got != 48000 { | ||||
| t.Fatalf("srt sample rate=%d", got) | 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) | |||||
| } | |||||
| } | } | ||||
| @@ -11,12 +11,29 @@ import ( | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| "github.com/jan/fm-rds-tx/internal/config" | "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest" | "github.com/jan/fm-rds-tx/internal/ingest" | ||||
| aoipad "github.com/jan/fm-rds-tx/internal/ingest/adapters/aoip" | |||||
| ) | ) | ||||
| type streamReadCloser struct{ io.Reader } | type streamReadCloser struct{ io.Reader } | ||||
| func (r streamReadCloser) Close() error { return nil } | 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) { | func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) { | ||||
| cfg := config.Default() | cfg := config.Default() | ||||
| cfg.Ingest.Kind = "http-raw" | 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) { | func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) { | ||||
| t.Helper() | t.Helper() | ||||
| deadline := time.Now().Add(1 * time.Second) | deadline := time.Now().Add(1 * time.Second) | ||||