diff options
Diffstat (limited to 'solo-tool-project')
24 files changed, 1135 insertions, 641 deletions
diff --git a/solo-tool-project/pyproject.toml b/solo-tool-project/pyproject.toml index 36d4891..841ee46 100644 --- a/solo-tool-project/pyproject.toml +++ b/solo-tool-project/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "solo_tool" +version = "2.0" authors = [ { name = "Eddy Pedroni", email = "epedroni@pm.me" }, ] description = "A library for dissecting guitar solos" -requires-python = ">=3.12" +requires-python = ">=3.13" dependencies = [ "python-rtmidi", "sip", "mido", - "python-vlc" + "python-mpv" ] -dynamic = ["version"] [project.optional-dependencies] dev = [ diff --git a/solo-tool-project/src/solo_tool/handlers.py b/solo-tool-project/src/solo_tool/handlers.py index 13e982b..3beb0fb 100644 --- a/solo-tool-project/src/solo_tool/handlers.py +++ b/solo-tool-project/src/solo_tool/handlers.py @@ -2,7 +2,15 @@ from collections.abc import Callable from solo_tool.solo_tool import SoloTool -def changeSong(st: SoloTool, delta: int) -> Callable[[], None]: +def playPause(st: SoloTool) -> Callable[[], None]: + def f(): + if st.playing: + st.pause() + else: + st.play() + return f + +def songRelative(st: SoloTool, delta: int) -> Callable[[], None]: def f(): if st.song is None: st.song = 0 @@ -10,12 +18,67 @@ def changeSong(st: SoloTool, delta: int) -> Callable[[], None]: st.song += delta return f +def restartOrPreviousSong(st: SoloTool, threshold: float) -> Callable[[], None]: + def f(): + if st.position < threshold and st.song > 0: + st.song -= 1 + else: + st.position = 0.0 + return f + +def songAbsolute(st: SoloTool, index: int, followUp: Callable[[], None]=None) -> Callable[[], None]: + def f(): + st.song = index + if followUp is not None: + followUp() + return f + def seekRelative(st: SoloTool, delta: float) -> Callable[[], None]: def f(): st.position += delta return f +def seekAbsolute(st: SoloTool, new: float) -> Callable[[], None]: + def f(): + st.position = new + return f + def positionToKeyPoint(st: SoloTool) -> Callable[[], None]: def f(): st.keyPoint = st.position return f + +def keyPointAbsolute(st: SoloTool, kp: float) -> Callable[[], None]: + def f(): + st.keyPoint = kp + return f + +def keyPointRelative(st: SoloTool, delta: int) -> Callable[[], None]: + from bisect import bisect_right, bisect_left + def f(): + l = sorted(set(st.keyPoints + [st.keyPoint])) + if delta > 0: + pivot = bisect_right(l, st.keyPoint) - 1 + elif delta < 0: + pivot = bisect_left(l, st.keyPoint) + else: + return + new = max(min(pivot + delta, len(l) - 1), 0) + st.keyPoint = l[new] + return f + +def rateAbsolute(st: SoloTool, value: float) -> Callable[[], None]: + def f(): + st.rate = value + return f + +def rateRelative(st: SoloTool, delta: float) -> Callable[[], None]: + def f(): + st.rate += delta + return f + +def volumeAbsolute(st: SoloTool, value: float) -> Callable[[], None]: + def f(): + st.volume = value + return f + diff --git a/solo-tool-project/src/solo_tool/midi_controller_actition.py b/solo-tool-project/src/solo_tool/midi_controller_actition.py new file mode 100644 index 0000000..f4e6642 --- /dev/null +++ b/solo-tool-project/src/solo_tool/midi_controller_actition.py @@ -0,0 +1,46 @@ +import mido +from collections.abc import Callable + +from . import handlers +from .solo_tool import SoloTool + +class ActitionController: + class _MidoMidiWrapper: + def __init__(self): + self._callback = None + try: + self._inPort = mido.open_input("f_midi") + self._inPort.callback = self._midoCallback + except: + print("Failed to open f_midi port for Actition controller") + + def setCallback(self, callback: Callable[[int, int], None]) -> None: + self._callback = callback + + def _midoCallback(self, msg: mido.Message) -> None: + if msg.type != "control_change": + return + if self._callback: + self._callback(msg.control, msg.channel) + + def __init__(self, midiWrapperOverride=None): + self._handlers = {} + if midiWrapperOverride: + self._midiWrapper = midiWrapperOverride + else: + self._midiWrapper = ActitionController._MidoMidiWrapper() + self._midiWrapper.setCallback(self._callback) + + def _callback(self, control: int, channel: int) -> None: + if channel != 14: + return + if control in self._handlers: + self._handlers[control]() + + def setSoloTool(self, soloTool: SoloTool) -> None: + self._handlers = { + 102: handlers.seekAbsolute(soloTool, 0.0), + 103: handlers.positionToKeyPoint(soloTool), + 104: soloTool.jump, + 105: handlers.playPause(soloTool) + } 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 index 3dc8ec6..e79b60c 100644 --- a/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py +++ b/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py @@ -1,7 +1,34 @@ -from .midi_wrapper_mido import MidiWrapper +import mido from . import handlers +from .solo_tool import SoloTool -class MidiController: +class MidiWrapper: + def __init__(self): + self._inPort = None + self._outPort = None + + def connect(self, deviceName, callback): + if self._inPort is None and self._outPort is None: + self._inPort = mido.open_input(deviceName) + self._inPort.callback = callback + self._outPort = mido.open_output(deviceName) + + def disconnect(self): + if self._inPort is not None: + self._inPort.close() + self._inPort = None + + if self._outPort is not None: + self._outPort.reset() + self._outPort.close() + self._outPort = None + + def sendNoteOn(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) + +class LaunchpadMiniController: DEVICE_NAME = "Launchpad Mini MIDI 1" LIGHT_CONTROL_CHANNEL = 0 LED_GREEN = 124 @@ -18,7 +45,7 @@ class MidiController: MAX_PLAYBACK_VOLUME = 1.2 PLAYBACK_VOLUME_STEP = 0.1 - def __init__(self, soloTool, midiWrapperOverride=None): + def __init__(self, soloTool: SoloTool, midiWrapperOverride=None): self._soloTool = soloTool if midiWrapperOverride is not None: self._midiWrapper = midiWrapperOverride @@ -32,36 +59,36 @@ class MidiController: def _registerHandlers(self): self._handlers = { - 96 : self._soloTool.stop, + 96 : handlers.seekAbsolute(self._soloTool, 0.0), 114 : self._soloTool.jump, - 112 : self._playPause, - #118 : self._soloTool.previousStoredAbLimits, - #119 : self._soloTool.nextStoredAbLimits, + 112 : handlers.playPause(self._soloTool), + 118 : handlers.keyPointRelative(self._soloTool, -1), + 119 : handlers.keyPointRelative(self._soloTool, 1), 117 : handlers.positionToKeyPoint(self._soloTool), - 48 : handlers.changeSong(self._soloTool, -1), + 48 : handlers.songRelative(self._soloTool, -1), 49 : handlers.seekRelative(self._soloTool, -0.25), 50 : handlers.seekRelative(self._soloTool, -0.05), 51 : handlers.seekRelative(self._soloTool, -0.01), 52 : handlers.seekRelative(self._soloTool, 0.01), 53 : handlers.seekRelative(self._soloTool, 0.05), 54 : handlers.seekRelative(self._soloTool, 0.25), - 55 : handlers.changeSong(self._soloTool, 1), + 55 : handlers.songRelative(self._soloTool, 1), } for i in range(0, 8): - volume = round(MidiController.MIN_PLAYBACK_VOLUME + MidiController.PLAYBACK_VOLUME_STEP * i, 1) - self._handlers[i] = self._createSetPlaybackVolumeCallback(volume) + volume = round(LaunchpadMiniController.MIN_PLAYBACK_VOLUME + LaunchpadMiniController.PLAYBACK_VOLUME_STEP * i, 1) + self._handlers[i] = handlers.volumeAbsolute(self._soloTool, 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) + rate = round(LaunchpadMiniController.MIN_PLAYBACK_RATE + LaunchpadMiniController.PLAYBACK_RATE_STEP * i, 1) + self._handlers[button] = handlers.rateAbsolute(self._soloTool, rate) def connect(self): - self._midiWrapper.connect(MidiController.DEVICE_NAME, self._callback) + self._midiWrapper.connect(LaunchpadMiniController.DEVICE_NAME, self._callback) self._initialiseButtonLEDs() def disconnect(self): - self._allLEDsOff() + self._setAllLEDs(LaunchpadMiniController.LED_OFF) self._midiWrapper.disconnect() def _callback(self, msg): @@ -71,61 +98,39 @@ class MidiController: 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 _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, 2, MidiController.LED_GREEN) + self._setButtonLED(7, 0, LaunchpadMiniController.LED_GREEN) else: - self._setButtonLED(6, 2, MidiController.LED_RED) + self._setButtonLED(7, 0, LaunchpadMiniController.LED_YELLOW) 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)) + t1 = int(round(volume / LaunchpadMiniController.PLAYBACK_VOLUME_STEP, 1)) + t2 = int(round(LaunchpadMiniController.MIN_PLAYBACK_VOLUME / LaunchpadMiniController.PLAYBACK_VOLUME_STEP, 1)) lastColumnLit = t1 - t2 + 1 - self._lightRowUntilColumn(0, lastColumnLit, MidiController.LED_GREEN) + self._lightRowUntilColumn(0, lastColumnLit, LaunchpadMiniController.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)) + t1 = int(round(rate / LaunchpadMiniController.PLAYBACK_RATE_STEP, 1)) + t2 = int(round(LaunchpadMiniController.MIN_PLAYBACK_RATE / LaunchpadMiniController.PLAYBACK_RATE_STEP, 1)) lastColumnLit = t1 - t2 + 1 - self._lightRowUntilColumn(1, lastColumnLit, MidiController.LED_YELLOW) - - def _createSetPlaybackRateCallback(self, rate): - def f(): - self._soloTool.rate = rate - return f - - def _createSetPlaybackVolumeCallback(self, volume): - def f(): - self._soloTool.volume = volume - return f + self._lightRowUntilColumn(1, lastColumnLit, LaunchpadMiniController.LED_YELLOW) def _setButtonLED(self, row, col, colour): - self._midiWrapper.sendMessage(MidiController.BUTTON_MATRIX[row][col], colour, MidiController.LIGHT_CONTROL_CHANNEL) + self._midiWrapper.sendNoteOn(LaunchpadMiniController.BUTTON_MATRIX[row][col], colour, LaunchpadMiniController.LIGHT_CONTROL_CHANNEL) def _lightRowUntilColumn(self, row, column, litColour): - colours = [litColour] * column + [MidiController.LED_OFF] * (8 - column) + colours = [litColour] * column + [LaunchpadMiniController.LED_OFF] * (8 - column) for col in range(0, 8): self._setButtonLED(row, col, colours[col]) - def _allLEDsOff(self): + def _setAllLEDs(self, colour): for row in range(0, 8): for col in range(0, 8): - self._setButtonLED(row, col, MidiController.LED_OFF) + self._setButtonLED(row, col, colour) def _initialiseButtonLEDs(self): - self._allLEDsOff() + self._setAllLEDs(LaunchpadMiniController.LED_OFF) # volume buttons self._updateVolumeRow(self._soloTool.volume) @@ -134,22 +139,22 @@ class MidiController: self._updateRateRow(self._soloTool.rate) # playback control - self._setButtonLED(6, 0, MidiController.LED_RED) - self._updatePlayPauseButton(self._soloTool.isPlaying()) + self._setButtonLED(6, 0, LaunchpadMiniController.LED_YELLOW) + self._updatePlayPauseButton(self._soloTool.playing) - # AB control - self._setButtonLED(7, 2, MidiController.LED_YELLOW) - self._setButtonLED(7, 6, MidiController.LED_RED) - self._setButtonLED(7, 7, MidiController.LED_GREEN) - self._setButtonLED(7, 5, MidiController.LED_YELLOW) + # Key point control + self._setButtonLED(7, 2, LaunchpadMiniController.LED_YELLOW) + self._setButtonLED(7, 6, LaunchpadMiniController.LED_RED) + self._setButtonLED(7, 7, LaunchpadMiniController.LED_GREEN) + self._setButtonLED(7, 5, LaunchpadMiniController.LED_YELLOW) # Song control - self._setButtonLED(3, 0, MidiController.LED_RED) - self._setButtonLED(3, 1, MidiController.LED_RED) - self._setButtonLED(3, 2, MidiController.LED_RED) - self._setButtonLED(3, 3, MidiController.LED_RED) - self._setButtonLED(3, 4, MidiController.LED_GREEN) - self._setButtonLED(3, 5, MidiController.LED_GREEN) - self._setButtonLED(3, 6, MidiController.LED_GREEN) - self._setButtonLED(3, 7, MidiController.LED_GREEN) + self._setButtonLED(3, 0, LaunchpadMiniController.LED_RED) + self._setButtonLED(3, 1, LaunchpadMiniController.LED_RED) + self._setButtonLED(3, 2, LaunchpadMiniController.LED_RED) + self._setButtonLED(3, 3, LaunchpadMiniController.LED_RED) + self._setButtonLED(3, 4, LaunchpadMiniController.LED_GREEN) + self._setButtonLED(3, 5, LaunchpadMiniController.LED_GREEN) + self._setButtonLED(3, 6, LaunchpadMiniController.LED_GREEN) + self._setButtonLED(3, 7, LaunchpadMiniController.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 deleted file mode 100644 index 34f1031..0000000 --- a/solo-tool-project/src/solo_tool/midi_wrapper_mido.py +++ /dev/null @@ -1,28 +0,0 @@ -import mido - -class MidiWrapper: - def __init__(self): - self._inPort = None - self._outPort = None - - def connect(self, deviceName, callback): - if self._inPort is None and self._outPort is None: - self._inPort = mido.open_input(deviceName) - self._inPort.callback = callback - self._outPort = mido.open_output(deviceName) - - def disconnect(self): - if self._inPort is not None: - self._inPort.close() - self._inPort = None - - if self._outPort is not None: - self._outPort.reset() - self._outPort.close() - self._outPort = None - - 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 index 73b84b7..5b3539c 100644 --- a/solo-tool-project/src/solo_tool/notifier.py +++ b/solo-tool-project/src/solo_tool/notifier.py @@ -3,7 +3,9 @@ class Notifier: PLAYBACK_VOLUME_EVENT = 1 PLAYBACK_RATE_EVENT = 2 CURRENT_SONG_EVENT = 3 - CURRENT_KEY_POINT_EVENT = 3 + SONG_LIST_EVENT = 4 + CURRENT_KEY_POINT_EVENT = 5 + KEY_POINT_LIST_EVENT = 6 def __init__(self, player): self._callbacks = dict() diff --git a/solo-tool-project/src/solo_tool/player_mpv.py b/solo-tool-project/src/solo_tool/player_mpv.py new file mode 100644 index 0000000..ff7fd1a --- /dev/null +++ b/solo-tool-project/src/solo_tool/player_mpv.py @@ -0,0 +1,53 @@ +import mpv + +class Player: + def __init__(self): + self._player = mpv.MPV() + self._player.loop = "inf" + self._playingStateCallback = self._dummyCallback + self._volumeCallback = self._dummyCallback + self._player.observe_property("pause", lambda name, value: self._playingStateCallback()) + self._player.observe_property("volume", lambda name, value: self._volumeCallback()) + + def __del__(self): + self._player.close() + + def _dummyCallback(self): + pass + + def play(self): + self._player.pause = False + + def pause(self): + self._player.pause = True + + def isPlaying(self): + return not self._player.pause + + def setPlaybackRate(self, rate): + self._player.speed = rate + + def getPlaybackRate(self): + return self._player.speed + + def setPlaybackPosition(self, position): + self._player.percent_pos = int(position * 100) + + def getPlaybackPosition(self): + return float(self._player.percent_pos or 0.0) / 100.0 + + def setPlaybackVolume(self, volume): + self._player.volume = int(volume * 100) + + def getPlaybackVolume(self): + return float(self._player.volume) / 100.0 + + def setCurrentSong(self, path): + self.pause() + self._player.play(str(path)) + + def setPlayingStateChangedCallback(self, callback): + self._playingStateCallback = callback + + def setPlaybackVolumeChangedCallback(self, callback): + self._volumeCallback = callback diff --git a/solo-tool-project/src/solo_tool/player_vlc.py b/solo-tool-project/src/solo_tool/player_vlc.py deleted file mode 100644 index 283102e..0000000 --- a/solo-tool-project/src/solo_tool/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/solo-tool-project/src/solo_tool/session_manager.py b/solo-tool-project/src/solo_tool/session_manager.py index a4dabc0..8624207 100644 --- a/solo-tool-project/src/solo_tool/session_manager.py +++ b/solo-tool-project/src/solo_tool/session_manager.py @@ -1,29 +1,117 @@ +from typing import Protocol +from abc import abstractmethod +from . import SoloTool + +from pathlib import Path +from glob import glob import json +import requests +from os import getenv + +class SessionManager(): + def __init__(self, sessionPath: str): + self._sessionPath = sessionPath + + from re import search + match = search(r"^([a-z0-9]+://)", sessionPath) + if not match or match.group(0) == "file://": + self._backend = _FileSystemBackend(sessionPath) + elif match.group(0) in ["http://", "https://"]: + self._backend = _FileBrowserBackend(sessionPath) + else: + raise ValueError(f"Unsupported session path: {sessionPath}") -def loadSession(file, songList, abController): - jsonStr = file.read() - session = json.loads(jsonStr) + def getSessions(self) -> list[str]: + return self._backend.listIds() - songList.clear() - abController.clear() + def loadSession(self, id: str, player=None) -> SoloTool: + session = self._backend.read(id) - for entry in session: - songPath = entry["path"] - abLimits = entry["ab_limits"] - songList.append(songPath) + st = SoloTool(player=player) + for i, entry in enumerate(session): + songPath = entry["path"] + keyPoints = entry.get("key_points", []) + volume = entry.get("vol", 1.0) - if abLimits is not None: - for l in abLimits: - abController.storeLimits(l[0], l[1], songPath) + st.addSong(songPath, keyPoints=keyPoints, volume=volume) -def saveSession(file, songList, abController): - session = list() + return st + + def saveSession(self, soloTool: SoloTool, id: str) -> None: + session = [] + + for i, song in enumerate(soloTool.songs): + entry = { + "path": song, + "key_points" : soloTool._keyPoints[i], + "vol" : soloTool._volumes[i] + } + session.append(entry) + + self._backend.write(session, id) + +class _Backend(Protocol): + @abstractmethod + def listIds(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def read(self, id: str) -> dict: + raise NotImplementedError + + @abstractmethod + def write(self, session: dict, id: str) -> None: + raise NotImplementedError + +class _FileSystemBackend(_Backend): + def __init__(self, sessionPath: str): + self._sessionPath = Path(sessionPath) + + def listIds(self) -> list[str]: + return [Path(f).stem for f in glob(f"{self._sessionPath}/*.json")] + + def read(self, id: str) -> dict: + with open(self._sessionPath / f"{id}.json", "r") as f: + session = json.load(f) + return session + + def write(self, session: dict, id: str) -> None: + with open(self._sessionPath / f"{id}.json", "w") as f: + json.dump(session, f) + +class _FileBrowserBackend(_Backend): + def __init__(self, serverUrl: str): + self._baseUrl = serverUrl + self._username = getenv("ST_USER") + self._password = getenv("ST_PASS") + self._apiKey = self._getApiKey() + + def listIds(self) -> list[str]: + url = f"{self._baseUrl}/api/resources" + response = self._request("GET", url) + return [item["name"][0:-5] for item in response.json()["items"] if item["extension"] == ".json"] + + def read(self, id: str) -> dict: + url = f"{self._baseUrl}/api/raw/{id}.json" + response = self._request("GET", url) + return json.loads(response.content) + + def write(self, session: dict, id: str) -> None: + url = f"{self._baseUrl}/api/resources/{id}.json" + self._request("PUT", url, json=session) + + def _getApiKey(self) -> str: + response = requests.post(f"{self._baseUrl}/api/login", json={"username":self._username, "password":self._password}) + return response.content - for s in songList: - entry = { - "path": s, - "ab_limits" : abController.getStoredLimits(s) - } - session.append(entry) + def _request(self, verb: str, url: str, **kwargs): + headers = {"X-Auth" : self._apiKey} + response = requests.request(verb, url, headers=headers, **kwargs) + if response.status_code == requests.codes.UNAUTHORIZED: + # if unauthorized, the key might have expired + self._apiKey = self._getApiKey() + headers["X-Auth"] = self._apiKey + response = requests.request(verb, url, headers=headers, **kwargs) + response.raise_for_status() + return response - 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 index 97c3495..e8474e6 100644 --- a/solo-tool-project/src/solo_tool/solo_tool.py +++ b/solo-tool-project/src/solo_tool/solo_tool.py @@ -1,24 +1,37 @@ import os -from .session_manager import loadSession, saveSession from .notifier import Notifier -from .player_vlc import Player +from .player_mpv import Player class SoloTool: - def __init__(self, playerOverride=None): - self._player = Player() if playerOverride is None else playerOverride + def __init__(self, player=None): + self._player = Player() if player is None else player self._notifier = Notifier(self._player) self._songs = [] self._song = None self._keyPoints = [] self._keyPoint = None + self._volumes = [] + + def __del__(self): + del self._player def _updateSong(self, index): + previousSong = self._song self._song = index - path = self._songs[index] - self._player.setCurrentSong(path) + self._player.pause() + self._player.setCurrentSong(self._songs[index]) self._notifier.notify(Notifier.CURRENT_SONG_EVENT, index) - self._keyPoint = 0.0 + + previousKp = self._keyPoint + self._keyPoint = self.keyPoints[0] if len(self.keyPoints) > 0 else 0.0 + if previousKp != self._keyPoint: + self._notifier.notify(Notifier.CURRENT_KEY_POINT_EVENT, self._keyPoint) + + if previousSong is None or self._keyPoints[previousSong] != self._keyPoints[index]: + self._notifier.notify(Notifier.KEY_POINT_LIST_EVENT, self.keyPoints) + + self.volume = self._volumes[index] @staticmethod def _keyPointValid(kp: float) -> bool: @@ -28,11 +41,15 @@ class SoloTool: def songs(self) -> list[str]: return self._songs.copy() - def addSong(self, path: str) -> None: - if not os.path.isfile(path): - raise FileNotFoundError() + def addSong(self, path: str, keyPoints: list[float]=[], volume: float=1.0) -> None: + if path in self._songs: + return self._songs.append(path) - self._keyPoints.append([]) + self._keyPoints.append(keyPoints) + self._volumes.append(volume) + self._notifier.notify(Notifier.SONG_LIST_EVENT, self.songs) + if self.song is None: + self.song = 0 @property def song(self) -> int: @@ -50,17 +67,18 @@ class SoloTool: def keyPoints(self) -> list[float]: if self._song is None: return None - return self._keyPoints[self._song] + return self._keyPoints[self._song].copy() @keyPoints.setter def keyPoints(self, new: list[float]) -> None: if new is not None and self._song is not None: sanitized = sorted(list(set([p for p in new if SoloTool._keyPointValid(p)]))) - self._keyPoints[self._song] = sanitized + self._keyPoints[self._song] = sanitized.copy() + self._notifier.notify(Notifier.KEY_POINT_LIST_EVENT, self.keyPoints) @property def keyPoint(self) -> float: - return self._keyPoint + return float(self._keyPoint) if self._keyPoint is not None else None @keyPoint.setter def keyPoint(self, new: float) -> None: @@ -74,10 +92,8 @@ class SoloTool: def pause(self): self._player.pause() - def stop(self): - self._player.stop() - - def isPlaying(self): + @property + def playing(self) -> bool: return self._player.isPlaying() def jump(self): @@ -100,6 +116,8 @@ class SoloTool: @volume.setter def volume(self, new: float) -> None: if new is not None and new >= 0.0 and new != self._player.getPlaybackVolume(): + if self._song is not None: + self._volumes[self._song] = new self._player.setPlaybackVolume(new) self._notifier.notify(Notifier.PLAYBACK_VOLUME_EVENT, new) @@ -109,10 +127,21 @@ class SoloTool: @position.setter def position(self, new: float) -> None: - # TODO stop playback before changing position? if new is not None and new != self._player.getPlaybackPosition(): self._player.setPlaybackPosition(min(max(0.0, new), 1.0)) + def registerSongSelectionCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) + + def registerSongListCallback(self, callback): + self._notifier.registerCallback(Notifier.SONG_LIST_EVENT, callback) + + def registerKeyPointSelectionCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_KEY_POINT_EVENT, callback) + + def registerKeyPointListCallback(self, callback): + self._notifier.registerCallback(Notifier.KEY_POINT_LIST_EVENT, callback) + def registerPlayingStateCallback(self, callback): self._notifier.registerCallback(Notifier.PLAYING_STATE_EVENT, callback) @@ -122,9 +151,3 @@ class SoloTool: def registerRateCallback(self, callback): self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback) - def registerCurrentSongCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) - - def registerCurrentKeyPointCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_KEY_POINT_EVENT, callback) - diff --git a/solo-tool-project/test/fixtures.py b/solo-tool-project/test/fixtures.py new file mode 100644 index 0000000..1f2299f --- /dev/null +++ b/solo-tool-project/test/fixtures.py @@ -0,0 +1,35 @@ +import pytest +from pathlib import Path +import os + +from solo_tool.solo_tool import SoloTool +from player_mock import Player as MockPlayer + +@pytest.fixture +def mockPlayer(): + return MockPlayer() + +@pytest.fixture +def sessionPath(tmp_path): + path = tmp_path / "sessions" + os.mkdir(path) + return path + +@pytest.fixture +def soloTool(mockPlayer): + return SoloTool(player=mockPlayer) + +@pytest.fixture +def testSongs(tmp_path): + path = tmp_path / "songs" + os.mkdir(path) + songs = [ + path / "test.flac", + path / "test.mp3", + path / "test.mp4" + ] + + for song in songs: + song.touch() + return songs + diff --git a/solo-tool-project/test/handlers_integrationtest.py b/solo-tool-project/test/handlers_integrationtest.py new file mode 100644 index 0000000..6696f86 --- /dev/null +++ b/solo-tool-project/test/handlers_integrationtest.py @@ -0,0 +1,32 @@ +import pytest + +from fixtures import soloTool, testSongs, mockPlayer + +from solo_tool.handlers import keyPointRelative + +testCases = [ + ([0.1, 0.3], 0.0, +1, 0.1, "Start +1"), + ([0.1, 0.3], 0.1, +1, 0.3, "First +1"), + ([0.1, 0.3], 0.2, +1, 0.3, "Between +1"), + ([0.1, 0.3], 0.3, +1, 0.3, "Second +1"), + ([0.1, 0.3], 0.4, +1, 0.4, "End +1"), + + ([0.1, 0.3], 0.0, -1, 0.0, "Start -1"), + ([0.1, 0.3], 0.1, -1, 0.1, "First -1"), + ([0.1, 0.3], 0.2, -1, 0.1, "Between -1"), + ([0.1, 0.3], 0.3, -1, 0.1, "Second -1"), + ([0.1, 0.3], 0.4, -1, 0.3, "End -1"), + + ([0.0, 0.3], 0.0, -1, 0.0, "0.0 -1"), +] + +@pytest.mark.parametrize("keyPoints,current,delta,expected,description", testCases) +def test_keyPointRelativeEdgeCases(soloTool, testSongs, keyPoints, current, delta, expected, description): + soloTool.addSong(testSongs[0]) + soloTool.keyPoints = keyPoints + soloTool.keyPoint = current + + handler = keyPointRelative(soloTool, delta) + handler() + + assert soloTool.keyPoint == expected, description diff --git a/solo-tool-project/test/midi_actition_pedal_integrationtest.py b/solo-tool-project/test/midi_actition_pedal_integrationtest.py new file mode 100644 index 0000000..d820c2b --- /dev/null +++ b/solo-tool-project/test/midi_actition_pedal_integrationtest.py @@ -0,0 +1,118 @@ +import pytest +from fixtures import mockPlayer, testSongs +from solo_tool.solo_tool import SoloTool +from solo_tool.midi_controller_actition import ActitionController + +CHANNEL = 14 +REWIND = 102 +SET = 103 +JUMP = 104 +PLAY = 105 + +class MidiWrapperMock: + def __init__(self): + self.sentMessages = list() + + def setCallback(self, callback): + self.callback = callback + + def simulateInput(self, control, channel): + if self.callback is not None: + self.callback(control, channel) + + def getLatestMessage(self): + return self.sentMessages[-1] + +@pytest.fixture +def soloTool(mockPlayer, testSongs): + st = SoloTool(player=mockPlayer) + for song in testSongs: + st.addSong(song) + return st + +@pytest.fixture +def midiWrapperMock(soloTool): + return MidiWrapperMock() + +@pytest.fixture +def uut(soloTool, midiWrapperMock): + uut = ActitionController(midiWrapperMock) + uut.setSoloTool(soloTool) + return uut + +def test_rewindMessage(uut, soloTool, mockPlayer, midiWrapperMock): + soloTool.song = 1 + mockPlayer.position = 0.5 + + # Sending rewind goes back to the start of the song + midiWrapperMock.simulateInput(REWIND, CHANNEL) + assert mockPlayer.getPlaybackPosition() == 0.0 + + # Sending again does not change the song + assert soloTool.song == 1 + midiWrapperMock.simulateInput(REWIND, CHANNEL) + assert soloTool.song == 1 + assert mockPlayer.position == 0.0 + +def test_setMessage(uut, soloTool, mockPlayer, midiWrapperMock): + callbackValue = None + callbackCalled = False + + def callback(keyPoint): + nonlocal callbackCalled, callbackValue + callbackValue = keyPoint + callbackCalled = True + + soloTool.registerKeyPointSelectionCallback(callback) + + # Sending set sets the current position as the key point + assert soloTool.keyPoint == 0.0 + + mockPlayer.position = 0.3 + midiWrapperMock.simulateInput(SET, CHANNEL) + assert soloTool.keyPoint == 0.3 + assert callbackCalled + assert callbackValue == 0.3 + + # Sending it again does nothing + callbackCalled = False + midiWrapperMock.simulateInput(SET, CHANNEL) + assert soloTool.keyPoint == 0.3 + assert not callbackCalled + +def test_jumpMessage(uut, soloTool, mockPlayer, midiWrapperMock): + soloTool.keyPoint = 0.5 + mockPlayer.position = 0.0 + + # Sending jump sets the player position to the current key point + midiWrapperMock.simulateInput(JUMP, CHANNEL) + assert mockPlayer.position == 0.5 + + # Sending again does nothing + midiWrapperMock.simulateInput(JUMP, CHANNEL) + assert mockPlayer.position == 0.5 + +def test_playMessage(uut, soloTool, mockPlayer, midiWrapperMock): + callbackValue = None + callbackCalled = False + + def callback(state): + nonlocal callbackCalled, callbackValue + callbackValue = state + callbackCalled = True + + soloTool.registerPlayingStateCallback(callback) + + # Sending play starts playing + assert not mockPlayer.isPlaying() + midiWrapperMock.simulateInput(PLAY, CHANNEL) + assert mockPlayer.isPlaying() + assert callbackCalled + assert callbackValue == True + + # Sending again stops playing + callbackCalled = False + midiWrapperMock.simulateInput(PLAY, CHANNEL) + assert not mockPlayer.isPlaying() + assert callbackCalled + assert callbackValue == False diff --git a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py index 9588f9f..6841f24 100644 --- a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py +++ b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py @@ -1,9 +1,8 @@ import pytest from mido import Message -from solo_tool.midi_controller_launchpad_mini import MidiController -from solo_tool.solo_tool import SoloTool -from player_mock import Player as PlayerMock +from solo_tool.midi_controller_launchpad_mini import LaunchpadMiniController +from fixtures import soloTool, mockPlayer, testSongs LED_RED = 3 LED_YELLOW = 126 @@ -20,7 +19,7 @@ rwd25PcButton = 49 previousSongButton = 48 playPauseButton = 112 -stopButton = 96 +jumpToStartButton = 96 nextKeyPositionButton = 119 previousKeyPositionButton = 118 @@ -39,10 +38,10 @@ class MidiWrapperMock: def disconnect(self): self.connectedDevice = None - - def sendMessage(self, note, velocity, channel): + + def sendNoteOn(self, note, velocity, channel): self.sentMessages.append((note, velocity, channel)) - + def simulateInput(self, note, velocity=127, channel=0): if self.callback is not None: msg = Message("note_on", note=note, velocity=velocity, channel=channel) @@ -52,114 +51,77 @@ class MidiWrapperMock: 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) + return LaunchpadMiniController(soloTool, midiWrapperMock) -def test_startStopAndPauseButtons(uut, midiWrapperMock, playerMock): +def test_startAndPauseButtons(uut, midiWrapperMock, mockPlayer): 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) + assert not mockPlayer.playing midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - assert playerMock.state == PlayerMock.PAUSED + assert mockPlayer.playing + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_GREEN, 0) midiWrapperMock.simulateInput(playPauseButton) - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - assert playerMock.state == PlayerMock.PLAYING + assert not mockPlayer.playing + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_YELLOW, 0) - 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): +def test_startPauseButtonLed(uut, midiWrapperMock, mockPlayer, soloTool): uut.connect() - assert playerMock.state == PlayerMock.STOPPED + assert not mockPlayer.playing - playerMock.state = PlayerMock.PLAYING - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + mockPlayer.playing = True + mockPlayer.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_GREEN, 0) - playerMock.state = PlayerMock.STOPPED - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + mockPlayer.playing = False + mockPlayer.simulatePlayingStateChanged() + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.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_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock): - soloTool.addSong("test.flac") - soloTool.song = 0 +def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() soloTool.keyPoint = 0.5 - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 midiWrapperMock.simulateInput(jumpToKeyPositionButton) - assert playerMock.position == 0.5 + assert mockPlayer.position == 0.5 -def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: +# TODO implement +def test_jumpToStartButton(uut, midiWrapperMock, soloTool, mockPlayer): + pass + +def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + for s in testSongs: soloTool.addSong(s) uut.connect() - assert playerMock.currentSong == None - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[0] - + assert mockPlayer.currentSong == testSongs[0] midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[1] + assert mockPlayer.currentSong == testSongs[1] - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[1] + for _ in testSongs: + midiWrapperMock.simulateInput(nextSongButton) + assert mockPlayer.currentSong == testSongs[-1] midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] + assert mockPlayer.currentSong == testSongs[-2] - midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] + for _ in testSongs: + midiWrapperMock.simulateInput(previousSongButton) + assert mockPlayer.currentSong == testSongs[0] -def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" +def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): keyPoints = [0.2, 0.1] - soloTool.addSong(song) - soloTool.song = 0 + soloTool.addSong(testSongs[0]) soloTool.keyPoints = keyPoints uut.connect() @@ -167,18 +129,18 @@ def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, playe assert soloTool.keyPoint == 0.0 midiWrapperMock.simulateInput(nextKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 midiWrapperMock.simulateInput(nextKeyPositionButton) - soloTool.keyPoint == 0.2 + assert soloTool.keyPoint == 0.2 midiWrapperMock.simulateInput(previousKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 midiWrapperMock.simulateInput(previousKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 -def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackRateButtons(uut, midiWrapperMock, soloTool, mockPlayer): playbackRateOptions = { 16 : (0.5, [LED_YELLOW] * 1 + [LED_OFF] * 7), 17 : (0.6, [LED_YELLOW] * 2 + [LED_OFF] * 6), @@ -190,18 +152,18 @@ def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): 23 : (1.2, [LED_YELLOW] * 8) } uut.connect() - assert playerMock.rate == 1.0 + assert mockPlayer.rate == 1.0 for t, button in enumerate(playbackRateOptions): midiWrapperMock.sentMessages.clear() midiWrapperMock.simulateInput(button) - assert playerMock.rate == playbackRateOptions[button][0] + assert mockPlayer.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): +def test_playbackRateLeds(uut, midiWrapperMock, soloTool, mockPlayer): playbackRateOptions = [ (0.00, [LED_OFF] * 8), (0.49, [LED_OFF] * 8), @@ -231,19 +193,19 @@ def test_playbackRateLeds(uut, midiWrapperMock, soloTool, playerMock): (1.5, [LED_YELLOW] * 8) ] uut.connect() - assert playerMock.rate == 1.0 + assert mockPlayer.rate == 1.0 for t, (rate, leds) in enumerate(playbackRateOptions): print(t) midiWrapperMock.sentMessages.clear() soloTool.rate = rate - assert playerMock.rate == rate + assert mockPlayer.rate == rate for i, colour in enumerate(leds): assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) -def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, mockPlayer): playbackVolumeOptions = { 0 : (0.5, [LED_GREEN] * 1 + [LED_OFF] * 7), 1 : (0.6, [LED_GREEN] * 2 + [LED_OFF] * 6), @@ -255,18 +217,18 @@ def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): 7 : (1.2, [LED_GREEN] * 8) } uut.connect() - assert playerMock.volume == 1.0 + assert mockPlayer.volume == 1.0 for t, button in enumerate(playbackVolumeOptions): midiWrapperMock.sentMessages.clear() midiWrapperMock.simulateInput(button) - assert playerMock.volume == playbackVolumeOptions[button][0] + assert mockPlayer.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): +def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, mockPlayer): playbackVolumeOptions = [ (0.00, [LED_OFF] * 8), (0.49, [LED_OFF] * 8), @@ -296,13 +258,13 @@ def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, playerMock): (1.5, [LED_GREEN] * 8) ] uut.connect() - assert playerMock.volume == 1.0 + assert mockPlayer.volume == 1.0 for t, (volume, leds) in enumerate(playbackVolumeOptions): midiWrapperMock.sentMessages.clear() soloTool.volume = volume - assert playerMock.volume == volume + assert mockPlayer.volume == volume for i, colour in enumerate(leds): assert midiWrapperMock.sentMessages[i] == (i, colour, 0) @@ -321,7 +283,7 @@ def test_connectDisconnect(uut, midiWrapperMock): [(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), + (jumpToStartButton, LED_YELLOW, 0), (playPauseButton, LED_YELLOW, 0), (jumpToKeyPositionButton, LED_YELLOW, 0), (previousKeyPositionButton, LED_RED, 0), @@ -351,76 +313,67 @@ def test_connectDisconnect(uut, midiWrapperMock): assert set(midiWrapperMock.sentMessages) == set(teardownMessages) -def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: +def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + for s in testSongs: soloTool.addSong(s) uut.connect() - soloTool.song = 0 soloTool.play() - assert playerMock.state == PlayerMock.PLAYING + assert mockPlayer.playing assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_GREEN, 0) soloTool.song = 1 - assert playerMock.state == PlayerMock.STOPPED + assert not mockPlayer.playing assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0) -def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" - soloTool.addSong(song) - soloTool.song = 0 +def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() - playerMock.position = 0.3 + mockPlayer.position = 0.3 midiWrapperMock.simulateInput(setKeyPositionButton) assert soloTool.keyPoint == 0.3 - playerMock.position = 0.5 + mockPlayer.position = 0.5 midiWrapperMock.simulateInput(setKeyPositionButton) assert soloTool.keyPoint == 0.5 - playerMock.position = 0.7 + mockPlayer.position = 0.7 midiWrapperMock.simulateInput(jumpToKeyPositionButton) - assert playerMock.position == 0.5 + assert mockPlayer.position == 0.5 -def test_seekButtons(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" - soloTool.addSong(song) - soloTool.song = 0 +def test_seekButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 midiWrapperMock.simulateInput(fwd25PcButton) - assert playerMock.position == 0.25 + assert mockPlayer.position == 0.25 midiWrapperMock.simulateInput(fwd5PcButton) - assert playerMock.position == 0.30 + assert mockPlayer.position == 0.30 midiWrapperMock.simulateInput(fwd1PcButton) - assert playerMock.position == 0.31 + assert mockPlayer.position == 0.31 midiWrapperMock.simulateInput(fwd25PcButton) midiWrapperMock.simulateInput(fwd25PcButton) midiWrapperMock.simulateInput(fwd25PcButton) - assert playerMock.position == 1.0 + assert mockPlayer.position == 1.0 midiWrapperMock.simulateInput(rwd25PcButton) - assert playerMock.position == 0.75 + assert mockPlayer.position == 0.75 midiWrapperMock.simulateInput(rwd5PcButton) - assert playerMock.position == 0.70 + assert mockPlayer.position == 0.70 midiWrapperMock.simulateInput(rwd1PcButton) - assert playerMock.position == 0.69 + assert mockPlayer.position == 0.69 midiWrapperMock.simulateInput(rwd25PcButton) midiWrapperMock.simulateInput(rwd25PcButton) midiWrapperMock.simulateInput(rwd25PcButton) - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 diff --git a/solo-tool-project/test/notifier_unittest.py b/solo-tool-project/test/notifier_unittest.py index 115d21a..5749149 100644 --- a/solo-tool-project/test/notifier_unittest.py +++ b/solo-tool-project/test/notifier_unittest.py @@ -38,6 +38,7 @@ def test_allEvents(uut): checkEvent(uut, Notifier.PLAYBACK_RATE_EVENT) checkEvent(uut, Notifier.CURRENT_SONG_EVENT) checkEvent(uut, Notifier.CURRENT_KEY_POINT_EVENT) + checkEvent(uut, Notifier.KEY_POINT_LIST_EVENT) def test_eventWithoutRegisteredCallbacks(uut): uut.notify(Notifier.PLAYING_STATE_EVENT, 0) @@ -59,7 +60,7 @@ def test_eventsWithMockPlayer(uut, mockPlayer): assert called assert receivedValue == expectedValue - mockPlayer.state = 1 + mockPlayer.playing = True mockPlayer.volume = 75 checkEvent(Notifier.PLAYING_STATE_EVENT, mockPlayer.simulatePlayingStateChanged, True) diff --git a/solo-tool-project/test/player_mock.py b/solo-tool-project/test/player_mock.py index 3162e0f..a234e80 100644 --- a/solo-tool-project/test/player_mock.py +++ b/solo-tool-project/test/player_mock.py @@ -1,10 +1,6 @@ class Player(): - STOPPED = 0 - PLAYING = 1 - PAUSED = 2 - def __init__(self): - self.state = Player.STOPPED + self.playing = False self.rate = 1.0 self.position = 0.0 self.volume = 1.0 @@ -13,25 +9,19 @@ class Player(): 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: + previousState = self.playing + self.playing = True + if previousState != self.playing: self.playingStateChangedCallback() def pause(self): - previousState = self.state - self.state = Player.PAUSED - if previousState != Player.PAUSED: + previousState = self.playing + self.playing = False + if previousState != self.playing: self.playingStateChangedCallback() def isPlaying(self): - return self.state == Player.PLAYING + return self.playing def setPlaybackRate(self, rate): self.rate = rate @@ -40,9 +30,11 @@ class Player(): return self.rate def setPlaybackPosition(self, position): + print(f"{self} Setting playback position to {position}") self.position = position def getPlaybackPosition(self): + print(f"{self} Getting playback position: {self.position}") return self.position def setPlaybackVolume(self, volume): @@ -55,7 +47,6 @@ class Player(): return self.volume def setCurrentSong(self, path): - self.stop() self.currentSong = path def setPlayingStateChangedCallback(self, callback): diff --git a/solo-tool-project/test/session_manager_unittest.py b/solo-tool-project/test/session_manager_unittest.py index 0edc252..5786b23 100644 --- a/solo-tool-project/test/session_manager_unittest.py +++ b/solo-tool-project/test/session_manager_unittest.py @@ -1,59 +1,72 @@ import pytest from json import loads -import pathlib -import shutil +import os -pytestmark = pytest.mark.skip(reason="not yet implemented") +from solo_tool.session_manager import SessionManager +from fixtures import soloTool, mockPlayer, testSongs, sessionPath -from solo_tool.session_manager import loadSession, saveSession -from solo_tool.solo_tool import SoloTool +@pytest.fixture +def testSessionFile(sessionPath, testSongs): + contents = """[ + { + "path" : "test.flac", + "key_points" : [], + "vol" : 0.5 + }, + { + "path" : "test.mp3", + "key_points" : [0.1, 0.3] + } +]""" + sessionFile = sessionPath / "test-session.json" + with open(sessionFile, "w") as f: + f.write(contents) + return sessionFile @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 test_loadSession(prepared_tmp_path): - soloTool = loadSession(prepared_tmp_path / "test_session.json") +def sessionManager(sessionPath): + return SessionManager(str(sessionPath)) + +def test_loadSession(sessionManager, mockPlayer, testSessionFile): + sessions = sessionManager.getSessions() + assert sessions == [testSessionFile.stem] + soloTool = sessionManager.loadSession(sessions[0], player=mockPlayer) assert soloTool.songs == ["test.flac", "test.mp3"] soloTool.song = 0 - assert soloTool.keyPositions == [] + assert soloTool.keyPoints == [] + assert soloTool.volume == 0.5 soloTool.song = 1 - assert soloTool.keyPositions == [0.1, 0.3] + assert soloTool.keyPoints == [0.1, 0.3] + assert soloTool.volume == 1.0 -def test_saveSession(prepared_tmp_path): - soloTool = SoloTool() +def test_saveSession(sessionManager, soloTool, testSessionFile, sessionPath): soloTool.addSong("test.flac") + soloTool.volume = 0.5 + soloTool.addSong("test.mp3") - soloTool.keyPositions = [0.1, 0.3] + soloTool.song = 1 + soloTool.keyPoints = [0.1, 0.3] - testFile = prepared_tmp_path / "test_session_saved.json" - saveSession(soloTool, testFile) + sessionId = "test_session_saved" + sessionManager.saveSession(soloTool, sessionId) - with open(testFile, "r") as f: + with open(sessionPath / f"{sessionId}.json", "r") as f: savedSession = loads(f.read()) - with open(prepared_tmp_path / "test_session.json", "r") as f: + with open(testSessionFile, "r") as f: testSession = loads(f.read()) + testSession[1]["vol"] = 1.0 # Needed to handle default behaviour when vol is missing assert savedSession == testSession -def test_loadAndSaveEmptySession(prepared_tmp_path): - emptyFile = prepared_tmp_path / "empty_session.json" - - soloTool = SoloTool() +def test_loadAndSaveEmptySession(sessionManager, sessionPath, soloTool, tmp_path): + emptySession = "empty_session" - saveSession(soloTool, emptyFile) - reloadedTool = loadSession(emptyFile) + sessionManager.saveSession(soloTool, emptySession) + reloadedTool = sessionManager.loadSession(emptySession) assert reloadedTool.songs == [] diff --git a/solo-tool-project/test/solo_tool_integrationtest.py b/solo-tool-project/test/solo_tool_integrationtest.py index 2a818ed..e5745bb 100644 --- a/solo-tool-project/test/solo_tool_integrationtest.py +++ b/solo-tool-project/test/solo_tool_integrationtest.py @@ -1,42 +1,14 @@ -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 test_playerControls(uut, mockPlayer): - assert mockPlayer.state == MockPlayer.STOPPED - assert uut.isPlaying() == False +from fixtures import soloTool as uut, mockPlayer, testSongs + +def test_playerControls(uut, mockPlayer, testSongs): + assert not mockPlayer.playing + assert not uut.playing uut.play() - assert mockPlayer.state == MockPlayer.PLAYING - assert uut.isPlaying() == True + assert mockPlayer.playing + assert uut.playing uut.pause() - assert mockPlayer.state == MockPlayer.PAUSED - assert uut.isPlaying() == False - uut.stop() - assert mockPlayer.state == MockPlayer.STOPPED - assert uut.isPlaying() == False + assert not mockPlayer.playing + assert not uut.playing assert mockPlayer.rate == 1.0 uut.rate = 0.5 @@ -107,145 +79,8 @@ def test_sanitizePlaybackVolume(uut): uut.volume = 150.0 assert uut.volume == 150.0 -def test_addAndSelectSongs(uut, mockPlayer): - songs = [ - "test.mp3", - "test.flac" - ] - - # Songs are added one by one - for song in songs: - uut.addSong(song) - - # Songs are not selected automatically - assert mockPlayer.currentSong == None - assert uut.song == None - - # Song order is preserved - assert uut.songs == songs - - # Modifying the song list directly has no effect - uut.songs.append("something") - assert uut.songs == songs - - # Songs are selected by index - for i, s in enumerate(uut.songs): - uut.song = i - assert mockPlayer.currentSong == uut.songs[i] - assert uut.song == i - - # The current song cannot be de-selected - uut.song = None - assert uut.song == len(uut.songs) - 1 - - # Non-existent songs cannot be selected - uut.song = -1 - assert uut.song == len(uut.songs) - 1 - - uut.song = 2 - assert uut.song == len(uut.songs) - 1 - -def test_addAndJumpToKeyPoints(uut, mockPlayer): - uut.addSong("test.flac") - uut.addSong("test.mp3") - - def checkJump(before, expectedAfter): - mockPlayer.position = before - uut.jump() - assert mockPlayer.position == expectedAfter - - # Key points are None as long as no song is selected - uut.keyPoints = [0.1, 0.2] - uut.keyPoint = 0.5 - assert uut.keyPoints is None - assert uut.keyPoint is None - - uut.song = 0 - - # Once a song is selected, jump to start by default - assert uut.keyPoint == 0.0 - checkJump(0.5, 0.0) - - # By default songs have an empty list of key points - assert uut.keyPoints == [] - - uut.keyPoints = [0.2, 0.4, 0.1, 0.2] - - # Added key points are not automatically selected - assert uut.keyPoint == 0.0 - checkJump(0.1, 0.0) - - # Any key point can be selected - uut.keyPoint = uut.keyPoints[0] - checkJump(0.0, uut.keyPoints[0]) - - uut.keyPoint = 0.5 - checkJump(0.0, 0.5) - -def test_sanitizeKeyPoint(uut): - song = "test.flac" - uut.addSong(song) - uut.song = 0 - uut.keyPoints = [0.2, 0.4, 0.1, 0.2, None, -0.5, 1.0, 1.5] - - # Added key points are automatically de-duplicated, sanitized and sorted to ascending order - assert uut.keyPoints == [0.1, 0.2, 0.4] - - # Key point and key point list cannot be none - uut.keyPoint = 0.5 - - uut.keyPoint = None - assert uut.keyPoint == 0.5 - - uut.keyPoints = None - assert uut.keyPoints == [0.1, 0.2, 0.4] - - # Valid key points are in [0, 1) - uut.keyPoint = -0.1 - assert uut.keyPoint == 0.5 - - uut.keyPoint = 1.0 - assert uut.keyPoint == 0.5 - - uut.keyPoint = 0.999 - assert uut.keyPoint == 0.999 - -def test_keyPointsPerSong(uut, mockPlayer): - songs = [ - ("test.flac", [0.0, 0.5]), - ("test.mp3", [0.1]) - ] - - # Key points list is set for the selected song - for i, (song, keyPoints) in enumerate(songs): - uut.addSong(song) - uut.song = i - uut.keyPoints = keyPoints - - # Key points list is automatically loaded when the song selection changes - # Active key point is always reset to 0 when song selection changes - for i, (song, keyPoints) in enumerate(songs): - uut.keyPoint = 0.5 - uut.song = i - assert uut.keyPoints == keyPoints - assert uut.keyPoint == 0.0 - - # Key points are copied, not stored by reference - for i, (song, keyPoints) in enumerate(songs): - uut.song = i - keyPoints.append(1.0) - assert 1.0 not in uut.keyPoints - -def test_addInexistentSong(uut, mockPlayer): - song = "not/a/real/file" - - with pytest.raises(FileNotFoundError): - uut.addSong(song) - -def test_playingStateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.song = 0 +def test_playingStateNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) called = False receivedValue = None @@ -256,7 +91,7 @@ def test_playingStateNotification(uut, mockPlayer): uut.registerPlayingStateCallback(callback) - assert mockPlayer.state == MockPlayer.STOPPED + assert not mockPlayer.playing assert not called uut.play() @@ -273,22 +108,8 @@ def test_playingStateNotification(uut, mockPlayer): 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.song = 0 +def test_playbackVolumeNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) called = False receivedValue = None @@ -309,31 +130,22 @@ def test_playbackVolumeNotification(uut, mockPlayer): uut.volume = 0.3 assert not called -def test_playbackRateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.song = 0 - + # Volume can also change when the song changes + uut.addSong(testSongs[1]) + uut.song = 1 + assert called + assert receivedValue == 1.0 called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerRateCallback(callback) - - assert not called - uut.rate = 0.5 + uut.volume = 0.3 assert called - assert receivedValue == 0.5 + assert receivedValue == 0.3 called = False - uut.rate = 0.5 + uut.song = 0 assert not called -def test_currentSongNotification(uut): +def test_playbackVolumeNotificationBeforeFirstSong(uut, mockPlayer, testSongs): called = False receivedValue = None def callback(value): @@ -341,37 +153,21 @@ def test_currentSongNotification(uut): called = True receivedValue = value - uut.registerCurrentSongCallback(callback) - assert not called - - songs = [ - "test.flac", - "test.mp3" - ] - - # Adding a song does not trigger a notification - uut.addSong(songs[0]) + uut.registerVolumeCallback(callback) assert not called - # Selecting a song for the first time triggers - uut.song = 0 + uut.volume = 0.3 assert called - assert receivedValue == 0 + assert receivedValue == 0.3 called = False - uut.addSong(songs[1]) - assert not called - - # Selecting the same song does not trigger - uut.song = 0 - assert not called - - uut.song = 1 + uut.addSong(testSongs[0]) assert called - assert receivedValue == 1 - called = False + assert receivedValue == 1.0 + +def test_playbackRateNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) -def test_currentKeyPointNotification(uut): called = False receivedValue = None def callback(value): @@ -379,29 +175,15 @@ def test_currentKeyPointNotification(uut): called = True receivedValue = value - uut.registerCurrentKeyPointCallback(callback) - assert not called - - song = "test.flac" - uut.addSong(song) - uut.song = 0 + uut.registerRateCallback(callback) - # Selecting a song for the first time sets the key point to 0.0 - assert called - assert receivedValue == 0.0 - called = False + assert not called - # Changing the key point triggers a notification - uut.keyPoint = 0.5 + uut.rate = 0.5 assert called assert receivedValue == 0.5 called = False - # Adding list of key points does not trigger a notification - uut.keyPoints = [0.2, 0.4] - assert not called - - # Assigning the same key point again does not trigger a notification - uut.keyPoint = 0.5 + uut.rate = 0.5 assert not called diff --git a/solo-tool-project/test/solo_tool_keypoints_integrationtest.py b/solo-tool-project/test/solo_tool_keypoints_integrationtest.py new file mode 100644 index 0000000..f79103d --- /dev/null +++ b/solo-tool-project/test/solo_tool_keypoints_integrationtest.py @@ -0,0 +1,194 @@ +import pytest + +from fixtures import soloTool as uut, mockPlayer, testSongs + +def test_keyPointAndSongSelection(uut, mockPlayer, testSongs): + def checkJump(before, expectedAfter): + mockPlayer.position = before + uut.jump() + assert mockPlayer.position == expectedAfter + + # Key point is initially unset + assert uut.keyPoint is None + + # If no song is selected, setting the key point has no effect + assert uut.song is None + uut.keyPoint = 0.5 + assert uut.keyPoint is None + + # With a song selected, key point can be set and jumping works + uut.addSong(testSongs[0]) + uut.keyPoints = [0.3, 0.5] + + uut.keyPoint = 0.6 + assert uut.keyPoint == 0.6 + checkJump(0.8, 0.6) + + # When another song is selected, the key point is set to 0.0 + uut.addSong(testSongs[1]) + uut.song = 1 + assert uut.keyPoint == 0.0 + checkJump(0.5, 0.0) + + # If the selected song has stored key points, the key point is set to the first one instead + uut.song = 0 + assert uut.keyPoint == 0.3 + checkJump(0.5, 0.3) + +def test_keyPointListAndSongSelection(uut, testSongs): + # Key point list is initially unset, since no song is selected + assert uut.keyPoint is None + + # If no song is selected, setting the key point list has no effect + assert uut.song is None + uut.keyPoints = [0.5] + assert uut.keyPoints is None + + # When a song is added, key point list is initialized to empty + uut.addSong(testSongs[0]) + assert uut.keyPoints == [] + + # A new list can be assigned to the song, but it does not affect the current key point + uut.keyPoints = [0.1, 0.3] + assert uut.keyPoints == [0.1, 0.3] + assert uut.keyPoint == 0.0 + + # Each song has its own list of key points + uut.addSong(testSongs[1]) + uut.song = 1 + uut.keyPoints = [0.4] + + uut.song = 0 + assert uut.keyPoints == [0.1, 0.3] + uut.song = 1 + assert uut.keyPoints == [0.4] + +def test_keyPointEdgeCases(uut, testSongs): + uut.addSong(testSongs[0]) + + # Key point cannot be unset + uut.keyPoint = None + assert uut.keyPoint == 0.0 + + # Valid key points are in [0, 1) + uut.keyPoint = -0.1 + assert uut.keyPoint == 0.0 + + uut.keyPoint = 1.0 + assert uut.keyPoint == 0.0 + + uut.keyPoint = 0.999 + assert uut.keyPoint == 0.999 + +def test_keyPointListEdgeCases(uut, testSongs): + uut.addSong(testSongs[0]) + + # Key point list cannot be unset + uut.keyPoints = None + assert uut.keyPoints == [] + + # Appending to the list has no effect + uut.keyPoints.append(0.5) + assert uut.keyPoints == [] + + # Added key points are automatically de-duplicated, sanitized and sorted to ascending order + uut.keyPoints = [0.2, 0.4, 0.1, 0.2, None, -0.5, 1.0, 1.5] + assert uut.keyPoints == [0.1, 0.2, 0.4] + +def test_keyPointSelectionNotification(uut, testSongs): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerKeyPointSelectionCallback(callback) + assert not called + + # Selecting a song for the first time sets the key point to 0.0 + uut.addSong(testSongs[0]) + assert called + assert receivedValue == 0.0 + called = False + + # Changing the key point triggers a notification + uut.keyPoint = 0.5 + assert called + assert receivedValue == 0.5 + called = False + + # Adding list of key points does not trigger a notification + uut.keyPoints = [0.2, 0.4] + assert not called + + # Assigning the same key point again does not trigger a notification + uut.keyPoint = 0.5 + assert not called + + # Changing song triggers the notification + uut.addSong(testSongs[1]) + uut.song = 1 + assert called + assert receivedValue == 0.0 + called = False + + # But only if the key point really changes + uut.keyPoint = 0.2 + assert called + assert receivedValue == 0.2 + called = False + + uut.song = 0 + assert not called + +def test_keyPointListNotification(uut, testSongs): + called = False + receivedValue = None + def callback(value): + nonlocal called, receivedValue + called = True + receivedValue = value + + uut.registerKeyPointListCallback(callback) + assert not called + + # Adding the first song triggers since the list is now not None + uut.addSong(testSongs[0]) + assert called + assert receivedValue == [] + called = False + + # Adding list of key points triggers + uut.keyPoints = [0.2, 0.4] + assert called + assert receivedValue == [0.2, 0.4] + called = False + + # Same list does not trigger + uut.keyPoints = [0.2, 0.4] + assert called + assert receivedValue == [0.2, 0.4] + called = False + + # Incrementing list of key points triggers after sanitization + uut.keyPoints += [0.2, None, 0.1] + assert called + assert receivedValue == [0.1, 0.2, 0.4] + called = False + + # Changing song triggers + uut.addSong(testSongs[1]) + uut.song = 1 + assert called + assert receivedValue == [] + called = False + + # But only if the list really changed + uut.keyPoints = [0.1, 0.2, 0.4] + assert called + assert receivedValue == [0.1, 0.2, 0.4] + called = False + + uut.song = 0 + assert not called diff --git a/solo-tool-project/test/solo_tool_songs_integrationtest.py b/solo-tool-project/test/solo_tool_songs_integrationtest.py new file mode 100644 index 0000000..caa4a30 --- /dev/null +++ b/solo-tool-project/test/solo_tool_songs_integrationtest.py @@ -0,0 +1,134 @@ +import pytest + +from fixtures import soloTool as uut, mockPlayer, testSongs + +def test_songSelectionFlow(uut, mockPlayer, testSongs): + # Initially, song list is empty and no song is selected + assert uut.song is None + assert mockPlayer.currentSong == None + assert uut.songs == [] + + # When the first song is added, it is selected automatically + uut.addSong(testSongs[0]) + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + assert uut.songs == testSongs[0:1] + + # Subsequently added songs are not selected automatically + # Song list order is addition order + for i, song in enumerate(testSongs[1:]): + uut.addSong(song) + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + assert uut.songs == testSongs[0:i + 2] + + # Songs are selected by index + for i, s in enumerate(uut.songs): + uut.song = i + assert uut.song == i + assert mockPlayer.currentSong == uut.songs[i] + +def test_songSelectionEdgeCases(uut, mockPlayer, testSongs): + # When no songs are available, selecting has no effect + uut.song = 0 + assert uut.song == None + assert mockPlayer.currentSong == None + + for song in testSongs: + uut.addSong(song) + + # The current song cannot be de-selected + uut.song = None + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + + # Non-existent songs cannot be selected + uut.song = -1 + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + + uut.song = len(testSongs) + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + +def test_songAdditionEdgeCases(uut, mockPlayer, testSongs): + for song in testSongs: + uut.addSong(song) + + # Modifying the song list directly has no effect + uut.songs.append("something") + assert uut.songs == testSongs + assert mockPlayer.currentSong == testSongs[0] + + # Same song cannot be added twice + uut.addSong(testSongs[0]) + assert uut.song == 0 + assert mockPlayer.currentSong == testSongs[0] + assert uut.songs == testSongs + +def test_songSelectionNotification(uut, testSongs): + selectionCalled = False + selectionValue = None + def selectionCallback(value): + nonlocal selectionCalled, selectionValue + selectionCalled = True + selectionValue = value + + uut.registerSongSelectionCallback(selectionCallback) + assert not selectionCalled + + # Adding the first song triggers because the song is automatically selected + uut.addSong(testSongs[0]) + + assert selectionCalled + assert selectionValue == 0 + selectionCalled = False + + # Adding more songs does not trigger + for i, song in enumerate(testSongs[1:]): + uut.addSong(song) + assert not selectionCalled + + # Selecting another song triggers + uut.song = 1 + assert selectionCalled + assert selectionValue == 1 + selectionCalled = False + + # Selecting the currently selected song does not trigger + uut.song = 1 + assert not selectionCalled + +def test_songListNotification(uut, testSongs): + listCalled = False + listValue = None + def listCallback(value): + nonlocal listCalled, listValue + listCalled = True + listValue = value + + uut.registerSongListCallback(listCallback) + assert not listCalled + + # Adding the first song triggers + uut.addSong(testSongs[0]) + + assert listCalled + assert listValue == testSongs[0:1] + listCalled = False + + # Adding more songs triggers + for i, song in enumerate(testSongs[1:]): + uut.addSong(song) + + assert listCalled + assert listValue == testSongs[0:i + 2] + listCalled = False + + # Modifying the list in place does not trigger + uut.songs.append("something") + assert not listCalled + + # Adding an existing song does not trigger + uut.addSong(testSongs[0]) + assert not listCalled diff --git a/solo-tool-project/test/solo_tool_volume_integrationtest.py b/solo-tool-project/test/solo_tool_volume_integrationtest.py new file mode 100644 index 0000000..cc1aeef --- /dev/null +++ b/solo-tool-project/test/solo_tool_volume_integrationtest.py @@ -0,0 +1,54 @@ +import pytest + +from fixtures import soloTool as uut, mockPlayer, testSongs + +def test_perSongVolumeFlow(uut, mockPlayer, testSongs): + # Before a song is added, the volume starts at 100% + assert uut.song is None + assert mockPlayer.currentSong == None + assert uut.volume == 1.0 + assert mockPlayer.volume == 1.0 + + # When songs are added, their volume starts at 100% + uut.addSong(testSongs[0]) + assert uut.song == 0 + assert uut.volume == 1.0 + assert mockPlayer.volume == 1.0 + + # It's possible to change the volume + uut.volume = 0.5 + assert uut.volume == 0.5 + assert mockPlayer.volume == 0.5 + + # New song song is added, volume stays because the new song is not selected + uut.addSong(testSongs[1]) + assert uut.song == 0 + assert uut.volume == 0.5 + assert mockPlayer.volume == 0.5 + + # Select new song, volume is 100% + uut.song = 1 + assert uut.volume == 1.0 + assert mockPlayer.volume == 1.0 + + uut.volume = 0.75 + + # Previous song retains its volume + uut.song = 0 + assert uut.volume == 0.5 + assert mockPlayer.volume == 0.5 + + # New song also + uut.song = 1 + assert uut.volume == 0.75 + assert mockPlayer.volume == 0.75 + +def test_perSongVolumeEdgeCases(uut, mockPlayer, testSongs): + # If the player volume is not 100% when the first song is added, it is set to 100% + uut.volume = 0.5 + assert mockPlayer.volume == 0.5 + + uut.addSong(testSongs[0]) + assert uut.volume == 1.0 + assert mockPlayer.volume == 1.0 + diff --git a/solo-tool-project/test/test.flac b/solo-tool-project/test/test.flac Binary files differdeleted file mode 100644 index 9164735..0000000 --- a/solo-tool-project/test/test.flac +++ /dev/null diff --git a/solo-tool-project/test/test.mp3 b/solo-tool-project/test/test.mp3 Binary files differdeleted file mode 100644 index 3c353b7..0000000 --- a/solo-tool-project/test/test.mp3 +++ /dev/null diff --git a/solo-tool-project/test/test_session.json b/solo-tool-project/test/test_session.json deleted file mode 100644 index aed1e11..0000000 --- a/solo-tool-project/test/test_session.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "path" : "test.flac", - "key_positions" : null - }, - { - "path" : "test.mp3", - "key_positions" : [0.1, 0.3] - } -] |