# Copyright 2025 XMOS LIMITED.
# This Software is subject to the terms of the XMOS Public Licence: Version 1.

from functools import partial

from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
    QGroupBox,
    QSizePolicy,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QSlider,
    QPushButton,
    QLabel,
    QGridLayout,
    QScrollArea,
    QSplitter,
    QComboBox,
    QDoubleSpinBox,
    QDial
)
from PySide6.QtCore import Qt, Signal, QSignalBlocker, QTimer, QLocale
import numpy as np
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from matplotlib import pyplot as plt
from pydantic.fields import FieldInfo
import annotated_types
from audio_dsp.dsp import utils
from copy import deepcopy
import warnings
from audio_dsp.stages.cascaded_biquads import CascadedBiquads
from audio_dsp.design.pipeline import Pipeline
from audio_dsp import stages as dsp_stages
from audio_dsp.stages.cascaded_biquads import CascadedBiquadsParameters
from audio_dsp.models import fields
from audio_dsp.models.stage import StageParameters
from audio_dsp.stages.biquad import BiquadParameters
import typing
from pathlib import Path

from .translations import tr_str


class XWidget:
    """Base class for all widgets. Sets up the connection to the state and
    parameter updates.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    """
    def __init__(self, state, stage_name, stage_parameter):
        self.state = state
        self.state.STATE_CHANGED.connect(self.update_widget)

        self.stage_name = stage_name
        self.parameter = stage_parameter
        self.stage_index = self.state.node_dict[stage_name]
        self.op_type = self.state.state.graph.nodes[self.stage_index].op_type

    def update_widget(self, state):
        """
        Update the widget's displayed value to match the current parameter value in the state.
        This method should be overridden in subclasses to provide specific update behavior.
        QSignalBlocker should be used to prevent recursive calls to update_widget.

        Parameters
        ----------
        state : object
            The current application state, used to retrieve the latest parameter value.
        """
        warnings.warn("update_widget not defined, widget value will not update when state changes")

    def get_parameters(self):
        """Get all the parameters for the stage."""
        return self.state.state.graph.nodes[self.stage_index].parameters

    def get_parameter(self):
        """Get the widget parameter for the stage. This is a single value."""
        return getattr(self.state.state.graph.nodes[self.stage_index].parameters, self.parameter)

    def set_parameter(self, value):
        """Set the widget parameter value for the stage."""
        self.state.deep_update(self.stage_index, self.parameter, value)


class XFiniteWidget(XWidget):
    """Base class for widgets that have a finite range of values (e.g. sliders,
    dials). This class handles the conversion between the widget value and the
    state value.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    widget_range : tuple or list, optional
        The minimum and maximum values for the widget. If not provided,
        inferred from the parameter's field constraints.
    scale : float, optional
        The scaling factor between the widget value and the parameter value.
        If not provided, inferred from the parameter's type and range.
    """
    def __init__(self, state, stage_name, stage_parameter, widget_range=None, scale=None):
        super().__init__(state, stage_name, stage_parameter)

        try:
            field_spec = self.state.state.graph.nodes[self.stage_index].parameters.model_fields[self.parameter]
            if widget_range:
                self.fmin = min(widget_range)
                self.fmax = max(widget_range)
            else:
                self.fmin, self.fmax = _get_field_range(field_spec)

            # set the number of decimal places to use for the widget based on the range
            if scale:
                self.scale = scale
            elif self.state.state.graph.nodes[self.stage_index].parameters.model_fields[self.parameter].annotation is int:
                self.scale = 1
            elif self.fmax - self.fmin > 1000:
                self.scale = 1
            elif self.fmax - self.fmin > 1:
                self.scale = 0.1
            elif self.fmax - self.fmin <= 1:
                self.scale = 0.01

            # set limits inside valid values
            self.widget_max = np.floor(self.fmax / self.scale)
            self.widget_min = np.ceil(self.fmin / self.scale)

        except ValueError as e:
            raise ValueError(f"Field {self.parameter} does not have a range "
            "constraint. To use on a slider or dial, set manually") from e

    def widget_to_value(self, widget_value):
        return widget_value * self.scale

    def value_to_widget(self, value):
        return value / self.scale


class XSlider(XFiniteWidget, QWidget):
    """
    A vertical slider widget for adjusting a finite-range parameter.
    Synchronizes the slider's value with a parameter in the application state.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    stage_parameter : str
        The name of the parameter in the stage to control.
    label : str, optional
        The label to display above the slider. Defaults to the stage name.
    widget_range : tuple or list, optional
        The minimum and maximum values for the slider. If not provided, inferred from the parameter's field constraints.
    scale : float, optional
        The scaling factor between the widget value and the parameter value. If not provided, inferred from the parameter's type and range.

    Attributes
    ----------
    slider : QSlider
        The dial widget for adjusting the parameter value. See the 
        `QSlider <https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QSlider.html>`_
        documentation for more details.

    """
    def __init__(self, state, stage_name, stage_parameter, label=None, widget_range=None, scale=None):
        XFiniteWidget.__init__(self, state, stage_name, stage_parameter, widget_range, scale)
        QWidget.__init__(self)

        self.grid = QGridLayout()
        self.parameter = stage_parameter
        self.slider = QSlider(Qt.Vertical)
        self.slider.setRange(self.widget_min, self.widget_max)
        self.update_widget(self.state)
        self.slider.valueChanged.connect(lambda value: self.set_parameter(self.widget_to_value(value)))

        if not label:
            label = tr_str(stage_name).capitalize()
        self.grid.addWidget(QLabel(label, alignment=Qt.AlignHCenter), 0, 0, Qt.AlignHCenter)
        self.grid.addWidget(self.slider, 1, 0, Qt.AlignHCenter)

        self.setLayout(self.grid)

    def update_widget(self, state):
        """
        Update the widget's displayed value to match the current parameter value in the state.
        """
        value = self.get_parameter()
        # don't emit the change signal back to state
        with QSignalBlocker(self.slider):
            self.slider.setValue(self.value_to_widget(value))


class XButton(XWidget, QPushButton):
    """
    A toggle button widget for boolean or integer parameters.
    Reflects the parameter's value as checked/unchecked and updates the parameter in the state when clicked.

    This inherits from the QPushButton class. See the 
    `QPushButton <https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QPushButton.html>`_
    documentation for more details.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    stage_parameter : str
        The name of the parameter in the stage to control.
    label : str, optional
        The text to display on the button. Defaults to the capitalized parameter name.
    """
    def __init__(self, state, stage_name, stage_parameter, label=None):
        if not label:
            label = tr_str(stage_parameter).capitalize()
        XWidget.__init__(self, state, stage_name, stage_parameter)
        QPushButton.__init__(self, label)

        self.setCheckable(True)
        self.update_widget(self.state)
        self.clicked.connect(lambda state: self.set_parameter(int(state)))

    def update_widget(self, state):
        """
        Update the widget's displayed value to match the current parameter value in the state.
        """
        # don't emit the change signal back to state
        with QSignalBlocker(self):
            self.setChecked(int(self.get_parameter()))


class XComboBox(XWidget, QComboBox):
    """
    A dropdown box widget for selecting a finite set of options.

    This inherits from the QComboBox class. See the 
    `QComboBox <https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QComboBox.html>`_
    documentation for more details.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    stage_parameter : str
        The name of the parameter in the stage to control.
    label : str, optional
        The text to display on the button. Defaults to the capitalized parameter name.
    """
    def __init__(self, state, stage_name, stage_parameter, label=None):
        if not label:
            label = tr_str(stage_parameter).capitalize()
        XWidget.__init__(self, state, stage_name, stage_parameter)
        QComboBox.__init__(self)

        self.options = typing.get_args(self.get_parameters().model_fields[stage_parameter].annotation)
        self.tr_options = [tr_str(opt) for opt in self.options]

        self.addItems(self.tr_options)
        self.setEditable(False)
        self.update_widget(self.state)
        self.currentIndexChanged.connect(lambda state: self.set_parameter(self.widget_to_value(state)))
        self.wheelEvent = lambda event: event.ignore()

    def update_widget(self, state):
        """
        Update the widget's displayed value to match the current parameter value in the state.
        """
        # don't emit the change signal back to state
        with QSignalBlocker(self):
            self.setCurrentText(tr_str(self.get_parameter()))

    def widget_to_value(self, widget_value):
        return self.options[widget_value]


class XDial(XFiniteWidget, QWidget):
    """
    A rotary dial widget for adjusting a finite-range parameter.
    Synchronizes the dial's value with a parameter in the application state and displays the current value as a label.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    stage_parameter : str
        The name of the parameter in the stage to control.
    label : str, optional
        The label to display below the dial. Defaults to None.
    widget_range : tuple or list, optional
        The minimum and maximum values for the dial. If not provided, inferred from the parameter's field constraints.
    scale : float, optional
        The scaling factor between the widget value and the parameter value. If not provided, inferred from the parameter's type and range.

    Attributes
    ----------
    dial : QDial
        The dial widget for adjusting the parameter value. See the 
        `QDial <https://doc.qt.io/qtforpython-6/PySide6/QtWidgets/QDial.html>`_
        documentation for more details.

    """
    def __init__(self, state, stage_name, stage_parameter, label=None, widget_range=None, scale=None):
        XFiniteWidget.__init__(self, state, stage_name, stage_parameter, widget_range, scale)
        QWidget.__init__(self)

        self.grid = QVBoxLayout()

        self.dial = QDial()
        self.dial.setRange(self.widget_min, self.widget_max)
        self.dial.valueChanged.connect(lambda value: self.set_parameter(self.widget_to_value(value)))

        self.label = QLabel(alignment=Qt.AlignHCenter)
        self.update_widget(self.state)

        if label:
            self.grid.addWidget(QLabel(label, alignment=Qt.AlignHCenter), alignment=Qt.AlignHCenter | Qt.AlignBottom)

        self.grid.addWidget(self.dial, Qt.AlignHCenter)
        self.grid.addWidget(self.label, Qt.AlignHCenter)

        self.setLayout(self.grid)
        self.dial.wheelEvent = lambda event: event.ignore()

    def update_widget(self, state):
        """
        Update the widget's displayed value to match the current parameter value in the state.
        """
        value = self.get_parameter()
        # don't emit the change signal back to state
        with QSignalBlocker(self.dial):
            self.dial.setValue(self.value_to_widget(value))

        # update label to correct number of decimal places
        decimals = max(0, int(-np.log10(self.scale))) if self.scale < 1 else 0
        value_fmt = f"{value:.{decimals}f}"
        self.label.setText(f"{tr_str(self.parameter)}: {value_fmt}")


class StageParameterGroupbox(QGroupBox):
    """
    A group box containing all the runtime controllable parameters for a
    DSP stage. :class:`BiquadWidget`, :class:`XComboBox`, and :class:`XDial` widgets are used for
    the controls

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    label : str, optional
        The label to display below the groupbox. Defaults to stage_name.

    """
    def __init__(self, state, stage_name, label=None):
        self.state = state
        self.stage_name = stage_name
        self.stage_index = self.state.node_dict[stage_name]
        if label is None:
            label = tr_str(self.stage_name).capitalize()
        super().__init__(label)

        grid = QGridLayout()

        if self.state.state.graph.nodes[self.stage_index].op_type in ["Biquad", "BiquadSlew"]:
            bw = BiquadWidget(self.state, stage_name)
            grid.addWidget(bw, 0, 0, Qt.AlignCenter)

        else:
            fields = self.state.state.graph.nodes[self.stage_index].parameters.model_fields

            for i, (field, field_spec) in enumerate(fields.items()):
                if typing.get_origin(field_spec.annotation) == typing.Literal:
                    this_widget = XComboBox(self.state, stage_name, field)
                else:
                    this_widget = XDial(self.state, stage_name, field)
                n_cols = 5
                i_row = (i // n_cols)
                i_col = i % n_cols
                grid.addWidget(this_widget, i_row, i_col, Qt.AlignCenter)
        self.setLayout(grid)


def _get_field_range(f: FieldInfo):
    meta = f.metadata
    min = [g.ge for g in meta if isinstance(g, annotated_types.Ge)] or [
        g.gt for g in meta if isinstance(g, annotated_types.Gt)
    ]
    max = [g.le for g in meta if isinstance(g, annotated_types.Le)] or [
        g.lt for g in meta if isinstance(g, annotated_types.Lt)
    ]
    try:
        return min[0], max[0]
    except IndexError as e:
        raise ValueError(f"Field {f} must have a range constraint") from e


class bq_param_box(QDoubleSpinBox):
    """
    A double spin box that sends a signal upwards when the value is changed.
    """
    on_changed = Signal(object)

    def __init__(self, param_name, param_props):
        super().__init__()

        self.param_name = param_name

        if "maximum" in param_props:
            self.setMaximum(param_props["maximum"])
        elif "exclusiveMaximum" in param_props:
            self.setMaximum(param_props["exclusiveMaximum"])

        if "minimum" in param_props:
            self.setMinimum(param_props["minimum"])
        elif "exclusiveMinimum" in param_props:
            self.setMinimum(param_props["exclusiveMinimum"])

        # Set step size based on range
        rng = self.maximum() - self.minimum()
        if rng > 1000:
            self.scale = 1
        elif rng > 1:
            self.scale = 0.1
        else:
            self.scale = 0.01

        self.setSingleStep(self.scale)

        self.valueChanged.connect(self.on_changed.emit)

    def wheelEvent(self, event):
        # don't spin with scroll wheel
        event.ignore()


class biquad_group(QWidget):
    """
    A widget consisting of a drop down box of biquad types, that updates
    the available control parameters when changed. Emits a signal upwards of a BIQUAD_TYPES
    parameter when changed.
    """
    on_changed = Signal(StageParameters)

    def __init__(self, filt: StageParameters, horizontal=False):
        super().__init__()
        self.combobox = QComboBox()

        self.bq_params = filt.model_dump()

        # make a group box to hold the parameter widgets
        if horizontal:
            lay = QHBoxLayout()
        else:
            lay = QVBoxLayout()
        self.items_group = QGroupBox()
        self.items_group.setLayout(lay)

        # make a layout for the combobox and group box
        if horizontal:
            layout = QHBoxLayout(self)
        else:
            layout = QVBoxLayout(self)

        layout.addWidget(self.combobox)
        layout.addWidget(self.items_group)

        bq_types = BiquadParameters.model_json_schema()["$defs"]
        keys = []
        self.bq_types = []

        # for each biquad type, make a widget of labelled scrollboxes
        for bq_type_key, bq_type_props in bq_types.items():
            self.bq_types.append(bq_type_key[7:])  # remove 'biquad_' prefix
            keys.append(tr_str(bq_type_key))

            group = QWidget()
            layout = QGridLayout()
            group.setLayout(layout)

            # for each parameter, make a name and scrollbox in a table row
            for param_name, param_props in bq_type_props["properties"].items():

                if param_name =="type":
                    continue

                _lbl = QLabel(tr_str(param_name), parent=group)

                # make a scrollbox for each parameter
                widget = bq_param_box(param_name, param_props)
                widget.setValue(param_props["default"])
                widget.on_changed.connect(partial(self.widget_on_changed, param_name))

                # add to table
                if horizontal:
                    _col_index = layout.columnCount()
                    layout.addWidget(_lbl, 0, _col_index)
                    layout.addWidget(widget, 1, _col_index)
                else:
                    _row_index = layout.rowCount()
                    layout.addWidget(_lbl, _row_index, 0)
                    layout.addWidget(widget, _row_index, 1)

            # hide all parameters, reveal using self.select_type
            group.setVisible(False)
            self.items_group.layout().addWidget(group)

        self.combobox.addItems(keys)
        self.combobox.setEditable(False)
        self.combobox.currentIndexChanged.connect(self.select_type)
        self.combobox.setCurrentText(tr_str(f"biquad_{self.bq_params['type']}"))
        self.combobox.wheelEvent = lambda event: event.ignore()

    def select_type(self, idx: int, emit=True):
        """Select the biquad type from the combobox and show the parameters for that type."""
        # for each biquad type, hide it unless it matches the selected index
        for i in range(self.items_group.layout().count()):
            widget = self.items_group.layout().itemAt(i).widget()
            widget.setVisible(i == idx)
            if i == idx:
                # if selected index, update parameter values from state
                for x in widget.children():
                    if type(x) is bq_param_box and x.param_name in self.bq_params:
                        with QSignalBlocker(x):
                            x.setValue(self.bq_params[x.param_name])

        # set the type in the state dict to match the selected combobox item
        # and emit the parameters
        self.bq_params["type"] = self.bq_types[self.combobox.currentIndex()]
        if emit:
            self.widget_on_changed(None, None)

    def widget_on_changed(self, name: str, value):
        """Emit the parameters to the parent widget."""
        if name and value:
            self.bq_params[name] = value

        # get handle for this biquad type
        param_handle = getattr(fields, "biquad_" + self.bq_params["type"])

        # only pass valid dict keys through to the biquad function
        valid_keys = param_handle.model_fields.keys()
        tmp_dict = self.bq_params.copy()
        for field in list(tmp_dict.keys()):
            if field not in valid_keys:
                tmp_dict.pop(field)

        # pass parameter object upwards
        tmp_params = param_handle(**tmp_dict)
        self.on_changed.emit(tmp_params)

    def update_widget(self, parameters: StageParameters):
        new_params = parameters.model_dump()
        if new_params == self.bq_params:
            return
        else:
            self.bq_params = new_params
        new_type = f"biquad_{self.bq_params['type']}"
        new_index = self.bq_types.index(self.bq_params['type'])

        if self.combobox.currentIndex() != new_index:
            # if the type has changed, set the combobox to the new type
            with QSignalBlocker(self.combobox):
                self.combobox.setCurrentText(tr_str(new_type))
            self.select_type(new_index, emit=False)

        else:
            # else, just update the parameters
            self.select_type(self.combobox.currentIndex(), emit=False)


class BiquadWidget(XWidget, QWidget):
    """A widget for a biquad filter stage. This widget contains a combobox for
    selecting the filter type, and a set of scrollboxes for the parameters.
    When the filter type is changed, the parameters are updated to match
    the selected filter type.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    """
    def __init__(self, state, stage_name):
        XWidget.__init__(self, state, stage_name, stage_parameter="filter_type")
        QWidget.__init__(self)

        filt = self.get_parameter()
        self.this_bq = biquad_group(filt, horizontal=True)
        self.this_bq.on_changed.connect(lambda state: self.set_parameter(state))

        layout = QVBoxLayout(self)
        layout.addWidget(self.this_bq)

    def update_widget(self, state):
        self.this_bq.update_widget(self.get_parameter())


class MplCanvas(FigureCanvasQTAgg):
    """A canvas for plotting the frequency response of stages."""
    def __init__(self, name, plot_phase=True, db_lim=[-20, 20]):

        self.plot_phase = plot_phase
        if self.plot_phase:
            self.fig, self.axes = plt.subplots(2, 1, sharex=True, layout="constrained")
        else:
            self.fig, self.axes = plt.subplots(1, 1, layout="constrained")
            self.axes = [self.axes]
        self.is_drawn = False
        self.mag_line = None
        self.phase_line = None
        self._background = None
        self.fig.suptitle((f"{name} "+ tr_str("frequency response")).title())
        self.db_lim = db_lim

        # log sample indexes for the frequency response
        self.nfft = 1024*16
        self.idx = np.unique(np.geomspace(1, self.nfft, 500, dtype=int)) - 1

        super().__init__(self.fig)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.mpl_connect("resize_event", self._on_resize)
        self.mpl_connect("draw_event", self._on_draw)

    def _on_resize(self, event):
        # background is no longer valid
        self._background = None

    def _on_draw(self, event):
        if self._background is None:
            # if background is None, we are drawing for the first time
            # after a resize
            self._background = self.copy_from_bbox(self.fig.bbox)
            # Call update_plot after 50 ms to allow resize to finish
            QTimer.singleShot(50, self.update_plot)

    def update_plot(self, f=None, h=None):

        if f is not None and h is not None:
            self.f = f[self.idx]
            self.h_db = utils.db(h[self.idx])
            self.h_angle = np.angle(h[self.idx])

        ticks = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
        ticks_labels = [20, 50, 100, 200, 500, 1000, 2000, "5k", "10k", "20k"]

        if not hasattr(self, "mag_line") or self.mag_line is None:
            self.axes[0].cla()
            self.mag_line = self.axes[0].semilogx(self.f, self.h_db, animated=True)
            self.axes[0].set_yticks(np.arange(-24, 25, 6))
            self.axes[0].set_ylim(self.db_lim)
            self.axes[0].set_xlim([20, 20000])
            self.axes[0].set_xticks(ticks)
            self.axes[0].set_ylabel(tr_str("Magnitude (dB)"))
            self.axes[0].grid()
            if self.plot_phase:
                self.phase_line = self.axes[1].semilogx(self.f, self.h_angle, animated=True)
                self.axes[1].cla()
                self.axes[1].set_ylim([-3.2, 3.2])
                self.axes[1].set_yticks([-np.pi, -np.pi / 2, 0, np.pi / 2, np.pi])
                self.axes[1].set_yticklabels(["-π", "", "0", "", "π"])
                self.axes[1].set_ylabel(tr_str("Phase (rad)"))
                self.axes[1].grid()

            self.axes[-1].set_xticks(ticks)
            self.axes[-1].set_xticklabels(ticks_labels)
            self.axes[-1].set_xlabel(tr_str("Frequency (Hz)"))

            self.draw()
            self._background = self.copy_from_bbox(self.fig.bbox)

        else:
            if self._background is None:
                # don't draw, wait for background to reappear
                return

            # update the lines and blitt
            self.restore_region(self._background)
            self.mag_line[0].set_ydata(self.h_db)
            self.axes[0].draw_artist(self.mag_line[0])
            if self.plot_phase:
                self.phase_line[0].set_ydata(self.h_angle)
                self.axes[1].draw_artist(self.phase_line[0])
            self.blit(self.fig.bbox)


class PeqTab(XWidget, QWidget):
    """A widget for the PEQ stage. This widget contains a combobox for
    selecting the filter type, and a set of scrollboxes for the parameters, and
    a plot of the frequency response.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    """
    def __init__(self, state, stage_name):
        XWidget.__init__(self, state, stage_name, stage_parameter="filters")
        QWidget.__init__(self)

        self.params = deepcopy(self.get_parameter())

        self.op_handle = getattr(dsp_stages, self.op_type)
        self.param_handle = type(self.get_parameters())

        # get a cascaded biquad stage instance
        tmp_pipeline, i = Pipeline.begin(1, state.state.graph.fs)
        tmp_pipeline.stage(self.op_handle, i)
        self.peq_stage = tmp_pipeline.stages[2]

        # make a canvas to plot the frequency response
        self.canvas = MplCanvas(self.stage_name)
        self.update_graph()

        # make the list of biquad filters
        combo = self.peq_biquad_panel()

        # Horizontal layout, QSplitter can be adusted with the mouse
        layout = QVBoxLayout()
        horizontal_layout = QSplitter()
        horizontal_layout.addWidget(combo)
        horizontal_layout.addWidget(self.canvas)
        layout.addWidget(horizontal_layout)
        self.setLayout(layout)

    def update_graph(self):
        # update the pipeline model to get the frequency response
        self.peq_stage.set_parameters(self.param_handle(filters=self.params))
        f, h = self.peq_stage.get_frequency_response(self.canvas.nfft)
        self.canvas.update_plot(f, h)

    def _update_peq_i(self, i, value):
        self.params[i] = value
        self.set_parameter(self.params)
        # update_widget will handle updating the graph

    def peq_biquad_panel(self):
        # make a panel containing N sets of biquad parameter widgets
        container = QWidget()

        widget = QScrollArea()
        widget.setWidget(container)
        widget.setWidgetResizable(True)

        layout = QVBoxLayout(container)

        self.peq = []
        for i, filt in enumerate(self.params):
            # pass each biquad filter in state to make a new widget, using
            # the state parameters to set the values.
            this_bq = biquad_group(filt)
            this_bq.on_changed.connect(partial(self._update_peq_i, i))

            self.peq.append(this_bq)
            layout.addWidget(self.peq[-1])

        # set width to avoid horizontal scroll
        min_width = widget.sizeHint().width() + 2 * widget.frameWidth() + widget.verticalScrollBar().sizeHint().width()
        widget.setMinimumWidth(max(min_width, 221))

        return widget

    def update_widget(self, state):
        # go update the widgets and graphs
        self.params = deepcopy(self.get_parameter())
        for p, bq in zip(self.params, self.peq):
            bq.update_widget(p)
        self.update_graph()


class Geq10bTab(XWidget, QWidget):
    """A widget for the GEQ stage. This widget contains sliders for the
    gains in each frequency band, and a plot of the frequency response.

    Parameters
    ----------
    state : object
        The application state object, which must provide STATE_CHANGED signal and state access.
    stage_name : str
        The name of the stage in the state graph to which this widget is bound.
    """
    def __init__(self, state, stage_name):
        XWidget.__init__(self, state, stage_name, stage_parameter="gains_db")
        QWidget.__init__(self)

        self.params = deepcopy(self.get_parameter())

        self.op_handle = getattr(dsp_stages, "GraphicEq10b")
        self.param_handle = type(self.get_parameters())

        # get a cascaded biquad stage instance
        tmp_pipeline, i = Pipeline.begin(1, state.state.graph.fs)
        tmp_pipeline.stage(self.op_handle, i)
        self.geq_stage = tmp_pipeline.stages[2]

        # make a canvas to plot the frequency response
        self.canvas = MplCanvas(self.stage_name, plot_phase=False, db_lim=[-26, 2])
        self.update_graph()

        # slider values must be ints
        self.range = [-12, 12]
        self.scale = 10

        combo = self.geq_slider_panel()

        # Vertical layout
        layout = QVBoxLayout()
        layout.addLayout(combo)
        layout.addWidget(self.canvas)
        self.setLayout(layout)

    def update_graph(self):
        # update the pipeline model to get the frequency response
        self.geq_stage.set_parameters(self.param_handle(gains_db=self.params))
        f, h = self.geq_stage.get_frequency_response(self.canvas.nfft)
        self.canvas.update_plot(f, h)

    def _update_geq_i(self, i, value):
        self.params[i] = value/self.scale
        self.set_parameter(self.params)
        # update_widget will handle updating the graph

    def geq_slider_panel(self):
        # Graphical EQ with sliders
        self.sliders = []
        eq_layout = QHBoxLayout()
        eq_layout.setSpacing(0)
        eq_layout.setContentsMargins(50, 0, 0, 0)
        eq_layout.addStretch(1)

        for i, f in enumerate(self.geq_stage.dsp_block.cfs):
            slider_layout = QHBoxLayout()
            slider_layout.setSpacing(0)
            slider_layout.setContentsMargins(5, 0, 0, 0)

            slider = QSlider(Qt.Vertical)
            slider.setRange(self.range[0]*self.scale, self.range[1]*self.scale)
            slider.setValue(0)
            slider.setMinimumHeight(100)
            slider.valueChanged.connect(partial(self._update_geq_i, i))
            slider_layout.addWidget(slider, stretch=1)
            self.sliders.append(slider)

            label = QLabel(str(int(f)))
            # Remove fixed height to allow label to scale with layout
            label.setAlignment(Qt.AlignVCenter)
            slider_layout.addWidget(label)

            slider_widget = QWidget()
            slider_widget.setLayout(slider_layout)
            eq_layout.addWidget(slider_widget)  # Each slider+label gets equal stretch

        eq_layout.addStretch(1)

        return eq_layout

    def update_widget(self, state):
        # go update the widgets and graphs
        self.params = deepcopy(self.get_parameter())
        for i, s in enumerate(self.sliders):
            with QSignalBlocker(s):
                s.setValue(self.params[i]*self.scale)
        self.update_graph()


class XMOSLogo(QIcon):
    """An icon containing the XMOS Logo"""
    def __init__(self):
        super().__init__(
            str(Path(__file__).parent
                / "assets"
                / "xmos_logo_small-103x30.png"
            )
        )

