From cda8197669409689be291660f93cb288ab2d31b3 Mon Sep 17 00:00:00 2001 From: Eddy Pedroni Date: Sat, 9 Nov 2024 20:35:56 +0100 Subject: Migrate to project-based structure --- .gitignore | 4 +- MainWindow.py | 111 ---- Makefile | 21 + abcontroller.py | 82 --- abcontroller_unittest.py | 272 ---------- bootstrap-venv.sh | 5 - cli-project/pyproject.toml | 24 + cli-project/src/solo_tool_cli.py | 57 ++ cli-project/test/solo_tool_cli_integrationtest.py | 41 ++ cli-project/test/test.flac | Bin 0 -> 31743252 bytes cli-project/test/test.mp3 | Bin 0 -> 5389533 bytes cli-project/test/test_session.json | 13 + diagram.drawio | 1 - doc/diagram.drawio | 1 + doc/known-issues.md | 62 +++ gui-project/pyproject.toml | 24 + gui-project/src/MainWindow.py | 111 ++++ gui-project/src/mainwindow.ui | 161 ++++++ gui-project/src/solo_tool_gui.py | 261 +++++++++ known-issues.md | 62 --- mainwindow.ui | 161 ------ midi_controller_launchpad_mini.py | 145 ----- midi_launchpad_mini_integrationtest.py | 387 -------------- midi_wrapper_mido.py | 21 - notifier.py | 30 -- notifier_unittest.py | 100 ---- player_mock.py | 71 --- player_vlc.py | 55 -- playlist.py | 41 -- playlist_unittest.py | 148 ----- pre-commit | 4 + requirements.txt | 11 +- session_manager.py | 41 -- session_manager_unittest.py | 163 ------ solo-tool-project/pyproject.toml | 23 + solo-tool-project/src/solo_tool/__init__.py | 1 + solo-tool-project/src/solo_tool/abcontroller.py | 82 +++ .../solo_tool/midi_controller_launchpad_mini.py | 145 +++++ .../src/solo_tool/midi_wrapper_mido.py | 21 + solo-tool-project/src/solo_tool/notifier.py | 29 + solo-tool-project/src/solo_tool/player_vlc.py | 55 ++ solo-tool-project/src/solo_tool/playlist.py | 40 ++ solo-tool-project/src/solo_tool/session_manager.py | 41 ++ solo-tool-project/src/solo_tool/solo_tool.py | 164 ++++++ solo-tool-project/test/abcontroller_unittest.py | 272 ++++++++++ .../test/midi_launchpad_mini_integrationtest.py | 387 ++++++++++++++ solo-tool-project/test/notifier_unittest.py | 100 ++++ solo-tool-project/test/player_mock.py | 71 +++ solo-tool-project/test/playlist_unittest.py | 148 +++++ solo-tool-project/test/session_manager_unittest.py | 163 ++++++ .../test/solo_tool_integrationtest.py | 594 +++++++++++++++++++++ solo-tool-project/test/test.flac | Bin 0 -> 31743252 bytes solo-tool-project/test/test.mp3 | Bin 0 -> 5389533 bytes solo-tool-project/test/test_session.json | 13 + solo_tool.py | 164 ------ solo_tool_cli.py | 56 -- solo_tool_cli_integrationtest.py | 75 --- solo_tool_integrationtest.py | 594 --------------------- solo_tool_qt.py | 258 --------- test.flac | Bin 31743252 -> 0 bytes test.mp3 | Bin 5389533 -> 0 bytes test_session.json | 13 - 62 files changed, 3135 insertions(+), 3065 deletions(-) delete mode 100644 MainWindow.py create mode 100644 Makefile delete mode 100644 abcontroller.py delete mode 100644 abcontroller_unittest.py delete mode 100755 bootstrap-venv.sh create mode 100644 cli-project/pyproject.toml create mode 100644 cli-project/src/solo_tool_cli.py create mode 100644 cli-project/test/solo_tool_cli_integrationtest.py create mode 100644 cli-project/test/test.flac create mode 100644 cli-project/test/test.mp3 create mode 100644 cli-project/test/test_session.json delete mode 100644 diagram.drawio create mode 100644 doc/diagram.drawio create mode 100644 doc/known-issues.md create mode 100644 gui-project/pyproject.toml create mode 100644 gui-project/src/MainWindow.py create mode 100644 gui-project/src/mainwindow.ui create mode 100644 gui-project/src/solo_tool_gui.py delete mode 100644 known-issues.md delete mode 100644 mainwindow.ui delete mode 100644 midi_controller_launchpad_mini.py delete mode 100644 midi_launchpad_mini_integrationtest.py delete mode 100644 midi_wrapper_mido.py delete mode 100644 notifier.py delete mode 100644 notifier_unittest.py delete mode 100644 player_mock.py delete mode 100644 player_vlc.py delete mode 100644 playlist.py delete mode 100644 playlist_unittest.py create mode 100644 pre-commit delete mode 100644 session_manager.py delete mode 100644 session_manager_unittest.py create mode 100644 solo-tool-project/pyproject.toml create mode 100644 solo-tool-project/src/solo_tool/__init__.py create mode 100644 solo-tool-project/src/solo_tool/abcontroller.py create mode 100644 solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py create mode 100644 solo-tool-project/src/solo_tool/midi_wrapper_mido.py create mode 100644 solo-tool-project/src/solo_tool/notifier.py create mode 100644 solo-tool-project/src/solo_tool/player_vlc.py create mode 100644 solo-tool-project/src/solo_tool/playlist.py create mode 100644 solo-tool-project/src/solo_tool/session_manager.py create mode 100644 solo-tool-project/src/solo_tool/solo_tool.py create mode 100644 solo-tool-project/test/abcontroller_unittest.py create mode 100644 solo-tool-project/test/midi_launchpad_mini_integrationtest.py create mode 100644 solo-tool-project/test/notifier_unittest.py create mode 100644 solo-tool-project/test/player_mock.py create mode 100644 solo-tool-project/test/playlist_unittest.py create mode 100644 solo-tool-project/test/session_manager_unittest.py create mode 100644 solo-tool-project/test/solo_tool_integrationtest.py create mode 100644 solo-tool-project/test/test.flac create mode 100644 solo-tool-project/test/test.mp3 create mode 100644 solo-tool-project/test/test_session.json delete mode 100644 solo_tool.py delete mode 100644 solo_tool_cli.py delete mode 100644 solo_tool_cli_integrationtest.py delete mode 100644 solo_tool_integrationtest.py delete mode 100644 solo_tool_qt.py delete mode 100644 test.flac delete mode 100644 test.mp3 delete mode 100644 test_session.json diff --git a/.gitignore b/.gitignore index 92afa22..cbe6874 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -__pycache__/ +**/__pycache__/ venv/ +**/*.egg-info +**/build diff --git a/MainWindow.py b/MainWindow.py deleted file mode 100644 index 137bd33..0000000 --- a/MainWindow.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'mainwindow.ui' -# -# Created by: PyQt5 UI code generator 5.15.6 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_MainWindow(object): - def setupUi(self, MainWindow): - MainWindow.setObjectName("MainWindow") - MainWindow.resize(971, 767) - self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) - self.verticalLayout.setObjectName("verticalLayout") - self.listsLayout = QtWidgets.QHBoxLayout() - self.listsLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.listsLayout.setSpacing(6) - self.listsLayout.setObjectName("listsLayout") - self.songListView = QtWidgets.QListView(self.centralwidget) - self.songListView.setObjectName("songListView") - self.listsLayout.addWidget(self.songListView) - self.abListView = QtWidgets.QListView(self.centralwidget) - self.abListView.setObjectName("abListView") - self.listsLayout.addWidget(self.abListView) - self.verticalLayout.addLayout(self.listsLayout) - self.slidersLayout = QtWidgets.QVBoxLayout() - self.slidersLayout.setObjectName("slidersLayout") - self.songSlider = QtWidgets.QSlider(self.centralwidget) - self.songSlider.setMinimumSize(QtCore.QSize(0, 0)) - self.songSlider.setOrientation(QtCore.Qt.Horizontal) - self.songSlider.setObjectName("songSlider") - self.slidersLayout.addWidget(self.songSlider) - self.aSlider = QtWidgets.QSlider(self.centralwidget) - self.aSlider.setOrientation(QtCore.Qt.Horizontal) - self.aSlider.setObjectName("aSlider") - self.slidersLayout.addWidget(self.aSlider) - self.bSlider = QtWidgets.QSlider(self.centralwidget) - self.bSlider.setOrientation(QtCore.Qt.Horizontal) - self.bSlider.setObjectName("bSlider") - self.slidersLayout.addWidget(self.bSlider) - self.verticalLayout.addLayout(self.slidersLayout) - self.buttonsLayout = QtWidgets.QGridLayout() - self.buttonsLayout.setObjectName("buttonsLayout") - self.pauseButton = QtWidgets.QPushButton(self.centralwidget) - self.pauseButton.setObjectName("pauseButton") - self.buttonsLayout.addWidget(self.pauseButton, 0, 1, 1, 1) - self.initMidiButton = QtWidgets.QPushButton(self.centralwidget) - self.initMidiButton.setObjectName("initMidiButton") - self.buttonsLayout.addWidget(self.initMidiButton, 0, 4, 1, 1) - self.rateSlider = QtWidgets.QSlider(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.rateSlider.sizePolicy().hasHeightForWidth()) - self.rateSlider.setSizePolicy(sizePolicy) - self.rateSlider.setOrientation(QtCore.Qt.Horizontal) - self.rateSlider.setObjectName("rateSlider") - self.buttonsLayout.addWidget(self.rateSlider, 2, 0, 1, 1) - self.playButton = QtWidgets.QPushButton(self.centralwidget) - self.playButton.setObjectName("playButton") - self.buttonsLayout.addWidget(self.playButton, 0, 0, 1, 1) - self.saveSessionButton = QtWidgets.QPushButton(self.centralwidget) - self.saveSessionButton.setObjectName("saveSessionButton") - self.buttonsLayout.addWidget(self.saveSessionButton, 0, 2, 1, 1) - self.storeAbButton = QtWidgets.QPushButton(self.centralwidget) - self.storeAbButton.setObjectName("storeAbButton") - self.buttonsLayout.addWidget(self.storeAbButton, 2, 3, 1, 1) - self.loadSessionButton = QtWidgets.QPushButton(self.centralwidget) - self.loadSessionButton.setObjectName("loadSessionButton") - self.buttonsLayout.addWidget(self.loadSessionButton, 0, 3, 1, 1) - self.abRepeatCheckBox = QtWidgets.QCheckBox(self.centralwidget) - self.abRepeatCheckBox.setObjectName("abRepeatCheckBox") - self.buttonsLayout.addWidget(self.abRepeatCheckBox, 2, 1, 1, 1) - self.addSongButton = QtWidgets.QPushButton(self.centralwidget) - self.addSongButton.setObjectName("addSongButton") - self.buttonsLayout.addWidget(self.addSongButton, 2, 4, 1, 1) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.setAButton = QtWidgets.QPushButton(self.centralwidget) - self.setAButton.setObjectName("setAButton") - self.horizontalLayout.addWidget(self.setAButton) - self.setBButton = QtWidgets.QPushButton(self.centralwidget) - self.setBButton.setObjectName("setBButton") - self.horizontalLayout.addWidget(self.setBButton) - self.buttonsLayout.addLayout(self.horizontalLayout, 2, 2, 1, 1) - self.verticalLayout.addLayout(self.buttonsLayout) - MainWindow.setCentralWidget(self.centralwidget) - - self.retranslateUi(MainWindow) - QtCore.QMetaObject.connectSlotsByName(MainWindow) - - def retranslateUi(self, MainWindow): - _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) - self.pauseButton.setText(_translate("MainWindow", "Pause")) - self.initMidiButton.setText(_translate("MainWindow", "Connect MIDI")) - self.playButton.setText(_translate("MainWindow", "Play")) - self.saveSessionButton.setText(_translate("MainWindow", "Save session")) - self.storeAbButton.setText(_translate("MainWindow", "Store AB")) - self.loadSessionButton.setText(_translate("MainWindow", "Load session")) - self.abRepeatCheckBox.setText(_translate("MainWindow", "AB repeat")) - self.addSongButton.setText(_translate("MainWindow", "Add song")) - self.setAButton.setText(_translate("MainWindow", "Set A")) - self.setBButton.setText(_translate("MainWindow", "Set B")) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1288113 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +test: all + cd solo-tool-project/test && ../../venv/bin/pytest *test.py + cd cli-project/test && ../../venv/bin/pytest *test.py + +all: venv .git/hooks/pre-commit + +clean: + rm -rf venv + +.git/hooks/pre-commit: pre-commit + install -m 755 pre-commit .git/hooks/pre-commit + +venv: venv/touchfile + +venv/touchfile: requirements.txt solo-tool-project/pyproject.toml cli-project/pyproject.toml gui-project/pyproject.toml + rm -rf venv + python -m venv venv + ./venv/bin/pip install -r requirements.txt + touch venv/touchfile + +.PHONY: all test clean diff --git a/abcontroller.py b/abcontroller.py deleted file mode 100644 index cec9fb2..0000000 --- a/abcontroller.py +++ /dev/null @@ -1,82 +0,0 @@ -from collections import namedtuple - -_AB = namedtuple("_AB", ["a", "b"]) - -class ABController: - def __init__(self, enabled=True, callback=None): - self._setPositionCallback = callback - self._limits = {} # dictionary of all songs - self._songLimits = None # list of limits for selected song - self._currentLimits = _AB(0.0, 0.0) # a/b positions of active limit - self._loadedIndex = None - self._enabled = enabled - - def _ensureSongExists(self, path): - if path not in self._limits: - self._limits[path] = [] - - def setCurrentSong(self, path): - self._ensureSongExists(path) - self._songLimits = self._limits[path] - self._loadedIndex = None - - def storeLimits(self, aLimit, bLimit, song=None): - if song is not None: - self._ensureSongExists(song) - songLimits = self._limits[song] - else: - songLimits = self._songLimits - - if songLimits is None: - return - - ab = _AB(aLimit, bLimit) - songLimits.append(ab) - - def loadLimits(self, index): - if not self._songLimits: - return - - if index >= 0 and index < len(self._songLimits): - self._currentLimits = self._songLimits[index] - self._loadedIndex = index - - def nextStoredAbLimits(self): - if self._loadedIndex is None: - nextIndex = 0 - else: - nextIndex = self._loadedIndex + 1 - self.loadLimits(nextIndex) - - def previousStoredAbLimits(self): - if self._loadedIndex is None: - previousIndex = 0 - else: - previousIndex = self._loadedIndex - 1 - self.loadLimits(previousIndex) - - def setLimits(self, aLimit, bLimit): - self._currentLimits = _AB(aLimit, bLimit) - self._loadedIndex = None - - def positionChanged(self, position): - if position > self._currentLimits.b and self._setPositionCallback and self._enabled: - self._setPositionCallback(self._currentLimits.a) - - def setEnable(self, enable): - self._enabled = enable - - def isEnabled(self): - return self._enabled - - def getStoredLimits(self, song): - return self._limits.get(song) - - def getCurrentLimits(self): - return self._currentLimits - - def getLoadedIndex(self): - return self._loadedIndex - - def clear(self): - self.__init__(enabled=self._enabled, callback=self._setPositionCallback) diff --git a/abcontroller_unittest.py b/abcontroller_unittest.py deleted file mode 100644 index 9fdcbc3..0000000 --- a/abcontroller_unittest.py +++ /dev/null @@ -1,272 +0,0 @@ -from abcontroller import ABController -from collections import namedtuple - -TCase = namedtuple("TCase", ["currentPosition", "requestedPosition"]) -AB = namedtuple("AB", ["a", "b"]) -abLimits = AB(0.2, 0.4) - -def _checkLimits(uut, tests): - requestedPosition = None - def callback(newPosition): - nonlocal requestedPosition - requestedPosition = newPosition - - originalCallback = uut._setPositionCallback - uut._setPositionCallback = callback - - for t in tests: - uut.positionChanged(t.currentPosition) - assert requestedPosition == t.requestedPosition - - uut._setPositionCallback = originalCallback - -def checkLimits(uut, aLimit, bLimit, fail=False): - tests = [ - TCase(aLimit - 0.1, None), - TCase(aLimit, None), - TCase(bLimit - 0.1, None), - TCase(bLimit, None), - TCase(bLimit + 0.1, aLimit if not fail else None) - ] - _checkLimits(uut, tests) - if not fail: - assert uut.getCurrentLimits()[0] == aLimit - assert uut.getCurrentLimits()[1] == bLimit - -def checkDefaultLimits(uut): - tests = [ - TCase(0.0, None), - TCase(0.1, 0.0), - TCase(0.5, 0.0) - ] - _checkLimits(uut, tests) - -def test_oneSetOfLimits(): - song = "/path/to/song" - - uut = ABController() - uut.setCurrentSong(song) - uut.storeLimits(abLimits.a, abLimits.b) - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - checkLimits(uut, abLimits.a, abLimits.b) - assert uut.getStoredLimits(song) == [abLimits] - -def test_multipleSetsOfLimits(): - song = "/path/to/song" - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5), - AB(0.0, 1.2) - ] - - uut = ABController() - uut.setCurrentSong(song) - for l in abLimits: - uut.storeLimits(l.a, l.b) - - for i, l in enumerate(abLimits): - uut.loadLimits(i) - assert uut.getLoadedIndex() == i - checkLimits(uut, l.a, l.b) - - assert uut.getStoredLimits(song) == abLimits - -def test_multipleSongs(): - songs = [ - "/path/to/song", - "/path/to/another/song" - ] - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5) - ] - uut = ABController() - for i, s in enumerate(songs): - uut.storeLimits(abLimits[i].a, abLimits[i].b, s) - - for i, s in enumerate(songs): - uut.setCurrentSong(s) - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - checkLimits(uut, abLimits[i].a, abLimits[i].b) - assert uut.getStoredLimits(s) == [abLimits[i]] - -def test_disableAbRepeat(): - song = "/path/to/song" - - uut = ABController() - uut.setCurrentSong(song) - uut.storeLimits(abLimits.a, abLimits.b) - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - assert uut.isEnabled() - - uut.setEnable(False) - checkLimits(uut, abLimits.a, abLimits.b, fail=True) - assert not uut.isEnabled() - - uut.setEnable(True) - checkLimits(uut, abLimits.a, abLimits.b) - assert uut.isEnabled() - -def test_storeLimitsToSpecificSong(): - song = "/path/to/song" - - uut = ABController() - uut.storeLimits(abLimits.a, abLimits.b, song) - uut.setCurrentSong(song) - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - checkLimits(uut, abLimits.a, abLimits.b) - -def test_storeLimitsWithoutCurrentSong(): - uut = ABController() - uut.storeLimits(abLimits.a, abLimits.b) - uut.loadLimits(0) - assert uut.getLoadedIndex() == None - - checkDefaultLimits(uut) - -def test_storeLimitsToSongWithoutCurrentSong(): - song = "/path/to/song" - uut = ABController() - uut.storeLimits(abLimits.a, abLimits.b, song) - uut.loadLimits(0) - assert uut.getLoadedIndex() == None - - checkDefaultLimits(uut) - - uut.setCurrentSong(song) - - checkDefaultLimits(uut) - - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - checkLimits(uut, abLimits.a, abLimits.b) - -def test_storeLimitsToCurrentSongButDoNotSetCurrentLimits(): - song = "/path/to/song" - uut = ABController() - uut.setCurrentSong(song) - uut.storeLimits(abLimits.a, abLimits.b) - assert uut.getLoadedIndex() == None - - checkDefaultLimits(uut) - - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - checkLimits(uut, abLimits.a, abLimits.b) - -def test_getStoredLimitsOfInexistentSong(): - song = "/path/to/song" - uut = ABController() - assert uut.getStoredLimits(song) == None - -def test_clearAbController(): - songsWithLimits = [ - ("/path/to/song", AB(0.2, 0.4)), - ("/path/to/another/song", AB(0.3, 0.5)) - ] - - uut = ABController() - for s in songsWithLimits: - uut.storeLimits(s[1].a, s[1].b, s[0]) - - for i, s in enumerate(songsWithLimits): - assert uut.getStoredLimits(s[0]) == [s[1]] - - uut.clear() - - for i, s in enumerate(songsWithLimits): - assert uut.getStoredLimits(s[0]) == None - -def test_setTemporaryLimits(): - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5), - AB(0.0, 1.2) - ] - uut = ABController() - - for l in abLimits: - uut.setLimits(l.a, l.b) - assert uut.getLoadedIndex() == None - checkLimits(uut, l.a, l.b) - -def test_setTemporaryLimitsWithCurrentSong(): - songLimits = AB(0.2, 0.4) - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5), - AB(0.0, 1.2) - ] - song = "/path/to/song" - uut = ABController() - uut.setCurrentSong(song) - uut.storeLimits(songLimits.a, songLimits.b) - uut.loadLimits(0) - assert uut.getLoadedIndex() == 0 - - for l in abLimits: - uut.setLimits(l.a, l.b) - checkLimits(uut, l.a, l.b) - -def test_defaultBehaviour(): - uut = ABController() - checkDefaultLimits(uut) - -def test_nextStoredLimit(): - song = "/path/to/song" - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5) - ] - - uut = ABController() - uut.setCurrentSong(song) - for l in abLimits: - uut.storeLimits(l.a, l.b) - - checkDefaultLimits(uut) - - uut.nextStoredAbLimits() - checkLimits(uut, abLimits[0].a, abLimits[0].b) - - uut.nextStoredAbLimits() - checkLimits(uut, abLimits[1].a, abLimits[1].b) - - uut.nextStoredAbLimits() - checkLimits(uut, abLimits[1].a, abLimits[1].b) - -def test_previousStoredLimit(): - song = "/path/to/song" - abLimits = [ - AB(0.2, 0.4), - AB(0.3, 0.5) - ] - - uut = ABController() - uut.setCurrentSong(song) - for l in abLimits: - uut.storeLimits(l.a, l.b) - - checkDefaultLimits(uut) - - uut.previousStoredAbLimits() - checkLimits(uut, abLimits[0].a, abLimits[0].b) - - uut.previousStoredAbLimits() - checkLimits(uut, abLimits[0].a, abLimits[0].b) - - uut.loadLimits(1) - checkLimits(uut, abLimits[1].a, abLimits[1].b) - - uut.previousStoredAbLimits() - checkLimits(uut, abLimits[0].a, abLimits[0].b) diff --git a/bootstrap-venv.sh b/bootstrap-venv.sh deleted file mode 100755 index 99919d6..0000000 --- a/bootstrap-venv.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/zsh - -rm -rf venv -python -m venv venv -./venv/bin/pip install -r requirements.txt diff --git a/cli-project/pyproject.toml b/cli-project/pyproject.toml new file mode 100644 index 0000000..7d31a09 --- /dev/null +++ b/cli-project/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "solo_tool_cli" +authors = [ + { name = "Eddy Pedroni", email = "epedroni@pm.me" }, +] +description = "A CLI frontend for the solo_tool library" +requires-python = ">=3.12" +dependencies = [ + "solo_tool" +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "pytest" +] + +[project.scripts] +solo-tool-cli = "solo_tool_cli:main" + diff --git a/cli-project/src/solo_tool_cli.py b/cli-project/src/solo_tool_cli.py new file mode 100644 index 0000000..1c8a7d6 --- /dev/null +++ b/cli-project/src/solo_tool_cli.py @@ -0,0 +1,57 @@ +import sys +import time +import threading + +from solo_tool import SoloTool +from solo_tool.midi_controller_launchpad_mini import MidiController + +class SoloToolCLI: + def __init__(self, sessionJson, soloToolOverride=None, midiOverride=None, tickEnable=True): + self._soloTool = SoloTool() if soloToolOverride is None else soloToolOverride + self._soloTool.loadSession(sessionJson) + self._midiController = MidiController(self._soloTool) if midiOverride is None else midiOverride + self._commands = { + "song" : self._song, + "midi" : self._midi + } + if tickEnable: + self._tick() + + def input(self, commandString): + split = commandString.strip().split(" ") + if split[0] in self._commands: + self._commands[split[0]](split[1:]) + + def _song(self, args): + if len(args) > 0: + self._soloTool.setSong(int(args[0])) + else: + songs = self._soloTool.getSongs() + print("Songs:") + for i, s in enumerate(songs): + print(f" {i} {s}") + + def _midi(self, args): + if len(args) > 0 and args[0] == "connect": + print("Connecting to MIDI device...") + self._midiController.connect() + else: + print("Supported device: Novation Launchpad Mini MkII") + + def _tick(self): + self._soloTool.tick() + threading.Timer(0.1, self._tick).start() + +def main(): + args = sys.argv[1:] + if len(args) == 0: + print("Please provide path to session file") + sys.exit(1) + + soloToolCli = SoloToolCLI(args[0]) + while(True): + commandString = input("> ") + soloToolCli.input(commandString) + +if __name__ == '__main__': + main() diff --git a/cli-project/test/solo_tool_cli_integrationtest.py b/cli-project/test/solo_tool_cli_integrationtest.py new file mode 100644 index 0000000..c29a223 --- /dev/null +++ b/cli-project/test/solo_tool_cli_integrationtest.py @@ -0,0 +1,41 @@ +import pytest +import io +from contextlib import redirect_stdout + +from solo_tool_cli import SoloToolCLI +from solo_tool import SoloTool + +class MockMidiController: + def __init__(self, soloTool): + self.connected = False + + def connect(self): + self.connected = True + +@pytest.fixture +def soloTool(): + return SoloTool() + +@pytest.fixture +def mockMidi(soloTool): + return MockMidiController(soloTool) + +@pytest.fixture +def uut(soloTool, mockMidi): + return SoloToolCLI("test_session.json", soloToolOverride=soloTool, midiOverride=mockMidi, tickEnable=False) + +def test_connectMidi(uut, mockMidi): + expectedOutput = """\ +Supported device: Novation Launchpad Mini MkII +Connecting to MIDI device... +""" + + with io.StringIO() as buf, redirect_stdout(buf): + uut.input("midi") + assert not mockMidi.connected + + uut.input("midi connect") + assert mockMidi.connected + + assert buf.getvalue() == expectedOutput + diff --git a/cli-project/test/test.flac b/cli-project/test/test.flac new file mode 100644 index 0000000..9164735 Binary files /dev/null and b/cli-project/test/test.flac differ diff --git a/cli-project/test/test.mp3 b/cli-project/test/test.mp3 new file mode 100644 index 0000000..3c353b7 Binary files /dev/null and b/cli-project/test/test.mp3 differ diff --git a/cli-project/test/test_session.json b/cli-project/test/test_session.json new file mode 100644 index 0000000..f48b792 --- /dev/null +++ b/cli-project/test/test_session.json @@ -0,0 +1,13 @@ +[ + { + "path" : "test.flac", + "ab_limits" : null + }, + { + "path" : "test.mp3", + "ab_limits" : [ + [0.1, 0.2], + [0.3, 0.4] + ] + } +] diff --git a/diagram.drawio b/diagram.drawio deleted file mode 100644 index e3d1846..0000000 --- a/diagram.drawio +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/doc/diagram.drawio b/doc/diagram.drawio new file mode 100644 index 0000000..e3d1846 --- /dev/null +++ b/doc/diagram.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/doc/known-issues.md b/doc/known-issues.md new file mode 100644 index 0000000..ae248f5 --- /dev/null +++ b/doc/known-issues.md @@ -0,0 +1,62 @@ +# Open Issues + +* AB limits are displayed as p.u. + * timestamps would be best + * named limits would also be acceptable e.g. first solo, second solo, etc +* Store playback volume separately for each song (also in session JSON) +* Playlist mode + * automatically play next song when current song ends + * playback doesn't stop when jumping to next/previous song + * should this be the default anyway? +* AB list view in Qt GUI is currently not working correctly + * selection is not cleared properly when changing songs + * sometimes crashes when selecting limits with MIDI controller + +# Closed Issues + +* Moving AB sliders does not set AB limit. Instead need to save AB limit and then select it to apply +* Loading session is additive, should clear the state first +* Songs are displayed as full path, should be file name or ideally title from metadata +* When switching between songs, AB limit selection is not reset, this means that if the song has only one limit, it is not possible to load it anymore +* No GUI to control playback speed +* Add buttons to write current playback position to A or B limit sliders +* Switching between songs and AB limits does not work properly + * AB controller only keeps track of limit index, not current song => when song changes, index is invalid but not properly reset +* Changing song while playing does not update play/pause button LED on MIDI controller +* Accept file path as argument to Qt GUI to automatically load session +* Key mapping in Qt to jump to A + * Space bar in principle + * Not so easy to do actually, used Super L instead +* AB repeat toggle in MIDI controller + +# Use Cases + +## Song/solo practice + +On PC: + +0. Load session +1. Select song +2. Select A/B limit +3. Enable A/B repeat +3. Set overall volume + +On MIDI controller: + +* play/pause +* stop +* set playback speed +* next/previous A/B limit +* jump to limit A + +## Set practice + +On PC: + +0. Load session +1. Set overall volume + +On MIDI controller: + +* next/previous song + diff --git a/gui-project/pyproject.toml b/gui-project/pyproject.toml new file mode 100644 index 0000000..1e6fcf4 --- /dev/null +++ b/gui-project/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "solo_tool_gui" +authors = [ + { name = "Eddy Pedroni", email = "epedroni@pm.me" }, +] +description = "A Qt5-based GUI frontend for the solo_tool library" +requires-python = ">=3.12" +dependencies = [ + "PyQt5>=5.6", + "solo_tool" +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ +] + +[project.gui-scripts] +solo-tool-gui = "solo_tool_gui:main" + diff --git a/gui-project/src/MainWindow.py b/gui-project/src/MainWindow.py new file mode 100644 index 0000000..137bd33 --- /dev/null +++ b/gui-project/src/MainWindow.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'mainwindow.ui' +# +# Created by: PyQt5 UI code generator 5.15.6 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(971, 767) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout.setObjectName("verticalLayout") + self.listsLayout = QtWidgets.QHBoxLayout() + self.listsLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.listsLayout.setSpacing(6) + self.listsLayout.setObjectName("listsLayout") + self.songListView = QtWidgets.QListView(self.centralwidget) + self.songListView.setObjectName("songListView") + self.listsLayout.addWidget(self.songListView) + self.abListView = QtWidgets.QListView(self.centralwidget) + self.abListView.setObjectName("abListView") + self.listsLayout.addWidget(self.abListView) + self.verticalLayout.addLayout(self.listsLayout) + self.slidersLayout = QtWidgets.QVBoxLayout() + self.slidersLayout.setObjectName("slidersLayout") + self.songSlider = QtWidgets.QSlider(self.centralwidget) + self.songSlider.setMinimumSize(QtCore.QSize(0, 0)) + self.songSlider.setOrientation(QtCore.Qt.Horizontal) + self.songSlider.setObjectName("songSlider") + self.slidersLayout.addWidget(self.songSlider) + self.aSlider = QtWidgets.QSlider(self.centralwidget) + self.aSlider.setOrientation(QtCore.Qt.Horizontal) + self.aSlider.setObjectName("aSlider") + self.slidersLayout.addWidget(self.aSlider) + self.bSlider = QtWidgets.QSlider(self.centralwidget) + self.bSlider.setOrientation(QtCore.Qt.Horizontal) + self.bSlider.setObjectName("bSlider") + self.slidersLayout.addWidget(self.bSlider) + self.verticalLayout.addLayout(self.slidersLayout) + self.buttonsLayout = QtWidgets.QGridLayout() + self.buttonsLayout.setObjectName("buttonsLayout") + self.pauseButton = QtWidgets.QPushButton(self.centralwidget) + self.pauseButton.setObjectName("pauseButton") + self.buttonsLayout.addWidget(self.pauseButton, 0, 1, 1, 1) + self.initMidiButton = QtWidgets.QPushButton(self.centralwidget) + self.initMidiButton.setObjectName("initMidiButton") + self.buttonsLayout.addWidget(self.initMidiButton, 0, 4, 1, 1) + self.rateSlider = QtWidgets.QSlider(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.rateSlider.sizePolicy().hasHeightForWidth()) + self.rateSlider.setSizePolicy(sizePolicy) + self.rateSlider.setOrientation(QtCore.Qt.Horizontal) + self.rateSlider.setObjectName("rateSlider") + self.buttonsLayout.addWidget(self.rateSlider, 2, 0, 1, 1) + self.playButton = QtWidgets.QPushButton(self.centralwidget) + self.playButton.setObjectName("playButton") + self.buttonsLayout.addWidget(self.playButton, 0, 0, 1, 1) + self.saveSessionButton = QtWidgets.QPushButton(self.centralwidget) + self.saveSessionButton.setObjectName("saveSessionButton") + self.buttonsLayout.addWidget(self.saveSessionButton, 0, 2, 1, 1) + self.storeAbButton = QtWidgets.QPushButton(self.centralwidget) + self.storeAbButton.setObjectName("storeAbButton") + self.buttonsLayout.addWidget(self.storeAbButton, 2, 3, 1, 1) + self.loadSessionButton = QtWidgets.QPushButton(self.centralwidget) + self.loadSessionButton.setObjectName("loadSessionButton") + self.buttonsLayout.addWidget(self.loadSessionButton, 0, 3, 1, 1) + self.abRepeatCheckBox = QtWidgets.QCheckBox(self.centralwidget) + self.abRepeatCheckBox.setObjectName("abRepeatCheckBox") + self.buttonsLayout.addWidget(self.abRepeatCheckBox, 2, 1, 1, 1) + self.addSongButton = QtWidgets.QPushButton(self.centralwidget) + self.addSongButton.setObjectName("addSongButton") + self.buttonsLayout.addWidget(self.addSongButton, 2, 4, 1, 1) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.setAButton = QtWidgets.QPushButton(self.centralwidget) + self.setAButton.setObjectName("setAButton") + self.horizontalLayout.addWidget(self.setAButton) + self.setBButton = QtWidgets.QPushButton(self.centralwidget) + self.setBButton.setObjectName("setBButton") + self.horizontalLayout.addWidget(self.setBButton) + self.buttonsLayout.addLayout(self.horizontalLayout, 2, 2, 1, 1) + self.verticalLayout.addLayout(self.buttonsLayout) + MainWindow.setCentralWidget(self.centralwidget) + + self.retranslateUi(MainWindow) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) + self.pauseButton.setText(_translate("MainWindow", "Pause")) + self.initMidiButton.setText(_translate("MainWindow", "Connect MIDI")) + self.playButton.setText(_translate("MainWindow", "Play")) + self.saveSessionButton.setText(_translate("MainWindow", "Save session")) + self.storeAbButton.setText(_translate("MainWindow", "Store AB")) + self.loadSessionButton.setText(_translate("MainWindow", "Load session")) + self.abRepeatCheckBox.setText(_translate("MainWindow", "AB repeat")) + self.addSongButton.setText(_translate("MainWindow", "Add song")) + self.setAButton.setText(_translate("MainWindow", "Set A")) + self.setBButton.setText(_translate("MainWindow", "Set B")) diff --git a/gui-project/src/mainwindow.ui b/gui-project/src/mainwindow.ui new file mode 100644 index 0000000..ac4d97b --- /dev/null +++ b/gui-project/src/mainwindow.ui @@ -0,0 +1,161 @@ + + + MainWindow + + + + 0 + 0 + 971 + 767 + + + + MainWindow + + + + + + + 6 + + + QLayout::SetDefaultConstraint + + + + + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + + + + + Pause + + + + + + + Connect MIDI + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + Play + + + + + + + Save session + + + + + + + Store AB + + + + + + + Load session + + + + + + + AB repeat + + + + + + + Add song + + + + + + + + + Set A + + + + + + + Set B + + + + + + + + + + + + + diff --git a/gui-project/src/solo_tool_gui.py b/gui-project/src/solo_tool_gui.py new file mode 100644 index 0000000..0488f84 --- /dev/null +++ b/gui-project/src/solo_tool_gui.py @@ -0,0 +1,261 @@ +import sys + +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * +from MainWindow import Ui_MainWindow + +from solo_tool import SoloTool +from solo_tool.midi_controller_launchpad_mini import MidiController + +POSITION_FACTOR = 100000 +RATE_FACTOR = 10 +UI_REFRESH_PERIOD_MS = 500 + +CHANGE_GUI = 0 +CHANGE_INTERNAL = 1 + +class PlaylistModel(QAbstractListModel): + def __init__(self, soloTool, *args, **kwargs): + super(PlaylistModel, self).__init__(*args, **kwargs) + self.soloTool = soloTool + + def data(self, index, role): + if role == Qt.DisplayRole: + from pathlib import Path + path = Path(self.soloTool.getSongs()[index.row()]) + return path.name + + def rowCount(self, index): + return len(self.soloTool.getSongs()) + +class ABListModel(QAbstractListModel): + def __init__(self, soloTool, *args, **kwargs): + super(ABListModel, self).__init__(*args, **kwargs) + self.soloTool = soloTool + + def data(self, index, role): + if role == Qt.DisplayRole: + ab = self.soloTool.getStoredAbLimits()[index.row()] + return f"{ab[0]} - {ab[1]}" + + def rowCount(self, index): + return len(self.soloTool.getStoredAbLimits()) + +class MainWindow(QMainWindow, Ui_MainWindow): + songChangeSignal = pyqtSignal(int) + abLimitsChangeSignal = pyqtSignal(int) + + def __init__(self, *args, **kwargs): + super(MainWindow, self).__init__(*args, **kwargs) + + self.setupUi(self) + + self.timer = QTimer(self) + self.timer.setInterval(UI_REFRESH_PERIOD_MS) + self.timer.timeout.connect(self.timerCallback) + + self.soloTool = SoloTool() + self.midiController = MidiController(self.soloTool) + + self.playlistModel = PlaylistModel(self.soloTool) + self.songListView.setModel(self.playlistModel) + self.songListView.selectionModel().selectionChanged.connect(self.playlistSelectionChanged) + self.songChangePending = None + self.songChangeSignal.connect(self.currentSongChanged) + self.soloTool.registerCurrentSongCallback(self.songChangeSignal.emit) + + self.abListModel = ABListModel(self.soloTool) + self.abListView.setModel(self.abListModel) + self.abListView.selectionModel().selectionChanged.connect(self.abListSelectionChanged) + self.abLimitsChangePending = None + self.abLimitsChangeSignal.connect(self.currentAbLimitsChanged) + self.soloTool.registerCurrentAbLimitsCallback(self.abLimitsChangeSignal.emit) + + self.songSlider.setMaximum(POSITION_FACTOR) + self.songSlider.sliderPressed.connect(self.songSliderPressed) + self.songSlider.sliderReleased.connect(self.songSliderReleased) + + self.aSlider.setMaximum(POSITION_FACTOR) + self.aSlider.sliderReleased.connect(self.abSliderReleased) + self.bSlider.setMaximum(POSITION_FACTOR) + self.bSlider.sliderReleased.connect(self.abSliderReleased) + + self.rateSlider.setRange(int(0.5 * RATE_FACTOR), int(1.2 * RATE_FACTOR)) + self.rateSlider.setSingleStep(int(0.1 * RATE_FACTOR)) + self.rateSlider.setValue(int(1.0 * RATE_FACTOR)) + self.rateSlider.sliderReleased.connect(self.rateSliderReleased) + + self.playButton.pressed.connect(self.soloTool.play) + self.pauseButton.pressed.connect(self.soloTool.pause) + self.storeAbButton.pressed.connect(self.storeAbLimits) + self.setAButton.pressed.connect(self.setA) + self.setBButton.pressed.connect(self.setB) + self.saveSessionButton.pressed.connect(self.saveSession) + self.loadSessionButton.pressed.connect(self.loadSession) + self.addSongButton.pressed.connect(self.addSong) + self.abRepeatCheckBox.clicked.connect(self.toggleAbRepeat) + self.initMidiButton.pressed.connect(self.initMidi) + + self.timer.start() + + if len(sys.argv) > 1: + self.loadSession(sys.argv[1]) + + self.show() + + def timerCallback(self): + position = self.soloTool.getPlaybackPosition() * POSITION_FACTOR + self.songSlider.setValue(int(position)) + self.soloTool.tick() + + def addSong(self): + path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "mp3 Audio (*.mp3);FLAC audio (*.flac);All files (*.*)") + if path: + self.soloTool.addSong(path) + self.playlistModel.layoutChanged.emit() + + def storeAbLimits(self): + a = self.aSlider.value() / float(POSITION_FACTOR) + b = self.bSlider.value() / float(POSITION_FACTOR) + self.soloTool.storeAbLimits(a, b) + self.abListModel.layoutChanged.emit() + + def setA(self): + position = self.songSlider.value() + self.aSlider.setValue(position) + self.abSliderReleased() + + def setB(self): + position = self.songSlider.value() + self.bSlider.setValue(position) + self.abSliderReleased() + + def toggleAbRepeat(self): + enable = self.abRepeatCheckBox.isChecked() + self.soloTool.setAbLimitEnable(enable) + + def saveSession(self): + path, _ = QFileDialog.getSaveFileName(self, "Open file", "", "session file (*.json)") + if path: + self.soloTool.saveSession(path) + + def loadSession(self, path=None): + if path is None: + path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "session file (*.json)") + if path is not None: + self.soloTool.loadSession(path) + self.playlistModel.layoutChanged.emit() + self.abListModel.layoutChanged.emit() + + def songSliderPressed(self): + self.timer.stop() + + def songSliderReleased(self): + position = self.songSlider.value() / float(POSITION_FACTOR) + self.soloTool.setPlaybackPosition(position) + self.timer.start() + + def clearListViewSelection(self, listView): + i = listView.selectionModel().currentIndex() + listView.selectionModel().select(i, QItemSelectionModel.Deselect) + + def abSliderReleased(self): + a = self.aSlider.value() / float(POSITION_FACTOR) + b = self.bSlider.value() / float(POSITION_FACTOR) + self.soloTool.setAbLimits(a, b) + self.clearListViewSelection(self.abListView) + + def rateSliderReleased(self): + rate = self.rateSlider.value() / float(RATE_FACTOR) + self.soloTool.setPlaybackRate(rate) + + def playlistSelectionChanged(self, i): + if self.songChangePending == CHANGE_INTERNAL: + self.songChangePending = None + else: + assert self.songChangePending is None + self.songChangePending = CHANGE_GUI + index = i.indexes()[0].row() + self.soloTool.setSong(index) + + self.clearListViewSelection(self.abListView) + self.abListModel.layoutChanged.emit() + + def currentSongChanged(self, songIndex): + if self.songChangePending == CHANGE_GUI: + self.songChangePending = None + else: + assert self.songChangePending is None + self.songChangePending = CHANGE_INTERNAL + i = self.playlistModel.createIndex(songIndex, 0) + self.songListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) + + def abListSelectionChanged(self, i): + if self.abLimitsChangePending == CHANGE_INTERNAL: + print("Ack internal change") + self.abLimitsChangePending = None + else: + assert self.abLimitsChangePending is None + if i is not None and not i.isEmpty(): + print("Processing GUI change") + self.abLimitsChangePending = CHANGE_GUI + index = i.indexes()[0].row() + ab = self.soloTool.getStoredAbLimits()[index] + self.soloTool.loadAbLimits(index) + self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) + self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) + + def currentAbLimitsChanged(self, abIndex): + if self.abLimitsChangePending == CHANGE_GUI: + print("Ack GUI change") + self.abLimitsChangePending = None + else: + assert self.abLimitsChangePending is None + print("Processing internal change") + self.abLimitsChangePending = CHANGE_INTERNAL + i = self.abListModel.createIndex(abIndex, 0) + self.abListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) + ab = self.soloTool.getStoredAbLimits()[abIndex] + self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) + self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) + + def initMidi(self): + try: + self.midiController.connect() + except Exception as e: + print("Error: could not connect to MIDI controller") + print(e) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Super_L: + self.soloTool.jumpToA() + +def main(): + app = QApplication([]) + app.setApplicationName("Solo Tool") + app.setStyle("Fusion") + + # Fusion dark palette from https://gist.github.com/QuantumCD/6245215. + palette = QPalette() + palette.setColor(QPalette.Window, QColor(53, 53, 53)) + palette.setColor(QPalette.WindowText, Qt.white) + palette.setColor(QPalette.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) + palette.setColor(QPalette.ToolTipBase, Qt.white) + palette.setColor(QPalette.ToolTipText, Qt.white) + palette.setColor(QPalette.Text, Qt.white) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, Qt.white) + palette.setColor(QPalette.BrightText, Qt.red) + palette.setColor(QPalette.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + palette.setColor(QPalette.HighlightedText, Qt.black) + app.setPalette(palette) + app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }") + + window = MainWindow() + app.exec_() + +if __name__ == '__main__': + main() diff --git a/known-issues.md b/known-issues.md deleted file mode 100644 index ae248f5..0000000 --- a/known-issues.md +++ /dev/null @@ -1,62 +0,0 @@ -# Open Issues - -* AB limits are displayed as p.u. - * timestamps would be best - * named limits would also be acceptable e.g. first solo, second solo, etc -* Store playback volume separately for each song (also in session JSON) -* Playlist mode - * automatically play next song when current song ends - * playback doesn't stop when jumping to next/previous song - * should this be the default anyway? -* AB list view in Qt GUI is currently not working correctly - * selection is not cleared properly when changing songs - * sometimes crashes when selecting limits with MIDI controller - -# Closed Issues - -* Moving AB sliders does not set AB limit. Instead need to save AB limit and then select it to apply -* Loading session is additive, should clear the state first -* Songs are displayed as full path, should be file name or ideally title from metadata -* When switching between songs, AB limit selection is not reset, this means that if the song has only one limit, it is not possible to load it anymore -* No GUI to control playback speed -* Add buttons to write current playback position to A or B limit sliders -* Switching between songs and AB limits does not work properly - * AB controller only keeps track of limit index, not current song => when song changes, index is invalid but not properly reset -* Changing song while playing does not update play/pause button LED on MIDI controller -* Accept file path as argument to Qt GUI to automatically load session -* Key mapping in Qt to jump to A - * Space bar in principle - * Not so easy to do actually, used Super L instead -* AB repeat toggle in MIDI controller - -# Use Cases - -## Song/solo practice - -On PC: - -0. Load session -1. Select song -2. Select A/B limit -3. Enable A/B repeat -3. Set overall volume - -On MIDI controller: - -* play/pause -* stop -* set playback speed -* next/previous A/B limit -* jump to limit A - -## Set practice - -On PC: - -0. Load session -1. Set overall volume - -On MIDI controller: - -* next/previous song - diff --git a/mainwindow.ui b/mainwindow.ui deleted file mode 100644 index ac4d97b..0000000 --- a/mainwindow.ui +++ /dev/null @@ -1,161 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 971 - 767 - - - - MainWindow - - - - - - - 6 - - - QLayout::SetDefaultConstraint - - - - - - - - - - - - - - - - 0 - 0 - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - - - Pause - - - - - - - Connect MIDI - - - - - - - - 0 - 0 - - - - Qt::Horizontal - - - - - - - Play - - - - - - - Save session - - - - - - - Store AB - - - - - - - Load session - - - - - - - AB repeat - - - - - - - Add song - - - - - - - - - Set A - - - - - - - Set B - - - - - - - - - - - - - diff --git a/midi_controller_launchpad_mini.py b/midi_controller_launchpad_mini.py deleted file mode 100644 index 0cc952c..0000000 --- a/midi_controller_launchpad_mini.py +++ /dev/null @@ -1,145 +0,0 @@ -from midi_wrapper_mido import MidiWrapper - -class MidiController: - DEVICE_NAME = "Launchpad Mini MIDI 1" - LIGHT_CONTROL_CHANNEL = 0 - LED_GREEN = 124 - LED_YELLOW = 126 - LED_RED = 3 - LED_OFF = 0 - BUTTON_MATRIX = [[x for x in range(y * 16, y * 16 + 8)] for y in range(0,8)] # address as [row][col] - - MIN_PLAYBACK_RATE = 0.5 - MAX_PLAYBACK_RATE = 1.2 - PLAYBACK_RATE_STEP = 0.1 - - MIN_PLAYBACK_VOLUME = 0.5 - MAX_PLAYBACK_VOLUME = 1.2 - PLAYBACK_VOLUME_STEP = 0.1 - - def __init__(self, soloTool, midiWrapperOverride=None): - self._soloTool = soloTool - if midiWrapperOverride is not None: - self._midiWrapper = midiWrapperOverride - else: - self._midiWrapper = MidiWrapper() - - self._registerHandlers() - self._soloTool.registerPlayingStateCallback(self._updatePlayPauseButton) - self._soloTool.registerPlaybackVolumeCallback(self._updateVolumeRow) - self._soloTool.registerPlaybackRateCallback(self._updateRateRow) - self._soloTool.registerAbLimitEnabledCallback(self._updateToggleAbLimitEnableButton) - - def _registerHandlers(self): - self._handlers = { - 96 : self._soloTool.stop, - 99 : self._soloTool.jumpToA, - 112 : self._playPause, - 101 : self._toggleAbLimitEnable, - 102 : self._soloTool.previousStoredAbLimits, - 103 : self._soloTool.nextStoredAbLimits, - 118 : self._soloTool.previousSong, - 119 : self._soloTool.nextSong - } - - for i in range(0, 8): - volume = round(MidiController.MIN_PLAYBACK_VOLUME + MidiController.PLAYBACK_VOLUME_STEP * i, 1) - self._handlers[i] = self._createSetPlaybackVolumeCallback(volume) - - for i, button in enumerate(range(16, 24)): - rate = round(MidiController.MIN_PLAYBACK_RATE + MidiController.PLAYBACK_RATE_STEP * i, 1) - self._handlers[button] = self._createSetPlaybackRateCallback(rate) - - def connect(self): - self._midiWrapper.setCallback(self._callback) - self._midiWrapper.connect(MidiController.DEVICE_NAME) - self._initialiseButtonLEDs() - - def _callback(self, msg): - if msg.type != "note_on" or msg.velocity < 127: - return - - if msg.note in self._handlers: - handler = self._handlers[msg.note]() - - def _playPause(self): - if self._soloTool.isPlaying(): - self._soloTool.pause() - else: - self._soloTool.play() - - def _toggleAbLimitEnable(self): - self._soloTool.setAbLimitEnable(not self._soloTool.isAbLimitEnabled()) - - def _updatePlayPauseButton(self, playing): - if playing: - self._setButtonLED(7, 0, MidiController.LED_GREEN) - else: - self._setButtonLED(7, 0, MidiController.LED_YELLOW) - - def _updateToggleAbLimitEnableButton(self, enabled): - if enabled: - self._setButtonLED(6, 5, MidiController.LED_GREEN) - else: - self._setButtonLED(6, 5, MidiController.LED_RED) - - def _updateVolumeRow(self, volume): - t1 = int(round(volume / MidiController.PLAYBACK_VOLUME_STEP, 1)) - t2 = int(round(MidiController.MIN_PLAYBACK_VOLUME / MidiController.PLAYBACK_VOLUME_STEP, 1)) - lastColumnLit = t1 - t2 + 1 - self._lightRowUntilColumn(0, lastColumnLit, MidiController.LED_GREEN) - - def _updateRateRow(self, rate): - t1 = int(round(rate / MidiController.PLAYBACK_RATE_STEP, 1)) - t2 = int(round(MidiController.MIN_PLAYBACK_RATE / MidiController.PLAYBACK_RATE_STEP, 1)) - lastColumnLit = t1 - t2 + 1 - self._lightRowUntilColumn(1, lastColumnLit, MidiController.LED_YELLOW) - - def _createSetPlaybackRateCallback(self, rate): - def f(): - self._soloTool.setPlaybackRate(rate) - return f - - def _createSetPlaybackVolumeCallback(self, volume): - def f(): - self._soloTool.setPlaybackVolume(volume) - return f - - def _setButtonLED(self, row, col, colour): - self._midiWrapper.sendMessage(MidiController.BUTTON_MATRIX[row][col], colour, MidiController.LIGHT_CONTROL_CHANNEL) - - def _lightRowUntilColumn(self, row, column, litColour): - colours = [litColour] * column + [MidiController.LED_OFF] * (8 - column) - for col in range(0, 8): - self._setButtonLED(row, col, colours[col]) - - def _allLEDsOff(self): - for row in range(0, 8): - for col in range(0, 8): - self._setButtonLED(row, col, MidiController.LED_OFF) - - def _initialiseButtonLEDs(self): - self._allLEDsOff() - - # volume buttons - self._updateVolumeRow(self._soloTool.getPlaybackVolume()) - - # playback rate buttons - self._updateRateRow(self._soloTool.getPlaybackRate()) - - # playback control - self._setButtonLED(6, 0, MidiController.LED_RED) - self._updatePlayPauseButton(self._soloTool.isPlaying()) - - # AB repeat toggle - self._updateToggleAbLimitEnableButton(self._soloTool.isAbLimitEnabled()) - - # AB control - self._setButtonLED(6, 3, MidiController.LED_YELLOW) - self._setButtonLED(6, 6, MidiController.LED_RED) - self._setButtonLED(6, 7, MidiController.LED_GREEN) - - # Song control - self._setButtonLED(7, 6, MidiController.LED_RED) - self._setButtonLED(7, 7, MidiController.LED_GREEN) - diff --git a/midi_launchpad_mini_integrationtest.py b/midi_launchpad_mini_integrationtest.py deleted file mode 100644 index e9bc6b5..0000000 --- a/midi_launchpad_mini_integrationtest.py +++ /dev/null @@ -1,387 +0,0 @@ -import pytest - -from midi_controller_launchpad_mini import MidiController -from solo_tool import SoloTool -from player_mock import Player as PlayerMock - -LED_RED = 3 -LED_YELLOW = 126 -LED_GREEN = 124 -LED_OFF = 0 - -nextSongButton = 119 -previousSongButton = 118 -playPauseButton = 112 -stopButton = 96 -nextLimitButton = 103 -previousLimitButton = 102 -abToggleButton = 101 -jumpToAButton = 99 - -class MidiWrapperMock: - def __init__(self): - self.callback = None - self.connectedDevice = None - self.sentMessages = list() - - def setCallback(self, callback): - self.callback = callback - - def connect(self, deviceName): - self.connectedDevice = deviceName - - def sendMessage(self, note, velocity, channel): - self.sentMessages.append((note, velocity, channel)) - - def simulateInput(self, note, velocity=127, channel=0): - if self.callback is not None: - from mido import Message - msg = Message("note_on", note=note, velocity=velocity, channel=channel) - self.callback(msg) - - def getLatestMessage(self): - return self.sentMessages[-1] - -@pytest.fixture -def playerMock(): - return PlayerMock() - -@pytest.fixture -def soloTool(playerMock): - return SoloTool(playerMock) - -@pytest.fixture -def midiWrapperMock(): - return MidiWrapperMock() - -@pytest.fixture -def uut(soloTool, midiWrapperMock): - return MidiController(soloTool, midiWrapperMock) - -def test_connect(uut, midiWrapperMock): - expectedDevice = "Launchpad Mini MIDI 1" - uut.connect() - - assert midiWrapperMock.connectedDevice == expectedDevice - -def test_startStopAndPauseButtons(uut, midiWrapperMock, playerMock): - uut.connect() - - assert playerMock.state == PlayerMock.STOPPED - - midiWrapperMock.simulateInput(playPauseButton) - assert playerMock.state == PlayerMock.PLAYING - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - - midiWrapperMock.simulateInput(stopButton) - assert playerMock.state == PlayerMock.STOPPED - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - - midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - - midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - assert playerMock.state == PlayerMock.PAUSED - - midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - assert playerMock.state == PlayerMock.PLAYING - - midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - - midiWrapperMock.simulateInput(stopButton) - assert playerMock.state == PlayerMock.STOPPED - -def test_startPauseButtonLed(uut, midiWrapperMock, playerMock, soloTool): - uut.connect() - - assert playerMock.state == PlayerMock.STOPPED - - playerMock.state = PlayerMock.PLAYING - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - - playerMock.state = PlayerMock.STOPPED - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - - playerMock.state = PlayerMock.PAUSED - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - - playerMock.state = PlayerMock.PLAYING - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - -def test_abToggleButton(uut, midiWrapperMock, soloTool): - uut.connect() - - midiWrapperMock.simulateInput(abToggleButton) - assert soloTool.isAbLimitEnabled() - assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) - - midiWrapperMock.simulateInput(abToggleButton) - assert not soloTool.isAbLimitEnabled() - assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) - -def test_abToggleButtonLed(uut, midiWrapperMock, soloTool): - uut.connect() - - soloTool.setAbLimitEnable(True) - assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) - - soloTool.setAbLimitEnable(False) - assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) - -def test_jumpToAButton(uut, midiWrapperMock, soloTool, playerMock): - ab = (0.5, 0.6) - uut.connect() - - soloTool.setAbLimits(ab[0], ab[1]) - assert playerMock.position == 0.0 - - midiWrapperMock.simulateInput(jumpToAButton) - assert playerMock.position == ab[0] - -def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: - soloTool.addSong(s) - uut.connect() - - assert playerMock.currentSong == None - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[0] - - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[1] - - midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] - - midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] - -def test_previousAndNextAbButtons(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" - abLimits = [ - [0.2, 0.4], - [0.1, 0.3] - ] - - soloTool.addSong(song) - soloTool.setSong(0) - soloTool.setAbLimitEnable(True) - - for ab in abLimits: - soloTool.storeAbLimits(ab[0], ab[1]) - - uut.connect() - - def checkLimit(aLimit, bLimit): - playerMock.position = bLimit - 0.1 - soloTool.tick() - assert playerMock.position == bLimit - 0.1 - - playerMock.position = bLimit + 0.1 - soloTool.tick() - assert playerMock.position == aLimit - - checkLimit(0.0, 0.0) - - midiWrapperMock.simulateInput(nextLimitButton) - checkLimit(abLimits[0][0], abLimits[0][1]) - - midiWrapperMock.simulateInput(nextLimitButton) - checkLimit(abLimits[1][0], abLimits[1][1]) - - midiWrapperMock.simulateInput(nextLimitButton) - checkLimit(abLimits[1][0], abLimits[1][1]) - - midiWrapperMock.simulateInput(previousLimitButton) - checkLimit(abLimits[0][0], abLimits[0][1]) - - midiWrapperMock.simulateInput(previousLimitButton) - checkLimit(abLimits[0][0], abLimits[0][1]) - -def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): - playbackRateOptions = { - 16 : (0.5, [LED_YELLOW] * 1 + [LED_OFF] * 7), - 17 : (0.6, [LED_YELLOW] * 2 + [LED_OFF] * 6), - 18 : (0.7, [LED_YELLOW] * 3 + [LED_OFF] * 5), - 19 : (0.8, [LED_YELLOW] * 4 + [LED_OFF] * 4), - 20 : (0.9, [LED_YELLOW] * 5 + [LED_OFF] * 3), - 21 : (1.0, [LED_YELLOW] * 6 + [LED_OFF] * 2), - 22 : (1.1, [LED_YELLOW] * 7 + [LED_OFF] * 1), - 23 : (1.2, [LED_YELLOW] * 8) - } - uut.connect() - assert playerMock.rate == 1.0 - - for t, button in enumerate(playbackRateOptions): - midiWrapperMock.sentMessages.clear() - - midiWrapperMock.simulateInput(button) - assert playerMock.rate == playbackRateOptions[button][0] - - for i, colour in enumerate(playbackRateOptions[button][1]): - assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) - -def test_playbackRateLeds(uut, midiWrapperMock, soloTool, playerMock): - playbackRateOptions = [ - (0.00, [LED_OFF] * 8), - (0.49, [LED_OFF] * 8), - - (0.50, [LED_YELLOW] * 1 + [LED_OFF] * 7), - (0.59, [LED_YELLOW] * 1 + [LED_OFF] * 7), - - (0.60, [LED_YELLOW] * 2 + [LED_OFF] * 6), - (0.69, [LED_YELLOW] * 2 + [LED_OFF] * 6), - - (0.70, [LED_YELLOW] * 3 + [LED_OFF] * 5), - (0.79, [LED_YELLOW] * 3 + [LED_OFF] * 5), - - (0.80, [LED_YELLOW] * 4 + [LED_OFF] * 4), - (0.89, [LED_YELLOW] * 4 + [LED_OFF] * 4), - - (0.90, [LED_YELLOW] * 5 + [LED_OFF] * 3), - (0.99, [LED_YELLOW] * 5 + [LED_OFF] * 3), - - (1.00, [LED_YELLOW] * 6 + [LED_OFF] * 2), - (1.09, [LED_YELLOW] * 6 + [LED_OFF] * 2), - - (1.10, [LED_YELLOW] * 7 + [LED_OFF] * 1), - (1.19, [LED_YELLOW] * 7 + [LED_OFF] * 1), - - (1.2, [LED_YELLOW] * 8), - (1.5, [LED_YELLOW] * 8) - ] - uut.connect() - assert playerMock.rate == 1.0 - - for t, (rate, leds) in enumerate(playbackRateOptions): - midiWrapperMock.sentMessages.clear() - - soloTool.setPlaybackRate(rate) - assert playerMock.rate == rate - - for i, colour in enumerate(leds): - assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) - -def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): - playbackVolumeOptions = { - 0 : (0.5, [LED_GREEN] * 1 + [LED_OFF] * 7), - 1 : (0.6, [LED_GREEN] * 2 + [LED_OFF] * 6), - 2 : (0.7, [LED_GREEN] * 3 + [LED_OFF] * 5), - 3 : (0.8, [LED_GREEN] * 4 + [LED_OFF] * 4), - 4 : (0.9, [LED_GREEN] * 5 + [LED_OFF] * 3), - 5 : (1.0, [LED_GREEN] * 6 + [LED_OFF] * 2), - 6 : (1.1, [LED_GREEN] * 7 + [LED_OFF] * 1), - 7 : (1.2, [LED_GREEN] * 8) - } - uut.connect() - assert playerMock.volume == 1.0 - - for t, button in enumerate(playbackVolumeOptions): - midiWrapperMock.sentMessages.clear() - - midiWrapperMock.simulateInput(button) - assert playerMock.volume == playbackVolumeOptions[button][0] - - for i, colour in enumerate(playbackVolumeOptions[button][1]): - assert midiWrapperMock.sentMessages[i] == (i, colour, 0) - -def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, playerMock): - playbackVolumeOptions = [ - (0.00, [LED_OFF] * 8), - (0.49, [LED_OFF] * 8), - - (0.50, [LED_GREEN] * 1 + [LED_OFF] * 7), - (0.59, [LED_GREEN] * 1 + [LED_OFF] * 7), - - (0.60, [LED_GREEN] * 2 + [LED_OFF] * 6), - (0.69, [LED_GREEN] * 2 + [LED_OFF] * 6), - - (0.70, [LED_GREEN] * 3 + [LED_OFF] * 5), - (0.79, [LED_GREEN] * 3 + [LED_OFF] * 5), - - (0.80, [LED_GREEN] * 4 + [LED_OFF] * 4), - (0.89, [LED_GREEN] * 4 + [LED_OFF] * 4), - - (0.90, [LED_GREEN] * 5 + [LED_OFF] * 3), - (0.99, [LED_GREEN] * 5 + [LED_OFF] * 3), - - (1.00, [LED_GREEN] * 6 + [LED_OFF] * 2), - (1.09, [LED_GREEN] * 6 + [LED_OFF] * 2), - - (1.10, [LED_GREEN] * 7 + [LED_OFF] * 1), - (1.19, [LED_GREEN] * 7 + [LED_OFF] * 1), - - (1.2, [LED_GREEN] * 8), - (1.5, [LED_GREEN] * 8) - ] - uut.connect() - assert playerMock.volume == 1.0 - - for t, (volume, leds) in enumerate(playbackVolumeOptions): - midiWrapperMock.sentMessages.clear() - - soloTool.setPlaybackVolume(volume) - assert playerMock.volume == volume - - for i, colour in enumerate(leds): - assert midiWrapperMock.sentMessages[i] == (i, colour, 0) - -def test_unassignedButton(uut, midiWrapperMock): - unassignedButton = 48 - uut.connect() - - # expect no crash - midiWrapperMock.simulateInput(unassignedButton) - # XXX would be better to assert that nothing changed in the solo tool - -def test_initializationMessages(uut, midiWrapperMock): - expectedMessages = set( - [(int(i / 8) * 16 + (i % 8), LED_OFF, 0) for i in range(0, 64)] + # clear all - [(i, LED_GREEN, 0) for i in range(0, 6)] + # volume row - [(i, LED_YELLOW, 0) for i in range(16, 22)] + # playback rate row - [ - (stopButton, LED_RED, 0), - (playPauseButton, LED_YELLOW, 0), - (abToggleButton, LED_RED, 0), - (jumpToAButton, LED_YELLOW, 0), - (previousLimitButton, LED_RED, 0), - (nextLimitButton, LED_GREEN, 0), - (previousSongButton, LED_RED, 0), - (nextSongButton, LED_GREEN, 0) - ] - ) - - uut.connect() - - sentMessagesSet = set(midiWrapperMock.sentMessages) - assert sentMessagesSet == expectedMessages - -def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: - soloTool.addSong(s) - uut.connect() - - soloTool.setSong(0) - soloTool.play() - assert playerMock.state == PlayerMock.PLAYING - assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_GREEN, 0) - - soloTool.nextSong() - assert playerMock.state == PlayerMock.STOPPED - assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0) - diff --git a/midi_wrapper_mido.py b/midi_wrapper_mido.py deleted file mode 100644 index bf3aa85..0000000 --- a/midi_wrapper_mido.py +++ /dev/null @@ -1,21 +0,0 @@ -import mido - -class MidiWrapper: - def __init__(self): - self._inPort = None - self._outPort = None - self._callback = None - - def setCallback(self, callback): - self._callback = callback - - def connect(self, deviceName): - self._inPort = mido.open_input(deviceName) - self._inPort.callback = self._callback - self._outPort = mido.open_output(deviceName) - - def sendMessage(self, note, velocity, channel): - if self._outPort is not None: - msg = mido.Message('note_on', channel=channel, velocity=velocity, note=note) - self._outPort.send(msg) - diff --git a/notifier.py b/notifier.py deleted file mode 100644 index fae41b9..0000000 --- a/notifier.py +++ /dev/null @@ -1,30 +0,0 @@ - -class Notifier: - PLAYING_STATE_EVENT = 0 - PLAYBACK_VOLUME_EVENT = 1 - PLAYBACK_RATE_EVENT = 2 - CURRENT_SONG_EVENT = 3 - CURRENT_AB_EVENT = 4 - AB_LIMIT_ENABLED_EVENT = 5 - - def __init__(self, player): - self._callbacks = dict() - self._player = player - self._player.setPlayingStateChangedCallback(self._playingStateChangedCallback) - self._player.setPlaybackVolumeChangedCallback(self._playbackVolumeChangedCallback) - - def registerCallback(self, event, callback): - if event not in self._callbacks: - self._callbacks[event] = list() - self._callbacks[event].append(callback) - - def notify(self, event, value): - for callback in self._callbacks.get(event, list()): - callback(value) - - def _playingStateChangedCallback(self, *args): - self.notify(Notifier.PLAYING_STATE_EVENT, self._player.isPlaying()) - - def _playbackVolumeChangedCallback(self, *args): - self.notify(Notifier.PLAYBACK_VOLUME_EVENT, self._player.getPlaybackVolume()) - diff --git a/notifier_unittest.py b/notifier_unittest.py deleted file mode 100644 index b27a17f..0000000 --- a/notifier_unittest.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest - -from notifier import Notifier -from player_mock import Player - -@pytest.fixture -def mockPlayer(): - return Player() - -@pytest.fixture -def uut(mockPlayer): - return Notifier(mockPlayer) - -def test_allEvents(uut): - def checkEvent(uut, event): - callbacks = 2 - calledFlags = [False] * callbacks - values = [None] * callbacks - - def createCallback(i): - def cb(value): - nonlocal calledFlags, values - calledFlags[i] = True - values[i] = value - - return cb - - for i in range(0, callbacks): - uut.registerCallback(event, createCallback(i)) - - assert not any(calledFlags) - uut.notify(event, 123) - assert all(calledFlags) - assert values == [123] * callbacks - - checkEvent(uut, Notifier.PLAYING_STATE_EVENT) - checkEvent(uut, Notifier.PLAYBACK_VOLUME_EVENT) - checkEvent(uut, Notifier.PLAYBACK_RATE_EVENT) - checkEvent(uut, Notifier.CURRENT_SONG_EVENT) - checkEvent(uut, Notifier.CURRENT_AB_EVENT) - checkEvent(uut, Notifier.AB_LIMIT_ENABLED_EVENT) - -def test_eventWithoutRegisteredCallbacks(uut): - uut.notify(Notifier.PLAYING_STATE_EVENT, 0) - # expect no crash - -def test_eventsWithMockPlayer(uut, mockPlayer): - def checkEvent(eventCode, simulateEvent, expectedValue): - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerCallback(eventCode, callback) - - assert not called - simulateEvent() - assert called - assert receivedValue == expectedValue - - mockPlayer.state = 1 - mockPlayer.volume = 75 - - checkEvent(Notifier.PLAYING_STATE_EVENT, mockPlayer.simulatePlayingStateChanged, True) - checkEvent(Notifier.PLAYBACK_VOLUME_EVENT, mockPlayer.simulatePlaybackVolumeChanged, 75) - -def test_singleEventNotification(uut): - playingStateCalled = False - def playingStateCallback(value): - nonlocal playingStateCalled - playingStateCalled = True - - volumeCalled = False - def volumeCallback(value): - nonlocal volumeCalled - volumeCalled = True - - uut.registerCallback(Notifier.PLAYING_STATE_EVENT, playingStateCallback) - uut.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, volumeCallback) - - assert not playingStateCalled - assert not volumeCalled - - uut.notify(Notifier.PLAYING_STATE_EVENT, 0) - assert playingStateCalled - assert not volumeCalled - - playingStateCalled = False - - uut.notify(Notifier.PLAYBACK_VOLUME_EVENT, 0) - assert not playingStateCalled - assert volumeCalled - - volumeCalled = False - - uut.notify(Notifier.PLAYBACK_RATE_EVENT, 0) - assert not playingStateCalled - assert not volumeCalled diff --git a/player_mock.py b/player_mock.py deleted file mode 100644 index 3162e0f..0000000 --- a/player_mock.py +++ /dev/null @@ -1,71 +0,0 @@ -class Player(): - STOPPED = 0 - PLAYING = 1 - PAUSED = 2 - - def __init__(self): - self.state = Player.STOPPED - self.rate = 1.0 - self.position = 0.0 - self.volume = 1.0 - self.currentSong = None - self.playingStateChangedCallback = None - self.playbackVolumeChangedCallback = None - - def play(self): - previousState = self.state - self.state = Player.PLAYING - if previousState != Player.PLAYING: - self.playingStateChangedCallback() - - def stop(self): - previousState = self.state - self.state = Player.STOPPED - if previousState != Player.STOPPED: - self.playingStateChangedCallback() - - def pause(self): - previousState = self.state - self.state = Player.PAUSED - if previousState != Player.PAUSED: - self.playingStateChangedCallback() - - def isPlaying(self): - return self.state == Player.PLAYING - - def setPlaybackRate(self, rate): - self.rate = rate - - def getPlaybackRate(self): - return self.rate - - def setPlaybackPosition(self, position): - self.position = position - - def getPlaybackPosition(self): - return self.position - - def setPlaybackVolume(self, volume): - changed = self.volume != volume - self.volume = volume - if changed: - self.playbackVolumeChangedCallback() - - def getPlaybackVolume(self): - return self.volume - - def setCurrentSong(self, path): - self.stop() - self.currentSong = path - - def setPlayingStateChangedCallback(self, callback): - self.playingStateChangedCallback = callback - - def simulatePlayingStateChanged(self): - self.playingStateChangedCallback() - - def setPlaybackVolumeChangedCallback(self, callback): - self.playbackVolumeChangedCallback = callback - - def simulatePlaybackVolumeChanged(self): - self.playbackVolumeChangedCallback() diff --git a/player_vlc.py b/player_vlc.py deleted file mode 100644 index 283102e..0000000 --- a/player_vlc.py +++ /dev/null @@ -1,55 +0,0 @@ -import vlc - -class Player: - def __init__(self): - self._player = vlc.MediaPlayer() - - def play(self): - self._player.play() - - def stop(self): - self._player.stop() - - def pause(self): - self._player.pause() - - def isPlaying(self): - playing = self._player.is_playing() == 1 - return playing - - def setPlaybackRate(self, rate): - self._player.set_rate(rate) - - def getPlaybackRate(self): - return self._player.get_rate() - - def setPlaybackPosition(self, position): - self._player.set_position(position) - - def getPlaybackPosition(self): - return self._player.get_position() - - def setPlaybackVolume(self, volume): - self._player.audio_set_volume(int(volume * 100)) - - def getPlaybackVolume(self): - return self._player.audio_get_volume() / 100.0 - - def setCurrentSong(self, path): - self._player.stop() - media = vlc.Media(path) - self._player.set_media(media) - - def setPlayingStateChangedCallback(self, callback): - events = [ - vlc.EventType.MediaPlayerStopped, - vlc.EventType.MediaPlayerPlaying, - vlc.EventType.MediaPlayerPaused - ] - manager = self._player.event_manager() - for e in events: - manager.event_attach(e, callback) - - def setPlaybackVolumeChangedCallback(self, callback): - manager = self._player.event_manager() - manager.event_attach(vlc.EventType.MediaPlayerAudioVolume, callback) diff --git a/playlist.py b/playlist.py deleted file mode 100644 index 5dad711..0000000 --- a/playlist.py +++ /dev/null @@ -1,41 +0,0 @@ - -class Playlist: - def __init__(self, callback): - self._songList = list() - self._currentSong = None - self._setSongCallback = callback - - def addSong(self, path): - self._songList.append(path) - - def setCurrentSong(self, index): - if index >= 0 and index < len(self._songList): - self._currentSong = index - self._setSongCallback(self._songList[index]) - - def getCurrentSong(self): - index = self._currentSong - return self._songList[index] if index is not None else None - - def getCurrentSongIndex(self): - return self._currentSong - - def getSongs(self): - return self._songList - - def clear(self): - self.__init__(self._setSongCallback) - - def nextSong(self): - if self._currentSong is None: - nextSong = 0 - else: - nextSong = self._currentSong + 1 - self.setCurrentSong(nextSong) - - def previousSong(self): - if self._currentSong is None: - prevSong = 0 - else: - prevSong = self._currentSong - 1 - self.setCurrentSong(prevSong) diff --git a/playlist_unittest.py b/playlist_unittest.py deleted file mode 100644 index 815a05f..0000000 --- a/playlist_unittest.py +++ /dev/null @@ -1,148 +0,0 @@ -from playlist import Playlist - -def test_addAndSelectOneSong(): - songAddedByUser = "/path/to/song" - songSetByCallback = None - - def testCallback(song): - nonlocal songSetByCallback - songSetByCallback = song - - uut = Playlist(testCallback) - uut.addSong(songAddedByUser) - uut.setCurrentSong(0) - - assert songAddedByUser == songSetByCallback - assert uut.getCurrentSong() == songAddedByUser - assert uut.getCurrentSongIndex() == 0 - assert uut.getSongs() == [songAddedByUser] - -def test_addTwoSongsAndSelectBoth(): - songAddedByUser = ["/path/to/song", "/path/to/second/song"] - songSetByCallback = None - - def testCallback(song): - nonlocal songSetByCallback - songSetByCallback = song - - uut = Playlist(testCallback) - uut.addSong(songAddedByUser[0]) - uut.addSong(songAddedByUser[1]) - assert uut.getSongs() == songAddedByUser - - uut.setCurrentSong(0) - assert songAddedByUser[0] == songSetByCallback - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 - - uut.setCurrentSong(1) - assert songAddedByUser[1] == songSetByCallback - assert uut.getCurrentSong() == songAddedByUser[1] - assert uut.getCurrentSongIndex() == 1 - -def test_firstAddedSongIsNotSelected(): - songAddedByUser = "/path/to/song" - songSetByCallback = None - - def testCallback(song): - nonlocal songSetByCallback - songSetByCallback = song - - uut = Playlist(testCallback) - uut.addSong(songAddedByUser) - - assert songSetByCallback == None - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - assert uut.getSongs() == [songAddedByUser] - -def test_invalidSongSelection(): - songAddedByUser = "/path/to/song" - songSetByCallback = None - - def testCallback(song): - nonlocal songSetByCallback - songSetByCallback = song - - uut = Playlist(testCallback) - assert songSetByCallback == None - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - - uut.setCurrentSong(10) - assert songSetByCallback == None - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - - uut.addSong(songAddedByUser) - uut.setCurrentSong(10) - assert songSetByCallback == None - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - assert uut.getSongs() == [songAddedByUser] - -def test_clearPlaylist(): - songAddedByUser = ["/path/to/song", "/path/to/second/song"] - - def dummy(index): - pass - - uut = Playlist(dummy) - for s in songAddedByUser: - uut.addSong(s) - uut.setCurrentSong(0) - - assert uut.getSongs() == songAddedByUser - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 - - uut.clear() - - assert uut.getSongs() == [] - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - -def test_nextSong(): - songAddedByUser = ["/path/to/song", "/path/to/second/song"] - - uut = Playlist(lambda index: None) - for s in songAddedByUser: - uut.addSong(s) - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - - uut.nextSong() - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 - - uut.nextSong() - assert uut.getCurrentSong() == songAddedByUser[1] - assert uut.getCurrentSongIndex() == 1 - - uut.nextSong() - assert uut.getCurrentSong() == songAddedByUser[1] - assert uut.getCurrentSongIndex() == 1 - -def test_previousSong(): - songAddedByUser = ["/path/to/song", "/path/to/second/song"] - - uut = Playlist(lambda index: None) - for s in songAddedByUser: - uut.addSong(s) - assert uut.getCurrentSong() == None - assert uut.getCurrentSongIndex() == None - - uut.previousSong() - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 - - uut.previousSong() - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 - - uut.setCurrentSong(1) - assert uut.getCurrentSong() == songAddedByUser[1] - assert uut.getCurrentSongIndex() == 1 - uut.previousSong() - assert uut.getCurrentSong() == songAddedByUser[0] - assert uut.getCurrentSongIndex() == 0 diff --git a/pre-commit b/pre-commit new file mode 100644 index 0000000..668071c --- /dev/null +++ b/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh + +make test +exit $? diff --git a/requirements.txt b/requirements.txt index 564f8a7..20ce1da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,4 @@ -pytest +-e solo-tool-project[dev] +-e cli-project[dev] +-e gui-project[dev] -sip -mido -python-rtmidi - -PyQt5>=5.6 - -python-vlc diff --git a/session_manager.py b/session_manager.py deleted file mode 100644 index 718e864..0000000 --- a/session_manager.py +++ /dev/null @@ -1,41 +0,0 @@ -import json - -class SessionManager: - def __init__(self, playlist, abController): - self._playlist = playlist - self._abController = abController - - def addSong(self, path): - self._playlist.addSong(path) - - def storeLimits(self, aLimit, bLimit): - self._abController.storeLimits(aLimit, bLimit) - - def loadSession(self, file): - jsonStr = file.read() - session = json.loads(jsonStr) - - self._playlist.clear() - self._abController.clear() - - for entry in session: - songPath = entry["path"] - abLimits = entry["ab_limits"] - self._playlist.addSong(songPath) - - if abLimits is not None: - for l in abLimits: - self._abController.storeLimits(l[0], l[1], songPath) - - def saveSession(self, file): - songs = self._playlist.getSongs() - session = list() - - for s in songs: - entry = { - "path": s, - "ab_limits" : self._abController.getStoredLimits(s) - } - session.append(entry) - - file.write(json.dumps(session)) diff --git a/session_manager_unittest.py b/session_manager_unittest.py deleted file mode 100644 index 169aa17..0000000 --- a/session_manager_unittest.py +++ /dev/null @@ -1,163 +0,0 @@ -from session_manager import SessionManager -from json import loads, dumps - -testSession = [ - { - "path" : "/path/to/another/song", - "ab_limits" : None - }, - { - "path" : "/path/to/song", - "ab_limits" : [ - [0.1, 0.2], - [0.3, 0.4] - ] - }, - { - "path" : "/path/to/something", - "ab_limits" : [ - [0.1, 0.2] - ] - } -] - -class PlaylistMock: - def __init__(self): - self.lastAddedSong = None - self.songs = list() - - def addSong(self, s): - self.songs.append(s) - self.lastAddedSong = s - - def getSongs(self): - return self.songs - - def clear(self): - self.__init__() - -class ABControllerMock: - def __init__(self): - self.limits = dict() - - def storeLimits(self, aLimit, bLimit, song="current"): - if song not in self.limits: - self.limits[song] = list() - self.limits[song].append([aLimit, bLimit]) - - def getStoredLimits(self, song): - return self.limits.get(song) - - def clear(self): - self.__init__() - -class MockFile: - def __init__(self, init=""): - self.contents = init - - def open(self, *args): - pass - - def write(self, s): - self.contents += s - - def read(self): - return self.contents - - -def test_addSongs(): - songs = [ - "/path/to/song", - "/path/to/another/song" - ] - - playlistMock = PlaylistMock() - uut = SessionManager(playlistMock, None) - - for s in songs: - uut.addSong(s) - assert playlistMock.lastAddedSong == s - -def test_addAbLimits(): - abLimits = [ - [0.1, 0.2], - [0.3, 0.4] - ] - - abControllerMock = ABControllerMock() - uut = SessionManager(None, abControllerMock) - - for i, ab in enumerate(abLimits): - uut.storeLimits(ab[0], ab[1]) - assert abControllerMock.limits["current"][i] == ab - -def test_loadSession(): - playlistMock = PlaylistMock() - abControllerMock = ABControllerMock() - uut = SessionManager(playlistMock, abControllerMock) - - sessionFile = MockFile(dumps(testSession)) - uut.loadSession(sessionFile) - - for i, entry in enumerate(testSession): - expectedSong = entry["path"] - expectedLimits = entry["ab_limits"] - loadedSong = playlistMock.songs[i] - loadedLimits = abControllerMock.limits.get(expectedSong) - - assert loadedSong == expectedSong - assert loadedLimits == expectedLimits - -def test_saveSession(): - playlistMock = PlaylistMock() - abControllerMock = ABControllerMock() - uut = SessionManager(playlistMock, abControllerMock) - - for i, entry in enumerate(testSession): - song = entry["path"] - playlistMock.addSong(song) - - abLimits = entry["ab_limits"] - if abLimits is not None: - for l in abLimits: - abControllerMock.storeLimits(l[0], l[1], song) - - sessionFile = MockFile() - uut.saveSession(sessionFile) - - savedSession = loads(sessionFile.read()) - assert savedSession == testSession - -def test_loadAndSaveEmptySession(): - playlistMock = PlaylistMock() - abControllerMock = ABControllerMock() - uut = SessionManager(playlistMock, abControllerMock) - - sessionFile = MockFile() - - uut.saveSession(sessionFile) - assert loads(sessionFile.read()) == list() - - uut.loadSession(sessionFile) - - songs = playlistMock.getSongs() - assert songs == list() - for s in songs: - assert abControllerMock.getStoredLimits(s) == None - -def test_loadSessionNotAdditive(): - playlistMock = PlaylistMock() - abControllerMock = ABControllerMock() - uut = SessionManager(playlistMock, abControllerMock) - - sessionFile = MockFile(dumps(testSession)) - uut.loadSession(sessionFile) - uut.loadSession(sessionFile) - - songs = playlistMock.getSongs() - assert len(songs) == len(set(songs)) - for s in songs: - abLimits = abControllerMock.getStoredLimits(s) - if abLimits is not None: - abLimitStr = [f"[{l[0]}, {l[1]}] " for l in abLimits] - assert len(abLimitStr) == len(set(abLimitStr)) diff --git a/solo-tool-project/pyproject.toml b/solo-tool-project/pyproject.toml new file mode 100644 index 0000000..36d4891 --- /dev/null +++ b/solo-tool-project/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "solo_tool" +authors = [ + { name = "Eddy Pedroni", email = "epedroni@pm.me" }, +] +description = "A library for dissecting guitar solos" +requires-python = ">=3.12" +dependencies = [ + "python-rtmidi", + "sip", + "mido", + "python-vlc" +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "pytest" +] diff --git a/solo-tool-project/src/solo_tool/__init__.py b/solo-tool-project/src/solo_tool/__init__.py new file mode 100644 index 0000000..e1e7777 --- /dev/null +++ b/solo-tool-project/src/solo_tool/__init__.py @@ -0,0 +1 @@ +from .solo_tool import SoloTool diff --git a/solo-tool-project/src/solo_tool/abcontroller.py b/solo-tool-project/src/solo_tool/abcontroller.py new file mode 100644 index 0000000..cec9fb2 --- /dev/null +++ b/solo-tool-project/src/solo_tool/abcontroller.py @@ -0,0 +1,82 @@ +from collections import namedtuple + +_AB = namedtuple("_AB", ["a", "b"]) + +class ABController: + def __init__(self, enabled=True, callback=None): + self._setPositionCallback = callback + self._limits = {} # dictionary of all songs + self._songLimits = None # list of limits for selected song + self._currentLimits = _AB(0.0, 0.0) # a/b positions of active limit + self._loadedIndex = None + self._enabled = enabled + + def _ensureSongExists(self, path): + if path not in self._limits: + self._limits[path] = [] + + def setCurrentSong(self, path): + self._ensureSongExists(path) + self._songLimits = self._limits[path] + self._loadedIndex = None + + def storeLimits(self, aLimit, bLimit, song=None): + if song is not None: + self._ensureSongExists(song) + songLimits = self._limits[song] + else: + songLimits = self._songLimits + + if songLimits is None: + return + + ab = _AB(aLimit, bLimit) + songLimits.append(ab) + + def loadLimits(self, index): + if not self._songLimits: + return + + if index >= 0 and index < len(self._songLimits): + self._currentLimits = self._songLimits[index] + self._loadedIndex = index + + def nextStoredAbLimits(self): + if self._loadedIndex is None: + nextIndex = 0 + else: + nextIndex = self._loadedIndex + 1 + self.loadLimits(nextIndex) + + def previousStoredAbLimits(self): + if self._loadedIndex is None: + previousIndex = 0 + else: + previousIndex = self._loadedIndex - 1 + self.loadLimits(previousIndex) + + def setLimits(self, aLimit, bLimit): + self._currentLimits = _AB(aLimit, bLimit) + self._loadedIndex = None + + def positionChanged(self, position): + if position > self._currentLimits.b and self._setPositionCallback and self._enabled: + self._setPositionCallback(self._currentLimits.a) + + def setEnable(self, enable): + self._enabled = enable + + def isEnabled(self): + return self._enabled + + def getStoredLimits(self, song): + return self._limits.get(song) + + def getCurrentLimits(self): + return self._currentLimits + + def getLoadedIndex(self): + return self._loadedIndex + + def clear(self): + self.__init__(enabled=self._enabled, callback=self._setPositionCallback) diff --git a/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py b/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py new file mode 100644 index 0000000..961127c --- /dev/null +++ b/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py @@ -0,0 +1,145 @@ +from .midi_wrapper_mido import MidiWrapper + +class MidiController: + DEVICE_NAME = "Launchpad Mini MIDI 1" + LIGHT_CONTROL_CHANNEL = 0 + LED_GREEN = 124 + LED_YELLOW = 126 + LED_RED = 3 + LED_OFF = 0 + BUTTON_MATRIX = [[x for x in range(y * 16, y * 16 + 8)] for y in range(0,8)] # address as [row][col] + + MIN_PLAYBACK_RATE = 0.5 + MAX_PLAYBACK_RATE = 1.2 + PLAYBACK_RATE_STEP = 0.1 + + MIN_PLAYBACK_VOLUME = 0.5 + MAX_PLAYBACK_VOLUME = 1.2 + PLAYBACK_VOLUME_STEP = 0.1 + + def __init__(self, soloTool, midiWrapperOverride=None): + self._soloTool = soloTool + if midiWrapperOverride is not None: + self._midiWrapper = midiWrapperOverride + else: + self._midiWrapper = MidiWrapper() + + self._registerHandlers() + self._soloTool.registerPlayingStateCallback(self._updatePlayPauseButton) + self._soloTool.registerPlaybackVolumeCallback(self._updateVolumeRow) + self._soloTool.registerPlaybackRateCallback(self._updateRateRow) + self._soloTool.registerAbLimitEnabledCallback(self._updateToggleAbLimitEnableButton) + + def _registerHandlers(self): + self._handlers = { + 96 : self._soloTool.stop, + 99 : self._soloTool.jumpToA, + 112 : self._playPause, + 101 : self._toggleAbLimitEnable, + 102 : self._soloTool.previousStoredAbLimits, + 103 : self._soloTool.nextStoredAbLimits, + 118 : self._soloTool.previousSong, + 119 : self._soloTool.nextSong + } + + for i in range(0, 8): + volume = round(MidiController.MIN_PLAYBACK_VOLUME + MidiController.PLAYBACK_VOLUME_STEP * i, 1) + self._handlers[i] = self._createSetPlaybackVolumeCallback(volume) + + for i, button in enumerate(range(16, 24)): + rate = round(MidiController.MIN_PLAYBACK_RATE + MidiController.PLAYBACK_RATE_STEP * i, 1) + self._handlers[button] = self._createSetPlaybackRateCallback(rate) + + def connect(self): + self._midiWrapper.setCallback(self._callback) + self._midiWrapper.connect(MidiController.DEVICE_NAME) + self._initialiseButtonLEDs() + + def _callback(self, msg): + if msg.type != "note_on" or msg.velocity < 127: + return + + if msg.note in self._handlers: + handler = self._handlers[msg.note]() + + def _playPause(self): + if self._soloTool.isPlaying(): + self._soloTool.pause() + else: + self._soloTool.play() + + def _toggleAbLimitEnable(self): + self._soloTool.setAbLimitEnable(not self._soloTool.isAbLimitEnabled()) + + def _updatePlayPauseButton(self, playing): + if playing: + self._setButtonLED(7, 0, MidiController.LED_GREEN) + else: + self._setButtonLED(7, 0, MidiController.LED_YELLOW) + + def _updateToggleAbLimitEnableButton(self, enabled): + if enabled: + self._setButtonLED(6, 5, MidiController.LED_GREEN) + else: + self._setButtonLED(6, 5, MidiController.LED_RED) + + def _updateVolumeRow(self, volume): + t1 = int(round(volume / MidiController.PLAYBACK_VOLUME_STEP, 1)) + t2 = int(round(MidiController.MIN_PLAYBACK_VOLUME / MidiController.PLAYBACK_VOLUME_STEP, 1)) + lastColumnLit = t1 - t2 + 1 + self._lightRowUntilColumn(0, lastColumnLit, MidiController.LED_GREEN) + + def _updateRateRow(self, rate): + t1 = int(round(rate / MidiController.PLAYBACK_RATE_STEP, 1)) + t2 = int(round(MidiController.MIN_PLAYBACK_RATE / MidiController.PLAYBACK_RATE_STEP, 1)) + lastColumnLit = t1 - t2 + 1 + self._lightRowUntilColumn(1, lastColumnLit, MidiController.LED_YELLOW) + + def _createSetPlaybackRateCallback(self, rate): + def f(): + self._soloTool.setPlaybackRate(rate) + return f + + def _createSetPlaybackVolumeCallback(self, volume): + def f(): + self._soloTool.setPlaybackVolume(volume) + return f + + def _setButtonLED(self, row, col, colour): + self._midiWrapper.sendMessage(MidiController.BUTTON_MATRIX[row][col], colour, MidiController.LIGHT_CONTROL_CHANNEL) + + def _lightRowUntilColumn(self, row, column, litColour): + colours = [litColour] * column + [MidiController.LED_OFF] * (8 - column) + for col in range(0, 8): + self._setButtonLED(row, col, colours[col]) + + def _allLEDsOff(self): + for row in range(0, 8): + for col in range(0, 8): + self._setButtonLED(row, col, MidiController.LED_OFF) + + def _initialiseButtonLEDs(self): + self._allLEDsOff() + + # volume buttons + self._updateVolumeRow(self._soloTool.getPlaybackVolume()) + + # playback rate buttons + self._updateRateRow(self._soloTool.getPlaybackRate()) + + # playback control + self._setButtonLED(6, 0, MidiController.LED_RED) + self._updatePlayPauseButton(self._soloTool.isPlaying()) + + # AB repeat toggle + self._updateToggleAbLimitEnableButton(self._soloTool.isAbLimitEnabled()) + + # AB control + self._setButtonLED(6, 3, MidiController.LED_YELLOW) + self._setButtonLED(6, 6, MidiController.LED_RED) + self._setButtonLED(6, 7, MidiController.LED_GREEN) + + # Song control + self._setButtonLED(7, 6, MidiController.LED_RED) + self._setButtonLED(7, 7, MidiController.LED_GREEN) + diff --git a/solo-tool-project/src/solo_tool/midi_wrapper_mido.py b/solo-tool-project/src/solo_tool/midi_wrapper_mido.py new file mode 100644 index 0000000..bf3aa85 --- /dev/null +++ b/solo-tool-project/src/solo_tool/midi_wrapper_mido.py @@ -0,0 +1,21 @@ +import mido + +class MidiWrapper: + def __init__(self): + self._inPort = None + self._outPort = None + self._callback = None + + def setCallback(self, callback): + self._callback = callback + + def connect(self, deviceName): + self._inPort = mido.open_input(deviceName) + self._inPort.callback = self._callback + self._outPort = mido.open_output(deviceName) + + def sendMessage(self, note, velocity, channel): + if self._outPort is not None: + msg = mido.Message('note_on', channel=channel, velocity=velocity, note=note) + self._outPort.send(msg) + diff --git a/solo-tool-project/src/solo_tool/notifier.py b/solo-tool-project/src/solo_tool/notifier.py new file mode 100644 index 0000000..9f445b6 --- /dev/null +++ b/solo-tool-project/src/solo_tool/notifier.py @@ -0,0 +1,29 @@ +class Notifier: + PLAYING_STATE_EVENT = 0 + PLAYBACK_VOLUME_EVENT = 1 + PLAYBACK_RATE_EVENT = 2 + CURRENT_SONG_EVENT = 3 + CURRENT_AB_EVENT = 4 + AB_LIMIT_ENABLED_EVENT = 5 + + def __init__(self, player): + self._callbacks = dict() + self._player = player + self._player.setPlayingStateChangedCallback(self._playingStateChangedCallback) + self._player.setPlaybackVolumeChangedCallback(self._playbackVolumeChangedCallback) + + def registerCallback(self, event, callback): + if event not in self._callbacks: + self._callbacks[event] = list() + self._callbacks[event].append(callback) + + def notify(self, event, value): + for callback in self._callbacks.get(event, list()): + callback(value) + + def _playingStateChangedCallback(self, *args): + self.notify(Notifier.PLAYING_STATE_EVENT, self._player.isPlaying()) + + def _playbackVolumeChangedCallback(self, *args): + self.notify(Notifier.PLAYBACK_VOLUME_EVENT, self._player.getPlaybackVolume()) + diff --git a/solo-tool-project/src/solo_tool/player_vlc.py b/solo-tool-project/src/solo_tool/player_vlc.py new file mode 100644 index 0000000..283102e --- /dev/null +++ b/solo-tool-project/src/solo_tool/player_vlc.py @@ -0,0 +1,55 @@ +import vlc + +class Player: + def __init__(self): + self._player = vlc.MediaPlayer() + + def play(self): + self._player.play() + + def stop(self): + self._player.stop() + + def pause(self): + self._player.pause() + + def isPlaying(self): + playing = self._player.is_playing() == 1 + return playing + + def setPlaybackRate(self, rate): + self._player.set_rate(rate) + + def getPlaybackRate(self): + return self._player.get_rate() + + def setPlaybackPosition(self, position): + self._player.set_position(position) + + def getPlaybackPosition(self): + return self._player.get_position() + + def setPlaybackVolume(self, volume): + self._player.audio_set_volume(int(volume * 100)) + + def getPlaybackVolume(self): + return self._player.audio_get_volume() / 100.0 + + def setCurrentSong(self, path): + self._player.stop() + media = vlc.Media(path) + self._player.set_media(media) + + def setPlayingStateChangedCallback(self, callback): + events = [ + vlc.EventType.MediaPlayerStopped, + vlc.EventType.MediaPlayerPlaying, + vlc.EventType.MediaPlayerPaused + ] + manager = self._player.event_manager() + for e in events: + manager.event_attach(e, callback) + + def setPlaybackVolumeChangedCallback(self, callback): + manager = self._player.event_manager() + manager.event_attach(vlc.EventType.MediaPlayerAudioVolume, callback) diff --git a/solo-tool-project/src/solo_tool/playlist.py b/solo-tool-project/src/solo_tool/playlist.py new file mode 100644 index 0000000..bbfd8f5 --- /dev/null +++ b/solo-tool-project/src/solo_tool/playlist.py @@ -0,0 +1,40 @@ +class Playlist: + def __init__(self, callback): + self._songList = list() + self._currentSong = None + self._setSongCallback = callback + + def addSong(self, path): + self._songList.append(path) + + def setCurrentSong(self, index): + if index >= 0 and index < len(self._songList): + self._currentSong = index + self._setSongCallback(self._songList[index]) + + def getCurrentSong(self): + index = self._currentSong + return self._songList[index] if index is not None else None + + def getCurrentSongIndex(self): + return self._currentSong + + def getSongs(self): + return self._songList + + def clear(self): + self.__init__(self._setSongCallback) + + def nextSong(self): + if self._currentSong is None: + nextSong = 0 + else: + nextSong = self._currentSong + 1 + self.setCurrentSong(nextSong) + + def previousSong(self): + if self._currentSong is None: + prevSong = 0 + else: + prevSong = self._currentSong - 1 + self.setCurrentSong(prevSong) diff --git a/solo-tool-project/src/solo_tool/session_manager.py b/solo-tool-project/src/solo_tool/session_manager.py new file mode 100644 index 0000000..718e864 --- /dev/null +++ b/solo-tool-project/src/solo_tool/session_manager.py @@ -0,0 +1,41 @@ +import json + +class SessionManager: + def __init__(self, playlist, abController): + self._playlist = playlist + self._abController = abController + + def addSong(self, path): + self._playlist.addSong(path) + + def storeLimits(self, aLimit, bLimit): + self._abController.storeLimits(aLimit, bLimit) + + def loadSession(self, file): + jsonStr = file.read() + session = json.loads(jsonStr) + + self._playlist.clear() + self._abController.clear() + + for entry in session: + songPath = entry["path"] + abLimits = entry["ab_limits"] + self._playlist.addSong(songPath) + + if abLimits is not None: + for l in abLimits: + self._abController.storeLimits(l[0], l[1], songPath) + + def saveSession(self, file): + songs = self._playlist.getSongs() + session = list() + + for s in songs: + entry = { + "path": s, + "ab_limits" : self._abController.getStoredLimits(s) + } + session.append(entry) + + file.write(json.dumps(session)) diff --git a/solo-tool-project/src/solo_tool/solo_tool.py b/solo-tool-project/src/solo_tool/solo_tool.py new file mode 100644 index 0000000..211babf --- /dev/null +++ b/solo-tool-project/src/solo_tool/solo_tool.py @@ -0,0 +1,164 @@ +import os + +from .playlist import Playlist +from .abcontroller import ABController +from .session_manager import SessionManager +from .notifier import Notifier +from .player_vlc import Player + +class SoloTool: + def __init__(self, playerOverride=None): + self._player = Player() if playerOverride is None else playerOverride + self._playlist = Playlist(self._playlistCallback) + self._abController = ABController(enabled=False, callback=self._abControllerCallback) + self._sessionManager = SessionManager(self._playlist, self._abController) + self._notifier = Notifier(self._player) + + def _playlistCallback(self, path): + self._player.setCurrentSong(path) + self._abController.setCurrentSong(path) + + def _abControllerCallback(self, position): + self._player.setPlaybackPosition(position) + + def tick(self): + position = self._player.getPlaybackPosition() + self._abController.positionChanged(position) + + def addSong(self, path): + if os.path.isfile(path): + self._sessionManager.addSong(path) + + def setSong(self, index): + previous = self._playlist.getCurrentSongIndex() + self._playlist.setCurrentSong(index) + new = self._playlist.getCurrentSongIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) + + def nextSong(self): + previous = self._playlist.getCurrentSongIndex() + self._playlist.nextSong() + new = self._playlist.getCurrentSongIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) + + def previousSong(self): + previous = self._playlist.getCurrentSongIndex() + self._playlist.previousSong() + new = self._playlist.getCurrentSongIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) + + def getSongs(self): + return self._playlist.getSongs() + + def storeAbLimits(self, aLimit, bLimit): + self._abController.storeLimits(aLimit, bLimit) + + def loadAbLimits(self, index): + previous = self._abController.getLoadedIndex() + self._abController.loadLimits(index) + new = self._abController.getLoadedIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) + + def setAbLimits(self, aLimit, bLimit): + self._abController.setLimits(aLimit, bLimit) + + def getStoredAbLimits(self): + currentSong = self._playlist.getCurrentSong() + if currentSong is not None: + return self._abController.getStoredLimits(currentSong) + else: + return list() + + def setAbLimitEnable(self, enable): + previous = self._abController.isEnabled() + self._abController.setEnable(enable) + new = self._abController.isEnabled() + if previous != new: + self._notifier.notify(Notifier.AB_LIMIT_ENABLED_EVENT, new) + + def isAbLimitEnabled(self): + return self._abController.isEnabled() + + def nextStoredAbLimits(self): + previous = self._abController.getLoadedIndex() + self._abController.nextStoredAbLimits() + new = self._abController.getLoadedIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) + + def previousStoredAbLimits(self): + previous = self._abController.getLoadedIndex() + self._abController.previousStoredAbLimits() + new = self._abController.getLoadedIndex() + if previous != new: + self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) + + def jumpToA(self): + a = self._abController.getCurrentLimits()[0] + # XXX assumes that player.setPlaybackPosition is thread-safe! + self._player.setPlaybackPosition(a) + + def loadSession(self, path): + with open(path, "r") as f: + self._sessionManager.loadSession(f) + + def saveSession(self, path): + with open(path, "w") as f: + self._sessionManager.saveSession(f) + + def play(self): + self._player.play() + + def pause(self): + self._player.pause() + + def stop(self): + self._player.stop() + + def isPlaying(self): + return self._player.isPlaying() + + def setPlaybackRate(self, rate): + previous = self._player.getPlaybackRate() + self._player.setPlaybackRate(rate) + new = self._player.getPlaybackRate() + if previous != new: + self._notifier.notify(Notifier.PLAYBACK_RATE_EVENT, new) + + def getPlaybackRate(self): + return self._player.getPlaybackRate() + + def setPlaybackPosition(self, position): + self._player.setPlaybackPosition(position) + + def getPlaybackPosition(self): + return self._player.getPlaybackPosition() + + def setPlaybackVolume(self, volume): + self._player.setPlaybackVolume(volume) + + def getPlaybackVolume(self): + return self._player.getPlaybackVolume() + + def registerPlayingStateCallback(self, callback): + self._notifier.registerCallback(Notifier.PLAYING_STATE_EVENT, callback) + + def registerPlaybackVolumeCallback(self, callback): + self._notifier.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, callback) + + def registerPlaybackRateCallback(self, callback): + self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback) + + def registerCurrentSongCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) + + def registerCurrentAbLimitsCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_AB_EVENT, callback) + + def registerAbLimitEnabledCallback(self, callback): + self._notifier.registerCallback(Notifier.AB_LIMIT_ENABLED_EVENT, callback) + diff --git a/solo-tool-project/test/abcontroller_unittest.py b/solo-tool-project/test/abcontroller_unittest.py new file mode 100644 index 0000000..d2b7d31 --- /dev/null +++ b/solo-tool-project/test/abcontroller_unittest.py @@ -0,0 +1,272 @@ +from solo_tool.abcontroller import ABController +from collections import namedtuple + +TCase = namedtuple("TCase", ["currentPosition", "requestedPosition"]) +AB = namedtuple("AB", ["a", "b"]) +abLimits = AB(0.2, 0.4) + +def _checkLimits(uut, tests): + requestedPosition = None + def callback(newPosition): + nonlocal requestedPosition + requestedPosition = newPosition + + originalCallback = uut._setPositionCallback + uut._setPositionCallback = callback + + for t in tests: + uut.positionChanged(t.currentPosition) + assert requestedPosition == t.requestedPosition + + uut._setPositionCallback = originalCallback + +def checkLimits(uut, aLimit, bLimit, fail=False): + tests = [ + TCase(aLimit - 0.1, None), + TCase(aLimit, None), + TCase(bLimit - 0.1, None), + TCase(bLimit, None), + TCase(bLimit + 0.1, aLimit if not fail else None) + ] + _checkLimits(uut, tests) + if not fail: + assert uut.getCurrentLimits()[0] == aLimit + assert uut.getCurrentLimits()[1] == bLimit + +def checkDefaultLimits(uut): + tests = [ + TCase(0.0, None), + TCase(0.1, 0.0), + TCase(0.5, 0.0) + ] + _checkLimits(uut, tests) + +def test_oneSetOfLimits(): + song = "/path/to/song" + + uut = ABController() + uut.setCurrentSong(song) + uut.storeLimits(abLimits.a, abLimits.b) + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + checkLimits(uut, abLimits.a, abLimits.b) + assert uut.getStoredLimits(song) == [abLimits] + +def test_multipleSetsOfLimits(): + song = "/path/to/song" + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5), + AB(0.0, 1.2) + ] + + uut = ABController() + uut.setCurrentSong(song) + for l in abLimits: + uut.storeLimits(l.a, l.b) + + for i, l in enumerate(abLimits): + uut.loadLimits(i) + assert uut.getLoadedIndex() == i + checkLimits(uut, l.a, l.b) + + assert uut.getStoredLimits(song) == abLimits + +def test_multipleSongs(): + songs = [ + "/path/to/song", + "/path/to/another/song" + ] + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5) + ] + uut = ABController() + for i, s in enumerate(songs): + uut.storeLimits(abLimits[i].a, abLimits[i].b, s) + + for i, s in enumerate(songs): + uut.setCurrentSong(s) + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + checkLimits(uut, abLimits[i].a, abLimits[i].b) + assert uut.getStoredLimits(s) == [abLimits[i]] + +def test_disableAbRepeat(): + song = "/path/to/song" + + uut = ABController() + uut.setCurrentSong(song) + uut.storeLimits(abLimits.a, abLimits.b) + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + assert uut.isEnabled() + + uut.setEnable(False) + checkLimits(uut, abLimits.a, abLimits.b, fail=True) + assert not uut.isEnabled() + + uut.setEnable(True) + checkLimits(uut, abLimits.a, abLimits.b) + assert uut.isEnabled() + +def test_storeLimitsToSpecificSong(): + song = "/path/to/song" + + uut = ABController() + uut.storeLimits(abLimits.a, abLimits.b, song) + uut.setCurrentSong(song) + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + checkLimits(uut, abLimits.a, abLimits.b) + +def test_storeLimitsWithoutCurrentSong(): + uut = ABController() + uut.storeLimits(abLimits.a, abLimits.b) + uut.loadLimits(0) + assert uut.getLoadedIndex() == None + + checkDefaultLimits(uut) + +def test_storeLimitsToSongWithoutCurrentSong(): + song = "/path/to/song" + uut = ABController() + uut.storeLimits(abLimits.a, abLimits.b, song) + uut.loadLimits(0) + assert uut.getLoadedIndex() == None + + checkDefaultLimits(uut) + + uut.setCurrentSong(song) + + checkDefaultLimits(uut) + + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + checkLimits(uut, abLimits.a, abLimits.b) + +def test_storeLimitsToCurrentSongButDoNotSetCurrentLimits(): + song = "/path/to/song" + uut = ABController() + uut.setCurrentSong(song) + uut.storeLimits(abLimits.a, abLimits.b) + assert uut.getLoadedIndex() == None + + checkDefaultLimits(uut) + + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + checkLimits(uut, abLimits.a, abLimits.b) + +def test_getStoredLimitsOfInexistentSong(): + song = "/path/to/song" + uut = ABController() + assert uut.getStoredLimits(song) == None + +def test_clearAbController(): + songsWithLimits = [ + ("/path/to/song", AB(0.2, 0.4)), + ("/path/to/another/song", AB(0.3, 0.5)) + ] + + uut = ABController() + for s in songsWithLimits: + uut.storeLimits(s[1].a, s[1].b, s[0]) + + for i, s in enumerate(songsWithLimits): + assert uut.getStoredLimits(s[0]) == [s[1]] + + uut.clear() + + for i, s in enumerate(songsWithLimits): + assert uut.getStoredLimits(s[0]) == None + +def test_setTemporaryLimits(): + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5), + AB(0.0, 1.2) + ] + uut = ABController() + + for l in abLimits: + uut.setLimits(l.a, l.b) + assert uut.getLoadedIndex() == None + checkLimits(uut, l.a, l.b) + +def test_setTemporaryLimitsWithCurrentSong(): + songLimits = AB(0.2, 0.4) + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5), + AB(0.0, 1.2) + ] + song = "/path/to/song" + uut = ABController() + uut.setCurrentSong(song) + uut.storeLimits(songLimits.a, songLimits.b) + uut.loadLimits(0) + assert uut.getLoadedIndex() == 0 + + for l in abLimits: + uut.setLimits(l.a, l.b) + checkLimits(uut, l.a, l.b) + +def test_defaultBehaviour(): + uut = ABController() + checkDefaultLimits(uut) + +def test_nextStoredLimit(): + song = "/path/to/song" + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5) + ] + + uut = ABController() + uut.setCurrentSong(song) + for l in abLimits: + uut.storeLimits(l.a, l.b) + + checkDefaultLimits(uut) + + uut.nextStoredAbLimits() + checkLimits(uut, abLimits[0].a, abLimits[0].b) + + uut.nextStoredAbLimits() + checkLimits(uut, abLimits[1].a, abLimits[1].b) + + uut.nextStoredAbLimits() + checkLimits(uut, abLimits[1].a, abLimits[1].b) + +def test_previousStoredLimit(): + song = "/path/to/song" + abLimits = [ + AB(0.2, 0.4), + AB(0.3, 0.5) + ] + + uut = ABController() + uut.setCurrentSong(song) + for l in abLimits: + uut.storeLimits(l.a, l.b) + + checkDefaultLimits(uut) + + uut.previousStoredAbLimits() + checkLimits(uut, abLimits[0].a, abLimits[0].b) + + uut.previousStoredAbLimits() + checkLimits(uut, abLimits[0].a, abLimits[0].b) + + uut.loadLimits(1) + checkLimits(uut, abLimits[1].a, abLimits[1].b) + + uut.previousStoredAbLimits() + checkLimits(uut, abLimits[0].a, abLimits[0].b) diff --git a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py new file mode 100644 index 0000000..8542aae --- /dev/null +++ b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py @@ -0,0 +1,387 @@ +import pytest + +from solo_tool.midi_controller_launchpad_mini import MidiController +from solo_tool.solo_tool import SoloTool +from player_mock import Player as PlayerMock + +LED_RED = 3 +LED_YELLOW = 126 +LED_GREEN = 124 +LED_OFF = 0 + +nextSongButton = 119 +previousSongButton = 118 +playPauseButton = 112 +stopButton = 96 +nextLimitButton = 103 +previousLimitButton = 102 +abToggleButton = 101 +jumpToAButton = 99 + +class MidiWrapperMock: + def __init__(self): + self.callback = None + self.connectedDevice = None + self.sentMessages = list() + + def setCallback(self, callback): + self.callback = callback + + def connect(self, deviceName): + self.connectedDevice = deviceName + + def sendMessage(self, note, velocity, channel): + self.sentMessages.append((note, velocity, channel)) + + def simulateInput(self, note, velocity=127, channel=0): + if self.callback is not None: + from mido import Message + msg = Message("note_on", note=note, velocity=velocity, channel=channel) + self.callback(msg) + + def getLatestMessage(self): + return self.sentMessages[-1] + +@pytest.fixture +def playerMock(): + return PlayerMock() + +@pytest.fixture +def soloTool(playerMock): + return SoloTool(playerMock) + +@pytest.fixture +def midiWrapperMock(): + return MidiWrapperMock() + +@pytest.fixture +def uut(soloTool, midiWrapperMock): + return MidiController(soloTool, midiWrapperMock) + +def test_connect(uut, midiWrapperMock): + expectedDevice = "Launchpad Mini MIDI 1" + uut.connect() + + assert midiWrapperMock.connectedDevice == expectedDevice + +def test_startStopAndPauseButtons(uut, midiWrapperMock, playerMock): + uut.connect() + + assert playerMock.state == PlayerMock.STOPPED + + midiWrapperMock.simulateInput(playPauseButton) + assert playerMock.state == PlayerMock.PLAYING + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + + midiWrapperMock.simulateInput(stopButton) + assert playerMock.state == PlayerMock.STOPPED + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + + midiWrapperMock.simulateInput(playPauseButton) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + + midiWrapperMock.simulateInput(playPauseButton) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + assert playerMock.state == PlayerMock.PAUSED + + midiWrapperMock.simulateInput(playPauseButton) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + assert playerMock.state == PlayerMock.PLAYING + + midiWrapperMock.simulateInput(playPauseButton) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + + midiWrapperMock.simulateInput(stopButton) + assert playerMock.state == PlayerMock.STOPPED + +def test_startPauseButtonLed(uut, midiWrapperMock, playerMock, soloTool): + uut.connect() + + assert playerMock.state == PlayerMock.STOPPED + + playerMock.state = PlayerMock.PLAYING + playerMock.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + + playerMock.state = PlayerMock.STOPPED + playerMock.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + + playerMock.state = PlayerMock.PAUSED + playerMock.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + + playerMock.state = PlayerMock.PLAYING + playerMock.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + +def test_abToggleButton(uut, midiWrapperMock, soloTool): + uut.connect() + + midiWrapperMock.simulateInput(abToggleButton) + assert soloTool.isAbLimitEnabled() + assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) + + midiWrapperMock.simulateInput(abToggleButton) + assert not soloTool.isAbLimitEnabled() + assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) + +def test_abToggleButtonLed(uut, midiWrapperMock, soloTool): + uut.connect() + + soloTool.setAbLimitEnable(True) + assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) + + soloTool.setAbLimitEnable(False) + assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) + +def test_jumpToAButton(uut, midiWrapperMock, soloTool, playerMock): + ab = (0.5, 0.6) + uut.connect() + + soloTool.setAbLimits(ab[0], ab[1]) + assert playerMock.position == 0.0 + + midiWrapperMock.simulateInput(jumpToAButton) + assert playerMock.position == ab[0] + +def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock): + songs = [ + "test.flac", + "test.mp3" + ] + for s in songs: + soloTool.addSong(s) + uut.connect() + + assert playerMock.currentSong == None + midiWrapperMock.simulateInput(nextSongButton) + assert playerMock.currentSong == songs[0] + + midiWrapperMock.simulateInput(nextSongButton) + assert playerMock.currentSong == songs[1] + + midiWrapperMock.simulateInput(previousSongButton) + assert playerMock.currentSong == songs[0] + + midiWrapperMock.simulateInput(previousSongButton) + assert playerMock.currentSong == songs[0] + +def test_previousAndNextAbButtons(uut, midiWrapperMock, soloTool, playerMock): + song = "test.flac" + abLimits = [ + [0.2, 0.4], + [0.1, 0.3] + ] + + soloTool.addSong(song) + soloTool.setSong(0) + soloTool.setAbLimitEnable(True) + + for ab in abLimits: + soloTool.storeAbLimits(ab[0], ab[1]) + + uut.connect() + + def checkLimit(aLimit, bLimit): + playerMock.position = bLimit - 0.1 + soloTool.tick() + assert playerMock.position == bLimit - 0.1 + + playerMock.position = bLimit + 0.1 + soloTool.tick() + assert playerMock.position == aLimit + + checkLimit(0.0, 0.0) + + midiWrapperMock.simulateInput(nextLimitButton) + checkLimit(abLimits[0][0], abLimits[0][1]) + + midiWrapperMock.simulateInput(nextLimitButton) + checkLimit(abLimits[1][0], abLimits[1][1]) + + midiWrapperMock.simulateInput(nextLimitButton) + checkLimit(abLimits[1][0], abLimits[1][1]) + + midiWrapperMock.simulateInput(previousLimitButton) + checkLimit(abLimits[0][0], abLimits[0][1]) + + midiWrapperMock.simulateInput(previousLimitButton) + checkLimit(abLimits[0][0], abLimits[0][1]) + +def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): + playbackRateOptions = { + 16 : (0.5, [LED_YELLOW] * 1 + [LED_OFF] * 7), + 17 : (0.6, [LED_YELLOW] * 2 + [LED_OFF] * 6), + 18 : (0.7, [LED_YELLOW] * 3 + [LED_OFF] * 5), + 19 : (0.8, [LED_YELLOW] * 4 + [LED_OFF] * 4), + 20 : (0.9, [LED_YELLOW] * 5 + [LED_OFF] * 3), + 21 : (1.0, [LED_YELLOW] * 6 + [LED_OFF] * 2), + 22 : (1.1, [LED_YELLOW] * 7 + [LED_OFF] * 1), + 23 : (1.2, [LED_YELLOW] * 8) + } + uut.connect() + assert playerMock.rate == 1.0 + + for t, button in enumerate(playbackRateOptions): + midiWrapperMock.sentMessages.clear() + + midiWrapperMock.simulateInput(button) + assert playerMock.rate == playbackRateOptions[button][0] + + for i, colour in enumerate(playbackRateOptions[button][1]): + assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) + +def test_playbackRateLeds(uut, midiWrapperMock, soloTool, playerMock): + playbackRateOptions = [ + (0.00, [LED_OFF] * 8), + (0.49, [LED_OFF] * 8), + + (0.50, [LED_YELLOW] * 1 + [LED_OFF] * 7), + (0.59, [LED_YELLOW] * 1 + [LED_OFF] * 7), + + (0.60, [LED_YELLOW] * 2 + [LED_OFF] * 6), + (0.69, [LED_YELLOW] * 2 + [LED_OFF] * 6), + + (0.70, [LED_YELLOW] * 3 + [LED_OFF] * 5), + (0.79, [LED_YELLOW] * 3 + [LED_OFF] * 5), + + (0.80, [LED_YELLOW] * 4 + [LED_OFF] * 4), + (0.89, [LED_YELLOW] * 4 + [LED_OFF] * 4), + + (0.90, [LED_YELLOW] * 5 + [LED_OFF] * 3), + (0.99, [LED_YELLOW] * 5 + [LED_OFF] * 3), + + (1.00, [LED_YELLOW] * 6 + [LED_OFF] * 2), + (1.09, [LED_YELLOW] * 6 + [LED_OFF] * 2), + + (1.10, [LED_YELLOW] * 7 + [LED_OFF] * 1), + (1.19, [LED_YELLOW] * 7 + [LED_OFF] * 1), + + (1.2, [LED_YELLOW] * 8), + (1.5, [LED_YELLOW] * 8) + ] + uut.connect() + assert playerMock.rate == 1.0 + + for t, (rate, leds) in enumerate(playbackRateOptions): + midiWrapperMock.sentMessages.clear() + + soloTool.setPlaybackRate(rate) + assert playerMock.rate == rate + + for i, colour in enumerate(leds): + assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) + +def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): + playbackVolumeOptions = { + 0 : (0.5, [LED_GREEN] * 1 + [LED_OFF] * 7), + 1 : (0.6, [LED_GREEN] * 2 + [LED_OFF] * 6), + 2 : (0.7, [LED_GREEN] * 3 + [LED_OFF] * 5), + 3 : (0.8, [LED_GREEN] * 4 + [LED_OFF] * 4), + 4 : (0.9, [LED_GREEN] * 5 + [LED_OFF] * 3), + 5 : (1.0, [LED_GREEN] * 6 + [LED_OFF] * 2), + 6 : (1.1, [LED_GREEN] * 7 + [LED_OFF] * 1), + 7 : (1.2, [LED_GREEN] * 8) + } + uut.connect() + assert playerMock.volume == 1.0 + + for t, button in enumerate(playbackVolumeOptions): + midiWrapperMock.sentMessages.clear() + + midiWrapperMock.simulateInput(button) + assert playerMock.volume == playbackVolumeOptions[button][0] + + for i, colour in enumerate(playbackVolumeOptions[button][1]): + assert midiWrapperMock.sentMessages[i] == (i, colour, 0) + +def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, playerMock): + playbackVolumeOptions = [ + (0.00, [LED_OFF] * 8), + (0.49, [LED_OFF] * 8), + + (0.50, [LED_GREEN] * 1 + [LED_OFF] * 7), + (0.59, [LED_GREEN] * 1 + [LED_OFF] * 7), + + (0.60, [LED_GREEN] * 2 + [LED_OFF] * 6), + (0.69, [LED_GREEN] * 2 + [LED_OFF] * 6), + + (0.70, [LED_GREEN] * 3 + [LED_OFF] * 5), + (0.79, [LED_GREEN] * 3 + [LED_OFF] * 5), + + (0.80, [LED_GREEN] * 4 + [LED_OFF] * 4), + (0.89, [LED_GREEN] * 4 + [LED_OFF] * 4), + + (0.90, [LED_GREEN] * 5 + [LED_OFF] * 3), + (0.99, [LED_GREEN] * 5 + [LED_OFF] * 3), + + (1.00, [LED_GREEN] * 6 + [LED_OFF] * 2), + (1.09, [LED_GREEN] * 6 + [LED_OFF] * 2), + + (1.10, [LED_GREEN] * 7 + [LED_OFF] * 1), + (1.19, [LED_GREEN] * 7 + [LED_OFF] * 1), + + (1.2, [LED_GREEN] * 8), + (1.5, [LED_GREEN] * 8) + ] + uut.connect() + assert playerMock.volume == 1.0 + + for t, (volume, leds) in enumerate(playbackVolumeOptions): + midiWrapperMock.sentMessages.clear() + + soloTool.setPlaybackVolume(volume) + assert playerMock.volume == volume + + for i, colour in enumerate(leds): + assert midiWrapperMock.sentMessages[i] == (i, colour, 0) + +def test_unassignedButton(uut, midiWrapperMock): + unassignedButton = 48 + uut.connect() + + # expect no crash + midiWrapperMock.simulateInput(unassignedButton) + # XXX would be better to assert that nothing changed in the solo tool + +def test_initializationMessages(uut, midiWrapperMock): + expectedMessages = set( + [(int(i / 8) * 16 + (i % 8), LED_OFF, 0) for i in range(0, 64)] + # clear all + [(i, LED_GREEN, 0) for i in range(0, 6)] + # volume row + [(i, LED_YELLOW, 0) for i in range(16, 22)] + # playback rate row + [ + (stopButton, LED_RED, 0), + (playPauseButton, LED_YELLOW, 0), + (abToggleButton, LED_RED, 0), + (jumpToAButton, LED_YELLOW, 0), + (previousLimitButton, LED_RED, 0), + (nextLimitButton, LED_GREEN, 0), + (previousSongButton, LED_RED, 0), + (nextSongButton, LED_GREEN, 0) + ] + ) + + uut.connect() + + sentMessagesSet = set(midiWrapperMock.sentMessages) + assert sentMessagesSet == expectedMessages + +def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, playerMock): + songs = [ + "test.flac", + "test.mp3" + ] + for s in songs: + soloTool.addSong(s) + uut.connect() + + soloTool.setSong(0) + soloTool.play() + assert playerMock.state == PlayerMock.PLAYING + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_GREEN, 0) + + soloTool.nextSong() + assert playerMock.state == PlayerMock.STOPPED + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0) + diff --git a/solo-tool-project/test/notifier_unittest.py b/solo-tool-project/test/notifier_unittest.py new file mode 100644 index 0000000..8a6e988 --- /dev/null +++ b/solo-tool-project/test/notifier_unittest.py @@ -0,0 +1,100 @@ +import pytest + +from solo_tool.notifier import Notifier +from player_mock import Player + +@pytest.fixture +def mockPlayer(): + return Player() + +@pytest.fixture +def uut(mockPlayer): + return Notifier(mockPlayer) + +def test_allEvents(uut): + def checkEvent(uut, event): + callbacks = 2 + calledFlags = [False] * callbacks + values = [None] * callbacks + + def createCallback(i): + def cb(value): + nonlocal calledFlags, values + calledFlags[i] = True + values[i] = value + + return cb + + for i in range(0, callbacks): + uut.registerCallback(event, createCallback(i)) + + assert not any(calledFlags) + uut.notify(event, 123) + assert all(calledFlags) + assert values == [123] * callbacks + + checkEvent(uut, Notifier.PLAYING_STATE_EVENT) + checkEvent(uut, Notifier.PLAYBACK_VOLUME_EVENT) + checkEvent(uut, Notifier.PLAYBACK_RATE_EVENT) + checkEvent(uut, Notifier.CURRENT_SONG_EVENT) + checkEvent(uut, Notifier.CURRENT_AB_EVENT) + checkEvent(uut, Notifier.AB_LIMIT_ENABLED_EVENT) + +def test_eventWithoutRegisteredCallbacks(uut): + uut.notify(Notifier.PLAYING_STATE_EVENT, 0) + # expect no crash + +def test_eventsWithMockPlayer(uut, mockPlayer): + def checkEvent(eventCode, simulateEvent, expectedValue): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerCallback(eventCode, callback) + + assert not called + simulateEvent() + assert called + assert receivedValue == expectedValue + + mockPlayer.state = 1 + mockPlayer.volume = 75 + + checkEvent(Notifier.PLAYING_STATE_EVENT, mockPlayer.simulatePlayingStateChanged, True) + checkEvent(Notifier.PLAYBACK_VOLUME_EVENT, mockPlayer.simulatePlaybackVolumeChanged, 75) + +def test_singleEventNotification(uut): + playingStateCalled = False + def playingStateCallback(value): + nonlocal playingStateCalled + playingStateCalled = True + + volumeCalled = False + def volumeCallback(value): + nonlocal volumeCalled + volumeCalled = True + + uut.registerCallback(Notifier.PLAYING_STATE_EVENT, playingStateCallback) + uut.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, volumeCallback) + + assert not playingStateCalled + assert not volumeCalled + + uut.notify(Notifier.PLAYING_STATE_EVENT, 0) + assert playingStateCalled + assert not volumeCalled + + playingStateCalled = False + + uut.notify(Notifier.PLAYBACK_VOLUME_EVENT, 0) + assert not playingStateCalled + assert volumeCalled + + volumeCalled = False + + uut.notify(Notifier.PLAYBACK_RATE_EVENT, 0) + assert not playingStateCalled + assert not volumeCalled diff --git a/solo-tool-project/test/player_mock.py b/solo-tool-project/test/player_mock.py new file mode 100644 index 0000000..3162e0f --- /dev/null +++ b/solo-tool-project/test/player_mock.py @@ -0,0 +1,71 @@ +class Player(): + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + + def __init__(self): + self.state = Player.STOPPED + self.rate = 1.0 + self.position = 0.0 + self.volume = 1.0 + self.currentSong = None + self.playingStateChangedCallback = None + self.playbackVolumeChangedCallback = None + + def play(self): + previousState = self.state + self.state = Player.PLAYING + if previousState != Player.PLAYING: + self.playingStateChangedCallback() + + def stop(self): + previousState = self.state + self.state = Player.STOPPED + if previousState != Player.STOPPED: + self.playingStateChangedCallback() + + def pause(self): + previousState = self.state + self.state = Player.PAUSED + if previousState != Player.PAUSED: + self.playingStateChangedCallback() + + def isPlaying(self): + return self.state == Player.PLAYING + + def setPlaybackRate(self, rate): + self.rate = rate + + def getPlaybackRate(self): + return self.rate + + def setPlaybackPosition(self, position): + self.position = position + + def getPlaybackPosition(self): + return self.position + + def setPlaybackVolume(self, volume): + changed = self.volume != volume + self.volume = volume + if changed: + self.playbackVolumeChangedCallback() + + def getPlaybackVolume(self): + return self.volume + + def setCurrentSong(self, path): + self.stop() + self.currentSong = path + + def setPlayingStateChangedCallback(self, callback): + self.playingStateChangedCallback = callback + + def simulatePlayingStateChanged(self): + self.playingStateChangedCallback() + + def setPlaybackVolumeChangedCallback(self, callback): + self.playbackVolumeChangedCallback = callback + + def simulatePlaybackVolumeChanged(self): + self.playbackVolumeChangedCallback() diff --git a/solo-tool-project/test/playlist_unittest.py b/solo-tool-project/test/playlist_unittest.py new file mode 100644 index 0000000..842ce51 --- /dev/null +++ b/solo-tool-project/test/playlist_unittest.py @@ -0,0 +1,148 @@ +from solo_tool.playlist import Playlist + +def test_addAndSelectOneSong(): + songAddedByUser = "/path/to/song" + songSetByCallback = None + + def testCallback(song): + nonlocal songSetByCallback + songSetByCallback = song + + uut = Playlist(testCallback) + uut.addSong(songAddedByUser) + uut.setCurrentSong(0) + + assert songAddedByUser == songSetByCallback + assert uut.getCurrentSong() == songAddedByUser + assert uut.getCurrentSongIndex() == 0 + assert uut.getSongs() == [songAddedByUser] + +def test_addTwoSongsAndSelectBoth(): + songAddedByUser = ["/path/to/song", "/path/to/second/song"] + songSetByCallback = None + + def testCallback(song): + nonlocal songSetByCallback + songSetByCallback = song + + uut = Playlist(testCallback) + uut.addSong(songAddedByUser[0]) + uut.addSong(songAddedByUser[1]) + assert uut.getSongs() == songAddedByUser + + uut.setCurrentSong(0) + assert songAddedByUser[0] == songSetByCallback + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 + + uut.setCurrentSong(1) + assert songAddedByUser[1] == songSetByCallback + assert uut.getCurrentSong() == songAddedByUser[1] + assert uut.getCurrentSongIndex() == 1 + +def test_firstAddedSongIsNotSelected(): + songAddedByUser = "/path/to/song" + songSetByCallback = None + + def testCallback(song): + nonlocal songSetByCallback + songSetByCallback = song + + uut = Playlist(testCallback) + uut.addSong(songAddedByUser) + + assert songSetByCallback == None + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + assert uut.getSongs() == [songAddedByUser] + +def test_invalidSongSelection(): + songAddedByUser = "/path/to/song" + songSetByCallback = None + + def testCallback(song): + nonlocal songSetByCallback + songSetByCallback = song + + uut = Playlist(testCallback) + assert songSetByCallback == None + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + + uut.setCurrentSong(10) + assert songSetByCallback == None + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + + uut.addSong(songAddedByUser) + uut.setCurrentSong(10) + assert songSetByCallback == None + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + assert uut.getSongs() == [songAddedByUser] + +def test_clearPlaylist(): + songAddedByUser = ["/path/to/song", "/path/to/second/song"] + + def dummy(index): + pass + + uut = Playlist(dummy) + for s in songAddedByUser: + uut.addSong(s) + uut.setCurrentSong(0) + + assert uut.getSongs() == songAddedByUser + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 + + uut.clear() + + assert uut.getSongs() == [] + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + +def test_nextSong(): + songAddedByUser = ["/path/to/song", "/path/to/second/song"] + + uut = Playlist(lambda index: None) + for s in songAddedByUser: + uut.addSong(s) + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + + uut.nextSong() + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 + + uut.nextSong() + assert uut.getCurrentSong() == songAddedByUser[1] + assert uut.getCurrentSongIndex() == 1 + + uut.nextSong() + assert uut.getCurrentSong() == songAddedByUser[1] + assert uut.getCurrentSongIndex() == 1 + +def test_previousSong(): + songAddedByUser = ["/path/to/song", "/path/to/second/song"] + + uut = Playlist(lambda index: None) + for s in songAddedByUser: + uut.addSong(s) + assert uut.getCurrentSong() == None + assert uut.getCurrentSongIndex() == None + + uut.previousSong() + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 + + uut.previousSong() + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 + + uut.setCurrentSong(1) + assert uut.getCurrentSong() == songAddedByUser[1] + assert uut.getCurrentSongIndex() == 1 + uut.previousSong() + assert uut.getCurrentSong() == songAddedByUser[0] + assert uut.getCurrentSongIndex() == 0 diff --git a/solo-tool-project/test/session_manager_unittest.py b/solo-tool-project/test/session_manager_unittest.py new file mode 100644 index 0000000..5468880 --- /dev/null +++ b/solo-tool-project/test/session_manager_unittest.py @@ -0,0 +1,163 @@ +from solo_tool.session_manager import SessionManager +from json import loads, dumps + +testSession = [ + { + "path" : "/path/to/another/song", + "ab_limits" : None + }, + { + "path" : "/path/to/song", + "ab_limits" : [ + [0.1, 0.2], + [0.3, 0.4] + ] + }, + { + "path" : "/path/to/something", + "ab_limits" : [ + [0.1, 0.2] + ] + } +] + +class PlaylistMock: + def __init__(self): + self.lastAddedSong = None + self.songs = list() + + def addSong(self, s): + self.songs.append(s) + self.lastAddedSong = s + + def getSongs(self): + return self.songs + + def clear(self): + self.__init__() + +class ABControllerMock: + def __init__(self): + self.limits = dict() + + def storeLimits(self, aLimit, bLimit, song="current"): + if song not in self.limits: + self.limits[song] = list() + self.limits[song].append([aLimit, bLimit]) + + def getStoredLimits(self, song): + return self.limits.get(song) + + def clear(self): + self.__init__() + +class MockFile: + def __init__(self, init=""): + self.contents = init + + def open(self, *args): + pass + + def write(self, s): + self.contents += s + + def read(self): + return self.contents + + +def test_addSongs(): + songs = [ + "/path/to/song", + "/path/to/another/song" + ] + + playlistMock = PlaylistMock() + uut = SessionManager(playlistMock, None) + + for s in songs: + uut.addSong(s) + assert playlistMock.lastAddedSong == s + +def test_addAbLimits(): + abLimits = [ + [0.1, 0.2], + [0.3, 0.4] + ] + + abControllerMock = ABControllerMock() + uut = SessionManager(None, abControllerMock) + + for i, ab in enumerate(abLimits): + uut.storeLimits(ab[0], ab[1]) + assert abControllerMock.limits["current"][i] == ab + +def test_loadSession(): + playlistMock = PlaylistMock() + abControllerMock = ABControllerMock() + uut = SessionManager(playlistMock, abControllerMock) + + sessionFile = MockFile(dumps(testSession)) + uut.loadSession(sessionFile) + + for i, entry in enumerate(testSession): + expectedSong = entry["path"] + expectedLimits = entry["ab_limits"] + loadedSong = playlistMock.songs[i] + loadedLimits = abControllerMock.limits.get(expectedSong) + + assert loadedSong == expectedSong + assert loadedLimits == expectedLimits + +def test_saveSession(): + playlistMock = PlaylistMock() + abControllerMock = ABControllerMock() + uut = SessionManager(playlistMock, abControllerMock) + + for i, entry in enumerate(testSession): + song = entry["path"] + playlistMock.addSong(song) + + abLimits = entry["ab_limits"] + if abLimits is not None: + for l in abLimits: + abControllerMock.storeLimits(l[0], l[1], song) + + sessionFile = MockFile() + uut.saveSession(sessionFile) + + savedSession = loads(sessionFile.read()) + assert savedSession == testSession + +def test_loadAndSaveEmptySession(): + playlistMock = PlaylistMock() + abControllerMock = ABControllerMock() + uut = SessionManager(playlistMock, abControllerMock) + + sessionFile = MockFile() + + uut.saveSession(sessionFile) + assert loads(sessionFile.read()) == list() + + uut.loadSession(sessionFile) + + songs = playlistMock.getSongs() + assert songs == list() + for s in songs: + assert abControllerMock.getStoredLimits(s) == None + +def test_loadSessionNotAdditive(): + playlistMock = PlaylistMock() + abControllerMock = ABControllerMock() + uut = SessionManager(playlistMock, abControllerMock) + + sessionFile = MockFile(dumps(testSession)) + uut.loadSession(sessionFile) + uut.loadSession(sessionFile) + + songs = playlistMock.getSongs() + assert len(songs) == len(set(songs)) + for s in songs: + abLimits = abControllerMock.getStoredLimits(s) + if abLimits is not None: + abLimitStr = [f"[{l[0]}, {l[1]}] " for l in abLimits] + assert len(abLimitStr) == len(set(abLimitStr)) diff --git a/solo-tool-project/test/solo_tool_integrationtest.py b/solo-tool-project/test/solo_tool_integrationtest.py new file mode 100644 index 0000000..5903abf --- /dev/null +++ b/solo-tool-project/test/solo_tool_integrationtest.py @@ -0,0 +1,594 @@ +import pathlib +import shutil +import pytest + +from solo_tool.solo_tool import SoloTool +from player_mock import Player as MockPlayer + +@pytest.fixture +def mockPlayer(): + return MockPlayer() + +@pytest.fixture +def uut(mockPlayer): + return SoloTool(mockPlayer) + +@pytest.fixture +def prepared_tmp_path(tmp_path): + testFiles = [ + "test.flac", + "test.mp3", + "test_session.json" + ] + for f in testFiles: + shutil.copy(pathlib.Path(f), tmp_path) + + return tmp_path + +def checkLimit(uut, mockPlayer, aLimit, bLimit): + mockPlayer.position = bLimit - 0.1 + uut.tick() + assert mockPlayer.position == bLimit - 0.1 + + mockPlayer.position = bLimit + 0.1 + uut.tick() + assert mockPlayer.position == aLimit + +def test_playerControls(uut, mockPlayer): + assert mockPlayer.state == MockPlayer.STOPPED + assert uut.isPlaying() == False + uut.play() + assert mockPlayer.state == MockPlayer.PLAYING + assert uut.isPlaying() == True + uut.pause() + assert mockPlayer.state == MockPlayer.PAUSED + assert uut.isPlaying() == False + uut.stop() + assert mockPlayer.state == MockPlayer.STOPPED + assert uut.isPlaying() == False + + assert mockPlayer.rate == 1.0 + uut.setPlaybackRate(0.5) + assert mockPlayer.rate == 0.5 + + assert mockPlayer.position == 0.0 + uut.setPlaybackPosition(0.5) + assert mockPlayer.position == 0.5 + + assert mockPlayer.volume == 1.0 + uut.setPlaybackVolume(0.5) + assert mockPlayer.volume == 0.5 + +def test_addAndSetSongs(uut, mockPlayer): + songs = [ + "test.flac", + "test.mp3" + ] + + for s in songs: + uut.addSong(s) + assert mockPlayer.currentSong == None + + for i, s in enumerate(songs): + uut.setSong(i) + assert mockPlayer.currentSong == songs[i] + +def test_nextAndPreviousSong(uut, mockPlayer): + songs = [ + "test.flac", + "test.mp3" + ] + + for s in songs: + uut.addSong(s) + assert mockPlayer.currentSong == None + + uut.nextSong() + assert mockPlayer.currentSong == songs[0] + + uut.previousSong() + assert mockPlayer.currentSong == songs[0] + + uut.nextSong() + assert mockPlayer.currentSong == songs[1] + + uut.nextSong() + assert mockPlayer.currentSong == songs[1] + +def test_addAndSetAbLimits(uut, mockPlayer): + song = "test.flac" + abLimits = [ + [0.2, 0.4], + [0.1, 0.3] + ] + + uut.addSong(song) + uut.setSong(0) + + for ab in abLimits: + uut.storeAbLimits(ab[0], ab[1]) + + mockPlayer.position = 0.0 + uut.tick() + assert mockPlayer.position == 0.0 + + mockPlayer.position = 0.5 + uut.tick() + assert mockPlayer.position == 0.5 + + uut.loadAbLimits(0) + + uut.tick() + assert mockPlayer.position == 0.5 + + uut.setAbLimitEnable(True) + + uut.tick() + assert mockPlayer.position == 0.2 + + uut.tick() + assert mockPlayer.position == 0.2 + + uut.loadAbLimits(1) + uut.tick() + assert mockPlayer.position == 0.2 + + mockPlayer.position = 0.8 + uut.tick() + assert mockPlayer.position == 0.1 + +def test_abLimitEnabledGetter(uut): + assert not uut.isAbLimitEnabled() + + uut.setAbLimitEnable(True) + assert uut.isAbLimitEnabled() + + uut.setAbLimitEnable(False) + assert not uut.isAbLimitEnabled() + +def test_multipleSongsAndAbLimits(uut, mockPlayer): + songs = [ + "test.flac", + "test.mp3" + ] + abLimits = [ + [0.2, 0.4], + [0.5, 0.7] + ] + + for s in songs: + uut.addSong(s) + + for i, l in enumerate(abLimits): + uut.setSong(i) + uut.storeAbLimits(l[0], l[1]) + + uut.setAbLimitEnable(True) + + for i, l in enumerate(abLimits): + uut.setSong(i) + uut.loadAbLimits(0) + + mockPlayer.position = l[0] + uut.tick() + assert mockPlayer.position == l[0] + + mockPlayer.position = l[1] + 0.1 + uut.tick() + assert mockPlayer.position == l[0] + +def test_storeAbLimitsWithoutSong(uut, mockPlayer): + song = "test.flac" + abLimit = [0.2, 0.4] + overflow = abLimit[1] + 0.1 + default = 0.0 + mockPlayer.position = overflow + uut.setAbLimitEnable(True) + + uut.storeAbLimits(abLimit[0], abLimit[1]) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.loadAbLimits(0) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.addSong(song) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.loadAbLimits(0) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.setSong(0) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.loadAbLimits(0) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.storeAbLimits(abLimit[0], abLimit[1]) + uut.tick() + assert mockPlayer.position == default + mockPlayer.position = overflow + + uut.loadAbLimits(0) + uut.tick() + assert mockPlayer.position == abLimit[0] + +def test_nextAndPreviousAbLimit(uut, mockPlayer): + song = "test.flac" + abLimits = [ + [0.2, 0.4], + [0.1, 0.3] + ] + + uut.addSong(song) + uut.setSong(0) + uut.setAbLimitEnable(True) + + for ab in abLimits: + uut.storeAbLimits(ab[0], ab[1]) + + checkLimit(uut, mockPlayer, 0.0, 0.0) # default limits + + uut.nextStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) + + uut.nextStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) + + uut.nextStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) + + uut.previousStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) + + uut.previousStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) + +def test_abLimitsWhenChangingSongs(uut, mockPlayer): + songs = [ + "test.flac", + "test.mp3" + ] + abLimits = [ + [0.2, 0.4], + [0.1, 0.3], + [0.7, 0.8] + ] + uut.setAbLimitEnable(True) + + for s in songs: + uut.addSong(s) + + uut.setSong(0) + for ab in abLimits: + uut.storeAbLimits(ab[0], ab[1]) + + uut.setSong(1) + uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) + + uut.setSong(0) + uut.loadAbLimits(len(abLimits) - 1) + checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) + + uut.setSong(1) + checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) + + uut.previousStoredAbLimits() + checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) + +def test_loadAndSaveSession(prepared_tmp_path): + mockPlayer = MockPlayer() + uut = SoloTool(mockPlayer) + + loadedSessionFile = prepared_tmp_path / "test_session.json" + savedSessionFile = prepared_tmp_path / "test_session_save.json" + + uut.loadSession(loadedSessionFile) + uut.saveSession(savedSessionFile) + + import json + with open(loadedSessionFile, "r") as f: + loadedSession = json.loads(f.read()) + + with open(savedSessionFile, "r") as f: + savedSession = json.loads(f.read()) + + assert loadedSession == savedSession + +def test_addInexistentFile(uut, mockPlayer): + song = "not/a/real/file" + + uut.addSong(song) + uut.setSong(0) + + assert mockPlayer.currentSong == None + +def test_getters(uut, mockPlayer): + song = "test.flac" + abLimit = [0.2, 0.4] + + uut.addSong(song) + uut.setSong(0) + uut.storeAbLimits(abLimit[0], abLimit[1]) + + assert uut.getSongs() == [song] + + limits = uut.getStoredAbLimits() + assert len(limits) == 1 + assert limits[0][0] == abLimit[0] + assert limits[0][1] == abLimit[1] + + mockPlayer.position = 0.8 + assert uut.getPlaybackPosition() == 0.8 + + mockPlayer.volume = 0.8 + assert uut.getPlaybackVolume() == 0.8 + + mockPlayer.rate = 0.5 + assert uut.getPlaybackRate() == 0.5 + +def test_setTemporaryLimits(uut, mockPlayer): + song = "test.flac" + abLimits = [ + [0.2, 0.4], + [0.1, 0.4] + ] + overflow = 0.5 + + uut.setAbLimitEnable(True) + mockPlayer.position = overflow + uut.addSong(song) + uut.setSong(0) + uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) + uut.loadAbLimits(0) + + uut.setAbLimits(abLimits[1][0], abLimits[1][1]) + uut.tick() + assert mockPlayer.position == abLimits[1][0] + +def test_jumpToA(uut, mockPlayer): + abLimits = (0.2, 0.4) + initialPosition = 0.8 + + mockPlayer.position = initialPosition + + uut.jumpToA() + assert mockPlayer.position == 0.0 # default AB controller A limit + + uut.setAbLimits(abLimits[0], abLimits[1]) + uut.jumpToA() + assert mockPlayer.position == abLimits[0] + +def test_playingStateNotification(uut, mockPlayer): + song = "test.flac" + uut.addSong(song) + uut.setSong(0) + + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerPlayingStateCallback(callback) + + assert mockPlayer.state == MockPlayer.STOPPED + assert not called + + uut.play() + assert called + assert receivedValue == True + called = False + uut.play() + assert not called + + uut.pause() + assert called + assert receivedValue == False + called = False + uut.pause() + assert not called + + uut.play() + assert called + assert receivedValue == True + called = False + + uut.stop() + assert called + assert receivedValue == False + called = False + uut.stop() + assert not called + +def test_playbackVolumeNotification(uut, mockPlayer): + song = "test.flac" + uut.addSong(song) + uut.setSong(0) + + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerPlaybackVolumeCallback(callback) + + assert not called + + uut.setPlaybackVolume(0.3) + assert called + assert receivedValue == 0.3 + called = False + + uut.setPlaybackVolume(0.3) + assert not called + +def test_playbackRateNotification(uut, mockPlayer): + song = "test.flac" + uut.addSong(song) + uut.setSong(0) + + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerPlaybackRateCallback(callback) + + assert not called + + uut.setPlaybackRate(0.5) + assert called + assert receivedValue == 0.5 + called = False + + uut.setPlaybackRate(0.5) + assert not called + +def test_currentSongNotification(uut): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerCurrentSongCallback(callback) + assert not called + + songs = [ + "test.flac", + "test.mp3" + ] + uut.addSong(songs[0]) + assert not called + + uut.setSong(0) + assert called + assert receivedValue == 0 + called = False + + uut.addSong(songs[1]) + assert not called + + uut.setSong(0) + assert not called + + uut.setSong(1) + assert called + assert receivedValue == 1 + called = False + + uut.previousSong() + assert called + assert receivedValue == 0 + called = False + + uut.previousSong() + assert not called + + uut.nextSong() + assert called + assert receivedValue == 1 + called = False + + uut.nextSong() + assert not called + +def test_currentAbNotification(uut): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerCurrentAbLimitsCallback(callback) + assert not called + + song = "test.flac" + uut.addSong(song) + uut.setSong(0) + + abLimits = [ + (0.2, 0.3), + (0.4, 0.5) + ] + uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) + assert not called + uut.storeAbLimits(abLimits[1][0], abLimits[1][1]) + assert not called + + uut.loadAbLimits(0) + assert called + assert receivedValue == 0 + called = False + + uut.loadAbLimits(0) + assert not called + + uut.loadAbLimits(1) + assert called + assert receivedValue == 1 + called = False + + uut.previousStoredAbLimits() + assert called + assert receivedValue == 0 + called = False + + uut.previousStoredAbLimits() + assert not called + + uut.nextStoredAbLimits() + assert called + assert receivedValue == 1 + called = False + + uut.nextStoredAbLimits() + assert not called + +def test_abLimitEnabledNotification(uut): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerAbLimitEnabledCallback(callback) + assert not called + + uut.setAbLimitEnable(False) + assert not called + assert receivedValue is None + + uut.setAbLimitEnable(True) + assert called + assert receivedValue == True + called = False + receivedValue = None + + uut.setAbLimitEnable(True) + assert not called + assert receivedValue is None + + uut.setAbLimitEnable(False) + assert called + assert receivedValue == False diff --git a/solo-tool-project/test/test.flac b/solo-tool-project/test/test.flac new file mode 100644 index 0000000..9164735 Binary files /dev/null and b/solo-tool-project/test/test.flac differ diff --git a/solo-tool-project/test/test.mp3 b/solo-tool-project/test/test.mp3 new file mode 100644 index 0000000..3c353b7 Binary files /dev/null and b/solo-tool-project/test/test.mp3 differ diff --git a/solo-tool-project/test/test_session.json b/solo-tool-project/test/test_session.json new file mode 100644 index 0000000..f48b792 --- /dev/null +++ b/solo-tool-project/test/test_session.json @@ -0,0 +1,13 @@ +[ + { + "path" : "test.flac", + "ab_limits" : null + }, + { + "path" : "test.mp3", + "ab_limits" : [ + [0.1, 0.2], + [0.3, 0.4] + ] + } +] diff --git a/solo_tool.py b/solo_tool.py deleted file mode 100644 index 6cb1a77..0000000 --- a/solo_tool.py +++ /dev/null @@ -1,164 +0,0 @@ -import os - -from playlist import Playlist -from abcontroller import ABController -from session_manager import SessionManager -from notifier import Notifier -from player_vlc import Player - -class SoloTool: - def __init__(self, playerOverride=None): - self._player = Player() if playerOverride is None else playerOverride - self._playlist = Playlist(self._playlistCallback) - self._abController = ABController(enabled=False, callback=self._abControllerCallback) - self._sessionManager = SessionManager(self._playlist, self._abController) - self._notifier = Notifier(self._player) - - def _playlistCallback(self, path): - self._player.setCurrentSong(path) - self._abController.setCurrentSong(path) - - def _abControllerCallback(self, position): - self._player.setPlaybackPosition(position) - - def tick(self): - position = self._player.getPlaybackPosition() - self._abController.positionChanged(position) - - def addSong(self, path): - if os.path.isfile(path): - self._sessionManager.addSong(path) - - def setSong(self, index): - previous = self._playlist.getCurrentSongIndex() - self._playlist.setCurrentSong(index) - new = self._playlist.getCurrentSongIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) - - def nextSong(self): - previous = self._playlist.getCurrentSongIndex() - self._playlist.nextSong() - new = self._playlist.getCurrentSongIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) - - def previousSong(self): - previous = self._playlist.getCurrentSongIndex() - self._playlist.previousSong() - new = self._playlist.getCurrentSongIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_SONG_EVENT, new) - - def getSongs(self): - return self._playlist.getSongs() - - def storeAbLimits(self, aLimit, bLimit): - self._abController.storeLimits(aLimit, bLimit) - - def loadAbLimits(self, index): - previous = self._abController.getLoadedIndex() - self._abController.loadLimits(index) - new = self._abController.getLoadedIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) - - def setAbLimits(self, aLimit, bLimit): - self._abController.setLimits(aLimit, bLimit) - - def getStoredAbLimits(self): - currentSong = self._playlist.getCurrentSong() - if currentSong is not None: - return self._abController.getStoredLimits(currentSong) - else: - return list() - - def setAbLimitEnable(self, enable): - previous = self._abController.isEnabled() - self._abController.setEnable(enable) - new = self._abController.isEnabled() - if previous != new: - self._notifier.notify(Notifier.AB_LIMIT_ENABLED_EVENT, new) - - def isAbLimitEnabled(self): - return self._abController.isEnabled() - - def nextStoredAbLimits(self): - previous = self._abController.getLoadedIndex() - self._abController.nextStoredAbLimits() - new = self._abController.getLoadedIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) - - def previousStoredAbLimits(self): - previous = self._abController.getLoadedIndex() - self._abController.previousStoredAbLimits() - new = self._abController.getLoadedIndex() - if previous != new: - self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) - - def jumpToA(self): - a = self._abController.getCurrentLimits()[0] - # XXX assumes that player.setPlaybackPosition is thread-safe! - self._player.setPlaybackPosition(a) - - def loadSession(self, path): - with open(path, "r") as f: - self._sessionManager.loadSession(f) - - def saveSession(self, path): - with open(path, "w") as f: - self._sessionManager.saveSession(f) - - def play(self): - self._player.play() - - def pause(self): - self._player.pause() - - def stop(self): - self._player.stop() - - def isPlaying(self): - return self._player.isPlaying() - - def setPlaybackRate(self, rate): - previous = self._player.getPlaybackRate() - self._player.setPlaybackRate(rate) - new = self._player.getPlaybackRate() - if previous != new: - self._notifier.notify(Notifier.PLAYBACK_RATE_EVENT, new) - - def getPlaybackRate(self): - return self._player.getPlaybackRate() - - def setPlaybackPosition(self, position): - self._player.setPlaybackPosition(position) - - def getPlaybackPosition(self): - return self._player.getPlaybackPosition() - - def setPlaybackVolume(self, volume): - self._player.setPlaybackVolume(volume) - - def getPlaybackVolume(self): - return self._player.getPlaybackVolume() - - def registerPlayingStateCallback(self, callback): - self._notifier.registerCallback(Notifier.PLAYING_STATE_EVENT, callback) - - def registerPlaybackVolumeCallback(self, callback): - self._notifier.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, callback) - - def registerPlaybackRateCallback(self, callback): - self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback) - - def registerCurrentSongCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) - - def registerCurrentAbLimitsCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_AB_EVENT, callback) - - def registerAbLimitEnabledCallback(self, callback): - self._notifier.registerCallback(Notifier.AB_LIMIT_ENABLED_EVENT, callback) - diff --git a/solo_tool_cli.py b/solo_tool_cli.py deleted file mode 100644 index 43e3a9d..0000000 --- a/solo_tool_cli.py +++ /dev/null @@ -1,56 +0,0 @@ -import sys -import time -import threading - -from solo_tool import SoloTool -from midi_controller_launchpad_mini import MidiController - -class SoloToolCLI: - def __init__(self, sessionJson, soloToolOverride=None, midiOverride=None, tickEnable=True): - self._soloTool = SoloTool() if soloToolOverride is None else soloToolOverride - self._soloTool.loadSession(sessionJson) - self._midiController = MidiController(self._soloTool) if midiOverride is None else midiOverride - self._commands = { - "song" : self._song, - "midi" : self._midi - } - if tickEnable: - self._tick() - - def input(self, commandString): - split = commandString.strip().split(" ") - if split[0] in self._commands: - self._commands[split[0]](split[1:]) - - def _song(self, args): - if len(args) > 0: - self._soloTool.setSong(int(args[0])) - else: - songs = self._soloTool.getSongs() - print("Songs:") - for i, s in enumerate(songs): - print(f" {i} {s}") - - def _midi(self, args): - if len(args) > 0 and args[0] == "connect": - print("Connecting to MIDI device...") - self._midiController.connect() - else: - print("Supported device: Novation Launchpad Mini MkII") - - def _tick(self): - self._soloTool.tick() - threading.Timer(0.1, self._tick).start() - -def main(args): - if len(args) == 0: - print("Please provide path to session file") - sys.exit(1) - - soloToolCli = SoloToolCLI(args[0]) - while(True): - commandString = input("> ") - soloToolCli.input(commandString) - -if __name__ == '__main__': - main(sys.argv[1:]) diff --git a/solo_tool_cli_integrationtest.py b/solo_tool_cli_integrationtest.py deleted file mode 100644 index 25ef131..0000000 --- a/solo_tool_cli_integrationtest.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest -import io -from contextlib import redirect_stdout - -from solo_tool_cli import SoloToolCLI -from solo_tool import SoloTool -from player_mock import Player - -class MockMidiController: - def __init__(self, soloTool): - self.connected = False - - def connect(self): - self.connected = True - -@pytest.fixture -def mockPlayer(): - return Player() - -@pytest.fixture -def soloTool(mockPlayer): - return SoloTool(playerOverride=mockPlayer) - -@pytest.fixture -def mockMidi(soloTool): - return MockMidiController(soloTool) - -@pytest.fixture -def uut(soloTool, mockMidi): - return SoloToolCLI("test_session.json", soloToolOverride=soloTool, midiOverride=mockMidi, tickEnable=False) - -def test_songSelection(uut, soloTool, mockPlayer): - expectedOutput = """\ -Songs: - 0 test.flac - 1 test.mp3 -Songs: - 0 test.flac - 1 test.mp3 -Songs: - 0 test.flac - 1 test.mp3 -""" - - with io.StringIO() as buf, redirect_stdout(buf): - uut.input("song") - assert mockPlayer.currentSong == None - - uut.input("song 0") - assert mockPlayer.currentSong == "test.flac" - - uut.input("song ") - - uut.input("song 1") - assert mockPlayer.currentSong == "test.mp3" - - uut.input("song") - - assert buf.getvalue() == expectedOutput - -def test_connectMidi(uut, mockMidi): - expectedOutput = """\ -Supported device: Novation Launchpad Mini MkII -Connecting to MIDI device... -""" - - with io.StringIO() as buf, redirect_stdout(buf): - uut.input("midi") - assert not mockMidi.connected - - uut.input("midi connect") - assert mockMidi.connected - - assert buf.getvalue() == expectedOutput - diff --git a/solo_tool_integrationtest.py b/solo_tool_integrationtest.py deleted file mode 100644 index 5731eac..0000000 --- a/solo_tool_integrationtest.py +++ /dev/null @@ -1,594 +0,0 @@ -import pathlib -import shutil -import pytest - -from solo_tool import SoloTool -from player_mock import Player as MockPlayer - -@pytest.fixture -def mockPlayer(): - return MockPlayer() - -@pytest.fixture -def uut(mockPlayer): - return SoloTool(mockPlayer) - -@pytest.fixture -def prepared_tmp_path(tmp_path): - testFiles = [ - "test.flac", - "test.mp3", - "test_session.json" - ] - for f in testFiles: - shutil.copy(pathlib.Path(f), tmp_path) - - return tmp_path - -def checkLimit(uut, mockPlayer, aLimit, bLimit): - mockPlayer.position = bLimit - 0.1 - uut.tick() - assert mockPlayer.position == bLimit - 0.1 - - mockPlayer.position = bLimit + 0.1 - uut.tick() - assert mockPlayer.position == aLimit - -def test_playerControls(uut, mockPlayer): - assert mockPlayer.state == MockPlayer.STOPPED - assert uut.isPlaying() == False - uut.play() - assert mockPlayer.state == MockPlayer.PLAYING - assert uut.isPlaying() == True - uut.pause() - assert mockPlayer.state == MockPlayer.PAUSED - assert uut.isPlaying() == False - uut.stop() - assert mockPlayer.state == MockPlayer.STOPPED - assert uut.isPlaying() == False - - assert mockPlayer.rate == 1.0 - uut.setPlaybackRate(0.5) - assert mockPlayer.rate == 0.5 - - assert mockPlayer.position == 0.0 - uut.setPlaybackPosition(0.5) - assert mockPlayer.position == 0.5 - - assert mockPlayer.volume == 1.0 - uut.setPlaybackVolume(0.5) - assert mockPlayer.volume == 0.5 - -def test_addAndSetSongs(uut, mockPlayer): - songs = [ - "test.flac", - "test.mp3" - ] - - for s in songs: - uut.addSong(s) - assert mockPlayer.currentSong == None - - for i, s in enumerate(songs): - uut.setSong(i) - assert mockPlayer.currentSong == songs[i] - -def test_nextAndPreviousSong(uut, mockPlayer): - songs = [ - "test.flac", - "test.mp3" - ] - - for s in songs: - uut.addSong(s) - assert mockPlayer.currentSong == None - - uut.nextSong() - assert mockPlayer.currentSong == songs[0] - - uut.previousSong() - assert mockPlayer.currentSong == songs[0] - - uut.nextSong() - assert mockPlayer.currentSong == songs[1] - - uut.nextSong() - assert mockPlayer.currentSong == songs[1] - -def test_addAndSetAbLimits(uut, mockPlayer): - song = "test.flac" - abLimits = [ - [0.2, 0.4], - [0.1, 0.3] - ] - - uut.addSong(song) - uut.setSong(0) - - for ab in abLimits: - uut.storeAbLimits(ab[0], ab[1]) - - mockPlayer.position = 0.0 - uut.tick() - assert mockPlayer.position == 0.0 - - mockPlayer.position = 0.5 - uut.tick() - assert mockPlayer.position == 0.5 - - uut.loadAbLimits(0) - - uut.tick() - assert mockPlayer.position == 0.5 - - uut.setAbLimitEnable(True) - - uut.tick() - assert mockPlayer.position == 0.2 - - uut.tick() - assert mockPlayer.position == 0.2 - - uut.loadAbLimits(1) - uut.tick() - assert mockPlayer.position == 0.2 - - mockPlayer.position = 0.8 - uut.tick() - assert mockPlayer.position == 0.1 - -def test_abLimitEnabledGetter(uut): - assert not uut.isAbLimitEnabled() - - uut.setAbLimitEnable(True) - assert uut.isAbLimitEnabled() - - uut.setAbLimitEnable(False) - assert not uut.isAbLimitEnabled() - -def test_multipleSongsAndAbLimits(uut, mockPlayer): - songs = [ - "test.flac", - "test.mp3" - ] - abLimits = [ - [0.2, 0.4], - [0.5, 0.7] - ] - - for s in songs: - uut.addSong(s) - - for i, l in enumerate(abLimits): - uut.setSong(i) - uut.storeAbLimits(l[0], l[1]) - - uut.setAbLimitEnable(True) - - for i, l in enumerate(abLimits): - uut.setSong(i) - uut.loadAbLimits(0) - - mockPlayer.position = l[0] - uut.tick() - assert mockPlayer.position == l[0] - - mockPlayer.position = l[1] + 0.1 - uut.tick() - assert mockPlayer.position == l[0] - -def test_storeAbLimitsWithoutSong(uut, mockPlayer): - song = "test.flac" - abLimit = [0.2, 0.4] - overflow = abLimit[1] + 0.1 - default = 0.0 - mockPlayer.position = overflow - uut.setAbLimitEnable(True) - - uut.storeAbLimits(abLimit[0], abLimit[1]) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.loadAbLimits(0) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.addSong(song) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.loadAbLimits(0) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.setSong(0) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.loadAbLimits(0) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.storeAbLimits(abLimit[0], abLimit[1]) - uut.tick() - assert mockPlayer.position == default - mockPlayer.position = overflow - - uut.loadAbLimits(0) - uut.tick() - assert mockPlayer.position == abLimit[0] - -def test_nextAndPreviousAbLimit(uut, mockPlayer): - song = "test.flac" - abLimits = [ - [0.2, 0.4], - [0.1, 0.3] - ] - - uut.addSong(song) - uut.setSong(0) - uut.setAbLimitEnable(True) - - for ab in abLimits: - uut.storeAbLimits(ab[0], ab[1]) - - checkLimit(uut, mockPlayer, 0.0, 0.0) # default limits - - uut.nextStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) - - uut.nextStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) - - uut.nextStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) - - uut.previousStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) - - uut.previousStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) - -def test_abLimitsWhenChangingSongs(uut, mockPlayer): - songs = [ - "test.flac", - "test.mp3" - ] - abLimits = [ - [0.2, 0.4], - [0.1, 0.3], - [0.7, 0.8] - ] - uut.setAbLimitEnable(True) - - for s in songs: - uut.addSong(s) - - uut.setSong(0) - for ab in abLimits: - uut.storeAbLimits(ab[0], ab[1]) - - uut.setSong(1) - uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) - - uut.setSong(0) - uut.loadAbLimits(len(abLimits) - 1) - checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) - - uut.setSong(1) - checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) - - uut.previousStoredAbLimits() - checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) - -def test_loadAndSaveSession(prepared_tmp_path): - mockPlayer = MockPlayer() - uut = SoloTool(mockPlayer) - - loadedSessionFile = prepared_tmp_path / "test_session.json" - savedSessionFile = prepared_tmp_path / "test_session_save.json" - - uut.loadSession(loadedSessionFile) - uut.saveSession(savedSessionFile) - - import json - with open(loadedSessionFile, "r") as f: - loadedSession = json.loads(f.read()) - - with open(savedSessionFile, "r") as f: - savedSession = json.loads(f.read()) - - assert loadedSession == savedSession - -def test_addInexistentFile(uut, mockPlayer): - song = "not/a/real/file" - - uut.addSong(song) - uut.setSong(0) - - assert mockPlayer.currentSong == None - -def test_getters(uut, mockPlayer): - song = "test.flac" - abLimit = [0.2, 0.4] - - uut.addSong(song) - uut.setSong(0) - uut.storeAbLimits(abLimit[0], abLimit[1]) - - assert uut.getSongs() == [song] - - limits = uut.getStoredAbLimits() - assert len(limits) == 1 - assert limits[0][0] == abLimit[0] - assert limits[0][1] == abLimit[1] - - mockPlayer.position = 0.8 - assert uut.getPlaybackPosition() == 0.8 - - mockPlayer.volume = 0.8 - assert uut.getPlaybackVolume() == 0.8 - - mockPlayer.rate = 0.5 - assert uut.getPlaybackRate() == 0.5 - -def test_setTemporaryLimits(uut, mockPlayer): - song = "test.flac" - abLimits = [ - [0.2, 0.4], - [0.1, 0.4] - ] - overflow = 0.5 - - uut.setAbLimitEnable(True) - mockPlayer.position = overflow - uut.addSong(song) - uut.setSong(0) - uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) - uut.loadAbLimits(0) - - uut.setAbLimits(abLimits[1][0], abLimits[1][1]) - uut.tick() - assert mockPlayer.position == abLimits[1][0] - -def test_jumpToA(uut, mockPlayer): - abLimits = (0.2, 0.4) - initialPosition = 0.8 - - mockPlayer.position = initialPosition - - uut.jumpToA() - assert mockPlayer.position == 0.0 # default AB controller A limit - - uut.setAbLimits(abLimits[0], abLimits[1]) - uut.jumpToA() - assert mockPlayer.position == abLimits[0] - -def test_playingStateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.setSong(0) - - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerPlayingStateCallback(callback) - - assert mockPlayer.state == MockPlayer.STOPPED - assert not called - - uut.play() - assert called - assert receivedValue == True - called = False - uut.play() - assert not called - - uut.pause() - assert called - assert receivedValue == False - called = False - uut.pause() - assert not called - - uut.play() - assert called - assert receivedValue == True - called = False - - uut.stop() - assert called - assert receivedValue == False - called = False - uut.stop() - assert not called - -def test_playbackVolumeNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.setSong(0) - - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerPlaybackVolumeCallback(callback) - - assert not called - - uut.setPlaybackVolume(0.3) - assert called - assert receivedValue == 0.3 - called = False - - uut.setPlaybackVolume(0.3) - assert not called - -def test_playbackRateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.setSong(0) - - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerPlaybackRateCallback(callback) - - assert not called - - uut.setPlaybackRate(0.5) - assert called - assert receivedValue == 0.5 - called = False - - uut.setPlaybackRate(0.5) - assert not called - -def test_currentSongNotification(uut): - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerCurrentSongCallback(callback) - assert not called - - songs = [ - "test.flac", - "test.mp3" - ] - uut.addSong(songs[0]) - assert not called - - uut.setSong(0) - assert called - assert receivedValue == 0 - called = False - - uut.addSong(songs[1]) - assert not called - - uut.setSong(0) - assert not called - - uut.setSong(1) - assert called - assert receivedValue == 1 - called = False - - uut.previousSong() - assert called - assert receivedValue == 0 - called = False - - uut.previousSong() - assert not called - - uut.nextSong() - assert called - assert receivedValue == 1 - called = False - - uut.nextSong() - assert not called - -def test_currentAbNotification(uut): - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerCurrentAbLimitsCallback(callback) - assert not called - - song = "test.flac" - uut.addSong(song) - uut.setSong(0) - - abLimits = [ - (0.2, 0.3), - (0.4, 0.5) - ] - uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) - assert not called - uut.storeAbLimits(abLimits[1][0], abLimits[1][1]) - assert not called - - uut.loadAbLimits(0) - assert called - assert receivedValue == 0 - called = False - - uut.loadAbLimits(0) - assert not called - - uut.loadAbLimits(1) - assert called - assert receivedValue == 1 - called = False - - uut.previousStoredAbLimits() - assert called - assert receivedValue == 0 - called = False - - uut.previousStoredAbLimits() - assert not called - - uut.nextStoredAbLimits() - assert called - assert receivedValue == 1 - called = False - - uut.nextStoredAbLimits() - assert not called - -def test_abLimitEnabledNotification(uut): - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerAbLimitEnabledCallback(callback) - assert not called - - uut.setAbLimitEnable(False) - assert not called - assert receivedValue is None - - uut.setAbLimitEnable(True) - assert called - assert receivedValue == True - called = False - receivedValue = None - - uut.setAbLimitEnable(True) - assert not called - assert receivedValue is None - - uut.setAbLimitEnable(False) - assert called - assert receivedValue == False diff --git a/solo_tool_qt.py b/solo_tool_qt.py deleted file mode 100644 index 86557a7..0000000 --- a/solo_tool_qt.py +++ /dev/null @@ -1,258 +0,0 @@ -import sys - -from PyQt5.QtGui import * -from PyQt5.QtWidgets import * -from PyQt5.QtCore import * -from MainWindow import Ui_MainWindow - -from solo_tool import SoloTool -from midi_controller_launchpad_mini import MidiController - -POSITION_FACTOR = 100000 -RATE_FACTOR = 10 -UI_REFRESH_PERIOD_MS = 500 - -CHANGE_GUI = 0 -CHANGE_INTERNAL = 1 - -class PlaylistModel(QAbstractListModel): - def __init__(self, soloTool, *args, **kwargs): - super(PlaylistModel, self).__init__(*args, **kwargs) - self.soloTool = soloTool - - def data(self, index, role): - if role == Qt.DisplayRole: - from pathlib import Path - path = Path(self.soloTool.getSongs()[index.row()]) - return path.name - - def rowCount(self, index): - return len(self.soloTool.getSongs()) - -class ABListModel(QAbstractListModel): - def __init__(self, soloTool, *args, **kwargs): - super(ABListModel, self).__init__(*args, **kwargs) - self.soloTool = soloTool - - def data(self, index, role): - if role == Qt.DisplayRole: - ab = self.soloTool.getStoredAbLimits()[index.row()] - return f"{ab[0]} - {ab[1]}" - - def rowCount(self, index): - return len(self.soloTool.getStoredAbLimits()) - -class MainWindow(QMainWindow, Ui_MainWindow): - songChangeSignal = pyqtSignal(int) - abLimitsChangeSignal = pyqtSignal(int) - - def __init__(self, *args, **kwargs): - super(MainWindow, self).__init__(*args, **kwargs) - - self.setupUi(self) - - self.timer = QTimer(self) - self.timer.setInterval(UI_REFRESH_PERIOD_MS) - self.timer.timeout.connect(self.timerCallback) - - self.soloTool = SoloTool() - self.midiController = MidiController(self.soloTool) - - self.playlistModel = PlaylistModel(self.soloTool) - self.songListView.setModel(self.playlistModel) - self.songListView.selectionModel().selectionChanged.connect(self.playlistSelectionChanged) - self.songChangePending = None - self.songChangeSignal.connect(self.currentSongChanged) - self.soloTool.registerCurrentSongCallback(self.songChangeSignal.emit) - - self.abListModel = ABListModel(self.soloTool) - self.abListView.setModel(self.abListModel) - self.abListView.selectionModel().selectionChanged.connect(self.abListSelectionChanged) - self.abLimitsChangePending = None - self.abLimitsChangeSignal.connect(self.currentAbLimitsChanged) - self.soloTool.registerCurrentAbLimitsCallback(self.abLimitsChangeSignal.emit) - - self.songSlider.setMaximum(POSITION_FACTOR) - self.songSlider.sliderPressed.connect(self.songSliderPressed) - self.songSlider.sliderReleased.connect(self.songSliderReleased) - - self.aSlider.setMaximum(POSITION_FACTOR) - self.aSlider.sliderReleased.connect(self.abSliderReleased) - self.bSlider.setMaximum(POSITION_FACTOR) - self.bSlider.sliderReleased.connect(self.abSliderReleased) - - self.rateSlider.setRange(int(0.5 * RATE_FACTOR), int(1.2 * RATE_FACTOR)) - self.rateSlider.setSingleStep(int(0.1 * RATE_FACTOR)) - self.rateSlider.setValue(int(1.0 * RATE_FACTOR)) - self.rateSlider.sliderReleased.connect(self.rateSliderReleased) - - self.playButton.pressed.connect(self.soloTool.play) - self.pauseButton.pressed.connect(self.soloTool.pause) - self.storeAbButton.pressed.connect(self.storeAbLimits) - self.setAButton.pressed.connect(self.setA) - self.setBButton.pressed.connect(self.setB) - self.saveSessionButton.pressed.connect(self.saveSession) - self.loadSessionButton.pressed.connect(self.loadSession) - self.addSongButton.pressed.connect(self.addSong) - self.abRepeatCheckBox.clicked.connect(self.toggleAbRepeat) - self.initMidiButton.pressed.connect(self.initMidi) - - self.timer.start() - - if len(sys.argv) > 1: - self.loadSession(sys.argv[1]) - - self.show() - - def timerCallback(self): - position = self.soloTool.getPlaybackPosition() * POSITION_FACTOR - self.songSlider.setValue(int(position)) - self.soloTool.tick() - - def addSong(self): - path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "mp3 Audio (*.mp3);FLAC audio (*.flac);All files (*.*)") - if path: - self.soloTool.addSong(path) - self.playlistModel.layoutChanged.emit() - - def storeAbLimits(self): - a = self.aSlider.value() / float(POSITION_FACTOR) - b = self.bSlider.value() / float(POSITION_FACTOR) - self.soloTool.storeAbLimits(a, b) - self.abListModel.layoutChanged.emit() - - def setA(self): - position = self.songSlider.value() - self.aSlider.setValue(position) - self.abSliderReleased() - - def setB(self): - position = self.songSlider.value() - self.bSlider.setValue(position) - self.abSliderReleased() - - def toggleAbRepeat(self): - enable = self.abRepeatCheckBox.isChecked() - self.soloTool.setAbLimitEnable(enable) - - def saveSession(self): - path, _ = QFileDialog.getSaveFileName(self, "Open file", "", "session file (*.json)") - if path: - self.soloTool.saveSession(path) - - def loadSession(self, path=None): - if path is None: - path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "session file (*.json)") - if path is not None: - self.soloTool.loadSession(path) - self.playlistModel.layoutChanged.emit() - self.abListModel.layoutChanged.emit() - - def songSliderPressed(self): - self.timer.stop() - - def songSliderReleased(self): - position = self.songSlider.value() / float(POSITION_FACTOR) - self.soloTool.setPlaybackPosition(position) - self.timer.start() - - def clearListViewSelection(self, listView): - i = listView.selectionModel().currentIndex() - listView.selectionModel().select(i, QItemSelectionModel.Deselect) - - def abSliderReleased(self): - a = self.aSlider.value() / float(POSITION_FACTOR) - b = self.bSlider.value() / float(POSITION_FACTOR) - self.soloTool.setAbLimits(a, b) - self.clearListViewSelection(self.abListView) - - def rateSliderReleased(self): - rate = self.rateSlider.value() / float(RATE_FACTOR) - self.soloTool.setPlaybackRate(rate) - - def playlistSelectionChanged(self, i): - if self.songChangePending == CHANGE_INTERNAL: - self.songChangePending = None - else: - assert self.songChangePending is None - self.songChangePending = CHANGE_GUI - index = i.indexes()[0].row() - self.soloTool.setSong(index) - - self.clearListViewSelection(self.abListView) - self.abListModel.layoutChanged.emit() - - def currentSongChanged(self, songIndex): - if self.songChangePending == CHANGE_GUI: - self.songChangePending = None - else: - assert self.songChangePending is None - self.songChangePending = CHANGE_INTERNAL - i = self.playlistModel.createIndex(songIndex, 0) - self.songListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) - - def abListSelectionChanged(self, i): - if self.abLimitsChangePending == CHANGE_INTERNAL: - print("Ack internal change") - self.abLimitsChangePending = None - else: - assert self.abLimitsChangePending is None - if i is not None and not i.isEmpty(): - print("Processing GUI change") - self.abLimitsChangePending = CHANGE_GUI - index = i.indexes()[0].row() - ab = self.soloTool.getStoredAbLimits()[index] - self.soloTool.loadAbLimits(index) - self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) - self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) - - def currentAbLimitsChanged(self, abIndex): - if self.abLimitsChangePending == CHANGE_GUI: - print("Ack GUI change") - self.abLimitsChangePending = None - else: - assert self.abLimitsChangePending is None - print("Processing internal change") - self.abLimitsChangePending = CHANGE_INTERNAL - i = self.abListModel.createIndex(abIndex, 0) - self.abListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) - ab = self.soloTool.getStoredAbLimits()[abIndex] - self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) - self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) - - def initMidi(self): - try: - self.midiController.connect() - except Exception as e: - print("Error: could not connect to MIDI controller") - print(e) - - def keyPressEvent(self, event): - if event.key() == Qt.Key_Super_L: - self.soloTool.jumpToA() - -if __name__ == '__main__': - app = QApplication([]) - app.setApplicationName("Solo Tool") - app.setStyle("Fusion") - - # Fusion dark palette from https://gist.github.com/QuantumCD/6245215. - palette = QPalette() - palette.setColor(QPalette.Window, QColor(53, 53, 53)) - palette.setColor(QPalette.WindowText, Qt.white) - palette.setColor(QPalette.Base, QColor(25, 25, 25)) - palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) - palette.setColor(QPalette.ToolTipBase, Qt.white) - palette.setColor(QPalette.ToolTipText, Qt.white) - palette.setColor(QPalette.Text, Qt.white) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, Qt.white) - palette.setColor(QPalette.BrightText, Qt.red) - palette.setColor(QPalette.Link, QColor(42, 130, 218)) - palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) - palette.setColor(QPalette.HighlightedText, Qt.black) - app.setPalette(palette) - app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }") - - window = MainWindow() - app.exec_() diff --git a/test.flac b/test.flac deleted file mode 100644 index 9164735..0000000 Binary files a/test.flac and /dev/null differ diff --git a/test.mp3 b/test.mp3 deleted file mode 100644 index 3c353b7..0000000 Binary files a/test.mp3 and /dev/null differ diff --git a/test_session.json b/test_session.json deleted file mode 100644 index f48b792..0000000 --- a/test_session.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "path" : "test.flac", - "ab_limits" : null - }, - { - "path" : "test.mp3", - "ab_limits" : [ - [0.1, 0.2], - [0.3, 0.4] - ] - } -] -- cgit v1.2.3