Skip to content

Session Description Protocol (SDP)

voip.sdp

Session Description Protocol (SDP) implementation of RFC 4566.

SessionDescription dataclass

Bases: ByteSerializableObject

Session Description Protocol message RFC 4566.

Holds all session-level and media-level fields in their canonical order.

Source code in voip/sdp/messages.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@dataclasses.dataclass
class SessionDescription(ByteSerializableObject):
    """Session Description Protocol message [RFC 4566].

    Holds all session-level and media-level fields in their canonical order.

    [RFC 4566]: https://datatracker.ietf.org/doc/html/rfc4566
    """

    version: int = 0
    origin: Origin | None = None
    name: str = "-"
    title: str | None = None
    uri: str | None = None
    emails: list[str] = dataclasses.field(default_factory=list)
    phones: list[str] = dataclasses.field(default_factory=list)
    connection: ConnectionData | None = None
    bandwidths: list[Bandwidth] = dataclasses.field(default_factory=list)
    timings: list[Timing] = dataclasses.field(default_factory=list)
    repeat: str | None = None
    zone: str | None = None
    attributes: list[Attribute] = dataclasses.field(default_factory=list)
    media: list[MediaDescription] = dataclasses.field(default_factory=list)

    @classmethod
    def parse(cls, data: bytes | str) -> SessionDescription:
        text = data.decode() if isinstance(data, bytes) else data
        sdp = cls()
        current_media: MediaDescription | None = None
        for line in text.splitlines():
            current_media = sdp._apply_line(line.rstrip("\r"), current_media)
        return sdp

    def _apply_line(
        self, line: str, current_media: MediaDescription | None
    ) -> MediaDescription | None:
        """Apply a single SDP line to this session, return the active MediaDescription."""
        if not line or "=" not in line:
            return current_media
        letter, _, value = line.partition("=")
        if letter not in FIELD_BY_LETTER:
            return current_media
        field = FIELD_BY_LETTER[letter]
        parsed = field.parse(value)
        if isinstance(parsed, MediaDescription):
            self.media.append(parsed)
            return parsed
        if (
            letter == "a"
            and isinstance(parsed, Attribute)
            and current_media is not None
        ):
            if self._apply_media_attribute(parsed, current_media):
                return current_media
        if field.media_attr is not None and current_media is not None:
            return self._apply_to_media(
                current_media, field.media_attr, parsed, field.is_list
            )
        if field.is_list:
            getattr(self, field.session_attr).append(parsed)
        else:
            setattr(self, field.session_attr, parsed)
        return current_media

    @staticmethod
    def _apply_media_attribute(attr: Attribute, media: MediaDescription) -> bool:
        """Fold a media-level a= attribute into *media* if it is a format-specific attribute.

        Returns ``True`` when the attribute was consumed (``a=rtpmap`` or
        ``a=fmtp``), ``False`` otherwise so the caller can fall through to the
        generic attribute list.
        """
        return media.apply_attribute(attr)

    @staticmethod
    def _apply_to_media(
        media: MediaDescription, attr: str, value: object, is_list: bool
    ) -> MediaDescription:
        """Apply a parsed field value to a MediaDescription, return it unchanged."""
        if is_list:
            getattr(media, attr).append(value)
        else:
            setattr(media, attr, value)
        return media

    def __bytes__(self) -> bytes:
        return str(self).encode()

    def __str__(self) -> str:
        return "\r\n".join(self._lines()) + "\r\n"

    def _lines(self) -> Generator[str]:
        """Yield each SDP line in canonical field order."""
        for field in FIELD_MAP:
            if field.session_attr == "media":
                yield from (str(m) for m in self.media)
                continue
            value = getattr(self, field.session_attr)
            if field.is_list:
                yield from (f"{field.letter}={v}" for v in value)
            elif (
                value is not None
                and value != ""
                or field.session_attr in ("version", "name")
            ):
                yield f"{field.letter}={value}"

Types

voip.sdp.types

SDP field types as defined by RFC 4566.

Attribute dataclass

Bases: ByteSerializableObject

Attribute field (a=) as defined by RFC 4566 §5.13.

Source code in voip/sdp/types.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
@dataclasses.dataclass(slots=True)
class Attribute(ByteSerializableObject):
    """Attribute field (a=) as defined by RFC 4566 §5.13."""

    letter: ClassVar[str] = "a"
    session_attr: ClassVar[str] = "attributes"
    is_list: ClassVar[bool] = True
    media_attr: ClassVar[str | None] = "attributes"

    name: str
    value: str | None = None

    def __bytes__(self) -> bytes:
        match self.value:
            case None:
                return self.name.encode()
            case _:
                return f"{self.name}:{self.value}".encode()

    @classmethod
    def parse(cls, data: bytes | str) -> Attribute:
        value = data.decode() if isinstance(data, bytes) else data
        name, _, attr_value = value.partition(":")
        return cls(name=name, value=attr_value or None)

Bandwidth dataclass

Bases: ByteSerializableObject

Bandwidth field (b=) as defined by RFC 4566 §5.8.

Source code in voip/sdp/types.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@dataclasses.dataclass(slots=True)
class Bandwidth(ByteSerializableObject):
    """Bandwidth field (b=) as defined by RFC 4566 §5.8."""

    letter: ClassVar[str] = "b"
    session_attr: ClassVar[str] = "bandwidths"
    is_list: ClassVar[bool] = True
    media_attr: ClassVar[str | None] = "bandwidths"

    bwtype: str
    bandwidth: int

    def __bytes__(self) -> bytes:
        return f"{self.bwtype}:{self.bandwidth}".encode()

    @classmethod
    def parse(cls, data: bytes | str) -> Bandwidth:
        value = data.decode() if isinstance(data, bytes) else data
        bwtype, _, bandwidth = value.partition(":")
        return cls(bwtype=bwtype, bandwidth=int(bandwidth))

ConnectionData dataclass

Bases: ByteSerializableObject

Connection data field (c=) as defined by RFC 4566 §5.7.

Source code in voip/sdp/types.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@dataclasses.dataclass(slots=True)
class ConnectionData(ByteSerializableObject):
    """Connection data field (c=) as defined by RFC 4566 §5.7."""

    letter: ClassVar[str] = "c"
    session_attr: ClassVar[str] = "connection"
    is_list: ClassVar[bool] = False
    media_attr: ClassVar[str | None] = "connection"

    nettype: str
    addrtype: str
    connection_address: str

    def __bytes__(self) -> bytes:
        return f"{self.nettype} {self.addrtype} {self.connection_address}".encode()

    @classmethod
    def parse(cls, data: bytes | str) -> ConnectionData:
        value = data.decode() if isinstance(data, bytes) else data
        nettype, addrtype, connection_address = value.split(" ", 2)
        return cls(
            nettype=nettype,
            addrtype=addrtype,
            connection_address=connection_address,
        )

Field

Bases: Protocol

SDP field descriptor protocol.

Source code in voip/sdp/types.py
27
28
29
30
31
32
33
34
35
36
37
38
@runtime_checkable
class Field(Protocol):
    """SDP field descriptor protocol."""

    letter: str
    session_attr: str
    is_list: bool
    media_attr: str | None

    @staticmethod
    def parse(value: str) -> object:
        """Parse a raw SDP line value."""
parse(value) staticmethod

Parse a raw SDP line value.

Source code in voip/sdp/types.py
36
37
38
@staticmethod
def parse(value: str) -> object:
    """Parse a raw SDP line value."""

IntField dataclass

Descriptor for SDP fields that parse and serialize as integers.

Source code in voip/sdp/types.py
55
56
57
58
59
60
61
62
63
64
65
66
@dataclasses.dataclass(slots=True)
class IntField:
    """Descriptor for SDP fields that parse and serialize as integers."""

    letter: str
    session_attr: str
    is_list: bool = False
    media_attr: str | None = None

    @staticmethod
    def parse(value: str) -> int:
        return int(value)

MediaDescription dataclass

Bases: ByteSerializableObject

Media description section (m=) as defined by RFC 4566 §5.14.

Source code in voip/sdp/types.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
@dataclasses.dataclass(slots=True)
class MediaDescription(ByteSerializableObject):
    """Media description section (m=) as defined by RFC 4566 §5.14."""

    letter: ClassVar[str] = "m"
    session_attr: ClassVar[str] = "media"
    is_list: ClassVar[bool] = True
    media_attr: ClassVar[str | None] = None

    media: str
    port: int
    proto: str
    fmt: list[RTPPayloadFormat]
    title: str | None = None
    connection: ConnectionData | None = None
    bandwidths: list[Bandwidth] = dataclasses.field(default_factory=list)
    attributes: list[Attribute] = dataclasses.field(default_factory=list)

    def get_format(self, pt: int | str) -> RTPPayloadFormat | None:
        """Return the `RTPPayloadFormat` for payload type *pt*, or ``None``."""
        target = int(pt)
        return next((f for f in self.fmt if f.payload_type == target), None)

    def apply_attribute(self, attr: Attribute) -> bool:
        """Apply a media-level ``a=`` attribute, returning ``True`` if consumed.

        Handles ``a=rtpmap`` and ``a=fmtp`` by updating the matching
        `RTPPayloadFormat` entry.  Other attributes go to `attributes`.
        """
        if attr.name == "rtpmap" and attr.value is not None:
            rtpfmt = RTPPayloadFormat.parse(attr.value)
            for i, f in enumerate(self.fmt):
                if f.payload_type == rtpfmt.payload_type:
                    # Preserve fmtp if it was already applied out-of-order.
                    if f.fmtp is not None and rtpfmt.fmtp is None:
                        rtpfmt.fmtp = f.fmtp
                    self.fmt[i] = rtpfmt
                    break
            return True
        if attr.name == "fmtp" and attr.value is not None:
            pt_str, _, params = attr.value.partition(" ")
            try:
                pt = int(pt_str)
            except ValueError:
                return False
            for f in self.fmt:
                if f.payload_type == pt:
                    f.fmtp = params
                    return True
        return False

    def _lines(self) -> Generator[str]:
        """Yield each SDP line in canonical field order."""
        yield f"m={self.media} {self.port} {self.proto} {' '.join(str(f.payload_type) for f in self.fmt)}"
        match self.title:
            case str() as title:
                yield f"i={title}"
        match self.connection:
            case ConnectionData() as connection:
                yield f"c={connection}"
        yield from (f"b={b}" for b in self.bandwidths)
        for fmt in self.fmt:
            match fmt.encoding_name, fmt.sample_rate:
                case (str(), int()):
                    yield f"a=rtpmap:{fmt}"
            match fmt.fmtp:
                case str() as fmtp:
                    yield f"a=fmtp:{fmt.payload_type} {fmtp}"
        yield from (f"a={a}" for a in self.attributes)

    def __bytes__(self) -> bytes:
        return "\r\n".join(self._lines()).encode()

    @classmethod
    def parse(cls, data: bytes | str) -> MediaDescription:
        value = data.decode() if isinstance(data, bytes) else data
        lines = value.splitlines()
        first = lines[0].rstrip("\r").removeprefix("m=")
        media_type, port_str, proto, *fmts = first.split()
        fmt = [RTPPayloadFormat.from_pt(int(pt)) for pt in fmts]
        obj = cls(media=media_type, port=int(port_str), proto=proto, fmt=fmt)
        for line in lines[1:]:
            line = line.rstrip("\r")
            if not line or "=" not in line:
                continue
            letter, _, attr_value = line.partition("=")
            match letter:
                case "i":
                    obj.title = attr_value
                case "c":
                    obj.connection = ConnectionData.parse(attr_value)
                case "b":
                    obj.bandwidths.append(Bandwidth.parse(attr_value))
                case "a":
                    attr = Attribute.parse(attr_value)
                    if not obj.apply_attribute(attr):
                        obj.attributes.append(attr)
        return obj
apply_attribute(attr)

Apply a media-level a= attribute, returning True if consumed.

Handles a=rtpmap and a=fmtp by updating the matching RTPPayloadFormat entry. Other attributes go to attributes.

Source code in voip/sdp/types.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
def apply_attribute(self, attr: Attribute) -> bool:
    """Apply a media-level ``a=`` attribute, returning ``True`` if consumed.

    Handles ``a=rtpmap`` and ``a=fmtp`` by updating the matching
    `RTPPayloadFormat` entry.  Other attributes go to `attributes`.
    """
    if attr.name == "rtpmap" and attr.value is not None:
        rtpfmt = RTPPayloadFormat.parse(attr.value)
        for i, f in enumerate(self.fmt):
            if f.payload_type == rtpfmt.payload_type:
                # Preserve fmtp if it was already applied out-of-order.
                if f.fmtp is not None and rtpfmt.fmtp is None:
                    rtpfmt.fmtp = f.fmtp
                self.fmt[i] = rtpfmt
                break
        return True
    if attr.name == "fmtp" and attr.value is not None:
        pt_str, _, params = attr.value.partition(" ")
        try:
            pt = int(pt_str)
        except ValueError:
            return False
        for f in self.fmt:
            if f.payload_type == pt:
                f.fmtp = params
                return True
    return False
get_format(pt)

Return the RTPPayloadFormat for payload type pt, or None.

Source code in voip/sdp/types.py
355
356
357
358
def get_format(self, pt: int | str) -> RTPPayloadFormat | None:
    """Return the `RTPPayloadFormat` for payload type *pt*, or ``None``."""
    target = int(pt)
    return next((f for f in self.fmt if f.payload_type == target), None)

Origin dataclass

Bases: ByteSerializableObject

Origin field (o=) as defined by RFC 4566 §5.2.

Source code in voip/sdp/types.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
@dataclasses.dataclass(slots=True)
class Origin(ByteSerializableObject):
    """Origin field (o=) as defined by RFC 4566 §5.2."""

    letter: ClassVar[str] = "o"
    session_attr: ClassVar[str] = "origin"
    is_list: ClassVar[bool] = False
    media_attr: ClassVar[str | None] = None

    username: str
    sess_id: str
    sess_version: str
    nettype: str
    addrtype: str
    unicast_address: str

    def __bytes__(self) -> bytes:
        return (
            f"{self.username} {self.sess_id} {self.sess_version}"
            f" {self.nettype} {self.addrtype} {self.unicast_address}"
        ).encode()

    @classmethod
    def parse(cls, data: bytes | str) -> Origin:
        value = data.decode() if isinstance(data, bytes) else data
        username, sess_id, sess_version, nettype, addrtype, unicast_address = (
            value.split(" ", 5)
        )
        return cls(
            username=username,
            sess_id=sess_id,
            sess_version=sess_version,
            nettype=nettype,
            addrtype=addrtype,
            unicast_address=unicast_address,
        )

PayloadTypeSpec

Bases: NamedTuple

Typed specification for a static RTP payload type (RFC 3551 §6).

Source code in voip/sdp/types.py
204
205
206
207
208
209
210
211
212
class PayloadTypeSpec(NamedTuple):
    """Typed specification for a static RTP payload type (RFC 3551 §6)."""

    pt: int
    sample_rate: int
    encoding_name: str
    channels: int = 1
    #: Samples per standard 20 ms RTP frame; 0 = variable or not applicable.
    frame_size: int = 0

RTPPayloadFormat dataclass

Bases: ByteSerializableObject

RTP payload format descriptor (RFC 3551 §6 / RFC 4566 §6).

Codec parameters from a=rtpmap are merged in by the SDP parser. Static payload types fall back to the StaticPayloadType table. Dynamic payload types (PT ≥ 96) require an explicit a=rtpmap.

Serialises to the a=rtpmap value when codec fields are present.

Source code in voip/sdp/types.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
@dataclasses.dataclass(slots=True)
class RTPPayloadFormat(ByteSerializableObject):
    """RTP payload format descriptor (RFC 3551 §6 / RFC 4566 §6).

    Codec parameters from ``a=rtpmap`` are merged in by the SDP parser.
    Static payload types fall back to the `StaticPayloadType` table.
    Dynamic payload types (PT ≥ 96) require an explicit ``a=rtpmap``.

    Serialises to the ``a=rtpmap`` value when codec fields are present.
    """

    payload_type: int
    fmtp: str | None = None
    encoding_name: str | None = None
    channels: int = 1
    sample_rate: int | None = None

    def __post_init__(self):
        try:
            default = StaticPayloadType.from_pt(self.payload_type)
        except ValueError:
            pass
        else:
            self.sample_rate = self.sample_rate or default.sample_rate
            self.encoding_name = self.encoding_name or default.encoding_name
            self.channels = self.channels or default.channels

    def __bytes__(self) -> bytes:
        base = f"{self.payload_type} {self.encoding_name}/{self.sample_rate}"
        match self.channels:
            case 1:
                return base.encode()
            case _:
                return f"{base}/{self.channels}".encode()

    @classmethod
    def parse(cls, data: bytes | str) -> RTPPayloadFormat:
        value = data.decode() if isinstance(data, bytes) else data
        fmt, _, rest = value.partition(" ")
        parts = rest.split("/")
        if len(parts) < 2:
            raise ValueError(f"Invalid rtpmap value: {value!r}")
        return cls(
            payload_type=int(fmt),
            encoding_name=parts[0],
            sample_rate=int(parts[1]),
            channels=int(parts[2]) if len(parts) > 2 else 1,
        )

    @classmethod
    def from_pt(cls, pt: int) -> RTPPayloadFormat:
        """Create an `RTPPayloadFormat` from a payload type number."""
        return cls(payload_type=pt)

    @property
    def frame_size(self) -> int:
        """Samples per standard 20 ms RTP frame.

        For static payload types the value comes from `StaticPayloadType`.
        For dynamic payload types (e.g. Opus, PT ≥ 96) it is derived from
        `sample_rate` assuming a 20 ms packetisation interval.
        """
        try:
            spec = StaticPayloadType.from_pt(self.payload_type)
            if spec.frame_size:
                return spec.frame_size
        except ValueError:
            pass
        return (self.sample_rate or 8000) * 20 // 1000
frame_size property

Samples per standard 20 ms RTP frame.

For static payload types the value comes from StaticPayloadType. For dynamic payload types (e.g. Opus, PT ≥ 96) it is derived from sample_rate assuming a 20 ms packetisation interval.

from_pt(pt) classmethod

Create an RTPPayloadFormat from a payload type number.

Source code in voip/sdp/types.py
315
316
317
318
@classmethod
def from_pt(cls, pt: int) -> RTPPayloadFormat:
    """Create an `RTPPayloadFormat` from a payload type number."""
    return cls(payload_type=pt)

StaticPayloadType

Bases: PayloadTypeSpec, Enum

Static RTP payload types as defined by RFC 3551 §6.

Each member's value is a PayloadTypeSpec carrying the payload type number, sample rate, canonical encoding name, channel count, and frame size. Use from_pt to look up a member by its PT number.

Source code in voip/sdp/types.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
class StaticPayloadType(PayloadTypeSpec, enum.Enum):
    """Static RTP payload types as defined by RFC 3551 §6.

    Each member's `value` is a `PayloadTypeSpec` carrying the
    payload type number, sample rate, canonical encoding name, channel count,
    and frame size.  Use `from_pt` to look up a member by its PT number.
    """

    #: G.711 µ-law
    PCMU = PayloadTypeSpec(0, 8000, "PCMU", frame_size=160)
    GSM = PayloadTypeSpec(3, 8000, "GSM", frame_size=160)
    G723 = PayloadTypeSpec(4, 8000, "G723", frame_size=160)
    #: DVI4 at 8 kHz
    DVI4_8K = PayloadTypeSpec(5, 8000, "DVI4", frame_size=160)
    #: DVI4 at 16 kHz
    DVI4_16K = PayloadTypeSpec(6, 16000, "DVI4", frame_size=320)
    LPC = PayloadTypeSpec(7, 8000, "LPC", frame_size=160)
    #: G.711 A-law
    PCMA = PayloadTypeSpec(8, 8000, "PCMA", frame_size=160)
    #: RTP clock rate is 8000 per RFC 3551 even though wideband
    G722 = PayloadTypeSpec(9, 8000, "G722", frame_size=160)
    L16_STEREO = PayloadTypeSpec(10, 44100, "L16", 2, frame_size=882)
    L16_MONO = PayloadTypeSpec(11, 44100, "L16", frame_size=882)
    QCELP = PayloadTypeSpec(12, 8000, "QCELP", frame_size=160)
    CN = PayloadTypeSpec(13, 8000, "CN", frame_size=160)
    MPA = PayloadTypeSpec(14, 90000, "MPA")
    G728 = PayloadTypeSpec(15, 8000, "G728", frame_size=160)
    #: DVI4 at 11.025 kHz
    DVI4_11K = PayloadTypeSpec(16, 11025, "DVI4", frame_size=220)
    #: DVI4 at 22.05 kHz
    DVI4_22K = PayloadTypeSpec(17, 22050, "DVI4", frame_size=441)
    G729 = PayloadTypeSpec(18, 8000, "G729", frame_size=160)
    CELB = PayloadTypeSpec(25, 90000, "CelB")
    JPEG = PayloadTypeSpec(26, 90000, "JPEG")
    NV = PayloadTypeSpec(28, 90000, "nv")
    H261 = PayloadTypeSpec(31, 90000, "H261")
    #: MPEG-1 and MPEG-2 video
    MPV = PayloadTypeSpec(32, 90000, "MPV")
    #: MPEG-2 transport stream
    MP2T = PayloadTypeSpec(33, 90000, "MP2T")
    H263 = PayloadTypeSpec(34, 90000, "H263")

    @classmethod
    def from_pt(cls, pt: int) -> StaticPayloadType:
        """Look up a static payload type by its PT number."""
        for member in cls:
            if member.value.pt == pt:
                return member
        raise ValueError(f"No static payload type with PT {pt}")
from_pt(pt) classmethod

Look up a static payload type by its PT number.

Source code in voip/sdp/types.py
257
258
259
260
261
262
263
@classmethod
def from_pt(cls, pt: int) -> StaticPayloadType:
    """Look up a static payload type by its PT number."""
    for member in cls:
        if member.value.pt == pt:
            return member
    raise ValueError(f"No static payload type with PT {pt}")

StrField dataclass

Descriptor for SDP fields that parse and serialize as plain strings.

Source code in voip/sdp/types.py
41
42
43
44
45
46
47
48
49
50
51
52
@dataclasses.dataclass(slots=True)
class StrField:
    """Descriptor for SDP fields that parse and serialize as plain strings."""

    letter: str
    session_attr: str
    is_list: bool = False
    media_attr: str | None = None

    @staticmethod
    def parse(value: str) -> str:
        return value

Timing dataclass

Bases: ByteSerializableObject

Timing field (t=) as defined by RFC 4566 §5.9.

Source code in voip/sdp/types.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
@dataclasses.dataclass(slots=True)
class Timing(ByteSerializableObject):
    """Timing field (t=) as defined by RFC 4566 §5.9."""

    letter: ClassVar[str] = "t"
    session_attr: ClassVar[str] = "timings"
    is_list: ClassVar[bool] = True
    media_attr: ClassVar[str | None] = None

    start_time: int
    stop_time: int

    def __bytes__(self) -> bytes:
        return f"{self.start_time} {self.stop_time}".encode()

    @classmethod
    def parse(cls, data: bytes | str) -> Timing:
        value = data.decode() if isinstance(data, bytes) else data
        start_time, stop_time = value.split(" ", 1)
        return cls(start_time=int(start_time), stop_time=int(stop_time))