diff options
author | Eddy Pedroni <epedroni@pm.me> | 2024-11-09 20:35:56 +0100 |
---|---|---|
committer | Eddy Pedroni <epedroni@pm.me> | 2024-11-09 20:35:56 +0100 |
commit | cda8197669409689be291660f93cb288ab2d31b3 (patch) | |
tree | 81db9b0c7c0491e0737cbffb39af6b935c0dfeb8 /solo-tool-project | |
parent | a2257a900d4fffd6f94b73f1c48c62370ed1d684 (diff) |
Migrate to project-based structure
Diffstat (limited to 'solo-tool-project')
20 files changed, 2349 insertions, 0 deletions
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 Binary files differnew file mode 100644 index 0000000..9164735 --- /dev/null +++ b/solo-tool-project/test/test.flac diff --git a/solo-tool-project/test/test.mp3 b/solo-tool-project/test/test.mp3 Binary files differnew file mode 100644 index 0000000..3c353b7 --- /dev/null +++ b/solo-tool-project/test/test.mp3 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] + ] + } +] |