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/sigmf.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
"""SigMF recording save and load utilities."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import tempfile
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from numpy.typing import NDArray
|
|
30
|
+
|
|
31
|
+
from pluto_siege.constants import (
|
|
32
|
+
AUTHOR,
|
|
33
|
+
FREQ_RANGE_EXTENDED,
|
|
34
|
+
FREQ_RANGE_IN_SPEC,
|
|
35
|
+
RX_FULL_SCALE,
|
|
36
|
+
SAMPLE_RATE_RANGE,
|
|
37
|
+
VERSION,
|
|
38
|
+
brief,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from pluto_siege.gps import GPSFix
|
|
43
|
+
|
|
44
|
+
_MAX_METADATA_BYTES = 8 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_sigmf_utc(dt: datetime.datetime) -> str:
|
|
48
|
+
"""Format a datetime as an ISO-8601 UTC string with microsecond precision."""
|
|
49
|
+
return (
|
|
50
|
+
dt.astimezone(datetime.timezone.utc)
|
|
51
|
+
.isoformat(timespec="microseconds")
|
|
52
|
+
.replace("+00:00", "Z")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _meta_path(path: str) -> str:
|
|
57
|
+
return path.removesuffix(".sigmf-data").removesuffix(".sigmf-meta") + ".sigmf-meta"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _finite_int(value: object) -> int | None:
|
|
61
|
+
"""Return an integer when value is a finite whole number, otherwise None."""
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
num = float(value)
|
|
66
|
+
except (OverflowError, ValueError):
|
|
67
|
+
return None
|
|
68
|
+
if not math.isfinite(num) or not num.is_integer():
|
|
69
|
+
return None
|
|
70
|
+
return int(num)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _finite_float(value: object) -> float | None:
|
|
74
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
num = float(value)
|
|
78
|
+
except (OverflowError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
return num if math.isfinite(num) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate_coords(
|
|
84
|
+
lon: object, lat: object, alt: object = 0.0
|
|
85
|
+
) -> tuple[float, float, float] | None:
|
|
86
|
+
"""Validate geographic coordinates against RFC 7946 bounds."""
|
|
87
|
+
lon_f = _finite_float(lon)
|
|
88
|
+
lat_f = _finite_float(lat)
|
|
89
|
+
alt_f = _finite_float(alt)
|
|
90
|
+
if lon_f is None or lat_f is None or alt_f is None:
|
|
91
|
+
return None
|
|
92
|
+
if not (-180.0 <= lon_f <= 180.0 and -90.0 <= lat_f <= 90.0):
|
|
93
|
+
return None
|
|
94
|
+
return lon_f, lat_f, alt_f
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _supported_capture(
|
|
98
|
+
meta: object,
|
|
99
|
+
) -> tuple[tuple[dict[str, Any], dict[str, Any]] | None, str | None]:
|
|
100
|
+
"""Validate global and first capture blocks for scalar cf32_le playback."""
|
|
101
|
+
if not isinstance(meta, dict):
|
|
102
|
+
return None, "metadata is not a SigMF object"
|
|
103
|
+
global_meta = meta.get("global")
|
|
104
|
+
if not isinstance(global_meta, dict):
|
|
105
|
+
return None, "metadata has no global block"
|
|
106
|
+
dtype = global_meta.get("core:datatype")
|
|
107
|
+
if dtype is None:
|
|
108
|
+
return None, "metadata has no sample format"
|
|
109
|
+
if dtype != "cf32_le":
|
|
110
|
+
return None, f"sample format is {brief(dtype, 12)!r}, not cf32_le"
|
|
111
|
+
|
|
112
|
+
captures = meta.get("captures")
|
|
113
|
+
if not isinstance(captures, list) or not captures:
|
|
114
|
+
return None, "metadata has no captures block"
|
|
115
|
+
capture = captures[0]
|
|
116
|
+
if (
|
|
117
|
+
not isinstance(capture, dict)
|
|
118
|
+
or _finite_int(capture.get("core:sample_start", 0)) != 0
|
|
119
|
+
):
|
|
120
|
+
return None, "metadata must start capture at sample 0"
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
_finite_int(global_meta.get("core:num_channels", 1)) != 1
|
|
124
|
+
or _finite_int(global_meta.get("core:offset", 0)) != 0
|
|
125
|
+
or _finite_int(global_meta.get("core:trailing_bytes", 0)) != 0
|
|
126
|
+
or "core:dataset" in global_meta
|
|
127
|
+
or _finite_int(capture.get("core:header_bytes", 0)) != 0
|
|
128
|
+
):
|
|
129
|
+
return None, "metadata uses an unsupported data layout"
|
|
130
|
+
|
|
131
|
+
return (global_meta, capture), None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_sigmf_pair(
|
|
135
|
+
base_path: str,
|
|
136
|
+
array: NDArray[np.complex64],
|
|
137
|
+
freq: int,
|
|
138
|
+
sr: int,
|
|
139
|
+
timestamp_iso: str,
|
|
140
|
+
hw_model: str,
|
|
141
|
+
gps_fix: GPSFix | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Save captured IQ samples and metadata atomically as a SigMF pair."""
|
|
144
|
+
if not isinstance(base_path, str) or not base_path:
|
|
145
|
+
raise ValueError("base_path must be a non-empty string")
|
|
146
|
+
if (
|
|
147
|
+
not isinstance(array, np.ndarray)
|
|
148
|
+
or array.ndim != 1
|
|
149
|
+
or not np.iscomplexobj(array)
|
|
150
|
+
):
|
|
151
|
+
raise ValueError("SigMF samples must be a one-dimensional complex array")
|
|
152
|
+
if not bool(np.all(np.isfinite(array))):
|
|
153
|
+
raise ValueError("SigMF samples must be finite")
|
|
154
|
+
|
|
155
|
+
use_freq = _finite_int(freq)
|
|
156
|
+
use_sr = _finite_int(sr)
|
|
157
|
+
if (
|
|
158
|
+
use_freq is None
|
|
159
|
+
or not FREQ_RANGE_EXTENDED[0] <= use_freq <= FREQ_RANGE_EXTENDED[1]
|
|
160
|
+
):
|
|
161
|
+
raise ValueError("frequency is outside the supported tuning range")
|
|
162
|
+
if use_sr is None or not SAMPLE_RATE_RANGE[0] <= use_sr <= SAMPLE_RATE_RANGE[1]:
|
|
163
|
+
raise ValueError("sample rate is outside the supported range")
|
|
164
|
+
if not isinstance(timestamp_iso, str) or not timestamp_iso:
|
|
165
|
+
raise ValueError("timestamp must be a non-empty string")
|
|
166
|
+
if not isinstance(hw_model, str):
|
|
167
|
+
raise TypeError("hardware model must be a string")
|
|
168
|
+
|
|
169
|
+
data_path = base_path + ".sigmf-data"
|
|
170
|
+
meta_path = base_path + ".sigmf-meta"
|
|
171
|
+
directory = os.path.dirname(os.path.abspath(base_path))
|
|
172
|
+
os.makedirs(directory, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
tmp_data_path: str | None = None
|
|
175
|
+
tmp_meta_path: str | None = None
|
|
176
|
+
try:
|
|
177
|
+
norm_data = (array * np.float32(1.0 / RX_FULL_SCALE)).astype("<c8", copy=False)
|
|
178
|
+
data_fd, tmp_data_path = tempfile.mkstemp(
|
|
179
|
+
prefix=".sigmf-data-", suffix=".tmp", dir=directory
|
|
180
|
+
)
|
|
181
|
+
with os.fdopen(data_fd, "wb") as data_file:
|
|
182
|
+
norm_data.tofile(data_file)
|
|
183
|
+
data_file.flush()
|
|
184
|
+
os.fsync(data_file.fileno())
|
|
185
|
+
|
|
186
|
+
capture_entry: dict[str, Any] = {
|
|
187
|
+
"core:sample_start": 0,
|
|
188
|
+
"core:frequency": use_freq,
|
|
189
|
+
"core:datetime": timestamp_iso,
|
|
190
|
+
}
|
|
191
|
+
if gps_fix is not None and getattr(gps_fix, "has_fix", False) is True:
|
|
192
|
+
coords = _validate_coords(
|
|
193
|
+
getattr(gps_fix, "longitude", None),
|
|
194
|
+
getattr(gps_fix, "latitude", None),
|
|
195
|
+
getattr(gps_fix, "altitude", 0.0),
|
|
196
|
+
)
|
|
197
|
+
if coords is not None:
|
|
198
|
+
lon, lat, alt = coords
|
|
199
|
+
capture_entry["core:geolocation"] = {
|
|
200
|
+
"type": "Point",
|
|
201
|
+
"coordinates": [round(lon, 6), round(lat, 6), round(alt, 1)],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
meta = {
|
|
205
|
+
"global": {
|
|
206
|
+
"core:datatype": "cf32_le",
|
|
207
|
+
"core:sample_rate": use_sr,
|
|
208
|
+
"core:hw": hw_model,
|
|
209
|
+
"core:author": AUTHOR,
|
|
210
|
+
"core:version": "1.0.0",
|
|
211
|
+
"core:recorder": f"PlutoSiege v{VERSION}",
|
|
212
|
+
"core:description": "Host-estimated timestamp. May have USB/IIO latency.",
|
|
213
|
+
},
|
|
214
|
+
"captures": [capture_entry],
|
|
215
|
+
"annotations": [],
|
|
216
|
+
}
|
|
217
|
+
meta_fd, tmp_meta_path = tempfile.mkstemp(
|
|
218
|
+
prefix=".sigmf-meta-", suffix=".tmp", dir=directory
|
|
219
|
+
)
|
|
220
|
+
with os.fdopen(meta_fd, "w", encoding="utf-8") as meta_file:
|
|
221
|
+
json.dump(meta, meta_file, indent=2, allow_nan=False)
|
|
222
|
+
meta_file.flush()
|
|
223
|
+
os.fsync(meta_file.fileno())
|
|
224
|
+
|
|
225
|
+
os.replace(tmp_data_path, data_path)
|
|
226
|
+
tmp_data_path = None
|
|
227
|
+
try:
|
|
228
|
+
os.replace(tmp_meta_path, meta_path)
|
|
229
|
+
tmp_meta_path = None
|
|
230
|
+
except BaseException:
|
|
231
|
+
if os.path.exists(data_path):
|
|
232
|
+
try:
|
|
233
|
+
os.unlink(data_path)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
raise
|
|
237
|
+
finally:
|
|
238
|
+
for tmp_file in (tmp_data_path, tmp_meta_path):
|
|
239
|
+
if tmp_file is not None and os.path.exists(tmp_file):
|
|
240
|
+
try:
|
|
241
|
+
os.unlink(tmp_file)
|
|
242
|
+
except OSError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _load_sigmf_json(meta_path: str) -> tuple[object | None, str | None]:
|
|
247
|
+
if not os.path.exists(meta_path):
|
|
248
|
+
return None, "no .sigmf-meta file beside it"
|
|
249
|
+
try:
|
|
250
|
+
if os.path.getsize(meta_path) > _MAX_METADATA_BYTES:
|
|
251
|
+
return None, "metadata unreadable (metadata exceeds 8 MiB)"
|
|
252
|
+
with open(meta_path, "rb") as f:
|
|
253
|
+
raw_bytes = f.read()
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
return None, "no .sigmf-meta file beside it"
|
|
256
|
+
except OSError as error:
|
|
257
|
+
return None, f"metadata unreadable ({brief(error.strerror or 'I/O error')})"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return json.loads(raw_bytes.decode("utf-8")), None
|
|
261
|
+
except UnicodeDecodeError:
|
|
262
|
+
return None, "metadata is not valid UTF-8"
|
|
263
|
+
except (json.JSONDecodeError, RecursionError, ValueError):
|
|
264
|
+
return None, "metadata is not valid JSON"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_sigmf_meta(
|
|
268
|
+
path: str, bounds: tuple[int, int] | None = None
|
|
269
|
+
) -> tuple[int | None, int | None, str | None]:
|
|
270
|
+
"""Read and validate sample rate and center frequency from a SigMF recording."""
|
|
271
|
+
meta, problem = _load_sigmf_json(_meta_path(path))
|
|
272
|
+
if problem is not None:
|
|
273
|
+
return None, None, problem
|
|
274
|
+
|
|
275
|
+
parsed, err = _supported_capture(meta)
|
|
276
|
+
if parsed is None:
|
|
277
|
+
return None, None, err
|
|
278
|
+
global_meta, capture = parsed
|
|
279
|
+
|
|
280
|
+
sr = _finite_int(global_meta.get("core:sample_rate"))
|
|
281
|
+
freq = _finite_int(capture.get("core:frequency"))
|
|
282
|
+
if sr is None or freq is None:
|
|
283
|
+
return None, None, "metadata lacks an integral finite sample rate or frequency"
|
|
284
|
+
|
|
285
|
+
srlo, srhi = SAMPLE_RATE_RANGE
|
|
286
|
+
if not srlo <= sr <= srhi:
|
|
287
|
+
return (
|
|
288
|
+
None,
|
|
289
|
+
None,
|
|
290
|
+
f"rate {brief(sr, 9)} Hz outside {srlo / 1e3:g}k-{srhi / 1e6:g}M Hz",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if bounds is None:
|
|
294
|
+
low, high = FREQ_RANGE_IN_SPEC
|
|
295
|
+
else:
|
|
296
|
+
if not isinstance(bounds, tuple) or len(bounds) != 2:
|
|
297
|
+
return None, None, "invalid frequency bounds"
|
|
298
|
+
b_lo, b_hi = _finite_int(bounds[0]), _finite_int(bounds[1])
|
|
299
|
+
if b_lo is None or b_hi is None or b_lo > b_hi:
|
|
300
|
+
return None, None, "invalid frequency bounds"
|
|
301
|
+
low, high = b_lo, b_hi
|
|
302
|
+
|
|
303
|
+
if not low <= freq <= high:
|
|
304
|
+
freq_str = f"{freq / 1e6:.3f}"
|
|
305
|
+
return (
|
|
306
|
+
None,
|
|
307
|
+
None,
|
|
308
|
+
f"{brief(freq_str, 11)} MHz outside {low / 1e6:g}-{high / 1e6:g} MHz",
|
|
309
|
+
)
|
|
310
|
+
return sr, freq, None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def load_sigmf_geolocation(path: str) -> tuple[float, float, float] | None:
|
|
314
|
+
"""Extract (longitude, latitude, altitude) from a SigMF capture if present and valid."""
|
|
315
|
+
meta, problem = _load_sigmf_json(_meta_path(path))
|
|
316
|
+
if problem is not None:
|
|
317
|
+
return None
|
|
318
|
+
parsed, _ = _supported_capture(meta)
|
|
319
|
+
if parsed is None:
|
|
320
|
+
return None
|
|
321
|
+
_, capture = parsed
|
|
322
|
+
geo = capture.get("core:geolocation")
|
|
323
|
+
if not isinstance(geo, dict) or geo.get("type") != "Point":
|
|
324
|
+
return None
|
|
325
|
+
coords = geo.get("coordinates")
|
|
326
|
+
if not isinstance(coords, list) or len(coords) < 2:
|
|
327
|
+
return None
|
|
328
|
+
alt = coords[2] if len(coords) >= 3 else 0.0
|
|
329
|
+
return _validate_coords(coords[0], coords[1], alt)
|
pluto_siege/sound.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
"""Acoustic cue and sound effect synthesis for PlutoSiege."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import queue
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import wave
|
|
28
|
+
from enum import Enum, auto
|
|
29
|
+
from typing import NamedTuple
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SoundCue(Enum):
|
|
35
|
+
"""Categorized audio feedback cues for PlutoSiege events."""
|
|
36
|
+
|
|
37
|
+
SUCCESS = auto()
|
|
38
|
+
FAIL = auto()
|
|
39
|
+
START = auto()
|
|
40
|
+
TRIGGER = auto()
|
|
41
|
+
ALERT = auto()
|
|
42
|
+
|
|
43
|
+
# Semantic aliases
|
|
44
|
+
PASS = SUCCESS
|
|
45
|
+
CAPTURE_SUCCESS = SUCCESS
|
|
46
|
+
TX_START = START
|
|
47
|
+
TX_COMPLETE = SUCCESS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Tone(NamedTuple):
|
|
51
|
+
"""A single audible frequency pulse."""
|
|
52
|
+
|
|
53
|
+
freq_hz: int
|
|
54
|
+
duration_ms: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_CUES: dict[SoundCue, list[Tone]] = {
|
|
58
|
+
SoundCue.SUCCESS: [Tone(880, 80), Tone(1320, 120)],
|
|
59
|
+
SoundCue.FAIL: [Tone(370, 90), Tone(220, 160)],
|
|
60
|
+
SoundCue.START: [Tone(1100, 60)],
|
|
61
|
+
SoundCue.TRIGGER: [Tone(1480, 70)],
|
|
62
|
+
SoundCue.ALERT: [Tone(550, 70), Tone(550, 70)],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_sound_queue: queue.Queue[list[Tone]] = queue.Queue(maxsize=10)
|
|
66
|
+
_worker_thread: threading.Thread | None = None
|
|
67
|
+
_worker_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _generate_wav(tones: list[Tone]) -> bytes:
|
|
71
|
+
"""Synthesize an in-memory 16-bit mono PCM WAV from frequency tone definitions."""
|
|
72
|
+
sample_rate = 22050
|
|
73
|
+
segments: list[np.ndarray] = []
|
|
74
|
+
gap = np.zeros(int(sample_rate * 0.015), dtype="<i2")
|
|
75
|
+
for i, tone in enumerate(tones):
|
|
76
|
+
num_samples = int(sample_rate * (tone.duration_ms / 1000.0))
|
|
77
|
+
if num_samples > 0:
|
|
78
|
+
t = np.arange(num_samples, dtype=np.float64)
|
|
79
|
+
wave_pcm = (18000.0 * np.sin(2.0 * np.pi * tone.freq_hz * t / sample_rate)).astype("<i2")
|
|
80
|
+
segments.append(wave_pcm)
|
|
81
|
+
if i < len(tones) - 1:
|
|
82
|
+
segments.append(gap)
|
|
83
|
+
|
|
84
|
+
pcm_bytes = np.concatenate(segments).tobytes() if segments else b""
|
|
85
|
+
buf = io.BytesIO()
|
|
86
|
+
with wave.open(buf, "wb") as wf:
|
|
87
|
+
wf.setnchannels(1)
|
|
88
|
+
wf.setsampwidth(2)
|
|
89
|
+
wf.setframerate(sample_rate)
|
|
90
|
+
wf.writeframes(pcm_bytes)
|
|
91
|
+
return buf.getvalue()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _play_tones(tones: list[Tone]) -> None:
|
|
95
|
+
"""Synchronously play a sequence of frequency tones on the audio worker thread."""
|
|
96
|
+
try:
|
|
97
|
+
wav_bytes = _generate_wav(tones)
|
|
98
|
+
for player, args in (
|
|
99
|
+
("aplay", ["-q", "-"]),
|
|
100
|
+
("paplay", []),
|
|
101
|
+
("pw-play", ["-"]),
|
|
102
|
+
):
|
|
103
|
+
executable = shutil.which(player)
|
|
104
|
+
if executable:
|
|
105
|
+
try:
|
|
106
|
+
subprocess.run(
|
|
107
|
+
[executable, *args],
|
|
108
|
+
input=wav_bytes,
|
|
109
|
+
timeout=2.0,
|
|
110
|
+
check=True,
|
|
111
|
+
shell=False,
|
|
112
|
+
stdout=subprocess.DEVNULL,
|
|
113
|
+
stderr=subprocess.DEVNULL,
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
except (subprocess.SubprocessError, OSError):
|
|
117
|
+
continue
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
sys.stdout.write("\a")
|
|
123
|
+
sys.stdout.flush()
|
|
124
|
+
except (OSError, ValueError):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _worker() -> None:
|
|
129
|
+
"""Background consumer thread that plays tones without blocking SDR processing."""
|
|
130
|
+
while True:
|
|
131
|
+
tones = _sound_queue.get()
|
|
132
|
+
try:
|
|
133
|
+
_play_tones(tones)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
finally:
|
|
137
|
+
_sound_queue.task_done()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def play_cue(cue: SoundCue, enabled: bool = True) -> None:
|
|
141
|
+
"""Queue acoustic cue for non-blocking playback in the background worker thread."""
|
|
142
|
+
if not enabled or not isinstance(cue, SoundCue):
|
|
143
|
+
return
|
|
144
|
+
tones = _CUES.get(cue)
|
|
145
|
+
if not tones:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
global _worker_thread
|
|
149
|
+
with _worker_lock:
|
|
150
|
+
if _worker_thread is None or not _worker_thread.is_alive():
|
|
151
|
+
_worker_thread = threading.Thread(
|
|
152
|
+
target=_worker, daemon=True, name="PlutoSiegeAudio"
|
|
153
|
+
)
|
|
154
|
+
_worker_thread.start()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
_sound_queue.put_nowait(tones)
|
|
158
|
+
except queue.Full:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["SoundCue", "Tone", "play_cue"]
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
"""UI package for PlutoSiege TUI."""
|
|
18
|
+
|
|
19
|
+
from pluto_siege.ui.screens.main_menu import main
|
|
20
|
+
|
|
21
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
"""UI screen controllers subpackage."""
|
|
18
|
+
|
|
19
|
+
from pluto_siege.ui.screens.capture import screen_capture
|
|
20
|
+
from pluto_siege.ui.screens.loopback import screen_loopback
|
|
21
|
+
from pluto_siege.ui.screens.main_menu import curses_app, main
|
|
22
|
+
from pluto_siege.ui.screens.settings import screen_settings
|
|
23
|
+
from pluto_siege.ui.screens.transmit import screen_transmit
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"curses_app",
|
|
27
|
+
"main",
|
|
28
|
+
"screen_capture",
|
|
29
|
+
"screen_loopback",
|
|
30
|
+
"screen_settings",
|
|
31
|
+
"screen_transmit",
|
|
32
|
+
]
|