diff options
-rw-r--r-- | cli-project/src/solo_tool_cli.py | 4 | ||||
-rw-r--r-- | solo-tool-project/src/solo_tool/handlers.py | 4 | ||||
-rw-r--r-- | solo-tool-project/src/solo_tool/midi_controller_actition.py | 43 | ||||
-rw-r--r-- | solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py | 89 | ||||
-rw-r--r-- | solo-tool-project/src/solo_tool/midi_wrapper_mido.py | 28 | ||||
-rw-r--r-- | solo-tool-project/test/midi_actition_pedal_integrationtest.py | 118 | ||||
-rw-r--r-- | solo-tool-project/test/midi_launchpad_mini_integrationtest.py | 18 | ||||
-rw-r--r-- | solo-tool-project/test/player_mock.py | 2 | ||||
-rw-r--r-- | web-project/src/solo_tool_web.py | 8 |
9 files changed, 239 insertions, 75 deletions
diff --git a/cli-project/src/solo_tool_cli.py b/cli-project/src/solo_tool_cli.py index 89879fc..a52d4b3 100644 --- a/cli-project/src/solo_tool_cli.py +++ b/cli-project/src/solo_tool_cli.py @@ -2,7 +2,7 @@ import sys import time from solo_tool import SoloTool -from solo_tool.midi_controller_launchpad_mini import MidiController +from solo_tool.midi_controller_launchpad_mini import LaunchpadMiniController from solo_tool.session_manager import SessionManager def main(): @@ -14,7 +14,7 @@ def main(): sessionManager = SessionManager(args[0]) soloTool = sessionManager.loadSession(args[1]) - midiController = MidiController(soloTool) + midiController = LaunchpadMiniController(soloTool) try: midiController.connect() except: diff --git a/solo-tool-project/src/solo_tool/handlers.py b/solo-tool-project/src/solo_tool/handlers.py index 1820e86..3beb0fb 100644 --- a/solo-tool-project/src/solo_tool/handlers.py +++ b/solo-tool-project/src/solo_tool/handlers.py @@ -38,9 +38,9 @@ def seekRelative(st: SoloTool, delta: float) -> Callable[[], None]: st.position += delta return f -def seekAbsolute(st: SoloTool, delta: float) -> Callable[[], None]: +def seekAbsolute(st: SoloTool, new: float) -> Callable[[], None]: def f(): - st.position = delta + st.position = new return f def positionToKeyPoint(st: SoloTool) -> Callable[[], None]: 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..184b9c8 --- /dev/null +++ b/solo-tool-project/src/solo_tool/midi_controller_actition.py @@ -0,0 +1,43 @@ +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 + self._inPort = mido.open_input("f_midi") + self._inPort.callback = self._midoCallback + + def setCallback(self, callback: Callable[[int, int], None]) -> None: + self._callback = callback + + def _midoCallback(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 != 15: + 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 625e2ef..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 @@ -49,19 +76,19 @@ class MidiController: } for i in range(0, 8): - volume = round(MidiController.MIN_PLAYBACK_VOLUME + MidiController.PLAYBACK_VOLUME_STEP * i, 1) + 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) + 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._setAllLEDs(MidiController.LED_OFF) + self._setAllLEDs(LaunchpadMiniController.LED_OFF) self._midiWrapper.disconnect() def _callback(self, msg): @@ -73,27 +100,27 @@ class MidiController: def _updatePlayPauseButton(self, playing): if playing: - self._setButtonLED(7, 0, MidiController.LED_GREEN) + self._setButtonLED(7, 0, LaunchpadMiniController.LED_GREEN) else: - self._setButtonLED(7, 0, MidiController.LED_YELLOW) + 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) + 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]) @@ -103,7 +130,7 @@ class MidiController: self._setButtonLED(row, col, colour) def _initialiseButtonLEDs(self): - self._setAllLEDs(MidiController.LED_OFF) + self._setAllLEDs(LaunchpadMiniController.LED_OFF) # volume buttons self._updateVolumeRow(self._soloTool.volume) @@ -112,22 +139,22 @@ class MidiController: self._updateRateRow(self._soloTool.rate) # playback control - self._setButtonLED(6, 0, MidiController.LED_YELLOW) + self._setButtonLED(6, 0, LaunchpadMiniController.LED_YELLOW) self._updatePlayPauseButton(self._soloTool.playing) # Key point 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) + 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/test/midi_actition_pedal_integrationtest.py b/solo-tool-project/test/midi_actition_pedal_integrationtest.py new file mode 100644 index 0000000..092ff64 --- /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 = 15 +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 1a99cd4..6841f24 100644 --- a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py +++ b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py @@ -1,7 +1,7 @@ import pytest from mido import Message -from solo_tool.midi_controller_launchpad_mini import MidiController +from solo_tool.midi_controller_launchpad_mini import LaunchpadMiniController from fixtures import soloTool, mockPlayer, testSongs LED_RED = 3 @@ -38,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) @@ -56,7 +56,7 @@ def midiWrapperMock(): @pytest.fixture def uut(soloTool, midiWrapperMock): - return MidiController(soloTool, midiWrapperMock) + return LaunchpadMiniController(soloTool, midiWrapperMock) def test_startAndPauseButtons(uut, midiWrapperMock, mockPlayer): uut.connect() @@ -65,11 +65,11 @@ def test_startAndPauseButtons(uut, midiWrapperMock, mockPlayer): midiWrapperMock.simulateInput(playPauseButton) assert mockPlayer.playing - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_GREEN, 0) midiWrapperMock.simulateInput(playPauseButton) assert not mockPlayer.playing - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_YELLOW, 0) def test_startPauseButtonLed(uut, midiWrapperMock, mockPlayer, soloTool): uut.connect() @@ -78,11 +78,11 @@ def test_startPauseButtonLed(uut, midiWrapperMock, mockPlayer, soloTool): mockPlayer.playing = True mockPlayer.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_GREEN, 0) mockPlayer.playing = False mockPlayer.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) + assert midiWrapperMock.getLatestMessage() == (playPauseButton, LaunchpadMiniController.LED_YELLOW, 0) def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): soloTool.addSong(testSongs[0]) diff --git a/solo-tool-project/test/player_mock.py b/solo-tool-project/test/player_mock.py index e9e9ead..a234e80 100644 --- a/solo-tool-project/test/player_mock.py +++ b/solo-tool-project/test/player_mock.py @@ -30,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): diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py index 314c97a..b014061 100644 --- a/web-project/src/solo_tool_web.py +++ b/web-project/src/solo_tool_web.py @@ -7,9 +7,9 @@ import click from fastapi import HTTPException from urllib.parse import unquote -from solo_tool import SoloTool +from solo_tool import SoloTool, handlers from solo_tool.session_manager import SessionManager -from solo_tool import handlers +from solo_tool.midi_controller_actition import ActitionController def fileName(path: str) -> str: return unquote(basename(splitext(path)[0])) @@ -29,6 +29,7 @@ def songList(st: SoloTool, songDrawer) -> None: sessions = {} sessionManager = None +midiPedal = ActitionController() @ui.page('/{sessionId}') def sessionPage(sessionId: str): @@ -41,6 +42,7 @@ def sessionPage(sessionId: str): ui.page_title(sessionId) st = sessions[sessionId] + midiPedal.setSoloTool(st) # Manage songs dialog with ui.dialog() as manageSongsDialog: @@ -135,5 +137,5 @@ def main(port, refresh, reload, session_path): # Hardcoded dev settings if __name__ in {"__main__", "__mp_main__"}: - start(8080, 0.5, True, "https://files.0xf7.com") + start(8080, 0.5, False, "https://files.0xf7.com") #start(8080, 0.5, True, "/home/eddy/music/sessions") |