Skip to main content

comnum_spec/io/
sigmf.rs

1//! SigMF recordings: the metadata, the captures, the annotations, the archives.
2
3use super::Datatype;
4use super::raw::{decode_all, write_creating_dirs, write_raw};
5use comnum_core::{Error, Result, ensure};
6use comnum_math::C64;
7use serde_json::{Value as Json, json};
8use std::path::{Path, PathBuf};
9
10/// A capture segment of a SigMF recording.
11#[derive(Clone, Debug, PartialEq)]
12pub struct Capture {
13    /// `core:sample_start`: the first sample of the segment.
14    pub sample_start: usize,
15    /// `core:frequency`: the centre frequency (Hz).
16    pub frequency: Option<f64>,
17    /// `core:datetime` (ISO 8601).
18    pub datetime: Option<String>,
19}
20
21/// An annotation of a SigMF recording.
22#[derive(Clone, Debug, Default, PartialEq)]
23pub struct Annotation {
24    /// `core:sample_start`.
25    pub sample_start: usize,
26    /// `core:sample_count` (to the end of the recording when absent).
27    pub sample_count: Option<usize>,
28    /// `core:label` (a short name: the modulation, the emitter, …).
29    pub label: Option<String>,
30    /// `core:comment`.
31    pub comment: Option<String>,
32    /// `core:freq_lower_edge` (Hz).
33    pub freq_lower_edge: Option<f64>,
34    /// `core:freq_upper_edge` (Hz).
35    pub freq_upper_edge: Option<f64>,
36}
37
38impl Annotation {
39    /// The keys of [`Annotation::from_mapping`].
40    pub const KEYS: [&'static str; 6] =
41        ["sample_start", "sample_count", "label", "comment", "freq_lower_edge", "freq_upper_edge"];
42
43    /// An annotation from a mapping of the keys of [`Annotation::KEYS`] (the SigMF keys
44    /// without their `core:` prefix), as the Python bindings take it: `sample_start` is
45    /// required, the others are optional.
46    ///
47    /// # Errors
48    /// [`Error::Config`] for another key, or a missing or negative `sample_start`.
49    ///
50    /// # Examples
51    /// ```
52    /// use comnum_spec::io::Annotation;
53    /// let a = Annotation::from_mapping(&serde_json::json!({"sample_start": 3, "label": "QPSK"}))?;
54    /// assert_eq!((a.sample_start, a.label.as_deref()), (3, Some("QPSK")));
55    /// assert!(Annotation::from_mapping(&serde_json::json!({"sample_start": 3, "start": 1})).is_err());
56    /// # Ok::<(), comnum_core::Error>(())
57    /// ```
58    pub fn from_mapping(a: &Json) -> Result<Annotation> {
59        let keys = Annotation::KEYS;
60        if let Some(k) = a.as_object().and_then(|o| o.keys().find(|k| !keys.contains(&k.as_str()))) {
61            return Err(Error::Config(format!("unknown annotation key {k:?} (one of {keys:?})")));
62        }
63        let start = a["sample_start"]
64            .as_u64()
65            .ok_or_else(|| Error::Config("every annotation needs a non-negative integer sample_start".into()))?;
66        Ok(Annotation {
67            sample_start: start as usize,
68            sample_count: a["sample_count"].as_u64().map(|v| v as usize),
69            label: a["label"].as_str().map(str::to_string),
70            comment: a["comment"].as_str().map(str::to_string),
71            freq_lower_edge: a["freq_lower_edge"].as_f64(),
72            freq_upper_edge: a["freq_upper_edge"].as_f64(),
73        })
74    }
75
76    fn from_json(a: &Json) -> Annotation {
77        let s = |k: &str| a[k].as_str().map(str::to_string);
78        Annotation {
79            sample_start: a["core:sample_start"].as_u64().unwrap_or(0) as usize,
80            sample_count: a["core:sample_count"].as_u64().map(|v| v as usize),
81            label: s("core:label"),
82            comment: s("core:comment"),
83            freq_lower_edge: a["core:freq_lower_edge"].as_f64(),
84            freq_upper_edge: a["core:freq_upper_edge"].as_f64(),
85        }
86    }
87
88    fn to_json(&self) -> Json {
89        let mut m = serde_json::Map::new();
90        m.insert("core:sample_start".into(), json!(self.sample_start));
91        if let Some(v) = self.sample_count {
92            m.insert("core:sample_count".into(), json!(v));
93        }
94        for (k, v) in [("core:label", &self.label), ("core:comment", &self.comment)] {
95            if let Some(v) = v {
96                m.insert(k.into(), json!(v));
97            }
98        }
99        for (k, v) in [("core:freq_lower_edge", self.freq_lower_edge), ("core:freq_upper_edge", self.freq_upper_edge)] {
100            if let Some(v) = v {
101                m.insert(k.into(), json!(v));
102            }
103        }
104        Json::Object(m)
105    }
106}
107
108/// A SigMF recording.
109#[derive(Clone, Debug)]
110pub struct SigMF {
111    /// The samples, the channels interleaved (`num_channels` per instant).
112    pub samples: Vec<C64>,
113    /// `core:num_channels` (1 when absent).
114    pub num_channels: usize,
115    /// `core:sample_rate` (Hz).
116    pub sample_rate: Option<f64>,
117    /// `core:frequency` of the first capture (Hz).
118    pub frequency: Option<f64>,
119    /// `core:datatype`.
120    pub datatype: Datatype,
121    /// The capture segments, in the order of their first sample.
122    pub captures: Vec<Capture>,
123    /// The annotations, in the order of their first sample.
124    pub annotations: Vec<Annotation>,
125    /// The whole metadata document.
126    pub meta: Json,
127}
128
129impl SigMF {
130    /// The samples of channel `k` (none beyond the last channel).
131    pub fn channel(&self, k: usize) -> Vec<C64> {
132        self.samples.iter().skip(k).step_by(self.num_channels.max(1)).copied().collect()
133    }
134
135    /// The samples of channel `k`, which must exist.
136    ///
137    /// # Errors
138    /// [`Error::Config`] if the recording has no channel `k`.
139    pub fn try_channel(&self, k: usize) -> Result<Vec<C64>> {
140        ensure(k < self.num_channels, || format!("channel {k} of a recording of {}", self.num_channels))?;
141        Ok(self.channel(k))
142    }
143
144    /// The full scale of an integer type, $2^{b-1}$ for $b$ bits (`None` for floating
145    /// point): dividing by it gives the samples of `sigmf-python` with `autoscale`.
146    pub fn full_scale(&self) -> Option<f64> {
147        self.datatype.scalar.is_integer().then(|| self.datatype.scalar.half_range())
148    }
149
150    /// The samples of channel `k` covered by an annotation.
151    pub fn annotated(&self, a: &Annotation, k: usize) -> Vec<C64> {
152        let x = self.channel(k);
153        let start = a.sample_start.min(x.len());
154        let end = a.sample_count.map_or(x.len(), |c| (start + c).min(x.len()));
155        x[start..end].to_vec()
156    }
157}
158
159/// The paths `(name.sigmf-meta, name.sigmf-data)` of a SigMF recording given either.
160fn sigmf_paths(path: &Path) -> (PathBuf, PathBuf) {
161    let s = path.to_string_lossy();
162    let base = s.strip_suffix(".sigmf-meta").or_else(|| s.strip_suffix(".sigmf-data")).unwrap_or(&s).to_string();
163    (PathBuf::from(format!("{base}.sigmf-meta")), PathBuf::from(format!("{base}.sigmf-data")))
164}
165
166/// The files of a tar archive (POSIX ustar, as written by `sigmf-python` and GNU tar):
167/// `(name, content)`.
168pub(super) fn untar(bytes: &[u8]) -> Result<Vec<(String, &[u8])>> {
169    let mut files = Vec::new();
170    let mut at = 0;
171    let octal = |f: &[u8]| -> Result<usize> {
172        let s: String = f.iter().take_while(|&&c| c != 0).map(|&c| c as char).collect();
173        usize::from_str_radix(s.trim(), 8).map_err(|_| Error::Config(format!("tar: bad size field {s:?}")))
174    };
175    let text = |f: &[u8]| f.iter().take_while(|&&c| c != 0).map(|&c| c as char).collect::<String>();
176    while at + 512 <= bytes.len() {
177        let h = &bytes[at..at + 512];
178        if h.iter().all(|&c| c == 0) {
179            break;
180        }
181        let size = octal(&h[124..136])?;
182        let (prefix, name) = (text(&h[345..500]), text(&h[..100]));
183        let name = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
184        let start = at + 512;
185        // a corrupt size field may be anything: no arithmetic that can overflow
186        let Some(end) = start.checked_add(size).filter(|&e| e <= bytes.len()) else {
187            return Err(Error::Config(format!("tar: {name} ends after the archive")));
188        };
189        // regular files only ('0' or NUL)
190        if matches!(h[156], b'0' | 0) {
191            files.push((name, &bytes[start..end]));
192        }
193        at = end.div_ceil(512).saturating_mul(512);
194    }
195    Ok(files)
196}
197
198/// Read a SigMF recording from its `.sigmf-meta` or `.sigmf-data` file, their common
199/// base name, or a `.sigmf` archive (the first recording of the archive).
200///
201/// The reader follows SigMF 1.x: every data type ([`Datatype`]), interleaved channels
202/// (`core:num_channels`), the header bytes of the captures (`core:header_bytes`) and the
203/// trailing bytes of the file (`core:trailing_bytes`) removed, a non-conforming dataset
204/// (`core:dataset`, a data file of another name next to the metadata).
205///
206/// # Errors
207/// [`Error::Io`] for a missing file, [`Error::Config`] for invalid metadata,
208/// [`Error::Shape`] for a data file that is not a whole number of samples.
209pub fn read_sigmf(path: impl AsRef<Path>) -> Result<SigMF> {
210    let path = path.as_ref();
211    if path.extension().is_some_and(|e| e == "sigmf") {
212        let bytes = std::fs::read(path)?;
213        let files = untar(&bytes)?;
214        let (meta_name, meta) = files
215            .iter()
216            .find(|(n, _)| n.ends_with(".sigmf-meta"))
217            .ok_or_else(|| Error::Config(format!("{}: no .sigmf-meta in the archive", path.display())))?;
218        let data_name = format!("{}.sigmf-data", meta_name.trim_end_matches(".sigmf-meta"));
219        let data = files.iter().find(|(n, _)| *n == data_name).map_or(&[][..], |(_, d)| *d);
220        let meta: Json =
221            serde_json::from_slice(meta).map_err(|e| Error::Config(format!("{}/{meta_name}: {e}", path.display())))?;
222        return parse_sigmf(meta, data, &format!("{}/{meta_name}", path.display()));
223    }
224    let (meta_path, mut data_path) = sigmf_paths(path);
225    let meta: Json = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)
226        .map_err(|e| Error::Config(format!("{}: {e}", meta_path.display())))?;
227    if let Some(d) = meta["global"]["core:dataset"].as_str() {
228        data_path = meta_path.with_file_name(d);
229    }
230    let data = std::fs::read(&data_path)?;
231    parse_sigmf(meta, &data, &meta_path.display().to_string())
232}
233
234pub(super) fn parse_sigmf(meta: Json, data: &[u8], name: &str) -> Result<SigMF> {
235    let global = &meta["global"];
236    let dt =
237        global["core:datatype"].as_str().ok_or_else(|| Error::Config(format!("{name}: no global core:datatype")))?;
238    let datatype = Datatype::parse(dt)?;
239    let num_channels = global["core:num_channels"].as_u64().unwrap_or(1).max(1) as usize;
240    let trailing = global["core:trailing_bytes"].as_u64().unwrap_or(0) as usize;
241    let data = &data[..data.len().saturating_sub(trailing)];
242    let mut captures: Vec<(Capture, usize)> = meta["captures"]
243        .as_array()
244        .map(|cs| {
245            cs.iter()
246                .map(|c| {
247                    let cap = Capture {
248                        sample_start: c["core:sample_start"].as_u64().unwrap_or(0) as usize,
249                        frequency: c["core:frequency"].as_f64(),
250                        datetime: c["core:datetime"].as_str().map(str::to_string),
251                    };
252                    (cap, c["core:header_bytes"].as_u64().unwrap_or(0) as usize)
253                })
254                .collect()
255        })
256        .unwrap_or_default();
257    captures.sort_by_key(|(c, _)| c.sample_start);
258    // drop the header bytes in front of every capture
259    let frame = datatype.sample_bytes() * num_channels;
260    let mut body = Vec::with_capacity(data.len());
261    let mut at = 0usize;
262    for (i, (c, header)) in captures.iter().enumerate() {
263        let skip_to = at.saturating_add(*header);
264        ensure_le(skip_to, data.len(), name)?;
265        // corrupt sample starts may be anything: saturate rather than overflow
266        let samples = captures.get(i + 1).map(|(n, _)| (n.sample_start - c.sample_start).saturating_mul(frame));
267        let end = samples.map_or(data.len(), |b| skip_to.saturating_add(b).min(data.len()));
268        body.extend_from_slice(&data[skip_to..end]);
269        at = end;
270    }
271    if captures.is_empty() {
272        body.extend_from_slice(data);
273    }
274    let samples = decode_all(&body, datatype)?;
275    if samples.len() % num_channels != 0 {
276        return Err(Error::Shape(format!(
277            "{name}: {} samples is not a whole number of {num_channels} channels",
278            samples.len()
279        )));
280    }
281    let mut annotations: Vec<Annotation> =
282        meta["annotations"].as_array().map(|a| a.iter().map(Annotation::from_json).collect()).unwrap_or_default();
283    annotations.sort_by_key(|a| a.sample_start);
284    let captures: Vec<Capture> = captures.into_iter().map(|(c, _)| c).collect();
285    Ok(SigMF {
286        samples,
287        num_channels,
288        sample_rate: global["core:sample_rate"].as_f64(),
289        frequency: captures.first().and_then(|c| c.frequency),
290        datatype,
291        captures,
292        annotations,
293        meta,
294    })
295}
296
297fn ensure_le(a: usize, b: usize, name: &str) -> Result<()> {
298    if a > b {
299        return Err(Error::Config(format!("{name}: the header bytes of a capture go beyond the data file")));
300    }
301    Ok(())
302}
303
304/// Write a SigMF recording `base.sigmf-meta` and `base.sigmf-data`.
305///
306/// `global` adds keys to the global object of the metadata (a description, the
307/// hardware, the symbol rate of the transmitted signal, …).
308///
309/// # Errors
310/// [`Error::Io`].
311pub fn write_sigmf(
312    base: impl AsRef<Path>,
313    x: &[C64],
314    datatype: Datatype,
315    sample_rate: f64,
316    global: Option<&Json>,
317) -> Result<()> {
318    write_sigmf_annotated(base, x, datatype, sample_rate, None, global, &[])
319}
320
321/// Write a SigMF recording with the centre frequency of its capture (`core:frequency`)
322/// and annotations (see [`write_sigmf`]).
323///
324/// # Errors
325/// [`Error::Io`].
326pub fn write_sigmf_annotated(
327    base: impl AsRef<Path>,
328    x: &[C64],
329    datatype: Datatype,
330    sample_rate: f64,
331    frequency: Option<f64>,
332    global: Option<&Json>,
333    annotations: &[Annotation],
334) -> Result<()> {
335    let (meta_path, data_path) = sigmf_paths(base.as_ref());
336    write_raw(&data_path, x, datatype)?;
337    let mut g = json!({
338        "core:datatype": datatype.name(),
339        "core:sample_rate": sample_rate,
340        "core:version": "1.2.0",
341        "core:recorder": format!("comnum {}", env!("CARGO_PKG_VERSION")),
342    });
343    if let (Some(Json::Object(extra)), Json::Object(gm)) = (global, &mut g) {
344        gm.extend(extra.clone());
345    }
346    let mut capture = json!({"core:sample_start": 0});
347    if let (Some(f), Json::Object(c)) = (frequency, &mut capture) {
348        c.insert("core:frequency".into(), json!(f));
349    }
350    let mut annotations = annotations.to_vec();
351    annotations.sort_by_key(|a| a.sample_start);
352    let annotations: Vec<Json> = annotations.iter().map(Annotation::to_json).collect();
353    let meta = json!({"global": g, "captures": [capture], "annotations": annotations});
354    write_creating_dirs(&meta_path, (serde_json::to_string_pretty(&meta).unwrap_or_default() + "\n").as_bytes())
355}