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/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()