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/rtp_recorder.py ADDED
@@ -0,0 +1,457 @@
1
+ """
2
+ Generic RTP Recorder with Timing Support
3
+
4
+ This module provides a generic RTP recording framework with:
5
+ - State machine (idle → armed → recording → resync)
6
+ - Precise timing from radiod (GPS_TIME, RTP_TIMESNAP)
7
+ - Callbacks for application-specific storage
8
+ - Sequence number and timestamp validation
9
+ - Automatic resynchronization on errors
10
+
11
+ Designed to be reusable across different recording applications
12
+ (WSPR, FT8, general-purpose, etc.)
13
+ """
14
+
15
+ import socket
16
+ import struct
17
+ import logging
18
+ import time
19
+ from enum import Enum
20
+ from typing import Optional, Callable, NamedTuple, Dict, Any
21
+ from dataclasses import dataclass
22
+ import threading
23
+
24
+ from .discovery import ChannelInfo
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # RTP Constants (from radiod)
30
+ GPS_UTC_OFFSET = 315964800 # GPS epoch (1980-01-06) - Unix epoch (1970-01-01)
31
+ UNIX_EPOCH = 2208988800 # Unix epoch in NTP seconds
32
+ BILLION = 1_000_000_000
33
+
34
+
35
+ class RecorderState(Enum):
36
+ """Recorder state machine states"""
37
+ IDLE = "idle" # Not recording
38
+ ARMED = "armed" # Waiting for trigger condition
39
+ RECORDING = "recording" # Actively recording
40
+ RESYNC = "resync" # Lost sync, trying to recover
41
+
42
+
43
+ class RTPHeader(NamedTuple):
44
+ """Parsed RTP packet header"""
45
+ version: int
46
+ padding: bool
47
+ extension: bool
48
+ csrc_count: int
49
+ marker: bool
50
+ payload_type: int
51
+ sequence: int
52
+ timestamp: int
53
+ ssrc: int
54
+
55
+
56
+ @dataclass
57
+ class RecordingMetrics:
58
+ """Recording session metrics"""
59
+ packets_received: int = 0
60
+ packets_dropped: int = 0
61
+ packets_out_of_order: int = 0
62
+ bytes_received: int = 0
63
+ sequence_errors: int = 0
64
+ timestamp_jumps: int = 0
65
+ state_changes: int = 0
66
+ recording_start_time: Optional[float] = None
67
+ recording_stop_time: Optional[float] = None
68
+
69
+ def to_dict(self) -> dict:
70
+ """Convert metrics to dictionary"""
71
+ return {
72
+ 'packets_received': self.packets_received,
73
+ 'packets_dropped': self.packets_dropped,
74
+ 'packets_out_of_order': self.packets_out_of_order,
75
+ 'bytes_received': self.bytes_received,
76
+ 'sequence_errors': self.sequence_errors,
77
+ 'timestamp_jumps': self.timestamp_jumps,
78
+ 'state_changes': self.state_changes,
79
+ 'recording_duration': (
80
+ self.recording_stop_time - self.recording_start_time
81
+ if self.recording_start_time and self.recording_stop_time
82
+ else None
83
+ )
84
+ }
85
+
86
+
87
+ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
88
+ """
89
+ Parse RTP packet header
90
+
91
+ Args:
92
+ data: Raw packet bytes (minimum 12 bytes)
93
+
94
+ Returns:
95
+ RTPHeader if valid, None if invalid
96
+ """
97
+ if len(data) < 12:
98
+ return None
99
+
100
+ # Parse RTP header (RFC 3550)
101
+ byte0, byte1 = struct.unpack('!BB', data[0:2])
102
+
103
+ version = (byte0 >> 6) & 0x03
104
+ padding = bool(byte0 & 0x20)
105
+ extension = bool(byte0 & 0x10)
106
+ csrc_count = byte0 & 0x0F
107
+
108
+ marker = bool(byte1 & 0x80)
109
+ payload_type = byte1 & 0x7F
110
+
111
+ sequence, timestamp, ssrc = struct.unpack('!HIL', data[2:12])
112
+
113
+ return RTPHeader(
114
+ version=version,
115
+ padding=padding,
116
+ extension=extension,
117
+ csrc_count=csrc_count,
118
+ marker=marker,
119
+ payload_type=payload_type,
120
+ sequence=sequence,
121
+ timestamp=timestamp,
122
+ ssrc=ssrc
123
+ )
124
+
125
+
126
+ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]:
127
+ """
128
+ Convert RTP timestamp to Unix wall-clock time
129
+
130
+ Uses the GPS_TIME/RTP_TIMESNAP timing information from radiod.
131
+
132
+ Args:
133
+ rtp_timestamp: RTP timestamp from packet header
134
+ channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
135
+
136
+ Returns:
137
+ Unix timestamp (seconds) or None if timing info unavailable
138
+ """
139
+ if channel.gps_time is None or channel.rtp_timesnap is None:
140
+ return None
141
+
142
+ # Convert GPS nanoseconds to Unix time
143
+ sender_time = channel.gps_time + BILLION * (UNIX_EPOCH - GPS_UTC_OFFSET)
144
+
145
+ # Add offset from RTP timestamp difference
146
+ # Cast to int32 for proper wrapping behavior
147
+ rtp_delta = int((rtp_timestamp - channel.rtp_timesnap) & 0xFFFFFFFF)
148
+ if rtp_delta > 0x7FFFFFFF:
149
+ rtp_delta -= 0x100000000
150
+
151
+ time_offset = BILLION * rtp_delta // channel.sample_rate
152
+
153
+ wall_time_ns = sender_time + time_offset
154
+
155
+ # Convert to Unix seconds
156
+ return wall_time_ns / BILLION
157
+
158
+
159
+ class RTPRecorder:
160
+ """
161
+ Generic RTP recorder with state machine and timing support
162
+
163
+ Callbacks allow application-specific behavior:
164
+ - on_packet: Called for each received packet
165
+ - on_state_change: Called when state changes
166
+ - on_recording_start: Called when recording starts
167
+ - on_recording_stop: Called when recording stops
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ channel: ChannelInfo,
173
+ on_packet: Optional[Callable[[RTPHeader, bytes, float], None]] = None,
174
+ on_state_change: Optional[Callable[[RecorderState, RecorderState], None]] = None,
175
+ on_recording_start: Optional[Callable[[], None]] = None,
176
+ on_recording_stop: Optional[Callable[[RecordingMetrics], None]] = None,
177
+ max_packet_gap: int = 10,
178
+ resync_threshold: int = 5,
179
+ pass_all_packets: bool = False
180
+ ):
181
+ """
182
+ Initialize RTP recorder
183
+
184
+ Args:
185
+ channel: ChannelInfo with RTP stream details and timing
186
+ on_packet: Callback(header, payload, wallclock_time) for each packet
187
+ on_state_change: Callback(old_state, new_state) on state changes
188
+ on_recording_start: Callback when recording begins
189
+ on_recording_stop: Callback(metrics) when recording ends
190
+ max_packet_gap: Max sequence gap before triggering resync (ignored if pass_all_packets=True)
191
+ resync_threshold: Number of good packets needed to recover from resync
192
+ pass_all_packets: If True, pass ALL packets to callback regardless of sequence.
193
+ Metrics still track errors. Use when downstream has its own resequencer.
194
+ """
195
+ self.channel = channel
196
+ self.on_packet = on_packet
197
+ self.on_state_change = on_state_change
198
+ self.on_recording_start = on_recording_start
199
+ self.on_recording_stop = on_recording_stop
200
+
201
+ self.max_packet_gap = max_packet_gap
202
+ self.resync_threshold = resync_threshold
203
+ self.pass_all_packets = pass_all_packets
204
+
205
+ self.state = RecorderState.IDLE
206
+ self.metrics = RecordingMetrics()
207
+
208
+ # RTP state tracking
209
+ self.last_sequence: Optional[int] = None
210
+ self.last_timestamp: Optional[int] = None
211
+ self.resync_good_packets = 0
212
+
213
+ # Socket
214
+ self.socket: Optional[socket.socket] = None
215
+ self.running = False
216
+ self.thread: Optional[threading.Thread] = None
217
+
218
+ def _change_state(self, new_state: RecorderState):
219
+ """Change state and trigger callback"""
220
+ if new_state == self.state:
221
+ return
222
+
223
+ old_state = self.state
224
+ self.state = new_state
225
+ self.metrics.state_changes += 1
226
+
227
+ logger.info(f"State: {old_state.value} → {new_state.value}")
228
+
229
+ if self.on_state_change:
230
+ try:
231
+ self.on_state_change(old_state, new_state)
232
+ except Exception as e:
233
+ logger.error(f"Error in state_change callback: {e}", exc_info=True)
234
+
235
+ # Trigger recording callbacks
236
+ if new_state == RecorderState.RECORDING and self.on_recording_start:
237
+ self.metrics.recording_start_time = time.time()
238
+ try:
239
+ self.on_recording_start()
240
+ except Exception as e:
241
+ logger.error(f"Error in recording_start callback: {e}", exc_info=True)
242
+
243
+ elif old_state == RecorderState.RECORDING and new_state != RecorderState.RECORDING:
244
+ self.metrics.recording_stop_time = time.time()
245
+ if self.on_recording_stop:
246
+ try:
247
+ self.on_recording_stop(self.metrics)
248
+ except Exception as e:
249
+ logger.error(f"Error in recording_stop callback: {e}", exc_info=True)
250
+
251
+ def _create_socket(self) -> socket.socket:
252
+ """Create and configure RTP receive socket"""
253
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
254
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
255
+
256
+ if hasattr(socket, 'SO_REUSEPORT'):
257
+ try:
258
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
259
+ except OSError:
260
+ pass
261
+
262
+ # Bind to port
263
+ sock.bind(('0.0.0.0', self.channel.port))
264
+
265
+ # Join multicast group
266
+ mreq = struct.pack('=4s4s',
267
+ socket.inet_aton(self.channel.multicast_address),
268
+ socket.inet_aton('0.0.0.0'))
269
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
270
+
271
+ sock.settimeout(1.0) # Allow periodic checks of self.running
272
+
273
+ logger.info(f"Listening on {self.channel.multicast_address}:{self.channel.port}")
274
+
275
+ return sock
276
+
277
+ def _validate_packet(self, header: RTPHeader) -> bool:
278
+ """
279
+ Validate RTP packet and update state
280
+
281
+ Returns:
282
+ True if packet should be processed, False if dropped
283
+ """
284
+ # Check SSRC - always filter wrong SSRC
285
+ if header.ssrc != self.channel.ssrc:
286
+ logger.debug(f"Wrong SSRC: {header.ssrc} (expected {self.channel.ssrc})")
287
+ return False
288
+
289
+ # First packet
290
+ if self.last_sequence is None:
291
+ self.last_sequence = header.sequence
292
+ self.last_timestamp = header.timestamp
293
+ return True
294
+
295
+ # Check sequence number (track metrics even in pass_all mode)
296
+ expected_seq = (self.last_sequence + 1) & 0xFFFF
297
+ if header.sequence != expected_seq:
298
+ seq_gap = (header.sequence - expected_seq) & 0xFFFF
299
+
300
+ if seq_gap > self.max_packet_gap:
301
+ logger.warning(
302
+ f"Large sequence gap: {seq_gap} packets "
303
+ f"(got {header.sequence}, expected {expected_seq})"
304
+ )
305
+ self.metrics.sequence_errors += 1
306
+
307
+ # In pass_all mode, don't trigger resync - just log and continue
308
+ if not self.pass_all_packets:
309
+ # Trigger resync if recording
310
+ if self.state == RecorderState.RECORDING:
311
+ self._change_state(RecorderState.RESYNC)
312
+ self.resync_good_packets = 0
313
+ return False
314
+ else:
315
+ self.metrics.packets_dropped += seq_gap - 1
316
+
317
+ # Check timestamp progression
318
+ if self.last_timestamp is not None:
319
+ ts_delta = (header.timestamp - self.last_timestamp) & 0xFFFFFFFF
320
+
321
+ # Detect large jumps (more than 1 second worth)
322
+ if ts_delta > self.channel.sample_rate:
323
+ logger.warning(
324
+ f"Timestamp jump: {ts_delta} samples "
325
+ f"({ts_delta / self.channel.sample_rate:.2f}s)"
326
+ )
327
+ self.metrics.timestamp_jumps += 1
328
+
329
+ self.last_sequence = header.sequence
330
+ self.last_timestamp = header.timestamp
331
+
332
+ # In pass_all mode, skip resync state handling - always deliver
333
+ if self.pass_all_packets:
334
+ return True
335
+
336
+ # Handle resync state (original behavior)
337
+ if self.state == RecorderState.RESYNC:
338
+ self.resync_good_packets += 1
339
+ if self.resync_good_packets >= self.resync_threshold:
340
+ logger.info(f"Resync successful after {self.resync_good_packets} packets")
341
+ self._change_state(RecorderState.RECORDING)
342
+ return True
343
+ else:
344
+ return False # Drop packets during resync
345
+
346
+ return True
347
+
348
+ def _receive_loop(self):
349
+ """Main packet receiving loop"""
350
+ try:
351
+ self.socket = self._create_socket()
352
+
353
+ while self.running:
354
+ try:
355
+ data, addr = self.socket.recvfrom(8192)
356
+
357
+ self.metrics.packets_received += 1
358
+ self.metrics.bytes_received += len(data)
359
+
360
+ # Parse RTP header
361
+ header = parse_rtp_header(data)
362
+ if not header:
363
+ logger.debug("Invalid RTP packet")
364
+ continue
365
+
366
+ # Validate packet
367
+ if not self._validate_packet(header):
368
+ continue
369
+
370
+ # Extract payload (skip RTP header + CSRC)
371
+ header_len = 12 + (4 * header.csrc_count)
372
+ payload = data[header_len:]
373
+
374
+ # Compute wall-clock time
375
+ wallclock = rtp_to_wallclock(header.timestamp, self.channel)
376
+
377
+ # Call packet callback
378
+ if self.on_packet:
379
+ try:
380
+ self.on_packet(header, payload, wallclock)
381
+ except Exception as e:
382
+ logger.error(f"Error in packet callback: {e}", exc_info=True)
383
+
384
+ except socket.timeout:
385
+ continue
386
+ except Exception as e:
387
+ logger.error(f"Error receiving packet: {e}", exc_info=True)
388
+
389
+ finally:
390
+ if self.socket:
391
+ try:
392
+ self.socket.close()
393
+ except Exception:
394
+ pass # Ignore errors during cleanup
395
+ self.socket = None
396
+
397
+ def start(self):
398
+ """Start receiving RTP packets"""
399
+ if self.running:
400
+ logger.warning("Recorder already running")
401
+ return
402
+
403
+ logger.info(f"Starting RTP recorder for SSRC {self.channel.ssrc}")
404
+
405
+ self.running = True
406
+ self.thread = threading.Thread(target=self._receive_loop, daemon=True)
407
+ self.thread.start()
408
+
409
+ self._change_state(RecorderState.ARMED)
410
+
411
+ def stop(self):
412
+ """Stop receiving RTP packets"""
413
+ if not self.running:
414
+ return
415
+
416
+ logger.info("Stopping RTP recorder")
417
+
418
+ self.running = False
419
+ if self.thread:
420
+ self.thread.join(timeout=5.0)
421
+ self.thread = None
422
+
423
+ self._change_state(RecorderState.IDLE)
424
+
425
+ def start_recording(self):
426
+ """Transition from ARMED to RECORDING"""
427
+ if self.state == RecorderState.ARMED:
428
+ self._change_state(RecorderState.RECORDING)
429
+ else:
430
+ logger.warning(f"Cannot start recording from state {self.state.value}")
431
+
432
+ def stop_recording(self):
433
+ """Transition from RECORDING back to ARMED"""
434
+ if self.state in (RecorderState.RECORDING, RecorderState.RESYNC):
435
+ self._change_state(RecorderState.ARMED)
436
+ else:
437
+ logger.warning(f"Cannot stop recording from state {self.state.value}")
438
+
439
+ def get_metrics(self) -> Dict[str, Any]:
440
+ """Get current recording metrics"""
441
+ return self.metrics.to_dict()
442
+
443
+ def reset_metrics(self):
444
+ """Reset all metrics"""
445
+ self.metrics = RecordingMetrics()
446
+
447
+ def __del__(self):
448
+ """
449
+ Ensure recorder is stopped on garbage collection
450
+
451
+ This provides a safety net for unclosed recorders and helps
452
+ detect resource leaks during development.
453
+ """
454
+ try:
455
+ self.stop()
456
+ except Exception:
457
+ pass # Can't raise exceptions in __del__