Skip to content

Commit 8658978

Browse files
KelseyCreekSoftwareTeque5Kyle A Logue
authored
Signal Hound Converter (#136)
* Add Signal Hound to SigMF conversion functionality Implement converter for Signal Hound files to SigMF format with metadata extraction and IQ data handling. * Add Signal Hound file support to conversion script Integrate Signal Hound file conversion into the main script, updating magic byte checks and handling for new file type. * Update sigmf/convert/signalhound.py Co-authored-by: Teque5 <teque5@gmail.com> * Made code updates based on PR input. Still need to work on returning SigMF object instead of dictionary Updated the last modified date and refactored comments for clarity. * This version of the Signal Hound to SigMF conversion uses more of the SigMF File Object, but is not working. Implement converter for Signal Hound files to SigMF format, including metadata extraction and IQ data conversion. * add parity tests for signal hound processing * refactor signalhound, add tests, add docs * revert change to init to resolve conflict temporarily * unified detect_converter function & better signalhound conversion * basic tests for signalhound without external repo * increment to v1.8.0 * switch to safe XML parsing * code deduplication * fix for python3.13 --------- Co-authored-by: Teque5 <teque5@gmail.com> Co-authored-by: Kyle A Logue <kyle.a.logue@aero.org>
1 parent 241ed97 commit 8658978

18 files changed

+859
-121
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ sample_rate = meta.sample_rate # get sample rate
3232
# read other formats containing RF time series as SigMF
3333
meta = sigmf.fromfile("recording.wav") # WAV
3434
meta = sigmf.fromfile("recording.cdif") # BLUE / Platinum
35+
meta = sigmf.fromfile("recording.xml") # Signal Hound Spike
3536
```
3637

3738
### Docs

docs/requirements.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
# pinned 2025-01-15
2-
sphinx==8.1.3
3-
sphinx-rtd-theme==3.0.2
1+
sphinx>=8.0
2+
sphinx-rtd-theme>=3.0

docs/source/converters.rst

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Conversion is available for:
1212

1313
* **BLUE files** - MIDAS Blue and Platinum BLUE RF recordings (usually ``.cdif``)
1414
* **WAV files** - Audio recordings (``.wav``)
15+
* **Signal Hound Spike files** - Signal Hound zero-span recordings (``.xml`` + ``.iq``)
1516

1617
All converters return a :class:`~sigmf.SigMFFile` object with converted metadata.
1718

@@ -29,6 +30,7 @@ formats and reads without writing any output files:
2930
# auto-detect and create NCD for any supported format
3031
meta = sigmf.fromfile("recording.cdif") # BLUE file
3132
meta = sigmf.fromfile("recording.wav") # WAV file
33+
meta = sigmf.fromfile("recording.xml") # Signal Hound Spike file
3234
meta = sigmf.fromfile("recording.sigmf") # SigMF archive
3335
3436
all_samples = meta.read_samples()
@@ -44,13 +46,17 @@ For programmatic access, use the individual converter functions directly:
4446
4547
from sigmf.convert.wav import wav_to_sigmf
4648
from sigmf.convert.blue import blue_to_sigmf
49+
from sigmf.convert.signalhound import signalhound_to_sigmf
4750
4851
# convert WAV to SigMF archive
4952
_ = wav_to_sigmf(wav_path="recording.wav", out_path="recording", create_archive=True)
5053
5154
# convert BLUE to SigMF pair and return metadata for new files
5255
meta = blue_to_sigmf(blue_path="recording.cdif", out_path="recording")
5356
57+
# convert Signal Hound Spike to SigMF pair
58+
meta = signalhound_to_sigmf(signalhound_path="recording.xml", out_path="recording")
59+
5460
5561
Command Line Usage
5662
~~~~~~~~~~~~~~~~~~
@@ -65,8 +71,9 @@ Converters are accessed through a unified command-line interface that automatica
6571
# examples
6672
sigmf_convert recording.cdif recording.sigmf
6773
sigmf_convert recording.wav recording.sigmf
74+
sigmf_convert recording.xml recording.sigmf
6875
69-
The converter uses magic byte detection to automatically identify BLUE and WAV file formats.
76+
The converter uses magic byte detection to automatically identify BLUE, WAV, and Signal Hound Spike file formats.
7077
No need to remember format-specific commands!
7178

7279

@@ -168,4 +175,40 @@ Examples
168175
169176
# access standard SigMF data & metadata
170177
all_samples = meta.read_samples()
171-
sample_rate_hz = meta.sample_rate
178+
sample_rate_hz = meta.sample_rate
179+
180+
181+
Signal Hound Spike Converter
182+
-----------------------------
183+
184+
The Signal Hound Spike converter handles recordings from Signal Hound devices.
185+
These recordings consist of two files: an XML metadata file (``.xml``) and a binary IQ data file (``.iq``).
186+
The converter extracts metadata from the XML file and references the IQ data file, storing Signal Hound-specific
187+
fields in the ``spike:`` namespace extension.
188+
189+
.. autofunction:: sigmf.convert.signalhound.signalhound_to_sigmf
190+
191+
Examples
192+
~~~~~~~~
193+
194+
.. code-block:: python
195+
196+
from sigmf.convert.signalhound import signalhound_to_sigmf
197+
198+
# standard conversion (provide path to XML file)
199+
meta = signalhound_to_sigmf(signalhound_path="recording.xml", out_path="recording")
200+
201+
# create NCD automatically (metadata-only, references original .iq file)
202+
meta = signalhound_to_sigmf(signalhound_path="recording.xml")
203+
204+
# access standard SigMF data & metadata
205+
all_samples = meta.read_samples()
206+
sample_rate = meta.sample_rate
207+
center_freq = meta.get_captures()[0]["core:frequency"]
208+
209+
# access Signal Hound-specific metadata in spike: namespace
210+
reference_level_dbm = meta.get_global_field("spike:reference_level_dbm")
211+
scale_factor_mw = meta.get_global_field("spike:scale_factor_mw")
212+
if_bandwidth_hz = meta.get_global_field("spike:if_bandwidth_hz")
213+
iq_filename = meta.get_global_field("spike:iq_filename") # original IQ file name
214+
preview_trace = meta.get_global_field("spike:preview_trace") # max-hold trace

docs/source/developers.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ To build the docs and host locally:
6060
.. code-block:: console
6161
6262
$ cd docs
63+
$ pip install -r requirements.txt
6364
$ make clean
6465
$ make html
6566
$ python3 -m http.server --directory build/html/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ requires-python = ">=3.7"
2424
dependencies = [
2525
"numpy", # for vector math
2626
"jsonschema", # for spec validation
27+
"defusedxml", # for safe XML parsing (XXE protection)
2728
]
2829
[project.urls]
2930
repository = "https://github.com/sigmf/sigmf-python"

sigmf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# SPDX-License-Identifier: LGPL-3.0-or-later
66

77
# version of this python module
8-
__version__ = "1.7.2"
8+
__version__ = "1.8.0"
99
# matching version of the SigMF specification
1010
__specification__ = "1.2.6"
1111

sigmf/convert/__init__.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright: Multiple Authors
2+
#
3+
# This file is part of sigmf-python. https://github.com/sigmf/sigmf-python
4+
#
5+
# SPDX-License-Identifier: LGPL-3.0-or-later
6+
7+
"""Convert non-SigMF recordings to SigMF format"""
8+
9+
from pathlib import Path
10+
11+
from ..error import SigMFConversionError
12+
13+
14+
def get_magic_bytes(file_path: Path, count: int = 4, offset: int = 0) -> bytes:
15+
"""
16+
Get magic bytes from a file to help identify file type.
17+
18+
Parameters
19+
----------
20+
file_path : Path
21+
Path to the file to read magic bytes from.
22+
count : int, optional
23+
Number of bytes to read. Default is 4.
24+
offset : int, optional
25+
Byte offset to start reading from. Default is 0.
26+
27+
Returns
28+
-------
29+
bytes
30+
Magic bytes from the file.
31+
32+
Raises
33+
------
34+
SigMFConversionError
35+
If file cannot be read or is too small.
36+
"""
37+
try:
38+
with open(file_path, "rb") as handle:
39+
handle.seek(offset)
40+
magic_bytes = handle.read(count)
41+
if len(magic_bytes) < count:
42+
raise SigMFConversionError(f"File {file_path} too small to read {count} magic bytes at offset {offset}")
43+
return magic_bytes
44+
except (IOError, OSError) as err:
45+
raise SigMFConversionError(f"Cannot read magic bytes from {file_path}: {err}") from err
46+
47+
48+
def detect_converter(file_path: Path):
49+
"""
50+
Detect the appropriate converter for a non-SigMF file.
51+
52+
Parameters
53+
----------
54+
file_path : Path
55+
Path to the file to detect.
56+
57+
Returns
58+
-------
59+
str
60+
The converter name: "wav", "blue", or "signalhound"
61+
62+
Raises
63+
------
64+
SigMFConversionError
65+
If the file format is not supported or cannot be detected.
66+
"""
67+
magic_bytes = get_magic_bytes(file_path, count=4, offset=0)
68+
69+
if magic_bytes == b"RIFF":
70+
return "wav"
71+
72+
elif magic_bytes == b"BLUE":
73+
return "blue"
74+
75+
elif magic_bytes == b"<?xm": # <?xml version="1.0" encoding="UTF-8"?>
76+
# Check if it's a Signal Hound Spike file
77+
# Skip XML declaration (40 bytes) and check for SignalHoundIQFile root element
78+
expanded_magic_bytes = get_magic_bytes(file_path, count=17, offset=40)
79+
if expanded_magic_bytes == b"SignalHoundIQFile":
80+
return "signalhound"
81+
else:
82+
raise SigMFConversionError(
83+
f"Unsupported XML file format. Root element: {expanded_magic_bytes}. "
84+
f"Expected SignalHoundIQFile for Signal Hound Spike files."
85+
)
86+
87+
else:
88+
raise SigMFConversionError(
89+
f"Unsupported file format. Magic bytes: {magic_bytes}. "
90+
f"Supported formats for conversion are WAV, BLUE/Platinum, and Signal Hound Spike."
91+
)

sigmf/convert/__main__.py

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313

1414
from .. import __version__ as toolversion
1515
from ..error import SigMFConversionError
16-
from ..utils import get_magic_bytes
16+
from . import detect_converter
1717
from .blue import blue_to_sigmf
18+
from .signalhound import signalhound_to_sigmf
1819
from .wav import wav_to_sigmf
1920

2021

@@ -60,8 +61,8 @@ def main() -> None:
6061
exclusive_group.add_argument(
6162
"--ncd", action="store_true", help="Output .sigmf-meta only and process as a Non-Conforming Dataset (NCD)"
6263
)
63-
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
6464
parser.add_argument("--version", action="version", version=f"%(prog)s v{toolversion}")
65+
6566
args = parser.parse_args()
6667

6768
level_lut = {
@@ -85,33 +86,16 @@ def main() -> None:
8586
if output_path.is_dir():
8687
raise SigMFConversionError(f"Output path must be a filename, not a directory: {output_path}")
8788

88-
# detect file type using magic bytes (same logic as fromfile())
89-
magic_bytes = get_magic_bytes(input_path, count=4, offset=0)
90-
91-
if magic_bytes == b"RIFF":
92-
# WAV file
93-
_ = wav_to_sigmf(
94-
wav_path=input_path,
95-
out_path=output_path,
96-
create_archive=args.archive,
97-
create_ncd=args.ncd,
98-
overwrite=args.overwrite,
99-
)
100-
101-
elif magic_bytes == b"BLUE":
102-
# BLUE file
103-
_ = blue_to_sigmf(
104-
blue_path=input_path,
105-
out_path=output_path,
106-
create_archive=args.archive,
107-
create_ncd=args.ncd,
108-
overwrite=args.overwrite,
109-
)
89+
# detect file type using magic bytes
90+
converter_type = detect_converter(input_path)
11091

111-
else:
112-
raise SigMFConversionError(
113-
f"Unsupported file format. Magic bytes: {magic_bytes}. "
114-
f"Supported formats for conversion are WAV and BLUE/Platinum."
92+
if converter_type == "wav":
93+
_ = wav_to_sigmf(wav_path=input_path, out_path=output_path, create_archive=args.archive, create_ncd=args.ncd)
94+
elif converter_type == "blue":
95+
_ = blue_to_sigmf(blue_path=input_path, out_path=output_path, create_archive=args.archive, create_ncd=args.ncd)
96+
elif converter_type == "signalhound":
97+
_ = signalhound_to_sigmf(
98+
signalhound_path=input_path, out_path=output_path, create_archive=args.archive, create_ncd=args.ncd
11599
)
116100

117101

sigmf/convert/blue.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import numpy as np
2626
from packaging.version import InvalidVersion, Version
2727

28-
from .. import __version__ as toolversion
2928
from ..error import SigMFConversionError
3029
from ..sigmffile import SigMFFile, fromfile, get_sigmf_filenames
3130
from ..utils import SIGMF_DATETIME_ISO8601_FMT

0 commit comments

Comments
 (0)