streaming

Media streaming and broadcast systems in Go
Log | Files | Refs | README | LICENSE

commit 100851ab2d0042150f9239111f470af0052011a0
parent d8d776d33b9f56bdec169ba655a16551c6e33655
Author: Oliver Lowe <o@olowe.co>
Date:   Thu, 18 Jul 2024 18:04:06 +1000

sdp: implement session, media encoding

Diffstat:
Asdp/encode.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asdp/encode_test.go | 29+++++++++++++++++++++++++++++
Msdp/sdp.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msdp/sdp_test.go | 18+++++++++---------
Msdp/testdata/good.sdp | 5+++--
Asdp/time.go | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 311 insertions(+), 23 deletions(-)

diff --git a/sdp/encode.go b/sdp/encode.go @@ -0,0 +1,63 @@ +package sdp + +import ( + "fmt" + "strings" +) + +func (s *Session) String() string { + buf := &strings.Builder{} + fmt.Fprintln(buf, "v=0") + fmt.Fprintf(buf, "o=%s %d %d IN %s %s\n", s.Origin.Username, s.Origin.ID, s.Origin.Version, s.Origin.AddressType, s.Origin.Address) + fmt.Fprintf(buf, "s=%s\n", s.Name) + + if s.Info != "" { + fmt.Fprintf(buf, "i=%s\n", s.Info) + } + if s.URI != nil { + fmt.Fprintf(buf, "u=%s\n", s.URI) + } + if s.Email != nil { + // Remove quotes from mail.Address.String() to be + // identical to examples in RFC 8866. + fmt.Fprintf(buf, "e=%s\n", strings.ReplaceAll(s.Email.String(), `"`, "")) + } + if s.Phone != "" { + fmt.Fprintf(buf, "p=%s\n", s.Phone) + } + if s.Connection != nil { + fmt.Fprintln(buf, s.Connection) + } + if s.Bandwidth != nil { + fmt.Fprintln(buf, s.Bandwidth) + } + + // TODO(otl): what about the invalid case where Time[0] is zero but Time[1] is not? + if s.Time[0].IsZero() { + fmt.Fprintln(buf, "t=0 0") + } else { + fmt.Fprintf(buf, "t=%d %d\n", s.Time[0].Unix()+sinceTimeZero, s.Time[1].Unix()+sinceTimeZero) + } + + if s.Repeat != nil { + fmt.Fprintln(buf, s.Repeat) + } + + if s.Adjustments != nil { + adj := make([]string, len(s.Adjustments)) + for i := range s.Adjustments { + adj[i] = s.Adjustments[i].String() + } + fmt.Fprintf(buf, "z=%s\n", strings.Join(adj, " ")) + } + + if s.Attributes != nil { + fmt.Fprintf(buf, "a=%s\n", strings.Join(s.Attributes, " ")) + } + + for _, m := range s.Media { + fmt.Fprintln(buf, m) + } + + return strings.TrimSpace(buf.String()) +} diff --git a/sdp/encode_test.go b/sdp/encode_test.go @@ -0,0 +1,29 @@ +package sdp + +import ( + "bytes" + "io" + "os" + "testing" +) + +func TestWriteSession(t *testing.T) { + f, err := os.Open("testdata/good.sdp") + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + want := string(b) + session, err := ReadSession(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + if want != session.String() { + t.Errorf("mismatched sdp text") + t.Log("want", want) + t.Log("got", session.String()) + } +} diff --git a/sdp/sdp.go b/sdp/sdp.go @@ -107,7 +107,7 @@ type Bandwidth struct { func (b Bandwidth) String() string { // need kilobits per second as per section 5.8. - return fmt.Sprintf("%s:%d", b.Type, b.Bitrate/1e3) + return fmt.Sprintf("b=%s:%d", b.Type, b.Bitrate/1e3) } func parseBandwidth(s string) (Bandwidth, error) { @@ -132,7 +132,7 @@ type Media struct { Type string // TODO(otl): new type mediaType? Port int PortCount int - Protocol uint8 + Transport TransportProto Format []string // Optional fields Title string @@ -142,13 +142,52 @@ type Media struct { Attributes []string } +func (m Media) String() string { + buf := &strings.Builder{} + if m.PortCount == 0 { + fmt.Fprintf(buf, "m=%s %d %s %s\n", m.Type, m.Port, m.Transport, strings.Join(m.Format, " ")) + } else { + fmt.Fprintf(buf, "m=%s %d/%d %s %s\n", m.Type, m.Port, m.PortCount, m.Transport, strings.Join(m.Format, " ")) + } + + if m.Title != "" { + fmt.Fprintf(buf, "i=%s\n", m.Title) + } + if m.Connection != nil { + fmt.Fprintln(buf, m.Connection) + } + if m.Bandwidth != nil { + fmt.Fprintln(buf, m.Bandwidth) + } + if m.Attributes != nil { + fmt.Fprintf(buf, "a=%s", strings.Join(m.Attributes, " ")) + } + return strings.TrimSpace(buf.String()) +} + +type TransportProto uint8 + const ( - ProtoUDP uint8 = iota + ProtoUDP TransportProto = iota ProtoRTP ProtoRTPSecure ProtoRTPSecureFeedback ) +func (tp TransportProto) String() string { + switch tp { + case ProtoUDP: + return "udp" + case ProtoRTP: + return "RTP/AVP" + case ProtoRTPSecure: + return "RTP/SAVP" + case ProtoRTPSecureFeedback: + return "RTP/SAVPF" + } + return "unknown" +} + func parseMedia(s string) (Media, error) { fields := strings.Fields(s) if len(fields) < 4 { @@ -170,14 +209,14 @@ func parseMedia(s string) (Media, error) { } switch fields[2] { - case "udp": - m.Protocol = ProtoUDP - case "RTP/AVP": - m.Protocol = ProtoRTP - case "RTP/SAVP": - m.Protocol = ProtoRTPSecure - case "RTP/SAVPF": - m.Protocol = ProtoRTPSecureFeedback + case ProtoUDP.String(): + m.Transport = ProtoUDP + case ProtoRTP.String(): + m.Transport = ProtoRTP + case ProtoRTPSecure.String(): + m.Transport = ProtoRTPSecure + case ProtoRTPSecureFeedback.String(): + m.Transport = ProtoRTPSecureFeedback default: return Media{}, fmt.Errorf("unknown protocol %s", fields[2]) } @@ -189,11 +228,22 @@ func parseMedia(s string) (Media, error) { // ConnInfo represents connection information. type ConnInfo struct { Type string // TODO(otl): only "IP4", "IP6" valid... new int type? - Address string // IPv4, IPv6 literal or a hostname + Address string // IPv4, IPv6 literal TODO(otl): or a hostname? TTL int // time to live Count int // number of addresses after Address } +func (c *ConnInfo) String() string { + s := fmt.Sprintf("c=%s %s %s", "IN", c.Type, c.Address) + if c.TTL > 0 { + s += fmt.Sprintf("/%d", c.TTL) + } + if c.Count > 0 { + s += fmt.Sprintf("/%d", c.Count) + } + return s +} + func parseConnInfo(s string) (ConnInfo, error) { fields := strings.Fields(s) if len(fields) != 3 { diff --git a/sdp/sdp_test.go b/sdp/sdp_test.go @@ -37,21 +37,21 @@ func TestReadSession(t *testing.T) { }, Media: []Media{ Media{ - Type: "audio", - Port: 49170, - Protocol: ProtoRTP, - Format: []string{"0"}, + Type: "audio", + Port: 49170, + Transport: ProtoRTP, + Format: []string{"0"}, }, Media{ - Type: "audio", - Port: 49180, - Protocol: ProtoRTP, - Format: []string{"0"}, + Type: "audio", + Port: 49180, + Transport: ProtoRTP, + Format: []string{"0"}, }, Media{ Type: "video", Port: 51372, - Protocol: ProtoRTP, + Transport: ProtoRTP, Format: []string{"99"}, Connection: &ConnInfo{"IP6", "2001:db8::2", 0, 0}, Attributes: []string{"rtpmap:99", "h263-1998/90000"}, diff --git a/sdp/testdata/good.sdp b/sdp/testdata/good.sdp @@ -4,11 +4,11 @@ s=Call to John Smith i=SDP Offer #1 u=http://www.jdoe.example.com/home.html e=Jane Doe <jane@jdoe.example.com> -p=+1 617 555-6011 +p=+16175556011 c=IN IP4 198.51.100.1 t=0 0 m=audio 49170 RTP/AVP 0 m=audio 49180 RTP/AVP 0 m=video 51372 RTP/AVP 99 c=IN IP6 2001:db8::2 -a=rtpmap:99 h263-1998/90000 +a=rtpmap:99 h263-1998/90000 +\ No newline at end of file diff --git a/sdp/time.go b/sdp/time.go @@ -0,0 +1,145 @@ +package sdp + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// number of seconds from the zero time used in SDP; 1900-01-01T00:00Z +// to the Unix epoch. +const sinceTimeZero = 2208988800 + +func parseTimes(s string) ([2]time.Time, error) { + var times [2]time.Time + fields := strings.Fields(s) + if len(fields) != 2 { + return times, fmt.Errorf("bad number of fields %d: need 2", len(fields)) + } + start, err := strconv.Atoi(fields[0]) + if err != nil { + return times, fmt.Errorf("parse start time: %w", err) + } + if start != 0 { + times[0] = time.Unix(int64(start-sinceTimeZero), 0).UTC() + } + end, err := strconv.Atoi(fields[1]) + if err != nil { + return times, fmt.Errorf("parse end time: %w", err) + } + if end != 0 { + times[1] = time.Unix(int64(end-sinceTimeZero), 0).UTC() + } + return times, nil +} + +// Repeat represents a session's repetition cycle as described in +// RFC 8866 section 5.10. +type Repeat struct { + Interval time.Duration // duration between each repetition cycle + Active time.Duration // planned duration of each session + Offsets []time.Duration // duration(s) between each session +} + +func (rp *Repeat) String() string { + // TODO(otl): print with no decimal places? + s := fmt.Sprintf("r=%f %f ", rp.Interval.Round(time.Second).Seconds(), rp.Active.Round(time.Second).Seconds()) + if len(rp.Offsets) > 0 { + ss := make([]string, len(rp.Offsets)) + for i := range rp.Offsets { + ss[i] = fmt.Sprintf("%f", rp.Offsets[i].Round(time.Second).Seconds()) + } + s += strings.Join(ss, " ") + } + return strings.TrimSpace(s) +} + +func parseRepeat(s string) (Repeat, error) { + // guard against negative durations, decimals. + // these are valid for time.ParseDuration, but not for our Repeat. + if strings.Contains(s, "-") || strings.Contains(s, ".") { + return Repeat{}, errors.New("invalid duration") + } + + fields := strings.Fields(s) + if len(fields) < 3 { + return Repeat{}, fmt.Errorf("short line: have %d, want at least %d fields", len(fields), 3) + } + + var repeat Repeat + var err error + repeat.Interval, err = parseDuration(fields[0]) + if err != nil { + return Repeat{}, fmt.Errorf("parse interval %s: %w", fields[0], err) + } + repeat.Active, err = parseDuration(fields[1]) + if err != nil { + return Repeat{}, fmt.Errorf("parse active duration %s: %w", fields[1], err) + } + for _, s := range fields[2:] { + offset, err := parseDuration(s) + if err != nil { + return Repeat{}, fmt.Errorf("parse offset %s: %w", s, err) + } + repeat.Offsets = append(repeat.Offsets, offset) + } + return repeat, nil +} + +func parseDuration(s string) (time.Duration, error) { + // a bare int, like 86400 + i, err := strconv.Atoi(s) + if err == nil { + return time.Duration(i) * time.Second, nil + } + + // a duration string like 24h + dur, err := time.ParseDuration(s) + if err == nil { + return dur, nil + } + + // a duration string with days suffix, like 1d + // [0-9]+d + if !strings.HasSuffix(s, "d") { + return 0, fmt.Errorf("bad duration: expected d suffix for days") + } + j, err := strconv.Atoi(s[:len(s)-1]) + if err != nil { + return 0, fmt.Errorf("parse days: %w", err) + } + return time.Duration(j) * 24 * time.Hour, nil +} + +type TimeAdjustment struct { + When time.Time + Offset time.Duration +} + +func (t TimeAdjustment) String() string { + return fmt.Sprintf("%d %f", t.When.Unix()+sinceTimeZero, t.Offset.Round(time.Second).Seconds()) +} + +func parseAdjustments(line string) ([]TimeAdjustment, error) { + fields := strings.Fields(line) + if len(fields)%2 != 0 { + return nil, fmt.Errorf("odd field count %d", len(fields)) + } + var adjustments []TimeAdjustment + for i := 0; i < len(fields); i += 2 { + var adj TimeAdjustment + t, err := strconv.Atoi(fields[i]) + if err != nil { + return nil, fmt.Errorf("time %s: %w", fields[i], err) + } + adj.When = time.Unix(int64(t-sinceTimeZero), 0).UTC() + adj.Offset, err = parseDuration(fields[i+1]) + if err != nil { + return nil, fmt.Errorf("offset %s: %w", fields[i+1], err) + } + adjustments = append(adjustments, adj) + } + return adjustments, nil +}