rushaudio 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rushaudio/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """RushAudio — low-latency audio over IP live streaming protocol.
2
+
3
+ Pure-Python, zero-dependency port of the Rust reference implementation
4
+ (https://github.com/OseMine/rushaudio). Follows the canonical wire spec in
5
+ ``docs/protocol.md`` of the rushaudio repository.
6
+
7
+ Public API mirrors the Rust crate:
8
+
9
+ import rushaudio
10
+ server = rushaudio.create_server("0.0.0.0:4210")
11
+ client = rushaudio.create_client()
12
+
13
+ pkt = Packet.new(PacketType.AUDIO_DATA, seq=1, ts=0,
14
+ payload=b"...")
15
+ wire = pkt.encode()
16
+ parsed = Packet.decode(wire)
17
+ """
18
+
19
+ from . import constants as _c
20
+ from .connection import Connection, ConnectionPool
21
+ from .fec import FecEncoder
22
+ from .handshake import Handshake, HandshakeRole, HandshakeState
23
+ from .jitter import JitterBuffer, JitterStats
24
+ from .levels import AudioLevels
25
+ from .metadata import Metadata, MetadataBuilder, MetadataEntry, MetadataMap
26
+ from .packet import (
27
+ BadMagicError,
28
+ InvalidPayloadError,
29
+ Packet,
30
+ PacketError,
31
+ PacketHeader,
32
+ PacketTooShortError,
33
+ TruncatedPayloadError,
34
+ UnknownTypeError,
35
+ )
36
+ from .session import Session, SessionManager
37
+ from .transport import UdpTransport
38
+ from .types import (
39
+ AudioCodec,
40
+ ConnectionState,
41
+ PacketType,
42
+ StreamConfig,
43
+ StreamStats,
44
+ )
45
+
46
+ VERSION = "1.1.0"
47
+ DEFAULT_PORT: int = 4210
48
+
49
+
50
+ def create_server(bind_addr: str) -> UdpTransport:
51
+ """Bind a non-blocking UDP server, e.g. ``create_server("0.0.0.0:4210")``."""
52
+ return UdpTransport.bind(bind_addr)
53
+
54
+
55
+ def create_client() -> UdpTransport:
56
+ """Bind a non-blocking UDP socket on an ephemeral port."""
57
+ return UdpTransport.bind("0.0.0.0:0")
58
+
59
+
60
+ __all__ = [
61
+ "AudioCodec",
62
+ "AudioLevels",
63
+ "BadMagicError",
64
+ "Connection",
65
+ "ConnectionPool",
66
+ "ConnectionState",
67
+ "FecEncoder",
68
+ "Handshake",
69
+ "HandshakeRole",
70
+ "HandshakeState",
71
+ "InvalidPayloadError",
72
+ "JitterBuffer",
73
+ "JitterStats",
74
+ "Metadata",
75
+ "MetadataBuilder",
76
+ "MetadataEntry",
77
+ "MetadataMap",
78
+ "Packet",
79
+ "PacketError",
80
+ "PacketHeader",
81
+ "PacketTooShortError",
82
+ "PacketType",
83
+ "Session",
84
+ "SessionManager",
85
+ "StreamConfig",
86
+ "StreamStats",
87
+ "TruncatedPayloadError",
88
+ "UdpTransport",
89
+ "UnknownTypeError",
90
+ "VERSION",
91
+ "DEFAULT_PORT",
92
+ "create_server",
93
+ "create_client",
94
+ ]
@@ -0,0 +1,99 @@
1
+ """Per-peer connection tracking and a bounded connection pool.
2
+
3
+ Mirrors ``src/transport/connection.rs`` in the Rust reference implementation.
4
+ """
5
+
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Hashable, Iterator, List, Optional
9
+
10
+ from .types import ConnectionState, StreamConfig, StreamStats
11
+
12
+
13
+ @dataclass
14
+ class Connection:
15
+ """State and statistics for one remote peer."""
16
+
17
+ remote_addr: Hashable
18
+ state: ConnectionState = ConnectionState.DISCONNECTED
19
+ config: StreamConfig = field(default_factory=StreamConfig)
20
+ stats: StreamStats = field(default_factory=StreamStats)
21
+ sequence_number: int = 0
22
+ connected_at: Optional[float] = None
23
+ last_activity: float = field(default_factory=time.monotonic)
24
+ ssrc: int = 0
25
+
26
+ def next_seq(self) -> int:
27
+ """Return the current sequence number and advance it (mod 2^32)."""
28
+ value = self.sequence_number
29
+ self.sequence_number = (self.sequence_number + 1) & 0xFFFFFFFF
30
+ return value
31
+
32
+ def current_seq(self) -> int:
33
+ return self.sequence_number
34
+
35
+ def mark_activity(self) -> None:
36
+ self.last_activity = time.monotonic()
37
+
38
+ def elapsed_since_activity(self) -> float:
39
+ return time.monotonic() - self.last_activity
40
+
41
+ def set_state(self, state: ConnectionState) -> None:
42
+ self.state = state
43
+ if state == ConnectionState.CONNECTED:
44
+ self.connected_at = time.monotonic()
45
+
46
+ def record_sent(self, size: int) -> None:
47
+ self.stats.packets_sent += 1
48
+ self.stats.bytes_sent += size
49
+
50
+ def record_received(self, size: int) -> None:
51
+ self.stats.packets_received += 1
52
+ self.stats.bytes_received += size
53
+
54
+ def record_loss(self, count: int) -> None:
55
+ self.stats.packets_lost += count
56
+
57
+
58
+ class ConnectionPool:
59
+ """A bounded collection of connections keyed by remote address."""
60
+
61
+ def __init__(self, max_connections: int) -> None:
62
+ self._connections: List[Connection] = []
63
+ self._max_connections = max_connections
64
+
65
+ def get(self, addr: Hashable) -> Optional[Connection]:
66
+ for conn in self._connections:
67
+ if conn.remote_addr == addr:
68
+ return conn
69
+ return None
70
+
71
+ def get_mut(self, addr: Hashable) -> Optional[Connection]:
72
+ return self.get(addr)
73
+
74
+ def add(self, conn: Connection) -> bool:
75
+ if len(self._connections) >= self._max_connections:
76
+ return False
77
+ self._connections.append(conn)
78
+ return True
79
+
80
+ def remove(self, addr: Hashable) -> None:
81
+ self._connections = [c for c in self._connections if c.remote_addr != addr]
82
+
83
+ def len(self) -> int:
84
+ return len(self._connections)
85
+
86
+ def __len__(self) -> int:
87
+ return len(self._connections)
88
+
89
+ def is_empty(self) -> bool:
90
+ return not self._connections
91
+
92
+ def iter(self) -> Iterator[Connection]:
93
+ return iter(self._connections)
94
+
95
+ def __iter__(self) -> Iterator[Connection]:
96
+ return iter(self._connections)
97
+
98
+ def contains(self, addr: Hashable) -> bool:
99
+ return any(c.remote_addr == addr for c in self._connections)
rushaudio/constants.py ADDED
@@ -0,0 +1,62 @@
1
+ """Protocol constants for the RushAudio Python port.
2
+
3
+ Mirrors ``src/protocol/constants.rs`` in the Rust reference implementation.
4
+ """
5
+
6
+ PROTOCOL_MAGIC = bytes((0x52, 0x41)) # ASCII "RA"
7
+ PROTOCOL_VERSION = 1
8
+
9
+ HEADER_SIZE = 14 # 2(magic) + 1(ver) + 1(type) + 4(seq) + 4(ts) + 2(len)
10
+ MAX_PAYLOAD_SIZE = 4096
11
+ MAX_PACKET_SIZE = HEADER_SIZE + MAX_PAYLOAD_SIZE
12
+
13
+ # Default timing
14
+ DEFAULT_SAMPLE_RATE = 48000
15
+ DEFAULT_FRAME_DURATION_MS = 20
16
+ DEFAULT_JITTER_BUFFER_MS = 80
17
+ DEFAULT_KEEPALIVE_INTERVAL_MS = 5000
18
+ DEFAULT_HANDSHAKE_TIMEOUT_MS = 3000
19
+
20
+ # Opus defaults
21
+ OPUS_FRAME_SIZE_20MS = 960 # 48000 * 0.020
22
+ OPUS_CHANNELS = 2
23
+
24
+ # FEC
25
+ FEC_REDUNDANCY_COUNT = 1
26
+ FEC_GROUP_SIZE = 4
27
+
28
+ # Control codes
29
+ CONTROL_STREAM_START = 0x01
30
+ CONTROL_STREAM_STOP = 0x02
31
+ CONTROL_STREAM_PAUSE = 0x03
32
+ CONTROL_STREAM_RESUME = 0x04
33
+
34
+ # Metadata entry header: 1(key) + 2(length) = 3 bytes
35
+ METADATA_ENTRY_HEADER_SIZE = 3
36
+
37
+ # Built-in metadata keys (0x00 reserved, 0x01-0x7F reserved by protocol)
38
+ META_SSRC = 0x01
39
+ META_TRACK_TITLE = 0x02
40
+ META_ARTIST = 0x03
41
+ META_ALBUM = 0x04
42
+ META_GENRE = 0x05
43
+ META_SAMPLE_RATE = 0x06
44
+ META_CHANNELS = 0x07
45
+ META_CODEC_INFO = 0x08
46
+ META_BITRATE = 0x09
47
+ META_DURATION_MS = 0x0A
48
+ META_STREAM_TITLE = 0x0B
49
+ META_STREAM_URL = 0x0C
50
+
51
+ # Custom metadata keys start at 0x80
52
+ META_CUSTOM_BASE = 0x80
53
+
54
+ # Audio level / VU meter
55
+ # Payload: 4(audio_sequence) + 4(audio_timestamp) + 1(peak) + 1(rms)
56
+ AUDIO_LEVELS_PAYLOAD_SIZE = 10
57
+ # Levels are encoded in dBFS with 1 dB per unit. 0 = full scale (0 dBFS),
58
+ # -127 = quietest non-silent level. LEVEL_SILENCE (-128) means -infinity dBFS
59
+ # (digital silence).
60
+ LEVEL_DBFS_MAX = 0
61
+ LEVEL_DBFS_MIN = -127
62
+ LEVEL_SILENCE = -128
rushaudio/fec.py ADDED
@@ -0,0 +1,84 @@
1
+ """XOR-based forward error correction.
2
+
3
+ Mirrors ``src/utils/fec.rs`` in the Rust reference implementation and
4
+ spec section 8.
5
+
6
+ - Encoding: XOR-reduce the payloads of a group of packets into one repair
7
+ packet that also carries the group count and the original sequence numbers.
8
+ - Decoding: XOR the repair data with every surviving packet of the group to
9
+ recover a single missing packet. More than one loss per group is fatal.
10
+ """
11
+
12
+ from typing import List, Optional, Sequence, Tuple
13
+
14
+ from .packet import Packet
15
+ from .types import PacketType
16
+
17
+
18
+ class FecEncoder:
19
+ """Static helpers for FEC repair packet generation and recovery."""
20
+
21
+ @staticmethod
22
+ def generate_repair(group: Sequence[Packet], seq: int, ts: int) -> Optional[Packet]:
23
+ """Build a FECData repair packet for a group of data packets.
24
+
25
+ XORs all group payloads into ``repair_data`` (sized to the longest
26
+ payload in the group) and prefixes:
27
+ ``[group_count: u8] [sequence × group_count: u32 BE each]``.
28
+ """
29
+ if not group:
30
+ return None
31
+
32
+ max_len = max(len(pkt.payload) for pkt in group)
33
+ repair = bytearray(max_len)
34
+ for pkt in group:
35
+ for index, byte in enumerate(pkt.payload):
36
+ repair[index] ^= byte
37
+
38
+ fec_meta = bytearray([len(group)])
39
+ for pkt in group:
40
+ fec_meta += pkt.header.sequence.to_bytes(4, "big")
41
+
42
+ return Packet.new(
43
+ PacketType.FEC_DATA, seq, ts, bytes(fec_meta) + bytes(repair)
44
+ )
45
+
46
+ @staticmethod
47
+ def try_recover(
48
+ repair_packet: Packet, available: Sequence[Packet]
49
+ ) -> Optional[Tuple[int, bytes]]:
50
+ """Attempt to recover a lost packet.
51
+
52
+ Returns ``(lost_sequence, recovered_payload)`` when exactly one
53
+ packet of the FEC group is missing and recoverable; otherwise None.
54
+ """
55
+ payload = repair_packet.payload
56
+ if not payload:
57
+ return None
58
+
59
+ group_count = payload[0]
60
+ meta_end = 1 + group_count * 4
61
+ if len(payload) < meta_end:
62
+ return None
63
+
64
+ sequences = [
65
+ int.from_bytes(payload[1 + i * 4:5 + i * 4], "big")
66
+ for i in range(group_count)
67
+ ]
68
+ repair_data = bytearray(payload[meta_end:])
69
+
70
+ available_seqs = {pkt.header.sequence for pkt in available}
71
+ missing = [s for s in sequences if s not in available_seqs]
72
+ if len(missing) != 1:
73
+ return None
74
+ lost_seq = missing[0]
75
+
76
+ for pkt in available:
77
+ if pkt.header.sequence == lost_seq:
78
+ continue
79
+ if pkt.header.sequence in sequences:
80
+ for index, byte in enumerate(pkt.payload):
81
+ if index < len(repair_data):
82
+ repair_data[index] ^= byte
83
+
84
+ return lost_seq, bytes(repair_data)
rushaudio/handshake.py ADDED
@@ -0,0 +1,99 @@
1
+ """Session handshake state machine and payload builders/parsers.
2
+
3
+ Mirrors ``src/session/handshake.rs`` in the Rust reference implementation and
4
+ spec section 5.
5
+ """
6
+
7
+ import enum
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional, Tuple
11
+
12
+ from . import constants as _c
13
+ from .packet import Packet
14
+ from .types import AudioCodec, PacketType, StreamConfig
15
+
16
+
17
+ class HandshakeRole(enum.Enum):
18
+ INITIATOR = "Initiator"
19
+ RESPONDER = "Responder"
20
+
21
+
22
+ class HandshakeState(enum.Enum):
23
+ IDLE = "Idle"
24
+ WAITING_FOR_RESPONSE = "WaitingForResponse"
25
+ WAITING_FOR_REQUEST = "WaitingForRequest"
26
+ COMPLETED = "Completed"
27
+ FAILED = "Failed"
28
+
29
+
30
+ @dataclass
31
+ class Handshake:
32
+ """Tracks one side of a handshake and builds/parses its packets."""
33
+
34
+ role: HandshakeRole
35
+ ssrc: int
36
+ config: StreamConfig
37
+ state: HandshakeState = HandshakeState.IDLE
38
+ remote_addr: Optional[str] = None
39
+ started_at: Optional[float] = None
40
+ timeout: float = _c.DEFAULT_HANDSHAKE_TIMEOUT_MS / 1000.0
41
+
42
+ def build_request(self, seq: int, ts: int) -> Packet:
43
+ """Build a HandshakeRequest packet (14-byte payload, spec 4.3)."""
44
+ payload = (
45
+ self.ssrc.to_bytes(4, "big")
46
+ + self.config.sample_rate.to_bytes(4, "big")
47
+ + self.config.bitrate.to_bytes(4, "big")
48
+ + bytes((self.config.codec.to_u8(), self.config.channels))
49
+ )
50
+ return Packet.new(PacketType.HANDSHAKE_REQUEST, seq, ts, payload)
51
+
52
+ def build_response(self, seq: int, ts: int, accepted: bool) -> Packet:
53
+ """Build a HandshakeResponse packet (5-byte payload, spec 4.4)."""
54
+ payload = bytes((0x01 if accepted else 0x00,)) + self.ssrc.to_bytes(4, "big")
55
+ return Packet.new(PacketType.HANDSHAKE_RESPONSE, seq, ts, payload)
56
+
57
+ @staticmethod
58
+ def parse_request(packet: Packet) -> Optional[Tuple[int, StreamConfig]]:
59
+ """Parse a HandshakeRequest -> (remote_ssrc, StreamConfig)."""
60
+ payload = packet.payload
61
+ if len(payload) < 12:
62
+ return None
63
+
64
+ remote_ssrc = int.from_bytes(payload[0:4], "big")
65
+ sample_rate = int.from_bytes(payload[4:8], "big")
66
+ bitrate = int.from_bytes(payload[8:12], "big")
67
+ codec = AudioCodec.from_u8(payload[12]) if len(payload) > 12 else None
68
+ if codec is None:
69
+ codec = AudioCodec.OPUS
70
+ channels = payload[13] if len(payload) > 13 else 2
71
+
72
+ config = StreamConfig(
73
+ sample_rate=sample_rate,
74
+ channels=channels,
75
+ bitrate=bitrate,
76
+ codec=codec,
77
+ ssrc=0,
78
+ )
79
+ return remote_ssrc, config
80
+
81
+ @staticmethod
82
+ def parse_response(packet: Packet) -> Optional[Tuple[bool, int]]:
83
+ """Parse a HandshakeResponse -> (accepted, responder_ssrc)."""
84
+ payload = packet.payload
85
+ if len(payload) < 5:
86
+ return None
87
+ accepted = payload[0] == 0x01
88
+ ssrc = int.from_bytes(payload[1:5], "big")
89
+ return accepted, ssrc
90
+
91
+ def start(self, remote: str) -> None:
92
+ self.state = HandshakeState.WAITING_FOR_RESPONSE
93
+ self.remote_addr = remote
94
+ self.started_at = time.monotonic()
95
+
96
+ def timed_out(self) -> bool:
97
+ if self.started_at is None:
98
+ return True
99
+ return (time.monotonic() - self.started_at) > self.timeout
rushaudio/jitter.py ADDED
@@ -0,0 +1,144 @@
1
+ """Adaptive jitter buffer (spec section 7).
2
+
3
+ Mirrors ``src/audio/jitter.rs`` in the Rust reference implementation.
4
+
5
+ Packets are inserted in sequence order and only released once they have aged
6
+ past the target delay. The target delay adapts to observed jitter with an
7
+ exponential moving average.
8
+ """
9
+
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Deque, List, Optional, Tuple
13
+
14
+ from . import constants as _c
15
+
16
+
17
+ @dataclass
18
+ class _JitterPacket:
19
+ data: bytes
20
+ sequence: int
21
+ timestamp: int
22
+ received_at: float
23
+
24
+
25
+ # Placeholder to keep the public surface explicit in type hints.
26
+ JitterPacket = _JitterPacket
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class JitterStats:
31
+ depth: int
32
+ dropped: int
33
+ inserted: int
34
+ late: int
35
+ jitter_ms: float
36
+ target_delay_ms: float
37
+
38
+
39
+ class JitterBuffer:
40
+ """Sequence-ordered buffer that absorbs network jitter."""
41
+
42
+ def __init__(self, capacity: int = 256) -> None:
43
+ self._packets: List[_JitterPacket] = []
44
+ self._capacity = capacity
45
+ self._target_delay = _c.DEFAULT_JITTER_BUFFER_MS / 1000.0
46
+ self._min_delay = 0.020
47
+ self._max_delay = 0.400
48
+ self._last_playout_ts: Optional[int] = None
49
+ self._dropped = 0
50
+ self._inserted = 0
51
+ self._late = 0
52
+ self._current_jitter = 0.0
53
+
54
+ def with_capacity(self, cap: int) -> "JitterBuffer":
55
+ return JitterBuffer(capacity=cap)
56
+
57
+ def push(self, sequence: int, timestamp: int, data: bytes) -> None:
58
+ if len(self._packets) >= self._capacity:
59
+ self._packets.pop(0)
60
+ self._dropped += 1
61
+
62
+ packet = _JitterPacket(
63
+ data=bytes(data),
64
+ sequence=sequence,
65
+ timestamp=timestamp,
66
+ received_at=time.monotonic(),
67
+ )
68
+
69
+ index = self._insert_index(packet.sequence)
70
+ if index < len(self._packets) and self._packets[index].sequence == sequence:
71
+ return # duplicate, drop silently (mirrors Rust binary_search hit)
72
+
73
+ if self._last_playout_ts is not None:
74
+ if sequence < self._last_playout_ts and (
75
+ self._last_playout_ts - sequence
76
+ ) > 1000:
77
+ self._late += 1
78
+ return
79
+
80
+ self._packets.insert(index, packet)
81
+ self._inserted += 1
82
+
83
+ def _insert_index(self, sequence: int) -> int:
84
+ lo, hi = 0, len(self._packets)
85
+ while lo < hi:
86
+ mid = (lo + hi) // 2
87
+ if self._packets[mid].sequence < sequence:
88
+ lo = mid + 1
89
+ else:
90
+ hi = mid
91
+ return lo
92
+
93
+ def pop(self) -> Optional[Tuple[int, bytes]]:
94
+ if not self._packets:
95
+ return None
96
+
97
+ now = time.monotonic()
98
+ head = self._packets[0]
99
+ head_age = now - head.received_at
100
+ if head_age < self._target_delay:
101
+ return None
102
+
103
+ packet = self._packets.pop(0)
104
+ self._last_playout_ts = packet.timestamp
105
+ self._current_jitter = self._current_jitter * 0.875 + (head_age * 1000.0) * 0.125
106
+ return packet.timestamp, packet.data
107
+
108
+ def peek(self) -> Optional[Tuple[int, int, int]]:
109
+ if not self._packets:
110
+ return None
111
+ head = self._packets[0]
112
+ return head.sequence, head.timestamp, len(head.data)
113
+
114
+ def len(self) -> int:
115
+ return len(self._packets)
116
+
117
+ def __len__(self) -> int:
118
+ return len(self._packets)
119
+
120
+ def is_empty(self) -> bool:
121
+ return not self._packets
122
+
123
+ def clear(self) -> None:
124
+ self._packets.clear()
125
+
126
+ def stats(self) -> JitterStats:
127
+ return JitterStats(
128
+ depth=len(self._packets),
129
+ dropped=self._dropped,
130
+ inserted=self._inserted,
131
+ late=self._late,
132
+ jitter_ms=self._current_jitter,
133
+ target_delay_ms=self._target_delay * 1000.0,
134
+ )
135
+
136
+ def adapt_delay(self) -> None:
137
+ new_target = max(self._current_jitter * 2.0 + 10.0, 20.0) / 1000.0
138
+ self._target_delay = min(max(new_target, self._min_delay), self._max_delay)
139
+
140
+ def set_min_delay(self, ms: int) -> None:
141
+ self._min_delay = ms / 1000.0
142
+
143
+ def set_max_delay(self, ms: int) -> None:
144
+ self._max_delay = ms / 1000.0
rushaudio/levels.py ADDED
@@ -0,0 +1,52 @@
1
+ """Per-packet audio level measurement (VU meter data) packet type.
2
+
3
+ Mirrors ``src/protocol/levels.rs`` in the Rust reference implementation.
4
+
5
+ Levels are in dBFS with 1 dB per unit: ``0`` = full scale, ``-127`` =
6
+ quietest non-silent level, and ``LEVEL_SILENCE`` (-128) = digital silence.
7
+ """
8
+
9
+ import struct
10
+ from dataclasses import dataclass
11
+
12
+ from . import constants as _c
13
+ from .packet import InvalidPayloadError, Packet
14
+ from .types import PacketType
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AudioLevels:
19
+ """Levels describing one ``AudioData`` packet (spec section 4.10)."""
20
+
21
+ audio_sequence: int
22
+ audio_timestamp: int
23
+ peak: int
24
+ rms: int
25
+
26
+ def encode(self) -> bytes:
27
+ return (
28
+ self.audio_sequence.to_bytes(4, "big")
29
+ + self.audio_timestamp.to_bytes(4, "big")
30
+ + struct.pack("b", self.peak)
31
+ + struct.pack("b", self.rms)
32
+ )
33
+
34
+ @classmethod
35
+ def decode(cls, data: bytes) -> "AudioLevels":
36
+ if len(data) < _c.AUDIO_LEVELS_PAYLOAD_SIZE:
37
+ raise InvalidPayloadError("audio level payload too short")
38
+ return cls(
39
+ audio_sequence=int.from_bytes(data[0:4], "big"),
40
+ audio_timestamp=int.from_bytes(data[4:8], "big"),
41
+ peak=struct.unpack("b", data[8:9])[0],
42
+ rms=struct.unpack("b", data[9:10])[0],
43
+ )
44
+
45
+ def to_packet(self, seq: int, ts: int) -> Packet:
46
+ return Packet.new(PacketType.AUDIO_LEVEL, seq, ts, self.encode())
47
+
48
+ @classmethod
49
+ def from_packet(cls, packet: Packet) -> "AudioLevels":
50
+ if packet.header.packet_type != PacketType.AUDIO_LEVEL:
51
+ raise InvalidPayloadError("not an audio level packet")
52
+ return cls.decode(packet.payload)