ka9q-python 3.2.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.
- ka9q/__init__.py +98 -0
- ka9q/control.py +2295 -0
- ka9q/discovery.py +485 -0
- ka9q/exceptions.py +23 -0
- ka9q/resequencer.py +389 -0
- ka9q/rtp_recorder.py +457 -0
- ka9q/stream.py +393 -0
- ka9q/stream_quality.py +215 -0
- ka9q/types.py +161 -0
- ka9q/utils.py +202 -0
- ka9q_python-3.2.0.dist-info/METADATA +237 -0
- ka9q_python-3.2.0.dist-info/RECORD +15 -0
- ka9q_python-3.2.0.dist-info/WHEEL +5 -0
- ka9q_python-3.2.0.dist-info/licenses/LICENSE +21 -0
- ka9q_python-3.2.0.dist-info/top_level.txt +1 -0
ka9q/resequencer.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RTP Packet Resequencer with Gap Detection
|
|
3
|
+
|
|
4
|
+
Handles out-of-order packet delivery and maintains continuous sample streams.
|
|
5
|
+
Uses KA9Q timing architecture: RTP timestamps are the primary reference.
|
|
6
|
+
|
|
7
|
+
Key behaviors:
|
|
8
|
+
- Circular buffer for resequencing jittered packets
|
|
9
|
+
- Detects gaps via RTP timestamp jumps
|
|
10
|
+
- Fills gaps with zeros to maintain sample count integrity
|
|
11
|
+
- Tracks quality metrics for downstream applications
|
|
12
|
+
|
|
13
|
+
Design principle: Sample count integrity > real-time delivery
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import logging
|
|
18
|
+
from collections import deque
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Optional, Tuple, List
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from .stream_quality import GapSource, GapEvent
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RTPPacket:
|
|
30
|
+
"""Parsed RTP packet with samples"""
|
|
31
|
+
sequence: int # RTP sequence number (16-bit, wraps)
|
|
32
|
+
timestamp: int # RTP timestamp (32-bit, wraps)
|
|
33
|
+
ssrc: int # RTP SSRC identifier
|
|
34
|
+
samples: np.ndarray # IQ samples (complex64 or float32)
|
|
35
|
+
wallclock: Optional[float] = None # Unix timestamp if available
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ResequencerStats:
|
|
40
|
+
"""Statistics from resequencer operation"""
|
|
41
|
+
packets_received: int = 0
|
|
42
|
+
packets_resequenced: int = 0
|
|
43
|
+
packets_duplicate: int = 0
|
|
44
|
+
gaps_detected: int = 0
|
|
45
|
+
samples_output: int = 0
|
|
46
|
+
samples_filled: int = 0
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
'packets_received': self.packets_received,
|
|
51
|
+
'packets_resequenced': self.packets_resequenced,
|
|
52
|
+
'packets_duplicate': self.packets_duplicate,
|
|
53
|
+
'gaps_detected': self.gaps_detected,
|
|
54
|
+
'samples_output': self.samples_output,
|
|
55
|
+
'samples_filled': self.samples_filled,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PacketResequencer:
|
|
60
|
+
"""
|
|
61
|
+
Resequence out-of-order RTP packets and detect gaps.
|
|
62
|
+
|
|
63
|
+
Design:
|
|
64
|
+
- Circular buffer of N packets (handles network jitter)
|
|
65
|
+
- Process packets in sequence order
|
|
66
|
+
- Detect gaps via RTP timestamp jumps
|
|
67
|
+
- Fill gaps with zeros to maintain sample count integrity
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
reseq = PacketResequencer(buffer_size=64, samples_per_packet=320)
|
|
71
|
+
|
|
72
|
+
# For each received packet:
|
|
73
|
+
samples, gap_events = reseq.process_packet(packet)
|
|
74
|
+
if samples is not None:
|
|
75
|
+
# Deliver to application
|
|
76
|
+
app.on_samples(samples, gap_events)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Maximum gap to fill: 60 seconds at 16 kHz = 960,000 samples
|
|
80
|
+
# Larger gaps indicate stream restart or corruption
|
|
81
|
+
MAX_GAP_SAMPLES = 960_000
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
buffer_size: int = 64,
|
|
86
|
+
samples_per_packet: int = 320,
|
|
87
|
+
sample_rate: int = 16000,
|
|
88
|
+
):
|
|
89
|
+
"""
|
|
90
|
+
Initialize resequencer.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
buffer_size: Circular buffer size (packets). 64 handles ~2s jitter @ 320 samples/packet
|
|
94
|
+
samples_per_packet: Expected samples per RTP packet
|
|
95
|
+
sample_rate: Sample rate in Hz (for gap duration calculations)
|
|
96
|
+
"""
|
|
97
|
+
self.buffer_size = buffer_size
|
|
98
|
+
self.samples_per_packet = samples_per_packet
|
|
99
|
+
self.sample_rate = sample_rate
|
|
100
|
+
|
|
101
|
+
# Circular buffer: sequence_num -> packet
|
|
102
|
+
self.buffer: deque = deque(maxlen=buffer_size)
|
|
103
|
+
self.buffer_seq_nums: set = set()
|
|
104
|
+
|
|
105
|
+
# State tracking
|
|
106
|
+
self.initialized = False
|
|
107
|
+
self.next_expected_seq: Optional[int] = None
|
|
108
|
+
self.next_expected_ts: Optional[int] = None
|
|
109
|
+
self.cumulative_samples: int = 0 # Total samples output
|
|
110
|
+
|
|
111
|
+
# Statistics
|
|
112
|
+
self.stats = ResequencerStats()
|
|
113
|
+
|
|
114
|
+
logger.debug(
|
|
115
|
+
f"PacketResequencer: buffer={buffer_size}, "
|
|
116
|
+
f"samples/pkt={samples_per_packet}, rate={sample_rate}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def process_packet(
|
|
120
|
+
self,
|
|
121
|
+
packet: RTPPacket
|
|
122
|
+
) -> Tuple[Optional[np.ndarray], List[GapEvent]]:
|
|
123
|
+
"""
|
|
124
|
+
Process incoming RTP packet.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
packet: Parsed RTP packet with samples
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
(samples, gap_events):
|
|
131
|
+
- samples: Continuous sample array ready for output (may include gap fills)
|
|
132
|
+
- gap_events: List of gaps detected (empty if none)
|
|
133
|
+
"""
|
|
134
|
+
self.stats.packets_received += 1
|
|
135
|
+
|
|
136
|
+
# Initialize on first packet
|
|
137
|
+
if not self.initialized:
|
|
138
|
+
self._initialize(packet)
|
|
139
|
+
return None, []
|
|
140
|
+
|
|
141
|
+
# Check for duplicate
|
|
142
|
+
if packet.sequence in self.buffer_seq_nums:
|
|
143
|
+
logger.debug(f"Duplicate packet seq={packet.sequence}")
|
|
144
|
+
self.stats.packets_duplicate += 1
|
|
145
|
+
return None, []
|
|
146
|
+
|
|
147
|
+
# Add to buffer
|
|
148
|
+
self._add_to_buffer(packet)
|
|
149
|
+
|
|
150
|
+
# Try to output packets in sequence order
|
|
151
|
+
return self._try_output()
|
|
152
|
+
|
|
153
|
+
def _initialize(self, packet: RTPPacket):
|
|
154
|
+
"""Initialize sequencer with first packet"""
|
|
155
|
+
self.next_expected_seq = packet.sequence
|
|
156
|
+
self.next_expected_ts = packet.timestamp
|
|
157
|
+
self._add_to_buffer(packet)
|
|
158
|
+
self.initialized = True
|
|
159
|
+
logger.info(f"Resequencer initialized: seq={packet.sequence}, ts={packet.timestamp}")
|
|
160
|
+
|
|
161
|
+
def _add_to_buffer(self, packet: RTPPacket):
|
|
162
|
+
"""Add packet to circular buffer"""
|
|
163
|
+
self.buffer.append(packet)
|
|
164
|
+
self.buffer_seq_nums.add(packet.sequence)
|
|
165
|
+
|
|
166
|
+
# If buffer full, oldest automatically removed by deque
|
|
167
|
+
while len(self.buffer_seq_nums) > len(self.buffer):
|
|
168
|
+
# Sync set with deque contents
|
|
169
|
+
actual_seqs = {p.sequence for p in self.buffer}
|
|
170
|
+
self.buffer_seq_nums = actual_seqs
|
|
171
|
+
|
|
172
|
+
def _try_output(self) -> Tuple[Optional[np.ndarray], List[GapEvent]]:
|
|
173
|
+
"""Try to output next packet(s) in sequence"""
|
|
174
|
+
output_samples = []
|
|
175
|
+
gap_events = []
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
# Look for next expected sequence number
|
|
179
|
+
next_pkt = None
|
|
180
|
+
for pkt in self.buffer:
|
|
181
|
+
if pkt.sequence == self.next_expected_seq:
|
|
182
|
+
next_pkt = pkt
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if next_pkt is None:
|
|
186
|
+
# Packet not in buffer - check if we should skip ahead
|
|
187
|
+
if len(self.buffer) >= self.buffer_size // 2:
|
|
188
|
+
# Buffer filling up - packet probably lost
|
|
189
|
+
samples, gaps = self._handle_lost_packet()
|
|
190
|
+
if samples is not None:
|
|
191
|
+
output_samples.append(samples)
|
|
192
|
+
gap_events.extend(gaps)
|
|
193
|
+
continue # Try to output more
|
|
194
|
+
break # Keep waiting
|
|
195
|
+
|
|
196
|
+
# Found next packet - check for timestamp gap
|
|
197
|
+
if next_pkt.timestamp != self.next_expected_ts:
|
|
198
|
+
gap = self._detect_gap(next_pkt)
|
|
199
|
+
if gap is not None:
|
|
200
|
+
gap_events.append(gap)
|
|
201
|
+
# Insert gap fill
|
|
202
|
+
gap_fill = np.zeros(gap.duration_samples, dtype=next_pkt.samples.dtype)
|
|
203
|
+
output_samples.append(gap_fill)
|
|
204
|
+
self.stats.samples_filled += gap.duration_samples
|
|
205
|
+
|
|
206
|
+
# Remove from buffer and output
|
|
207
|
+
self.buffer.remove(next_pkt)
|
|
208
|
+
self.buffer_seq_nums.discard(next_pkt.sequence)
|
|
209
|
+
output_samples.append(next_pkt.samples)
|
|
210
|
+
|
|
211
|
+
# Update state
|
|
212
|
+
self.next_expected_seq = (next_pkt.sequence + 1) & 0xFFFF
|
|
213
|
+
self.next_expected_ts = next_pkt.timestamp + self.samples_per_packet
|
|
214
|
+
|
|
215
|
+
# Combine output
|
|
216
|
+
if output_samples:
|
|
217
|
+
combined = np.concatenate(output_samples)
|
|
218
|
+
self.stats.samples_output += len(combined)
|
|
219
|
+
self.cumulative_samples += len(combined)
|
|
220
|
+
return combined, gap_events
|
|
221
|
+
|
|
222
|
+
return None, []
|
|
223
|
+
|
|
224
|
+
def _detect_gap(self, next_pkt: RTPPacket) -> Optional[GapEvent]:
|
|
225
|
+
"""
|
|
226
|
+
Detect gap using KA9Q signed 32-bit arithmetic technique.
|
|
227
|
+
|
|
228
|
+
This handles RTP timestamp wraps naturally:
|
|
229
|
+
- Positive difference = forward gap (fill with zeros)
|
|
230
|
+
- Negative difference = backward jump (ignore, likely reorder)
|
|
231
|
+
"""
|
|
232
|
+
# Signed 32-bit difference (Phil Karn's technique)
|
|
233
|
+
ts_diff = (next_pkt.timestamp - self.next_expected_ts) & 0xFFFFFFFF
|
|
234
|
+
if ts_diff >= 0x80000000:
|
|
235
|
+
ts_gap = ts_diff - 0x100000000 # Negative
|
|
236
|
+
else:
|
|
237
|
+
ts_gap = ts_diff
|
|
238
|
+
|
|
239
|
+
# Only fill forward gaps
|
|
240
|
+
if ts_gap <= 0:
|
|
241
|
+
if ts_gap < 0:
|
|
242
|
+
logger.debug(f"Backward timestamp jump: {ts_gap} samples (reorder?)")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
# Cap absurdly large gaps
|
|
246
|
+
if ts_gap > self.MAX_GAP_SAMPLES:
|
|
247
|
+
logger.warning(f"Capping gap from {ts_gap} to {self.MAX_GAP_SAMPLES}")
|
|
248
|
+
ts_gap = self.MAX_GAP_SAMPLES
|
|
249
|
+
|
|
250
|
+
self.stats.gaps_detected += 1
|
|
251
|
+
|
|
252
|
+
# Estimate packets lost
|
|
253
|
+
packets_lost = ts_gap // self.samples_per_packet
|
|
254
|
+
|
|
255
|
+
logger.warning(
|
|
256
|
+
f"Gap: {ts_gap} samples ({ts_gap / self.sample_rate * 1000:.1f}ms), "
|
|
257
|
+
f"~{packets_lost} packets"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return GapEvent(
|
|
261
|
+
source=GapSource.NETWORK_LOSS,
|
|
262
|
+
position_samples=self.cumulative_samples,
|
|
263
|
+
duration_samples=ts_gap,
|
|
264
|
+
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
|
265
|
+
packets_affected=packets_lost,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _handle_lost_packet(self) -> Tuple[Optional[np.ndarray], List[GapEvent]]:
|
|
269
|
+
"""Handle case where expected packet is definitely lost"""
|
|
270
|
+
if len(self.buffer) == 0:
|
|
271
|
+
return None, []
|
|
272
|
+
|
|
273
|
+
# Find earliest packet by sequence
|
|
274
|
+
earliest = min(
|
|
275
|
+
self.buffer,
|
|
276
|
+
key=lambda p: self._seq_distance(self.next_expected_seq, p.sequence)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Calculate gap
|
|
280
|
+
ts_diff = (earliest.timestamp - self.next_expected_ts) & 0xFFFFFFFF
|
|
281
|
+
if ts_diff >= 0x80000000:
|
|
282
|
+
ts_gap = ts_diff - 0x100000000
|
|
283
|
+
else:
|
|
284
|
+
ts_gap = ts_diff
|
|
285
|
+
|
|
286
|
+
gap_events = []
|
|
287
|
+
output_samples = []
|
|
288
|
+
|
|
289
|
+
# Create gap fill if forward gap
|
|
290
|
+
if ts_gap > 0:
|
|
291
|
+
if ts_gap > self.MAX_GAP_SAMPLES:
|
|
292
|
+
ts_gap = self.MAX_GAP_SAMPLES
|
|
293
|
+
|
|
294
|
+
self.stats.gaps_detected += 1
|
|
295
|
+
packets_lost = (earliest.sequence - self.next_expected_seq) & 0xFFFF
|
|
296
|
+
|
|
297
|
+
gap = GapEvent(
|
|
298
|
+
source=GapSource.NETWORK_LOSS,
|
|
299
|
+
position_samples=self.cumulative_samples,
|
|
300
|
+
duration_samples=ts_gap,
|
|
301
|
+
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
|
302
|
+
packets_affected=packets_lost,
|
|
303
|
+
)
|
|
304
|
+
gap_events.append(gap)
|
|
305
|
+
|
|
306
|
+
gap_fill = np.zeros(ts_gap, dtype=earliest.samples.dtype)
|
|
307
|
+
output_samples.append(gap_fill)
|
|
308
|
+
self.stats.samples_filled += ts_gap
|
|
309
|
+
|
|
310
|
+
logger.warning(
|
|
311
|
+
f"Lost packet recovery: skip to seq={earliest.sequence}, "
|
|
312
|
+
f"gap={ts_gap} samples"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Remove and output the packet
|
|
316
|
+
self.buffer.remove(earliest)
|
|
317
|
+
self.buffer_seq_nums.discard(earliest.sequence)
|
|
318
|
+
output_samples.append(earliest.samples)
|
|
319
|
+
|
|
320
|
+
# Update state
|
|
321
|
+
self.next_expected_seq = (earliest.sequence + 1) & 0xFFFF
|
|
322
|
+
self.next_expected_ts = earliest.timestamp + self.samples_per_packet
|
|
323
|
+
self.stats.packets_resequenced += 1
|
|
324
|
+
|
|
325
|
+
combined = np.concatenate(output_samples) if output_samples else None
|
|
326
|
+
if combined is not None:
|
|
327
|
+
self.stats.samples_output += len(combined)
|
|
328
|
+
self.cumulative_samples += len(combined)
|
|
329
|
+
|
|
330
|
+
return combined, gap_events
|
|
331
|
+
|
|
332
|
+
def _seq_distance(self, from_seq: int, to_seq: int) -> int:
|
|
333
|
+
"""Calculate forward distance between sequence numbers (handles wrap)"""
|
|
334
|
+
dist = (to_seq - from_seq) & 0xFFFF
|
|
335
|
+
return dist if dist < 32768 else dist - 65536
|
|
336
|
+
|
|
337
|
+
def flush(self) -> Tuple[np.ndarray, List[GapEvent]]:
|
|
338
|
+
"""
|
|
339
|
+
Flush remaining packets in buffer (for shutdown).
|
|
340
|
+
|
|
341
|
+
Returns all buffered samples in sequence order with any gaps filled.
|
|
342
|
+
"""
|
|
343
|
+
output_samples = []
|
|
344
|
+
gap_events = []
|
|
345
|
+
|
|
346
|
+
# Sort by sequence
|
|
347
|
+
sorted_pkts = sorted(self.buffer, key=lambda p: p.sequence)
|
|
348
|
+
|
|
349
|
+
for pkt in sorted_pkts:
|
|
350
|
+
# Check for gap
|
|
351
|
+
if pkt.timestamp != self.next_expected_ts:
|
|
352
|
+
gap = self._detect_gap(pkt)
|
|
353
|
+
if gap is not None:
|
|
354
|
+
gap_events.append(gap)
|
|
355
|
+
gap_fill = np.zeros(gap.duration_samples, dtype=pkt.samples.dtype)
|
|
356
|
+
output_samples.append(gap_fill)
|
|
357
|
+
|
|
358
|
+
output_samples.append(pkt.samples)
|
|
359
|
+
self.next_expected_ts = pkt.timestamp + self.samples_per_packet
|
|
360
|
+
|
|
361
|
+
# Clear buffer
|
|
362
|
+
self.buffer.clear()
|
|
363
|
+
self.buffer_seq_nums.clear()
|
|
364
|
+
|
|
365
|
+
if output_samples:
|
|
366
|
+
combined = np.concatenate(output_samples)
|
|
367
|
+
self.stats.samples_output += len(combined)
|
|
368
|
+
self.cumulative_samples += len(combined)
|
|
369
|
+
return combined, gap_events
|
|
370
|
+
|
|
371
|
+
return np.array([], dtype=np.complex64), gap_events
|
|
372
|
+
|
|
373
|
+
def get_stats(self) -> dict:
|
|
374
|
+
"""Get resequencer statistics"""
|
|
375
|
+
stats = self.stats.to_dict()
|
|
376
|
+
stats['buffer_used'] = len(self.buffer)
|
|
377
|
+
stats['buffer_size'] = self.buffer_size
|
|
378
|
+
stats['cumulative_samples'] = self.cumulative_samples
|
|
379
|
+
return stats
|
|
380
|
+
|
|
381
|
+
def reset(self):
|
|
382
|
+
"""Reset resequencer state (for stream restart)"""
|
|
383
|
+
self.buffer.clear()
|
|
384
|
+
self.buffer_seq_nums.clear()
|
|
385
|
+
self.initialized = False
|
|
386
|
+
self.next_expected_seq = None
|
|
387
|
+
self.next_expected_ts = None
|
|
388
|
+
self.cumulative_samples = 0
|
|
389
|
+
self.stats = ResequencerStats()
|