diff --git a/src/urh/controller/widgets/SignalFrame.py b/src/urh/controller/widgets/SignalFrame.py index 14e2e9c251..ec4f68a5e0 100644 --- a/src/urh/controller/widgets/SignalFrame.py +++ b/src/urh/controller/widgets/SignalFrame.py @@ -159,6 +159,7 @@ def create_connects(self): self.ui.sliderSpectrogramMax.valueChanged.connect(self.on_slider_spectrogram_max_value_changed) self.ui.gvSpectrogram.y_scale_changed.connect(self.on_gv_spectrogram_y_scale_changed) self.ui.gvSpectrogram.bandpass_filter_triggered.connect(self.on_bandpass_filter_triggered) + self.ui.gvSpectrogram.export_fta_wanted.connect(self.on_export_fta_wanted) self.ui.btnAdvancedModulationSettings.clicked.connect(self.on_btn_advanced_modulation_settings_clicked) if self.signal is not None: @@ -191,6 +192,8 @@ def create_connects(self): self.ui.gvSignal.set_noise_clicked.connect(self.on_set_noise_in_graphic_view_clicked) self.ui.gvSignal.save_as_clicked.connect(self.save_signal_as) + self.ui.gvSignal.export_demodulated_clicked.connect(self.export_demodulated) + self.ui.gvSignal.create_clicked.connect(self.create_new_signal) self.ui.gvSignal.zoomed.connect(self.on_signal_zoomed) self.ui.gvSpectrogram.zoomed.connect(self.on_spectrum_zoomed) @@ -409,6 +412,27 @@ def save_signal_as(self): except Exception as e: QMessageBox.critical(self, self.tr("Error saving signal"), e.args[0]) + def export_demodulated(self): + try: + initial_name = self.signal.name + "-demodulated.complex" + except Exception as e: + logger.exception(e) + initial_name = "demodulated.complex" + + filename = FileOperator.get_save_file_name(initial_name) + if filename: + try: + self.setCursor(Qt.WaitCursor) + data = self.signal.qad + if filename.endswith(".wav"): + data = self.signal.qad.astype(np.float32) + data /= np.max(np.abs(data)) + data = FileOperator.convert_data_to_format(data, filename) + FileOperator.save_data(data, filename, self.signal.sample_rate, num_channels=1) + self.unsetCursor() + except Exception as e: + QMessageBox.critical(self, self.tr("Error exporting demodulated data"), e.args[0]) + def draw_signal(self, full_signal=False): gv_legend = self.ui.gvLegend gv_legend.y_sep = -self.signal.qad_center @@ -1210,3 +1234,24 @@ def on_btn_advanced_modulation_settings_clicked(self): dialog.message_length_divisor_edited.connect(self.on_message_length_divisor_edited) dialog.exec_() + @pyqtSlot() + def on_export_fta_wanted(self): + try: + initial_name = self.signal.name + "-spectrogram.ft" + except Exception as e: + logger.exception(e) + initial_name = "spectrogram.ft" + + filename = FileOperator.get_save_file_name(initial_name, caption="Export spectrogram") + if not filename: + return + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + self.ui.gvSpectrogram.scene_manager.spectrogram.export_to_fta(sample_rate=self.signal.sample_rate, + filename=filename, + include_amplitude=filename.endswith(".fta")) + except Exception as e: + logger.exception(e) + Errors.generic_error("Failed to export spectrogram", str(e)) + finally: + QApplication.restoreOverrideCursor() diff --git a/src/urh/signalprocessing/Spectrogram.py b/src/urh/signalprocessing/Spectrogram.py index 03375a4011..2fed0ab77c 100644 --- a/src/urh/signalprocessing/Spectrogram.py +++ b/src/urh/signalprocessing/Spectrogram.py @@ -97,6 +97,34 @@ def stft(self, samples: np.ndarray): result = np.fft.fft(frames * window, self.window_size) / np.atleast_1d(self.window_size) return result + def export_to_fta(self, sample_rate, filename: str, include_amplitude=False): + """ + Export to Frequency, Time, Amplitude file. + Frequency is double, Time (nanosecond) is uint32, Amplitude is float32 + + :return: + """ + spectrogram = self.__calculate_spectrogram(self.samples) + spectrogram = np.flipud(spectrogram.T) + if include_amplitude: + result = np.empty((spectrogram.shape[0], spectrogram.shape[1], 3), + dtype=[('f', np.float64), ('t', np.uint32), ('a', np.float32)]) + else: + result = np.empty((spectrogram.shape[0], spectrogram.shape[1], 2), + dtype=[('f', np.float64), ('t', np.uint32)]) + + fft_freqs = np.fft.fftshift(np.fft.fftfreq(spectrogram.shape[0], 1/sample_rate)) + time_width = 1e9 * ((len(self.samples) / sample_rate) / spectrogram.shape[1]) + + for i in range(spectrogram.shape[0]): + for j in range(spectrogram.shape[1]): + if include_amplitude: + result[i, j] = (fft_freqs[i], int(j*time_width), spectrogram[i, j]) + else: + result[i, j] = (fft_freqs[i], int(j * time_width)) + + result.tofile(filename) + def __calculate_spectrogram(self, samples: np.ndarray) -> np.ndarray: # Only shift axis 1 (frequency) and not time spectrogram = np.fft.fftshift(self.stft(samples), axes=(1,)) diff --git a/src/urh/ui/views/EditableGraphicView.py b/src/urh/ui/views/EditableGraphicView.py index 1c8309e46d..3311a1f1ab 100644 --- a/src/urh/ui/views/EditableGraphicView.py +++ b/src/urh/ui/views/EditableGraphicView.py @@ -12,6 +12,7 @@ class EditableGraphicView(ZoomableGraphicView): save_as_clicked = pyqtSignal() + export_demodulated_clicked = pyqtSignal() create_clicked = pyqtSignal(int, int) set_noise_clicked = pyqtSignal() participant_changed = pyqtSignal() @@ -130,12 +131,6 @@ def create_context_menu(self): self.paste_position = int(self.mapToScene(self.context_menu_position).x()) menu = QMenu(self) - if self.save_enabled: - menu.addAction(self.save_action) - - menu.addAction(self.save_as_action) - menu.addSeparator() - menu.addAction(self.copy_action) self.copy_action.setEnabled(self.something_is_selected) menu.addAction(self.paste_action) @@ -203,6 +198,17 @@ def create_context_menu(self): menu.addAction(self.undo_action) menu.addAction(self.redo_action) + if self.scene_type == 0: + menu.addSeparator() + if self.save_enabled: + menu.addAction(self.save_action) + + menu.addAction(self.save_as_action) + elif self.scene_type == 1: + menu.addSeparator() + export_demod_action = menu.addAction("Export demodulated...") + export_demod_action.triggered.connect(self.export_demodulated_clicked.emit) + return menu def clear_horizontal_selection(self): diff --git a/src/urh/ui/views/SpectrogramGraphicView.py b/src/urh/ui/views/SpectrogramGraphicView.py index a480a67196..8ae8de4a28 100644 --- a/src/urh/ui/views/SpectrogramGraphicView.py +++ b/src/urh/ui/views/SpectrogramGraphicView.py @@ -15,6 +15,7 @@ class SpectrogramGraphicView(ZoomableGraphicView): MINIMUM_VIEW_WIDTH = 10 y_scale_changed = pyqtSignal(float) bandpass_filter_triggered = pyqtSignal(float, float) + export_fta_wanted = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -68,6 +69,11 @@ def create_context_menu(self): configure_filter_bw.triggered.connect(self.on_configure_filter_bw_triggered) configure_filter_bw.setIcon(QIcon.fromTheme("configure")) + menu.addSeparator() + + export_fta_action = menu.addAction("Export spectrogram...") + export_fta_action.triggered.connect(self.on_export_fta_action_triggered) + return menu def zoom_to_selection(self, start: int, end: int): @@ -101,3 +107,10 @@ def __get_freqs(self): def on_configure_filter_bw_triggered(self): dialog = FilterBandwidthDialog(parent=self) dialog.show() + + @pyqtSlot() + def on_export_fta_action_triggered(self): + if not(self.scene_manager and self.scene_manager.spectrogram): + return + + self.export_fta_wanted.emit() diff --git a/src/urh/util/FileOperator.py b/src/urh/util/FileOperator.py index a4aea8779a..a963591909 100644 --- a/src/urh/util/FileOperator.py +++ b/src/urh/util/FileOperator.py @@ -108,6 +108,8 @@ def get_save_file_name(initial_name: str, wav_only=False, caption="Save signal") name_filter = "" elif caption == "Save simulator profile": name_filter = "Simulator (*.sim.xml *.sim);;All files (*)" + elif caption == "Export spectrogram": + name_filter = "Frequency Time (*.ft);;Frequency Time Amplitude (*.fta)" else: name_filter = "Protocols (*.proto.xml *.proto);;All files (*)" @@ -146,10 +148,10 @@ def save_data_dialog(signal_name: str, data, wav_only=False, parent=None) -> str return filename -def save_data(data, filename: str, sample_rate=1e6): +def save_data(data, filename: str, sample_rate=1e6, num_channels=2): if filename.endswith(".wav"): f = wave.open(filename, "w") - f.setnchannels(2) + f.setnchannels(num_channels) f.setsampwidth(2) f.setframerate(sample_rate) f.writeframes(data) @@ -174,18 +176,20 @@ def save_data(data, filename: str, sample_rate=1e6): rewrite_tar(archive) -def save_signal(signal): - filename = signal.filename +def convert_data_to_format(data: np.ndarray, filename: str): if filename.endswith(".wav"): - data = signal.wave_data + return (data.view(np.float32) * 32767).astype(np.int16) elif filename.endswith(".complex16u"): - data = (127.5 * (signal.data.view(np.float32) + 1.0)).astype(np.uint8) + return (127.5 * (data.view(np.float32) + 1.0)).astype(np.uint8) elif filename.endswith(".complex16s"): - data = (127.5 * ((signal.data.view(np.float32)) - 0.5 / 127.5)).astype(np.int8) + return (127.5 * ((data.view(np.float32)) - 0.5 / 127.5)).astype(np.int8) else: - data = signal.data + return data + - save_data(data, filename, sample_rate=signal.sample_rate) +def save_signal(signal): + data = convert_data_to_format(signal.data, signal.filename) + save_data(data, signal.filename, sample_rate=signal.sample_rate) def rewrite_zip(zip_name): diff --git a/tests/.coveragerc b/tests/.coveragerc index 9921a7ee58..51fada8b7d 100644 --- a/tests/.coveragerc +++ b/tests/.coveragerc @@ -31,3 +31,6 @@ exclude_lines = class SectionComboBox def createEditor def updateEditorGeometry + def on_export_fta_wanted + def export_to_fta + def export_demodulated diff --git a/tests/test_signal_tab_GUI.py b/tests/test_signal_tab_GUI.py index f6213a5224..6c6908b4e3 100644 --- a/tests/test_signal_tab_GUI.py +++ b/tests/test_signal_tab_GUI.py @@ -6,6 +6,7 @@ from tests.QtTestCase import QtTestCase from tests.utils_testing import get_path_for_data_file +from urh.controller.MainController import MainController from urh.signalprocessing.Participant import Participant @@ -266,3 +267,17 @@ def test_context_menu_text_edit_protocol_view(self): text_edit.selectAll() menu = text_edit.create_context_menu() self.assertEqual(len([action for action in menu.actions() if action.text() == "Participant"]), 1) + + def test_export_demodulated(self): + self.add_signal_to_form("esaver.complex") + assert isinstance(self.form, MainController) + self.form.signal_tab_controller.signal_frames[0].ui.gvSignal.context_menu_position = QPoint(0,0) + cm = self.form.signal_tab_controller.signal_frames[0].ui.gvSignal.create_context_menu() + export_action = next((a for a in cm.actions() if "demodulated" in a.text().lower()), None) + self.assertIsNone(export_action) + + self.form.signal_tab_controller.signal_frames[0].ui.cbSignalView.setCurrentIndex(1) + cm = self.form.signal_tab_controller.signal_frames[0].ui.gvSignal.create_context_menu() + export_action = next((a for a in cm.actions() if "demodulated" in a.text().lower()), None) + self.assertIsNotNone(export_action) + diff --git a/tests/test_simulator.py b/tests/test_simulator.py index ab53093bd5..66ed0a88bc 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -338,7 +338,7 @@ def test_external_program_simulator(self): bits = self.__demodulate(conn) self.assertEqual(bits[0], "101010101") - QTest.qWait(250) + QTest.qWait(500) self.assertTrue(simulator.simulation_is_finished()) conn.close()