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
4097PlotView::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 (¤tView);
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 (¤tView);
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
415666void PlotView::invalidateEvent ()
0 commit comments