Ejemplo n.º 1
0
 def __init__(self, parent, prefs, signal_model):
     super(SyncHTP1Dialog, self).__init__(parent)
     self.__preferences = prefs
     self.__signal_model = signal_model
     self.__simple_signal = None
     self.__filters_by_channel = {}
     self.__current_device_filters_by_channel = {}
     self.__spinner = None
     self.__beq_filter = None
     self.__signal_filter = None
     self.__last_requested_msoupdate = None
     self.__last_received_msoupdate = None
     self.__supports_shelf = False
     self.__channel_to_signal = {}
     self.setupUi(self)
     self.setWindowFlag(Qt.WindowMinimizeButtonHint)
     self.setWindowFlag(Qt.WindowMaximizeButtonHint)
     self.syncStatus = qta.IconWidget('fa5s.unlink')
     self.syncLayout.addWidget(self.syncStatus)
     self.ipAddress.setText(self.__preferences.get(HTP1_ADDRESS))
     self.filterView.setSelectionBehavior(QAbstractItemView.SelectRows)
     self.__filters = FilterModel(self.filterView, self.__preferences)
     self.filterView.setModel(FilterTableModel(self.__filters))
     self.filterView.horizontalHeader().setSectionResizeMode(
         QHeaderView.Stretch)
     self.filterView.selectionModel().selectionChanged.connect(
         self.__on_filter_selected)
     self.__magnitude_model = MagnitudeModel(
         'preview',
         self.previewChart,
         self.__preferences,
         self.get_curve_data,
         'Filter',
         db_range_calc=dBRangeCalculator(30, expand=True),
         fill_curves=True)
     self.connectButton.setIcon(qta.icon('fa5s.check'))
     self.disconnectButton.setIcon(qta.icon('fa5s.times'))
     self.resyncFilters.setIcon(qta.icon('fa5s.sync'))
     self.deleteFiltersButton.setIcon(qta.icon('fa5s.trash'))
     self.editFilterButton.setIcon(qta.icon('fa5s.edit'))
     self.selectBeqButton.setIcon(qta.icon('fa5s.folder-open'))
     self.limitsButton.setIcon(qta.icon('fa5s.arrows-alt'))
     self.showDetailsButton.setIcon(qta.icon('fa5s.info'))
     self.createPulsesButton.setIcon(qta.icon('fa5s.wave-square'))
     self.fullRangeButton.setIcon(qta.icon('fa5s.expand'))
     self.subOnlyButton.setIcon(qta.icon('fa5s.compress'))
     self.autoSyncButton.setIcon(qta.icon('fa5s.magic'))
     self.autoSyncButton.toggled.connect(
         lambda b: self.__preferences.set(HTP1_AUTOSYNC, b))
     self.autoSyncButton.setChecked(self.__preferences.get(HTP1_AUTOSYNC))
     self.__ws_client = QtWebSockets.QWebSocket(
         '', QtWebSockets.QWebSocketProtocol.Version13, None)
     self.__ws_client.error.connect(self.__on_ws_error)
     self.__ws_client.connected.connect(self.__on_ws_connect)
     self.__ws_client.disconnected.connect(self.__on_ws_disconnect)
     self.__ws_client.textMessageReceived.connect(self.__on_ws_message)
     self.__disable_on_disconnect()
     self.filterMapping.itemDoubleClicked.connect(
         self.__show_mapping_dialog)
     self.__restore_geometry()
Ejemplo n.º 2
0
 def __init__(self, parent: QWidget, prefs: Preferences, signals: List[Signal]):
     super(ImpulseDialog, self).__init__(parent)
     self.prefs = prefs
     self.setupUi(self)
     self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMinMaxButtonsHint)
     self.__signals: Dict[Signal, bool] = {s: True for s in signals}
     self.__magnitude_model = MagnitudeModel('preview', self.previewChart, prefs,
                                             self.__get_data(), 'Signal', fill_primary=False,
                                             x_min_pref_key=IMPULSE_GRAPH_X_MIN, x_max_pref_key=IMPULSE_GRAPH_X_MAX,
                                             y_range_calc=ImpulseRangeCalculator())
     self.limitsButton.setToolTip('Set graph axis limits')
Ejemplo n.º 3
0
 def __init__(self, preferences, signal, filter_model, redraw_main, selected_filter=None, parent=None, valid_filter_types=None):
     self.__preferences = preferences
     super(FilterDialog, self).__init__(parent) if parent is not None else super(FilterDialog, self).__init__()
     self.__redraw_main = redraw_main
     # for shelf filter, allow input via Q or S not both
     self.__q_is_active = True
     # allow user to control the steps for different fields, default to reasonably quick moving values
     self.__q_step_idx = self.__get_step(self.q_steps, self.__preferences.get(DISPLAY_Q_STEP), 3)
     self.__s_step_idx = self.__get_step(self.q_steps, self.__preferences.get(DISPLAY_S_STEP), 3)
     self.__gain_step_idx = self.__get_step(self.gain_steps, self.__preferences.get(DISPLAY_GAIN_STEP), 0)
     self.__freq_step_idx = self.__get_step(self.freq_steps, self.__preferences.get(DISPLAY_FREQ_STEP), 1)
     # init the UI itself
     self.setupUi(self)
     self.__snapshot = FilterModel(self.snapshotFilterView, self.__preferences, on_update=self.__on_snapshot_change)
     self.__working = FilterModel(self.workingFilterView, self.__preferences, on_update=self.__on_working_change)
     self.__selected_id = None
     self.__decorate_ui()
     self.__set_q_step(self.q_steps[self.__q_step_idx])
     self.__set_s_step(self.q_steps[self.__s_step_idx])
     self.__set_gain_step(self.gain_steps[self.__gain_step_idx])
     self.__set_freq_step(self.freq_steps[self.__freq_step_idx])
     # underlying filter model
     self.__signal = signal
     self.__filter_model = filter_model
     if self.__filter_model.filter.listener is not None:
         logger.debug(f"Selected filter has listener {self.__filter_model.filter.listener.name}")
     # init the chart
     self.__magnitude_model = MagnitudeModel('preview', self.previewChart, preferences,
                                             self.__get_data(), 'Filter', fill_primary=True,
                                             secondary_data_provider=self.__get_data('phase'),
                                             secondary_name='Phase', secondary_prefix='deg', fill_secondary=False,
                                             db_range_calc=dBRangeCalculator(30, expand=True),
                                             y2_range_calc=PhaseRangeCalculator(), show_y2_in_legend=False)
     # remove unsupported filter types
     if valid_filter_types:
         to_remove = []
         for i in range(self.filterType.count()):
             if self.filterType.itemText(i) not in valid_filter_types:
                 to_remove.append(i)
         for i1, i2 in enumerate(to_remove):
             self.filterType.removeItem(i2 - i1)
     # copy the filter into the working table
     self.__working.filter = self.__filter_model.clone()
     # and initialise the view
     for idx, f in enumerate(self.__working):
         selected = selected_filter is not None and f.id == selected_filter.id
         f.id = uuid4()
         if selected is True:
             self.__selected_id = f.id
             self.workingFilterView.selectRow(idx)
     if self.__selected_id is None:
         self.__add_working_filter()
     self.__restore_geometry()
Ejemplo n.º 4
0
 def redraw_all_axes(self):
     ''' Draws all charts. '''
     # have to clear the title first because the title can move from one axis to another (and clearing doesn't seem to remove that)
     self.set_title('')
     self.preview.canvas.figure.clear()
     image_spec, chart_spec, filter_spec = self.__calculate_layout()
     if image_spec is not None:
         self.__imshow_axes = self.preview.canvas.figure.add_subplot(
             image_spec)
         self.__init_imshow_axes()
     else:
         self.__imshow_axes = None
     self.__magnitude_model = MagnitudeModel(
         'report',
         self.preview,
         self.__preferences,
         self.get_curve_data,
         'Signals',
         show_legend=lambda: self.showLegend.isChecked(),
         subplot_spec=chart_spec,
         redraw_listener=self.on_redraw,
         grid_alpha=self.gridOpacity.value(),
         x_min_pref_key=REPORT_CHART_LIMITS_X0,
         x_max_pref_key=REPORT_CHART_LIMITS_X1,
         x_scale_pref_key=REPORT_CHART_LIMITS_X_SCALE)
     if filter_spec is not None:
         self.__filter_axes = self.preview.canvas.figure.add_subplot(
             filter_spec)
         self.filterRowHeightMultiplier.setEnabled(True)
         self.x0.setEnabled(False)
         self.x1.setEnabled(False)
         self.y0.setEnabled(False)
         self.y1.setEnabled(False)
     else:
         self.__filter_axes = None
         self.filterRowHeightMultiplier.setEnabled(False)
         self.x0.setEnabled(True)
         self.x1.setEnabled(True)
         self.y0.setEnabled(True)
         self.y1.setEnabled(True)
     self.replace_table(draw=False)
     self.set_title(self.title.text(), draw=False)
     self.apply_image(draw=False)
     self.preview.canvas.draw_idle()
     self.__record_image_size()
Ejemplo n.º 5
0
class ImpulseDialog(QDialog, Ui_impulseDialog):

    def __init__(self, parent: QWidget, prefs: Preferences, signals: List[Signal]):
        super(ImpulseDialog, self).__init__(parent)
        self.prefs = prefs
        self.setupUi(self)
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowMinMaxButtonsHint)
        self.__signals: Dict[Signal, bool] = {s: True for s in signals}
        self.__magnitude_model = MagnitudeModel('preview', self.previewChart, prefs,
                                                self.__get_data(), 'Signal', fill_primary=False,
                                                x_min_pref_key=IMPULSE_GRAPH_X_MIN, x_max_pref_key=IMPULSE_GRAPH_X_MAX,
                                                y_range_calc=ImpulseRangeCalculator())
        self.limitsButton.setToolTip('Set graph axis limits')

    def show_limits(self):
        ''' shows the limits dialog for the chart. '''
        self.__magnitude_model.show_limits()

    def update_chart(self):
        self.__magnitude_model.redraw()

    def __get_data(self):
        return lambda *args, **kwargs: self.get_curve_data(*args, **kwargs)

    def get_curve_data(self, reference=None):
        ''' preview of the filter to display on the chart '''
        result = []
        mode = 'I' if self.chartToggle.isChecked() else 'S'
        if self.__dsp:
            names = [n.text() for n in self.channelList.selectedItems()]
            for signal, selected in self.__signals.items():
                if selected:
                    result.append(MagnitudeData(signal.name, None, *signal.avg, colour=get_filter_colour(len(result))))
        return result

    def select_channels(self):
        '''
        Allow user to select signals to examine.
        '''
        def on_save(selected: List[str]):
            self.__signals = {s: s.name in selected for s in self.__signals.keys()}
            self.update_chart()

        SignalSelectorDialog(self, [s.name for s in self.__signals.keys()],
                             [s.name for s, b in self.__signals.items() if b], on_save).show()
Ejemplo n.º 6
0
class SaveReportDialog(QDialog, Ui_saveReportDialog):
    '''
    Save Report dialog
    '''
    def __init__(self, parent, preferences, signal_model, filter_model,
                 status_bar, selected_signal):
        super(SaveReportDialog, self).__init__(parent)
        self.__table = None
        self.__selected_signal = selected_signal
        self.__first_create = True
        self.__magnitude_model = None
        self.__imshow_axes = None
        self.__pixel_perfect_mode = False
        self.__filter_axes = None
        self.__image = None
        self.__dpi = None
        self.__x = None
        self.__y = None
        self.__aspect_ratio = None
        self.__signal_model = signal_model
        self.__filter_model = filter_model
        self.__preferences = preferences
        self.__status_bar = status_bar
        self.__xy_data = self.__signal_model.get_all_magnitude_data()
        self.__selected_xy = []
        self.setWindowFlags(self.windowFlags() | Qt.WindowSystemMenuHint
                            | Qt.WindowMinMaxButtonsHint)
        self.setupUi(self)
        self.imagePicker.setIcon(qta.icon('fa5s.folder-open'))
        self.limitsButton.setIcon(qta.icon('fa5s.arrows-alt'))
        self.saveLayout.setIcon(qta.icon('fa5s.save'))
        self.loadURL.setIcon(qta.icon('fa5s.download'))
        self.loadURL.setEnabled(False)
        self.snapToImageSize.setIcon(qta.icon('fa5s.expand'))
        self.snapToImageSize.setEnabled(False)
        self.buttonBox.button(
            QDialogButtonBox.RestoreDefaults).clicked.connect(
                self.discard_layout)
        for xy in self.__xy_data:
            self.curves.addItem(QListWidgetItem(xy.name, self.curves))
        show_signals = self.__preferences.get(DISPLAY_SHOW_SIGNALS)
        show_filtered_signals = self.__preferences.get(
            DISPLAY_SHOW_FILTERED_SIGNALS)
        pattern = get_visible_signal_name_filter(show_filtered_signals,
                                                 show_signals)
        if pattern is not None:
            for idx in range(0, self.curves.count()):
                item = self.curves.item(idx)
                if pattern.match(item.text()):
                    item.setSelected(True)
        else:
            self.curves.selectAll()
        self.preview.canvas.mpl_connect('resize_event',
                                        self.__canvas_size_to_xy)
        # init fields
        self.__restore_geometry()
        self.restore_layout(redraw=True)
        self.__record_image_size()

    def __restore_geometry(self):
        ''' loads the saved window size '''
        geometry = self.__preferences.get(REPORT_GEOMETRY)
        if geometry is not None:
            self.restoreGeometry(geometry)

    def __canvas_size_to_xy(self, event):
        '''
        Calculates the current size of the image.
        '''
        self.__dpi = self.preview.canvas.figure.dpi
        self.__x = event.width
        self.__y = event.height
        self.__aspect_ratio = self.__x / self.__y
        self.widthPixels.setValue(self.__x)
        self.heightPixels.setValue(self.__y)

    def redraw_all_axes(self):
        ''' Draws all charts. '''
        # have to clear the title first because the title can move from one axis to another (and clearing doesn't seem to remove that)
        self.set_title('')
        self.preview.canvas.figure.clear()
        image_spec, chart_spec, filter_spec = self.__calculate_layout()
        if image_spec is not None:
            self.__imshow_axes = self.preview.canvas.figure.add_subplot(
                image_spec)
            self.__init_imshow_axes()
        else:
            self.__imshow_axes = None
        self.__magnitude_model = MagnitudeModel(
            'report',
            self.preview,
            self.__preferences,
            self.get_curve_data,
            'Signals',
            show_legend=lambda: self.showLegend.isChecked(),
            subplot_spec=chart_spec,
            redraw_listener=self.on_redraw,
            grid_alpha=self.gridOpacity.value(),
            x_min_pref_key=REPORT_CHART_LIMITS_X0,
            x_max_pref_key=REPORT_CHART_LIMITS_X1,
            x_scale_pref_key=REPORT_CHART_LIMITS_X_SCALE)
        if filter_spec is not None:
            self.__filter_axes = self.preview.canvas.figure.add_subplot(
                filter_spec)
            self.filterRowHeightMultiplier.setEnabled(True)
            self.x0.setEnabled(False)
            self.x1.setEnabled(False)
            self.y0.setEnabled(False)
            self.y1.setEnabled(False)
        else:
            self.__filter_axes = None
            self.filterRowHeightMultiplier.setEnabled(False)
            self.x0.setEnabled(True)
            self.x1.setEnabled(True)
            self.y0.setEnabled(True)
            self.y1.setEnabled(True)
        self.replace_table(draw=False)
        self.set_title(self.title.text(), draw=False)
        self.apply_image(draw=False)
        self.preview.canvas.draw_idle()
        self.__record_image_size()

    def on_redraw(self):
        '''
        fired when the magnitude chart redraws, basically means the limits have changed so we have to redraw the image
        if we have it in the chart to make sure the extents fit.
        '''
        if self.__imshow_axes is None and self.__image is not None:
            self.__image.set_extent(
                self.__make_extent(self.__magnitude_model.limits))

    def replace_table(self, *args, draw=True):
        ''' Adds the table to the axis '''
        table = self.__make_table()
        if table is not None:
            if self.__filter_axes is not None:
                self.__filter_axes.clear()
                self.__filter_axes.axis('off')
                self.__filter_axes.add_table(table)
                self.__magnitude_model.limits.axes_1.spines['top'].set_visible(
                    True)
                self.__magnitude_model.limits.axes_1.spines[
                    'right'].set_visible(True)
            elif self.__magnitude_model is not None:
                for t in self.__magnitude_model.limits.axes_1.tables:
                    t.remove()
                tab = self.__magnitude_model.limits.axes_1.add_table(table)
                self.__magnitude_model.limits.axes_1.spines['top'].set_visible(
                    False)
                self.__magnitude_model.limits.axes_1.spines[
                    'right'].set_visible(False)
                tab.set_zorder(1000)
            if draw:
                self.preview.canvas.draw_idle()

    def __make_table(self):
        '''
        Transforms the filter model into a table. This code is based on the code in matplotlib.table
        :return: the table.
        '''
        if len(self.__filter_model) > 0 and self.__magnitude_model is not None:
            fc = self.__magnitude_model.limits.axes_1.get_facecolor()
            cell_kwargs = {}

            if self.__filter_axes is not None:
                table_axes = self.__filter_axes
                table_loc = {'loc': 'center'}
            else:
                table_axes = self.__magnitude_model.limits.axes_1
                table_loc = {
                    'bbox':
                    (self.x0.value(),
                     self.y0.value(), self.x1.value() - self.x0.value(),
                     self.y1.value() - self.y0.value())
                }
            # this is some hackery around the way the matplotlib table works
            # multiplier = 1.2 * 1.85 if not self.__first_create else 1.2
            multiplier = self.filterRowHeightMultiplier.value()
            self.__first_create = False
            row_height = (self.tableFontSize.value() / 72.0 *
                          self.preview.canvas.figure.dpi /
                          table_axes.bbox.height * multiplier)
            cell_kwargs['facecolor'] = fc

            table = Table(table_axes, **table_loc)
            table.set_zorder(1000)
            self.__add_filters_to_table(table, row_height, cell_kwargs)
            table.auto_set_font_size(False)
            table.set_fontsize(self.tableFontSize.value())
            return table
        return None

    def __add_filters_to_table(self, table, row_height, cell_kwargs):
        ''' renders the filters as a nicely formatted table '''
        cols = ('Freq', 'Gain', 'Q', 'Type', 'Total')
        col_width = 1 / len(cols)
        if 'bbox' in cell_kwargs:
            col_width *= cell_kwargs['bbox'][2]
        cells = [self.__table_print(f) for f in self.__filter_model]
        if self.__selected_signal is not None and not math.isclose(
                self.__selected_signal.offset, 0.0):
            cells.append(
                ['', f"{self.__selected_signal.offset:+g}", '', 'MV', ''])
        show_header = self.showTableHeader.isChecked()
        ec = matplotlib.rcParams['axes.edgecolor']
        if show_header:
            for idx, label in enumerate(cols):
                cell = table.add_cell(0,
                                      idx,
                                      width=col_width,
                                      height=row_height,
                                      text=label,
                                      loc='center',
                                      edgecolor=ec,
                                      **cell_kwargs)
                cell.set_alpha(self.tableAlpha.value())
        if len(cells) > 0:
            for idx, row in enumerate(cells):
                for col_idx, cell in enumerate(row):
                    cell = table.add_cell(idx + (1 if show_header else 0),
                                          col_idx,
                                          width=col_width,
                                          height=row_height,
                                          text=cell,
                                          loc='center',
                                          edgecolor=ec,
                                          **cell_kwargs)
                    cell.PAD = 0.02
                    cell.set_alpha(self.tableAlpha.value())
        return table

    def __calculate_layout(self):
        '''
        Creates the subplot specs for the chart layout, options are

        3 panes, horizontal, split right

        -------------------
        |        |        |
        |        |--------|
        |        |        |
        -------------------

        3 panes, horizontal, split left

        -------------------
        |        |        |
        |--------|        |
        |        |        |
        -------------------

        3 panes, vertical, split bottom

        -------------------
        |                 |
        |-----------------|
        |        |        |
        -------------------

        3 panes, vertical, split top

        -------------------
        |        |        |
        |-----------------|
        |                 |
        -------------------

        2 panes, vertical

        -------------------
        |                 |
        |-----------------|
        |                 |
        -------------------

        2 panes, horizontal

        -------------------
        |        |        |
        |        |        |
        |        |        |
        -------------------

        1 pane

        -------------------
        |                 |
        |                 |
        |                 |
        -------------------

        :return: image_spec, chart_spec, filter_spec
        '''
        layout = self.chartLayout.currentText()
        filter_spec = None
        image_spec = None
        chart_spec = None
        if layout == "Image | Chart, Filters":
            image_spec, chart_spec, filter_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == "Image | Filters, Chart":
            image_spec, filter_spec, chart_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == "Chart | Image, Filter":
            chart_spec, image_spec, filter_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == "Chart | Filters, Image":
            chart_spec, filter_spec, image_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == "Filters | Image, Chart":
            filter_spec, image_spec, chart_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == "Filters | Chart, Image":
            filter_spec, chart_spec, image_spec = self.__get_one_pane_two_pane_spec(
            )
        elif layout == 'Image, Filters | Chart':
            image_spec, filter_spec, chart_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == 'Filters, Image | Chart':
            filter_spec, image_spec, chart_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == 'Chart, Image | Filters':
            chart_spec, image_spec, filter_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == 'Image, Chart | Filters':
            image_spec, chart_spec, filter_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == 'Filters, Chart | Image':
            filter_spec, chart_spec, image_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == 'Chart, Filters | Image':
            chart_spec, filter_spec, image_spec = self.__get_two_pane_one_pane_spec(
            )
        elif layout == "Chart | Filters":
            chart_spec, filter_spec = self.__get_two_pane_spec()
        elif layout == "Filters | Chart":
            filter_spec, chart_spec = self.__get_two_pane_spec()
        elif layout == "Chart | Image":
            chart_spec, image_spec = self.__get_two_pane_spec()
        elif layout == "Image | Chart":
            image_spec, chart_spec = self.__get_two_pane_spec()
        elif layout == "Pixel Perfect Image | Chart":
            gs = GridSpec(1,
                          1,
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            chart_spec = gs.new_subplotspec((0, 0), 1, 1)
            self.__prepare_for_pixel_perfect()
        return image_spec, chart_spec, filter_spec

    def __prepare_for_pixel_perfect(self):
        ''' puts the report into pixel perfect mode which means honour the image size. '''
        self.__pixel_perfect_mode = True
        self.widthSpacing.setValue(0.0)
        self.heightSpacing.setValue(0.0)
        self.__record_image_size()

    def __honour_image_aspect_ratio(self):
        ''' resizes the window to fit the image aspect ratio based on the chart size '''
        if len(self.image.text()) > 0:
            width, height = Image.open(self.image.text()).size
            width_delta = width - self.__x
            height_delta = height - self.__y
            if width_delta != 0 or height_delta != 0:
                win_size = self.size()
                target_size = QSize(win_size.width() + width_delta,
                                    win_size.height() + height_delta)
                available_size = QDesktopWidget().availableGeometry()
                if available_size.width() < target_size.width(
                ) or available_size.height() < target_size.height():
                    target_size.scale(available_size.width() - 48,
                                      available_size.height() - 48,
                                      Qt.KeepAspectRatio)
                self.resize(target_size)

    def __get_one_pane_two_pane_spec(self):
        '''
        :return: layout spec with two panes with 1 axes in the first pane and 2 in the other.
        '''
        major_ratio = [self.majorSplitRatio.value(), 1]
        minor_ratio = [self.minorSplitRatio.value(), 1]
        if self.chartSplit.currentText() == 'Horizontal':
            gs = GridSpec(2,
                          2,
                          width_ratios=major_ratio,
                          height_ratios=minor_ratio,
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 2, 1)
            spec_2 = gs.new_subplotspec((0, 1), 1, 1)
            spec_3 = gs.new_subplotspec((1, 1), 1, 1)
        else:
            gs = GridSpec(2,
                          2,
                          width_ratios=minor_ratio,
                          height_ratios=major_ratio,
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 1, 2)
            spec_2 = gs.new_subplotspec((1, 0), 1, 1)
            spec_3 = gs.new_subplotspec((1, 1), 1, 1)
        return spec_1, spec_2, spec_3

    def __get_two_pane_one_pane_spec(self):
        '''
        :return: layout spec with two panes with 2 axes in the first pane and 1 in the other.
        '''
        major_ratio = [self.majorSplitRatio.value(), 1]
        minor_ratio = [self.minorSplitRatio.value(), 1]
        if self.chartSplit.currentText() == 'Horizontal':
            gs = GridSpec(2,
                          2,
                          width_ratios=major_ratio,
                          height_ratios=minor_ratio,
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 1, 1)
            spec_2 = gs.new_subplotspec((1, 0), 1, 1)
            spec_3 = gs.new_subplotspec((0, 1), 2, 1)
        else:
            gs = GridSpec(2,
                          2,
                          width_ratios=minor_ratio,
                          height_ratios=major_ratio,
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 1, 1)
            spec_2 = gs.new_subplotspec((0, 1), 1, 1)
            spec_3 = gs.new_subplotspec((1, 0), 2, 1)
        return spec_1, spec_2, spec_3

    def __get_two_pane_spec(self):
        '''
        :return: layout spec with two panes containing an axes in each.
        '''
        if self.chartSplit.currentText() == 'Horizontal':
            gs = GridSpec(1,
                          2,
                          width_ratios=[self.majorSplitRatio.value(), 1],
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 1, 1)
            spec_2 = gs.new_subplotspec((0, 1), 1, 1)
        else:
            gs = GridSpec(2,
                          1,
                          height_ratios=[self.majorSplitRatio.value(), 1],
                          wspace=self.widthSpacing.value(),
                          hspace=self.heightSpacing.value())
            spec_1 = gs.new_subplotspec((0, 0), 1, 1)
            spec_2 = gs.new_subplotspec((1, 0), 1, 1)
        return spec_1, spec_2

    def __init_imshow_axes(self):
        if self.__imshow_axes is not None:
            if self.imageBorder.isChecked():
                self.__imshow_axes.axis('on')
                self.__imshow_axes.get_xaxis().set_major_locator(NullLocator())
                self.__imshow_axes.get_yaxis().set_major_locator(NullLocator())
            else:
                self.__imshow_axes.axis('off')

    def __table_print(self, filt):
        ''' formats the filter into a format suitable for rendering in the table '''
        header = self.showTableHeader.isChecked()
        vals = [str(filt.freq) if header else f"{filt.freq} Hz"] if hasattr(
            filt, 'freq') else ['']
        from model.iir import CompoundPassFilter
        if isinstance(filt, CompoundPassFilter):
            vals.append('N/A')
            vals.append(f"{filt.type.value}{filt.order}")
            from model.iir import ComplexHighPass
            vals.append('HPF' if isinstance(filt, ComplexHighPass) else 'LPF')
            vals.append('')
        elif filt.filter_type.startswith('LPF') or filt.filter_type.startswith(
                'HPF'):
            vals.append('N/A')
            vals.append(f"{round(filt.q, 4)}")
            vals.append(filt.filter_type)
            vals.append('')
        else:
            gain = filt.gain if hasattr(filt, 'gain') else 0
            g_suffix = ' dB' if gain != 0 and not header else ''
            vals.append(f"{gain:+g}{g_suffix}" if gain != 0 else vals.
                        append(str('N/A')))

            vals.append(f"{round(filt.q, 4)}") if hasattr(
                filt, 'q') else vals.append(str('N/A'))
            filter_type = filt.filter_type
            if len(filt) > 1:
                filter_type += f" x{len(filt)}"
            vals.append(filter_type)
            if gain != 0 and len(filt) > 1:
                vals.append(f"{len(filt)*gain:+g}{g_suffix}")
            else:
                vals.append('')
        return vals

    def get_curve_data(self, reference=None):
        ''' feeds the magnitude model with data '''
        return self.__selected_xy

    def set_selected(self):
        ''' Updates the selected curves and redraws. '''
        selected = [x.text() for x in self.curves.selectedItems()]
        self.__selected_xy = [x for x in self.__xy_data if x.name in selected]
        if self.__magnitude_model is not None:
            self.redraw()

    def set_title_size(self, _):
        ''' updates the title size '''
        self.set_title(self.title.text())

    def set_title(self, text, draw=True):
        ''' sets the title text '''
        if self.__imshow_axes is None:
            if self.__magnitude_model is not None:
                self.__magnitude_model.limits.axes_1.set_title(
                    str(text), fontsize=self.titleFontSize.value())
        else:
            self.__imshow_axes.set_title(str(text),
                                         fontsize=self.titleFontSize.value())
        if draw:
            self.preview.canvas.draw_idle()

    def redraw(self):
        ''' triggers a redraw. '''
        self.__magnitude_model.redraw()

    def resizeEvent(self, resizeEvent):
        '''
        replaces the replace and updates axis sizes when the window resizes.
        :param resizeEvent: the event.
        '''
        super().resizeEvent(resizeEvent)
        self.replace_table()
        self.__record_image_size()

    def __record_image_size(self):
        ''' displays the image size on the form. '''
        if self.__image is None or self.__pixel_perfect_mode:
            self.imageWidthPixels.setValue(0)
            self.imageHeightPixels.setValue(0)
        else:
            width, height = get_ax_size(self.__image)
            self.imageWidthPixels.setValue(width)
            self.imageHeightPixels.setValue(height)

    def accept(self):
        ''' saves the report '''
        if self.__pixel_perfect_mode:
            self.__save_pixel_perfect()
        else:
            self.__save_report()

    def __save_report(self):
        ''' writes the figure to the specified format '''
        formats = "Report Files (*.png *.jpg *.jpeg)"
        file_name = QFileDialog.getSaveFileName(parent=self,
                                                caption='Export Report',
                                                filter=formats)
        if file_name:
            output_file = str(file_name[0]).strip()
            if len(output_file) == 0:
                return
            else:
                format = os.path.splitext(output_file)[1][1:].strip()
                if format in VALID_IMG_FORMATS:
                    scale_factor = self.widthPixels.value() / self.__x
                    from app import wait_cursor
                    with wait_cursor():
                        self.__status_bar.showMessage(
                            f"Saving report to {output_file}", 5000)
                        self.preview.canvas.figure.savefig(output_file,
                                                           format=format,
                                                           dpi=self.__dpi *
                                                           scale_factor,
                                                           pad_inches=0,
                                                           bbox_inches='tight')
                        self.__status_bar.showMessage(
                            f"Saved report to {output_file}", 5000)
                else:
                    msg_box = QMessageBox()
                    msg_box.setText(
                        f"Invalid output file format - {output_file} is not one of {VALID_IMG_FORMATS}"
                    )
                    msg_box.setIcon(QMessageBox.Critical)
                    msg_box.setWindowTitle('Unexpected Error')
                    msg_box.exec()

    def __save_pixel_perfect(self):
        ''' saves an image based on passing the image through directly '''
        if len(self.image.text()) > 0:
            file_name = QFileDialog.getSaveFileName(
                parent=self,
                caption='Export Report',
                filter='Report File (*.jpg *.png *.jpeg)')
            if file_name:
                output_file = str(file_name[0]).strip()
                if len(output_file) == 0:
                    return
                else:
                    format = os.path.splitext(output_file)[1][1:].strip()
                    if format in VALID_IMG_FORMATS:
                        from app import wait_cursor
                        with wait_cursor():
                            self.__status_bar.showMessage(
                                f"Saving report to {output_file}", 5000)
                            self.preview.canvas.figure.savefig(output_file,
                                                               format=format,
                                                               dpi=self.__dpi)
                            if self.__concat_images(format, output_file):
                                self.__status_bar.showMessage(
                                    f"Saved report to {output_file}", 5000)
                    else:
                        msg_box = QMessageBox()
                        msg_box.setText(
                            f"Invalid output file format - {output_file} is not one of {VALID_IMG_FORMATS}"
                        )
                        msg_box.setIcon(QMessageBox.Critical)
                        msg_box.setWindowTitle('Unexpected Error')
                        msg_box.exec()
        else:
            msg_box = QMessageBox()
            msg_box.setText('Unable to create report, no image selected')
            msg_box.setIcon(QMessageBox.Information)
            msg_box.setWindowTitle('No Image')
            msg_box.exec()

    def __concat_images(self, format, output_file):
        ''' cats 2 images vertically '''
        im_image = Image.open(self.image.text())
        mp_image = Image.open(output_file)
        more_args = {}
        convert_to_rgb = False
        if format.lower() == 'jpg' or format.lower() == 'jpeg':
            more_args['subsampling'] = 0
            more_args['quality'] = 95
            if im_image.format.lower() != 'jpg' and im_image.format.lower(
            ) != 'jpeg':
                msg_box = QMessageBox()
                msg_box.setText(
                    f"Image format is {im_image.format}/{im_image.mode} but the desired output format is JPG<p/><p/>The image must be converted to RGB in order to proceed."
                )
                msg_box.setIcon(QMessageBox.Warning)
                msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
                msg_box.setWindowTitle('Do you want to convert?')
                decision = msg_box.exec()
                if decision == QMessageBox.Yes:
                    convert_to_rgb = True
                else:
                    return False
        if im_image.size[0] != mp_image.size[0]:
            new_height = round(im_image.size[1] *
                               (mp_image.size[0] / im_image.size[0]))
            logger.debug(
                f"Resizing from {im_image.size} to match {mp_image.size}, new height {new_height}"
            )
            im_image = im_image.resize((mp_image.size[0], new_height),
                                       Image.LANCZOS)
        if convert_to_rgb:
            im_image = im_image.convert('RGB')
        final_image = Image.new(
            im_image.mode,
            (im_image.size[0], im_image.size[1] + mp_image.size[1]))
        final_image.paste(im_image, (0, 0))
        final_image.paste(mp_image, (0, im_image.size[1]))
        final_image.save(output_file, **more_args)
        return True

    def closeEvent(self, QCloseEvent):
        ''' Stores the window size on close '''
        self.__preferences.set(REPORT_GEOMETRY, self.saveGeometry())
        super().closeEvent(QCloseEvent)

    def save_layout(self):
        '''
        Saves the layout in the preferences.
        '''
        self.__preferences.set(REPORT_FILTER_ROW_HEIGHT_MULTIPLIER,
                               self.filterRowHeightMultiplier.value())
        self.__preferences.set(REPORT_TITLE_FONT_SIZE,
                               self.titleFontSize.value())
        self.__preferences.set(REPORT_IMAGE_ALPHA, self.imageOpacity.value())
        self.__preferences.set(REPORT_FILTER_X0, self.x0.value())
        self.__preferences.set(REPORT_FILTER_X1, self.x1.value())
        self.__preferences.set(REPORT_FILTER_Y0, self.y0.value())
        self.__preferences.set(REPORT_FILTER_Y1, self.y1.value())
        self.__preferences.set(REPORT_LAYOUT_MAJOR_RATIO,
                               self.majorSplitRatio.value())
        self.__preferences.set(REPORT_LAYOUT_MINOR_RATIO,
                               self.minorSplitRatio.value())
        self.__preferences.set(REPORT_LAYOUT_SPLIT_DIRECTION,
                               self.chartSplit.currentText())
        self.__preferences.set(REPORT_LAYOUT_TYPE,
                               self.chartLayout.currentText())
        self.__preferences.set(REPORT_CHART_GRID_ALPHA,
                               self.gridOpacity.value())
        self.__preferences.set(REPORT_CHART_SHOW_LEGEND,
                               self.showLegend.isChecked())
        self.__preferences.set(REPORT_FILTER_SHOW_HEADER,
                               self.showTableHeader.isChecked())
        self.__preferences.set(REPORT_FILTER_FONT_SIZE,
                               self.tableFontSize.value())
        self.__preferences.set(REPORT_LAYOUT_HSPACE,
                               self.heightSpacing.value())
        self.__preferences.set(REPORT_LAYOUT_WSPACE, self.widthSpacing.value())
        if self.__magnitude_model is not None:
            self.__preferences.set(REPORT_CHART_LIMITS_X0,
                                   self.__magnitude_model.limits.x_min)
            self.__preferences.set(REPORT_CHART_LIMITS_X1,
                                   self.__magnitude_model.limits.x_max)
            self.__preferences.set(REPORT_CHART_LIMITS_X_SCALE,
                                   self.__magnitude_model.limits.x_scale)

    def discard_layout(self):
        '''
        Discards the stored layout in the preferences.
        '''
        self.__preferences.clear_all(REPORT_GROUP)
        self.restore_layout(redraw=True)

    def restore_layout(self, redraw=False):
        '''
        Restores the saved layout.
        :param redraw: if true, also redraw the report.
        '''
        self.filterRowHeightMultiplier.blockSignals(True)
        self.filterRowHeightMultiplier.setValue(
            self.__preferences.get(REPORT_FILTER_ROW_HEIGHT_MULTIPLIER))
        if self.__first_create:
            self.filterRowHeightMultiplier.setValue(
                self.filterRowHeightMultiplier.value() * 1.85)
        self.filterRowHeightMultiplier.blockSignals(False)
        self.titleFontSize.setValue(
            self.__preferences.get(REPORT_TITLE_FONT_SIZE))
        with block_signals(self.tableFontSize):
            self.tableFontSize.setValue(
                self.__preferences.get(REPORT_FILTER_FONT_SIZE))
        with block_signals(self.showTableHeader):
            self.showTableHeader.setChecked(
                self.__preferences.get(REPORT_FILTER_SHOW_HEADER))
        self.imageOpacity.setValue(self.__preferences.get(REPORT_IMAGE_ALPHA))
        self.x0.setValue(self.__preferences.get(REPORT_FILTER_X0))
        self.x1.setValue(self.__preferences.get(REPORT_FILTER_X1))
        self.y0.setValue(self.__preferences.get(REPORT_FILTER_Y0))
        self.y1.setValue(self.__preferences.get(REPORT_FILTER_Y1))
        self.majorSplitRatio.setValue(
            self.__preferences.get(REPORT_LAYOUT_MAJOR_RATIO))
        with block_signals(self.minorSplitRatio):
            self.minorSplitRatio.setValue(
                self.__preferences.get(REPORT_LAYOUT_MINOR_RATIO))
        self.__restore_combo(REPORT_LAYOUT_SPLIT_DIRECTION, self.chartSplit)
        self.__restore_combo(REPORT_LAYOUT_TYPE, self.chartLayout)
        with block_signals(self.gridOpacity):
            self.gridOpacity.setValue(
                self.__preferences.get(REPORT_CHART_GRID_ALPHA))
        with block_signals(self.showLegend):
            self.showLegend.setChecked(
                self.__preferences.get(REPORT_CHART_SHOW_LEGEND))
        with block_signals(self.widthSpacing):
            self.widthSpacing.setValue(
                self.__preferences.get(REPORT_LAYOUT_WSPACE))
        with block_signals(self.heightSpacing):
            self.heightSpacing.setValue(
                self.__preferences.get(REPORT_LAYOUT_HSPACE))
        if redraw:
            self.redraw_all_axes()

    def __restore_combo(self, key, combo):
        value = self.__preferences.get(key)
        if value is not None:
            idx = combo.findText(value)
            if idx > -1:
                combo.setCurrentIndex(idx)

    def update_height(self, new_width):
        '''
        Updates the height as the width changes according to the aspect ratio.
        :param new_width: the new width.
        '''
        self.heightPixels.setValue(
            int(math.floor(new_width / self.__aspect_ratio)))

    def choose_image(self):
        '''
        Pick an image and display it.
        '''
        image = QFileDialog.getOpenFileName(
            parent=self,
            caption='Choose Image',
            filter='Images (*.png *.jpeg *.jpg)')
        img_file = image[0] if image is not None and len(image) > 0 else None
        self.image.setText(img_file)
        self.apply_image()

    def apply_image(self, draw=True):
        '''
        Applies the image to the current charts.
        :param draw: true if we should redraw.
        '''
        img_file = self.image.text()
        if len(img_file) > 0:
            im = imread(img_file)
            self.nativeImageWidth.setValue(im.shape[1])
            self.nativeImageHeight.setValue(im.shape[0])
            self.snapToImageSize.setEnabled(True)
            if self.__imshow_axes is None:
                if self.__magnitude_model is not None and not self.__pixel_perfect_mode:
                    extent = self.__make_extent(self.__magnitude_model.limits)
                    self.__image = self.__magnitude_model.limits.axes_1.imshow(
                        im, extent=extent, alpha=self.imageOpacity.value())
            else:
                self.__image = self.__imshow_axes.imshow(
                    im, alpha=self.imageOpacity.value())
        else:
            self.snapToImageSize.setEnabled(False)
            if self.__imshow_axes is None:
                if self.__image is not None:
                    pass
            else:
                self.nativeImageWidth.setValue(0)
                self.nativeImageHeight.setValue(0)
                self.__imshow_axes.clear()
                self.__init_imshow_axes()
        self.set_title(self.title.text())
        if draw:
            self.preview.canvas.draw_idle()
            self.__record_image_size()

    def __make_extent(self, limits):
        return limits.x_min, limits.x_max, limits.y1_min, limits.y1_max

    def set_table_font_size(self, size):
        ''' changes the size of the font in the table '''
        if self.__filter_axes is not None:
            self.replace_table()

    def show_limits(self):
        ''' Show the limits dialog '''
        if self.__magnitude_model is not None:
            self.__magnitude_model.show_limits()

    def set_image_opacity(self, value):
        ''' updates the image alpha '''
        if self.__image is not None:
            self.__image.set_alpha(value)
            self.preview.canvas.draw_idle()

    def set_image_border(self):
        ''' adds or removes the image border '''
        if self.__imshow_axes is not None:
            self.__init_imshow_axes()
            self.preview.canvas.draw_idle()
            self.__record_image_size()

    def set_grid_opacity(self, value):
        ''' updates the grid alpha '''
        if self.__magnitude_model is not None:
            self.__magnitude_model.limits.axes_1.grid(alpha=value)
            self.preview.canvas.draw_idle()

    def update_image_url(self, text):
        ''' changes the icon to open if the url is valid '''
        if len(text) > 0:
            o = urlparse(text)
            if len(o.scheme) > 0 and len(o.netloc) > 0:
                self.loadURL.setEnabled(True)
                if self.loadURL.signalsBlocked():
                    self.loadURL.setIcon(qta.icon('fa5s.download'))
                    self.loadURL.blockSignals(False)

    def load_image_from_url(self):
        ''' attempts to download the image and sets it as the file name '''
        tmp_image = self.__download_image()
        if tmp_image is not None:
            self.loadURL.setIcon(qta.icon('fa5s.check', color='green'))
            self.loadURL.blockSignals(True)
            self.image.setText(tmp_image)
            self.apply_image()
        else:
            self.loadURL.setIcon(qta.icon('fa5s.times', color='red'))

    def __download_image(self):
        '''
        Attempts to download the image.
        :return: the filename containing the downloaded data.
        '''
        url_file_type = None
        try:
            url_file_type = os.path.splitext(self.imageURL.text())[1].strip()
        except:
            pass
        tmp_file = tempfile.NamedTemporaryFile(delete=False,
                                               suffix=url_file_type)
        with open(tmp_file.name, 'wb') as f:
            try:
                f.write(requests.get(self.imageURL.text()).content)
                name = tmp_file.name
            except:
                logger.exception(f"Unable to download {self.imageURL.text()}")
                tmp_file.delete = True
                name = None
        return name

    def snap_to_image_size(self):
        ''' Snaps the dialog size to the image size. '''
        self.__honour_image_aspect_ratio()
Ejemplo n.º 7
0
 def __init__(self, preferences, signal_model, waveform_chart,
              spectrum_chart, signal_selector, rms_level, headroom,
              crest_factor, bm_headroom, bm_lpf_position, bm_clip_before,
              bm_clip_after, is_filtered, apply_hard_clip, start_time,
              end_time, show_spectrum_btn, hide_spectrum_btn, zoom_in_btn,
              zoom_out_btn, compare_spectrum_btn, source_file,
              load_signal_btn, show_limits_btn, show_stats_btn, y_min,
              y_max, bm_hpf, waveform_chart_btn):
     self.__is_visible = False
     self.__selected_name = None
     self.__preferences = preferences
     self.__signal_model = signal_model
     self.__current_signal = None
     self.__active_signal = None
     self.__is_filtered = is_filtered
     self.__apply_hard_clip = apply_hard_clip
     self.__bm_headroom = bm_headroom
     self.__bm_hpf = bm_hpf
     self.__bm_lpf_position = bm_lpf_position
     for x in BM_LPF_OPTIONS:
         self.__bm_lpf_position.addItem(x)
     self.__bm_clip_before = bm_clip_before
     self.__bm_clip_after = bm_clip_after
     self.__start_time = start_time
     self.__end_time = end_time
     self.__show_spectrum_btn = show_spectrum_btn
     self.__hide_spectrum_btn = hide_spectrum_btn
     self.__compare_spectrum_btn = compare_spectrum_btn
     self.__zoom_in_btn = zoom_in_btn
     self.__zoom_out_btn = zoom_out_btn
     self.__load_signal_btn = load_signal_btn
     self.__show_stats_btn = show_stats_btn
     self.__source_file = source_file
     self.__selector = signal_selector
     self.__waveform_chart_model = WaveformModel(waveform_chart,
                                                 crest_factor, rms_level,
                                                 headroom, start_time,
                                                 end_time, y_min, y_max,
                                                 self.__on_x_range_change)
     spectrum_chart.vbl.setContentsMargins(1, 1, 1, 1)
     spectrum_chart.setVisible(False)
     self.__magnitude_model = MagnitudeModel('spectrum',
                                             spectrum_chart,
                                             preferences,
                                             self.get_curve_data,
                                             'Spectrum',
                                             show_legend=lambda: False)
     self.__show_limits_btn = show_limits_btn
     # match the pyqtgraph layout
     self.__load_signal_btn.clicked.connect(self.__load_signal)
     self.__show_spectrum_btn.setIcon(qta.icon('fa5s.eye'))
     self.__hide_spectrum_btn.setIcon(qta.icon('fa5s.eye-slash'))
     self.__zoom_in_btn.setIcon(qta.icon('fa5s.search-plus'))
     self.__zoom_out_btn.setIcon(qta.icon('fa5s.search-minus'))
     self.__load_signal_btn.setIcon(qta.icon('fa5s.folder-open'))
     self.__show_limits_btn.setIcon(qta.icon('fa5s.arrows-alt'))
     self.__compare_spectrum_btn.setIcon(qta.icon('fa5s.chart-area'))
     self.__show_stats_btn.setIcon(qta.icon('fa5s.info-circle'))
     waveform_chart_btn.setIcon(qta.icon('fa5s.save'))
     waveform_chart_btn.setToolTip('Export to Chart')
     self.__show_spectrum_btn.clicked.connect(self.show_spectrum)
     self.__hide_spectrum_btn.clicked.connect(self.hide_spectrum)
     self.__compare_spectrum_btn.clicked.connect(self.compare_spectrum)
     self.__show_limits_btn.clicked.connect(
         self.__magnitude_model.show_limits)
     self.__show_stats_btn.clicked.connect(self.show_stats)
     self.__is_filtered.stateChanged['int'].connect(self.toggle_filter)
     self.__apply_hard_clip.stateChanged['int'].connect(
         self.toggle_hard_clip)
     self.__bm_headroom.currentIndexChanged['QString'].connect(
         self.change_bm_headroom)
     self.__bm_lpf_position.currentIndexChanged['QString'].connect(
         self.change_bm_lpf_position)
     self.__bm_hpf.stateChanged['int'].connect(self.toggle_hpf)
     self.__bm_clip_before.stateChanged['int'].connect(
         self.toggle_bm_clip_before)
     self.__bm_clip_after.stateChanged['int'].connect(
         self.toggle_bm_clip_after)
     self.__selector.currentIndexChanged['QString'].connect(
         self.update_waveform)
     self.__zoom_in_btn.clicked.connect(self.__waveform_chart_model.zoom_in)
     self.__zoom_out_btn.clicked.connect(self.__zoom_out)
     waveform_chart_btn.clicked.connect(self.save_charts)
     self.update_waveform(None)
Ejemplo n.º 8
0
class WaveformController:
    def __init__(self, preferences, signal_model, waveform_chart,
                 spectrum_chart, signal_selector, rms_level, headroom,
                 crest_factor, bm_headroom, bm_lpf_position, bm_clip_before,
                 bm_clip_after, is_filtered, apply_hard_clip, start_time,
                 end_time, show_spectrum_btn, hide_spectrum_btn, zoom_in_btn,
                 zoom_out_btn, compare_spectrum_btn, source_file,
                 load_signal_btn, show_limits_btn, show_stats_btn, y_min,
                 y_max, bm_hpf, waveform_chart_btn):
        self.__is_visible = False
        self.__selected_name = None
        self.__preferences = preferences
        self.__signal_model = signal_model
        self.__current_signal = None
        self.__active_signal = None
        self.__is_filtered = is_filtered
        self.__apply_hard_clip = apply_hard_clip
        self.__bm_headroom = bm_headroom
        self.__bm_hpf = bm_hpf
        self.__bm_lpf_position = bm_lpf_position
        for x in BM_LPF_OPTIONS:
            self.__bm_lpf_position.addItem(x)
        self.__bm_clip_before = bm_clip_before
        self.__bm_clip_after = bm_clip_after
        self.__start_time = start_time
        self.__end_time = end_time
        self.__show_spectrum_btn = show_spectrum_btn
        self.__hide_spectrum_btn = hide_spectrum_btn
        self.__compare_spectrum_btn = compare_spectrum_btn
        self.__zoom_in_btn = zoom_in_btn
        self.__zoom_out_btn = zoom_out_btn
        self.__load_signal_btn = load_signal_btn
        self.__show_stats_btn = show_stats_btn
        self.__source_file = source_file
        self.__selector = signal_selector
        self.__waveform_chart_model = WaveformModel(waveform_chart,
                                                    crest_factor, rms_level,
                                                    headroom, start_time,
                                                    end_time, y_min, y_max,
                                                    self.__on_x_range_change)
        spectrum_chart.vbl.setContentsMargins(1, 1, 1, 1)
        spectrum_chart.setVisible(False)
        self.__magnitude_model = MagnitudeModel('spectrum',
                                                spectrum_chart,
                                                preferences,
                                                self.get_curve_data,
                                                'Spectrum',
                                                show_legend=lambda: False)
        self.__show_limits_btn = show_limits_btn
        # match the pyqtgraph layout
        self.__load_signal_btn.clicked.connect(self.__load_signal)
        self.__show_spectrum_btn.setIcon(qta.icon('fa5s.eye'))
        self.__hide_spectrum_btn.setIcon(qta.icon('fa5s.eye-slash'))
        self.__zoom_in_btn.setIcon(qta.icon('fa5s.search-plus'))
        self.__zoom_out_btn.setIcon(qta.icon('fa5s.search-minus'))
        self.__load_signal_btn.setIcon(qta.icon('fa5s.folder-open'))
        self.__show_limits_btn.setIcon(qta.icon('fa5s.arrows-alt'))
        self.__compare_spectrum_btn.setIcon(qta.icon('fa5s.chart-area'))
        self.__show_stats_btn.setIcon(qta.icon('fa5s.info-circle'))
        waveform_chart_btn.setIcon(qta.icon('fa5s.save'))
        waveform_chart_btn.setToolTip('Export to Chart')
        self.__show_spectrum_btn.clicked.connect(self.show_spectrum)
        self.__hide_spectrum_btn.clicked.connect(self.hide_spectrum)
        self.__compare_spectrum_btn.clicked.connect(self.compare_spectrum)
        self.__show_limits_btn.clicked.connect(
            self.__magnitude_model.show_limits)
        self.__show_stats_btn.clicked.connect(self.show_stats)
        self.__is_filtered.stateChanged['int'].connect(self.toggle_filter)
        self.__apply_hard_clip.stateChanged['int'].connect(
            self.toggle_hard_clip)
        self.__bm_headroom.currentIndexChanged['QString'].connect(
            self.change_bm_headroom)
        self.__bm_lpf_position.currentIndexChanged['QString'].connect(
            self.change_bm_lpf_position)
        self.__bm_hpf.stateChanged['int'].connect(self.toggle_hpf)
        self.__bm_clip_before.stateChanged['int'].connect(
            self.toggle_bm_clip_before)
        self.__bm_clip_after.stateChanged['int'].connect(
            self.toggle_bm_clip_after)
        self.__selector.currentIndexChanged['QString'].connect(
            self.update_waveform)
        self.__zoom_in_btn.clicked.connect(self.__waveform_chart_model.zoom_in)
        self.__zoom_out_btn.clicked.connect(self.__zoom_out)
        waveform_chart_btn.clicked.connect(self.save_charts)
        self.update_waveform(None)

    def save_charts(self):
        self.__waveform_chart_model.export_chart()
        if self.__magnitude_model.is_visible() is True:
            self.__magnitude_model.export_chart()

    @property
    def is_visible(self):
        return self.__is_visible

    @property
    def selected_name(self):
        return self.__selected_name

    @selected_name.setter
    def selected_name(self, selected_name):
        self.__selected_name = selected_name

    def __zoom_out(self):
        ''' zooms out and hides the spectrum '''
        self.hide_spectrum()
        self.__waveform_chart_model.zoom_out()

    def __on_x_range_change(self):
        ''' updates the view if the x range changes. '''
        if self.__magnitude_model.is_visible():
            self.__magnitude_model.redraw()

    def get_curve_data(self, reference=None):
        '''
        :param reference: ignored as we don't expose a normalisation control in this chart.
        :return: the peak and avg spectrum for the currently filtered signal (if any).
        '''
        if self.__active_signal is not None and self.__magnitude_model.is_visible(
        ):
            sig = SingleChannelSignalData(signal=self.__active_signal.cut(
                to_seconds(self.__start_time), to_seconds(self.__end_time)))
            sig.reindex(self.__selector.currentIndex() - 1)
            return sig.get_all_xy()
        return []

    def on_visibility_change(self, show=True):
        '''
        Reacts to a change in visibility by deselecting the visible chart or showing the previously selected one.
        :param show: True if we're showing the chart.
        '''
        self.__is_visible = show
        if show is True:
            if self.__selected_name is not None:
                idx = self.__selector.findText(self.__selected_name)
                if idx > -1:
                    self.__selector.setCurrentIndex(idx)
                else:
                    if self.__selector.count() == 2:
                        self.__selector.setCurrentIndex(1)
                    else:
                        self.__reset_controls()
        else:
            self.__selected_name = self.__selector.currentText()
            self.__selector.setCurrentIndex(0)

    def refresh_selector(self):
        ''' Updates the selector with the available signals. '''
        currently_selected = self.__selector.currentText()
        from model.report import block_signals
        with block_signals(self.__selector):
            self.__selector.clear()
            self.__selector.addItem('  ')
            for bm in self.__signal_model.bass_managed_signals:
                self.__selector.addItem(f"(BM) {bm.name}")
                for c in bm.channels:
                    if c.signal is not None:
                        self.__selector.addItem(c.name)
                    else:
                        self.__selector.addItem(f"-- {c.name}")
            for s in self.__signal_model.non_bm_signals:
                if s.signal is not None:
                    self.__selector.addItem(s.name)
                else:
                    self.__selector.addItem(f"-- {s.name}")
            idx = self.__selector.findText(currently_selected)
            if idx > -1:
                self.__selector.setCurrentIndex(idx)
            else:
                self.__reset_controls()

    def __reset_controls(self):
        self.__waveform_chart_model.clear()
        self.__source_file.clear()
        self.__on_x_range_change()
        self.hide_spectrum()

    def update_waveform(self, signal_name):
        ''' displays the waveform for the selected signal '''
        if self.__current_signal is not None:
            self.__current_signal.unregister_listener(self.on_filter_update)
        self.__current_signal = self.__get_signal_data(signal_name)
        if self.__current_signal is None:
            self.__reset_time(self.__start_time)
            self.__reset_time(self.__end_time)
            self.__load_signal_btn.setEnabled(True)
            self.__bm_headroom.setEnabled(False)
            self.__bm_lpf_position.setEnabled(False)
            self.__bm_hpf.setEnabled(False)
            self.__bm_hpf.setChecked(False)
            self.__bm_clip_before.setEnabled(False)
            self.__bm_clip_after.setEnabled(False)
            self.__show_stats_btn.setEnabled(False)
            self.__reset_controls()
            self.__active_signal = None
        else:
            self.__load_signal_btn.setEnabled(False)
            self.__show_stats_btn.setEnabled(True)
            metadata = self.__current_signal.metadata
            if metadata is not None:
                if SIGNAL_CHANNEL in metadata:
                    self.__source_file.setText(
                        f"{metadata[SIGNAL_SOURCE_FILE]} - C{metadata[SIGNAL_CHANNEL]}"
                    )
                else:
                    self.__source_file.setText(metadata[SIGNAL_SOURCE_FILE])
            from model.report import block_signals
            if signal_name.startswith('(BM) '):
                with block_signals(self.__bm_hpf):
                    self.__bm_hpf.setEnabled(False)
                    self.__bm_hpf.setChecked(False)
                with block_signals(self.__bm_headroom):
                    self.__bm_headroom.setEnabled(True)
                    self.__bm_headroom.setCurrentText(
                        self.__current_signal.bm_headroom_type)
                with block_signals(self.__bm_lpf_position):
                    self.__bm_lpf_position.setEnabled(True)
                    self.__bm_lpf_position.setCurrentText(
                        self.__current_signal.bm_lpf_position)
                with block_signals(self.__bm_clip_before):
                    self.__bm_clip_before.setEnabled(True)
                    self.__bm_clip_before.setChecked(
                        self.__current_signal.clip_before)
                with block_signals(self.__bm_clip_after):
                    self.__bm_clip_after.setEnabled(True)
                    self.__bm_clip_after.setChecked(
                        self.__current_signal.clip_after)
            else:
                if signal_name.endswith('LFE'):
                    with block_signals(self.__bm_hpf):
                        self.__bm_hpf.setEnabled(False)
                        self.__bm_hpf.setChecked(False)
                else:
                    with block_signals(self.__bm_hpf):
                        self.__bm_hpf.setEnabled(True)
                        self.__bm_hpf.setChecked(
                            self.__current_signal.high_pass)
                self.__bm_headroom.setEnabled(False)
                self.__bm_lpf_position.setEnabled(False)
                self.__bm_clip_before.setEnabled(False)
                self.__bm_clip_after.setEnabled(False)
            self.__current_signal.register_listener(self.on_filter_update)
            self.__start_time.setEnabled(True)
            duration = QTime(0, 0, 0).addMSecs(
                self.__current_signal.duration_seconds * 1000.0)
            self.__start_time.setMaximumTime(duration)
            self.__end_time.setEnabled(True)
            self.__end_time.setMaximumTime(duration)
            self.__end_time.setTime(duration)
            self.toggle_filter(
                Qt.Checked if self.__is_filtered.isChecked() else Qt.Unchecked)

    def __load_signal(self):
        signal_name = self.__selector.currentText()
        if signal_name.startswith('-- '):
            signal_name = signal_name[3:]
        signal_data = self.__get_signal_data(signal_name)
        if signal_data is not None and signal_data.signal is None:
            AssociateSignalDialog(self.__preferences, signal_data).exec()
            if signal_data.signal is not None:
                self.__selector.setItemText(self.__selector.currentIndex(),
                                            signal_name)
                self.update_waveform(signal_name)

    def __get_signal_data(self, signal_name):
        if signal_name is not None and signal_name.startswith('(BM) '):
            return next((s for s in self.__signal_model.bass_managed_signals
                         if s.name == signal_name[5:]), None)
        else:
            return next(
                (s for s in self.__signal_model if s.name == signal_name),
                None)

    @staticmethod
    def __reset_time(time_widget):
        ''' resets and disables the supplied time field. '''
        from model.report import block_signals
        with block_signals(time_widget):
            time_widget.clearMaximumDateTime()
            time_widget.setTime(QTime())
            time_widget.setEnabled(False)

    def __set_bm_signal_attr(self, attr_name, attr_value):
        signal_name = self.__selector.currentText()
        if signal_name.startswith('(BM) '):
            signal_data = self.__get_signal_data(signal_name)
            if signal_data is not None:
                setattr(signal_data, attr_name, attr_value)
                self.toggle_filter(Qt.Checked if self.__is_filtered.isChecked(
                ) else Qt.Unchecked)

    def change_bm_headroom(self, headroom):
        ''' Changes the headroom allowed for bass management '''
        self.__set_bm_signal_attr('bm_headroom_type', headroom)

    def change_bm_lpf_position(self, lpf_position):
        ''' Changes the LPF applied during bass management '''
        self.__set_bm_signal_attr('bm_lpf_position', lpf_position)

    def toggle_bm_clip_before(self, state):
        ''' Changes whether to clip the signal before summation '''
        self.__set_bm_signal_attr('clip_before', state == Qt.Checked)

    def toggle_bm_clip_after(self, state):
        ''' Changes whether to clip the signal after summation '''
        self.__set_bm_signal_attr('clip_after', state == Qt.Checked)

    def toggle_hpf(self, state):
        ''' applies or removes the hpf from the visible waveform '''
        signal_name = self.__selector.currentText()
        signal_data = self.__get_signal_data(signal_name)
        if signal_data is not None:
            signal_data.high_pass = state == Qt.Checked
            from app import wait_cursor
            with wait_cursor():
                signal = signal_data.filter_signal(
                    filt=self.__is_filtered.isChecked(),
                    clip=self.__apply_hard_clip.isChecked(),
                    post_filt=self.__get_post_filt_hpf(
                        apply=state == Qt.Checked))
                self.__active_signal = signal
                self.__waveform_chart_model.signal = signal
                self.__waveform_chart_model.idx = self.__selector.currentIndex(
                ) - 1
                self.__waveform_chart_model.analyse()
                if self.__magnitude_model.is_visible():
                    self.__magnitude_model.redraw()

    def __get_post_filt_hpf(self, apply=None):
        post_filt = None
        if apply is None:
            apply = self.__bm_hpf.isChecked() and self.__bm_hpf.isEnabled()
        if apply is True:
            from model.iir import FilterType, ComplexHighPass
            hpf_fs = self.__preferences.get(BASS_MANAGEMENT_LPF_FS)
            post_filt = ComplexHighPass(FilterType.LINKWITZ_RILEY, 4, 1000,
                                        hpf_fs)
        return post_filt

    def toggle_filter(self, state):
        ''' Applies or removes the filter from the visible waveform '''
        signal_name = self.__selector.currentText()
        signal_data = self.__get_signal_data(signal_name)
        if signal_data is not None:
            from app import wait_cursor
            with wait_cursor():
                signal = signal_data.filter_signal(
                    filt=state == Qt.Checked,
                    clip=self.__apply_hard_clip.isChecked(),
                    post_filt=self.__get_post_filt_hpf())
                self.__active_signal = signal
                self.__waveform_chart_model.signal = signal
                self.__waveform_chart_model.idx = self.__selector.currentIndex(
                ) - 1
                self.__waveform_chart_model.analyse()
                if self.__magnitude_model.is_visible():
                    self.__magnitude_model.redraw()

    def toggle_hard_clip(self, state):
        ''' Applies or removes the hard clip option from the visible waveform '''
        signal_name = self.__selector.currentText()
        signal_data = self.__get_signal_data(signal_name)
        if signal_data is not None:
            from app import wait_cursor
            with wait_cursor():
                signal = signal_data.filter_signal(
                    filt=self.__is_filtered.isChecked(),
                    clip=state == Qt.Checked,
                    post_filt=self.__get_post_filt_hpf())
                self.__active_signal = signal
                self.__waveform_chart_model.signal = signal
                self.__waveform_chart_model.idx = self.__selector.currentIndex(
                ) - 1
                self.__waveform_chart_model.analyse()
                if self.__magnitude_model.is_visible():
                    self.__magnitude_model.redraw()

    def on_filter_update(self):
        ''' if the signal is filtered then updated the chart when the filter changes. '''
        if self.__is_filtered.isChecked():
            self.toggle_filter(Qt.Checked)

    def show_spectrum(self):
        ''' Updates the visible spectrum for the selected waveform limits '''
        self.__magnitude_model.set_visible(True)
        self.__magnitude_model.redraw()

    def hide_spectrum(self):
        ''' Resets the visible spectrum for the selected waveform limits '''
        self.__magnitude_model.set_visible(False)

    def compare_spectrum(self):
        from model.analysis import AnalyseSignalDialog
        AnalyseSignalDialog(self.__preferences,
                            self.__signal_model,
                            allow_load=False).exec()

    def show_stats(self):
        ''' Shows the signal stats dialog. '''
        StatsDialog(self.__active_signal,
                    self.__waveform_chart_model.rms).exec()
Ejemplo n.º 9
0
class SyncHTP1Dialog(QDialog, Ui_syncHtp1Dialog):

    def __init__(self, parent, prefs, signal_model):
        super(SyncHTP1Dialog, self).__init__(parent)
        self.__preferences = prefs
        self.__signal_model = signal_model
        self.__simple_signal = None
        self.__filters_by_channel = {}
        self.__current_device_filters_by_channel = {}
        self.__spinner = None
        self.__beq_filter = None
        self.__signal_filter = None
        self.__last_requested_msoupdate = None
        self.__last_received_msoupdate = None
        self.__supports_shelf = False
        self.__channel_to_signal = {}
        self.setupUi(self)
        self.setWindowFlag(Qt.WindowMinimizeButtonHint)
        self.setWindowFlag(Qt.WindowMaximizeButtonHint)
        self.syncStatus = qta.IconWidget('fa5s.unlink')
        self.syncLayout.addWidget(self.syncStatus)
        self.ipAddress.setText(self.__preferences.get(HTP1_ADDRESS))
        self.filterView.setSelectionBehavior(QAbstractItemView.SelectRows)

        from model.filter import FilterModel, FilterTableModel
        self.__filters = FilterModel(self.filterView, self.__preferences)
        self.filterView.setModel(FilterTableModel(self.__filters))
        self.filterView.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        self.filterView.selectionModel().selectionChanged.connect(self.__on_filter_selected)
        self.__magnitude_model = MagnitudeModel('preview', self.previewChart, self.__preferences,
                                                self.get_curve_data, 'Filter',
                                                x_min_pref_key=HTP1_GRAPH_X_MIN, x_max_pref_key=HTP1_GRAPH_X_MAX,
                                                y_range_calc=DecibelRangeCalculator(30, expand=True), fill_curves=True)
        self.connectButton.setIcon(qta.icon('fa5s.check'))
        self.disconnectButton.setIcon(qta.icon('fa5s.times'))
        self.resyncFilters.setIcon(qta.icon('fa5s.sync'))
        self.deleteFiltersButton.setIcon(qta.icon('fa5s.trash'))
        self.editFilterButton.setIcon(qta.icon('fa5s.edit'))
        self.selectBeqButton.setIcon(qta.icon('fa5s.folder-open'))
        self.limitsButton.setIcon(qta.icon('fa5s.arrows-alt'))
        self.showDetailsButton.setIcon(qta.icon('fa5s.info'))
        self.createPulsesButton.setIcon(qta.icon('fa5s.wave-square'))
        self.fullRangeButton.setIcon(qta.icon('fa5s.expand'))
        self.subOnlyButton.setIcon(qta.icon('fa5s.compress'))
        self.autoSyncButton.setIcon(qta.icon('fa5s.magic'))
        self.autoSyncButton.toggled.connect(lambda b: self.__preferences.set(HTP1_AUTOSYNC, b))
        self.autoSyncButton.setChecked(self.__preferences.get(HTP1_AUTOSYNC))
        self.__ws_client = QtWebSockets.QWebSocket('', QtWebSockets.QWebSocketProtocol.Version13, None)
        self.__ws_client.error.connect(self.__on_ws_error)
        self.__ws_client.connected.connect(self.__on_ws_connect)
        self.__ws_client.disconnected.connect(self.__on_ws_disconnect)
        self.__ws_client.textMessageReceived.connect(self.__on_ws_message)
        self.__disable_on_disconnect()
        self.filterMapping.itemDoubleClicked.connect(self.__show_mapping_dialog)
        self.__restore_geometry()

    def __restore_geometry(self):
        ''' loads the saved window size '''
        geometry = self.__preferences.get(HTP1_SYNC_GEOMETRY)
        if geometry is not None:
            self.restoreGeometry(geometry)

    def closeEvent(self, QCloseEvent):
        ''' Stores the window size on close '''
        self.__preferences.set(HTP1_SYNC_GEOMETRY, self.saveGeometry())
        super().closeEvent(QCloseEvent)

    def __on_ws_error(self, error_code):
        '''
        handles a websocket client error.
        :param error_code: the error code.
        '''
        logger.error(f"error code: {error_code}")
        logger.error(self.__ws_client.errorString())

    def __on_ws_connect(self):
        logger.info(f"Connected to {self.ipAddress.text()}")
        self.__preferences.set(HTP1_ADDRESS, self.ipAddress.text())
        self.__load_peq_slots()
        self.__enable_on_connect()
        self.syncStatus.setIcon(qta.icon('fa5s.link'))
        self.syncStatus.setEnabled(True)

    def __load_peq_slots(self):
        b = self.__ws_client.sendTextMessage('getmso')
        logger.debug(f"Sent {b} bytes")

    def __on_ws_disconnect(self):
        logger.info(f"Disconnected from {self.ipAddress.text()}")
        self.__disable_on_disconnect()

    def __disable_on_disconnect(self):
        ''' Clears all relevant state on disconnect. '''
        self.connectButton.setEnabled(True)
        self.disconnectButton.setEnabled(False)
        self.ipAddress.setReadOnly(False)
        self.resyncFilters.setEnabled(False)
        self.deleteFiltersButton.setEnabled(False)
        self.editFilterButton.setEnabled(False)
        self.showDetailsButton.setEnabled(False)
        self.createPulsesButton.setEnabled(False)
        self.filtersetSelector.clear()
        self.__filters_by_channel = {}
        self.__current_device_filters_by_channel = {}
        self.beqFile.clear()
        self.__beq_filter = None
        self.selectBeqButton.setEnabled(False)
        self.addFilterButton.setEnabled(False)
        self.removeFilterButton.setEnabled(False)
        self.applyFiltersButton.setEnabled(False)
        self.autoSyncButton.setEnabled(False)
        self.__show_signal_mapping()
        self.syncStatus.setIcon(qta.icon('fa5s.unlink'))
        self.syncStatus.setEnabled(False)
        self.__filters.filter = CompleteFilter(fs=HTP1_FS, sort_by_id=True)
        self.__simple_signal = self.__create_pulse('default', self.__filters.filter)
        self.__magnitude_model.redraw()

    def __enable_on_connect(self):
        ''' Prepares the UI for operation. '''
        self.connectButton.setEnabled(False)
        self.disconnectButton.setEnabled(True)
        self.ipAddress.setReadOnly(True)
        self.resyncFilters.setEnabled(True)
        self.autoSyncButton.setEnabled(True)
        self.selectBeqButton.setEnabled(True)
        self.createPulsesButton.setEnabled(True)
        self.__show_signal_mapping()
        self.on_signal_selected()

    def __on_ws_message(self, msg):
        '''
        Handles messages from the device.
        :param msg: the message.
        '''
        if msg.startswith('mso '):
            logger.debug(f"Processing mso {msg}")
            self.__on_mso(json.loads(msg[4:]))
        elif msg.startswith('msoupdate '):
            logger.debug(f"Processing msoupdate {msg}")
            self.__on_msoupdate(json.loads(msg[10:]))
        else:
            logger.warning(f"Unknown message {msg}")
            msg_box = QMessageBox(QMessageBox.Critical, 'Unknown Message',
                                  f"Received unexpected message from {self.ipAddress.text()}")
            msg_box.setDetailedText(f"<code>{msg}</code>")
            msg_box.setTextFormat(Qt.RichText)
            msg_box.exec()
            if self.__spinner is not None:
                stop_spinner(self.__spinner, self.syncStatus)
                self.__spinner = None
                self.syncStatus.setIcon(qta.icon('fa5s.times', color='red'))

    def __on_msoupdate(self, msoupdate):
        '''
        Handles msoupdate message sent after the device is updated.
        :param msoupdate: the update.
        '''
        if len(msoupdate) > 0 and 'path' in msoupdate[0] and msoupdate[0]['path'].startswith('/peq/slots/'):
            self.__last_received_msoupdate = msoupdate
            was_requested = False
            if self.__spinner is not None:
                was_requested = True
                stop_spinner(self.__spinner, self.syncStatus)
                self.__spinner = None
            if was_requested:
                if not self.__msoupdate_matches(msoupdate):
                    self.syncStatus.setIcon(qta.icon('fa5s.times', color='red'))
                    self.show_sync_details()
                else:
                    self.syncStatus.setIcon(qta.icon('fa5s.check', color='green'))
            else:
                self.syncStatus.setIcon(qta.icon('fa5s.question', color='red'))
            self.__update_device_state(msoupdate)
            self.showDetailsButton.setEnabled(True)
        else:
            logger.debug(f"Ignoring UI driven update")

    def __update_device_state(self, msoupdate):
        ''' applies the delta from msoupdate to the local cache of the device state. '''
        for op in msoupdate:
            if 'path' in op and op['path'].startswith('/peq/slots'):
                path = op['path']
                if path[0] == '/':
                    path = path[1:]
                tokens = path.split('/')
                idx = int(tokens[2])
                channel = tokens[4]
                attrib = tokens[5]
                val = op['value']
                if channel in self.__current_device_filters_by_channel:
                    cf = self.__current_device_filters_by_channel[channel]
                    f = cf[idx].resample(HTP1_FS)
                    if attrib == 'gaindB':
                        f.gain = float(val)
                    elif attrib == 'Fc':
                        f.freq = float(val)
                    elif attrib == 'Q':
                        f.q = float(val)
                    cf.save(f)

    def show_sync_details(self):
        if self.__last_requested_msoupdate is not None and self.__last_received_msoupdate is not None:
            SyncDetailsDialog(self, self.__last_requested_msoupdate, self.__last_received_msoupdate).exec()

    def __msoupdate_matches(self, msoupdate):
        '''
        compares each operation for equivalence.
        :param msoupdate: the update
        :returns true if they match
        '''
        for idx, actual in enumerate(msoupdate):
            expected = self.__last_requested_msoupdate[idx]
            if actual['op'] != expected['op'] or actual['path'] != expected['path'] or actual['value'] != expected['value']:
                logger.error(f"msoupdate does not match {actual} vs {expected}")
                return False
        return True

    def __on_mso(self, mso):
        '''
        Handles mso message from the device sent after a getmso is issued.
        :param mso: the mso.
        '''
        version = mso['versions']['swVer']
        version = version[1:] if version[0] == 'v' or version[0] == 'V' else version
        try:
            self.__supports_shelf = semver.parse_version_info(version) > semver.parse_version_info('1.4.0')
        except:
            logger.error(f"Unable to parse version {mso['versions']['swVer']}")
            result = QMessageBox.question(self,
                                          'Supports Shelf Filters?',
                                          f"Reported software version is "
                                          f"\n\n    {version}"
                                          f"\n\nDoes this version support shelf filters?",
                                          QMessageBox.Yes | QMessageBox.No,
                                          QMessageBox.No)
            self.__supports_shelf = result == QMessageBox.Yes

        speakers = mso['speakers']['groups']
        channels = ['lf', 'rf']
        for group in [s for s, v in speakers.items() if 'present' in v and v['present'] is True]:
            if group[0:2] == 'lr' and len(group) > 2:
                channels.append('l' + group[2:])
                channels.append('r' + group[2:])
            else:
                channels.append(group)

        peq_slots = mso['peq']['slots']

        class Filters(dict):
            def __init__(self):
                super().__init__()

            def __missing__(self, key):
                self[key] = CompleteFilter(fs=HTP1_FS, sort_by_id=True)
                return self[key]

        tmp1 = Filters()
        tmp2 = Filters()
        raw_filters = {c: [] for c in channels}
        unknown_channels = set()
        for idx, s in enumerate(peq_slots):
            for c in channels:
                if c in s['channels']:
                    tmp1[c].save(self.__convert_to_filter(s['channels'][c], idx))
                    tmp2[c].save(self.__convert_to_filter(s['channels'][c], idx))
                    raw_filters[c].append(s['channels'][c])
                else:
                    unknown_channels.add(c)
        if unknown_channels:
            peq_channels = peq_slots[0]['channels'].keys()
            logger.error(f"Unknown channels encountered [peq channels: {peq_channels}, unknown: {unknown_channels}]")

        from model.report import block_signals
        with block_signals(self.filtersetSelector):
            now = self.filtersetSelector.currentText()
            self.filtersetSelector.clear()
            now_idx = -1
            for idx, c in enumerate(channels):
                self.filtersetSelector.addItem(c)
                if c == now:
                    now_idx = idx
            if now_idx > -1:
                self.filtersetSelector.setCurrentIndex(now_idx)

        self.__filters_by_channel = tmp1
        self.__current_device_filters_by_channel = tmp2
        self.__filters.filter = self.__filters_by_channel[self.filtersetSelector.itemText(0)]
        if not self.__channel_to_signal:
            self.load_from_signals()
        self.filterView.selectRow(0)
        self.__magnitude_model.redraw()
        self.syncStatus.setIcon(qta.icon('fa5s.link'))
        self.__last_received_msoupdate = None
        self.__last_requested_msoupdate = None
        self.showDetailsButton.setEnabled(False)

    @staticmethod
    def __convert_to_filter(filter_params, id):
        ft = int(filter_params['FilterType']) if SyncHTP1Dialog.__has_filter_type(filter_params) else 0
        if ft == 0:
            return PeakingEQ(HTP1_FS, filter_params['Fc'], filter_params['Q'], filter_params['gaindB'], f_id=id)
        elif ft == 1:
            return LowShelf(HTP1_FS, filter_params['Fc'], filter_params['Q'], filter_params['gaindB'], f_id=id)
        elif ft == 2:
            return HighShelf(HTP1_FS, filter_params['Fc'], filter_params['Q'], filter_params['gaindB'], f_id=id)

    @staticmethod
    def __has_filter_type(filter_params):
        return 'FilterType' in filter_params

    def add_filter(self):
        '''
        Adds the filter to the selected channel.
        '''
        selected_filter = self.__get_selected_filter()
        if selected_filter is not None:
            if self.__in_complex_mode():
                for i in self.filterMapping.selectedItems():
                    c = i.text().split(' ')[1]
                    s = self.__channel_to_signal[c]
                    if s is not None:
                        self.__apply_filter(selected_filter, s.filter)
                        if c == self.filtersetSelector.currentText():
                            self.__filters.filter = self.__filters.filter
            else:
                self.__apply_filter(selected_filter, self.__filters.filter)
                self.__filters.filter = self.__filters.filter
            self.__magnitude_model.redraw()

    @staticmethod
    def __apply_filter(source, target):
        '''
        Places the target filter in the source in the last n slots.
        :param source: the source filter.
        :param target: the target.
        '''
        max_idx = len(target)
        target.removeByIndex(range(max_idx-len(source), max_idx))
        for f in source:
            f.id = len(target)
            target.save(f)

    def __get_selected_filter(self):
        selected_filter = None
        if self.__signal_filter is not None:
            selected_filter = self.__signal_filter
        elif self.__beq_filter is not None:
            selected_filter = self.__beq_filter
        return selected_filter

    def remove_filter(self):
        '''
        Searches for the BEQ in the selected channel and highlights them for deletion.
        Auto deletes if in complex mode.
        '''
        selected_filter = self.__get_selected_filter()
        if selected_filter is not None:
            if self.__in_complex_mode():
                for i in self.filterMapping.selectedItems():
                    c = i.text().split(' ')[1]
                    s = self.__channel_to_signal[c]
                    if s is not None:
                        for r in self.__remove_from_filter(selected_filter, s.filter):
                            to_save = self.__filters if c == self.filtersetSelector.currentText() else s.filter
                            to_save.save(self.__make_passthrough(r))
                self.__magnitude_model.redraw()
            else:
                rows = self.__remove_from_filter(selected_filter, self.__filters.filter)
                self.filterView.clearSelection()
                if len(rows) > 0:
                    for r in rows:
                        self.filterView.selectRow(r)

    def __remove_from_filter(self, to_remove, target):
        filt_idx = 0
        rows = []
        for i, f in enumerate(target):
            if self.__is_equivalent(f, to_remove[filt_idx]):
                filt_idx += 1
                rows.append(i)
            if filt_idx >= len(to_remove):
                break
        return rows

    def select_filter(self, channel_name):
        '''
        loads a filter from the current signals.
        '''
        signal = self.__signal_model.find_by_name(channel_name)
        if signal is not None:
            self.__signal_filter = signal.filter
        else:
            self.__signal_filter = None
        self.__enable_filter_buttons()

    @staticmethod
    def __is_equivalent(a, b):
        return a.freq == b.freq and a.gain == b.gain and hasattr(a, 'q') and hasattr(b, 'q') and math.isclose(a.q, b.q)

    def send_filters_to_device(self):
        '''
        Sends the selected filters to the device
        '''
        if self.__in_complex_mode():
            ops, unsupported_filter_types_per_channel = self.__convert_filter_mappings_to_ops()
            channels_to_update = [i.text().split(' ')[1] for i in self.filterMapping.selectedItems()]
            unsynced_channels = []
            for c, s in self.__channel_to_signal.items():
                if s is not None:
                    if c not in channels_to_update:
                        if c in self.__current_device_filters_by_channel:
                            current = s.filter
                            device = self.__current_device_filters_by_channel[c]
                            if current != device:
                                unsynced_channels.append(c)
            if unsynced_channels:
                result = QMessageBox.question(self,
                                              'Sync all changed channels?',
                                              f"Filters in {len(unsynced_channels)} channels have changed but will not be "
                                              f"synced to the HTP-1."
                                              f"\n\nChannels: {', '.join(sorted([k for k in unsynced_channels]))}"
                                              f"\n\nDo you want to sync all changed channels? ",
                                              QMessageBox.Yes | QMessageBox.No,
                                              QMessageBox.No)
                if result == QMessageBox.Yes:
                    for c in unsynced_channels:
                        for i in range(self.filterMapping.count()):
                            item: QListWidgetItem = self.filterMapping.item(i)
                            if c == item.text().split(' ')[1]:
                                item.setSelected(True)
                    self.send_filters_to_device()
                    return
        else:
            ops, unsupported_filter_types_per_channel = self.__convert_current_filter_to_ops()
        do_send = True
        if unsupported_filter_types_per_channel:
            printed = '\n'.join([f"{k} - {', '.join(v)}" for k, v in unsupported_filter_types_per_channel.items()])
            result = QMessageBox.question(self,
                                          'Unsupported Filters Detected',
                                          f"Unsupported filter types found in the filter set:"
                                          f"\n\n{printed}"
                                          f"\n\nDo you want sync the supported filters only? ",
                                          QMessageBox.Yes | QMessageBox.No,
                                          QMessageBox.No)
            do_send = result == QMessageBox.Yes
        if do_send:
            from app import wait_cursor
            with wait_cursor():
                all_ops = [op for slot_ops in ops for op in slot_ops]
                self.__last_requested_msoupdate = all_ops
                msg = f"changemso {json.dumps(self.__last_requested_msoupdate)}"
                logger.debug(f"Sending to {self.ipAddress.text()} -> {msg}")
                self.__spinner = StoppableSpin(self.syncStatus, 'sync')
                spin_icon = qta.icon('fa5s.spinner', color='green', animation=self.__spinner)
                self.syncStatus.setIcon(spin_icon)
                self.__ws_client.sendTextMessage(msg)

    def __convert_current_filter_to_ops(self):
        '''
        Converts the selected filter to operations.
        :return: (the operations, channel with non PEQ filters)
        '''
        unsupported_filter_types_per_channel = {}
        selected_filter = self.filtersetSelector.currentText()
        ops = [self.__as_operation(idx, selected_filter, f) for idx, f in enumerate(self.__filters.filter)]
        unsupported_types = [f.filter_type for f in self.__filters.filter if self.__not_supported(f.filter_type)]
        if unsupported_types:
            unsupported_filter_types_per_channel[selected_filter] = set(unsupported_types)
        return ops, unsupported_filter_types_per_channel

    def __convert_filter_mappings_to_ops(self):
        '''
        Converts the selected filter mappings to operations.
        :return: (the operations, the channels with unsupported filter types)
        '''
        ops = []
        unsupported_filter_types_per_channel = OrderedDict()
        for i in self.filterMapping.selectedItems():
            c = i.text().split(' ')[1]
            s = self.__channel_to_signal[c]
            if s is not None:
                unsupported_types = [f.filter_type for f in s.filter if self.__not_supported(f.filter_type)]
                if unsupported_types:
                    unsupported_filter_types_per_channel[c] = sorted(set(unsupported_types))
                channel_ops = [self.__as_operation(idx, c, f) for idx, f in enumerate(s.filter)]
                base = len(channel_ops)
                remainder = 16 - base
                if remainder > 0:
                    channel_ops += [self.__as_operation(base + i, c, self.__make_passthrough(i)) for i in range(remainder)]
                ops += channel_ops
        return ops, unsupported_filter_types_per_channel

    def __not_supported(self, filter_type):
        if self.__supports_shelf is True:
            supported = filter_type == 'PEQ' or filter_type == 'LS' or filter_type == 'HS'
        else:
            supported = filter_type == 'PEQ'
        return not supported

    @staticmethod
    def __make_passthrough(idx):
        return PeakingEQ(HTP1_FS, 100, 1, 0, f_id=idx)

    def __as_operation(self, idx, channel, f):
        prefix = f"/peq/slots/{idx}/channels/{channel}"
        ops = [
            {
                'op': 'replace',
                'path': f"{prefix}/Fc",
                'value': f.freq
            },
            {
                'op': 'replace',
                'path': f"{prefix}/Q",
                'value': f.q
            },
            {
                'op': 'replace',
                'path': f"{prefix}/gaindB",
                'value': f.gain
            }
        ]
        if self.__supports_shelf:
            if isinstance(f, PeakingEQ):
                ft = 0
            elif isinstance(f, LowShelf):
                ft = 1
            elif isinstance(f, HighShelf):
                ft = 2
            else:
                raise ValueError(f"Unknown filter type {f}")
            ops.append(
                {
                    'op': 'replace',
                    'path': f"{prefix}/FilterType",
                    'value': ft
                }
            )
        return ops

    def clear_filters(self):
        '''
        Replaces the selected filters.
        '''
        selection_model = self.filterView.selectionModel()
        if selection_model.hasSelection():
            self.__filters.delete([x.row() for x in selection_model.selectedRows()])
            ids = [f.id for f in self.__filters]
            for i in range(16):
                if i not in ids:
                    self.__filters.save(self.__make_passthrough(i))
            self.applyFiltersButton.setEnabled(len(self.__filters) == 16)
            self.__magnitude_model.redraw()

    def connect_htp1(self):
        '''
        Connects to the websocket of the specified ip:port.
        '''
        self.ipAddress.setReadOnly(True)
        logger.info(f"Connecting to {self.ipAddress.text()}")
        self.__ws_client.open(QUrl(f"ws://{self.ipAddress.text()}/ws/controller"))

    def disconnect_htp1(self):
        '''
        disconnects the ws
        '''
        logger.info(f"Closing connection to {self.ipAddress.text()}")
        self.__ws_client.close()
        logger.info(f"Closed connection to {self.ipAddress.text()}")
        self.ipAddress.setReadOnly(False)

    def display_filterset(self, filterset):
        '''
        Displays the selected filterset in the chart and the table.
        :param filterset: the filterset.
        '''
        if filterset in self.__filters_by_channel:
            self.__filters.filter = self.__filters_by_channel[filterset]
            self.filterView.selectRow(0)
            self.__magnitude_model.redraw()
        else:
            logger.warning(f"Unknown filterset {filterset}")

    def resync_filters(self):
        '''
        reloads the PEQ slots
        '''
        self.__load_peq_slots()
        self.beqFile.clear()

    def select_beq(self):
        '''
        Selects a filter from the BEQ repos.
        '''
        from model.minidsp import load_as_filter

        filters, file_name = load_as_filter(self, self.__preferences, HTP1_FS, unroll=True)
        self.beqFile.setText(file_name)
        self.__beq_filter = filters
        self.__enable_filter_buttons()

    def __enable_filter_buttons(self):
        enable = self.__beq_filter is not None or self.__signal_filter is not None
        self.addFilterButton.setEnabled(enable)
        self.removeFilterButton.setEnabled(enable)

    def get_curve_data(self, reference=None):
        ''' preview of the filter to display on the chart '''
        result = []
        if len(self.__filters) > 0:
            result.append(self.__filters.get_transfer_function().get_magnitude(colour=get_filter_colour(len(result))))
            for f in self.__filters:
                result.append(f.get_transfer_function()
                               .get_magnitude(colour=get_filter_colour(len(result)), linestyle=':'))
        return result

    def reject(self):
        '''
        Ensures the HTP1 is disconnected before closing.
        '''
        self.disconnect_htp1()
        super().reject()

    def show_limits(self):
        '''
        Shows the limits dialog.
        '''
        self.__magnitude_model.show_limits()

    def show_full_range(self):
        ''' sets the limits to full range. '''
        self.__magnitude_model.show_full_range()

    def show_sub_only(self):
        ''' sets the limits to sub only. '''
        self.__magnitude_model.show_sub_only()

    def create_pulses(self):
        ''' Creates a dirac pulse with the specified filter for each channel and loads them into the signal model. '''
        for p in [self.__create_pulse(c, f) for c, f in self.__filters_by_channel.items()]:
            self.__signal_model.add(p)
        self.load_from_signals()

    def __recalc_mapping(self):
        for c in self.__filters_by_channel.keys():
            found = False
            for s in self.__signal_model:
                if s.name.endswith(f"_{c}"):
                    self.__channel_to_signal[c] = s
                    found = True
                    break
            if not found:
                self.__channel_to_signal[c] = None

    def __show_signal_mapping(self):
        show_it = self.__in_complex_mode()
        self.loadFromSignalsButton.setEnabled(show_it)
        self.filterMappingLabel.setVisible(show_it)
        self.filterMapping.setVisible(show_it)
        self.filterMappingLabel.setEnabled(show_it)
        self.filterMapping.setEnabled(show_it)
        self.selectNoneButton.setVisible(show_it)
        self.selectAllButton.setVisible(show_it)
        self.filterMapping.clear()
        for c, signal in self.__channel_to_signal.items():
            self.filterMapping.addItem(f"Channel {c} -> {signal.name if signal else 'No Filter'}")
        for i in range(self.filterMapping.count()):
            item: QListWidgetItem = self.filterMapping.item(i)
            if item.text().endswith('No Filter'):
                item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
        self.filtersetLabel.setText('Preview' if show_it else 'Channel')

    def __in_complex_mode(self):
        '''
        :return: true if we have signals and are connected.
        '''
        return len(self.__signal_model) > 0 and not self.connectButton.isEnabled()

    def __create_pulse(self, c, f):
        from scipy.signal import unit_impulse
        from model.signal import SingleChannelSignalData, Signal
        signal = Signal(f"pulse_{c}", unit_impulse(4 * HTP1_FS, 'mid'), self.__preferences, fs=HTP1_FS)
        return SingleChannelSignalData(name=f"pulse_{c}", filter=f, signal=signal)

    def load_from_signals(self):
        '''
        Maps the signals to the HTP1 channels.
        '''
        self.__recalc_mapping()
        self.__show_signal_mapping()
        self.__apply_filters_to_channels()
        self.on_signal_selected()

    def __apply_filters_to_channels(self):
        '''
        Applies the filters from the mapped signals to the channels.
        '''
        for c, signal in self.__channel_to_signal.items():
            if signal is not None:
                self.__filters_by_channel[c] = signal.filter

    def __show_mapping_dialog(self, item: QListWidgetItem):
        ''' Shows the edit mapping dialog '''
        if self.filterMapping.isEnabled():
            text = item.text()
            channel_name = text.split(' ')[1]
            mapped_signal = self.__channel_to_signal.get(channel_name, None)
            EditMappingDialog(self,
                              self.__filters_by_channel.keys(),
                              channel_name,
                              self.__signal_model,
                              mapped_signal,
                              self.__map_signal_to_channel).exec()

    def __map_signal_to_channel(self, channel_name, signal):
        '''
        updates the mapping of a signal to a channel.
        :param channel_name: the channel name.
        :param signal: the mapped signal.
        '''
        if signal:
            self.__channel_to_signal[channel_name] = signal
        else:
            self.__channel_to_signal[channel_name] = None
        self.__show_signal_mapping()

    def on_signal_selected(self):
        ''' if any channels are selected, enable the sync button. '''
        is_selected = self.filterMapping.selectionModel().hasSelection()
        enable = is_selected or not self.__in_complex_mode()
        self.applyFiltersButton.setEnabled(enable)
        enable_beq = enable and self.__get_selected_filter() is not None
        self.addFilterButton.setEnabled(enable_beq)
        self.removeFilterButton.setEnabled(enable_beq)
        self.addFilterButton.setEnabled(enable_beq)
        self.removeFilterButton.setEnabled(enable_beq)

    def clear_sync_selection(self):
        ''' clears any selection for sync . '''
        self.filterMapping.selectionModel().clearSelection()

    def select_all_for_sync(self):
        ''' selects all channels for sync. '''
        self.filterMapping.selectAll()

    def __on_filter_selected(self):
        ''' enables the edit/delete buttons when selections change. '''
        selection = self.filterView.selectionModel()
        self.deleteFiltersButton.setEnabled(selection.hasSelection())
        self.editFilterButton.setEnabled(selection.hasSelection())

    def edit_filter(self):
        selection = self.filterView.selectionModel()
        if selection.hasSelection():
            if self.__in_complex_mode():
                signal = self.__signal_model.find_by_name(f"pulse_{self.filtersetSelector.currentText()}")
                # ensure edited signal is selected for sync (otherwise autosync can get v confused)
                for i in range(self.filterMapping.count()):
                    item: QListWidgetItem = self.filterMapping.item(i)
                    if self.filtersetSelector.currentText() == item.text().split(' ')[1]:
                        item.setSelected(True)
            else:
                signal = self.__simple_signal
            from model.filter import FilterDialog
            FilterDialog(self.__preferences,
                         signal,
                         self.__filters,
                         self.__on_filter_save,
                         selected_filter=self.__filters[selection.selectedRows()[0].row()],
                         valid_filter_types=['PEQ', 'Low Shelf', 'High Shelf'] if self.__supports_shelf else ['PEQ'],
                         parent=self,
                         max_filters=16,
                         x_lim=(self.__magnitude_model.limits.x_min, self.__magnitude_model.limits.x_max)).show()

    def __on_filter_save(self):
        ''' reacts to a filter being saved by redrawing the UI and syncing the filter to the HTP-1. '''
        self.__magnitude_model.redraw()
        can_sync = len(self.__filters) == 16
        self.applyFiltersButton.setEnabled(can_sync)
        if not can_sync:
            msg_box = QMessageBox()
            msg_box.setText(f"Too many filters loaded, remove {len(self.__filters) - 16} to be able to sync")
            msg_box.setIcon(QMessageBox.Warning)
            msg_box.setWindowTitle('Too Many Filters')
            msg_box.exec()
        if self.autoSyncButton.isChecked() and can_sync:
            self.send_filters_to_device()
Ejemplo n.º 10
0
class FilterDialog(QDialog, Ui_editFilterDialog):
    '''
    Add/Edit Filter dialog
    '''
    is_shelf = ['Low Shelf', 'High Shelf']
    gain_required = is_shelf + ['PEQ', 'Gain']
    q_steps = [0.0001, 0.001, 0.01, 0.1]
    gain_steps = [0.1, 1.0]
    freq_steps = [0.1, 1.0, 2.0, 5.0]
    passthrough = Passthrough()

    def __init__(self,
                 preferences,
                 signal,
                 filter_model,
                 redraw_main,
                 selected_filter=None,
                 parent=None,
                 valid_filter_types=None):
        self.__preferences = preferences
        super(FilterDialog,
              self).__init__(parent) if parent is not None else super(
                  FilterDialog, self).__init__()
        self.__redraw_main = redraw_main
        # for shelf filter, allow input via Q or S not both
        self.__q_is_active = True
        # allow user to control the steps for different fields, default to reasonably quick moving values
        self.__q_step_idx = self.__get_step(
            self.q_steps, self.__preferences.get(DISPLAY_Q_STEP), 3)
        self.__s_step_idx = self.__get_step(
            self.q_steps, self.__preferences.get(DISPLAY_S_STEP), 3)
        self.__gain_step_idx = self.__get_step(
            self.gain_steps, self.__preferences.get(DISPLAY_GAIN_STEP), 0)
        self.__freq_step_idx = self.__get_step(
            self.freq_steps, self.__preferences.get(DISPLAY_FREQ_STEP), 1)
        # init the UI itself
        self.setupUi(self)
        self.__snapshot = FilterModel(self.snapshotFilterView,
                                      self.__preferences,
                                      on_update=self.__on_snapshot_change)
        self.__working = FilterModel(self.workingFilterView,
                                     self.__preferences,
                                     on_update=self.__on_working_change)
        self.__selected_id = None
        self.__decorate_ui()
        self.__set_q_step(self.q_steps[self.__q_step_idx])
        self.__set_s_step(self.q_steps[self.__s_step_idx])
        self.__set_gain_step(self.gain_steps[self.__gain_step_idx])
        self.__set_freq_step(self.freq_steps[self.__freq_step_idx])
        # underlying filter model
        self.__signal = signal
        self.__filter_model = filter_model
        if self.__filter_model.filter.listener is not None:
            logger.debug(
                f"Selected filter has listener {self.__filter_model.filter.listener.name}"
            )
        # init the chart
        self.__magnitude_model = MagnitudeModel(
            'preview',
            self.previewChart,
            preferences,
            self,
            'Filter',
            db_range_calc=dBRangeCalculator(30, expand=True),
            fill_curves=True)
        # remove unsupported filter types
        if valid_filter_types:
            to_remove = []
            for i in range(self.filterType.count()):
                if self.filterType.itemText(i) not in valid_filter_types:
                    to_remove.append(i)
            for i1, i2 in enumerate(to_remove):
                self.filterType.removeItem(i2 - i1)
        # copy the filter into the working table
        self.__working.filter = self.__filter_model.clone()
        # and initialise the view
        for idx, f in enumerate(self.__working):
            selected = selected_filter is not None and f.id == selected_filter.id
            f.id = uuid4()
            if selected is True:
                self.__selected_id = f.id
                self.workingFilterView.selectRow(idx)
        if self.__selected_id is None:
            self.__add_working_filter()

    def __select_working_filter(self):
        ''' Loads the selected filter into the edit fields. '''
        selection = self.workingFilterView.selectionModel()
        if selection.hasSelection():
            idx = selection.selectedRows()[0].row()
            self.headerLabel.setText(f"Working Filter {idx+1}")
            self.__select_filter(self.__working[idx])

    def __select_snapshot_filter(self):
        ''' Loads the selected filter into the edit fields. '''
        selection = self.snapshotFilterView.selectionModel()
        if selection.hasSelection():
            idx = selection.selectedRows()[0].row()
            self.headerLabel.setText(f"Snapshot Filter {idx+1}")
            self.__select_filter(self.__snapshot[idx])

    def __on_snapshot_change(self, _):
        ''' makes the snapshot table visible when we have one. '''
        self.snapshotFilterView.setVisible(len(self.__snapshot) > 0)
        self.snapshotViewButtonWidget.setVisible(len(self.__snapshot) > 0)
        self.__magnitude_model.redraw()
        return True

    def __on_working_change(self, visible_names):
        ''' ensure the graph redraws when a filter changes. '''
        self.__magnitude_model.redraw()
        return True

    def __decorate_ui(self):
        ''' polishes the UI by setting tooltips, adding icons and connecting widgets to functions. '''
        self.__set_tooltips()
        self.__set_icons()
        self.__connect_working_buttons()
        self.__connect_snapshot_buttons()
        self.__link_table_views()

    def __link_table_views(self):
        ''' Links the table views into the dialog. '''
        self.snapshotFilterView.setVisible(False)
        self.snapshotViewButtonWidget.setVisible(False)
        self.snapshotFilterView.setModel(FilterTableModel(self.__snapshot))
        self.snapshotFilterView.horizontalHeader().setSectionResizeMode(
            QHeaderView.Stretch)
        self.snapshotFilterView.selectionModel().selectionChanged.connect(
            self.__select_snapshot_filter)
        self.workingFilterView.setModel(FilterTableModel(self.__working))
        self.workingFilterView.horizontalHeader().setSectionResizeMode(
            QHeaderView.Stretch)
        self.workingFilterView.selectionModel().selectionChanged.connect(
            self.__select_working_filter)

    def __set_icons(self):
        self.saveButton.setIcon(qta.icon('fa5s.save'))
        self.saveButton.setIconSize(QtCore.QSize(32, 32))
        self.exitButton.setIcon(qta.icon('fa5s.sign-out-alt'))
        self.exitButton.setIconSize(QtCore.QSize(32, 32))
        self.snapFilterButton.setIcon(qta.icon('fa5s.copy'))
        self.acceptSnapButton.setIcon(qta.icon('fa5s.check'))
        self.loadSnapButton.setIcon(qta.icon('fa5s.folder-open'))
        self.resetButton.setIcon(qta.icon('fa5s.undo'))
        self.optimiseButton.setIcon(qta.icon('fa5s.magic'))
        self.addWorkingRowButton.setIcon(qta.icon('fa5s.plus'))
        self.addSnapshotRowButton.setIcon(qta.icon('fa5s.plus'))
        self.removeWorkingRowButton.setIcon(qta.icon('fa5s.minus'))
        self.removeSnapshotRowButton.setIcon(qta.icon('fa5s.minus'))
        self.limitsButton.setIcon(qta.icon('fa5s.arrows-alt'))
        self.fullRangeButton.setIcon(qta.icon('fa5s.expand'))
        self.subOnlyButton.setIcon(qta.icon('fa5s.compress'))

    def __set_tooltips(self):
        self.addSnapshotRowButton.setToolTip('Add new filter to snapshot')
        self.removeSnapshotRowButton.setToolTip(
            'Remove selected filter from snapshot')
        self.addWorkingRowButton.setToolTip('Add new filter')
        self.removeWorkingRowButton.setToolTip('Remove selected filter')
        self.snapFilterButton.setToolTip('Freeze snapshot')
        self.loadSnapButton.setToolTip('Load snapshot')
        self.acceptSnapButton.setToolTip('Apply snapshot')
        self.resetButton.setToolTip('Reset snapshot')
        self.optimiseButton.setToolTip('Optimise filters')
        self.targetBiquadCount.setToolTip(
            'Optimised filter target biquad count')
        self.saveButton.setToolTip('Save')
        self.exitButton.setToolTip('Exit')
        self.limitsButton.setToolTip('Set Graph Limits')

    def __connect_working_buttons(self):
        ''' Connects the buttons associated with the working filter. '''
        self.addWorkingRowButton.clicked.connect(self.__add_working_filter)
        self.removeWorkingRowButton.clicked.connect(
            self.__remove_working_filter)

    def __add_working_filter(self):
        ''' adds a new filter. '''
        new_filter = self.__make_default_filter()
        self.__working.save(new_filter)
        for idx, f in enumerate(self.__working):
            if f.id == new_filter.id:
                self.__selected_id = f.id
                self.workingFilterView.selectRow(idx)

    def __remove_working_filter(self):
        ''' removes the selected filter. '''
        selection = self.workingFilterView.selectionModel()
        if selection.hasSelection():
            self.__working.delete([r.row() for r in selection.selectedRows()])
            if len(self.__working) > 0:
                self.workingFilterView.selectRow(0)

    def __connect_snapshot_buttons(self):
        ''' Connects the buttons associated with the snapshot filter. '''
        self.snapFilterButton.clicked.connect(self.__snap_filter)
        self.resetButton.clicked.connect(self.__clear_snapshot)
        self.acceptSnapButton.clicked.connect(self.__apply_snapshot)
        self.loadSnapButton.clicked.connect(self.__load_filter_as_snapshot)
        self.optimiseButton.clicked.connect(self.__optimise_filter)
        self.addSnapshotRowButton.clicked.connect(self.__add_snapshot_filter)
        self.removeSnapshotRowButton.clicked.connect(
            self.__remove_snapshot_filter)

    def __add_snapshot_filter(self):
        ''' adds a new filter. '''
        new_filter = self.__make_default_filter()
        self.__snapshot.save(new_filter)
        for idx, f in enumerate(self.__snapshot):
            if f.id == new_filter.id:
                self.snapshotFilterView.selectRow(idx)

    def __make_default_filter(self):
        ''' Creates a new filter using the default preferences. '''
        return LowShelf(self.__signal.fs,
                        self.__preferences.get(FILTERS_DEFAULT_FREQ),
                        self.__preferences.get(FILTERS_DEFAULT_Q),
                        0.0,
                        f_id=uuid4())

    def __remove_snapshot_filter(self):
        ''' removes the selected filter. '''
        selection = self.snapshotFilterView.selectionModel()
        if selection.hasSelection():
            self.__snapshot.delete([r.row() for r in selection.selectedRows()])
            if len(self.__snapshot) > 0:
                self.snapshotFilterView.selectRow(0)

    def __snap_filter(self):
        '''
        Captures the current filter as the snapshot.
        '''
        self.__snapshot.filter = self.__working.clone()

    def __clear_snapshot(self):
        ''' Removes the current snapshot. '''
        self.__snapshot.delete(range(len(self.__snapshot)))

    def __apply_snapshot(self):
        ''' Saves the snapshot as the filter. '''
        if len(self.__snapshot) > 0:
            snap = self.__snapshot.filter
            self.__clear_snapshot()
            snap.description = self.__filter_model.filter.description
            self.__filter_model.filter = snap

    def __load_filter_as_snapshot(self):
        ''' Allows a filter to be loaded from a supported file format and set as the snapshot. '''
        result = QMessageBox.question(
            self, 'Load Filter or XML?',
            f"Do you want to load from a filter or a minidsp beq file?"
            f"\n\nClick Yes to load from a filter or No for a beq file",
            QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        load_xml = result == QMessageBox.No
        loaded_snapshot = None
        if load_xml is True:
            from model.minidsp import load_as_filter
            filters, _ = load_as_filter(self, self.__preferences,
                                        self.__signal.fs)
            if filters is not None:
                loaded_snapshot = CompleteFilter(fs=self.__signal.fs,
                                                 filters=filters,
                                                 description='Snapshot')
        else:
            loaded_snapshot = load_filter(self)
        if loaded_snapshot is not None:
            self.__snapshot.filter = loaded_snapshot

    def __optimise_filter(self):
        '''
        Optimises the current filter and stores it as a snapshot.
        '''
        current_filter = self.__working.clone()
        to_save = self.targetBiquadCount.value() - current_filter.biquads
        if to_save < 0:
            optimised_filter = CompleteFilter(fs=current_filter.fs,
                                              filters=optimise_filters(
                                                  current_filter,
                                                  current_filter.fs, -to_save),
                                              description='Optimised')
            self.__snapshot.filter = optimised_filter
        else:
            QMessageBox.information(
                self, 'Optimise Filters',
                f"Current filter uses {current_filter.biquads} biquads so no optimisation required"
            )

    def show_limits(self):
        ''' shows the limits dialog for the filter chart. '''
        self.__magnitude_model.show_limits()

    def show_full_range(self):
        ''' sets the limits to full range. '''
        self.__magnitude_model.show_full_range()

    def show_sub_only(self):
        ''' sets the limits to sub only. '''
        self.__magnitude_model.show_sub_only()

    def __select_filter(self, selected_filter):
        ''' Refreshes the params and display with the selected filter '''
        from model.report import block_signals
        self.__selected_id = selected_filter.id
        # populate the fields with values if we're editing an existing filter
        if hasattr(selected_filter, 'gain'):
            with block_signals(self.filterGain):
                self.filterGain.setValue(selected_filter.gain)
        if hasattr(selected_filter, 'q'):
            with block_signals(self.filterQ):
                self.filterQ.setValue(selected_filter.q)
        if hasattr(selected_filter, 'freq'):
            with block_signals(self.freq):
                self.freq.setValue(selected_filter.freq)
        if hasattr(selected_filter, 'order'):
            with block_signals(self.filterOrder):
                self.filterOrder.setValue(selected_filter.order)
        if hasattr(selected_filter, 'type'):
            displayName = 'Butterworth' if selected_filter.type is FilterType.BUTTERWORTH else 'Linkwitz-Riley'
            with block_signals(self.passFilterType):
                self.passFilterType.setCurrentIndex(
                    self.passFilterType.findText(displayName))
        if hasattr(selected_filter, 'count') and issubclass(
                type(selected_filter), Shelf):
            with block_signals(self.filterCount):
                self.filterCount.setValue(selected_filter.count)
        with block_signals(self.filterType):
            self.filterType.setCurrentText(selected_filter.display_name)
        # configure visible/enabled fields for the current filter type
        self.enableFilterParams()
        with block_signals(self.freq):
            self.freq.setMaximum(self.__signal.fs / 2.0)

    @staticmethod
    def __get_step(steps, value, default_idx):
        for idx, val in enumerate(steps):
            if str(val) == value:
                return idx
        return default_idx

    def __write_to_filter_model(self):
        ''' Stores the filter in the model. '''
        self.__filter_model.filter = self.__working.filter
        self.__signal.filter = self.__filter_model.filter
        self.__redraw_main()

    def accept(self):
        ''' Saves the filter. '''
        self.previewFilter()
        self.__write_to_filter_model()

    def previewFilter(self):
        ''' creates a filter if the params are valid '''
        active_model, active_view = self.__get_active()
        active_model.save(self.__create_filter())
        self.__ensure_filter_is_selected(active_model, active_view)

    def __get_active(self):
        if self.headerLabel.text().startswith('Working') or len(
                self.headerLabel.text()) == 0:
            active_model = self.__working
            active_view = self.workingFilterView
        else:
            active_model = self.__snapshot
            active_view = self.snapshotFilterView
        return active_model, active_view

    def __ensure_filter_is_selected(self, active_model, active_view):
        '''
        Filter model resets the model on every change, this clears the selection so we have to restore that selection
        to ensure the row remains visibly selected while also blocking signals to avoid a pointless update of the
        fields.
        '''
        for idx, f in enumerate(active_model):
            if f.id == self.__selected_id:
                from model.report import block_signals
                with block_signals(active_view):
                    active_view.selectRow(idx)

    def getMagnitudeData(self, reference=None):
        ''' preview of the filter to display on the chart '''
        result = []
        extra = 0
        if len(self.__filter_model) > 0:
            result.append(
                self.__filter_model.getTransferFunction().getMagnitude(
                    colour=get_filter_colour(len(result))))
        else:
            extra += 1
        if len(self.__working) > 0:
            result.append(self.__working.getTransferFunction().getMagnitude(
                colour=get_filter_colour(len(result)), linestyle='-'))
        else:
            extra += 1
        if len(self.__snapshot) > 0:
            result.append(self.__snapshot.getTransferFunction().getMagnitude(
                colour=get_filter_colour(len(result) + extra), linestyle='-.'))
        else:
            extra += 1
        active_model, _ = self.__get_active()
        for f in active_model:
            if self.showIndividual.isChecked() or f.id == self.__selected_id:
                style = '--' if f.id == self.__selected_id else ':'
                result.append(f.getTransferFunction().getMagnitude(
                    colour=get_filter_colour(len(result) + extra),
                    linestyle=style))
        return result

    def create_shaping_filter(self):
        '''
        Creates a filter of the specified type.
        :return: the filter.
        '''
        filt = None
        if self.filterType.currentText() == 'Low Shelf':
            filt = LowShelf(self.__signal.fs, self.freq.value(),
                            self.filterQ.value(), self.filterGain.value(),
                            self.filterCount.value())
        elif self.filterType.currentText() == 'High Shelf':
            filt = HighShelf(self.__signal.fs, self.freq.value(),
                             self.filterQ.value(), self.filterGain.value(),
                             self.filterCount.value())
        elif self.filterType.currentText() == 'PEQ':
            filt = PeakingEQ(self.__signal.fs, self.freq.value(),
                             self.filterQ.value(), self.filterGain.value())
        elif self.filterType.currentText() == 'Gain':
            filt = Gain(self.__signal.fs, self.filterGain.value())
        elif self.filterType.currentText() == 'Variable Q LPF':
            filt = SecondOrder_LowPass(self.__signal.fs, self.freq.value(),
                                       self.filterQ.value())
        elif self.filterType.currentText() == 'Variable Q HPF':
            filt = SecondOrder_HighPass(self.__signal.fs, self.freq.value(),
                                        self.filterQ.value())
        if filt is None:
            raise ValueError(
                f"Unknown filter type {self.filterType.currentText()}")
        else:
            filt.id = self.__selected_id
        return filt

    def __create_filter(self):
        ''' creates a filter from the currently selected parameters. '''
        return self.create_pass_filter() if self.__is_pass_filter(
        ) else self.create_shaping_filter()

    def create_pass_filter(self):
        '''
        Creates a predefined high or low pass filter.
        :return: the filter.
        '''
        if self.filterType.currentText() == 'Low Pass':
            filt = ComplexLowPass(
                FilterType[self.passFilterType.currentText().upper().replace(
                    '-', '_')], self.filterOrder.value(), self.__signal.fs,
                self.freq.value())
        else:
            filt = ComplexHighPass(
                FilterType[self.passFilterType.currentText().upper().replace(
                    '-', '_')], self.filterOrder.value(), self.__signal.fs,
                self.freq.value())
        filt.id = self.__selected_id
        return filt

    def __is_pass_filter(self):
        '''
        :return: true if the current options indicate a predefined high or low pass filter.
        '''
        selected_filter = self.filterType.currentText()
        return selected_filter == 'Low Pass' or selected_filter == 'High Pass'

    def __is_gain_filter(self):
        '''
        :return: true if the current options indicate a predefined high or low pass filter.
        '''
        selected_filter = self.filterType.currentText()
        return selected_filter == 'Gain'

    def enableFilterParams(self):
        '''
        Configures the various input fields for the currently selected filter type.
        '''
        if self.__is_pass_filter():
            self.passFilterType.setVisible(True)
            self.filterOrder.setVisible(True)
            self.orderLabel.setVisible(True)
            self.filterQ.setVisible(False)
            self.filterQLabel.setVisible(False)
            self.qStepButton.setVisible(False)
            self.filterGain.setVisible(False)
            self.gainStepButton.setVisible(False)
            self.gainLabel.setVisible(False)
        else:
            self.passFilterType.setVisible(False)
            self.filterOrder.setVisible(False)
            self.orderLabel.setVisible(False)
            if self.__is_gain_filter():
                self.qStepButton.setVisible(False)
                self.filterQ.setVisible(False)
                self.filterQLabel.setVisible(False)
                self.freq.setVisible(False)
                self.freqStepButton.setVisible(False)
                self.freqLabel.setVisible(False)
            else:
                self.qStepButton.setVisible(True)
                self.filterQ.setVisible(True)
                self.filterQLabel.setVisible(True)
                self.freq.setVisible(True)
                self.freqStepButton.setVisible(True)
                self.freqLabel.setVisible(True)
            self.filterGain.setVisible(self.__is_gain_required())
            self.gainStepButton.setVisible(self.__is_gain_required())
            self.gainLabel.setVisible(self.__is_gain_required())
        is_shelf_filter = self.__is_shelf_filter()
        if is_shelf_filter:
            self.filterQ.setEnabled(self.__q_is_active)
            self.filterS.setEnabled(not self.__q_is_active)
            # set icons
            inactive_icon = qta.icon('fa5s.chevron-circle-left')
            if self.__q_is_active is True:
                self.qStepButton.setText(
                    str(self.q_steps[self.__q_step_idx % len(self.q_steps)]))
                self.sStepButton.setIcon(inactive_icon)
            else:
                self.qStepButton.setIcon(inactive_icon)
                self.sStepButton.setText(
                    str(self.q_steps[self.__s_step_idx % len(self.q_steps)]))
        self.gainStepButton.setText(
            str(self.gain_steps[self.__gain_step_idx % len(self.gain_steps)]))
        self.freqStepButton.setText(
            str(self.freq_steps[self.__freq_step_idx % len(self.freq_steps)]))
        self.filterCountLabel.setVisible(is_shelf_filter)
        self.filterCount.setVisible(is_shelf_filter)
        self.sLabel.setVisible(is_shelf_filter)
        self.filterS.setVisible(is_shelf_filter)
        self.sStepButton.setVisible(is_shelf_filter)

    def changeOrderStep(self):
        '''
        Sets the order step based on the type of high/low pass filter to ensure that LR only allows even orders.
        '''
        if self.passFilterType.currentText() == 'Butterworth':
            self.filterOrder.setSingleStep(1)
            self.filterOrder.setMinimum(1)
        elif self.passFilterType.currentText() == 'Linkwitz-Riley':
            if self.filterOrder.value() % 2 != 0:
                self.filterOrder.setValue(max(2, self.filterOrder.value() - 1))
            self.filterOrder.setSingleStep(2)
            self.filterOrder.setMinimum(2)

    def __is_gain_required(self):
        return self.filterType.currentText() in self.gain_required

    def __is_shelf_filter(self):
        return self.filterType.currentText() in self.is_shelf

    def recalcShelfFromQ(self, q):
        '''
        Updates S based on the selected value of Q.
        :param q: the q.
        '''
        gain = self.filterGain.value()
        if self.__q_is_active is True and not math.isclose(gain, 0.0):
            self.filterS.setValue(q_to_s(q, gain))

    def recalcShelfFromGain(self, gain):
        '''
        Updates S based on the selected gain.
        :param gain: the gain.
        '''
        if not math.isclose(gain, 0.0):
            max_s = round(max_permitted_s(gain), 4)
            self.filterS.setMaximum(max_s)
            if self.__q_is_active is True:
                q = self.filterQ.value()
                self.filterS.setValue(q_to_s(q, gain))
            else:
                if self.filterS.value() > max_s:
                    self.filterS.blockSignals(True)
                    self.filterS.setValue(max_s, 4)
                    self.filterS.blockSignals(False)
                self.filterQ.setValue(s_to_q(self.filterS.value(), gain))

    def recalcShelfFromS(self, s):
        '''
        Updates the shelf based on a change in S
        :param s: the new S
        '''
        gain = self.filterGain.value()
        if self.__q_is_active is False and not math.isclose(gain, 0.0):
            self.filterQ.setValue(s_to_q(s, gain))

    def handleSToolButton(self):
        '''
        Reacts to the S tool button click.
        '''
        if self.__q_is_active is True:
            self.__q_is_active = False
            self.filterS.setEnabled(True)
            self.sStepButton.setIcon(QIcon())
            self.filterQ.setEnabled(False)
            self.qStepButton.setIcon(qta.icon('fa5s.chevron-circle-left'))
        else:
            self.__s_step_idx += 1
        self.__set_s_step(self.q_steps[self.__s_step_idx % len(self.q_steps)])

    def __set_s_step(self, step_val):
        self.__preferences.set(DISPLAY_S_STEP, str(step_val))
        self.sStepButton.setText(str(step_val))
        self.filterS.setSingleStep(step_val)

    def handleQToolButton(self):
        '''
        Reacts to the q tool button click.
        '''
        if self.__q_is_active is True:
            self.__q_step_idx += 1
        else:
            self.__q_is_active = True
            self.filterS.setEnabled(False)
            self.qStepButton.setIcon(QIcon())
            self.filterQ.setEnabled(True)
            self.sStepButton.setIcon(qta.icon('fa5s.chevron-circle-left'))
        self.__set_q_step(self.q_steps[self.__q_step_idx % len(self.q_steps)])

    def __set_q_step(self, step_val):
        self.__preferences.set(DISPLAY_Q_STEP, str(step_val))
        self.qStepButton.setText(str(step_val))
        self.filterQ.setSingleStep(step_val)

    def handleGainToolButton(self):
        '''
        Reacts to the gain tool button click.
        '''
        self.__gain_step_idx += 1
        self.__set_gain_step(self.gain_steps[self.__gain_step_idx %
                                             len(self.gain_steps)])

    def __set_gain_step(self, step_val):
        self.__preferences.set(DISPLAY_GAIN_STEP, str(step_val))
        self.gainStepButton.setText(str(step_val))
        self.filterGain.setSingleStep(step_val)

    def handleFreqToolButton(self):
        '''
        Reacts to the frequency tool button click.
        '''
        self.__freq_step_idx += 1
        self.__set_freq_step(self.freq_steps[self.__freq_step_idx %
                                             len(self.freq_steps)])

    def __set_freq_step(self, step_val):
        self.__preferences.set(DISPLAY_FREQ_STEP, str(step_val))
        self.freqStepButton.setText(str(step_val))
        self.freq.setSingleStep(step_val)