diff --git a/.gitignore b/.gitignore index feae5c1..4ef3e36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ *.pyc -__pycache__ \ No newline at end of file +*__pycache__ \ No newline at end of file diff --git a/blipblop/__pycache__/constants.cpython-38.pyc b/blipblop/__pycache__/constants.cpython-38.pyc deleted file mode 100644 index 3bee8fc..0000000 Binary files a/blipblop/__pycache__/constants.cpython-38.pyc and /dev/null differ diff --git a/blipblop/__pycache__/main.cpython-38.pyc b/blipblop/__pycache__/main.cpython-38.pyc deleted file mode 100644 index 76b2ee1..0000000 Binary files a/blipblop/__pycache__/main.cpython-38.pyc and /dev/null differ diff --git a/blipblop/constants.py b/blipblop/constants.py index 9faa640..d36850e 100644 --- a/blipblop/constants.py +++ b/blipblop/constants.py @@ -1,6 +1,7 @@ import os import glob from PyQt5.QtGui import QIcon +from PyQt5.QtMultimedia import QSound organization = "bendalab" application = "blipblop" @@ -9,14 +10,34 @@ version = 0.1 PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) ICONS_FOLDER = os.path.join(PACKAGE_ROOT, "icons") DOCS_ROOT_FILE = os.path.join(PACKAGE_ROOT, "docs", "index.md") +SNDS_FOLDER = os.path.join(PACKAGE_ROOT, "sounds") ICONS_PATHS = glob.glob(os.path.join(ICONS_FOLDER, "*.png")) ICONS_PATHS.extend(glob.glob(os.path.join(ICONS_FOLDER, "*.icns"))) ICONS_PATHS = sorted(ICONS_PATHS) ICON_DICT = {} + +SNDS_PATHS = glob.glob(os.path.join(SNDS_FOLDER, "*.wav")) +SNDS_PATHS = sorted(SNDS_PATHS) +SNDS_DICT = {} + for icon in ICONS_PATHS: ICON_DICT[icon.split(os.sep)[-1].split(".")[0]] = icon +for snd in SNDS_PATHS: + SNDS_DICT[snd.split(os.sep)[-1].split(".")[0]] = snd + + +def get_sound(name): + if name in SNDS_DICT.keys(): + print(name) + print(SNDS_DICT[name]) + return QSound(SNDS_DICT[name]) + else: + print("Sound %s not found!" % name) + return None + + def get_icon(name): if name in ICON_DICT.keys(): return QIcon(ICON_DICT[name]) diff --git a/blipblop/ui/audioblop.py b/blipblop/ui/audioblop.py index 09cc878..6a9a8f5 100644 --- a/blipblop/ui/audioblop.py +++ b/blipblop/ui/audioblop.py @@ -1,20 +1,241 @@ -from PyQt5.QtWidgets import QComboBox, QFrame, QGroupBox, QHBoxLayout, QLabel, QSplitter, QTextEdit, QVBoxLayout, QWidget -from PyQt5.QtCore import QItemSelectionModel, Qt +from PyQt5.QtWidgets import QAction, QFormLayout, QGridLayout, QLabel, QLineEdit, QSizePolicy, QSlider, QSpinBox, QTextEdit, QWidget +from PyQt5.QtCore import QPoint, QTimer, Qt, pyqtSignal, QSettings +from PyQt5.QtGui import QColor, QFont, QKeySequence, QPainter, QBrush, QPen, QPixmap +from PyQt5.QtMultimedia import QSound +import os import blipblop.constants as cnst +import numpy as np +import datetime as dt + +class SettingsPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent=parent) + + self._trial_spinner = QSpinBox() + self._trial_spinner.setMinimum(5) + self._trial_spinner.setMaximum(25) + self._trial_spinner.setValue(10) + + self._min_delay_edit = QLineEdit() + self._min_delay_edit.setText(str("1000")) + self._min_delay_edit.setToolTip("Minimum delay between start of trial and stimulus display") + self._min_delay_edit.setEnabled(False) + + self._max_delay_edit = QLineEdit() + self._max_delay_edit.setText(str("5000")) + self._max_delay_edit.setToolTip("Maximum delay between start of trial and stimulus display") + self._max_delay_edit.setEnabled(False) + + self._saliency_slider = QSlider(Qt.Horizontal) + self._saliency_slider.setMinimum(0) + self._saliency_slider.setMaximum(100) + self._saliency_slider.setSliderPosition(100) + self._saliency_slider.setTickInterval(25) + self._saliency_slider.setTickPosition(QSlider.TicksBelow) + self._saliency_slider.setToolTip("Saliency of the stimulus, i.e. its opacity") + + self._size_slider = QSlider(Qt.Horizontal) + self._size_slider.setMinimum(0) + self._size_slider.setMaximum(200) + self._size_slider.setSliderPosition(100) + self._size_slider.setTickInterval(25) + self._size_slider.setTickPosition(QSlider.TicksBelow) + self._size_slider.setToolTip("Diameter of the stimulus in pixel") + + self._instructions = QTextEdit() + self._instructions.setMarkdown("* fixate central cross\n * press start (enter) when ready\n * press space bar as soon as the stimulus occurs") + self._instructions.setMinimumHeight(200) + self._instructions.setReadOnly(True) + + form_layout = QFormLayout() + form_layout.addRow("Settings", None) + form_layout.addRow("number of trials", self._trial_spinner) + form_layout.addRow("minimum delay [ms]", self._min_delay_edit) + form_layout.addRow("maximum delay [ms]", self._max_delay_edit) + form_layout.addRow("stimulus saliency", self._saliency_slider) + form_layout.addRow("stimulus size", self._size_slider) + form_layout.addRow("instructions", self._instructions) + self.setLayout(form_layout) + + @property + def trials(self): + return self._trial_spinner.value() + + @property + def saliency(self): + return self._saliency_slider.sliderPosition() + + @property + def size(self): + return self._size_slider.sliderPosition() + + @property + def min_delay(self): + return int(self._min_delay_edit.text()) + + @property + def max_delay(self): + return int(self._max_delay_edit.text()) + + def set_enabled(self, enabled): + self._trial_spinner.setEnabled(enabled) + self._saliency_slider.setEnabled(enabled) class AudioBlop(QWidget): + task_done = pyqtSignal() + task_aborted = pyqtSignal() + def __init__(self, parent=None) -> None: super().__init__(parent=parent) - vbox = QVBoxLayout() - - l = QLabel("Auditory task") - vbox.addWidget(l) + grid = QGridLayout() + grid.setColumnStretch(0, 1) + grid.setColumnStretch(3, 1) + grid.setRowStretch(1, 1) + grid.setRowStretch(3, 1) + self.setLayout(grid) + l = QLabel("Auditory reaction test") + l.setPixmap(QPixmap(os.path.join(cnst.ICONS_FOLDER, "auditory_task.png"))) + grid.addWidget(l, 0, 0, Qt.AlignLeft) + self._status_label = QLabel("Ready to start, press enter ...") + QFont + grid.addWidget(self._status_label, 3, 4, Qt.AlignBaseline) + self._draw_area = QLabel() + self._draw_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + grid.addWidget(self._draw_area, 2, 1) + + self._settings = SettingsPanel() + grid.addWidget(self._settings, 2, 4) + + self.reset_canvas() + self.create_actions() + + self._start_time = None + self._response_time = None + self._reaction_times = [] + self._trial_counter = 0 + self._session_running = False + self._trial_running = False + + self.setFocus() + + def create_actions(self): + self._start_action = QAction("start trial") + self._start_action.setShortcuts([QKeySequence("enter"), QKeySequence("return")]) + self._start_action.triggered.connect(self.on_trial_start) + + self._reaction = QAction("reaction") + self._reaction.setShortcut(QKeySequence("space")) + self._reaction.triggered.connect(self.on_reaction) + + self._abort = QAction("abort") + self._abort.setShortcut(QKeySequence("escape")) + self._abort.triggered.connect(self.on_abort) + + self.addAction(self._start_action) + self.addAction(self._reaction) + self.addAction(self._abort) + + def on_reaction(self): + if not self._session_running or not self._trial_running: + return + + self._response_time = dt.datetime.now() + if self._trial_counter < self._settings.trials: + self._status_label.setText("Trial %i of %i, press enter for next trial" % (self._trial_counter, self._settings.trials)) + if self._start_time is None: + self._reaction_times.append(-1000) + else: + reaction_time = self._response_time - self._start_time + self._reaction_times.append(reaction_time.total_seconds()) + self.reset_canvas() + self._trial_running = False + + def reset_canvas(self): + bkg_color = QColor() + bkg_color.setAlphaF(0.0) + canvas = QPixmap(400, 400) + self._canvas_center = QPoint(200, 200) + canvas.fill(bkg_color) + self.draw_fixation(canvas) + self._draw_area.setPixmap(canvas) + self._draw_area.update() + + def draw_fixation(self, pixmap): + left = QPoint(175, 200) + right = QPoint(225, 200) + top = QPoint(200, 175) + bottom = QPoint(200, 225) + painter = QPainter(pixmap) + painter.setPen(QPen(Qt.red, 2, Qt.SolidLine)) + painter.drawLine(left, right) + painter.drawLine(top, bottom) + painter.end() + self._canvas = QPixmap(400, 400) + self._canvas_center = QPoint(200, 200) + self._draw_area.setPixmap(self._canvas) + + def blip(self): + bells = cnst.get_sound("message") + bells.setLoops(10) + #QSound("mysounds/bells.wav"); + bells.play(); + stim_size = self._settings.size + painter = QPainter(self._draw_area.pixmap()) + painter.setPen(QPen(Qt.red, 1, Qt.SolidLine)) + color = QColor(Qt.red) + color.setAlphaF(self._settings.saliency/100) + painter.setBrush(QBrush(color, Qt.SolidPattern)) + painter.drawEllipse(self._canvas_center, stim_size, stim_size) + painter.end() + self._start_time = dt.datetime.now() + self._draw_area.update() + + def on_trial_start(self): + print("start trial", self._trial_running) + if self._trial_running: + return + print("start trial") + if not self._session_running: + self._settings.set_enabled(False) + self._session_running = True + self._trial_running = True + if self._trial_counter >= self._settings.trials: + self.task_done.emit + return + self._trial_counter += 1 + self._status_label.setText("Trial %i of %i running" % (self._trial_counter, self._settings.trials)) + self.setStatusTip("Test") + min_interval = int(self._settings.min_delay / 100) + max_interval = int(self._settings.max_delay / 100) + interval = np.random.randint(min_interval, max_interval, 1) * 100 + self._start_time = None + timer = QTimer(self) + timer.setSingleShot(True) + timer.setInterval(int(interval)) + timer.timeout.connect(self.blip) + timer.start() + + def on_abort(self): + self.reset() + self.task_aborted.emit() + + @property + def results(self): + return self._reaction_times() + def reset(self): - pass - + self.reset_canvas() + self._trial_counter = 0 + self._session_running = 0 + self._reaction_times = [] + self._trial_running = False + self._session_running = False + self._status_label.setText("Ready to start...") + self._settings.set_enabled(True) + \ No newline at end of file diff --git a/setup.py b/setup.py index bba1e7d..c4713c5 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ install_req = ["PyQt5", "numpy"] data_files = [("icons", glob.glob(os.path.join("icons", "*.png"))), ("icons", glob.glob(os.path.join("icons", "*.ic*"))), + ("sounds", glob.glob(os.path.join("sounds", "*.wav"))), (".", ["LICENSE"]), ("docs", glob.glob(os.path.join("docs", "*.md"))) ] diff --git a/sounds/bell.wav b/sounds/bell.wav new file mode 100644 index 0000000..ebbf7dd Binary files /dev/null and b/sounds/bell.wav differ diff --git a/sounds/complete.wav b/sounds/complete.wav new file mode 100644 index 0000000..9e215a1 Binary files /dev/null and b/sounds/complete.wav differ diff --git a/sounds/message.wav b/sounds/message.wav new file mode 100644 index 0000000..394bc53 Binary files /dev/null and b/sounds/message.wav differ