rushaudio 1.1.0__tar.gz
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-1.1.0/PKG-INFO +192 -0
- rushaudio-1.1.0/README.md +171 -0
- rushaudio-1.1.0/pyproject.toml +36 -0
- rushaudio-1.1.0/rushaudio/__init__.py +94 -0
- rushaudio-1.1.0/rushaudio/connection.py +99 -0
- rushaudio-1.1.0/rushaudio/constants.py +62 -0
- rushaudio-1.1.0/rushaudio/fec.py +84 -0
- rushaudio-1.1.0/rushaudio/handshake.py +99 -0
- rushaudio-1.1.0/rushaudio/jitter.py +144 -0
- rushaudio-1.1.0/rushaudio/levels.py +52 -0
- rushaudio-1.1.0/rushaudio/metadata.py +211 -0
- rushaudio-1.1.0/rushaudio/packet.py +170 -0
- rushaudio-1.1.0/rushaudio/prelude.py +99 -0
- rushaudio-1.1.0/rushaudio/py.typed +0 -0
- rushaudio-1.1.0/rushaudio/session.py +99 -0
- rushaudio-1.1.0/rushaudio/transport.py +88 -0
- rushaudio-1.1.0/rushaudio/types.py +92 -0
- rushaudio-1.1.0/rushaudio.egg-info/PKG-INFO +192 -0
- rushaudio-1.1.0/rushaudio.egg-info/SOURCES.txt +21 -0
- rushaudio-1.1.0/rushaudio.egg-info/dependency_links.txt +1 -0
- rushaudio-1.1.0/rushaudio.egg-info/top_level.txt +1 -0
- rushaudio-1.1.0/setup.cfg +4 -0
- rushaudio-1.1.0/tests/test_protocol.py +404 -0
rushaudio-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rushaudio
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Low-latency audio over IP live streaming protocol (Python port)
|
|
5
|
+
Author: RushAudio Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/OseMine/rushaudio
|
|
8
|
+
Keywords: audio,streaming,low-latency,protocol,udp
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# RushAudio — Python port
|
|
23
|
+
|
|
24
|
+
A pure-Python, zero-dependency implementation of the [RushAudio](https://github.com/OseMine/rushaudio) protocol: a **binary protocol for low-latency audio streaming over UDP**.
|
|
25
|
+
|
|
26
|
+
This port mirrors the public API and wire format of the Rust reference implementation, so it is interoperable byte-for-byte on the wire.
|
|
27
|
+
|
|
28
|
+
| Property | Value |
|
|
29
|
+
|----------|-------|
|
|
30
|
+
| Transport | UDP (default port 4210) |
|
|
31
|
+
| Header size | 14 bytes |
|
|
32
|
+
| Max payload | 4096 bytes |
|
|
33
|
+
| Latency target | 30–100ms |
|
|
34
|
+
| Loss recovery | XOR FEC (recovers 1 loss per group) |
|
|
35
|
+
| Codecs | Opus, PCM S16LE, A-law, μ-law |
|
|
36
|
+
| Metadata | TLV key-value pairs (built-in + custom) |
|
|
37
|
+
| Byte order | Big-endian (except PCM samples: little-endian) |
|
|
38
|
+
| Python | ≥ 3.9, standard library only |
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install rushaudio # from PyPI (once published)
|
|
44
|
+
# or build/install from source:
|
|
45
|
+
cd ports/python
|
|
46
|
+
python -m pip wheel . -w dist # -> dist/rushaudio-1.1.0-py3-none-any.whl
|
|
47
|
+
pip install dist/rushaudio-1.1.0-py3-none-any.whl
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import rushaudio
|
|
54
|
+
from rushaudio.prelude import *
|
|
55
|
+
|
|
56
|
+
server = rushaudio.create_server("0.0.0.0:4210") # receiver
|
|
57
|
+
client = rushaudio.create_client() # sender
|
|
58
|
+
peer = "127.0.0.1:4210"
|
|
59
|
+
|
|
60
|
+
# Encode/decode an audio frame (AudioData payload, spec §4.1)
|
|
61
|
+
payload = Packet.encode_audio_payload(2, 48000, AudioCodec.OPUS, opus_bytes)
|
|
62
|
+
pkt = Packet.new(PacketType.AUDIO_DATA, seq=1, ts=0, payload=payload)
|
|
63
|
+
client.send_packet(pkt, peer)
|
|
64
|
+
|
|
65
|
+
got = server.recv_packet() # non-blocking: None if nothing available
|
|
66
|
+
if got is not None:
|
|
67
|
+
packet, addr = got
|
|
68
|
+
ch, sr, codec, frame = Packet.decode_audio_payload(packet.payload)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Metadata (TLV)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
meta = (
|
|
75
|
+
MetadataBuilder()
|
|
76
|
+
.track_title("My Song")
|
|
77
|
+
.artist("Artist Name")
|
|
78
|
+
.album("Album")
|
|
79
|
+
.sample_rate(48000)
|
|
80
|
+
.channels(2)
|
|
81
|
+
.codec_info("Opus")
|
|
82
|
+
.bitrate(128000)
|
|
83
|
+
.custom(0x80, b"app-specific-data")
|
|
84
|
+
.build()
|
|
85
|
+
)
|
|
86
|
+
client.send_packet(meta.to_packet(seq=2, ts=20), peer)
|
|
87
|
+
|
|
88
|
+
received = Metadata.from_packet(packet) # on the other side
|
|
89
|
+
title = received.get_string(META_TRACK_TITLE) # -> "My Song"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Handshake
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
config = StreamConfig(sample_rate=48000, channels=2, bitrate=128000, codec=AudioCodec.OPUS)
|
|
96
|
+
hs = Handshake(role=HandshakeRole.INITIATOR, ssrc=12345, config=config)
|
|
97
|
+
client.send_packet(hs.build_request(0, 0), peer)
|
|
98
|
+
|
|
99
|
+
# server side:
|
|
100
|
+
got = server.recv_packet()
|
|
101
|
+
remote_ssrc, cfg = Handshake.parse_request(got[0])
|
|
102
|
+
responder = Handshake(role=HandshakeRole.RESPONDER, ssrc=67890, config=cfg)
|
|
103
|
+
server.send_packet(responder.build_response(0, 0, accepted=True), got[1])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## FEC recovery
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
group = [pkt1, pkt2, pkt3] # original AudioData packets
|
|
110
|
+
repair = FecEncoder.generate_repair(group, seq=100, ts=0)
|
|
111
|
+
client.send_packet(repair, peer)
|
|
112
|
+
|
|
113
|
+
# receiver, having lost pkt2 of the group:
|
|
114
|
+
recovered = FecEncoder.try_recover(repair, [pkt1, pkt3]) # -> (2, b"...")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Jitter buffer
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
jb = JitterBuffer() # 80ms target delay
|
|
121
|
+
jb.push(seq, ts, payload_bytes) # inserts in sequence order
|
|
122
|
+
out = jb.pop() # (ts, payload) once aged, else None
|
|
123
|
+
jb.adapt_delay() # clamp(2*jitter + 10ms, 20ms, 400ms)
|
|
124
|
+
stats = jb.stats() # depth, dropped, late, jitter
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Wire / raw bytes
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
wire = pkt.encode() # header + payload, 14+N bytes
|
|
131
|
+
parsed = Packet.decode(wire) # raises PacketError subclasses on invalid data
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`UdpTransport.recv_packet()` returns `(Packet, (host, port))` or `None` when
|
|
135
|
+
nothing is available (non-blocking). Non-parseable datagrams are skipped.
|
|
136
|
+
|
|
137
|
+
## Layout
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
ports/python/
|
|
141
|
+
├── pyproject.toml
|
|
142
|
+
├── rushaudio/
|
|
143
|
+
│ ├── __init__.py # public API: create_server / create_client / DEFAULT_PORT
|
|
144
|
+
│ ├── prelude.py # from rushaudio.prelude import *
|
|
145
|
+
│ ├── constants.py # magic, metadata keys, timing, FEC/level constants
|
|
146
|
+
│ ├── types.py # PacketType, AudioCodec, StreamConfig, StreamStats
|
|
147
|
+
│ ├── packet.py # 14-byte header, Packet encode/decode, errors
|
|
148
|
+
│ ├── metadata.py # Metadata / MetadataBuilder TLV codec
|
|
149
|
+
│ ├── levels.py # AudioLevels (VU meter) packet type
|
|
150
|
+
│ ├── fec.py # XOR FEC encode + single-loss recovery
|
|
151
|
+
│ ├── jitter.py # adaptive jitter buffer
|
|
152
|
+
│ ├── handshake.py # handshake state machine + payload builders
|
|
153
|
+
│ ├── transport.py # non-blocking UdpTransport
|
|
154
|
+
│ ├── connection.py # Connection + bounded ConnectionPool
|
|
155
|
+
│ └── session.py # SessionManager (keepalive, stale sweeping)
|
|
156
|
+
└── tests/
|
|
157
|
+
└── test_protocol.py # 37 tests (mirrors tests/integration.rs)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Running the tests
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cd ports/python
|
|
164
|
+
python -m unittest discover -s tests -v # 37 tests, stdlib only
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Parity with the Rust reference
|
|
168
|
+
|
|
169
|
+
| Rust (v1.1.0) | Python |
|
|
170
|
+
|----------------|--------|
|
|
171
|
+
| `Packet::new / encode / decode` | `Packet.new / encode / decode` |
|
|
172
|
+
| `PacketType` | `PacketType` (`AUDIO_DATA`, …) |
|
|
173
|
+
| `AudioCodec` | `AudioCodec` (`OPUS`, `RAW_PCM_I16`, …) |
|
|
174
|
+
| `Packet::encode_audio_payload / decode_audio_payload` | same names, static methods |
|
|
175
|
+
| `MetadataBuilder` fluent API | identical fluent API |
|
|
176
|
+
| `Metadata {get,get_string,get_u32,get_u16,encode,decode,to_packet,…}` | identical methods |
|
|
177
|
+
| `AudioLevels` | `AudioLevels` |
|
|
178
|
+
| `FecEncoder::generate_repair / try_recover` | `FecEncoder.generate_repair / try_recover` |
|
|
179
|
+
| `JitterBuffer` | `JitterBuffer` |
|
|
180
|
+
| `Handshake` (roles/states, build/parse) | `Handshake` |
|
|
181
|
+
| `UdpTransport::bind / send_packet / recv_packet` | `UdpTransport.bind / send_packet / recv_packet` |
|
|
182
|
+
| `Connection`, `ConnectionPool` | `Connection`, `ConnectionPool` |
|
|
183
|
+
| `SessionManager` | `SessionManager` |
|
|
184
|
+
| `prelude` | `rushaudio.prelude` |
|
|
185
|
+
| `create_server(addr)` / `create_client()` | identical |
|
|
186
|
+
|
|
187
|
+
Naming intentionally follows PEP 8 in Python (e.g. `PacketType.AUDIO_DATA`,
|
|
188
|
+
`ConnectionState.STREAMING`) while behavior remains wire-identical.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# RushAudio — Python port
|
|
2
|
+
|
|
3
|
+
A pure-Python, zero-dependency implementation of the [RushAudio](https://github.com/OseMine/rushaudio) protocol: a **binary protocol for low-latency audio streaming over UDP**.
|
|
4
|
+
|
|
5
|
+
This port mirrors the public API and wire format of the Rust reference implementation, so it is interoperable byte-for-byte on the wire.
|
|
6
|
+
|
|
7
|
+
| Property | Value |
|
|
8
|
+
|----------|-------|
|
|
9
|
+
| Transport | UDP (default port 4210) |
|
|
10
|
+
| Header size | 14 bytes |
|
|
11
|
+
| Max payload | 4096 bytes |
|
|
12
|
+
| Latency target | 30–100ms |
|
|
13
|
+
| Loss recovery | XOR FEC (recovers 1 loss per group) |
|
|
14
|
+
| Codecs | Opus, PCM S16LE, A-law, μ-law |
|
|
15
|
+
| Metadata | TLV key-value pairs (built-in + custom) |
|
|
16
|
+
| Byte order | Big-endian (except PCM samples: little-endian) |
|
|
17
|
+
| Python | ≥ 3.9, standard library only |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install rushaudio # from PyPI (once published)
|
|
23
|
+
# or build/install from source:
|
|
24
|
+
cd ports/python
|
|
25
|
+
python -m pip wheel . -w dist # -> dist/rushaudio-1.1.0-py3-none-any.whl
|
|
26
|
+
pip install dist/rushaudio-1.1.0-py3-none-any.whl
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import rushaudio
|
|
33
|
+
from rushaudio.prelude import *
|
|
34
|
+
|
|
35
|
+
server = rushaudio.create_server("0.0.0.0:4210") # receiver
|
|
36
|
+
client = rushaudio.create_client() # sender
|
|
37
|
+
peer = "127.0.0.1:4210"
|
|
38
|
+
|
|
39
|
+
# Encode/decode an audio frame (AudioData payload, spec §4.1)
|
|
40
|
+
payload = Packet.encode_audio_payload(2, 48000, AudioCodec.OPUS, opus_bytes)
|
|
41
|
+
pkt = Packet.new(PacketType.AUDIO_DATA, seq=1, ts=0, payload=payload)
|
|
42
|
+
client.send_packet(pkt, peer)
|
|
43
|
+
|
|
44
|
+
got = server.recv_packet() # non-blocking: None if nothing available
|
|
45
|
+
if got is not None:
|
|
46
|
+
packet, addr = got
|
|
47
|
+
ch, sr, codec, frame = Packet.decode_audio_payload(packet.payload)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Metadata (TLV)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
meta = (
|
|
54
|
+
MetadataBuilder()
|
|
55
|
+
.track_title("My Song")
|
|
56
|
+
.artist("Artist Name")
|
|
57
|
+
.album("Album")
|
|
58
|
+
.sample_rate(48000)
|
|
59
|
+
.channels(2)
|
|
60
|
+
.codec_info("Opus")
|
|
61
|
+
.bitrate(128000)
|
|
62
|
+
.custom(0x80, b"app-specific-data")
|
|
63
|
+
.build()
|
|
64
|
+
)
|
|
65
|
+
client.send_packet(meta.to_packet(seq=2, ts=20), peer)
|
|
66
|
+
|
|
67
|
+
received = Metadata.from_packet(packet) # on the other side
|
|
68
|
+
title = received.get_string(META_TRACK_TITLE) # -> "My Song"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Handshake
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
config = StreamConfig(sample_rate=48000, channels=2, bitrate=128000, codec=AudioCodec.OPUS)
|
|
75
|
+
hs = Handshake(role=HandshakeRole.INITIATOR, ssrc=12345, config=config)
|
|
76
|
+
client.send_packet(hs.build_request(0, 0), peer)
|
|
77
|
+
|
|
78
|
+
# server side:
|
|
79
|
+
got = server.recv_packet()
|
|
80
|
+
remote_ssrc, cfg = Handshake.parse_request(got[0])
|
|
81
|
+
responder = Handshake(role=HandshakeRole.RESPONDER, ssrc=67890, config=cfg)
|
|
82
|
+
server.send_packet(responder.build_response(0, 0, accepted=True), got[1])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## FEC recovery
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
group = [pkt1, pkt2, pkt3] # original AudioData packets
|
|
89
|
+
repair = FecEncoder.generate_repair(group, seq=100, ts=0)
|
|
90
|
+
client.send_packet(repair, peer)
|
|
91
|
+
|
|
92
|
+
# receiver, having lost pkt2 of the group:
|
|
93
|
+
recovered = FecEncoder.try_recover(repair, [pkt1, pkt3]) # -> (2, b"...")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Jitter buffer
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
jb = JitterBuffer() # 80ms target delay
|
|
100
|
+
jb.push(seq, ts, payload_bytes) # inserts in sequence order
|
|
101
|
+
out = jb.pop() # (ts, payload) once aged, else None
|
|
102
|
+
jb.adapt_delay() # clamp(2*jitter + 10ms, 20ms, 400ms)
|
|
103
|
+
stats = jb.stats() # depth, dropped, late, jitter
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Wire / raw bytes
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
wire = pkt.encode() # header + payload, 14+N bytes
|
|
110
|
+
parsed = Packet.decode(wire) # raises PacketError subclasses on invalid data
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`UdpTransport.recv_packet()` returns `(Packet, (host, port))` or `None` when
|
|
114
|
+
nothing is available (non-blocking). Non-parseable datagrams are skipped.
|
|
115
|
+
|
|
116
|
+
## Layout
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
ports/python/
|
|
120
|
+
├── pyproject.toml
|
|
121
|
+
├── rushaudio/
|
|
122
|
+
│ ├── __init__.py # public API: create_server / create_client / DEFAULT_PORT
|
|
123
|
+
│ ├── prelude.py # from rushaudio.prelude import *
|
|
124
|
+
│ ├── constants.py # magic, metadata keys, timing, FEC/level constants
|
|
125
|
+
│ ├── types.py # PacketType, AudioCodec, StreamConfig, StreamStats
|
|
126
|
+
│ ├── packet.py # 14-byte header, Packet encode/decode, errors
|
|
127
|
+
│ ├── metadata.py # Metadata / MetadataBuilder TLV codec
|
|
128
|
+
│ ├── levels.py # AudioLevels (VU meter) packet type
|
|
129
|
+
│ ├── fec.py # XOR FEC encode + single-loss recovery
|
|
130
|
+
│ ├── jitter.py # adaptive jitter buffer
|
|
131
|
+
│ ├── handshake.py # handshake state machine + payload builders
|
|
132
|
+
│ ├── transport.py # non-blocking UdpTransport
|
|
133
|
+
│ ├── connection.py # Connection + bounded ConnectionPool
|
|
134
|
+
│ └── session.py # SessionManager (keepalive, stale sweeping)
|
|
135
|
+
└── tests/
|
|
136
|
+
└── test_protocol.py # 37 tests (mirrors tests/integration.rs)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Running the tests
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
cd ports/python
|
|
143
|
+
python -m unittest discover -s tests -v # 37 tests, stdlib only
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Parity with the Rust reference
|
|
147
|
+
|
|
148
|
+
| Rust (v1.1.0) | Python |
|
|
149
|
+
|----------------|--------|
|
|
150
|
+
| `Packet::new / encode / decode` | `Packet.new / encode / decode` |
|
|
151
|
+
| `PacketType` | `PacketType` (`AUDIO_DATA`, …) |
|
|
152
|
+
| `AudioCodec` | `AudioCodec` (`OPUS`, `RAW_PCM_I16`, …) |
|
|
153
|
+
| `Packet::encode_audio_payload / decode_audio_payload` | same names, static methods |
|
|
154
|
+
| `MetadataBuilder` fluent API | identical fluent API |
|
|
155
|
+
| `Metadata {get,get_string,get_u32,get_u16,encode,decode,to_packet,…}` | identical methods |
|
|
156
|
+
| `AudioLevels` | `AudioLevels` |
|
|
157
|
+
| `FecEncoder::generate_repair / try_recover` | `FecEncoder.generate_repair / try_recover` |
|
|
158
|
+
| `JitterBuffer` | `JitterBuffer` |
|
|
159
|
+
| `Handshake` (roles/states, build/parse) | `Handshake` |
|
|
160
|
+
| `UdpTransport::bind / send_packet / recv_packet` | `UdpTransport.bind / send_packet / recv_packet` |
|
|
161
|
+
| `Connection`, `ConnectionPool` | `Connection`, `ConnectionPool` |
|
|
162
|
+
| `SessionManager` | `SessionManager` |
|
|
163
|
+
| `prelude` | `rushaudio.prelude` |
|
|
164
|
+
| `create_server(addr)` / `create_client()` | identical |
|
|
165
|
+
|
|
166
|
+
Naming intentionally follows PEP 8 in Python (e.g. `PacketType.AUDIO_DATA`,
|
|
167
|
+
`ConnectionState.STREAMING`) while behavior remains wire-identical.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rushaudio"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Low-latency audio over IP live streaming protocol (Python port)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "RushAudio Contributors" }]
|
|
13
|
+
keywords = ["audio", "streaming", "low-latency", "protocol", "udp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/OseMine/rushaudio"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["rushaudio*"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
rushaudio = ["py.typed"]
|
|
@@ -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)
|
|
@@ -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
|