ka9q-python 3.22.0__py3-none-any.whl → 3.26.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 CHANGED
@@ -119,7 +119,11 @@ from .slot_clock import SlotClock, Slot, SlotClockDesyncError, rtp_diff
119
119
  from .spectrum_stream import SpectrumStream
120
120
  from .status_listener import StatusListener, StatusListenerStats
121
121
 
122
+ from .recovery_ladder import RecoveryAction, RecoveryLadder # noqa: E402
123
+
122
124
  __all__ = [
125
+ "RecoveryAction",
126
+ "RecoveryLadder",
123
127
  # Control
124
128
  'RadiodControl',
125
129
  'allocate_ssrc',
ka9q/compat.py CHANGED
@@ -12,4 +12,4 @@ Usage:
12
12
  print(f"Compatible with ka9q-radio at {KA9Q_RADIO_COMMIT}")
13
13
  """
14
14
 
15
- KA9Q_RADIO_COMMIT: str = "cedec349f7b4212078de3e007d142b4c64d36546"
15
+ KA9Q_RADIO_COMMIT: str = "401992cdecaa7f85b093bff053f074453c99bdf5"
ka9q/control.py CHANGED
@@ -65,6 +65,32 @@ class Metrics:
65
65
 
66
66
 
67
67
  # SSRC allocation function - compatible with signal-recorder
68
+ # Preset -> demodulator, mirroring the `demod =` line of each preset in
69
+ # ka9q-radio's share/presets.conf. LINEAR is the default because radio.h
70
+ # defines it as "everything else"; defaulting to FM (as this once did) puts
71
+ # an FM demodulator behind sam/ame/dsb/cwu/cwl/wspr/nam/amsq as well as wfm.
72
+ _FM_PRESETS = frozenset({"fm", "nfm", "pm", "npm"})
73
+ _WFM_PRESETS = frozenset({"wfm"})
74
+ _SPECTRUM_PRESETS = frozenset({"spectrum"})
75
+
76
+
77
+ def demod_type_for_preset(preset: str) -> int:
78
+ """Return the DEMOD_TYPE that matches a radiod preset name.
79
+
80
+ radiod sets the demodulator from the preset, but clients that also send
81
+ DEMOD_TYPE override it -- so the value has to agree with the preset or
82
+ the channel runs a demodulator the caller never asked for.
83
+ """
84
+ name = (preset or "").strip().lower()
85
+ if name in _WFM_PRESETS:
86
+ return DemodType.WFM_DEMOD
87
+ if name in _FM_PRESETS:
88
+ return DemodType.FM_DEMOD
89
+ if name in _SPECTRUM_PRESETS:
90
+ return DemodType.SPECT_DEMOD
91
+ return DemodType.LINEAR_DEMOD
92
+
93
+
68
94
  def allocate_ssrc(
69
95
  frequency_hz: float,
70
96
  preset: str = "iq",
@@ -764,6 +790,19 @@ def decode_status_dict(buffer: bytes) -> dict:
764
790
  status['rf_agc'] = decode_int(data, optlen)
765
791
  elif type_val == StatusType.PRESET:
766
792
  status['preset'] = decode_string(data, optlen)
793
+ # ⛔ The two fields that say how to READ the samples. status.py has
794
+ # always decoded them; this decoder -- the one discovery.py builds
795
+ # ChannelInfo from -- did not, so every consumer holding a
796
+ # ChannelInfo had to fall back to the preset STRING to decide
797
+ # complex-versus-real. Phil Karn, 2026-09-21: "the individual
798
+ # parameters [are] definitive, with a preset just being a convenient
799
+ # shorthand"; a preset can be tweaked out of truth (PRESET USB then
800
+ # filters -3000,-50 yields LSB) and, from the nopreset branch on,
801
+ # may not arrive at all.
802
+ elif type_val == StatusType.DEMOD_TYPE:
803
+ status['demod_type'] = decode_int(data, optlen)
804
+ elif type_val == StatusType.OUTPUT_CHANNELS:
805
+ status['output_channels'] = decode_int(data, optlen)
767
806
  elif type_val == StatusType.LOW_EDGE:
768
807
  status['low_edge'] = decode_float(data, optlen)
769
808
  elif type_val == StatusType.HIGH_EDGE:
@@ -799,6 +838,21 @@ def decode_status_dict(buffer: bytes) -> dict:
799
838
  else:
800
839
  _TTL_ZERO_WARNED.add(ssrc)
801
840
  logger.warning(msg)
841
+ elif type_val == StatusType.FILTER_DROPS:
842
+ # radiod's count of output blocks this channel's demod thread
843
+ # lapped. Since ka9q-radio 55d9048d each lapped block is
844
+ # counted here AND emitted as a block of zeros, so the sample
845
+ # timeline stays intact and the loss shows up in the data --
846
+ # which makes this counter cross-checkable against
847
+ # ``PacketResequencer``'s zero-block detection rather than
848
+ # something a client has to take on trust.
849
+ #
850
+ # ``decode_status_packet`` has decoded it into
851
+ # ``ChannelStatus.filter_drops`` all along; this lightweight
852
+ # decoder -- the one ``StatusListener`` runs on every
853
+ # broadcast -- silently skipped it, so the per-channel drop
854
+ # count was invisible to every long-running client.
855
+ status['filter_drops'] = decode_int(data, optlen)
802
856
  elif type_val == StatusType.LIFETIME:
803
857
  status['lifetime'] = decode_int(data, optlen)
804
858
  elif type_val == StatusType.DESCRIPTION:
@@ -841,6 +895,14 @@ def _encoding_name(value: Optional[int]) -> str:
841
895
  return f"UNKNOWN({value})"
842
896
 
843
897
 
898
+ #: Bounds on `RadiodControl._drain_status_socket`. Comfortably above a full
899
+ #: receive buffer's worth of status packets (~26k for a 5 MB queue), while
900
+ #: guaranteeing the drain terminates even if the group refills as fast as it
901
+ #: is read -- or if the socket is a test double that never raises.
902
+ _DRAIN_MAX_PACKETS = 50000
903
+ _DRAIN_MAX_SEC = 0.25
904
+
905
+
844
906
  class RadiodControl:
845
907
  """
846
908
  Control interface for radiod
@@ -1475,8 +1537,13 @@ class RadiodControl:
1475
1537
  encode_string(cmdbuffer, StatusType.PRESET, preset)
1476
1538
  logger.info(f"Setting preset for SSRC {ssrc} to {preset}")
1477
1539
 
1478
- # DEMOD_TYPE: 0=linear (IQ/USB/LSB/etc), 1=FM
1479
- demod_type = 0 if preset.lower() in ['iq', 'usb', 'lsb', 'cw', 'am'] else 1
1540
+ # DEMOD_TYPE must agree with what the preset just selected, because
1541
+ # radiod applies whichever arrives last and this one follows PRESET
1542
+ # in the same packet. Getting it wrong silently substitutes a
1543
+ # different demodulator: asking for "wfm" and sending FM_DEMOD gets
1544
+ # the narrowband FM demod running behind a +/-110 kHz filter, which
1545
+ # produces no output at all and reports snr=-inf forever.
1546
+ demod_type = demod_type_for_preset(preset)
1480
1547
  encode_int(cmdbuffer, StatusType.DEMOD_TYPE, demod_type)
1481
1548
  logger.info(f"Setting DEMOD_TYPE for SSRC {ssrc} to {demod_type}")
1482
1549
 
@@ -1629,7 +1696,8 @@ class RadiodControl:
1629
1696
  return True
1630
1697
 
1631
1698
  def poll_channel(self, ssrc: int, expected_freq: Optional[float] = None,
1632
- timeout: float = 2.0, frequency_tolerance: float = 1.0):
1699
+ timeout: float = 2.0, frequency_tolerance: float = 1.0,
1700
+ allow_idle: bool = False):
1633
1701
  """Targeted status poll for a single channel — O(1), not O(channels).
1634
1702
 
1635
1703
  Sends a poll naming exactly this ``ssrc`` and returns the ChannelInfo
@@ -1646,6 +1714,23 @@ class RadiodControl:
1646
1714
  status appears or the timeout elapses. With no ``expected_freq`` an
1647
1715
  obviously-empty reply (frequency 0) is rejected.
1648
1716
 
1717
+ **The 0 Hz blind spot (issue #6).** That empty reply is byte-for-byte
1718
+ what radiod also sends for a channel it *holds* at 0 Hz: one being
1719
+ reaped after :meth:`remove_channel` (radiod tunes it to 0 Hz and lets
1720
+ its lifetime run out), one parked at 0 Hz, or -- on builds that create
1721
+ channels dynamically -- one this very poll just minted. So with the
1722
+ default ``allow_idle=False`` a purging SSRC is indistinguishable from a
1723
+ free one: both return ``None``. Re-creating such an SSRC hands back
1724
+ the dying channel, which answers status and never emits RTP.
1725
+
1726
+ Pass ``allow_idle=True`` to receive that reply instead: the returned
1727
+ ``ChannelInfo`` has ``frequency == 0.0`` and proves that radiod
1728
+ currently answers for this SSRC at 0 Hz -- i.e. the SSRC is **not**
1729
+ free to reuse right now. It does *not* tell you why (reaping vs
1730
+ parked vs freshly minted), and ``None`` then means only "no reply".
1731
+ ``expected_freq`` always wins: a 0 Hz reply never matches a non-zero
1732
+ expectation, whatever ``allow_idle`` says.
1733
+
1649
1734
  This is the establishment/verify probe: after ``create_channel`` sends
1650
1735
  the create, one ``poll_channel(ssrc, expected_freq=f)`` confirms radiod
1651
1736
  accepted it and yields the *granted* encoding + RTP_TIMESNAP/GPS_TIME
@@ -1708,8 +1793,9 @@ class RadiodControl:
1708
1793
  if expected_freq is not None:
1709
1794
  if abs(freq - expected_freq) > frequency_tolerance:
1710
1795
  continue # empty or stale reply — keep waiting
1711
- elif not freq:
1796
+ elif not freq and not allow_idle:
1712
1797
  continue # radiod's "no such channel" empty status
1798
+ # -- or a channel held at 0 Hz; see #6
1713
1799
  dest = status.get("destination", {})
1714
1800
  mcast = dest.get("address", "") if isinstance(dest, dict) else ""
1715
1801
  port = dest.get("port", 0) if isinstance(dest, dict) else 0
@@ -1990,8 +2076,14 @@ class RadiodControl:
1990
2076
  # configs); the returned ChannelInfo carries the granted value, which
1991
2077
  # consumers use authoritatively. A rate/preset divergence is logged
1992
2078
  # but not fatal — the channel exists at the requested frequency.
1993
- if (channel.sample_rate != sample_rate
1994
- or channel.preset.lower() != preset.lower()):
2079
+ # An ABSENT preset is not a divergence. radiod may decline to echo
2080
+ # PRESET at all (write-only from the nopreset branch on), and a label
2081
+ # nobody sent cannot disagree with the one we asked for. Warning on
2082
+ # that would fire on every single ensure_channel call and bury the
2083
+ # real rate divergences underneath it.
2084
+ granted_preset = (getattr(channel, 'preset', None) or "").lower()
2085
+ preset_diverged = bool(granted_preset) and granted_preset != preset.lower()
2086
+ if channel.sample_rate != sample_rate or preset_diverged:
1995
2087
  logger.warning(
1996
2088
  "ensure_channel: SSRC %s granted rate=%s preset=%s "
1997
2089
  "(requested rate=%s preset=%s)",
@@ -2033,6 +2125,13 @@ class RadiodControl:
2033
2125
  - Always call this when your application is done with a channel
2034
2126
  - Especially important for long-running applications that create temporary channels
2035
2127
  - The channel may still appear in discovery for a brief time after calling this
2128
+ - Removal is asynchronous: radiod tunes the channel to 0 Hz and only
2129
+ reaps it when its lifetime runs out (build/config dependent; ~20 s
2130
+ observed). Until then a poll for this SSRC gets a 0 Hz reply that
2131
+ :meth:`poll_channel` drops by default, so the SSRC *looks* free
2132
+ while re-creating it would hand back the dying channel. Use
2133
+ ``poll_channel(ssrc, allow_idle=True)`` to see that state
2134
+ (issue #6).
2036
2135
  """
2037
2136
  _validate_ssrc(ssrc)
2038
2137
 
@@ -2162,31 +2261,81 @@ class RadiodControl:
2162
2261
 
2163
2262
  return status_sock
2164
2263
 
2264
+ @staticmethod
2265
+ def _drain_status_socket(sock) -> int:
2266
+ """Discard whatever is already queued. Returns how many were dropped.
2267
+
2268
+ The cached poll socket is joined to radiod's status group but read
2269
+ ONLY during a poll, so between polls the kernel queues the entire
2270
+ continuous status broadcast into it. Left alone it reaches the
2271
+ receive-buffer ceiling and stays there: on B4, wspr-recorder's copy
2272
+ sat permanently full and had discarded 1,254,234 packets, ~24/s.
2273
+
2274
+ Two costs, and the second is the one that matters. The socket pins
2275
+ its whole buffer in kernel memory; and every poll then has to
2276
+ recvfrom() and decode its way through thousands of stale packets
2277
+ before reaching the reply it wants, inside a 2 s timeout. Draining
2278
+ first makes a poll read only post-command traffic, which is what a
2279
+ request/response exchange on a shared multicast group requires
2280
+ anyway.
2281
+
2282
+ Correctness does not depend on this -- `poll_status` matches on
2283
+ `command_tag` and would reject stale packets regardless -- but
2284
+ latency and the drop counter do.
2285
+
2286
+ Bounded on purpose. An unbounded "read until it raises" loop becomes
2287
+ the stall it exists to prevent: on a busy group the socket can be
2288
+ refilled as fast as it is emptied, and the drain never returns. The
2289
+ caps below are far above a full buffer's worth (a 5 MB queue of
2290
+ ~200-byte status packets is ~26k) yet still terminate. This was not
2291
+ theoretical -- the first version hung the tune() test suite outright,
2292
+ because a mocked socket never raises.
2293
+ """
2294
+ n = 0
2295
+ deadline = time.monotonic() + _DRAIN_MAX_SEC
2296
+ sock.setblocking(False)
2297
+ try:
2298
+ for _ in range(_DRAIN_MAX_PACKETS):
2299
+ try:
2300
+ sock.recv(65536)
2301
+ n += 1
2302
+ except (BlockingIOError, InterruptedError):
2303
+ break
2304
+ except OSError:
2305
+ break
2306
+ if not n % 256 and time.monotonic() > deadline:
2307
+ logger.warning(
2308
+ "status drain hit its %.2fs budget after %d packets; "
2309
+ "the group is busier than the drain can empty",
2310
+ _DRAIN_MAX_SEC, n)
2311
+ break
2312
+ else:
2313
+ logger.warning(
2314
+ "status drain hit its %d-packet cap; some stale status "
2315
+ "remains queued", _DRAIN_MAX_PACKETS)
2316
+ finally:
2317
+ sock.setblocking(True)
2318
+ sock.settimeout(0.1)
2319
+ if n:
2320
+ logger.debug("drained %d stale status packet(s) before poll", n)
2321
+ return n
2322
+
2165
2323
  def _get_or_create_status_listener(self):
2324
+ """Removed: the cached status socket leaked.
2325
+
2326
+ It stayed joined to radiod's status group and unread between polls,
2327
+ so the kernel queued the continuous status broadcast into it until
2328
+ the receive buffer capped and every further packet was discarded --
2329
+ 1,254,234 of them on B4, ~24/s, with the queue permanently full.
2330
+ Draining at poll time did not help, because in steady state nothing
2331
+ polls. Each exchange now creates and closes its own socket via
2332
+ `_setup_status_listener`; `close()` still tolerates the old
2333
+ attribute so existing callers are unaffected.
2166
2334
  """
2167
- Get cached status listener socket or create new one if needed.
2168
-
2169
- This method implements socket reuse to avoid creating/destroying sockets
2170
- on every tune() call, which saves 20-30ms per operation and prevents
2171
- socket exhaustion.
2172
-
2173
- Returns:
2174
- Cached or newly created status listener socket
2175
- """
2176
- import threading
2177
-
2178
- # Lazy initialization of lock (avoid threading overhead if never used)
2179
- if self._status_sock_lock is None:
2180
- self._status_sock_lock = threading.Lock()
2181
-
2182
- with self._status_sock_lock:
2183
- if self._status_sock is None:
2184
- logger.debug("Creating cached status listener socket")
2185
- self._status_sock = self._setup_status_listener()
2186
- else:
2187
- logger.debug("Reusing cached status listener socket")
2188
- return self._status_sock
2189
-
2335
+ raise NotImplementedError(
2336
+ "the cached status socket was removed (it leaked); call "
2337
+ "_setup_status_listener() per exchange and close it")
2338
+
2190
2339
  def tune(self, ssrc: int, frequency_hz: Optional[float] = None,
2191
2340
  preset: Optional[str] = None, sample_rate: Optional[int] = None,
2192
2341
  low_edge: Optional[float] = None, high_edge: Optional[float] = None,
@@ -2326,9 +2475,16 @@ class RadiodControl:
2326
2475
 
2327
2476
  encode_eol(cmdbuffer)
2328
2477
 
2329
- # Get cached status listener (or create if first use)
2330
- # Socket is reused across tune() calls to avoid creation/destruction overhead
2331
- status_sock = self._get_or_create_status_listener()
2478
+ # One socket per exchange (see poll_status for why caching it leaked).
2479
+ # The old comment claimed reuse avoided creation overhead; the cost it
2480
+ # actually avoided was ~1 ms of setup per tune, against a socket that
2481
+ # stayed joined to the status group for the process lifetime and
2482
+ # queued the whole broadcast into a buffer nobody read.
2483
+ status_sock = self._setup_status_listener()
2484
+ # Same request/response contract as poll_status: only packets that
2485
+ # arrive after the tune command can be its reply, and wading through
2486
+ # a full buffer's worth of stale ones eats the timeout.
2487
+ self._drain_status_socket(status_sock)
2332
2488
 
2333
2489
  try:
2334
2490
  start_time = time.time()
@@ -2385,9 +2541,10 @@ class RadiodControl:
2385
2541
  raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
2386
2542
 
2387
2543
  finally:
2388
- # NOTE: Do NOT close status_sock here - it's cached for reuse
2389
- # Socket will be closed in close() method
2390
- pass
2544
+ try:
2545
+ status_sock.close()
2546
+ except OSError:
2547
+ pass
2391
2548
 
2392
2549
  def _decode_status_response(self, buffer: bytes) -> dict:
2393
2550
  """
@@ -3318,30 +3475,44 @@ class RadiodControl:
3318
3475
  encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
3319
3476
  encode_eol(cmdbuffer)
3320
3477
 
3321
- status_sock = self._get_or_create_status_listener()
3322
- start = _time.time()
3323
- last_send = 0.0
3324
- retry = 0.1
3325
- while _time.time() - start < timeout:
3326
- now = _time.time()
3327
- if now - last_send >= retry:
3328
- self.send_command(cmdbuffer)
3329
- last_send = now
3330
- retry = min(retry * 2, 1.0)
3331
- remaining = timeout - (now - start)
3332
- ready = select.select([status_sock], [], [], min(retry, remaining, 0.5))
3333
- if not ready[0]:
3334
- continue
3478
+ # One socket per exchange, closed on the way out. Caching it for
3479
+ # the process lifetime left it joined to the status group and
3480
+ # unread between polls, so the kernel queued radiod's continuous
3481
+ # broadcast into it until the buffer capped: on B4 that socket sat
3482
+ # permanently full having discarded 1.25M packets. Draining at poll
3483
+ # time cannot fix that, because in steady state nothing polls.
3484
+ status_sock = self._setup_status_listener()
3485
+ try:
3486
+ # Only traffic that arrives AFTER the command can be its reply.
3487
+ self._drain_status_socket(status_sock)
3488
+ start = _time.time()
3489
+ last_send = 0.0
3490
+ retry = 0.1
3491
+ while _time.time() - start < timeout:
3492
+ now = _time.time()
3493
+ if now - last_send >= retry:
3494
+ self.send_command(cmdbuffer)
3495
+ last_send = now
3496
+ retry = min(retry * 2, 1.0)
3497
+ remaining = timeout - (now - start)
3498
+ ready = select.select([status_sock], [], [], min(retry, remaining, 0.5))
3499
+ if not ready[0]:
3500
+ continue
3501
+ try:
3502
+ buf, _addr = status_sock.recvfrom(8192)
3503
+ except socket.timeout:
3504
+ continue
3505
+ st = decode_status_packet(buf)
3506
+ if st is None:
3507
+ continue
3508
+ if st.ssrc == ssrc and st.command_tag == command_tag:
3509
+ return st
3510
+ raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
3511
+ finally:
3335
3512
  try:
3336
- buf, _addr = status_sock.recvfrom(8192)
3337
- except socket.timeout:
3338
- continue
3339
- st = decode_status_packet(buf)
3340
- if st is None:
3341
- continue
3342
- if st.ssrc == ssrc and st.command_tag == command_tag:
3343
- return st
3344
- raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
3513
+ status_sock.close()
3514
+ except OSError:
3515
+ pass
3345
3516
 
3346
3517
  def listen_status(self, callback, duration: Optional[float] = None,
3347
3518
  ssrcs: Optional[set] = None):
@@ -3356,28 +3527,38 @@ class RadiodControl:
3356
3527
  import time as _time
3357
3528
  from .status import decode_status_packet
3358
3529
 
3359
- status_sock = self._get_or_create_status_listener()
3360
- start = _time.time()
3361
- while True:
3362
- if duration is not None and _time.time() - start >= duration:
3363
- return
3364
- remaining = None if duration is None else max(0.0, duration - (_time.time() - start))
3365
- ready = select.select([status_sock], [], [], 0.5 if remaining is None else min(0.5, remaining))
3366
- if not ready[0]:
3367
- continue
3368
- try:
3369
- buf, _addr = status_sock.recvfrom(8192)
3370
- except socket.timeout:
3371
- continue
3372
- st = decode_status_packet(buf)
3373
- if st is None or st.ssrc is None:
3374
- continue
3375
- if ssrcs is not None and st.ssrc not in ssrcs:
3376
- continue
3530
+ # Passive listener: its own socket for the life of the call, then
3531
+ # closed. Unlike the request/response paths this one is read
3532
+ # continuously, so it never accumulates -- but it must not share a
3533
+ # cached socket that outlives the call either.
3534
+ status_sock = self._setup_status_listener()
3535
+ try:
3536
+ start = _time.time()
3537
+ while True:
3538
+ if duration is not None and _time.time() - start >= duration:
3539
+ return
3540
+ remaining = None if duration is None else max(0.0, duration - (_time.time() - start))
3541
+ ready = select.select([status_sock], [], [], 0.5 if remaining is None else min(0.5, remaining))
3542
+ if not ready[0]:
3543
+ continue
3544
+ try:
3545
+ buf, _addr = status_sock.recvfrom(8192)
3546
+ except socket.timeout:
3547
+ continue
3548
+ st = decode_status_packet(buf)
3549
+ if st is None or st.ssrc is None:
3550
+ continue
3551
+ if ssrcs is not None and st.ssrc not in ssrcs:
3552
+ continue
3553
+ try:
3554
+ callback(st)
3555
+ except Exception as exc:
3556
+ logger.warning(f"listen_status callback raised: {exc}")
3557
+ finally:
3377
3558
  try:
3378
- callback(st)
3379
- except Exception as exc:
3380
- logger.warning(f"listen_status callback raised: {exc}")
3559
+ status_sock.close()
3560
+ except OSError:
3561
+ pass
3381
3562
 
3382
3563
  # ── Continuous STATUS listener (v3.16.0+) ──────────────────────
3383
3564
 
ka9q/discovery.py CHANGED
@@ -82,6 +82,31 @@ class ChannelInfo:
82
82
  # per-band abs-divergence check remains the backstop for real sustained drift.
83
83
  anchor_step_threshold_sec: float = 0.75 # |move| beyond this ⇒ a step (configurable)
84
84
 
85
+ # radiod's cumulative count of output blocks this channel's demod thread
86
+ # lapped (FILTER_DROPS). Appended last so the addition is purely
87
+ # additive — no existing positional construction shifts.
88
+ #
89
+ # CUMULATIVE since the radiod process started, so consumers must
90
+ # DIFFERENCE it; a level on its own says nothing about current loss.
91
+ # ``None`` until a STATUS broadcast carrying the tag has been seen —
92
+ # absent and "zero drops" are different claims and must not be
93
+ # conflated.
94
+ filter_drops: Optional[int] = None
95
+
96
+ # ⛔ APPENDED, deliberately. These belong logically beside `preset`, but
97
+ # inserting them there would shift the position of every field after it
98
+ # and break any client constructing a ChannelInfo positionally. The
99
+ # contract test caught exactly that. Logical grouping is not worth a
100
+ # silent argument shift in five downstream repos.
101
+ #
102
+ # The parameter vector radiod treats as definitive: demod_type and
103
+ # output_channels together say whether the payload carries complex or
104
+ # real samples. The preset string above is a convenience label that a
105
+ # later per-parameter tweak can leave stale, and which radiod may stop
106
+ # echoing entirely. None when the status packet did not carry them.
107
+ demod_type: Optional[int] = None
108
+ output_channels: Optional[int] = None
109
+
85
110
  def __post_init__(self):
86
111
  # Seed the atomic-pair snapshot from the constructor args if both
87
112
  # were provided. Lets ``get_anchor`` return the construction-time
@@ -324,7 +349,9 @@ def discover_channels_native(status_address: str, listen_duration: float = 2.0,
324
349
  port=port,
325
350
  gps_time=status.get('gps_time'),
326
351
  rtp_timesnap=status.get('rtp_timesnap'),
327
- encoding=status.get('encoding', 0)
352
+ encoding=status.get('encoding', 0),
353
+ demod_type=status.get('demod_type'),
354
+ output_channels=status.get('output_channels'),
328
355
  )
329
356
 
330
357
  # Store or update channel info
ka9q/managed_stream.py CHANGED
@@ -133,6 +133,7 @@ class ManagedStream:
133
133
  samples_per_packet: int = 320,
134
134
  resequence_buffer_size: int = 64,
135
135
  deliver_interval_packets: int = 10,
136
+ raw_payloads: bool = False,
136
137
  ):
137
138
  """
138
139
  Initialize ManagedStream.
@@ -154,6 +155,10 @@ class ManagedStream:
154
155
  max_restore_attempts: Max restore attempts, 0=unlimited (default: 0)
155
156
  samples_per_packet: Expected samples per RTP packet
156
157
  resequence_buffer_size: Packets to buffer for resequencing
158
+ raw_payloads: Deliver undecoded RTP payloads (List[bytes], one per
159
+ packet) instead of samples — required for the framed encodings
160
+ OPUS/OPUS_VOIP/AX25, which have no raw-sample representation.
161
+ See :class:`ka9q.stream.RadiodStream` for the full contract.
157
162
  deliver_interval_packets: Deliver to callback every N packets
158
163
  """
159
164
  self._control = control
@@ -179,6 +184,7 @@ class ManagedStream:
179
184
  self._samples_per_packet = samples_per_packet
180
185
  self._resequence_buffer_size = resequence_buffer_size
181
186
  self._deliver_interval_packets = deliver_interval_packets
187
+ self._raw_payloads = raw_payloads
182
188
 
183
189
  # State
184
190
  self._state = StreamState.STOPPED
@@ -305,6 +311,7 @@ class ManagedStream:
305
311
  samples_per_packet=self._samples_per_packet,
306
312
  resequence_buffer_size=self._resequence_buffer_size,
307
313
  deliver_interval_packets=self._deliver_interval_packets,
314
+ raw_payloads=self._raw_payloads,
308
315
  )
309
316
  self._stream.start()
310
317
 
ka9q/multi_stream.py CHANGED
@@ -52,7 +52,7 @@ import numpy as np
52
52
 
53
53
  from .discovery import ChannelInfo
54
54
  from .resequencer import PacketResequencer, RTPPacket
55
- from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
55
+ from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_utc
56
56
  from .stream import ENCODING_SAMPLE_BYTES, SampleCallback, parse_rtp_samples
57
57
  from .stream_quality import GapEvent, GapSource, StreamQuality
58
58
 
@@ -512,7 +512,7 @@ class MultiStream:
512
512
  continue
513
513
 
514
514
  # Wallclock
515
- wallclock = rtp_to_wallclock(header.timestamp, slot.channel_info)
515
+ wallclock = rtp_to_utc(header.timestamp, slot.channel_info)
516
516
 
517
517
  # Resequencer
518
518
  packet = RTPPacket(
@@ -524,11 +524,17 @@ class MultiStream:
524
524
  )
525
525
  output, gaps = slot.resequencer.process_packet(packet)
526
526
 
527
+ # Harvest gaps unconditionally. A radiod zero-block marker
528
+ # can be reported for a packet that produced no output yet
529
+ # (arrived out of order and is still buffered); collecting
530
+ # only inside the branch below discarded it, and the stall
531
+ # went silent one layer further up.
532
+ slot.gap_buffer.extend(gaps)
533
+
527
534
  if output is not None and len(output) > 0:
528
535
  if not slot.sample_buffer:
529
536
  slot.pending_rtp_start = slot.resequencer.last_chunk_rtp_start
530
537
  slot.sample_buffer.append(output)
531
- slot.gap_buffer.extend(gaps)
532
538
  slot.packets_since_delivery += 1
533
539
 
534
540
  if slot.packets_since_delivery >= slot.deliver_interval:
@@ -617,6 +623,27 @@ class MultiStream:
617
623
 
618
624
  # ── delivery ──
619
625
 
626
+ @staticmethod
627
+ def _account_delivery(slot: _ChannelSlot, combined, gaps) -> None:
628
+ """Book one delivered batch against the completeness counters.
629
+
630
+ `completeness_pct` computes
631
+ `(delivered - gaps_filled) / expected`, but MultiStream only ever
632
+ set `delivered`. With `expected` left at 0 the property took its
633
+ early return and reported a hardcoded 100.0 no matter what was
634
+ lost — on every recorder built on MultiStream, which is all the
635
+ multiplexing ones. AC0G-B4 2026-08-15 logged 42 gaps totalling
636
+ 93,600 samples in 25 minutes while completeness read 100.0%.
637
+
638
+ Gap fills keep the sample count whole, so `expected` tracks
639
+ `delivered` and the loss is carried by `gaps_filled`.
640
+ """
641
+ n = len(combined)
642
+ slot.quality.total_samples_delivered += n
643
+ slot.quality.total_samples_expected += n
644
+ slot.quality.total_gaps_filled += sum(
645
+ g.duration_samples for g in gaps)
646
+
620
647
  def _deliver(self, slot: _ChannelSlot) -> None:
621
648
  if not slot.sample_buffer or slot.on_samples is None:
622
649
  slot.sample_buffer.clear()
@@ -628,7 +655,7 @@ class MultiStream:
628
655
  combined = np.concatenate(slot.sample_buffer)
629
656
  n = len(combined)
630
657
 
631
- slot.quality.total_samples_delivered += n
658
+ self._account_delivery(slot, combined, slot.gap_buffer)
632
659
  slot.quality.batch_samples_delivered = n
633
660
  slot.quality.batch_gaps = list(slot.gap_buffer)
634
661
  slot.quality.sample_rate = slot.sample_rate
@@ -0,0 +1,139 @@
1
+ """Escalating recovery from a radiod restart.
2
+
3
+ ``MultiStream`` restores a slot whose stream merely stopped. It cannot
4
+ help when radiod itself went away and came back, because the channel the
5
+ slot refers to no longer exists: radiod recreates SSRCs at Template
6
+ defaults with TTL=0 after a brief outage, the per-channel verify times
7
+ out, and the slot stays stale forever while the client goes on looking
8
+ healthy.
9
+
10
+ Recovering from that needs a second tier -- re-issue the channel
11
+ requests -- and a third when even that fails -- tear the source down and
12
+ reconnect. wspr-recorder worked this out and kept it in its own
13
+ repository. The cost of that showed on AC0G-B4 on 2026-08-30: after a
14
+ radiod outage, wspr re-provisioned 17 stale channels and recovered on
15
+ its own, while meteor-scatter, which has only the tier-1 callback, sat
16
+ dead for twenty-two hours until a human noticed. psk-recorder has the
17
+ same gap. One client in three solved it, and the other two failed
18
+ silently.
19
+
20
+ The POLICY belongs here, because recovering from a radiod restart is a
21
+ property of the clients<->radiod interface and that interface is what
22
+ this package is. The MECHANISMS stay with the client: what it means to
23
+ re-provision depends on that client's own configuration, which this
24
+ package should not know. So the ladder decides *what* should happen and
25
+ the caller decides *how*.
26
+
27
+ Typical use, from whatever loop the client already runs::
28
+
29
+ ladder = RecoveryLadder()
30
+ ...
31
+ action = ladder.observe(healthy=(active == expected))
32
+ if action is RecoveryAction.REPROVISION:
33
+ rm.reprovision_stale()
34
+ elif action is RecoveryAction.FULL_RESET:
35
+ rm.full_reset()
36
+ elif action is RecoveryAction.RESTART_SELF:
37
+ log.critical("in-process repair exhausted after %d attempts; "
38
+ "exiting for systemd to replace us",
39
+ ladder.consecutive_degraded)
40
+ raise SystemExit(1) # Restart=always does the rest
41
+
42
+ One ladder per source. A client with two radiods keeps two, so a fault
43
+ on one never escalates the other.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import enum
48
+
49
+ __all__ = ["RecoveryAction", "RecoveryLadder"]
50
+
51
+
52
+ class RecoveryAction(enum.Enum):
53
+ """What the ladder asks the caller to do about this observation."""
54
+
55
+ NONE = "none"
56
+ REPROVISION = "reprovision"
57
+ FULL_RESET = "full_reset"
58
+ #: In-process remedies are exhausted; the PROCESS must be replaced.
59
+ #:
60
+ #: ⛔ OPT-IN (`restart_after=None` by default). An action a caller does
61
+ #: not handle falls through its if/elif chain and does NOTHING, which is
62
+ #: silent inaction — the exact failure this ladder exists to prevent. So
63
+ #: a caller asks for this rung only once it can act on it.
64
+ RESTART_SELF = "restart_self"
65
+
66
+
67
+ class RecoveryLadder:
68
+ """Consecutive-degraded counter with two escalation thresholds.
69
+
70
+ The counter clears the instant a source returns to full health, so a
71
+ single bad check followed by recovery never ratchets a later,
72
+ unrelated incident toward a full reset.
73
+ """
74
+
75
+ DEFAULT_REPROVISION_AFTER = 1
76
+ DEFAULT_FULL_RESET_AFTER = 3
77
+
78
+ def __init__(
79
+ self,
80
+ reprovision_after: int = DEFAULT_REPROVISION_AFTER,
81
+ full_reset_after: int = DEFAULT_FULL_RESET_AFTER,
82
+ restart_after: int | None = None,
83
+ ) -> None:
84
+ if full_reset_after < reprovision_after:
85
+ raise ValueError(
86
+ f"full_reset_after ({full_reset_after}) must not precede "
87
+ f"reprovision_after ({reprovision_after}): a ladder whose "
88
+ f"top rung comes first is not a ladder"
89
+ )
90
+ if restart_after is not None and restart_after < full_reset_after:
91
+ raise ValueError(
92
+ f"restart_after ({restart_after}) must not precede "
93
+ f"full_reset_after ({full_reset_after}): replacing the "
94
+ f"process is the LAST resort, not an earlier one"
95
+ )
96
+ self.reprovision_after = int(reprovision_after)
97
+ self.full_reset_after = int(full_reset_after)
98
+ self.restart_after = (None if restart_after is None
99
+ else int(restart_after))
100
+ self.consecutive_degraded = 0
101
+ #: Totals since construction, so an operator can tell a station
102
+ #: that is quietly self-healing from one that is merely quiet.
103
+ self.reprovisions = 0
104
+ self.full_resets = 0
105
+ self.restarts_requested = 0
106
+
107
+ @property
108
+ def escalating(self) -> bool:
109
+ """True while a fault is open (at least one degraded check)."""
110
+ return self.consecutive_degraded > 0
111
+
112
+ def observe(self, healthy: bool) -> RecoveryAction:
113
+ """Record one health check and return the action it calls for."""
114
+ if healthy:
115
+ self.consecutive_degraded = 0
116
+ return RecoveryAction.NONE
117
+
118
+ self.consecutive_degraded += 1
119
+ n = self.consecutive_degraded
120
+ if self.restart_after is not None and n >= self.restart_after:
121
+ # Checked before FULL_RESET because it is the higher rung: once
122
+ # in-process repair has demonstrably failed this many times,
123
+ # repeating it is the behaviour we are fixing.
124
+ self.restarts_requested += 1
125
+ return RecoveryAction.RESTART_SELF
126
+ if n >= self.full_reset_after:
127
+ # Stays here rather than giving up. A client that cannot
128
+ # recover must keep trying: going quiet is the exact failure
129
+ # this ladder exists to prevent.
130
+ self.full_resets += 1
131
+ return RecoveryAction.FULL_RESET
132
+ if n >= self.reprovision_after:
133
+ self.reprovisions += 1
134
+ return RecoveryAction.REPROVISION
135
+ return RecoveryAction.NONE
136
+
137
+ def reset(self) -> None:
138
+ """Forget the current fault (not the totals)."""
139
+ self.consecutive_degraded = 0
ka9q/resequencer.py CHANGED
@@ -157,11 +157,47 @@ class PacketResequencer:
157
157
  self.stats.packets_duplicate += 1
158
158
  return None, []
159
159
 
160
+ # radiod zero-fills a dropped output block, so this packet may
161
+ # carry synthetic samples with a perfectly contiguous timestamp.
162
+ # Detect it here, before the resequencer -- which by definition
163
+ # cannot see it, there being no timestamp gap to find.
164
+ zero_gap = self._detect_zero_block(packet)
165
+
160
166
  # Add to buffer
161
167
  self._add_to_buffer(packet)
162
168
 
163
169
  # Try to output packets in sequence order
164
- return self._try_output()
170
+ samples, gaps = self._try_output()
171
+ if zero_gap is not None:
172
+ gaps = list(gaps) + [zero_gap]
173
+ return samples, gaps
174
+
175
+ def _detect_zero_block(self, packet: RTPPacket) -> Optional[GapEvent]:
176
+ """A packet of nothing but exact zeros is radiod's drop marker.
177
+
178
+ Cheap on the hot path: real samples almost always have a
179
+ non-zero first element, so the full scan is only reached for
180
+ packets that are already suspect.
181
+ """
182
+ samples = packet.samples
183
+ if samples is None or len(samples) == 0:
184
+ return None
185
+ if samples[0] != 0 or samples.any():
186
+ return None
187
+ n = len(samples)
188
+ self.stats.samples_filled += n
189
+ logger.warning(
190
+ f"radiod block drop: {n} zero samples "
191
+ f"({n / self.sample_rate * 1000:.1f} ms) at seq={packet.sequence} "
192
+ f"-- synthetic, not received signal"
193
+ )
194
+ return GapEvent(
195
+ source=GapSource.RADIOD_BLOCK_DROP,
196
+ position_samples=self.cumulative_samples,
197
+ duration_samples=n,
198
+ timestamp_utc=datetime.now(timezone.utc).isoformat(),
199
+ packets_affected=1,
200
+ )
165
201
 
166
202
  def _initialize(self, packet: RTPPacket):
167
203
  """Initialize sequencer with first packet"""
ka9q/rtp_recorder.py CHANGED
@@ -16,6 +16,7 @@ import socket
16
16
  import struct
17
17
  import logging
18
18
  import time
19
+ import warnings
19
20
  from enum import Enum
20
21
  from typing import Optional, Callable, NamedTuple, Dict, Any
21
22
  from dataclasses import dataclass
@@ -242,9 +243,24 @@ def rtp_to_utc(
242
243
  return wall_time_ns / BILLION
243
244
 
244
245
 
245
- # Deprecated alias — kept so existing callers keep working. Prefer
246
- # ``rtp_to_utc`` (the reference is RTP/GPS, not the host wall clock).
247
- rtp_to_wallclock = rtp_to_utc
246
+ def rtp_to_wallclock(
247
+ rtp_timestamp: int,
248
+ channel: ChannelInfo,
249
+ wallclock_hint_sec: Optional[float] = None,
250
+ ) -> Optional[float]:
251
+ """Deprecated alias for :func:`rtp_to_utc` (renamed 2026-06-27).
252
+
253
+ Identical signature and behavior. Emits ``DeprecationWarning`` on
254
+ every call (audit finding F16) — the in-tree sigmond clients migrated
255
+ 2026-08; this is the visible signal for any remaining callers.
256
+ """
257
+ warnings.warn(
258
+ "ka9q rtp_to_wallclock() is deprecated; use rtp_to_utc() "
259
+ "(same signature and behavior, renamed 2026-06-27)",
260
+ DeprecationWarning,
261
+ stacklevel=2,
262
+ )
263
+ return rtp_to_utc(rtp_timestamp, channel, wallclock_hint_sec)
248
264
 
249
265
 
250
266
  class RTPRecorder:
@@ -479,7 +495,7 @@ class RTPRecorder:
479
495
  payload = data[header_len:]
480
496
 
481
497
  # Compute wall-clock time
482
- wallclock = rtp_to_wallclock(header.timestamp, self.channel)
498
+ wallclock = rtp_to_utc(header.timestamp, self.channel)
483
499
 
484
500
  # Call packet callback
485
501
  if self.on_packet:
ka9q/status_listener.py CHANGED
@@ -475,6 +475,15 @@ class StatusListener:
475
475
  if enc is not None:
476
476
  ci.encoding = enc
477
477
 
478
+ # Cumulative per-channel output block drops. Refreshed here so a
479
+ # long-running client can watch radiod's own loss counter without
480
+ # opening a second status socket -- differencing it across two
481
+ # reads is the honest measurement; the level alone is meaningless
482
+ # because it accumulates from radiod start.
483
+ drops = status.get("filter_drops")
484
+ if drops is not None:
485
+ ci.filter_drops = drops
486
+
478
487
  self.stats.updates_applied += 1
479
488
 
480
489
  for cb in per_ssrc_callbacks:
ka9q/stream.py CHANGED
@@ -36,9 +36,11 @@ import numpy as np
36
36
  from datetime import datetime, timezone
37
37
  from typing import Optional, Callable, List
38
38
 
39
+ from .types import DemodType
40
+
39
41
  from ._multicast import join_multicast_all_interfaces
40
42
  from .discovery import ChannelInfo
41
- from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
43
+ from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_utc
42
44
  from .resequencer import PacketResequencer, RTPPacket
43
45
  from .stream_quality import GapSource, GapEvent, StreamQuality
44
46
  from .types import Encoding
@@ -181,6 +183,46 @@ def _decode_to_float32(payload: bytes, encoding: int, *, is_iq: bool = False) ->
181
183
  return np.frombuffer(payload, dtype='<f4').astype(np.float32, copy=False)
182
184
 
183
185
 
186
+ def _derive_is_iq(demod_type, output_channels, preset) -> bool:
187
+ """Does this channel's payload carry complex samples?
188
+
189
+ Decided from the parameter vector radiod treats as definitive, and only
190
+ from the preset label when the vector is absent.
191
+
192
+ Phil Karn, 2026-09-21: "the individual parameters [are] definitive, with
193
+ a preset just being a convenient shorthand for loading a specific
194
+ predefined set of them into the channel." A preset can be tweaked out of
195
+ truth -- send PRESET USB, retune the filters to -3000,-50, and you have
196
+ the lower sideband while the label still reads "usb" -- and from the
197
+ nopreset branch onward radiod may not echo it at all. Reading the label
198
+ then yields 'unknown', flips this flag false, and every complex stream is
199
+ parsed as audio at half the samples per packet, with nothing raised and
200
+ nothing logged.
201
+
202
+ ⛔ Two output channels does NOT mean complex on its own. ka9q-radio
203
+ docs/table.csv: OUTPUT_CHANNELS is "1 or 2 in Linear and WFM, 1 in FM",
204
+ so a WFM channel with two of them is carrying stereo AUDIO. Only under
205
+ the linear demodulator do two channels mean I and Q.
206
+ """
207
+ if demod_type is not None:
208
+ if demod_type in (int(DemodType.SPECT_DEMOD),
209
+ int(DemodType.SPECT2_DEMOD)):
210
+ # Unchanged from the preset-based behaviour. Whether spectrum
211
+ # framing truly matches IQ is a separate question; do not answer
212
+ # it silently while fixing something else.
213
+ return True
214
+ if demod_type == int(DemodType.LINEAR_DEMOD):
215
+ if output_channels is not None:
216
+ return output_channels == 2
217
+ # Linear with no channel count: fall through to the label rather
218
+ # than guess, since linear covers both IQ and every real mode.
219
+ else:
220
+ return False # FM, WFM -- never complex, whatever the count
221
+
222
+ label = (preset or "").lower()
223
+ return label in ('iq', 'spectrum')
224
+
225
+
184
226
  class OpusDecoder:
185
227
  """Optional Opus → float32 decoder for radiod OPUS / OPUS_VOIP streams.
186
228
 
@@ -263,6 +305,7 @@ class RadiodStream:
263
305
  samples_per_packet: int = 320,
264
306
  resequence_buffer_size: int = 64,
265
307
  deliver_interval_packets: int = 10,
308
+ raw_payloads: bool = False,
266
309
  ):
267
310
  """
268
311
  Initialize RadiodStream.
@@ -273,11 +316,31 @@ class RadiodStream:
273
316
  samples_per_packet: Expected samples per RTP packet (320 @ 16kHz)
274
317
  resequence_buffer_size: Packets to buffer for resequencing (64 = ~2s)
275
318
  deliver_interval_packets: Deliver to callback every N packets (batching)
319
+ raw_payloads: Deliver undecoded RTP payloads instead of samples.
320
+ ``on_samples`` then receives a ``List[bytes]`` — one entry per
321
+ RTP packet, in arrival order — rather than an ``np.ndarray``.
322
+
323
+ This is the transport mode for the *framed* encodings that
324
+ ``parse_rtp_samples()`` deliberately does not decode: OPUS,
325
+ OPUS_VOIP and AX25. Without it those payloads are dropped
326
+ (``_parse_samples`` returns None) and the callback never fires.
327
+
328
+ Use it when the consumer wants the codec frames themselves —
329
+ forwarding Opus to a browser's WebCodecs decoder, or writing
330
+ an Ogg/AX25 file — rather than PCM. To get PCM from an Opus
331
+ channel instead, decode the frames with :class:`OpusDecoder`.
332
+
333
+ The resequencer is bypassed: a codec frame is opaque, so it
334
+ can be neither concatenated nor zero-filled, and gap
335
+ concealment belongs to the decoder (Opus has built-in PLC).
336
+ Loss/reorder therefore surface to the consumer, which for a
337
+ sequenced transport like TCP/WebSocket is exactly right.
276
338
  """
277
339
  self.channel = channel
278
340
  self.on_samples = on_samples
279
341
  self.samples_per_packet = samples_per_packet
280
342
  self.deliver_interval_packets = deliver_interval_packets
343
+ self.raw_payloads = raw_payloads
281
344
 
282
345
  # Resequencer
283
346
  self.resequencer = PacketResequencer(
@@ -289,7 +352,8 @@ class RadiodStream:
289
352
  # Quality tracking
290
353
  self.quality = StreamQuality()
291
354
 
292
- # Sample accumulator for batched delivery
355
+ # Sample accumulator for batched delivery. In raw_payloads mode the
356
+ # entries are `bytes` (one RTP payload each) instead of arrays.
293
357
  self._sample_buffer: List[np.ndarray] = []
294
358
  self._gap_buffer: List[GapEvent] = []
295
359
  self._pending_rtp_start = None
@@ -305,10 +369,14 @@ class RadiodStream:
305
369
  self._max_reconnect_backoff = 60.0 # Max backoff
306
370
  self._consecutive_errors = 0
307
371
 
308
- # Detect payload format from channel preset
372
+ # Detect payload format from the channel's PARAMETERS, not its label.
309
373
  # IQ mode: complex64 (interleaved float32 I/Q)
310
374
  # Audio modes: float32 (mono or stereo)
311
- self._is_iq = channel.preset.lower() in ('iq', 'spectrum')
375
+ self._is_iq = _derive_is_iq(
376
+ getattr(channel, 'demod_type', None),
377
+ getattr(channel, 'output_channels', None),
378
+ getattr(channel, 'preset', None),
379
+ )
312
380
 
313
381
  # Payload samples differ from RTP timestamp increment in IQ mode
314
382
  # IQ: 160 complex samples per packet, but timestamp advances by 320
@@ -359,12 +427,17 @@ class RadiodStream:
359
427
  self._thread.join(timeout=5.0)
360
428
  self._thread = None
361
429
 
362
- # Flush resequencer
363
- final_samples, final_gaps = self.resequencer.flush()
364
- if len(final_samples) > 0 or final_gaps:
365
- self._sample_buffer.append(final_samples)
366
- self._gap_buffer.extend(final_gaps)
430
+ # Flush resequencer. In raw_payloads mode it never ran, and its
431
+ # ndarray output must not be mixed into a buffer of codec frames —
432
+ # just deliver whatever frames are still pending.
433
+ if self.raw_payloads:
367
434
  self._deliver_samples()
435
+ else:
436
+ final_samples, final_gaps = self.resequencer.flush()
437
+ if len(final_samples) > 0 or final_gaps:
438
+ self._sample_buffer.append(final_samples)
439
+ self._gap_buffer.extend(final_gaps)
440
+ self._deliver_samples()
368
441
 
369
442
  logger.info(
370
443
  f"RadiodStream stopped. Completeness: {self.quality.completeness_pct:.1f}%, "
@@ -581,13 +654,28 @@ class RadiodStream:
581
654
  self._record_empty_payload(header)
582
655
  return
583
656
 
657
+ # Raw-payload mode: the payload is an opaque codec frame. Buffer it
658
+ # verbatim and skip both the sample parser and the resequencer, which
659
+ # can only operate on numeric samples.
660
+ if self.raw_payloads:
661
+ self._sample_buffer.append(payload)
662
+ self._packets_since_delivery += 1
663
+ if self._packets_since_delivery >= self.deliver_interval_packets:
664
+ self._deliver_samples()
665
+ wallclock = rtp_to_utc(header.timestamp, self.channel)
666
+ if wallclock:
667
+ self.quality.last_packet_utc = datetime.fromtimestamp(
668
+ wallclock, tz=timezone.utc
669
+ ).isoformat()
670
+ return
671
+
584
672
  # Parse samples from payload
585
673
  samples = self._parse_samples(payload)
586
674
  if samples is None:
587
675
  return
588
676
 
589
677
  # Get wallclock time
590
- wallclock = rtp_to_wallclock(header.timestamp, self.channel)
678
+ wallclock = rtp_to_utc(header.timestamp, self.channel)
591
679
 
592
680
  # Create packet for resequencer
593
681
  packet = RTPPacket(
@@ -649,28 +737,40 @@ class RadiodStream:
649
737
  if not self._sample_buffer:
650
738
  return
651
739
 
652
- # Combine samples
653
- samples = np.concatenate(self._sample_buffer)
740
+ # Combine samples. Opaque codec frames stay a list of bytes — they
741
+ # have no numeric representation to concatenate — and their "sample"
742
+ # count is a frame count.
743
+ if self.raw_payloads:
744
+ samples = list(self._sample_buffer)
745
+ n_units = len(samples)
746
+ else:
747
+ samples = np.concatenate(self._sample_buffer)
748
+ n_units = len(samples)
654
749
  gaps = list(self._gap_buffer)
655
750
 
656
751
  # Update quality for this batch
657
752
  batch_start = self.quality.total_samples_delivered
658
753
  self.quality.batch_start_sample = batch_start
659
754
  self.quality.delivered_rtp_start = self._pending_rtp_start
660
- self.quality.batch_samples_delivered = len(samples)
755
+ self.quality.batch_samples_delivered = n_units
661
756
  self.quality.batch_gaps = gaps
662
- self.quality.total_samples_delivered += len(samples)
757
+ self.quality.total_samples_delivered += n_units
663
758
 
664
- # Update expected samples (based on actual payload samples per packet)
665
- self.quality.total_samples_expected = (
666
- self.quality.rtp_packets_received * self._payload_samples_per_packet
667
- )
668
-
669
- # Update RTP loss stats from resequencer
670
- reseq_stats = self.resequencer.get_stats()
671
- self.quality.rtp_packets_lost = reseq_stats.get('gaps_detected', 0)
672
- self.quality.rtp_packets_resequenced = reseq_stats.get('packets_resequenced', 0)
673
- self.quality.rtp_packets_duplicate = reseq_stats.get('packets_duplicate', 0)
759
+ if self.raw_payloads:
760
+ # One delivered unit per RTP packet, so "expected" is the packet
761
+ # count. Resequencer stats stay zero: it never ran.
762
+ self.quality.total_samples_expected = self.quality.rtp_packets_received
763
+ else:
764
+ # Update expected samples (based on actual payload samples per packet)
765
+ self.quality.total_samples_expected = (
766
+ self.quality.rtp_packets_received * self._payload_samples_per_packet
767
+ )
768
+
769
+ # Update RTP loss stats from resequencer
770
+ reseq_stats = self.resequencer.get_stats()
771
+ self.quality.rtp_packets_lost = reseq_stats.get('gaps_detected', 0)
772
+ self.quality.rtp_packets_resequenced = reseq_stats.get('packets_resequenced', 0)
773
+ self.quality.rtp_packets_duplicate = reseq_stats.get('packets_duplicate', 0)
674
774
 
675
775
  # Clear buffers
676
776
  self._sample_buffer = []
ka9q/stream_quality.py CHANGED
@@ -32,6 +32,19 @@ class GapSource(Enum):
32
32
 
33
33
  RESEQUENCE_TIMEOUT = "resequence_timeout"
34
34
  """Packet arrived after resequence window expired"""
35
+
36
+ RADIOD_BLOCK_DROP = "radiod_block_drop"
37
+ """radiod dropped a filter output block and emitted zeros in its place.
38
+
39
+ Since ka9q-radio 55d9048d (2026.08.15-1-trixie1) a lapped output
40
+ block is counted and zero-filled rather than skipped, which keeps
41
+ sample counts honest but leaves RTP timestamps CONTIGUOUS -- so no
42
+ timestamp gap exists for the resequencer to find. The zeros
43
+ themselves are the signal: a solid block of exact IEEE-754 zeros
44
+ across every sample does not occur in real RF, where ~144 dB of
45
+ instantaneous float dynamic range means noise always occupies the
46
+ low bits. Distinct from NETWORK_LOSS: the packets DID arrive, and
47
+ the samples they carry are synthetic rather than missing."""
35
48
 
36
49
  EMPTY_PAYLOAD = "empty_payload"
37
50
  """RTP packet received but payload was empty/zeros"""
ka9q/types.py CHANGED
@@ -2,7 +2,7 @@
2
2
  ka9q-radio protocol types and constants
3
3
 
4
4
  Auto-generated by scripts/sync_types.py from ka9q-radio C headers.
5
- Validated against ka9q-radio commit: cedec349f7b4
5
+ Validated against ka9q-radio commit: 401992cdecaa
6
6
 
7
7
  DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
8
8
  """
@@ -158,9 +158,9 @@ class Encoding:
158
158
  F16 = F16LE
159
159
 
160
160
 
161
- # Demodulator types — auto-generated from ka9q-radio/src/radio.h
161
+ # Demodulator types — auto-generated from ka9q-radio (see ENUM_SEARCH_PATHS)
162
162
  class DemodType:
163
- """Demodulator types — values must match ka9q-radio/src/radio.h enum demod_type"""
163
+ """Demodulator types — values must match ka9q-radio enum demod_type (status.h since 2026-09-19)"""
164
164
 
165
165
  INVALID_DEMOD = -1 # used as sentinel
166
166
  LINEAR_DEMOD = 0 # Linear demodulation, i.e., everything else: SSB, CW, DSB, CAM, IQ
@@ -176,6 +176,7 @@ class DemodType:
176
176
  class WindowType:
177
177
  """FFT window types — values must match ka9q-radio/src/window.h enum window_type"""
178
178
 
179
+ INVALID_WINDOW = -1
179
180
  KAISER_WINDOW = 0
180
181
  RECT_WINDOW = 1 # essentially kaiser with beta = 0
181
182
  BLACKMAN_WINDOW = 2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.22.0
3
+ Version: 3.26.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Author-email: Michael Hauan AC0G <ac0g@hauan.org>
6
6
  License: MIT
@@ -0,0 +1,30 @@
1
+ ka9q/__init__.py,sha256=P2glBiqPlHhJO7YN8TAMMg9SBtBcwtql_AF5MZxOBE0,5483
2
+ ka9q/_multicast.py,sha256=U6pCiH1ruy3i5yv7B_rRhkakokwnetu1Nd3009nAI5w,3343
3
+ ka9q/addressing.py,sha256=GaHLeTwRpsmJawRAROoj-AqALcVkqO6Jq4_th9u7fjE,2681
4
+ ka9q/cli.py,sha256=nfU4hqScqjJtuWBBraPxUb-37cvjY_qtZkNIxr9GzRU,15040
5
+ ka9q/compat.py,sha256=uiU-wkQ1p88fcfPYRdqU1QxHiunmQPIhM5CXkW_5GMs,436
6
+ ka9q/control.py,sha256=twKMbLjXmkAp2P2Pi51QKCj1W8zrv4wE-4RYSSeL7Zk,156426
7
+ ka9q/discovery.py,sha256=-jGcDczuwEQmmJRogc2hR22PUKT2qxWkPgy_ffd9Cdk,28615
8
+ ka9q/exceptions.py,sha256=_P8rok_tTmRMu-lLRhS95kv8FRK5PEY80_6jIux2mWM,474
9
+ ka9q/managed_stream.py,sha256=gHvGcQgJF5FCOzVp7V6vcIp75NLGnOHgClGR24i9_No,18842
10
+ ka9q/monitor.py,sha256=jw4UqoPpmKhX_xImQ31AvOmET6rsetCoefd7UBB9Tpo,5262
11
+ ka9q/multi_stream.py,sha256=0ztgMVPRh0bZTFbsCTdeejWe3SRJYaF8Pha67MV98t0,31774
12
+ ka9q/pps_calibrator.py,sha256=yaVEzr1zmXXbPNwnISGoqMq2HcuUWmVLLqYCfjj8avk,11656
13
+ ka9q/recovery_ladder.py,sha256=033U8r3E-ncS_hubKMcQHv5SZAot0gX5pQS5srA9y5M,5877
14
+ ka9q/resequencer.py,sha256=m_OWattFpqIugSqWr6aadsmUeoJIPkfINKfPi6gp_sg,17300
15
+ ka9q/rtp_recorder.py,sha256=-YpG9mleoGjEd5voPSxnGcdVsFvyxSgOJ8f7ZaXGcT0,23210
16
+ ka9q/slot_clock.py,sha256=9OuE1qMHJ5YBHfNPtQSguj_KHgkU9iAXfhOLEk-1B78,13468
17
+ ka9q/spectrum_stream.py,sha256=utMhpH8hkkwOSD1WpKb4r0YHm3isDF0TvYMq-jJJFwY,11458
18
+ ka9q/status.py,sha256=Tep1FW4bgg80v0qn18Biulg6g0S1F2LmbrDXVu2HXSg,28224
19
+ ka9q/status_listener.py,sha256=20l9aHii263myYlO8mmSgI_tUcchUwLwgh69Dx-1UQY,20045
20
+ ka9q/stream.py,sha256=s1Xl4Njia9DwXxsSF2XSon1plFjJoDXXXCGeLZdF734,33111
21
+ ka9q/stream_quality.py,sha256=YrKa07PzDi4xeF5tubwpDvSTs5eHu0G7mOa12iIINrs,8222
22
+ ka9q/tui.py,sha256=ryL5CxfYkVzXhsCy1hw9GP3ya8hDbZTccfkc_uJtrDQ,27182
23
+ ka9q/types.py,sha256=woWL_ShotZnYUYsR4RBL2wu13S1MTkkJC9_-9IVh14g,7514
24
+ ka9q/utils.py,sha256=fFR_sYIpeDyPaFlXzN6jCiGhYlssvrMMVx4z6vyhqcc,7107
25
+ ka9q_python-3.26.0.dist-info/licenses/LICENSE,sha256=mjz-l5TVsO9j7Jqt1gfLvTWtZK_HL9sZRRpsHaq4Qv8,1074
26
+ ka9q_python-3.26.0.dist-info/METADATA,sha256=6Bj7pdfXVJebxW24J-3bInOyKsh32qjMzz16_hirSq0,14637
27
+ ka9q_python-3.26.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
+ ka9q_python-3.26.0.dist-info/entry_points.txt,sha256=Wt5Q4OMUKHaf4KdsZXWsjSBpbHVuTtt3pypVIHi7eRk,39
29
+ ka9q_python-3.26.0.dist-info/top_level.txt,sha256=FUTLZDtQpJGenGPPlN34E5QpQi_oaiLB6LuzgHblBNE,5
30
+ ka9q_python-3.26.0.dist-info/RECORD,,
@@ -1,29 +0,0 @@
1
- ka9q/__init__.py,sha256=VNGzyyvs0Lz9MG34Kts04BrOvzZS9G7Z4R2La3XNPlE,5364
2
- ka9q/_multicast.py,sha256=U6pCiH1ruy3i5yv7B_rRhkakokwnetu1Nd3009nAI5w,3343
3
- ka9q/addressing.py,sha256=GaHLeTwRpsmJawRAROoj-AqALcVkqO6Jq4_th9u7fjE,2681
4
- ka9q/cli.py,sha256=nfU4hqScqjJtuWBBraPxUb-37cvjY_qtZkNIxr9GzRU,15040
5
- ka9q/compat.py,sha256=YimJ1SzJOf7_TiJccun_aq_CgJ0Xtnexq_XsXY5ZHoQ,436
6
- ka9q/control.py,sha256=fVvLMzSx3Tl6ZdfCal257TzCl5fGInwkwJ8BCpmlFu4,146362
7
- ka9q/discovery.py,sha256=JjA-zKqHmib8W6KOMpCMX30IFoWrWiDj1ckbkzR-jqA,27131
8
- ka9q/exceptions.py,sha256=_P8rok_tTmRMu-lLRhS95kv8FRK5PEY80_6jIux2mWM,474
9
- ka9q/managed_stream.py,sha256=I3P7C74ssDKebfCzU4hD4HbnfYbJOeC39c-VaZqLlSU,18404
10
- ka9q/monitor.py,sha256=jw4UqoPpmKhX_xImQ31AvOmET6rsetCoefd7UBB9Tpo,5262
11
- ka9q/multi_stream.py,sha256=ConVJBgi_snqzG_VpbNh6dmaNJhQWb4GiRtmX3S-EK8,30449
12
- ka9q/pps_calibrator.py,sha256=yaVEzr1zmXXbPNwnISGoqMq2HcuUWmVLLqYCfjj8avk,11656
13
- ka9q/resequencer.py,sha256=EgYAj_s0sWr8gS2EG-8Rz5Jw7HYla82sPytQKqwrses,15797
14
- ka9q/rtp_recorder.py,sha256=UBxurokkU9pECK2sfWw9I4iy7KquDxpkWb9XW3cm2zE,22667
15
- ka9q/slot_clock.py,sha256=9OuE1qMHJ5YBHfNPtQSguj_KHgkU9iAXfhOLEk-1B78,13468
16
- ka9q/spectrum_stream.py,sha256=utMhpH8hkkwOSD1WpKb4r0YHm3isDF0TvYMq-jJJFwY,11458
17
- ka9q/status.py,sha256=Tep1FW4bgg80v0qn18Biulg6g0S1F2LmbrDXVu2HXSg,28224
18
- ka9q/status_listener.py,sha256=oJ_aGhgDKea_j1-rbZFBBFFnw4FQW3CpCESYOrFev_0,19590
19
- ka9q/stream.py,sha256=VnFQSjVD_WLobH8O9nl1vqqiaWzJdpP-XQc7vy5Ji4w,27905
20
- ka9q/stream_quality.py,sha256=cuI_wnAuh3Dh5wpkI3ujXgbbh7xobdEgtYuZM1cKnOY,7482
21
- ka9q/tui.py,sha256=ryL5CxfYkVzXhsCy1hw9GP3ya8hDbZTccfkc_uJtrDQ,27182
22
- ka9q/types.py,sha256=E4y9wtLABNloL63y6aNsjvOpj_kdrUPiRKjAQcXzacQ,7462
23
- ka9q/utils.py,sha256=fFR_sYIpeDyPaFlXzN6jCiGhYlssvrMMVx4z6vyhqcc,7107
24
- ka9q_python-3.22.0.dist-info/licenses/LICENSE,sha256=mjz-l5TVsO9j7Jqt1gfLvTWtZK_HL9sZRRpsHaq4Qv8,1074
25
- ka9q_python-3.22.0.dist-info/METADATA,sha256=hmTXtuhSSr2KJR1alVBE8M19QVLwi3unHJToVN5FnXU,14637
26
- ka9q_python-3.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
- ka9q_python-3.22.0.dist-info/entry_points.txt,sha256=Wt5Q4OMUKHaf4KdsZXWsjSBpbHVuTtt3pypVIHi7eRk,39
28
- ka9q_python-3.22.0.dist-info/top_level.txt,sha256=FUTLZDtQpJGenGPPlN34E5QpQi_oaiLB6LuzgHblBNE,5
29
- ka9q_python-3.22.0.dist-info/RECORD,,