Skip to main content

comnum_spec/
io.rs

1//! Reading and writing sampled signals: measured captures and reference sequences.
2//!
3//! Three formats cover the usual outputs of oscilloscopes, software-defined radios and
4//! numerical tools:
5//!
6//! - **SigMF** (the Signal Metadata Format of GNU Radio and the SDR community): a JSON
7//!   file `name.sigmf-meta` with the data type and the sampling rate, next to the raw
8//!   samples `name.sigmf-data`; [`read_sigmf`], [`write_sigmf`];
9//! - **raw** interleaved samples with a SigMF data type (`cf32_le` is the `.cfile` of
10//!   GNU Radio, `ci16_le` the IQ format of most SDRs and many oscilloscopes);
11//!   [`read_raw`], [`write_raw`];
12//! - **NPY** arrays of NumPy (complex, real or integer, one dimension); [`read_npy`],
13//!   [`write_npy`], [`write_npy_i64`].
14//!
15//! [`read_samples`] picks the format from the extension. Integer samples are converted
16//! to floating point without scaling (the receivers normalise the power).
17//!
18//! # Examples
19//! ```
20//! use comnum_spec::io::{read_sigmf, write_sigmf, Datatype};
21//! use comnum_math::C64;
22//! let dir = std::env::temp_dir().join("comnum-doc-io");
23//! let x: Vec<C64> = (0..8).map(|k| C64::new(k as f64, -(k as f64))).collect();
24//! write_sigmf(dir.join("capture"), &x, Datatype::CI16, 64e9, None).unwrap();
25//! let c = read_sigmf(dir.join("capture.sigmf-meta")).unwrap();
26//! assert_eq!(c.samples, x);
27//! assert_eq!((c.sample_rate, c.datatype), (Some(64e9), Datatype::CI16));
28//! ```
29
30mod datatype;
31mod npy;
32mod raw;
33mod sigmf;
34
35pub use datatype::{Datatype, Scalar};
36pub use npy::{Npy, NpyData, read_npy, write_npy, write_npy_i64};
37pub use raw::{read_raw, write_raw};
38pub use sigmf::{Annotation, Capture, SigMF, read_sigmf, write_sigmf, write_sigmf_annotated};
39
40use comnum_core::{Error, Result};
41use comnum_math::C64;
42use std::path::Path;
43
44/// The samples of one channel of a recording ([`read_samples`]).
45#[derive(Clone, Debug, PartialEq)]
46pub struct Samples {
47    /// The complex samples.
48    pub samples: Vec<C64>,
49    /// The sampling rate (Hz), when the file records it.
50    pub sample_rate: Option<f64>,
51}
52
53/// Read samples from a SigMF recording (`.sigmf-meta`, `.sigmf-data`, `.sigmf`), an NPY
54/// array (`.npy`) or a raw file of the given data type: the samples of `channel` and the
55/// sampling rate when the file records it.
56///
57/// # Errors
58/// [`Error::Config`] for a raw file without a data type, or a channel that the recording
59/// does not have; the errors of the readers.
60pub fn read_samples(path: impl AsRef<Path>, datatype: Option<Datatype>, channel: usize) -> Result<Samples> {
61    let path = path.as_ref();
62    let name = path.to_string_lossy();
63    let channels = |n: usize| {
64        if channel < n {
65            Ok(())
66        } else {
67            Err(Error::Config(format!("{}: channel {channel} of a recording of {n}", path.display())))
68        }
69    };
70    if name.ends_with(".sigmf-meta") || name.ends_with(".sigmf-data") || name.ends_with(".sigmf") {
71        let s = read_sigmf(path)?;
72        channels(s.num_channels)?;
73        return Ok(Samples { samples: s.channel(channel), sample_rate: s.sample_rate });
74    }
75    channels(1)?;
76    if name.ends_with(".npy") {
77        return Ok(Samples { samples: read_npy(path)?.to_complex(), sample_rate: None });
78    }
79    let dt = datatype.ok_or_else(|| {
80        Error::Config(format!("{}: a raw file needs a datatype (cf32_le, ci16_le, …)", path.display()))
81    })?;
82    Ok(Samples { samples: read_raw(path, dt)?, sample_rate: None })
83}
84
85#[cfg(test)]
86mod tests {
87    use super::raw::encode_all;
88    use super::sigmf::{parse_sigmf, untar};
89    use super::*;
90    use serde_json::json;
91    use std::path::PathBuf;
92
93    fn tmp(name: &str) -> PathBuf {
94        std::env::temp_dir().join(format!("comnum-io-{}-{name}", std::process::id()))
95    }
96
97    #[test]
98    fn raw_round_trips_for_every_type() {
99        let x: Vec<C64> = (0..50).map(|k| C64::new(k as f64 - 25.0, (3.0 - k as f64 / 2.0).round())).collect();
100        for dt in [Datatype::CF32, Datatype::CF64, Datatype::CI32, Datatype::CI16, Datatype::CI8] {
101            let p = tmp(&dt.name());
102            write_raw(&p, &x, dt).unwrap();
103            assert_eq!(read_raw(&p, dt).unwrap(), x, "{}", dt.name());
104            assert_eq!(std::fs::metadata(&p).unwrap().len() as usize, x.len() * dt.sample_bytes());
105            std::fs::remove_file(p).ok();
106        }
107        let p = tmp("rf32");
108        write_raw(&p, &x, Datatype::RF32).unwrap();
109        assert!(read_raw(&p, Datatype::RF32).unwrap().iter().zip(&x).all(|(a, b)| a.re == b.re && a.im == 0.0));
110        std::fs::remove_file(p).ok();
111    }
112
113    #[test]
114    fn every_sigmf_type_round_trips() {
115        let x: Vec<C64> = (0..40).map(|k| C64::new(k as f64 - 20.0, (5.0 - k as f64 / 3.0).round())).collect();
116        for c in ["c", "r"] {
117            for t in ["f32", "f64", "i32", "i16", "i8", "u32", "u16", "u8"] {
118                for e in ["_le", "_be"] {
119                    let name = format!("{c}{t}{e}");
120                    let dt = Datatype::parse(&name).unwrap();
121                    let p = tmp(&format!("all-{name}"));
122                    write_raw(&p, &x, dt).unwrap();
123                    let y = read_raw(&p, dt).unwrap();
124                    let want: Vec<C64> = x.iter().map(|v| if dt.complex { *v } else { C64::new(v.re, 0.0) }).collect();
125                    assert_eq!(y, want, "{name}");
126                    std::fs::remove_file(p).ok();
127                    // the 8-bit types have no endianness
128                    assert_eq!(dt.name(), if t.ends_with('8') { format!("{c}{t}") } else { name });
129                }
130            }
131        }
132        // big endian: 1.0 as f32 is 3f 80 00 00
133        let dt = Datatype::parse("rf32_be").unwrap();
134        assert_eq!(dt.decode(&[0x3f, 0x80, 0, 0]), C64::new(1.0, 0.0));
135        // unsigned: centred by 2^(b-1)
136        assert_eq!(Datatype::CU8.decode(&[128, 0]), C64::new(0.0, -128.0));
137        assert!(Datatype::parse("cx16").is_err() && Datatype::parse("ci16_xx").is_err());
138    }
139
140    #[test]
141    fn sigmf_headers_channels_and_annotations() {
142        // two channels of ci16, two captures each preceded by a header, trailing bytes
143        let dir = tmp("ncd");
144        std::fs::create_dir_all(&dir).unwrap();
145        let x: Vec<C64> = (0..12).map(|k| C64::new(k as f64, -(k as f64))).collect();
146        let raw = encode_all(&x, Datatype::CI16);
147        // capture 0: samples 0..3 (6 values, 24 bytes) after 5 header bytes; capture 1:
148        // samples 3..6 after 7 header bytes; then 3 trailing bytes
149        let mut data = vec![9u8; 5];
150        data.extend_from_slice(&raw[..24]);
151        data.extend(vec![8u8; 7]);
152        data.extend_from_slice(&raw[24..]);
153        data.extend([1, 2, 3]);
154        std::fs::write(dir.join("other.bin"), &data).unwrap();
155        let meta = json!({
156            "global": {"core:datatype": "ci16_le", "core:num_channels": 2, "core:sample_rate": 1e6,
157                       "core:trailing_bytes": 3, "core:dataset": "other.bin", "core:version": "1.2.0"},
158            "captures": [{"core:sample_start": 3, "core:header_bytes": 7, "core:frequency": 2.4e9},
159                         {"core:sample_start": 0, "core:header_bytes": 5, "core:frequency": 2.4e9}],
160            "annotations": [{"core:sample_start": 4, "core:sample_count": 2, "core:label": "QPSK"}]
161        });
162        std::fs::write(dir.join("rec.sigmf-meta"), meta.to_string()).unwrap();
163        let r = read_sigmf(dir.join("rec.sigmf-meta")).unwrap();
164        assert_eq!(r.samples, x);
165        assert_eq!((r.num_channels, r.frequency, r.captures.len()), (2, Some(2.4e9), 2));
166        assert_eq!(r.channel(1), x.iter().skip(1).step_by(2).copied().collect::<Vec<_>>());
167        let a = &r.annotations[0];
168        assert_eq!((a.label.as_deref(), r.annotated(a, 0)), (Some("QPSK"), vec![x[8], x[10]]));
169        assert_eq!(r.full_scale(), Some(32768.0));
170        // written annotations come back
171        let base = dir.join("w");
172        let ann = Annotation {
173            sample_start: 2,
174            sample_count: Some(3),
175            label: Some("16-QAM".into()),
176            ..Annotation::default()
177        };
178        write_sigmf_annotated(&base, &x, Datatype::CF32, 1e6, Some(915e6), None, std::slice::from_ref(&ann)).unwrap();
179        let w = read_sigmf(&base).unwrap();
180        assert_eq!((w.annotations, w.frequency), (vec![ann], Some(915e6)));
181        std::fs::remove_dir_all(dir).ok();
182    }
183
184    #[test]
185    fn npy_round_trips_and_reads_numpy_headers() {
186        let x: Vec<C64> = (0..7).map(|k| C64::new(k as f64 * 0.5, -1.0)).collect();
187        let p = tmp("a.npy");
188        write_npy(&p, &x).unwrap();
189        let a = read_npy(&p).unwrap();
190        assert_eq!((a.shape.clone(), a.data.clone()), (vec![7], NpyData::Complex(x)));
191        let bytes = std::fs::read(&p).unwrap();
192        assert_eq!((10 + u16::from_le_bytes([bytes[8], bytes[9]]) as usize) % 64, 0);
193        write_npy_i64(&p, &[3, -1, 7]).unwrap();
194        assert_eq!(read_npy(&p).unwrap().data, NpyData::Int(vec![3, -1, 7]));
195        std::fs::write(&p, b"not numpy").unwrap();
196        assert!(read_npy(&p).is_err());
197        std::fs::remove_file(p).ok();
198    }
199
200    #[test]
201    fn corrupt_archives_and_arrays_are_errors_not_panics() {
202        // a tar header whose size field is out of range, or not a number
203        for size in [&b"77777777777\0"[..], b"zz\0"] {
204            let mut tar = vec![0u8; 1024];
205            tar[..14].copy_from_slice(b"rec.sigmf-meta");
206            tar[124..124 + size.len()].copy_from_slice(size);
207            assert!(untar(&tar).is_err());
208            let p = tmp("corrupt.sigmf");
209            std::fs::write(&p, &tar).unwrap();
210            assert!(read_sigmf(&p).is_err());
211            std::fs::remove_file(p).ok();
212        }
213        // captures whose header bytes or sample starts overflow
214        for (start, header) in [(0u64, u64::MAX), (u64::MAX, 0)] {
215            let meta = json!({"global": {"core:datatype": "cf32_le"},
216                "captures": [{"core:sample_start": 0, "core:header_bytes": header},
217                             {"core:sample_start": start, "core:header_bytes": 0}]});
218            let r = parse_sigmf(meta, &[0u8; 64], "corrupt");
219            assert!(r.is_err() || r.is_ok_and(|s| s.samples.len() == 8), "{start} {header}");
220        }
221        // an NPY array whose shape overflows, or whose header is cut
222        let npy = |header: &str| {
223            let mut b = b"\x93NUMPY\x01\x00".to_vec();
224            b.extend((header.len() as u16).to_le_bytes());
225            b.extend(header.as_bytes());
226            b.extend([0u8; 16]);
227            b
228        };
229        let p = tmp("corrupt.npy");
230        for header in [
231            "{'descr': '<f8', 'fortran_order': False, 'shape': (4294967296, 4294967296, 4294967296), }",
232            "{'descr': '<c16', 'fortran_order': False, 'shape': (1152921504606846976,), }",
233            "{'descr': '<f8', 'fortran_order': False, 'shape': (-1,), }",
234            "{'descr': '",
235        ] {
236            std::fs::write(&p, npy(header)).unwrap();
237            assert!(read_npy(&p).is_err(), "{header}");
238        }
239        let mut cut = npy("{'descr': '<f8', 'fortran_order': False, 'shape': (2,), }");
240        cut[8] = 0xff;
241        std::fs::write(&p, cut).unwrap();
242        assert!(read_npy(&p).is_err());
243        std::fs::remove_file(p).ok();
244    }
245
246    #[test]
247    fn sigmf_keeps_the_metadata() {
248        let x = vec![C64::new(0.25, -0.5); 16];
249        let base = tmp("rec");
250        write_sigmf(&base, &x, Datatype::CF32, 80e9, Some(&json!({"comnum:symbol_rate": 32e9}))).unwrap();
251        let r = read_sigmf(format!("{}.sigmf-data", base.display())).unwrap();
252        assert_eq!(r.samples, x);
253        assert_eq!(r.meta["global"]["comnum:symbol_rate"], json!(32e9));
254        let Samples { samples: s, sample_rate: fs } =
255            read_samples(format!("{}.sigmf-meta", base.display()), None, 0).unwrap();
256        assert_eq!((s.len(), fs), (16, Some(80e9)));
257        assert!(read_samples(&base, None, 0).is_err());
258        assert!(read_samples(format!("{}.sigmf-meta", base.display()), None, 1).is_err());
259    }
260}