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:
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
+}