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/stream.py ADDED
@@ -0,0 +1,393 @@
1
+ """
2
+ RadiodStream - High-Level Sample Stream Interface
3
+
4
+ Provides a continuous sample stream from radiod with quality metadata.
5
+ This is the primary interface for applications consuming radio data.
6
+
7
+ Features:
8
+ - Automatic multicast subscription
9
+ - RTP packet reception and parsing
10
+ - Packet resequencing and gap filling
11
+ - Quality tracking (StreamQuality) with every callback
12
+ - Cross-platform support (Linux, macOS, Windows)
13
+
14
+ Usage:
15
+ from ka9q import RadiodStream, StreamQuality
16
+
17
+ def on_samples(samples: np.ndarray, quality: StreamQuality):
18
+ # Process continuous sample stream
19
+ print(f"Got {len(samples)} samples, completeness: {quality.completeness_pct:.1f}%")
20
+
21
+ stream = RadiodStream(
22
+ channel=channel_info,
23
+ on_samples=on_samples,
24
+ )
25
+ stream.start()
26
+ # ... run until done ...
27
+ stream.stop()
28
+ """
29
+
30
+ import socket
31
+ import struct
32
+ import logging
33
+ import threading
34
+ import numpy as np
35
+ from datetime import datetime, timezone
36
+ from typing import Optional, Callable, List
37
+
38
+ from .discovery import ChannelInfo
39
+ from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
40
+ from .resequencer import PacketResequencer, RTPPacket
41
+ from .stream_quality import GapSource, GapEvent, StreamQuality
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Type alias for sample callback
46
+ SampleCallback = Callable[[np.ndarray, StreamQuality], None]
47
+
48
+
49
+ class RadiodStream:
50
+ """
51
+ High-level interface to a radiod IQ/audio stream.
52
+
53
+ Handles all low-level details:
54
+ - Multicast subscription and RTP packet reception
55
+ - Packet resequencing and gap detection
56
+ - Gap filling with zeros for continuous stream
57
+ - Quality tracking with detailed metrics
58
+
59
+ Delivers to application:
60
+ - Continuous sample stream (np.ndarray, complex64 or float32)
61
+ - StreamQuality metadata with every callback
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ channel: ChannelInfo,
67
+ on_samples: Optional[SampleCallback] = None,
68
+ samples_per_packet: int = 320,
69
+ resequence_buffer_size: int = 64,
70
+ deliver_interval_packets: int = 10,
71
+ ):
72
+ """
73
+ Initialize RadiodStream.
74
+
75
+ Args:
76
+ channel: ChannelInfo with stream details (from discover_channels)
77
+ on_samples: Callback(samples, quality) for sample delivery
78
+ samples_per_packet: Expected samples per RTP packet (320 @ 16kHz)
79
+ resequence_buffer_size: Packets to buffer for resequencing (64 = ~2s)
80
+ deliver_interval_packets: Deliver to callback every N packets (batching)
81
+ """
82
+ self.channel = channel
83
+ self.on_samples = on_samples
84
+ self.samples_per_packet = samples_per_packet
85
+ self.deliver_interval_packets = deliver_interval_packets
86
+
87
+ # Resequencer
88
+ self.resequencer = PacketResequencer(
89
+ buffer_size=resequence_buffer_size,
90
+ samples_per_packet=samples_per_packet,
91
+ sample_rate=channel.sample_rate,
92
+ )
93
+
94
+ # Quality tracking
95
+ self.quality = StreamQuality()
96
+
97
+ # Sample accumulator for batched delivery
98
+ self._sample_buffer: List[np.ndarray] = []
99
+ self._gap_buffer: List[GapEvent] = []
100
+ self._packets_since_delivery = 0
101
+
102
+ # Socket and threading
103
+ self._socket: Optional[socket.socket] = None
104
+ self._running = False
105
+ self._thread: Optional[threading.Thread] = None
106
+
107
+ # Detect payload format from channel preset
108
+ # IQ mode: complex64 (interleaved float32 I/Q)
109
+ # Audio modes: float32 (mono or stereo)
110
+ self._is_iq = channel.preset.lower() in ('iq', 'spectrum')
111
+
112
+ # Payload samples differ from RTP timestamp increment in IQ mode
113
+ # IQ: 160 complex samples per packet, but timestamp advances by 320
114
+ # Audio: samples_per_packet real samples, timestamp advances same
115
+ self._payload_samples_per_packet = samples_per_packet // 2 if self._is_iq else samples_per_packet
116
+
117
+ def start(self):
118
+ """Start receiving and delivering samples."""
119
+ if self._running:
120
+ logger.warning("Stream already running")
121
+ return
122
+
123
+ # Initialize quality tracking
124
+ self.quality = StreamQuality(
125
+ stream_start_utc=datetime.now(timezone.utc).isoformat(),
126
+ sample_rate=self.channel.sample_rate,
127
+ )
128
+
129
+ # Track first RTP timestamp
130
+ self._first_rtp_timestamp: Optional[int] = None
131
+
132
+ # Reset resequencer
133
+ self.resequencer.reset()
134
+
135
+ # Start receive thread
136
+ self._running = True
137
+ self._thread = threading.Thread(target=self._receive_loop, daemon=True)
138
+ self._thread.start()
139
+
140
+ logger.info(
141
+ f"RadiodStream started: {self.channel.multicast_address}:{self.channel.port} "
142
+ f"SSRC={self.channel.ssrc}"
143
+ )
144
+
145
+ def stop(self) -> StreamQuality:
146
+ """
147
+ Stop receiving and return final quality metrics.
148
+
149
+ Returns:
150
+ Final StreamQuality with complete statistics
151
+ """
152
+ if not self._running:
153
+ return self.quality.copy()
154
+
155
+ self._running = False
156
+
157
+ if self._thread:
158
+ self._thread.join(timeout=5.0)
159
+ self._thread = None
160
+
161
+ # Flush resequencer
162
+ final_samples, final_gaps = self.resequencer.flush()
163
+ if len(final_samples) > 0 or final_gaps:
164
+ self._sample_buffer.append(final_samples)
165
+ self._gap_buffer.extend(final_gaps)
166
+ self._deliver_samples()
167
+
168
+ logger.info(
169
+ f"RadiodStream stopped. Completeness: {self.quality.completeness_pct:.1f}%, "
170
+ f"Gaps: {self.quality.total_gap_events}"
171
+ )
172
+
173
+ return self.quality.copy()
174
+
175
+ def _create_socket(self) -> socket.socket:
176
+ """Create and configure multicast receive socket."""
177
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
178
+
179
+ # Allow address reuse
180
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
181
+ if hasattr(socket, 'SO_REUSEPORT'):
182
+ try:
183
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
184
+ except OSError:
185
+ pass # Not supported on all platforms
186
+
187
+ # Bind to port
188
+ sock.bind(('0.0.0.0', self.channel.port))
189
+
190
+ # Join multicast group
191
+ mreq = struct.pack(
192
+ '=4s4s',
193
+ socket.inet_aton(self.channel.multicast_address),
194
+ socket.inet_aton('0.0.0.0')
195
+ )
196
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
197
+
198
+ # Timeout for periodic running check
199
+ sock.settimeout(1.0)
200
+
201
+ logger.debug(
202
+ f"Joined multicast {self.channel.multicast_address}:{self.channel.port}"
203
+ )
204
+
205
+ return sock
206
+
207
+ def _receive_loop(self):
208
+ """Main packet receiving loop."""
209
+ try:
210
+ self._socket = self._create_socket()
211
+
212
+ while self._running:
213
+ try:
214
+ data, addr = self._socket.recvfrom(8192)
215
+ self._process_packet(data)
216
+
217
+ except socket.timeout:
218
+ continue
219
+ except Exception as e:
220
+ if self._running:
221
+ logger.error(f"Receive error: {e}", exc_info=True)
222
+
223
+ finally:
224
+ if self._socket:
225
+ try:
226
+ self._socket.close()
227
+ except Exception:
228
+ pass # Ignore errors during cleanup
229
+ self._socket = None
230
+
231
+ def _process_packet(self, data: bytes):
232
+ """Process a received RTP packet."""
233
+ # Parse RTP header
234
+ header = parse_rtp_header(data)
235
+ if header is None:
236
+ logger.debug("Invalid RTP packet")
237
+ return
238
+
239
+ # Filter by SSRC
240
+ if header.ssrc != self.channel.ssrc:
241
+ return # Wrong stream
242
+
243
+ # Update RTP stats
244
+ self.quality.rtp_packets_received += 1
245
+
246
+ # Track first and last RTP timestamps
247
+ if self._first_rtp_timestamp is None:
248
+ self._first_rtp_timestamp = header.timestamp
249
+ self.quality.first_rtp_timestamp = header.timestamp
250
+ self.quality.last_rtp_timestamp = header.timestamp
251
+
252
+ # Extract payload
253
+ header_len = 12 + (4 * header.csrc_count)
254
+ payload = data[header_len:]
255
+
256
+ if len(payload) == 0:
257
+ # Empty payload - track as gap source
258
+ self._record_empty_payload(header)
259
+ return
260
+
261
+ # Parse samples from payload
262
+ samples = self._parse_samples(payload)
263
+ if samples is None:
264
+ return
265
+
266
+ # Get wallclock time
267
+ wallclock = rtp_to_wallclock(header.timestamp, self.channel)
268
+
269
+ # Create packet for resequencer
270
+ packet = RTPPacket(
271
+ sequence=header.sequence,
272
+ timestamp=header.timestamp,
273
+ ssrc=header.ssrc,
274
+ samples=samples,
275
+ wallclock=wallclock,
276
+ )
277
+
278
+ # Process through resequencer
279
+ output_samples, gap_events = self.resequencer.process_packet(packet)
280
+
281
+ # Accumulate output
282
+ if output_samples is not None:
283
+ self._sample_buffer.append(output_samples)
284
+ self._gap_buffer.extend(gap_events)
285
+ self._packets_since_delivery += 1
286
+
287
+ # Update gap stats
288
+ for gap in gap_events:
289
+ self.quality.total_gap_events += 1
290
+ self.quality.total_gaps_filled += gap.duration_samples
291
+
292
+ # Deliver if we've accumulated enough
293
+ if self._packets_since_delivery >= self.deliver_interval_packets:
294
+ self._deliver_samples()
295
+
296
+ # Update timing
297
+ if wallclock:
298
+ self.quality.last_packet_utc = datetime.fromtimestamp(
299
+ wallclock, tz=timezone.utc
300
+ ).isoformat()
301
+
302
+ def _parse_samples(self, payload: bytes) -> Optional[np.ndarray]:
303
+ """Parse samples from RTP payload."""
304
+ try:
305
+ if self._is_iq:
306
+ # IQ mode: float32 interleaved I/Q -> complex64
307
+ # 960 bytes = 240 floats = 120 complex samples
308
+ floats = np.frombuffer(payload, dtype=np.float32)
309
+ if len(floats) % 2 != 0:
310
+ logger.warning(f"Odd number of floats in IQ payload: {len(floats)}")
311
+ return None
312
+ samples = floats[0::2] + 1j * floats[1::2]
313
+ return samples.astype(np.complex64)
314
+ else:
315
+ # Audio mode: float32 mono
316
+ return np.frombuffer(payload, dtype=np.float32)
317
+
318
+ except Exception as e:
319
+ logger.error(f"Failed to parse payload: {e}")
320
+ return None
321
+
322
+ def _record_empty_payload(self, header: RTPHeader):
323
+ """Record an empty payload as a gap event."""
324
+ gap = GapEvent(
325
+ source=GapSource.EMPTY_PAYLOAD,
326
+ position_samples=self.quality.total_samples_delivered,
327
+ duration_samples=self.samples_per_packet,
328
+ timestamp_utc=datetime.now(timezone.utc).isoformat(),
329
+ packets_affected=1,
330
+ )
331
+ self._gap_buffer.append(gap)
332
+ self.quality.total_gap_events += 1
333
+ self.quality.total_gaps_filled += self.samples_per_packet
334
+
335
+ def _deliver_samples(self):
336
+ """Deliver accumulated samples to callback."""
337
+ if not self._sample_buffer:
338
+ return
339
+
340
+ # Combine samples
341
+ samples = np.concatenate(self._sample_buffer)
342
+ gaps = list(self._gap_buffer)
343
+
344
+ # Update quality for this batch
345
+ batch_start = self.quality.total_samples_delivered
346
+ self.quality.batch_start_sample = batch_start
347
+ self.quality.batch_samples_delivered = len(samples)
348
+ self.quality.batch_gaps = gaps
349
+ self.quality.total_samples_delivered += len(samples)
350
+
351
+ # Update expected samples (based on actual payload samples per packet)
352
+ self.quality.total_samples_expected = (
353
+ self.quality.rtp_packets_received * self._payload_samples_per_packet
354
+ )
355
+
356
+ # Update RTP loss stats from resequencer
357
+ reseq_stats = self.resequencer.get_stats()
358
+ self.quality.rtp_packets_lost = reseq_stats.get('gaps_detected', 0)
359
+ self.quality.rtp_packets_resequenced = reseq_stats.get('packets_resequenced', 0)
360
+ self.quality.rtp_packets_duplicate = reseq_stats.get('packets_duplicate', 0)
361
+
362
+ # Clear buffers
363
+ self._sample_buffer = []
364
+ self._gap_buffer = []
365
+ self._packets_since_delivery = 0
366
+
367
+ # Deliver to callback
368
+ if self.on_samples:
369
+ try:
370
+ self.on_samples(samples, self.quality.copy())
371
+ except Exception as e:
372
+ logger.error(f"Error in sample callback: {e}", exc_info=True)
373
+
374
+ @property
375
+ def is_running(self) -> bool:
376
+ """True if stream is actively receiving."""
377
+ return self._running
378
+
379
+ def get_quality(self) -> StreamQuality:
380
+ """Get current quality metrics (copy)."""
381
+ return self.quality.copy()
382
+
383
+ def __del__(self):
384
+ """
385
+ Ensure stream is stopped on garbage collection
386
+
387
+ This provides a safety net for unclosed streams and helps
388
+ detect resource leaks during development.
389
+ """
390
+ try:
391
+ self.stop()
392
+ except Exception:
393
+ pass # Can't raise exceptions in __del__
ka9q/stream_quality.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ Stream Quality Metadata for ka9q-python
3
+
4
+ Provides data structures for tracking stream quality and gap information.
5
+ This metadata accompanies sample streams delivered to applications.
6
+
7
+ Gap Types (detected by ka9q-python core):
8
+ - NETWORK_LOSS: RTP packets lost in transit (sequence gap)
9
+ - RESEQUENCE_TIMEOUT: Packet arrived too late to resequence
10
+ - EMPTY_PAYLOAD: RTP packet received with no data
11
+ - STREAM_START: Gap at beginning before first packet
12
+ - STREAM_INTERRUPTION: radiod stopped sending packets
13
+
14
+ Applications may add their own gap types (e.g., cadence_fill, late_start)
15
+ when they perform segmentation.
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import List, Optional
20
+ from enum import Enum
21
+
22
+
23
+ class GapSource(Enum):
24
+ """
25
+ Gap types detected by ka9q-python core layer.
26
+
27
+ Applications may define additional gap types for app-level gaps
28
+ (e.g., segment boundary padding, late start, tone lock wait).
29
+ """
30
+ NETWORK_LOSS = "network_loss"
31
+ """RTP sequence gap - packets lost in transit"""
32
+
33
+ RESEQUENCE_TIMEOUT = "resequence_timeout"
34
+ """Packet arrived after resequence window expired"""
35
+
36
+ EMPTY_PAYLOAD = "empty_payload"
37
+ """RTP packet received but payload was empty/zeros"""
38
+
39
+ STREAM_START = "stream_start"
40
+ """Initial gap before first packet received"""
41
+
42
+ STREAM_INTERRUPTION = "stream_interruption"
43
+ """Extended silence - radiod may have stopped sending"""
44
+
45
+
46
+ @dataclass
47
+ class GapEvent:
48
+ """
49
+ Single gap detected in the sample stream.
50
+
51
+ Position is relative to stream start (cumulative sample count).
52
+ Applications can convert to segment-relative positions as needed.
53
+ """
54
+ source: GapSource
55
+ """What caused this gap"""
56
+
57
+ position_samples: int
58
+ """Offset from stream start (cumulative sample count)"""
59
+
60
+ duration_samples: int
61
+ """Gap size in samples (zeros inserted)"""
62
+
63
+ timestamp_utc: str
64
+ """ISO format timestamp when gap was detected"""
65
+
66
+ packets_affected: int = 0
67
+ """Number of packets involved (for NETWORK_LOSS)"""
68
+
69
+ def to_dict(self) -> dict:
70
+ """Serialize for JSON/storage"""
71
+ return {
72
+ 'source': self.source.value,
73
+ 'position_samples': self.position_samples,
74
+ 'duration_samples': self.duration_samples,
75
+ 'timestamp_utc': self.timestamp_utc,
76
+ 'packets_affected': self.packets_affected,
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class StreamQuality:
82
+ """
83
+ Quality metadata for a batch of samples delivered to application.
84
+
85
+ Includes both per-batch and cumulative statistics.
86
+ Applications receive this with every sample callback.
87
+ """
88
+
89
+ # === Per-batch info ===
90
+ batch_start_sample: int = 0
91
+ """Position of first sample in this batch (cumulative from stream start)"""
92
+
93
+ batch_samples_delivered: int = 0
94
+ """Number of samples in this batch"""
95
+
96
+ batch_gaps: List[GapEvent] = field(default_factory=list)
97
+ """Gaps detected in this batch"""
98
+
99
+ # === Cumulative (stream lifetime) ===
100
+ total_samples_delivered: int = 0
101
+ """Total samples delivered since stream start"""
102
+
103
+ total_samples_expected: int = 0
104
+ """Total samples expected based on elapsed time"""
105
+
106
+ total_gaps_filled: int = 0
107
+ """Total zero-fill samples inserted for gaps"""
108
+
109
+ total_gap_events: int = 0
110
+ """Number of distinct gap events"""
111
+
112
+ # === RTP statistics ===
113
+ rtp_packets_received: int = 0
114
+ """Packets received from multicast"""
115
+
116
+ rtp_packets_expected: int = 0
117
+ """Packets expected based on sequence numbers"""
118
+
119
+ rtp_packets_lost: int = 0
120
+ """Packets never received (sequence gaps)"""
121
+
122
+ rtp_packets_late: int = 0
123
+ """Packets that arrived after resequence window"""
124
+
125
+ rtp_packets_duplicate: int = 0
126
+ """Duplicate packets (same sequence number)"""
127
+
128
+ rtp_packets_resequenced: int = 0
129
+ """Packets that arrived out of order but were resequenced"""
130
+
131
+ # === Timing ===
132
+ stream_start_utc: str = ""
133
+ """ISO format timestamp when stream started"""
134
+
135
+ last_packet_utc: str = ""
136
+ """ISO format timestamp of most recent packet"""
137
+
138
+ # === RTP timing (for applications needing precise timing) ===
139
+ first_rtp_timestamp: int = 0
140
+ """RTP timestamp of first packet received"""
141
+
142
+ last_rtp_timestamp: int = 0
143
+ """RTP timestamp of most recent packet"""
144
+
145
+ sample_rate: int = 0
146
+ """Sample rate in Hz (for RTP timestamp conversion)"""
147
+
148
+ @property
149
+ def completeness_pct(self) -> float:
150
+ """Percentage of expected samples that were actually received (not gap-filled)"""
151
+ if self.total_samples_expected == 0:
152
+ return 100.0
153
+ actual = self.total_samples_delivered - self.total_gaps_filled
154
+ return min(100.0, (actual / self.total_samples_expected) * 100)
155
+
156
+ @property
157
+ def has_gaps(self) -> bool:
158
+ """True if any gaps have been detected"""
159
+ return self.total_gap_events > 0
160
+
161
+ def to_dict(self) -> dict:
162
+ """Serialize for JSON/storage"""
163
+ return {
164
+ # Batch
165
+ 'batch_start_sample': self.batch_start_sample,
166
+ 'batch_samples_delivered': self.batch_samples_delivered,
167
+ 'batch_gaps': [g.to_dict() for g in self.batch_gaps],
168
+
169
+ # Cumulative
170
+ 'total_samples_delivered': self.total_samples_delivered,
171
+ 'total_samples_expected': self.total_samples_expected,
172
+ 'total_gaps_filled': self.total_gaps_filled,
173
+ 'total_gap_events': self.total_gap_events,
174
+ 'completeness_pct': self.completeness_pct,
175
+
176
+ # RTP stats
177
+ 'rtp_packets_received': self.rtp_packets_received,
178
+ 'rtp_packets_expected': self.rtp_packets_expected,
179
+ 'rtp_packets_lost': self.rtp_packets_lost,
180
+ 'rtp_packets_late': self.rtp_packets_late,
181
+ 'rtp_packets_duplicate': self.rtp_packets_duplicate,
182
+ 'rtp_packets_resequenced': self.rtp_packets_resequenced,
183
+
184
+ # Timing
185
+ 'stream_start_utc': self.stream_start_utc,
186
+ 'last_packet_utc': self.last_packet_utc,
187
+
188
+ # RTP timing
189
+ 'first_rtp_timestamp': self.first_rtp_timestamp,
190
+ 'last_rtp_timestamp': self.last_rtp_timestamp,
191
+ 'sample_rate': self.sample_rate,
192
+ }
193
+
194
+ def copy(self) -> 'StreamQuality':
195
+ """Create a copy (for passing to callbacks without mutation issues)"""
196
+ return StreamQuality(
197
+ batch_start_sample=self.batch_start_sample,
198
+ batch_samples_delivered=self.batch_samples_delivered,
199
+ batch_gaps=list(self.batch_gaps),
200
+ total_samples_delivered=self.total_samples_delivered,
201
+ total_samples_expected=self.total_samples_expected,
202
+ total_gaps_filled=self.total_gaps_filled,
203
+ total_gap_events=self.total_gap_events,
204
+ rtp_packets_received=self.rtp_packets_received,
205
+ rtp_packets_expected=self.rtp_packets_expected,
206
+ rtp_packets_lost=self.rtp_packets_lost,
207
+ rtp_packets_late=self.rtp_packets_late,
208
+ rtp_packets_duplicate=self.rtp_packets_duplicate,
209
+ rtp_packets_resequenced=self.rtp_packets_resequenced,
210
+ stream_start_utc=self.stream_start_utc,
211
+ last_packet_utc=self.last_packet_utc,
212
+ first_rtp_timestamp=self.first_rtp_timestamp,
213
+ last_rtp_timestamp=self.last_rtp_timestamp,
214
+ sample_rate=self.sample_rate,
215
+ )