pluto-siege 2.0.1__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.
- pluto_siege/__init__.py +43 -0
- pluto_siege/__main__.py +23 -0
- pluto_siege/config.py +249 -0
- pluto_siege/constants.py +132 -0
- pluto_siege/device.py +311 -0
- pluto_siege/dsp.py +157 -0
- pluto_siege/engine.py +460 -0
- pluto_siege/gps.py +419 -0
- pluto_siege/settings.py +54 -0
- pluto_siege/sigmf.py +329 -0
- pluto_siege/sound.py +162 -0
- pluto_siege/ui/__init__.py +21 -0
- pluto_siege/ui/screens/__init__.py +32 -0
- pluto_siege/ui/screens/capture.py +226 -0
- pluto_siege/ui/screens/loopback.py +90 -0
- pluto_siege/ui/screens/main_menu.py +363 -0
- pluto_siege/ui/screens/settings.py +504 -0
- pluto_siege/ui/screens/transmit.py +240 -0
- pluto_siege/ui/widgets/__init__.py +45 -0
- pluto_siege/ui/widgets/framework.py +863 -0
- pluto_siege-2.0.1.dist-info/METADATA +187 -0
- pluto_siege-2.0.1.dist-info/RECORD +26 -0
- pluto_siege-2.0.1.dist-info/WHEEL +5 -0
- pluto_siege-2.0.1.dist-info/entry_points.txt +2 -0
- pluto_siege-2.0.1.dist-info/licenses/LICENSE +674 -0
- pluto_siege-2.0.1.dist-info/top_level.txt +1 -0
pluto_siege/device.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# PlutoSiege - RF capture and replay tool for PlutoSDR.
|
|
2
|
+
# Copyright (C) 2026 SketchTurnerDev
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""PlutoSDR device access: open, configure, cleanup, and SDRDevice protocol."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any, Protocol, runtime_checkable
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numpy.typing import NDArray
|
|
28
|
+
|
|
29
|
+
from pluto_siege.config import CONFIG, AppConfig
|
|
30
|
+
from pluto_siege.constants import (
|
|
31
|
+
IO_TIMEOUT_FACTOR,
|
|
32
|
+
IO_TIMEOUT_MIN_MS,
|
|
33
|
+
MAX_RF_BW,
|
|
34
|
+
RX_BUFFER_RANGE,
|
|
35
|
+
RX_GAIN_RANGE,
|
|
36
|
+
SAMPLE_RATE_RANGE,
|
|
37
|
+
TX_GAIN_RANGE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_DRIVER_IMPORT_ERROR: Exception | None = None
|
|
41
|
+
try:
|
|
42
|
+
import adi
|
|
43
|
+
except (ImportError, OSError, AttributeError) as exc:
|
|
44
|
+
adi = None
|
|
45
|
+
_DRIVER_IMPORT_ERROR = exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class SDRDevice(Protocol):
|
|
50
|
+
"""The AD936x/pyadi-iio interface used by PlutoSiege."""
|
|
51
|
+
|
|
52
|
+
sample_rate: int
|
|
53
|
+
rx_lo: int
|
|
54
|
+
tx_lo: int
|
|
55
|
+
rx_buffer_size: int
|
|
56
|
+
rx_rf_bandwidth: int
|
|
57
|
+
tx_rf_bandwidth: int
|
|
58
|
+
rx_hardwaregain_chan0: float
|
|
59
|
+
tx_hardwaregain_chan0: float
|
|
60
|
+
gain_control_mode_chan0: str
|
|
61
|
+
tx_cyclic_buffer: bool
|
|
62
|
+
loopback: int
|
|
63
|
+
ctx: Any
|
|
64
|
+
|
|
65
|
+
def rx(self) -> NDArray[np.complex64]: ...
|
|
66
|
+
def tx(self, data: NDArray[np.complex64]) -> None: ...
|
|
67
|
+
def rx_destroy_buffer(self) -> None: ...
|
|
68
|
+
def tx_destroy_buffer(self) -> None: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SuppressCStderr:
|
|
72
|
+
"""Temporarily redirect stderr (fd 2) to devnull so C-level libiio logs do not corrupt the TUI."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._null = -1
|
|
76
|
+
self._stderr = -1
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> SuppressCStderr:
|
|
79
|
+
try:
|
|
80
|
+
sys.stderr.flush()
|
|
81
|
+
self._null = os.open(os.devnull, os.O_WRONLY)
|
|
82
|
+
self._stderr = os.dup(2)
|
|
83
|
+
os.dup2(self._null, 2)
|
|
84
|
+
except OSError:
|
|
85
|
+
self._cleanup()
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *args: object) -> None:
|
|
89
|
+
self._cleanup()
|
|
90
|
+
|
|
91
|
+
def _cleanup(self) -> None:
|
|
92
|
+
if self._stderr != -1:
|
|
93
|
+
try:
|
|
94
|
+
os.dup2(self._stderr, 2)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
try:
|
|
98
|
+
os.close(self._stderr)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
self._stderr = -1
|
|
102
|
+
if self._null != -1:
|
|
103
|
+
try:
|
|
104
|
+
os.close(self._null)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
self._null = -1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
suppress_c_stderr = SuppressCStderr
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cleanup_sdr(sdr: SDRDevice) -> None:
|
|
114
|
+
"""Silence the transmitter, disable loopback, and release both buffers. Never raises."""
|
|
115
|
+
with suppress_c_stderr():
|
|
116
|
+
for attr, value in (
|
|
117
|
+
("loopback", 0),
|
|
118
|
+
("tx_cyclic_buffer", False),
|
|
119
|
+
("tx_hardwaregain_chan0", TX_GAIN_RANGE[0]),
|
|
120
|
+
):
|
|
121
|
+
try:
|
|
122
|
+
setattr(sdr, attr, value)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
for method_name in ("tx_destroy_buffer", "rx_destroy_buffer"):
|
|
126
|
+
try:
|
|
127
|
+
getattr(sdr, method_name)()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def set_io_timeout(sdr: SDRDevice, expected_seconds: float) -> bool:
|
|
133
|
+
"""Bound a libiio transfer; returns False if the driver does not support timeouts."""
|
|
134
|
+
if not math.isfinite(expected_seconds) or expected_seconds < 0:
|
|
135
|
+
raise ValueError("Expected transfer duration must be finite and non-negative")
|
|
136
|
+
max_ms = 2**32 - 1
|
|
137
|
+
seconds = min(expected_seconds, max_ms / (1000 * IO_TIMEOUT_FACTOR))
|
|
138
|
+
ms = min(max_ms, max(IO_TIMEOUT_MIN_MS, int(seconds * 1000 * IO_TIMEOUT_FACTOR)))
|
|
139
|
+
try:
|
|
140
|
+
sdr.ctx.set_timeout(ms)
|
|
141
|
+
return True
|
|
142
|
+
except (AttributeError, OSError, RuntimeError, OverflowError):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def safe_rx(sdr: SDRDevice) -> NDArray[np.complex64]:
|
|
147
|
+
"""Receive an owned, finite, 1D complex64 buffer or raise OSError on link failure."""
|
|
148
|
+
raw = sdr.rx()
|
|
149
|
+
if raw is None:
|
|
150
|
+
raise OSError("SDR returned empty data (USB disconnected?)")
|
|
151
|
+
try:
|
|
152
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
153
|
+
data = np.array(raw, dtype=np.complex64, order="C", copy=True)
|
|
154
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
155
|
+
raise OSError("SDR returned invalid sample data") from exc
|
|
156
|
+
if data.size == 0:
|
|
157
|
+
raise OSError("SDR returned empty data (USB disconnected?)")
|
|
158
|
+
if data.ndim != 1:
|
|
159
|
+
raise OSError("SDR must return one channel as a one-dimensional buffer")
|
|
160
|
+
if not bool(np.all(np.isfinite(data))):
|
|
161
|
+
raise OSError("SDR returned non-finite samples")
|
|
162
|
+
return data
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_number(
|
|
166
|
+
value: float,
|
|
167
|
+
limits: tuple[float, float],
|
|
168
|
+
name: str,
|
|
169
|
+
*,
|
|
170
|
+
integral: bool = False,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Validate numeric parameter ranges before programming SDR hardware."""
|
|
173
|
+
try:
|
|
174
|
+
number = float(value)
|
|
175
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
176
|
+
raise ValueError(f"{name} must be numeric") from exc
|
|
177
|
+
if (
|
|
178
|
+
isinstance(value, (bool, str))
|
|
179
|
+
or not math.isfinite(number)
|
|
180
|
+
or not limits[0] <= number <= limits[1]
|
|
181
|
+
or (integral and not number.is_integer())
|
|
182
|
+
):
|
|
183
|
+
raise ValueError(f"{name} must be within {limits[0]:g} .. {limits[1]:g}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_rx_settings(
|
|
187
|
+
sdr: SDRDevice, use_sr: int, freq: int, buf_size: int, rx_gain: float
|
|
188
|
+
) -> None:
|
|
189
|
+
sdr.rx_rf_bandwidth = min(use_sr, MAX_RF_BW)
|
|
190
|
+
sdr.rx_lo = int(freq)
|
|
191
|
+
sdr.rx_buffer_size = int(buf_size)
|
|
192
|
+
sdr.gain_control_mode_chan0 = "manual"
|
|
193
|
+
sdr.rx_hardwaregain_chan0 = float(rx_gain)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _apply_tx_settings(
|
|
197
|
+
sdr: SDRDevice, use_sr: int, freq: int, tx_gain: float
|
|
198
|
+
) -> None:
|
|
199
|
+
sdr.tx_rf_bandwidth = min(use_sr, MAX_RF_BW)
|
|
200
|
+
sdr.tx_lo = int(freq)
|
|
201
|
+
sdr.tx_hardwaregain_chan0 = float(tx_gain)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cfg_rx(
|
|
205
|
+
sdr: SDRDevice,
|
|
206
|
+
sr: int | None = None,
|
|
207
|
+
freq: int | None = None,
|
|
208
|
+
config: AppConfig | None = None,
|
|
209
|
+
) -> bool:
|
|
210
|
+
"""Configure RX and return whether libiio timeout is supported."""
|
|
211
|
+
cfg = CONFIG if config is None else config
|
|
212
|
+
use_sr = cfg.sample_rate if sr is None else sr
|
|
213
|
+
use_freq = cfg.rx_freq if freq is None else freq
|
|
214
|
+
_check_number(use_sr, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
|
|
215
|
+
_check_number(use_freq, cfg.freq_bounds, "RX frequency", integral=True)
|
|
216
|
+
_check_number(cfg.rx_buffer_size, RX_BUFFER_RANGE, "RX buffer size", integral=True)
|
|
217
|
+
_check_number(cfg.rx_gain, RX_GAIN_RANGE, "RX gain")
|
|
218
|
+
with suppress_c_stderr():
|
|
219
|
+
cleanup_sdr(sdr)
|
|
220
|
+
try:
|
|
221
|
+
sdr.sample_rate = int(use_sr)
|
|
222
|
+
_apply_rx_settings(
|
|
223
|
+
sdr, int(use_sr), int(use_freq), cfg.rx_buffer_size, cfg.rx_gain
|
|
224
|
+
)
|
|
225
|
+
return set_io_timeout(sdr, cfg.rx_buffer_size / use_sr)
|
|
226
|
+
except BaseException:
|
|
227
|
+
cleanup_sdr(sdr)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cfg_tx(
|
|
232
|
+
sdr: SDRDevice,
|
|
233
|
+
sr: int | None = None,
|
|
234
|
+
freq: int | None = None,
|
|
235
|
+
config: AppConfig | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Configure non-cyclic TX."""
|
|
238
|
+
cfg = CONFIG if config is None else config
|
|
239
|
+
use_sr = cfg.sample_rate if sr is None else sr
|
|
240
|
+
use_freq = cfg.tx_freq if freq is None else freq
|
|
241
|
+
_check_number(use_sr, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
|
|
242
|
+
_check_number(use_freq, cfg.freq_bounds, "TX frequency", integral=True)
|
|
243
|
+
_check_number(cfg.tx_gain, TX_GAIN_RANGE, "TX gain")
|
|
244
|
+
with suppress_c_stderr():
|
|
245
|
+
cleanup_sdr(sdr)
|
|
246
|
+
try:
|
|
247
|
+
sdr.sample_rate = int(use_sr)
|
|
248
|
+
_apply_tx_settings(sdr, int(use_sr), int(use_freq), cfg.tx_gain)
|
|
249
|
+
sdr.tx_cyclic_buffer = False
|
|
250
|
+
except BaseException:
|
|
251
|
+
cleanup_sdr(sdr)
|
|
252
|
+
raise
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cfg_loopback(sdr: SDRDevice, config: AppConfig | None = None) -> bool:
|
|
256
|
+
"""Configure digital loopback; returns False only when I/O timeout is unavailable."""
|
|
257
|
+
cfg = CONFIG if config is None else config
|
|
258
|
+
_check_number(cfg.sample_rate, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
|
|
259
|
+
_check_number(cfg.rx_freq, cfg.freq_bounds, "RX frequency", integral=True)
|
|
260
|
+
_check_number(cfg.tx_freq, cfg.freq_bounds, "TX frequency", integral=True)
|
|
261
|
+
_check_number(cfg.rx_buffer_size, RX_BUFFER_RANGE, "RX buffer size", integral=True)
|
|
262
|
+
_check_number(cfg.rx_gain, RX_GAIN_RANGE, "RX gain")
|
|
263
|
+
_check_number(cfg.tx_gain, TX_GAIN_RANGE, "TX gain")
|
|
264
|
+
with suppress_c_stderr():
|
|
265
|
+
cleanup_sdr(sdr)
|
|
266
|
+
try:
|
|
267
|
+
sdr.sample_rate = int(cfg.sample_rate)
|
|
268
|
+
_apply_rx_settings(
|
|
269
|
+
sdr, cfg.sample_rate, cfg.rx_freq, cfg.rx_buffer_size, cfg.rx_gain
|
|
270
|
+
)
|
|
271
|
+
_apply_tx_settings(sdr, cfg.sample_rate, cfg.tx_freq, cfg.tx_gain)
|
|
272
|
+
sdr.loopback = 1
|
|
273
|
+
sdr.tx_cyclic_buffer = True
|
|
274
|
+
return set_io_timeout(sdr, cfg.rx_buffer_size / cfg.sample_rate)
|
|
275
|
+
except BaseException:
|
|
276
|
+
cleanup_sdr(sdr)
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def open_sdr(uri: str, fallback_model: str = "PlutoSDR") -> tuple[SDRDevice, str]:
|
|
281
|
+
"""Open a PlutoSDR device and read its hardware model."""
|
|
282
|
+
if adi is None:
|
|
283
|
+
detail = f" ({_DRIVER_IMPORT_ERROR})" if _DRIVER_IMPORT_ERROR else ""
|
|
284
|
+
if len(detail) > 40:
|
|
285
|
+
detail = detail[:37] + "...)"
|
|
286
|
+
raise ImportError(
|
|
287
|
+
f"pyadi-iio / libiio driver is not available{detail}.\n"
|
|
288
|
+
"See README.md for driver and installation instructions."
|
|
289
|
+
) from _DRIVER_IMPORT_ERROR
|
|
290
|
+
if not isinstance(uri, str) or not uri.strip():
|
|
291
|
+
raise ValueError("Pluto connection URI must not be empty")
|
|
292
|
+
clean_uri = uri.strip()
|
|
293
|
+
try:
|
|
294
|
+
with suppress_c_stderr():
|
|
295
|
+
sdr = adi.Pluto(clean_uri)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
raise OSError(str(exc) or f"Failed to open {clean_uri}") from exc
|
|
298
|
+
model = fallback_model
|
|
299
|
+
try:
|
|
300
|
+
value = sdr.ctx.attrs["hw_model"]
|
|
301
|
+
value = value if isinstance(value, str) else value.value
|
|
302
|
+
if isinstance(value, str) and value.strip():
|
|
303
|
+
model = value.strip()
|
|
304
|
+
except (KeyError, AttributeError, OSError, TypeError):
|
|
305
|
+
pass
|
|
306
|
+
return sdr, model
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def release_sdr(sdr: SDRDevice | None) -> None:
|
|
310
|
+
if sdr is not None:
|
|
311
|
+
cleanup_sdr(sdr)
|
pluto_siege/dsp.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# PlutoSiege - RF capture and replay tool for PlutoSDR.
|
|
2
|
+
# Copyright (C) 2026 SketchTurnerDev
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Signal-level metrics: sub-window power, dBFS conversions, saturation, and SNR."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import NDArray
|
|
25
|
+
|
|
26
|
+
from pluto_siege.constants import (
|
|
27
|
+
DB_EPSILON,
|
|
28
|
+
DETECT_SUB_WINDOW,
|
|
29
|
+
LOOPBACK_TONE_BINS,
|
|
30
|
+
NF_PERCENTILE,
|
|
31
|
+
RX_FULL_SCALE,
|
|
32
|
+
SATURATION_MARGIN,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
INV_FULL_SCALE_SQ = 1.0 / (RX_FULL_SCALE * RX_FULL_SCALE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def subwindow_powers(
|
|
39
|
+
data: NDArray[np.complex64], sub_size: int = DETECT_SUB_WINDOW
|
|
40
|
+
) -> NDArray[np.float64]:
|
|
41
|
+
"""Full-scale-normalised mean powers, including a final partial sub-window."""
|
|
42
|
+
sub_size = max(1, int(sub_size))
|
|
43
|
+
if data.ndim != 1:
|
|
44
|
+
data = data.ravel()
|
|
45
|
+
if data.dtype != np.complex64 or not data.flags["C_CONTIGUOUS"]:
|
|
46
|
+
data = np.ascontiguousarray(data, dtype=np.complex64)
|
|
47
|
+
n_samples = data.size
|
|
48
|
+
if n_samples == 0:
|
|
49
|
+
return np.zeros(1, dtype=np.float64)
|
|
50
|
+
usable = n_samples - n_samples % sub_size
|
|
51
|
+
if usable:
|
|
52
|
+
flat = data[:usable].view(np.float32).reshape(-1, sub_size * 2)
|
|
53
|
+
powers = np.einsum("ij,ij->i", flat, flat, dtype=np.float64) * (
|
|
54
|
+
INV_FULL_SCALE_SQ / sub_size
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
powers = np.empty(0, dtype=np.float64)
|
|
58
|
+
if usable < n_samples:
|
|
59
|
+
tail = data[usable:].view(np.float32)
|
|
60
|
+
power = float(np.einsum("i,i->", tail, tail, dtype=np.float64))
|
|
61
|
+
powers = np.append(powers, power * INV_FULL_SCALE_SQ / (n_samples - usable))
|
|
62
|
+
return powers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def power_to_dbfs(power: float) -> float:
|
|
66
|
+
"""Linear full-scale-normalised power to dBFS; unusable inputs give the floor."""
|
|
67
|
+
if not math.isfinite(power) or power <= 0.0:
|
|
68
|
+
return -120.0
|
|
69
|
+
return float(10.0 * math.log10(power + DB_EPSILON))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dbfs_to_power(dbfs: float) -> float:
|
|
73
|
+
"""Convert a threshold without log work per buffer, bounded against overflow."""
|
|
74
|
+
if math.isnan(dbfs):
|
|
75
|
+
return 0.0
|
|
76
|
+
return float(10.0 ** (min(dbfs, 300.0) / 10.0))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def max_subwindow_dbfs(
|
|
80
|
+
data: NDArray[np.complex64], sub_size: int = DETECT_SUB_WINDOW
|
|
81
|
+
) -> float:
|
|
82
|
+
"""Peak sub-window level in dBFS, used for triggering."""
|
|
83
|
+
return power_to_dbfs(float(np.max(subwindow_powers(data, sub_size))))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
peak_window_dbfs = max_subwindow_dbfs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def noise_floor_dbfs(
|
|
90
|
+
powers: NDArray[np.float64], percentile: float = NF_PERCENTILE
|
|
91
|
+
) -> float:
|
|
92
|
+
"""Noise floor from pooled finite sub-window powers."""
|
|
93
|
+
if powers.size == 0:
|
|
94
|
+
return -120.0
|
|
95
|
+
valid = powers[np.isfinite(powers)]
|
|
96
|
+
if valid.size == 0:
|
|
97
|
+
return -120.0
|
|
98
|
+
return power_to_dbfs(float(np.percentile(valid, percentile)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_saturated(
|
|
102
|
+
data: NDArray[np.complex64], margin: float = SATURATION_MARGIN
|
|
103
|
+
) -> bool:
|
|
104
|
+
if data.size == 0:
|
|
105
|
+
return False
|
|
106
|
+
if data.ndim != 1:
|
|
107
|
+
data = data.ravel()
|
|
108
|
+
limit = margin * RX_FULL_SCALE
|
|
109
|
+
if data.dtype != np.complex64 or not data.flags["C_CONTIGUOUS"]:
|
|
110
|
+
data = np.ascontiguousarray(data, dtype=np.complex64)
|
|
111
|
+
flat = data.view(np.float32)
|
|
112
|
+
return bool(max(float(np.max(flat)), -float(np.min(flat))) >= limit)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def calculate_snr_db(
|
|
116
|
+
rx: NDArray[np.complex64],
|
|
117
|
+
fs: float,
|
|
118
|
+
tone_freq: float,
|
|
119
|
+
tone_bins: int = LOOPBACK_TONE_BINS,
|
|
120
|
+
) -> float:
|
|
121
|
+
"""Estimate in-band SNR from a Hann-windowed FFT, wrapping at Nyquist.
|
|
122
|
+
|
|
123
|
+
Noise per bin is estimated outside the tone band, then scaled to the band
|
|
124
|
+
width. This preserves the existing local-band metric (not broadband SNR).
|
|
125
|
+
"""
|
|
126
|
+
if rx.ndim != 1:
|
|
127
|
+
rx = rx.ravel()
|
|
128
|
+
n_samples = rx.size
|
|
129
|
+
if (
|
|
130
|
+
n_samples < 32
|
|
131
|
+
or not math.isfinite(fs)
|
|
132
|
+
or fs <= 0
|
|
133
|
+
or not math.isfinite(tone_freq)
|
|
134
|
+
or not -fs / 2 <= tone_freq < fs / 2
|
|
135
|
+
or not bool(np.all(np.isfinite(rx)))
|
|
136
|
+
):
|
|
137
|
+
return -120.0
|
|
138
|
+
tone_bins = max(1, int(tone_bins))
|
|
139
|
+
windowed_fft = np.fft.fft(rx * np.hanning(n_samples))
|
|
140
|
+
spectrum = windowed_fft.real**2 + windowed_fft.imag**2
|
|
141
|
+
if float(np.sum(spectrum)) <= 1e-18:
|
|
142
|
+
return -120.0
|
|
143
|
+
if 2 * tone_bins + 1 >= n_samples:
|
|
144
|
+
return 0.0
|
|
145
|
+
|
|
146
|
+
target_bin = round(tone_freq * n_samples / fs) % n_samples
|
|
147
|
+
signal_bins = (target_bin + np.arange(-tone_bins, tone_bins + 1)) % n_samples
|
|
148
|
+
in_band_mask = np.zeros(n_samples, dtype=bool)
|
|
149
|
+
in_band_mask[signal_bins] = True
|
|
150
|
+
in_band = float(np.sum(spectrum[in_band_mask]))
|
|
151
|
+
if in_band <= 1e-18:
|
|
152
|
+
return -120.0
|
|
153
|
+
# Sum the noise bins directly. Subtracting two nearly equal large totals
|
|
154
|
+
# loses the residual noise on a clean loopback tone.
|
|
155
|
+
noise = float(np.mean(spectrum[~in_band_mask])) * signal_bins.size
|
|
156
|
+
signal_power = max(in_band - noise, 1e-20)
|
|
157
|
+
return float(10.0 * math.log10(signal_power / max(noise, 1e-20)))
|