Skip to content

Commit 82cfac9

Browse files
author
Jacob Gilbert
committed
enhancing file export to support sigmf, clearer decimation, shifting, etc
1 parent e853c6b commit 82cfac9

8 files changed

Lines changed: 360 additions & 37 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
cmake_minimum_required(VERSION 3.1)
1+
cmake_minimum_required(VERSION 3.6)
22
project(inspectrum CXX)
33
enable_testing()
44

src/plotview.cpp

Lines changed: 281 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,81 @@
1818
*/
1919

2020
#include "plotview.h"
21+
#include <cmath>
2122
#include <iostream>
2223
#include <fstream>
24+
#include <limits>
2325
#include <QtGlobal>
2426
#include <QApplication>
27+
#include <QCheckBox>
2528
#include <QClipboard>
2629
#include <QDebug>
2730
#include <QFileDialog>
31+
#include <QFileInfo>
2832
#include <QGridLayout>
2933
#include <QGroupBox>
34+
#include <QHBoxLayout>
35+
#include <QJsonArray>
36+
#include <QJsonDocument>
37+
#include <QJsonObject>
38+
#include <QLabel>
3039
#include <QMenu>
40+
#include <QMessageBox>
3141
#include <QPainter>
3242
#include <QProgressDialog>
43+
#include <QPushButton>
3344
#include <QRadioButton>
3445
#include <QScrollBar>
46+
#include <QSizePolicy>
3547
#include <QSpinBox>
3648
#include <QToolTip>
3749
#include <QVBoxLayout>
3850
#include "plots.h"
51+
#include "util.h"
52+
53+
// Convert a normalised float sample (~[-1, 1]) to a signed 16-bit value.
54+
static inline int16_t floatToS16(float v)
55+
{
56+
return (int16_t)clamp((int32_t)lrintf(v * 32768.0f), -32768, 32767);
57+
}
58+
59+
// Write a single output sample. Overloaded so the export template compiles for
60+
// both complex<float> and float sources; sc16 only applies to complex sources.
61+
static inline void writeSample(std::ofstream &os, const std::complex<float> &v, bool sc16)
62+
{
63+
if (sc16) {
64+
int16_t iq[2] = { floatToS16(v.real()), floatToS16(v.imag()) };
65+
os.write(reinterpret_cast<const char*>(iq), sizeof(iq));
66+
} else {
67+
os.write(reinterpret_cast<const char*>(&v), sizeof(v));
68+
}
69+
}
70+
71+
static inline void writeSample(std::ofstream &os, const float &v, bool /*sc16*/)
72+
{
73+
os.write(reinterpret_cast<const char*>(&v), sizeof(v));
74+
}
75+
76+
// Apply a frequency-shift mix (e^{j*phase}) to a sample. Overloaded so the
77+
// export template compiles for float sources, where shifting doesn't apply.
78+
static inline std::complex<float> mixSample(const std::complex<float> &v, double phase)
79+
{
80+
return v * std::complex<float>((float)std::cos(phase), (float)std::sin(phase));
81+
}
82+
83+
static inline float mixSample(const float &v, double /*phase*/)
84+
{
85+
return v;
86+
}
87+
88+
// Round to a given number of significant figures.
89+
static double roundToSigFigs(double v, int figs)
90+
{
91+
if (v == 0.0)
92+
return 0.0;
93+
double mag = std::pow(10.0, figs - 1 - (int)std::floor(std::log10(std::fabs(v))));
94+
return std::round(v * mag) / mag;
95+
}
3996

4097
PlotView::PlotView(InputSource *input) : cursors(this), viewRange({0, 0})
4198
{
@@ -332,50 +389,165 @@ void PlotView::exportSamples(std::shared_ptr<AbstractSampleSource> src)
332389
return;
333390
}
334391

392+
const bool isComplex = std::is_same<SOURCETYPE, std::complex<float>>::value;
393+
394+
// The frequency offset field is absolute (relative to the recording centre /
395+
// spectrogram DC). When exporting the tuner output, the tuner has already
396+
// mixed its centre to DC, so we track that intrinsic offset and subtract it
397+
// from the requested offset to avoid double-counting the shift.
398+
double recordingCenter = sampleSrc->getFrequency();
399+
double tunerIntrinsicOffset = 0;
400+
if (spectrogramPlot != nullptr && src == spectrogramPlot->output()) {
401+
tunerIntrinsicOffset = spectrogramPlot->getTunerOffsetFrequency();
402+
recordingCenter = spectrogramPlot->getCenterFrequency() - tunerIntrinsicOffset;
403+
}
404+
335405
QFileDialog dialog(this);
336406
dialog.setAcceptMode(QFileDialog::AcceptSave);
337407
dialog.setFileMode(QFileDialog::AnyFile);
338-
dialog.setNameFilter(getFileNameFilter<SOURCETYPE>());
408+
// Format is chosen by the "Output Format" controls below; these filters only
409+
// affect which existing files are listed. "All files" stays the default so no
410+
// extension is forced onto the typed name.
411+
dialog.setNameFilters({
412+
"All files (*)",
413+
"SigMF files (*.sigmf-meta *.sigmf-data)",
414+
"All supported (*.fc32 *.sc16 *.sigmf-meta *.sigmf-data)"
415+
});
339416
dialog.setOption(QFileDialog::DontUseNativeDialog, true);
340417

341-
QGroupBox groupBox("Selection To Export", &dialog);
342-
QVBoxLayout vbox(&groupBox);
343-
344-
QRadioButton cursorSelection("Cursor Selection", &groupBox);
345-
QRadioButton currentView("Current View", &groupBox);
346-
QRadioButton completeFile("Complete File (Experimental)", &groupBox);
418+
QGridLayout *l = dialog.findChild<QGridLayout*>();
347419

420+
// --- Selection to export ---
421+
QGroupBox selectionBox("Selection to Export", &dialog);
422+
QRadioButton cursorSelection("Cursor selection", &selectionBox);
423+
QRadioButton currentView("Current view", &selectionBox);
424+
QRadioButton completeFile("Complete file (experimental)", &selectionBox);
348425
if (cursorsEnabled) {
349426
cursorSelection.setChecked(true);
350427
} else {
351428
currentView.setChecked(true);
352429
cursorSelection.setEnabled(false);
353430
}
431+
QVBoxLayout selectionLayout(&selectionBox);
432+
selectionLayout.addWidget(&cursorSelection);
433+
selectionLayout.addWidget(&currentView);
434+
selectionLayout.addWidget(&completeFile);
435+
selectionLayout.addStretch(1);
436+
437+
// --- Output format (sc16 only offered for complex sources) ---
438+
QGroupBox formatBox("Output Format", &dialog);
439+
QRadioButton fmtNative(isComplex ? "Complex float32 (fc32)" : "Float32 (f32)", &formatBox);
440+
QRadioButton fmtSc16("Complex int16 (sc16)", &formatBox);
441+
fmtNative.setChecked(true);
442+
QCheckBox sigmfCheck("Use SigMF format", &formatBox);
443+
QVBoxLayout formatLayout(&formatBox);
444+
formatLayout.addWidget(&fmtNative);
445+
if (isComplex)
446+
formatLayout.addWidget(&fmtSc16);
447+
formatLayout.addWidget(&sigmfCheck);
448+
formatLayout.addStretch(1);
449+
450+
// --- Resampling: decimation + frequency offset on one row ---
451+
// The frequency offset shifts the exported band up/down before decimation.
452+
// Disabled for real captures.
453+
bool offsetEnabled = isComplex && !mainSampleSource->realSignal();
454+
455+
QGroupBox resampleBox("Resampling", &dialog);
456+
QLabel decimationLabel("Decimation:", &resampleBox);
457+
QSpinBox decimation(&resampleBox);
458+
decimation.setRange(1, std::numeric_limits<int>::max());
459+
decimation.setValue(1);
460+
decimation.setMaximumWidth(70); // only needs 2-3 digits
461+
462+
QLabel offsetLabel("Frequency offset (Hz):", &resampleBox);
463+
QSpinBox frequencyOffset(&resampleBox);
464+
int maxOffset = std::numeric_limits<int>::max();
465+
if (sampleSrc->rate() > 0)
466+
maxOffset = (int)std::min(sampleSrc->rate() / 2.0, (double)std::numeric_limits<int>::max());
467+
frequencyOffset.setRange(-maxOffset, maxOffset);
468+
frequencyOffset.setValue(0);
469+
frequencyOffset.setMinimumWidth(160); // holds large Hz values comfortably
470+
frequencyOffset.setEnabled(offsetEnabled);
471+
offsetLabel.setEnabled(offsetEnabled);
472+
frequencyOffset.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
473+
474+
// "Suggest" fills decimation + offset from the on-screen frequency filter
475+
// (tuner): decimation from its width, offset from its centre (4 sig figs).
476+
// Only available when a tuner is active on a complex source.
477+
bool suggestEnabled = offsetEnabled && spectrogramPlot != nullptr
478+
&& spectrogramPlot->tunerEnabled();
479+
QPushButton suggestButton("Suggest", &resampleBox);
480+
suggestButton.setEnabled(suggestEnabled);
481+
if (suggestEnabled) {
482+
connect(&suggestButton, &QPushButton::clicked, this, [&]() {
483+
double relBW = spectrogramPlot->getTunerRelativeBandwidth();
484+
if (relBW > 0)
485+
decimation.setValue(std::max(1, (int)std::ceil(1.0 / relBW)));
486+
frequencyOffset.setValue(
487+
(int)roundToSigFigs(spectrogramPlot->getTunerOffsetFrequency(), 4));
488+
});
489+
}
354490

355-
vbox.addWidget(&cursorSelection);
356-
vbox.addWidget(&currentView);
357-
vbox.addWidget(&completeFile);
358-
vbox.addStretch(1);
359-
360-
groupBox.setLayout(&vbox);
361-
362-
QGridLayout *l = dialog.findChild<QGridLayout*>();
363-
l->addWidget(&groupBox, 4, 1);
364-
365-
QGroupBox groupBox2("Decimation");
366-
QSpinBox decimation(&groupBox2);
367-
decimation.setMinimum(1);
368-
decimation.setValue(1 / sampleSrc->relativeBandwidth());
369-
370-
QVBoxLayout vbox2;
371-
vbox2.addWidget(&decimation);
372-
373-
groupBox2.setLayout(&vbox2);
374-
l->addWidget(&groupBox2, 4, 2);
491+
QHBoxLayout resampleLayout(&resampleBox);
492+
resampleLayout.addWidget(&decimationLabel);
493+
resampleLayout.addWidget(&decimation);
494+
resampleLayout.addSpacing(20);
495+
resampleLayout.addWidget(&offsetLabel);
496+
resampleLayout.addWidget(&frequencyOffset, 1);
497+
resampleLayout.addWidget(&suggestButton);
498+
499+
// Pack the option groups into their own grid spanning the full dialog width,
500+
// so they fill the space evenly instead of leaving the bottom-right empty.
501+
QGridLayout *optionsGrid = new QGridLayout();
502+
optionsGrid->addWidget(&selectionBox, 0, 0);
503+
optionsGrid->addWidget(&formatBox, 0, 1);
504+
optionsGrid->addWidget(&resampleBox, 1, 0, 1, 2);
505+
optionsGrid->setColumnStretch(0, 1);
506+
optionsGrid->setColumnStretch(1, 1);
507+
l->addLayout(optionsGrid, 4, 0, 1, l->columnCount());
508+
509+
// Live-preview the bandwidth that decimation/offset will keep on the
510+
// spectrogram, but only when exporting the spectrogram's own samples.
511+
bool showDecimationPreview = (spectrogramPlot != nullptr) &&
512+
(src == spectrogramPlot->output() || src == spectrogramPlot->input());
513+
if (showDecimationPreview) {
514+
auto updatePreview = [this, &decimation, &frequencyOffset]() {
515+
spectrogramPlot->setDecimationPreview(decimation.value(), frequencyOffset.value());
516+
};
517+
updatePreview();
518+
connect(&decimation, QOverload<int>::of(&QSpinBox::valueChanged),
519+
this, [updatePreview](int) { updatePreview(); });
520+
connect(&frequencyOffset, QOverload<int>::of(&QSpinBox::valueChanged),
521+
this, [updatePreview](int) { updatePreview(); });
522+
}
375523

376524
if (dialog.exec()) {
377525
QStringList fileNames = dialog.selectedFiles();
378526

527+
const bool sc16 = isComplex && fmtSc16.isChecked();
528+
QString datatype, defaultExt;
529+
if (!isComplex) {
530+
datatype = "rf32_le"; defaultExt = ".f32";
531+
} else if (sc16) {
532+
datatype = "ci16_le"; defaultExt = ".sc16";
533+
} else {
534+
datatype = "cf32_le"; defaultExt = ".fc32";
535+
}
536+
537+
// When writing SigMF, the dataset uses the .sigmf-data extension so it
538+
// pairs with the .sigmf-meta sidecar; the radio still selects the binary
539+
// encoding (recorded as core:datatype). Otherwise use the format's
540+
// extension, appended only if the user didn't supply one.
541+
QString outPath;
542+
if (sigmfCheck.isChecked()) {
543+
QFileInfo fi(fileNames[0]);
544+
outPath = fi.path() + "/" + fi.completeBaseName() + ".sigmf-data";
545+
} else {
546+
outPath = fileNames[0];
547+
if (QFileInfo(outPath).suffix().isEmpty())
548+
outPath += defaultExt;
549+
}
550+
379551
size_t start, end;
380552
if (cursorSelection.isChecked()) {
381553
start = selectedSamples.minimum;
@@ -388,7 +560,19 @@ void PlotView::exportSamples(std::shared_ptr<AbstractSampleSource> src)
388560
end = sampleSrc->count();
389561
}
390562

391-
std::ofstream os (fileNames[0].toStdString(), std::ios::binary);
563+
std::ofstream os (outPath.toStdString(), std::ios::binary);
564+
565+
// The offset field is absolute; the data we read already sits at
566+
// tunerIntrinsicOffset, so the mix needed is the difference. (For real
567+
// captures the offset is disabled and the data is left where it is.)
568+
// Skip the mix entirely when there's no shift so the common path stays a
569+
// plain copy.
570+
const double rate = sampleSrc->rate();
571+
const double appliedOffset = offsetEnabled ? (double)frequencyOffset.value()
572+
: tunerIntrinsicOffset;
573+
const double normOffset = (rate > 0) ? (appliedOffset - tunerIntrinsicOffset) / rate : 0.0;
574+
const bool applyMix = (normOffset != 0.0);
575+
const int decim = decimation.value();
392576

393577
size_t index;
394578
// viewRange.length() is used as some less arbitrary step value
@@ -404,12 +588,79 @@ void PlotView::exportSamples(std::shared_ptr<AbstractSampleSource> src)
404588
size_t length = std::min(step, end - index);
405589
auto samples = sampleSrc->getSamples(index, length);
406590
if (samples != nullptr) {
407-
for (auto i = 0; i < length; i += decimation.value()) {
408-
os.write((const char*)&samples[i], sizeof(SOURCETYPE));
591+
for (auto i = 0; i < length; i += decim) {
592+
if (applyMix) {
593+
// Mix down by the offset so the targeted band lands at DC.
594+
double phase = -Tau * std::fmod((double)(index + i) * normOffset, 1.0);
595+
writeSample(os, mixSample(samples[i], phase), sc16);
596+
} else {
597+
writeSample(os, samples[i], sc16);
598+
}
409599
}
410600
}
411601
}
602+
os.close();
603+
604+
if (!progress.wasCanceled() && sigmfCheck.isChecked()) {
605+
double outRate = sampleSrc->rate() / decimation.value();
606+
writeSigMFMeta(outPath, datatype, outRate, recordingCenter + appliedOffset);
607+
}
608+
}
609+
610+
// Clear the bandwidth preview now the dialog has closed.
611+
if (showDecimationPreview)
612+
spectrogramPlot->setDecimationPreview(0, 0);
613+
}
614+
615+
void PlotView::writeSigMFMeta(const QString &dataFilename, const QString &datatype,
616+
double sampleRate, double centerFrequency)
617+
{
618+
QFileInfo dataInfo(dataFilename);
619+
QString fname = dataInfo.fileName();
620+
621+
// SigMF pairs <base>.sigmf-meta with its dataset. Derive <base> from the
622+
// data file, stripping a .sigmf-data suffix specially since it isn't a
623+
// normal extension.
624+
QString base;
625+
const QString sigmfDataSuffix = ".sigmf-data";
626+
if (fname.endsWith(sigmfDataSuffix))
627+
base = fname.left(fname.length() - sigmfDataSuffix.length());
628+
else
629+
base = dataInfo.completeBaseName();
630+
631+
QString metaFilename = dataInfo.path() + "/" + base + ".sigmf-meta";
632+
633+
QJsonObject global;
634+
global["core:datatype"] = datatype;
635+
global["core:version"] = "1.0.0";
636+
if (sampleRate > 0)
637+
global["core:sample_rate"] = sampleRate;
638+
// If the dataset isn't named <base>.sigmf-data, record it as a
639+
// non-conforming dataset so the metadata still points at the right file.
640+
if (!fname.endsWith(sigmfDataSuffix))
641+
global["core:dataset"] = fname;
642+
643+
QJsonObject capture;
644+
capture["core:sample_start"] = 0;
645+
if (centerFrequency != 0)
646+
capture["core:frequency"] = centerFrequency;
647+
QJsonArray captures;
648+
captures.append(capture);
649+
650+
QJsonObject root;
651+
root["global"] = global;
652+
root["captures"] = captures;
653+
root["annotations"] = QJsonArray();
654+
655+
QFile metafile(metaFilename);
656+
if (!metafile.open(QFile::WriteOnly | QIODevice::Text)) {
657+
QMessageBox::warning(this, "Export",
658+
"Samples were written, but the SigMF metadata file could not be created: "
659+
+ metafile.errorString());
660+
return;
412661
}
662+
metafile.write(QJsonDocument(root).toJson());
663+
metafile.close();
413664
}
414665

415666
void PlotView::invalidateEvent()

src/plotview.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public slots:
9393
void extractSymbols(std::shared_ptr<AbstractSampleSource> src, bool toClipboard);
9494
void exportSamples(std::shared_ptr<AbstractSampleSource> src);
9595
template<typename SOURCETYPE> void exportSamples(std::shared_ptr<AbstractSampleSource> src);
96+
void writeSigMFMeta(const QString &dataFilename, const QString &datatype, double sampleRate, double centerFrequency);
9697
int plotsHeight();
9798
size_t samplesPerColumn();
9899
void updateViewRange(bool reCenter);

0 commit comments

Comments
 (0)