aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile36
-rw-r--r--cli-project/pyproject.toml4
-rw-r--r--cli-project/src/solo_tool_cli.py15
-rw-r--r--deployment/solo-tool.service12
-rwxr-xr-xdeployment/start-solo-tool.sh10
-rw-r--r--doc/diagram.drawio565
-rw-r--r--doc/known-issues.md2
-rw-r--r--gui-project/src/MainWindow.py111
-rw-r--r--gui-project/src/mainwindow.ui161
-rw-r--r--gui-project/src/solo_tool_gui.py265
-rw-r--r--pacman.txt8
-rw-r--r--readme.md34
-rw-r--r--requirements.txt2
-rw-r--r--solo-tool-project/pyproject.toml6
-rw-r--r--solo-tool-project/src/solo_tool/abcontroller.py82
-rw-r--r--solo-tool-project/src/solo_tool/handlers.py84
-rw-r--r--solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py105
-rw-r--r--solo-tool-project/src/solo_tool/notifier.py5
-rw-r--r--solo-tool-project/src/solo_tool/player_mpv.py53
-rw-r--r--solo-tool-project/src/solo_tool/player_vlc.py55
-rw-r--r--solo-tool-project/src/solo_tool/playlist.py40
-rw-r--r--solo-tool-project/src/solo_tool/session_manager.py55
-rw-r--r--solo-tool-project/src/solo_tool/solo_tool.py254
-rw-r--r--solo-tool-project/test/abcontroller_unittest.py272
-rw-r--r--solo-tool-project/test/fixtures.py32
-rw-r--r--solo-tool-project/test/handlers_integrationtest.py32
-rw-r--r--solo-tool-project/test/midi_launchpad_mini_integrationtest.py313
-rw-r--r--solo-tool-project/test/notifier_unittest.py6
-rw-r--r--solo-tool-project/test/player_mock.py27
-rw-r--r--solo-tool-project/test/playlist_unittest.py148
-rw-r--r--solo-tool-project/test/session_manager_unittest.py186
-rw-r--r--solo-tool-project/test/solo_tool_integrationtest.py568
-rw-r--r--solo-tool-project/test/solo_tool_keypoints_integrationtest.py194
-rw-r--r--solo-tool-project/test/solo_tool_songs_integrationtest.py141
-rw-r--r--solo-tool-project/test/test.flacbin31743252 -> 0 bytes
-rw-r--r--solo-tool-project/test/test.mp3bin5389533 -> 0 bytes
-rw-r--r--solo-tool-project/test/test_session.json13
-rw-r--r--web-project/pyproject.toml (renamed from gui-project/pyproject.toml)12
-rw-r--r--web-project/src/solo_tool_web.py131
39 files changed, 1285 insertions, 2754 deletions
diff --git a/Makefile b/Makefile
index 74a7976..9556dd9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,20 +1,36 @@
test: all
- cd solo-tool-project/test && ../../venv/bin/pytest *test.py
+ cd solo-tool-project/test && ../../.venv/bin/pytest *test.py
-all: venv .git/hooks/pre-commit
+all: .venv .git/hooks/pre-commit
clean:
- rm -rf venv
+ rm -rf .venv
.git/hooks/pre-commit: pre-commit
install -m 755 pre-commit .git/hooks/pre-commit
-venv: venv/touchfile
+.venv: .venv/touchfile
-venv/touchfile: requirements.txt solo-tool-project/pyproject.toml cli-project/pyproject.toml gui-project/pyproject.toml
- rm -rf venv
- python -m venv venv
- ./venv/bin/pip install -r requirements.txt
- touch venv/touchfile
+.venv/touchfile: requirements.txt solo-tool-project/pyproject.toml cli-project/pyproject.toml web-project/pyproject.toml
+ rm -rf .venv
+ uv venv
+ uv pip install -r requirements.txt
+ touch .venv/touchfile
-.PHONY: all test clean
+gui: .venv/touchfile
+ ./.venv/bin/python web-project/src/solo_tool_web.py 8080
+
+install: deployment/solo-tool.service deployment/start-solo-tool.sh
+ mkdir -p ~/.config/systemd/user
+ install -o eddy -g eddy -m 644 deployment/solo-tool.service ~/.config/systemd/user
+ chmod 755 deployment/start-solo-tool.sh
+ systemctl --user daemon-reload
+ systemctl --user enable solo-tool.service
+ systemctl --user restart solo-tool.service
+
+uninstall:
+ systemctl --user disable --now solo-tool.service
+ rm ~/.config/systemd/user/solo-tool.service
+ systemctl --user daemon-reload
+
+.PHONY: all test clean gui install uninstall
diff --git a/cli-project/pyproject.toml b/cli-project/pyproject.toml
index 3e2c855..489d1ec 100644
--- a/cli-project/pyproject.toml
+++ b/cli-project/pyproject.toml
@@ -8,9 +8,9 @@ authors = [
{ name = "Eddy Pedroni", email = "epedroni@pm.me" },
]
description = "A CLI frontend for the solo_tool library"
-requires-python = ">=3.12"
+requires-python = ">=3.13"
dependencies = [
- "solo_tool"
+ "solo_tool>=2.0"
]
dynamic = ["version"]
diff --git a/cli-project/src/solo_tool_cli.py b/cli-project/src/solo_tool_cli.py
index 5cc1537..d0f39c1 100644
--- a/cli-project/src/solo_tool_cli.py
+++ b/cli-project/src/solo_tool_cli.py
@@ -3,6 +3,7 @@ import time
from solo_tool import SoloTool
from solo_tool.midi_controller_launchpad_mini import MidiController
+from solo_tool.session_manager import loadSession
def main():
args = sys.argv[1:]
@@ -10,20 +11,16 @@ def main():
print("Please provide path to session file")
sys.exit(1)
- soloTool = SoloTool()
- soloTool.loadSession(args[0])
-
- def tick():
- soloTool.tick()
- threading.Timer(0.1, tick).start()
+ soloTool = loadSession(args[0])
midiController = MidiController(soloTool)
midiController.connect()
try:
- while(True):
- time.sleep(0.1)
- soloTool.tick()
+ while True:
+ raw = input("> ")
+ if raw == "q":
+ break
except KeyboardInterrupt:
pass
finally:
diff --git a/deployment/solo-tool.service b/deployment/solo-tool.service
new file mode 100644
index 0000000..e33a036
--- /dev/null
+++ b/deployment/solo-tool.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Solo tool web frontend service
+After=network-online.target sound.target
+
+[Service]
+WorkingDirectory=/home/eddy/git/solo-tool
+Environment="SESSION_DIR=/home/eddy/music/sessions"
+Environment="SONG_POOL=/home/eddy/music/songs"
+ExecStart=/home/eddy/git/solo-tool/deployment/start-solo-tool.sh
+
+[Install]
+WantedBy=default.target
diff --git a/deployment/start-solo-tool.sh b/deployment/start-solo-tool.sh
new file mode 100755
index 0000000..d1d38f7
--- /dev/null
+++ b/deployment/start-solo-tool.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/bash
+
+# Get latest version
+git pull
+
+# Bootstrap venv
+make
+
+# Run web UI
+./.venv/bin/python web-project/src/solo_tool_web.py 80
diff --git a/doc/diagram.drawio b/doc/diagram.drawio
index f900292..3c37bc7 100644
--- a/doc/diagram.drawio
+++ b/doc/diagram.drawio
@@ -1,569 +1,250 @@
-<mxfile host="Electron" modified="2024-11-10T11:22:07.508Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" etag="iuxE9_e737GxBbJf21Ic" version="22.1.2" type="device" pages="3">
- <diagram id="g-wcGVps3MkI6_XAwNEs" name="Core">
- <mxGraphModel dx="1561" dy="946" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
- <root>
- <mxCell id="0" />
- <mxCell id="1" parent="0" />
- <mxCell id="5IB1TeDA8rQgVov2ilYq-1" value="solo tool" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;glass=0;shadow=0;sketch=0;fillColor=none;align=left;verticalAlign=top;" parent="1" vertex="1">
- <mxGeometry x="410" y="227" width="530" height="693" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="718ck8ZuCs3BOJF-nClt-3" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-6" value="&lt;div&gt;Current&lt;/div&gt;&lt;div&gt;position&lt;br&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="MU1YSbBTE73kW5gn9q-V-5" vertex="1" connectable="0">
- <mxGeometry x="0.2328" y="-1" relative="1" as="geometry">
- <mxPoint x="10" y="1" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-1" value="&lt;div&gt;media player&lt;/div&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" parent="1" vertex="1">
- <mxGeometry x="440" y="648" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-11" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="300" y="508.0344827586207" as="sourcePoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-12" value="&lt;div&gt;Set current&lt;/div&gt;&lt;div&gt;song&lt;br&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="9fq4LfI0W2HX4gTsbRt6-3" vertex="1" connectable="0">
- <mxGeometry x="0.2112" relative="1" as="geometry">
- <mxPoint y="-47" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-5" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-4" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-4" value="Play" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="260" y="525" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-7" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-6" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-6" value="Pause" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="250" y="562" width="50" height="20" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-9" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-8" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-8" value="Stop" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="260" y="598" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-10" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-10" value="Set playback rate" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="190" y="635" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-12" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="9fq4LfI0W2HX4gTsbRt6-12" value="Set playback position" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="170" y="671" width="130" height="20" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-1" target="5IB1TeDA8rQgVov2ilYq-2" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-1" value="Add a/b limit" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="840" y="68" width="80" height="20" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-3" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-3" value="a/b controller" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" parent="1" vertex="1">
- <mxGeometry x="650" y="408" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-9" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-9" value="Enable a/b mode" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="830" y="128" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="UmMSCIYVAIiNOvlXGdHZ-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-11" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-11" value="playlist" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" parent="1" vertex="1">
- <mxGeometry x="440" y="408" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-13" target="5IB1TeDA8rQgVov2ilYq-2" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-13" value="Add song" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="360" y="98" width="70" height="20" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-15" target="MU1YSbBTE73kW5gn9q-V-11" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-15" value="Choose song" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="340" y="128" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-17" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-17" value="Set volume" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="220" y="708" width="80" height="20" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="MU1YSbBTE73kW5gn9q-V-22" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="MU1YSbBTE73kW5gn9q-V-22" value="Choose a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="840" y="98" width="100" height="20" as="geometry" />
- </mxCell>
- <mxCell id="718ck8ZuCs3BOJF-nClt-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="9fq4LfI0W2HX4gTsbRt6-1" target="718ck8ZuCs3BOJF-nClt-3" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="760.3103448275863" y="608" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="718ck8ZuCs3BOJF-nClt-5" value="&lt;div&gt;Current&lt;/div&gt;&lt;div&gt;position&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="718ck8ZuCs3BOJF-nClt-4" vertex="1" connectable="0">
- <mxGeometry x="0.2833" relative="1" as="geometry">
- <mxPoint x="-23" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="718ck8ZuCs3BOJF-nClt-6" value="&lt;div&gt;Set current&lt;/div&gt;&lt;div&gt;position&lt;br&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="718ck8ZuCs3BOJF-nClt-4" vertex="1" connectable="0">
- <mxGeometry x="0.2833" relative="1" as="geometry">
- <mxPoint x="-46" y="-120" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="718ck8ZuCs3BOJF-nClt-3" value="tick" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
- <mxGeometry x="750" y="648" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.25;exitY=1;exitDx=0;exitDy=0;" parent="1" source="5IB1TeDA8rQgVov2ilYq-2" target="MU1YSbBTE73kW5gn9q-V-11" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-4" value="Add song" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="5IB1TeDA8rQgVov2ilYq-3" vertex="1" connectable="0">
- <mxGeometry x="0.2933" y="3" relative="1" as="geometry">
- <mxPoint x="22" y="-4" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="5IB1TeDA8rQgVov2ilYq-2" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-6" value="&lt;div&gt;Add a/b limit&lt;br&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="5IB1TeDA8rQgVov2ilYq-5" vertex="1" connectable="0">
- <mxGeometry x="0.2833" y="-2" relative="1" as="geometry">
- <mxPoint x="-32" y="-7" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-2" value="&lt;div&gt;session&lt;/div&gt;&lt;div&gt;manager&lt;br&gt;&lt;/div&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" parent="1" vertex="1">
- <mxGeometry x="550" y="258" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="5IB1TeDA8rQgVov2ilYq-14" target="5IB1TeDA8rQgVov2ilYq-2" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-14" value="Load session" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="445" y="58" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="5IB1TeDA8rQgVov2ilYq-15" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="630" y="258" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="5IB1TeDA8rQgVov2ilYq-15" value="Save session" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="445" y="28" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="PYF8YKytvgJsIEJjpRti-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="PYF8YKytvgJsIEJjpRti-1" target="718ck8ZuCs3BOJF-nClt-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="PYF8YKytvgJsIEJjpRti-1" value="Tick" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="700" y="940" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-1" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-1" value="Next a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="830" y="160" width="100" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-2" target="MU1YSbBTE73kW5gn9q-V-3" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-2" value="Previous a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="830" y="190" width="120" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-5" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-5" value="Jump to A" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="230" y="737" width="70" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-7" target="MU1YSbBTE73kW5gn9q-V-11" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-7" value="Next song" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="350" y="160" width="80" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ofcqv09syQELO3cvxpxf-8" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="520" y="410" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-8" value="Previous song" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="330" y="190" width="100" height="20" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-11" target="9fq4LfI0W2HX4gTsbRt6-1" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-15" value="playing state&lt;br&gt;callback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ofcqv09syQELO3cvxpxf-14" vertex="1" connectable="0">
- <mxGeometry x="0.2283" y="-2" relative="1" as="geometry">
- <mxPoint x="22" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-11" value="&lt;div&gt;notifier&lt;/div&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" parent="1" vertex="1">
- <mxGeometry x="505" y="790" width="160" height="80" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ofcqv09syQELO3cvxpxf-12" target="ofcqv09syQELO3cvxpxf-11" edge="1">
- <mxGeometry relative="1" as="geometry" />
- </mxCell>
- <mxCell id="ofcqv09syQELO3cvxpxf-12" value="Register playing state callback" style="text;html=1;align=right;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
- <mxGeometry x="110" y="820" width="200" height="20" as="geometry" />
- </mxCell>
- </root>
- </mxGraphModel>
- </diagram>
- <diagram id="yK3rgzEW7m2RTtpwjvJ6" name="MIDI">
- <mxGraphModel dx="2015" dy="395" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
- <root>
- <mxCell id="OKDEixDBbmxQMGRGU1jO-0" />
- <mxCell id="OKDEixDBbmxQMGRGU1jO-1" parent="OKDEixDBbmxQMGRGU1jO-0" />
- <mxCell id="KjrEduvjUaLFBeyMDJhb-19" value="" style="endArrow=classic;html=1;rounded=0;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" parent="OKDEixDBbmxQMGRGU1jO-1" edge="1">
- <mxGeometry width="50" height="50" relative="1" as="geometry">
- <mxPoint x="-270" y="247" as="sourcePoint" />
- <mxPoint x="-110" y="247" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="KjrEduvjUaLFBeyMDJhb-20" value="" style="endArrow=classic;html=1;rounded=0;exitX=0;exitY=0.75;exitDx=0;exitDy=0;" parent="OKDEixDBbmxQMGRGU1jO-1" edge="1">
- <mxGeometry width="50" height="50" relative="1" as="geometry">
- <mxPoint x="-110" y="280" as="sourcePoint" />
- <mxPoint x="-270" y="280.5" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="KjrEduvjUaLFBeyMDJhb-21" value="MIDI bus" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1">
- <mxGeometry x="-230" y="255" width="70" height="20" as="geometry" />
- </mxCell>
- <mxCell id="KjrEduvjUaLFBeyMDJhb-22" value="Device" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1">
- <mxGeometry x="-430" y="214.5" width="140" height="105.5" as="geometry" />
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;entryX=0;entryY=0.75;entryDx=0;entryDy=0;" parent="OKDEixDBbmxQMGRGU1jO-1" source="fBglSRjiR8ACvM9LEDBr-1" target="fBglSRjiR8ACvM9LEDBr-3" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="80" y="294" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-3" value="callback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="cDpx_x92aZCDt8U1TkIR-2" vertex="1" connectable="0">
- <mxGeometry x="-0.2773" y="-1" relative="1" as="geometry">
- <mxPoint x="10" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="fBglSRjiR8ACvM9LEDBr-1" value="mido" style="rounded=0;whiteSpace=wrap;html=1;" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1">
- <mxGeometry x="-100" y="200" width="80" height="134.5" as="geometry" />
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;startArrow=classic;startFill=1;endArrow=none;endFill=0;" parent="OKDEixDBbmxQMGRGU1jO-1" source="fBglSRjiR8ACvM9LEDBr-3" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="370" y="232.8888888888889" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-5" value="Set mapping" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="cDpx_x92aZCDt8U1TkIR-4" vertex="1" connectable="0">
- <mxGeometry x="-0.2097" y="-2" relative="1" as="geometry">
- <mxPoint x="17" y="-2" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;startArrow=none;startFill=0;endArrow=classic;endFill=1;" parent="OKDEixDBbmxQMGRGU1jO-1" source="fBglSRjiR8ACvM9LEDBr-3" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="360" y="300.66666666666663" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-7" value="Play/pause/stop&lt;br&gt;etc" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="cDpx_x92aZCDt8U1TkIR-6" vertex="1" connectable="0">
- <mxGeometry x="-0.26" y="-2" relative="1" as="geometry">
- <mxPoint x="14" y="-21" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-0" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;startArrow=classic;startFill=1;endArrow=none;endFill=0;" parent="OKDEixDBbmxQMGRGU1jO-1" source="fBglSRjiR8ACvM9LEDBr-3" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="360" y="267.33333333333326" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="fBglSRjiR8ACvM9LEDBr-3" value="midi&lt;br&gt;interface" style="rounded=0;whiteSpace=wrap;html=1;" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1">
- <mxGeometry x="90" y="199.5" width="120" height="135.5" as="geometry" />
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-0" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.25;entryDx=0;entryDy=0;exitX=0;exitY=0.25;exitDx=0;exitDy=0;" parent="OKDEixDBbmxQMGRGU1jO-1" source="fBglSRjiR8ACvM9LEDBr-3" target="fBglSRjiR8ACvM9LEDBr-1" edge="1">
- <mxGeometry relative="1" as="geometry">
- <mxPoint x="80" y="241" as="sourcePoint" />
- <mxPoint x="210" y="530" as="targetPoint" />
- </mxGeometry>
- </mxCell>
- <mxCell id="cDpx_x92aZCDt8U1TkIR-1" value="send" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="cDpx_x92aZCDt8U1TkIR-0" vertex="1" connectable="0">
- <mxGeometry x="-0.168" y="5" relative="1" as="geometry">
- <mxPoint x="-10" y="-5" as="offset" />
- </mxGeometry>
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-15" value="" style="group" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1" connectable="0">
- <mxGeometry x="717" y="125" width="190" height="429" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-0" value="&lt;div&gt;SoloTool&lt;/div&gt;" style="rounded=0;whiteSpace=wrap;html=1;glass=0;shadow=0;sketch=0;align=right;verticalAlign=top;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry width="190" height="429" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-7" value="Play" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="223" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-9" value="Pause" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="254" width="50" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-11" value="Stop" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="284" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-13" value="Set playback rate" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="315" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-15" value="Set playback position" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="345" width="130" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-17" value="Add a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="71" width="80" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-21" value="Enable a/b mode" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="162" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-25" value="Add song" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="101" width="70" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-27" value="Choose song" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="193" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-29" value="Set volume" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="376" width="80" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-31" value="Choose a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="132" width="100" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-42" value="Load session" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="40" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-44" value="Save session" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="10" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="jLuthnc1TARuY79bHbOS-46" value="Tick" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-15" vertex="1">
- <mxGeometry x="10" y="406" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-16" value="" style="group" parent="OKDEixDBbmxQMGRGU1jO-1" vertex="1" connectable="0">
- <mxGeometry x="517" y="255" width="110" height="232" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-1" value="Play" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="91" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-2" value="Pause" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="122" width="50" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-3" value="Stop" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="152" width="40" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-4" value="Set playback rate" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="183" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-5" value="&lt;span style=&quot;color: rgb(0 , 0 , 0)&quot;&gt;Set volume&lt;/span&gt;" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontColor=#666666;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="214" width="78" height="18" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-7" value="Enable a/b mode" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="30" width="110" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-9" value="Choose song" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry y="61" width="90" height="20" as="geometry" />
- </mxCell>
- <mxCell id="FipM2rDAY6IncK5jYn7S-11" value="Choose a/b limit" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="FipM2rDAY6IncK5jYn7S-16" vertex="1">
- <mxGeometry width="100" height="20" as="geometry" />
- </mxCell>
- </root>
- </mxGraphModel>
- </diagram>
+<mxfile host="Electron" modified="2025-02-25T14:57:34.998Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" etag="YTK2v8-32YjVXFJp_mJd" version="22.1.2" type="device" pages="2">
<diagram id="PyNSc7ezSt42GBdjTBvd" name="Launchpad">
- <mxGraphModel dx="1561" dy="946" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
+ <mxGraphModel dx="1562" dy="963" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
<root>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-0" />
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-1" parent="ZtjfeE3uwfRsFhnWfLYL-0" />
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-39" value="0&lt;br&gt;volume&lt;br&gt;50%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-40" value="16&lt;br&gt;speed&lt;br&gt;50%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-41" value="32" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-42" value="48&lt;br&gt;previous&lt;br&gt;song" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-43" value="64" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-44" value="80" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="476" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-45" value="96&lt;br&gt;stop" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="604" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-45" value="96&lt;br&gt;jump to start" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="40" y="564" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-46" value="112&lt;br&gt;play/&lt;br&gt;pause" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="40" y="691" width="80" height="80" as="geometry" />
+ <mxGeometry x="40" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-84" value="1&lt;br&gt;volume&lt;br&gt;60%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-85" value="17&lt;br&gt;speed&lt;br&gt;60%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-86" value="33" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-87" value="49&lt;br&gt;-25%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-88" value="65" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-89" value="81" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-90" value="97" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="564" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-91" value="113" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="130" y="691" width="80" height="80" as="geometry" />
+ <mxGeometry x="130" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-93" value="2&lt;br&gt;volume&lt;br&gt;70%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-94" value="18&lt;br&gt;speed&lt;br&gt;70%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-95" value="34" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-96" value="50&lt;br&gt;-5%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-97" value="66" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-98" value="82" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="220" y="476" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-99" value="98&lt;br&gt;toggle AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="604" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-99" value="98" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="220" y="564" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-100" value="114&lt;br&gt;jump to A" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="220" y="691" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-100" value="114&lt;br&gt;jump to key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="220" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-102" value="3&lt;br&gt;volume&lt;br&gt;80%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-103" value="19&lt;br&gt;speed&lt;br&gt;80%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-104" value="35" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-105" value="51&lt;br&gt;-1%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-106" value="67" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-107" value="83" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-108" value="&lt;div&gt;99&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="564" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-109" value="115" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="310" y="691" width="80" height="80" as="geometry" />
+ <mxGeometry x="310" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-111" value="4&lt;br&gt;volume&lt;br&gt;90%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-112" value="20&lt;br&gt;speed&lt;br&gt;90%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-113" value="36" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-114" value="52&lt;br&gt;+1%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-115" value="68" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-116" value="84" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-117" value="100" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="400" y="564" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-118" value="116&lt;br&gt;set A" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="400" y="691" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-118" value="116" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="400" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-120" value="5&lt;br&gt;volume&lt;br&gt;100%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-121" value="21&lt;br&gt;speed&lt;br&gt;100%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-122" value="37" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-123" value="53&lt;br&gt;+5%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-124" value="69" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-125" value="85" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-126" value="&lt;div&gt;101&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="490" y="564" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-127" value="117&lt;br&gt;set B" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="490" y="691" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-127" value="117&lt;br&gt;set key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="490" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-129" value="6&lt;br&gt;volume&lt;br&gt;110%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-130" value="22&lt;br&gt;speed&lt;br&gt;110%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-131" value="38" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-132" value="54&lt;br&gt;+25%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-133" value="70" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-134" value="86" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-135" value="102" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="580" y="564" width="80" height="80" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-136" value="118&lt;br&gt;previous AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="580" y="691" width="80" height="80" as="geometry" />
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-136" value="118&lt;br&gt;previous key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="580" y="651" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-138" value="7&lt;br&gt;volume&lt;br&gt;120%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="80" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="40" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-139" value="23&lt;br&gt;speed&lt;br&gt;120%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="167" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="127" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-140" value="39" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="255" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="215" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-141" value="55&lt;br&gt;next song" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="342" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="302" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-142" value="71" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="429" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="389" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-143" value="87" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="516" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="476" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="ZtjfeE3uwfRsFhnWfLYL-144" value="103" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="604" width="80" height="80" as="geometry" />
+ <mxGeometry x="670" y="564" width="80" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-145" value="119&lt;br&gt;next key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
+ <mxGeometry x="670" y="651" width="80" height="80" as="geometry" />
+ </mxCell>
+ </root>
+ </mxGraphModel>
+ </diagram>
+ <diagram id="R-0UAU87gWX4lK6NCzNs" name="Web wireframe">
+ <mxGraphModel dx="1302" dy="803" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
+ <root>
+ <mxCell id="0" />
+ <mxCell id="1" parent="0" />
+ <mxCell id="0goJ5iq8U8227kam6OUo-1" value="Key point list" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="160" y="80" width="80" height="240" as="geometry" />
+ </mxCell>
+ <mxCell id="0goJ5iq8U8227kam6OUo-2" value="Volume slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="380" y="280" width="260" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="0goJ5iq8U8227kam6OUo-3" value="Speed" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="280" y="280" width="60" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="0goJ5iq8U8227kam6OUo-4" value="Speed +5" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="340" y="280" width="40" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="0goJ5iq8U8227kam6OUo-5" value="Speed -5" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="240" y="280" width="40" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="E9TFIlIWBKXALOEyTYeL-1" value="Seek slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="240" y="120" width="440" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="E9TFIlIWBKXALOEyTYeL-2" value="Prev song" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="240" y="200" width="80" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="e6dzTLVl2QyovQL1D1hT-10" value="Next song" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="600" y="200" width="80" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="2VOf0fCjGpZdvwMfuWx9-2" value="Key point slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="240" y="160" width="440" height="40" as="geometry" />
+ </mxCell>
+ <mxCell id="e5je7AeTKV-z7aj2oazw-2" value="Stop" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="320" y="200" width="80" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="e5je7AeTKV-z7aj2oazw-3" value="Play/pause" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="400" y="200" width="100" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="e5je7AeTKV-z7aj2oazw-4" value="Jump" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
+ <mxGeometry x="500" y="200" width="100" height="80" as="geometry" />
+ </mxCell>
+ <mxCell id="ZINFS9bsx5oSfdTS2e79-1" value="Full&lt;br&gt;screen" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
+ <mxGeometry x="640" y="280" width="40" height="40" as="geometry" />
</mxCell>
- <mxCell id="ZtjfeE3uwfRsFhnWfLYL-145" value="119&lt;br&gt;next AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">
- <mxGeometry x="670" y="691" width="80" height="80" as="geometry" />
+ <mxCell id="ZINFS9bsx5oSfdTS2e79-4" value="Song name" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
+ <mxGeometry x="240" y="80" width="440" height="40" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
diff --git a/doc/known-issues.md b/doc/known-issues.md
index f41bfc8..def8865 100644
--- a/doc/known-issues.md
+++ b/doc/known-issues.md
@@ -11,6 +11,8 @@
* AB list view in Qt GUI is currently not working correctly
* selection is not cleared properly when changing songs
* sometimes crashes when selecting limits with MIDI controller
+* AB limit stays the same when changing song
+ * Should switch to 0 if available?
* MIDI controller feature requests:
* play head location indicator
diff --git a/gui-project/src/MainWindow.py b/gui-project/src/MainWindow.py
deleted file mode 100644
index 137bd33..0000000
--- a/gui-project/src/MainWindow.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# Form implementation generated from reading ui file 'mainwindow.ui'
-#
-# Created by: PyQt5 UI code generator 5.15.6
-#
-# WARNING: Any manual changes made to this file will be lost when pyuic5 is
-# run again. Do not edit this file unless you know what you are doing.
-
-
-from PyQt5 import QtCore, QtGui, QtWidgets
-
-
-class Ui_MainWindow(object):
- def setupUi(self, MainWindow):
- MainWindow.setObjectName("MainWindow")
- MainWindow.resize(971, 767)
- self.centralwidget = QtWidgets.QWidget(MainWindow)
- self.centralwidget.setObjectName("centralwidget")
- self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
- self.verticalLayout.setObjectName("verticalLayout")
- self.listsLayout = QtWidgets.QHBoxLayout()
- self.listsLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint)
- self.listsLayout.setSpacing(6)
- self.listsLayout.setObjectName("listsLayout")
- self.songListView = QtWidgets.QListView(self.centralwidget)
- self.songListView.setObjectName("songListView")
- self.listsLayout.addWidget(self.songListView)
- self.abListView = QtWidgets.QListView(self.centralwidget)
- self.abListView.setObjectName("abListView")
- self.listsLayout.addWidget(self.abListView)
- self.verticalLayout.addLayout(self.listsLayout)
- self.slidersLayout = QtWidgets.QVBoxLayout()
- self.slidersLayout.setObjectName("slidersLayout")
- self.songSlider = QtWidgets.QSlider(self.centralwidget)
- self.songSlider.setMinimumSize(QtCore.QSize(0, 0))
- self.songSlider.setOrientation(QtCore.Qt.Horizontal)
- self.songSlider.setObjectName("songSlider")
- self.slidersLayout.addWidget(self.songSlider)
- self.aSlider = QtWidgets.QSlider(self.centralwidget)
- self.aSlider.setOrientation(QtCore.Qt.Horizontal)
- self.aSlider.setObjectName("aSlider")
- self.slidersLayout.addWidget(self.aSlider)
- self.bSlider = QtWidgets.QSlider(self.centralwidget)
- self.bSlider.setOrientation(QtCore.Qt.Horizontal)
- self.bSlider.setObjectName("bSlider")
- self.slidersLayout.addWidget(self.bSlider)
- self.verticalLayout.addLayout(self.slidersLayout)
- self.buttonsLayout = QtWidgets.QGridLayout()
- self.buttonsLayout.setObjectName("buttonsLayout")
- self.pauseButton = QtWidgets.QPushButton(self.centralwidget)
- self.pauseButton.setObjectName("pauseButton")
- self.buttonsLayout.addWidget(self.pauseButton, 0, 1, 1, 1)
- self.initMidiButton = QtWidgets.QPushButton(self.centralwidget)
- self.initMidiButton.setObjectName("initMidiButton")
- self.buttonsLayout.addWidget(self.initMidiButton, 0, 4, 1, 1)
- self.rateSlider = QtWidgets.QSlider(self.centralwidget)
- sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed)
- sizePolicy.setHorizontalStretch(0)
- sizePolicy.setVerticalStretch(0)
- sizePolicy.setHeightForWidth(self.rateSlider.sizePolicy().hasHeightForWidth())
- self.rateSlider.setSizePolicy(sizePolicy)
- self.rateSlider.setOrientation(QtCore.Qt.Horizontal)
- self.rateSlider.setObjectName("rateSlider")
- self.buttonsLayout.addWidget(self.rateSlider, 2, 0, 1, 1)
- self.playButton = QtWidgets.QPushButton(self.centralwidget)
- self.playButton.setObjectName("playButton")
- self.buttonsLayout.addWidget(self.playButton, 0, 0, 1, 1)
- self.saveSessionButton = QtWidgets.QPushButton(self.centralwidget)
- self.saveSessionButton.setObjectName("saveSessionButton")
- self.buttonsLayout.addWidget(self.saveSessionButton, 0, 2, 1, 1)
- self.storeAbButton = QtWidgets.QPushButton(self.centralwidget)
- self.storeAbButton.setObjectName("storeAbButton")
- self.buttonsLayout.addWidget(self.storeAbButton, 2, 3, 1, 1)
- self.loadSessionButton = QtWidgets.QPushButton(self.centralwidget)
- self.loadSessionButton.setObjectName("loadSessionButton")
- self.buttonsLayout.addWidget(self.loadSessionButton, 0, 3, 1, 1)
- self.abRepeatCheckBox = QtWidgets.QCheckBox(self.centralwidget)
- self.abRepeatCheckBox.setObjectName("abRepeatCheckBox")
- self.buttonsLayout.addWidget(self.abRepeatCheckBox, 2, 1, 1, 1)
- self.addSongButton = QtWidgets.QPushButton(self.centralwidget)
- self.addSongButton.setObjectName("addSongButton")
- self.buttonsLayout.addWidget(self.addSongButton, 2, 4, 1, 1)
- self.horizontalLayout = QtWidgets.QHBoxLayout()
- self.horizontalLayout.setObjectName("horizontalLayout")
- self.setAButton = QtWidgets.QPushButton(self.centralwidget)
- self.setAButton.setObjectName("setAButton")
- self.horizontalLayout.addWidget(self.setAButton)
- self.setBButton = QtWidgets.QPushButton(self.centralwidget)
- self.setBButton.setObjectName("setBButton")
- self.horizontalLayout.addWidget(self.setBButton)
- self.buttonsLayout.addLayout(self.horizontalLayout, 2, 2, 1, 1)
- self.verticalLayout.addLayout(self.buttonsLayout)
- MainWindow.setCentralWidget(self.centralwidget)
-
- self.retranslateUi(MainWindow)
- QtCore.QMetaObject.connectSlotsByName(MainWindow)
-
- def retranslateUi(self, MainWindow):
- _translate = QtCore.QCoreApplication.translate
- MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
- self.pauseButton.setText(_translate("MainWindow", "Pause"))
- self.initMidiButton.setText(_translate("MainWindow", "Connect MIDI"))
- self.playButton.setText(_translate("MainWindow", "Play"))
- self.saveSessionButton.setText(_translate("MainWindow", "Save session"))
- self.storeAbButton.setText(_translate("MainWindow", "Store AB"))
- self.loadSessionButton.setText(_translate("MainWindow", "Load session"))
- self.abRepeatCheckBox.setText(_translate("MainWindow", "AB repeat"))
- self.addSongButton.setText(_translate("MainWindow", "Add song"))
- self.setAButton.setText(_translate("MainWindow", "Set A"))
- self.setBButton.setText(_translate("MainWindow", "Set B"))
diff --git a/gui-project/src/mainwindow.ui b/gui-project/src/mainwindow.ui
deleted file mode 100644
index ac4d97b..0000000
--- a/gui-project/src/mainwindow.ui
+++ /dev/null
@@ -1,161 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<ui version="4.0">
- <class>MainWindow</class>
- <widget class="QMainWindow" name="MainWindow">
- <property name="geometry">
- <rect>
- <x>0</x>
- <y>0</y>
- <width>971</width>
- <height>767</height>
- </rect>
- </property>
- <property name="windowTitle">
- <string>MainWindow</string>
- </property>
- <widget class="QWidget" name="centralwidget">
- <layout class="QVBoxLayout" name="verticalLayout">
- <item>
- <layout class="QHBoxLayout" name="listsLayout">
- <property name="spacing">
- <number>6</number>
- </property>
- <property name="sizeConstraint">
- <enum>QLayout::SetDefaultConstraint</enum>
- </property>
- <item>
- <widget class="QListView" name="songListView"/>
- </item>
- <item>
- <widget class="QListView" name="abListView"/>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QVBoxLayout" name="slidersLayout">
- <item>
- <widget class="QSlider" name="songSlider">
- <property name="minimumSize">
- <size>
- <width>0</width>
- <height>0</height>
- </size>
- </property>
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QSlider" name="aSlider">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QSlider" name="bSlider">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QGridLayout" name="buttonsLayout">
- <item row="0" column="1">
- <widget class="QPushButton" name="pauseButton">
- <property name="text">
- <string>Pause</string>
- </property>
- </widget>
- </item>
- <item row="0" column="4">
- <widget class="QPushButton" name="initMidiButton">
- <property name="text">
- <string>Connect MIDI</string>
- </property>
- </widget>
- </item>
- <item row="2" column="0">
- <widget class="QSlider" name="rateSlider">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
- <horstretch>0</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
- </property>
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- </widget>
- </item>
- <item row="0" column="0">
- <widget class="QPushButton" name="playButton">
- <property name="text">
- <string>Play</string>
- </property>
- </widget>
- </item>
- <item row="0" column="2">
- <widget class="QPushButton" name="saveSessionButton">
- <property name="text">
- <string>Save session</string>
- </property>
- </widget>
- </item>
- <item row="2" column="3">
- <widget class="QPushButton" name="storeAbButton">
- <property name="text">
- <string>Store AB</string>
- </property>
- </widget>
- </item>
- <item row="0" column="3">
- <widget class="QPushButton" name="loadSessionButton">
- <property name="text">
- <string>Load session</string>
- </property>
- </widget>
- </item>
- <item row="2" column="1">
- <widget class="QCheckBox" name="abRepeatCheckBox">
- <property name="text">
- <string>AB repeat</string>
- </property>
- </widget>
- </item>
- <item row="2" column="4">
- <widget class="QPushButton" name="addSongButton">
- <property name="text">
- <string>Add song</string>
- </property>
- </widget>
- </item>
- <item row="2" column="2">
- <layout class="QHBoxLayout" name="horizontalLayout">
- <item>
- <widget class="QPushButton" name="setAButton">
- <property name="text">
- <string>Set A</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QPushButton" name="setBButton">
- <property name="text">
- <string>Set B</string>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- </layout>
- </item>
- </layout>
- </widget>
- </widget>
- <resources/>
- <connections/>
-</ui>
diff --git a/gui-project/src/solo_tool_gui.py b/gui-project/src/solo_tool_gui.py
deleted file mode 100644
index cd73c9a..0000000
--- a/gui-project/src/solo_tool_gui.py
+++ /dev/null
@@ -1,265 +0,0 @@
-import sys
-
-from PyQt5.QtGui import *
-from PyQt5.QtWidgets import *
-from PyQt5.QtCore import *
-from MainWindow import Ui_MainWindow
-
-from solo_tool import SoloTool
-from solo_tool.midi_controller_launchpad_mini import MidiController
-
-POSITION_FACTOR = 100000
-RATE_FACTOR = 10
-UI_REFRESH_PERIOD_MS = 500
-
-CHANGE_GUI = 0
-CHANGE_INTERNAL = 1
-
-class PlaylistModel(QAbstractListModel):
- def __init__(self, soloTool, *args, **kwargs):
- super(PlaylistModel, self).__init__(*args, **kwargs)
- self.soloTool = soloTool
-
- def data(self, index, role):
- if role == Qt.DisplayRole:
- from pathlib import Path
- path = Path(self.soloTool.getSongs()[index.row()])
- return path.name
-
- def rowCount(self, index):
- return len(self.soloTool.getSongs())
-
-class ABListModel(QAbstractListModel):
- def __init__(self, soloTool, *args, **kwargs):
- super(ABListModel, self).__init__(*args, **kwargs)
- self.soloTool = soloTool
-
- def data(self, index, role):
- if role == Qt.DisplayRole:
- ab = self.soloTool.getStoredAbLimits()[index.row()]
- return f"{ab[0]} - {ab[1]}"
-
- def rowCount(self, index):
- return len(self.soloTool.getStoredAbLimits())
-
-class MainWindow(QMainWindow, Ui_MainWindow):
- songChangeSignal = pyqtSignal(int)
- abLimitsChangeSignal = pyqtSignal(int)
-
- def __init__(self, *args, **kwargs):
- super(MainWindow, self).__init__(*args, **kwargs)
-
- self.setupUi(self)
-
- self.timer = QTimer(self)
- self.timer.setInterval(UI_REFRESH_PERIOD_MS)
- self.timer.timeout.connect(self.timerCallback)
-
- self.soloTool = SoloTool()
- self.midiController = MidiController(self.soloTool)
-
- self.playlistModel = PlaylistModel(self.soloTool)
- self.songListView.setModel(self.playlistModel)
- self.songListView.selectionModel().selectionChanged.connect(self.playlistSelectionChanged)
- self.songChangePending = None
- self.songChangeSignal.connect(self.currentSongChanged)
- self.soloTool.registerCurrentSongCallback(self.songChangeSignal.emit)
-
- self.abListModel = ABListModel(self.soloTool)
- self.abListView.setModel(self.abListModel)
- self.abListView.selectionModel().selectionChanged.connect(self.abListSelectionChanged)
- self.abLimitsChangePending = None
- self.abLimitsChangeSignal.connect(self.currentAbLimitsChanged)
- self.soloTool.registerCurrentAbLimitsCallback(self.abLimitsChangeSignal.emit)
-
- self.songSlider.setMaximum(POSITION_FACTOR)
- self.songSlider.sliderPressed.connect(self.songSliderPressed)
- self.songSlider.sliderReleased.connect(self.songSliderReleased)
-
- self.aSlider.setMaximum(POSITION_FACTOR)
- self.aSlider.sliderReleased.connect(self.abSliderReleased)
- self.bSlider.setMaximum(POSITION_FACTOR)
- self.bSlider.sliderReleased.connect(self.abSliderReleased)
-
- self.rateSlider.setRange(int(0.5 * RATE_FACTOR), int(1.2 * RATE_FACTOR))
- self.rateSlider.setSingleStep(int(0.1 * RATE_FACTOR))
- self.rateSlider.setValue(int(1.0 * RATE_FACTOR))
- self.rateSlider.sliderReleased.connect(self.rateSliderReleased)
-
- self.playButton.pressed.connect(self.soloTool.play)
- self.pauseButton.pressed.connect(self.soloTool.pause)
- self.storeAbButton.pressed.connect(self.storeAbLimits)
- self.setAButton.pressed.connect(self.setA)
- self.setBButton.pressed.connect(self.setB)
- self.saveSessionButton.pressed.connect(self.saveSession)
- self.loadSessionButton.pressed.connect(self.loadSession)
- self.addSongButton.pressed.connect(self.addSong)
- self.abRepeatCheckBox.clicked.connect(self.toggleAbRepeat)
- self.initMidiButton.pressed.connect(self.initMidi)
-
- self.timer.start()
-
- if len(sys.argv) > 1:
- self.loadSession(sys.argv[1])
-
- self.show()
-
- def timerCallback(self):
- position = self.soloTool.getPlaybackPosition() * POSITION_FACTOR
- self.songSlider.setValue(int(position))
- self.soloTool.tick()
-
- def addSong(self):
- path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "mp3 Audio (*.mp3);FLAC audio (*.flac);All files (*.*)")
- if path:
- self.soloTool.addSong(path)
- self.playlistModel.layoutChanged.emit()
-
- def storeAbLimits(self):
- a = self.aSlider.value() / float(POSITION_FACTOR)
- b = self.bSlider.value() / float(POSITION_FACTOR)
- self.soloTool.storeAbLimits(a, b)
- self.abListModel.layoutChanged.emit()
-
- def setA(self):
- position = self.songSlider.value()
- self.aSlider.setValue(position)
- self.abSliderReleased()
-
- def setB(self):
- position = self.songSlider.value()
- self.bSlider.setValue(position)
- self.abSliderReleased()
-
- def toggleAbRepeat(self):
- enable = self.abRepeatCheckBox.isChecked()
- self.soloTool.setAbLimitEnable(enable)
-
- def saveSession(self):
- path, _ = QFileDialog.getSaveFileName(self, "Open file", "", "session file (*.json)")
- if path:
- self.soloTool.saveSession(path)
-
- def loadSession(self, path=None):
- if path is None:
- path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "session file (*.json)")
- if path is not None:
- self.soloTool.loadSession(path)
- self.playlistModel.layoutChanged.emit()
- self.abListModel.layoutChanged.emit()
-
- def songSliderPressed(self):
- self.timer.stop()
-
- def songSliderReleased(self):
- position = self.songSlider.value() / float(POSITION_FACTOR)
- self.soloTool.setPlaybackPosition(position)
- self.timer.start()
-
- def clearListViewSelection(self, listView):
- i = listView.selectionModel().currentIndex()
- listView.selectionModel().select(i, QItemSelectionModel.Deselect)
-
- def abSliderReleased(self):
- a = self.aSlider.value() / float(POSITION_FACTOR)
- b = self.bSlider.value() / float(POSITION_FACTOR)
- self.soloTool.setAbLimits(a, b)
- self.clearListViewSelection(self.abListView)
-
- def rateSliderReleased(self):
- rate = self.rateSlider.value() / float(RATE_FACTOR)
- self.soloTool.setPlaybackRate(rate)
-
- def playlistSelectionChanged(self, i):
- if self.songChangePending == CHANGE_INTERNAL:
- self.songChangePending = None
- else:
- assert self.songChangePending is None
- self.songChangePending = CHANGE_GUI
- index = i.indexes()[0].row()
- self.soloTool.setSong(index)
-
- self.clearListViewSelection(self.abListView)
- self.abListModel.layoutChanged.emit()
-
- def currentSongChanged(self, songIndex):
- if self.songChangePending == CHANGE_GUI:
- self.songChangePending = None
- else:
- assert self.songChangePending is None
- self.songChangePending = CHANGE_INTERNAL
- i = self.playlistModel.createIndex(songIndex, 0)
- self.songListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect)
-
- def abListSelectionChanged(self, i):
- if self.abLimitsChangePending == CHANGE_INTERNAL:
- print("Ack internal change")
- self.abLimitsChangePending = None
- else:
- assert self.abLimitsChangePending is None
- if i is not None and not i.isEmpty():
- print("Processing GUI change")
- self.abLimitsChangePending = CHANGE_GUI
- index = i.indexes()[0].row()
- ab = self.soloTool.getStoredAbLimits()[index]
- self.soloTool.loadAbLimits(index)
- self.aSlider.setValue(int(ab[0] * POSITION_FACTOR))
- self.bSlider.setValue(int(ab[1] * POSITION_FACTOR))
-
- def currentAbLimitsChanged(self, abIndex):
- if self.abLimitsChangePending == CHANGE_GUI:
- print("Ack GUI change")
- self.abLimitsChangePending = None
- else:
- assert self.abLimitsChangePending is None
- print("Processing internal change")
- self.abLimitsChangePending = CHANGE_INTERNAL
- i = self.abListModel.createIndex(abIndex, 0)
- self.abListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect)
- ab = self.soloTool.getStoredAbLimits()[abIndex]
- self.aSlider.setValue(int(ab[0] * POSITION_FACTOR))
- self.bSlider.setValue(int(ab[1] * POSITION_FACTOR))
-
- def initMidi(self):
- try:
- self.midiController.connect()
- except Exception as e:
- print("Error: could not connect to MIDI controller")
- print(e)
-
- def keyPressEvent(self, event):
- if event.key() == Qt.Key_Super_L:
- self.soloTool.jumpToA()
-
- def closeEvent(self, event):
- self.midiController.disconnect()
- event.accept()
-
-def main():
- app = QApplication([])
- app.setApplicationName("Solo Tool")
- app.setStyle("Fusion")
-
- # Fusion dark palette from https://gist.github.com/QuantumCD/6245215.
- palette = QPalette()
- palette.setColor(QPalette.Window, QColor(53, 53, 53))
- palette.setColor(QPalette.WindowText, Qt.white)
- palette.setColor(QPalette.Base, QColor(25, 25, 25))
- palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53))
- palette.setColor(QPalette.ToolTipBase, Qt.white)
- palette.setColor(QPalette.ToolTipText, Qt.white)
- palette.setColor(QPalette.Text, Qt.white)
- palette.setColor(QPalette.Button, QColor(53, 53, 53))
- palette.setColor(QPalette.ButtonText, Qt.white)
- palette.setColor(QPalette.BrightText, Qt.red)
- palette.setColor(QPalette.Link, QColor(42, 130, 218))
- palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
- palette.setColor(QPalette.HighlightedText, Qt.black)
- app.setPalette(palette)
- app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }")
-
- window = MainWindow()
- app.exec_()
-
-if __name__ == '__main__':
- main()
diff --git a/pacman.txt b/pacman.txt
index cb8a962..05e154e 100644
--- a/pacman.txt
+++ b/pacman.txt
@@ -1,6 +1,4 @@
pkgconf
-gst-plugins-good
-
-qt5-multimedia
-
-vlc
+make
+gcc
+mpv
diff --git a/readme.md b/readme.md
index 74cabe8..be2269b 100644
--- a/readme.md
+++ b/readme.md
@@ -1,37 +1,53 @@
# Solo Tool
-This tool is designed to facilitate learning songs, and solos in particular, by slowing down playback and automatically repeating short sections of the file.
+This tool is designed to facilitate learning songs, and solos in particular, by slowing down playback and automatically jumping to predefined points in the song.
## Dependencies
-See requirements.txt and pacman.txt for required packages.
+Python dependencies are listed in the pyproject.toml files of the different projects and should be automatically installed by `pip`.
+
+Non-Python dependencies are listed in pacman.txt and should be manually installed before running `make`.
## Usage
-To use the GUI, run:
+To set up the environment and run the tests, run `make`:
+
+```
+make
+```
+
+The web GUI can also be run with `make`:
```
-python solo_tool_qt.py
+make gui
```
-Or for the (very basic) CLI:
+Alternatively, the tool can be executed in headless mode. In this case all it does is load the provided session and connect to the MIDI controller:
```
-python solo_tool_cli.py
+./venv/bin/solo_tool_cli
```
## MIDI
-It is currently possible to control the tool with MIDI. With the device plugged in, a connection can be established by clicking on "Connect MIDI" in the GUI or entering "midi connect" in the CLI. Currently the only device supported is the Novation Launchpad Mini Mk II.
+It is currently possible to control the tool with MIDI. With the device plugged in, a connection is automatically established by the CLI. Currently the only device supported is the Novation Launchpad Mini Mk II.
+
+The MIDI device button mapping is documented in `doc/diagram.drawio`.
## Tests
For the automated tests, run:
```
-pytest *test.py
+make test
+```
+
+or just
+
+```
+make
```
## Architecture
-More details on the architecture are available in `diagram.drawio`.
+More details on the architecture are available in `doc/diagram.drawio`.
diff --git a/requirements.txt b/requirements.txt
index 20ce1da..459ff68 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-e solo-tool-project[dev]
-e cli-project[dev]
--e gui-project[dev]
+-e web-project[dev]
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/abcontroller.py b/solo-tool-project/src/solo_tool/abcontroller.py
deleted file mode 100644
index cec9fb2..0000000
--- a/solo-tool-project/src/solo_tool/abcontroller.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from collections import namedtuple
-
-_AB = namedtuple("_AB", ["a", "b"])
-
-class ABController:
- def __init__(self, enabled=True, callback=None):
- self._setPositionCallback = callback
- self._limits = {} # dictionary of all songs
- self._songLimits = None # list of limits for selected song
- self._currentLimits = _AB(0.0, 0.0) # a/b positions of active limit
- self._loadedIndex = None
- self._enabled = enabled
-
- def _ensureSongExists(self, path):
- if path not in self._limits:
- self._limits[path] = []
-
- def setCurrentSong(self, path):
- self._ensureSongExists(path)
- self._songLimits = self._limits[path]
- self._loadedIndex = None
-
- def storeLimits(self, aLimit, bLimit, song=None):
- if song is not None:
- self._ensureSongExists(song)
- songLimits = self._limits[song]
- else:
- songLimits = self._songLimits
-
- if songLimits is None:
- return
-
- ab = _AB(aLimit, bLimit)
- songLimits.append(ab)
-
- def loadLimits(self, index):
- if not self._songLimits:
- return
-
- if index >= 0 and index < len(self._songLimits):
- self._currentLimits = self._songLimits[index]
- self._loadedIndex = index
-
- def nextStoredAbLimits(self):
- if self._loadedIndex is None:
- nextIndex = 0
- else:
- nextIndex = self._loadedIndex + 1
- self.loadLimits(nextIndex)
-
- def previousStoredAbLimits(self):
- if self._loadedIndex is None:
- previousIndex = 0
- else:
- previousIndex = self._loadedIndex - 1
- self.loadLimits(previousIndex)
-
- def setLimits(self, aLimit, bLimit):
- self._currentLimits = _AB(aLimit, bLimit)
- self._loadedIndex = None
-
- def positionChanged(self, position):
- if position > self._currentLimits.b and self._setPositionCallback and self._enabled:
- self._setPositionCallback(self._currentLimits.a)
-
- def setEnable(self, enable):
- self._enabled = enable
-
- def isEnabled(self):
- return self._enabled
-
- def getStoredLimits(self, song):
- return self._limits.get(song)
-
- def getCurrentLimits(self):
- return self._currentLimits
-
- def getLoadedIndex(self):
- return self._loadedIndex
-
- def clear(self):
- self.__init__(enabled=self._enabled, callback=self._setPositionCallback)
diff --git a/solo-tool-project/src/solo_tool/handlers.py b/solo-tool-project/src/solo_tool/handlers.py
new file mode 100644
index 0000000..1820e86
--- /dev/null
+++ b/solo-tool-project/src/solo_tool/handlers.py
@@ -0,0 +1,84 @@
+from collections.abc import Callable
+
+from solo_tool.solo_tool import SoloTool
+
+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
+ else:
+ 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, delta: float) -> Callable[[], None]:
+ def f():
+ st.position = delta
+ 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_launchpad_mini.py b/solo-tool-project/src/solo_tool/midi_controller_launchpad_mini.py
index fb6e385..625e2ef 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,4 +1,5 @@
from .midi_wrapper_mido import MidiWrapper
+from . import handlers
class MidiController:
DEVICE_NAME = "Launchpad Mini MIDI 1"
@@ -26,47 +27,41 @@ class MidiController:
self._registerHandlers()
self._soloTool.registerPlayingStateCallback(self._updatePlayPauseButton)
- self._soloTool.registerPlaybackVolumeCallback(self._updateVolumeRow)
- self._soloTool.registerPlaybackRateCallback(self._updateRateRow)
- self._soloTool.registerAbLimitEnabledCallback(self._updateToggleAbLimitEnableButton)
-
- self._aLimit = 0.0
- self._bLimit = 0.0
+ self._soloTool.registerVolumeCallback(self._updateVolumeRow)
+ self._soloTool.registerRateCallback(self._updateRateRow)
def _registerHandlers(self):
self._handlers = {
- 96 : self._soloTool.stop,
- 114 : self._soloTool.jumpToA,
- 112 : self._playPause,
- 98 : self._toggleAbLimitEnable,
- 118 : self._soloTool.previousStoredAbLimits,
- 119 : self._soloTool.nextStoredAbLimits,
- 116 : self._setALimit,
- 117 : self._setBLimit,
- 48 : self._soloTool.previousSong,
- 49 : self._createSeekHandler(-0.25),
- 50 : self._createSeekHandler(-0.05),
- 51 : self._createSeekHandler(-0.01),
- 52 : self._createSeekHandler(0.01),
- 53 : self._createSeekHandler(0.05),
- 54 : self._createSeekHandler(0.25),
- 55 : self._soloTool.nextSong,
+ 96 : handlers.seekAbsolute(self._soloTool, 0.0),
+ 114 : self._soloTool.jump,
+ 112 : handlers.playPause(self._soloTool),
+ 118 : handlers.keyPointRelative(self._soloTool, -1),
+ 119 : handlers.keyPointRelative(self._soloTool, 1),
+ 117 : handlers.positionToKeyPoint(self._soloTool),
+ 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.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)
+ 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)
+ self._handlers[button] = handlers.rateAbsolute(self._soloTool, rate)
def connect(self):
self._midiWrapper.connect(MidiController.DEVICE_NAME, self._callback)
self._initialiseButtonLEDs()
def disconnect(self):
- self._allLEDsOff()
+ self._setAllLEDs(MidiController.LED_OFF)
self._midiWrapper.disconnect()
def _callback(self, msg):
@@ -76,42 +71,12 @@ 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 _createSeekHandler(self, delta):
- def f():
- newPosition = self._soloTool.getPlaybackPosition() + delta
- newPosition = min(1.0, max(0.0, newPosition))
- self._soloTool.setPlaybackPosition(newPosition)
- return f
-
- def _setALimit(self):
- self._aLimit = self._soloTool.getPlaybackPosition()
- self._soloTool.setAbLimits(self._aLimit, self._bLimit)
-
- def _setBLimit(self):
- self._bLimit = self._soloTool.getPlaybackPosition()
- self._soloTool.setAbLimits(self._aLimit, self._bLimit)
-
- 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, 2, MidiController.LED_GREEN)
- else:
- self._setButtonLED(6, 2, 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))
@@ -124,16 +89,6 @@ class MidiController:
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)
@@ -142,32 +97,28 @@ class MidiController:
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(MidiController.LED_OFF)
# volume buttons
- self._updateVolumeRow(self._soloTool.getPlaybackVolume())
+ self._updateVolumeRow(self._soloTool.volume)
# playback rate buttons
- self._updateRateRow(self._soloTool.getPlaybackRate())
+ self._updateRateRow(self._soloTool.rate)
# playback control
- self._setButtonLED(6, 0, MidiController.LED_RED)
- self._updatePlayPauseButton(self._soloTool.isPlaying())
-
- # AB repeat toggle
- self._updateToggleAbLimitEnableButton(self._soloTool.isAbLimitEnabled())
+ self._setButtonLED(6, 0, MidiController.LED_YELLOW)
+ self._updatePlayPauseButton(self._soloTool.playing)
- # AB control
+ # 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, 4, MidiController.LED_YELLOW)
self._setButtonLED(7, 5, MidiController.LED_YELLOW)
# Song control
diff --git a/solo-tool-project/src/solo_tool/notifier.py b/solo-tool-project/src/solo_tool/notifier.py
index 9f445b6..5b3539c 100644
--- a/solo-tool-project/src/solo_tool/notifier.py
+++ b/solo-tool-project/src/solo_tool/notifier.py
@@ -3,8 +3,9 @@ class Notifier:
PLAYBACK_VOLUME_EVENT = 1
PLAYBACK_RATE_EVENT = 2
CURRENT_SONG_EVENT = 3
- CURRENT_AB_EVENT = 4
- AB_LIMIT_ENABLED_EVENT = 5
+ 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/playlist.py b/solo-tool-project/src/solo_tool/playlist.py
deleted file mode 100644
index bbfd8f5..0000000
--- a/solo-tool-project/src/solo_tool/playlist.py
+++ /dev/null
@@ -1,40 +0,0 @@
-class Playlist:
- def __init__(self, callback):
- self._songList = list()
- self._currentSong = None
- self._setSongCallback = callback
-
- def addSong(self, path):
- self._songList.append(path)
-
- def setCurrentSong(self, index):
- if index >= 0 and index < len(self._songList):
- self._currentSong = index
- self._setSongCallback(self._songList[index])
-
- def getCurrentSong(self):
- index = self._currentSong
- return self._songList[index] if index is not None else None
-
- def getCurrentSongIndex(self):
- return self._currentSong
-
- def getSongs(self):
- return self._songList
-
- def clear(self):
- self.__init__(self._setSongCallback)
-
- def nextSong(self):
- if self._currentSong is None:
- nextSong = 0
- else:
- nextSong = self._currentSong + 1
- self.setCurrentSong(nextSong)
-
- def previousSong(self):
- if self._currentSong is None:
- prevSong = 0
- else:
- prevSong = self._currentSong - 1
- self.setCurrentSong(prevSong)
diff --git a/solo-tool-project/src/solo_tool/session_manager.py b/solo-tool-project/src/solo_tool/session_manager.py
index 718e864..cd5ebf7 100644
--- a/solo-tool-project/src/solo_tool/session_manager.py
+++ b/solo-tool-project/src/solo_tool/session_manager.py
@@ -1,41 +1,30 @@
import json
+from . import SoloTool
-class SessionManager:
- def __init__(self, playlist, abController):
- self._playlist = playlist
- self._abController = abController
+def loadSession(file: str, songPool: str, player=None) -> SoloTool:
+ with open(file, "r") as f:
+ session = json.load(f)
- def addSong(self, path):
- self._playlist.addSong(path)
+ st = SoloTool(songPool, player=player)
- def storeLimits(self, aLimit, bLimit):
- self._abController.storeLimits(aLimit, bLimit)
+ for i, entry in enumerate(session):
+ songPath = entry["path"]
+ keyPoints = entry["key_points"]
- def loadSession(self, file):
- jsonStr = file.read()
- session = json.loads(jsonStr)
+ st.addSong(songPath)
+ st._keyPoints[i] = keyPoints
- self._playlist.clear()
- self._abController.clear()
+ return st
+
+def saveSession(soloTool: SoloTool, file: str) -> None:
+ session = []
- for entry in session:
- songPath = entry["path"]
- abLimits = entry["ab_limits"]
- self._playlist.addSong(songPath)
+ for i, song in enumerate(soloTool.songs):
+ entry = {
+ "path": song,
+ "key_points" : soloTool._keyPoints[i]
+ }
+ session.append(entry)
- 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))
+ with open(file, "w") as f:
+ json.dump(session, f)
diff --git a/solo-tool-project/src/solo_tool/solo_tool.py b/solo-tool-project/src/solo_tool/solo_tool.py
index 211babf..92b5595 100644
--- a/solo-tool-project/src/solo_tool/solo_tool.py
+++ b/solo-tool-project/src/solo_tool/solo_tool.py
@@ -1,114 +1,91 @@
import os
+from pathlib import Path
-from .playlist import Playlist
-from .abcontroller import ABController
-from .session_manager import SessionManager
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
- self._playlist = Playlist(self._playlistCallback)
- self._abController = ABController(enabled=False, callback=self._abControllerCallback)
- self._sessionManager = SessionManager(self._playlist, self._abController)
+ def __init__(self, songPool: str, player=None):
+ self._songPool = Path(songPool)
+ self._player = Player() if player is None else player
self._notifier = Notifier(self._player)
+ self._songs = []
+ self._song = None
+ self._keyPoints = []
+ self._keyPoint = None
- 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 __del__(self):
+ del self._player
+
+ def _updateSong(self, index):
+ previousSong = self._song
+ self._song = index
+ self._player.pause()
+ self._player.setCurrentSong(self._songPool / self._songs[index])
+ self._notifier.notify(Notifier.CURRENT_SONG_EVENT, index)
+
+ 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)
+
+ @staticmethod
+ def _keyPointValid(kp: float) -> bool:
+ return kp is not None and kp >= 0.0 and kp < 1.0
+
+ @property
+ def songs(self) -> list[str]:
+ return self._songs.copy()
+
+ def addSong(self, fileName: str) -> None:
+ path = self._songPool / fileName
+ if not os.path.isfile(path):
+ raise FileNotFoundError(path)
+ if path in self._songs:
+ return
+ self._songs.append(fileName)
+ self._keyPoints.append([])
+ self._notifier.notify(Notifier.SONG_LIST_EVENT, self.songs)
+ if self.song is None:
+ self.song = 0
+
+ @property
+ def song(self) -> int:
+ return self._song
+
+ @song.setter
+ def song(self, new: int) -> None:
+ if new is not None \
+ and new >= 0 \
+ and new < len(self._songs) \
+ and new != self._song:
+ self._updateSong(new)
+
+ @property
+ def keyPoints(self) -> list[float]:
+ if self._song is None:
+ return None
+ 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.copy()
+ self._notifier.notify(Notifier.KEY_POINT_LIST_EVENT, self.keyPoints)
+
+ @property
+ def keyPoint(self) -> float:
+ return float(self._keyPoint) if self._keyPoint is not None else None
+
+ @keyPoint.setter
+ def keyPoint(self, new: float) -> None:
+ if self._song is not None and SoloTool._keyPointValid(new) and new != self._keyPoint:
+ self._keyPoint = new
+ self._notifier.notify(Notifier.CURRENT_KEY_POINT_EVENT, new)
def play(self):
self._player.play()
@@ -116,49 +93,60 @@ 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 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 jump(self):
+ self._player.setPlaybackPosition(self._keyPoint)
- def getPlaybackRate(self):
+ @property
+ def rate(self) -> float:
return self._player.getPlaybackRate()
- def setPlaybackPosition(self, position):
- self._player.setPlaybackPosition(position)
+ @rate.setter
+ def rate(self, new: float) -> None:
+ if new is not None and new >= 0.0 and new != self._player.getPlaybackRate():
+ self._player.setPlaybackRate(new)
+ self._notifier.notify(Notifier.PLAYBACK_RATE_EVENT, new)
- def getPlaybackPosition(self):
+ @property
+ def volume(self) -> float:
+ return self._player.getPlaybackVolume()
+
+ @volume.setter
+ def volume(self, new: float) -> None:
+ if new is not None and new >= 0.0 and new != self._player.getPlaybackVolume():
+ self._player.setPlaybackVolume(new)
+ self._notifier.notify(Notifier.PLAYBACK_VOLUME_EVENT, new)
+
+ @property
+ def position(self) -> float:
return self._player.getPlaybackPosition()
- def setPlaybackVolume(self, volume):
- self._player.setPlaybackVolume(volume)
+ @position.setter
+ def position(self, new: float) -> None:
+ if new is not None and new != self._player.getPlaybackPosition():
+ self._player.setPlaybackPosition(min(max(0.0, new), 1.0))
- def getPlaybackVolume(self):
- return self._player.getPlaybackVolume()
+ 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)
- def registerPlaybackVolumeCallback(self, callback):
+ def registerVolumeCallback(self, callback):
self._notifier.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, callback)
- def registerPlaybackRateCallback(self, callback):
+ 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 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
deleted file mode 100644
index d2b7d31..0000000
--- a/solo-tool-project/test/abcontroller_unittest.py
+++ /dev/null
@@ -1,272 +0,0 @@
-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/fixtures.py b/solo-tool-project/test/fixtures.py
new file mode 100644
index 0000000..f70901b
--- /dev/null
+++ b/solo-tool-project/test/fixtures.py
@@ -0,0 +1,32 @@
+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 songPool(tmp_path):
+ return tmp_path / "songs"
+
+@pytest.fixture
+def soloTool(mockPlayer, songPool):
+ return SoloTool(songPool, player=mockPlayer)
+
+@pytest.fixture
+def testSongs(songPool):
+ songs = [
+ songPool / "test.flac",
+ songPool / "test.mp3",
+ songPool / "test.mp4"
+ ]
+
+ os.mkdir(songPool)
+ 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..85050d6
--- /dev/null
+++ b/solo-tool-project/test/handlers_integrationtest.py
@@ -0,0 +1,32 @@
+import pytest
+
+from fixtures import soloTool, testSongs, mockPlayer, songPool
+
+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_launchpad_mini_integrationtest.py b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py
index 8fd09bb..be8ed88 100644
--- a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py
+++ b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py
@@ -2,8 +2,7 @@ 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 fixtures import songPool, soloTool, mockPlayer, testSongs
LED_RED = 3
LED_YELLOW = 126
@@ -20,14 +19,12 @@ rwd25PcButton = 49
previousSongButton = 48
playPauseButton = 112
-stopButton = 96
+jumpToStartButton = 96
-nextLimitButton = 119
-previousLimitButton = 118
-abToggleButton = 98
-jumpToAButton = 114
-setAButton = 116
-setBButton = 117
+nextKeyPositionButton = 119
+previousKeyPositionButton = 118
+setKeyPositionButton = 117
+jumpToKeyPositionButton = 114
class MidiWrapperMock:
def __init__(self):
@@ -54,14 +51,6 @@ 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()
@@ -69,152 +58,89 @@ def midiWrapperMock():
def uut(soloTool, midiWrapperMock):
return MidiController(soloTool, midiWrapperMock)
-def test_startStopAndPauseButtons(uut, midiWrapperMock, playerMock):
+def test_startAndPauseButtons(uut, midiWrapperMock, mockPlayer):
uut.connect()
- assert playerMock.state == PlayerMock.STOPPED
+ assert not mockPlayer.playing
midiWrapperMock.simulateInput(playPauseButton)
- assert playerMock.state == PlayerMock.PLAYING
+ assert mockPlayer.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 not mockPlayer.playing
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):
+def test_startPauseButtonLed(uut, midiWrapperMock, mockPlayer, soloTool):
uut.connect()
- assert playerMock.state == PlayerMock.STOPPED
+ assert not mockPlayer.playing
- playerMock.state = PlayerMock.PLAYING
- playerMock.simulatePlayingStateChanged()
+ mockPlayer.playing = True
+ mockPlayer.simulatePlayingStateChanged()
assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0)
- playerMock.state = PlayerMock.STOPPED
- playerMock.simulatePlayingStateChanged()
+ mockPlayer.playing = False
+ mockPlayer.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)
+def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs):
+ soloTool.addSong(testSongs[0])
uut.connect()
- soloTool.setAbLimits(ab[0], ab[1])
- assert playerMock.position == 0.0
+ soloTool.keyPoint = 0.5
+ assert mockPlayer.position == 0.0
- midiWrapperMock.simulateInput(jumpToAButton)
- assert playerMock.position == ab[0]
+ midiWrapperMock.simulateInput(jumpToKeyPositionButton)
+ 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(previousSongButton)
- assert playerMock.currentSong == songs[0]
+ for _ in testSongs:
+ midiWrapperMock.simulateInput(nextSongButton)
+ assert mockPlayer.currentSong == testSongs[-1]
midiWrapperMock.simulateInput(previousSongButton)
- assert playerMock.currentSong == songs[0]
+ assert mockPlayer.currentSong == testSongs[-2]
-def test_previousAndNextAbButtons(uut, midiWrapperMock, soloTool, playerMock):
- song = "test.flac"
- abLimits = [
- [0.2, 0.4],
- [0.1, 0.3]
- ]
+ for _ in testSongs:
+ midiWrapperMock.simulateInput(previousSongButton)
+ assert mockPlayer.currentSong == testSongs[0]
- soloTool.addSong(song)
- soloTool.setSong(0)
- soloTool.setAbLimitEnable(True)
+def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs):
+ keyPoints = [0.2, 0.1]
- for ab in abLimits:
- soloTool.storeAbLimits(ab[0], ab[1])
+ soloTool.addSong(testSongs[0])
+ soloTool.keyPoints = keyPoints
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)
+ assert soloTool.keyPoint == 0.0
- midiWrapperMock.simulateInput(nextLimitButton)
- checkLimit(abLimits[0][0], abLimits[0][1])
+ midiWrapperMock.simulateInput(nextKeyPositionButton)
+ assert soloTool.keyPoint == 0.1
- midiWrapperMock.simulateInput(nextLimitButton)
- checkLimit(abLimits[1][0], abLimits[1][1])
+ midiWrapperMock.simulateInput(nextKeyPositionButton)
+ assert soloTool.keyPoint == 0.2
- midiWrapperMock.simulateInput(nextLimitButton)
- checkLimit(abLimits[1][0], abLimits[1][1])
+ midiWrapperMock.simulateInput(previousKeyPositionButton)
+ assert soloTool.keyPoint == 0.1
- midiWrapperMock.simulateInput(previousLimitButton)
- checkLimit(abLimits[0][0], abLimits[0][1])
+ midiWrapperMock.simulateInput(previousKeyPositionButton)
+ assert soloTool.keyPoint == 0.1
- midiWrapperMock.simulateInput(previousLimitButton)
- checkLimit(abLimits[0][0], abLimits[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),
@@ -226,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),
@@ -267,18 +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.setPlaybackRate(rate)
- assert playerMock.rate == rate
+ soloTool.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),
@@ -290,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),
@@ -331,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.setPlaybackVolume(volume)
- assert playerMock.volume == volume
+ soloTool.volume = volume
+ assert mockPlayer.volume == volume
for i, colour in enumerate(leds):
assert midiWrapperMock.sentMessages[i] == (i, colour, 0)
@@ -356,22 +283,20 @@ 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),
- (playPauseButton, LED_YELLOW, 0),
- (abToggleButton, LED_RED, 0),
- (jumpToAButton, LED_YELLOW, 0),
- (previousLimitButton, LED_RED, 0),
- (nextLimitButton, LED_GREEN, 0),
- (setAButton, LED_YELLOW, 0),
- (setBButton, LED_YELLOW, 0),
- (previousSongButton, LED_RED, 0),
- (rwd1PcButton, LED_RED, 0),
- (rwd5PcButton, LED_RED, 0),
- (rwd25PcButton, LED_RED, 0),
- (nextSongButton, LED_GREEN, 0),
- (fwd1PcButton, LED_GREEN, 0),
- (fwd5PcButton, LED_GREEN, 0),
- (fwd25PcButton, LED_GREEN, 0),
+ (jumpToStartButton, LED_YELLOW, 0),
+ (playPauseButton, LED_YELLOW, 0),
+ (jumpToKeyPositionButton, LED_YELLOW, 0),
+ (previousKeyPositionButton, LED_RED, 0),
+ (nextKeyPositionButton, LED_GREEN, 0),
+ (setKeyPositionButton, LED_YELLOW, 0),
+ (previousSongButton, LED_RED, 0),
+ (rwd1PcButton, LED_RED, 0),
+ (rwd5PcButton, LED_RED, 0),
+ (rwd25PcButton, LED_RED, 0),
+ (nextSongButton, LED_GREEN, 0),
+ (fwd1PcButton, LED_GREEN, 0),
+ (fwd5PcButton, LED_GREEN, 0),
+ (fwd25PcButton, LED_GREEN, 0),
])
teardownMessages = [(int(i / 8) * 16 + (i % 8), LED_OFF, 0) for i in range(0, 64)] # clear all
@@ -388,93 +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.setSong(0)
soloTool.play()
- assert playerMock.state == PlayerMock.PLAYING
+ assert mockPlayer.playing
assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_GREEN, 0)
- soloTool.nextSong()
- assert playerMock.state == PlayerMock.STOPPED
+ soloTool.song = 1
+ assert not mockPlayer.playing
assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0)
-def test_setAbButtons(uut, midiWrapperMock, soloTool, playerMock):
- song = "test.flac"
- soloTool.addSong(song)
- soloTool.setSong(0)
- soloTool.setAbLimitEnable(True)
- abLimits = (0.6, 0.8)
- soloTool.storeAbLimits(abLimits[0], abLimits[1])
+def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs):
+ soloTool.addSong(testSongs[0])
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
-
- # Set A limit
- playerMock.position = 0.3
- midiWrapperMock.simulateInput(setAButton)
- playerMock.position = 0.5
- midiWrapperMock.simulateInput(jumpToAButton)
-
- assert playerMock.position == 0.3
+ mockPlayer.position = 0.3
+ midiWrapperMock.simulateInput(setKeyPositionButton)
+ assert soloTool.keyPoint == 0.3
- # Set B limit
- playerMock.position = 0.4
- midiWrapperMock.simulateInput(setBButton)
- checkLimit(0.3, 0.4)
+ mockPlayer.position = 0.5
+ midiWrapperMock.simulateInput(setKeyPositionButton)
+ assert soloTool.keyPoint == 0.5
- # Selecting preset overrides manually set limits
- midiWrapperMock.simulateInput(nextLimitButton)
- checkLimit(abLimits[0], abLimits[1])
+ mockPlayer.position = 0.7
+ midiWrapperMock.simulateInput(jumpToKeyPositionButton)
+ assert mockPlayer.position == 0.5
-def test_seekButtons(uut, midiWrapperMock, soloTool, playerMock):
- song = "test.flac"
- soloTool.addSong(song)
- soloTool.setSong(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 8a6e988..5749149 100644
--- a/solo-tool-project/test/notifier_unittest.py
+++ b/solo-tool-project/test/notifier_unittest.py
@@ -37,8 +37,8 @@ def test_allEvents(uut):
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)
+ 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)
@@ -60,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..e9e9ead 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
@@ -55,7 +45,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/playlist_unittest.py b/solo-tool-project/test/playlist_unittest.py
deleted file mode 100644
index 842ce51..0000000
--- a/solo-tool-project/test/playlist_unittest.py
+++ /dev/null
@@ -1,148 +0,0 @@
-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
index 5468880..cff3b35 100644
--- a/solo-tool-project/test/session_manager_unittest.py
+++ b/solo-tool-project/test/session_manager_unittest.py
@@ -1,163 +1,65 @@
-from solo_tool.session_manager import SessionManager
-from json import loads, dumps
+import pytest
+from json import loads
+import os
-testSession = [
- {
- "path" : "/path/to/another/song",
- "ab_limits" : None
- },
+from solo_tool.session_manager import loadSession, saveSession
+from fixtures import songPool, soloTool, mockPlayer, testSongs
+
+@pytest.fixture
+def testSessionFile(tmp_path, testSongs):
+ contents = """[
{
- "path" : "/path/to/song",
- "ab_limits" : [
- [0.1, 0.2],
- [0.3, 0.4]
- ]
+ "path" : "test.flac",
+ "key_points" : []
},
{
- "path" : "/path/to/something",
- "ab_limits" : [
- [0.1, 0.2]
- ]
+ "path" : "test.mp3",
+ "key_points" : [0.1, 0.3]
}
-]
-
-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
+ basePath = tmp_path / "sessions"
+ sessionFile = basePath / "test-session.json"
- def write(self, s):
- self.contents += s
+ os.mkdir(basePath)
+ with open(sessionFile, "w") as f:
+ f.write(contents)
- def read(self):
- return self.contents
+ return sessionFile
+def test_loadSession(songPool, testSessionFile, mockPlayer):
+ soloTool = loadSession(testSessionFile, songPool, player=mockPlayer)
-def test_addSongs():
- songs = [
- "/path/to/song",
- "/path/to/another/song"
- ]
+ assert soloTool.songs == ["test.flac", "test.mp3"]
- playlistMock = PlaylistMock()
- uut = SessionManager(playlistMock, None)
+ soloTool.song = 0
+ assert soloTool.keyPoints == []
- for s in songs:
- uut.addSong(s)
- assert playlistMock.lastAddedSong == s
+ soloTool.song = 1
+ assert soloTool.keyPoints == [0.1, 0.3]
-def test_addAbLimits():
- abLimits = [
- [0.1, 0.2],
- [0.3, 0.4]
- ]
+def test_saveSession(soloTool, testSessionFile, tmp_path):
+ soloTool.addSong("test.flac")
+ soloTool.addSong("test.mp3")
+ soloTool.song = 1
+ soloTool.keyPoints = [0.1, 0.3]
- abControllerMock = ABControllerMock()
- uut = SessionManager(None, abControllerMock)
+ testFile = tmp_path / "test_session_saved.json"
+ saveSession(soloTool, testFile)
- 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)
+ with open(testFile, "r") as f:
+ savedSession = loads(f.read())
- sessionFile = MockFile(dumps(testSession))
- uut.loadSession(sessionFile)
+ with open(testSessionFile, "r") as f:
+ testSession = loads(f.read())
- 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)
+def test_loadAndSaveEmptySession(songPool, soloTool, tmp_path):
+ emptyFile = tmp_path / "empty_session.json"
- sessionFile = MockFile()
+ saveSession(soloTool, emptyFile)
+ reloadedTool = loadSession(emptyFile, songPool)
- 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))
+ assert reloadedTool.songs == []
+
diff --git a/solo-tool-project/test/solo_tool_integrationtest.py b/solo-tool-project/test/solo_tool_integrationtest.py
index 5903abf..7b274a3 100644
--- a/solo-tool-project/test/solo_tool_integrationtest.py
+++ b/solo-tool-project/test/solo_tool_integrationtest.py
@@ -1,379 +1,86 @@
-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
+from fixtures import soloTool as uut, songPool, mockPlayer, testSongs
def test_playerControls(uut, mockPlayer):
- assert mockPlayer.state == MockPlayer.STOPPED
- assert uut.isPlaying() == False
+ 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.setPlaybackRate(0.5)
+ uut.rate = 0.5
assert mockPlayer.rate == 0.5
+ assert uut.rate == 0.5
assert mockPlayer.position == 0.0
- uut.setPlaybackPosition(0.5)
+ uut.position = 0.5
assert mockPlayer.position == 0.5
+ assert uut.position == 0.5
assert mockPlayer.volume == 1.0
- uut.setPlaybackVolume(0.5)
+ uut.volume = 0.5
assert mockPlayer.volume == 0.5
+ assert uut.volume == 0.5
-def test_addAndSetSongs(uut, mockPlayer):
- songs = [
- "test.flac",
- "test.mp3"
- ]
+def test_sanitizePlaybackRate(uut):
+ # Initial value
+ assert uut.rate == 1.0
- 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
+ # Valid rates are >= 0.0, invalid is ignored
+ uut.rate = -0.1
+ assert uut.rate == 1.0
- 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]
- ]
+ uut.rate = 0.0
+ assert uut.rate == 0.0
- 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)
+ uut.rate = 0.0001
+ assert uut.rate == 0.0001
- assert mockPlayer.currentSong == None
+ uut.rate = 150.0
+ assert uut.rate == 150.0
-def test_getters(uut, mockPlayer):
- song = "test.flac"
- abLimit = [0.2, 0.4]
+def test_sanitizePlaybackPosition(uut):
+ # Initial value
+ assert uut.position == 0.0
- uut.addSong(song)
- uut.setSong(0)
- uut.storeAbLimits(abLimit[0], abLimit[1])
+ # Valid positions are in [0, 1], invalid is limited
+ uut.position = 0.2
+ assert uut.position == 0.2
- assert uut.getSongs() == [song]
+ uut.position = -0.1
+ assert uut.position == 0.0
- limits = uut.getStoredAbLimits()
- assert len(limits) == 1
- assert limits[0][0] == abLimit[0]
- assert limits[0][1] == abLimit[1]
+ uut.position = 1.0
+ assert uut.position == 1.0
- mockPlayer.position = 0.8
- assert uut.getPlaybackPosition() == 0.8
+ uut.position = 0.4
+ assert uut.position == 0.4
- mockPlayer.volume = 0.8
- assert uut.getPlaybackVolume() == 0.8
+ uut.position = 1.5
+ assert uut.position == 1.0
- mockPlayer.rate = 0.5
- assert uut.getPlaybackRate() == 0.5
+def test_sanitizePlaybackVolume(uut):
+ # Initial value
+ assert uut.volume == 1.0
-def test_setTemporaryLimits(uut, mockPlayer):
- song = "test.flac"
- abLimits = [
- [0.2, 0.4],
- [0.1, 0.4]
- ]
- overflow = 0.5
+ # Valid volumes are >= 0.0, invalid is ignored
+ uut.volume = -0.1
+ assert uut.volume == 1.0
- uut.setAbLimitEnable(True)
- mockPlayer.position = overflow
- uut.addSong(song)
- uut.setSong(0)
- uut.storeAbLimits(abLimits[0][0], abLimits[0][1])
- uut.loadAbLimits(0)
+ uut.volume = 0.0
+ assert uut.volume == 0.0
- uut.setAbLimits(abLimits[1][0], abLimits[1][1])
- uut.tick()
- assert mockPlayer.position == abLimits[1][0]
+ uut.volume = 1.0
+ assert uut.volume == 1.0
-def test_jumpToA(uut, mockPlayer):
- abLimits = (0.2, 0.4)
- initialPosition = 0.8
+ uut.volume = 150.0
+ assert uut.volume == 150.0
- 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)
+def test_playingStateNotification(uut, mockPlayer, testSongs):
+ uut.addSong(testSongs[0])
called = False
receivedValue = None
@@ -384,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()
@@ -401,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.setSong(0)
+def test_playbackVolumeNotification(uut, mockPlayer, testSongs):
+ uut.addSong(testSongs[0])
called = False
receivedValue = None
@@ -425,22 +118,20 @@ def test_playbackVolumeNotification(uut, mockPlayer):
called = True
receivedValue = value
- uut.registerPlaybackVolumeCallback(callback)
+ uut.registerVolumeCallback(callback)
assert not called
- uut.setPlaybackVolume(0.3)
+ uut.volume = 0.3
assert called
assert receivedValue == 0.3
called = False
- uut.setPlaybackVolume(0.3)
+ uut.volume = 0.3
assert not called
-def test_playbackRateNotification(uut, mockPlayer):
- song = "test.flac"
- uut.addSong(song)
- uut.setSong(0)
+def test_playbackRateNotification(uut, mockPlayer, testSongs):
+ uut.addSong(testSongs[0])
called = False
receivedValue = None
@@ -449,146 +140,15 @@ def test_playbackRateNotification(uut, mockPlayer):
called = True
receivedValue = value
- uut.registerPlaybackRateCallback(callback)
+ uut.registerRateCallback(callback)
assert not called
- uut.setPlaybackRate(0.5)
+ uut.rate = 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)
+ uut.rate = 0.5
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/solo_tool_keypoints_integrationtest.py b/solo-tool-project/test/solo_tool_keypoints_integrationtest.py
new file mode 100644
index 0000000..3db86f5
--- /dev/null
+++ b/solo-tool-project/test/solo_tool_keypoints_integrationtest.py
@@ -0,0 +1,194 @@
+import pytest
+
+from fixtures import soloTool as uut, songPool, 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..6030d19
--- /dev/null
+++ b/solo-tool-project/test/solo_tool_songs_integrationtest.py
@@ -0,0 +1,141 @@
+import pytest
+
+from fixtures import soloTool as uut, songPool, 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
+
+ # Songs must exist in the filesystem
+ with pytest.raises(FileNotFoundError):
+ uut.addSong("/not/a/real/file")
+ 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/test.flac b/solo-tool-project/test/test.flac
deleted file mode 100644
index 9164735..0000000
--- a/solo-tool-project/test/test.flac
+++ /dev/null
Binary files differ
diff --git a/solo-tool-project/test/test.mp3 b/solo-tool-project/test/test.mp3
deleted file mode 100644
index 3c353b7..0000000
--- a/solo-tool-project/test/test.mp3
+++ /dev/null
Binary files differ
diff --git a/solo-tool-project/test/test_session.json b/solo-tool-project/test/test_session.json
deleted file mode 100644
index f48b792..0000000
--- a/solo-tool-project/test/test_session.json
+++ /dev/null
@@ -1,13 +0,0 @@
-[
- {
- "path" : "test.flac",
- "ab_limits" : null
- },
- {
- "path" : "test.mp3",
- "ab_limits" : [
- [0.1, 0.2],
- [0.3, 0.4]
- ]
- }
-]
diff --git a/gui-project/pyproject.toml b/web-project/pyproject.toml
index 1e6fcf4..844de72 100644
--- a/gui-project/pyproject.toml
+++ b/web-project/pyproject.toml
@@ -3,15 +3,15 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
-name = "solo_tool_gui"
+name = "solo_tool_web"
authors = [
{ name = "Eddy Pedroni", email = "epedroni@pm.me" },
]
-description = "A Qt5-based GUI frontend for the solo_tool library"
-requires-python = ">=3.12"
+description = "A NiceGUI-based web frontend for the solo_tool library"
+requires-python = ">=3.13"
dependencies = [
- "PyQt5>=5.6",
- "solo_tool"
+ "nicegui==2.11.1",
+ "solo_tool>=2.0"
]
dynamic = ["version"]
@@ -20,5 +20,5 @@ dev = [
]
[project.gui-scripts]
-solo-tool-gui = "solo_tool_gui:main"
+solo-tool-web = "solo_tool_web:main"
diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py
new file mode 100644
index 0000000..54b18de
--- /dev/null
+++ b/web-project/src/solo_tool_web.py
@@ -0,0 +1,131 @@
+from glob import glob
+import sys
+from os import getenv
+from os.path import basename, splitext
+from functools import partial
+from nicegui import ui
+from starlette.formparsers import MultiPartParser
+
+from solo_tool import SoloTool
+from solo_tool.session_manager import loadSession, saveSession
+from solo_tool import handlers
+
+SESSION_DIR = getenv("SESSION_DIR", "/home/eddy/music/sessions")
+SONG_POOL = getenv("SONG_POOL", "/home/eddy/music/songs")
+
+def fileName(path: str) -> str:
+ return basename(splitext(path)[0])
+
+@ui.refreshable
+def keyPointList(st: SoloTool) -> None:
+ with ui.list().props('separator'):
+ if st.keyPoints is not None:
+ for kp in st.keyPoints:
+ ui.item(f"{kp:0.2}", on_click=handlers.keyPointAbsolute(st, kp)).props('clickable v-ripple').classes('text-lg')
+
+@ui.refreshable
+def songList(st: SoloTool, songDrawer) -> None:
+ with ui.list().props('separator'):
+ for i, path in enumerate(st.songs):
+ ui.item(fileName(path), on_click=handlers.songAbsolute(st, i, lambda: songDrawer.hide())).props('clickable v-ripple')
+
+sessions = {}
+for f in glob(f"{SESSION_DIR}/*.json"):
+ sessionName = fileName(f)
+ songTool = loadSession(f, SONG_POOL)
+ songTool.registerKeyPointListCallback(lambda new: keyPointList.refresh())
+ songTool.registerSongSelectionCallback(lambda new: keyPointList.refresh())
+ songTool.registerSongListCallback(lambda new: songList.refresh())
+ sessions[sessionName] = songTool
+
+@ui.page('/{sessionName}')
+def sessionPage(sessionName: str):
+ if sessionName not in sessions:
+ return
+
+ fullscreen = ui.fullscreen()
+ ui.dark_mode().enable()
+ ui.colors(secondary='#ffc107')
+ ui.page_title(sessionName)
+
+ # Improved performance with large file uploads
+ MultiPartParser.max_file_size = 1024 * 1024 * 100 # 100 MB
+
+ st = sessions[sessionName]
+
+ # Upload song dialog
+ def handleFileUpload(e):
+ from shutil import copyfileobj
+ newSong = f"{SONG_POOL}/{e.name}"
+ with open(newSong, "wb") as f:
+ copyfileobj(e.content, f)
+ st.addSong(newSong)
+
+ with ui.dialog() as uploadSongDialog:
+ ui.upload(label="Upload songs", auto_upload=True, on_upload=handleFileUpload).classes('max-w-full')
+
+ # Header
+ with ui.header().classes('items-center justify-between'):
+ with ui.row().classes('items-center justify-start'):
+ ui.button(icon='menu', on_click=lambda: songDrawer.toggle()).props('flat dense round color=white')
+ ui.label().bind_text_from(st, 'song', lambda index: fileName(st.songs[index]) if index is not None else "Select a song").classes('text-lg')
+ with ui.row().classes('items-center justify-start'):
+ ui.button(icon='home', on_click=lambda: ui.navigate.to("/")).props('flat dense round color=white')
+ def save(): saveSession(st, f"{SESSION_DIR}/{sessionName}.json")
+ ui.button(icon='save', on_click=save).props('flat dense round color=white')
+ ui.button(icon='fullscreen', on_click=fullscreen.toggle).props('flat dense round color=white')
+
+ # Key points list
+ with ui.right_drawer(top_corner=True, bottom_corner=True).props('width=120 behavior=desktop'):
+ ui.label("Key Points").classes('text-lg')
+ keyPointList(st)
+ def addKeyPoint() -> None: st.keyPoints += [st.keyPoint]
+ ui.button(icon='add', on_click=addKeyPoint).props('flat round dense color=white')
+
+ # Song list
+ with ui.left_drawer(bottom_corner=True).props('overlay breakpoint=8000') as songDrawer:
+ songList(st, songDrawer)
+ ui.button(icon='add', on_click=uploadSongDialog.open).props('flat round dense color=white')
+
+ # Playback position
+ def setPosition(e) -> None: st.position = e.args
+ ui.slider(min=0, max=1.0, step=0.001) \
+ .bind_value_from(st, 'position') \
+ .on('change', setPosition) \
+ .props('thumb-size=0px track-size=16px')
+
+ # Key point position
+ ui.slider(min=0, max=1.0, step=0.001).bind_value(st, 'keyPoint').props('selection-color=transparent color=secondary')
+
+ # Play control
+ with ui.button_group().classes('w-full').style('height: 80px'):
+ buttonSize = "20px"
+ ui.button(icon='skip_previous', on_click=handlers.restartOrPreviousSong(st, 0.01)).props(f"size={buttonSize}").style('flex: 1')
+ ui.button(color='positive', on_click=handlers.playPause(st)).bind_icon_from(st, "playing", lambda playing: "pause" if playing else "play_arrow").props(f"size={buttonSize}").style('flex: 1')
+ ui.button(icon='vertical_align_bottom', on_click=handlers.positionToKeyPoint(st), color='negative').props(f"size={buttonSize}").style('flex: 2')
+ ui.button(icon='undo', on_click=st.jump, color='secondary').props(f"size={buttonSize}").style('flex: 2')
+ ui.button(icon='skip_next', on_click=handlers.songRelative(st, 1)).props(f"size={buttonSize}").style('flex: 1')
+
+ # Playback rate
+ with ui.row().classes('w-full justify-between no-wrap items-center'):
+ markerLabels = ",".join([f"{v}:'{v}x'" for v in [0.4, 0.6, 0.8, 1.0, 1.2]])
+ ui.slider(min=0.4, max=1.2, step=0.05).bind_value(st, 'rate').props(f'snap markers :marker-labels="{{{markerLabels}}}"').classes('q-px-md')
+
+ # Volume
+ with ui.row().classes('w-full justify-between no-wrap items-center'):
+ volumeLabels = ",".join([f"{v}:'{int(v*100)}%'" for v in [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]])
+ ui.slider(min=0, max=1.25, step=0.01).bind_value(st, 'volume').props(f':marker-labels="{{{volumeLabels}}}"').classes('q-px-md')
+
+@ui.page('/')
+def landingPage():
+ ui.dark_mode().enable()
+ ui.page_title("Solo Tool")
+
+ # Header
+ with ui.header().classes('items-center'):
+ ui.label("Choose a session").classes('text-lg')
+
+ for name, soloTool in sessions.items():
+ ui.button(name, on_click=partial(ui.navigate.to, f"/{name}"))
+
+ui.run(binding_refresh_interval=0.5, port=int(sys.argv[1]))