| @@ -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` | |||||
| @@ -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()) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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. | |||||
| @@ -0,0 +1,3 @@ | |||||
| module aoiprxkit | |||||
| go 1.22 | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 = `<!doctype html> | |||||
| <html> | |||||
| <head> | |||||
| <meta charset="utf-8" /> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||||
| <title>aoiprxkit meter</title> | |||||
| <style> | |||||
| body { font-family: system-ui, sans-serif; margin: 20px; background: #111; color: #eee; } | |||||
| .meta { margin-bottom: 16px; color: #bbb; } | |||||
| .row { margin: 12px 0; } | |||||
| .label { margin-bottom: 4px; } | |||||
| .bar { width: 100%; height: 22px; background: #222; border-radius: 6px; overflow: hidden; } | |||||
| .fill { height: 100%; background: linear-gradient(90deg, #2ecc71, #f1c40f, #e74c3c); width: 0%; } | |||||
| .nums { font-size: 12px; color: #bbb; margin-top: 4px; } | |||||
| </style> | |||||
| </head> | |||||
| <body> | |||||
| <h1>aoiprxkit live meter</h1> | |||||
| <div id="meta" class="meta">waiting for frames…</div> | |||||
| <div id="meters"></div> | |||||
| <script> | |||||
| const meta = document.getElementById('meta'); | |||||
| const root = document.getElementById('meters'); | |||||
| const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/live'); | |||||
| ws.onmessage = (ev) => { | |||||
| const snap = JSON.parse(ev.data); | |||||
| meta.textContent = (snap.source || 'unknown') + ' · ' + snap.sampleRateHz + ' Hz · ' + snap.channels + ' ch · ' + snap.updatedAt; | |||||
| root.innerHTML = ''; | |||||
| (snap.meters || []).forEach((m, idx) => { | |||||
| const row = document.createElement('div'); | |||||
| row.className = 'row'; | |||||
| const label = document.createElement('div'); | |||||
| label.className = 'label'; | |||||
| label.textContent = 'Channel ' + (idx + 1); | |||||
| const bar = document.createElement('div'); | |||||
| bar.className = 'bar'; | |||||
| const fill = document.createElement('div'); | |||||
| fill.className = 'fill'; | |||||
| fill.style.width = Math.max(0, Math.min(100, m.peak * 100)).toFixed(1) + '%'; | |||||
| bar.appendChild(fill); | |||||
| const nums = document.createElement('div'); | |||||
| nums.className = 'nums'; | |||||
| nums.textContent = 'RMS ' + m.rms.toFixed(4) + ' · Peak ' + m.peak.toFixed(4) + ' · Latest ' + m.latest.toFixed(4); | |||||
| row.appendChild(label); | |||||
| row.appendChild(bar); | |||||
| row.appendChild(nums); | |||||
| root.appendChild(row); | |||||
| }); | |||||
| }; | |||||
| </script> | |||||
| </body> | |||||
| </html>` | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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"` | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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, | |||||
| }) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() } | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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") | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -5,9 +5,12 @@ go 1.22 | |||||
| require github.com/jan/fm-rds-tx/internal v0.0.0 | require github.com/jan/fm-rds-tx/internal v0.0.0 | ||||
| require ( | require ( | ||||
| aoiprxkit v0.0.0 // indirect | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 // indirect | github.com/hajimehoshi/go-mp3 v0.3.4 // indirect | ||||
| github.com/jfreymuth/oggvorbis v1.0.5 // indirect | github.com/jfreymuth/oggvorbis v1.0.5 // indirect | ||||
| github.com/jfreymuth/vorbis v1.0.2 // indirect | github.com/jfreymuth/vorbis v1.0.2 // indirect | ||||
| ) | ) | ||||
| replace github.com/jan/fm-rds-tx/internal => ./internal | replace github.com/jan/fm-rds-tx/internal => ./internal | ||||
| replace aoiprxkit => ./aoiprxkit | |||||
| @@ -77,6 +77,7 @@ type IngestConfig struct { | |||||
| Stdin IngestPCMConfig `json:"stdin"` | Stdin IngestPCMConfig `json:"stdin"` | ||||
| HTTPRaw IngestPCMConfig `json:"httpRaw"` | HTTPRaw IngestPCMConfig `json:"httpRaw"` | ||||
| Icecast IngestIcecastConfig `json:"icecast"` | Icecast IngestIcecastConfig `json:"icecast"` | ||||
| SRT IngestSRTConfig `json:"srt"` | |||||
| } | } | ||||
| type IngestReconnectConfig struct { | type IngestReconnectConfig struct { | ||||
| @@ -104,6 +105,13 @@ type IngestIcecastRadioTextConfig struct { | |||||
| OnlyOnChange bool `json:"onlyOnChange"` | 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 { | 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}, | ||||
| @@ -152,6 +160,11 @@ func Default() Config { | |||||
| OnlyOnChange: true, | OnlyOnChange: true, | ||||
| }, | }, | ||||
| }, | }, | ||||
| SRT: IngestSRTConfig{ | |||||
| Mode: "listener", | |||||
| SampleRateHz: 48000, | |||||
| Channels: 2, | |||||
| }, | |||||
| }, | }, | ||||
| } | } | ||||
| } | } | ||||
| @@ -238,8 +251,9 @@ func (c Config) Validate() error { | |||||
| if c.Ingest.Kind == "" { | if c.Ingest.Kind == "" { | ||||
| c.Ingest.Kind = "none" | 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: | default: | ||||
| return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) | 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" { | 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") | 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") | 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)) { | switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) { | ||||
| case "", "auto", "native", "ffmpeg", "fallback": | case "", "auto", "native", "ffmpeg", "fallback": | ||||
| default: | default: | ||||
| @@ -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) { | func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { | ||||
| cfg := Default() | cfg := Default() | ||||
| cfg.Ingest.Stdin.SampleRateHz = 0 | cfg.Ingest.Stdin.SampleRateHz = 0 | ||||
| @@ -1,10 +1,13 @@ | |||||
| module github.com/jan/fm-rds-tx/internal | module github.com/jan/fm-rds-tx/internal | ||||
| go 1.21 | |||||
| go 1.22 | |||||
| require ( | require ( | ||||
| aoiprxkit v0.0.0 | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 | github.com/hajimehoshi/go-mp3 v0.3.4 | ||||
| github.com/jfreymuth/oggvorbis v1.0.5 | github.com/jfreymuth/oggvorbis v1.0.5 | ||||
| ) | ) | ||||
| require github.com/jfreymuth/vorbis v1.0.2 // indirect | require github.com/jfreymuth/vorbis v1.0.2 // indirect | ||||
| replace aoiprxkit => ../aoiprxkit | |||||
| @@ -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: | |||||
| } | |||||
| } | |||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -7,16 +7,19 @@ import ( | |||||
| "os" | "os" | ||||
| "strings" | "strings" | ||||
| "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/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/stdinpcm" | "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" | ||||
| ) | ) | ||||
| type Deps struct { | type Deps struct { | ||||
| Stdin io.Reader | |||||
| HTTP *http.Client | |||||
| Stdin io.Reader | |||||
| HTTP *http.Client | |||||
| SRTOpener aoiprxkit.SRTConnOpener | |||||
| } | } | ||||
| type AudioIngress interface { | type AudioIngress interface { | ||||
| @@ -50,6 +53,19 @@ func BuildSource(cfg config.Config, deps Deps) (ingest.Source, AudioIngress, err | |||||
| icecast.WithDecoderPreference(cfg.Ingest.Icecast.Decoder), | icecast.WithDecoderPreference(cfg.Ingest.Icecast.Decoder), | ||||
| ) | ) | ||||
| return src, nil, nil | 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: | 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) | ||||
| } | } | ||||
| @@ -67,6 +83,10 @@ func SampleRateForKind(cfg config.Config) int { | |||||
| } | } | ||||
| case "icecast": | case "icecast": | ||||
| return 44100 | return 44100 | ||||
| case "srt": | |||||
| if cfg.Ingest.SRT.SampleRateHz > 0 { | |||||
| return cfg.Ingest.SRT.SampleRateHz | |||||
| } | |||||
| } | } | ||||
| return 44100 | return 44100 | ||||
| } | } | ||||
| @@ -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) { | func TestBuildSourceUnsupportedKind(t *testing.T) { | ||||
| cfg := config.Default() | cfg := config.Default() | ||||
| cfg.Ingest.Kind = "nope" | cfg.Ingest.Kind = "nope" | ||||
| @@ -114,4 +137,10 @@ func TestSampleRateForKind(t *testing.T) { | |||||
| if got := SampleRateForKind(cfg); got != 44100 { | if got := SampleRateForKind(cfg); got != 44100 { | ||||
| t.Fatalf("icecast sample rate=%d", got) | 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) | |||||
| } | |||||
| } | } | ||||
| @@ -1,15 +1,22 @@ | |||||
| package factory | package factory | ||||
| import ( | import ( | ||||
| "bytes" | |||||
| "context" | "context" | ||||
| "io" | |||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| "aoiprxkit" | |||||
| "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" | ||||
| ) | ) | ||||
| type streamReadCloser struct{ io.Reader } | |||||
| func (r streamReadCloser) Close() error { return nil } | |||||
| 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" | ||||
| @@ -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) { | 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) | ||||