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