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 +94 -0
- rushaudio/connection.py +99 -0
- rushaudio/constants.py +62 -0
- rushaudio/fec.py +84 -0
- rushaudio/handshake.py +99 -0
- rushaudio/jitter.py +144 -0
- rushaudio/levels.py +52 -0
- rushaudio/metadata.py +211 -0
- rushaudio/packet.py +170 -0
- rushaudio/prelude.py +99 -0
- rushaudio/py.typed +0 -0
- rushaudio/session.py +99 -0
- rushaudio/transport.py +88 -0
- rushaudio/types.py +92 -0
- rushaudio-1.1.0.dist-info/METADATA +192 -0
- rushaudio-1.1.0.dist-info/RECORD +18 -0
- rushaudio-1.1.0.dist-info/WHEEL +5 -0
- rushaudio-1.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
rushaudio/connection.py
ADDED
|
@@ -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)
|