diff options
35 files changed, 929 insertions, 1626 deletions
@@ -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 ee5ea5e..3c37bc7 100644 --- a/doc/diagram.drawio +++ b/doc/diagram.drawio @@ -1,373 +1,4 @@ -<mxfile host="Electron" modified="2025-02-22T17:51:11.071Z" 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="jME-aKyU6IcygTFz2vXw" version="22.1.2" type="device" pages="4"> - <diagram id="g-wcGVps3MkI6_XAwNEs" name="Core"> - <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="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="<div>Current</div><div>position<br></div>" 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="<div>media player</div>" 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="<div>Set current</div><div>song<br></div>" 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="<div>Current</div><div>position</div>" 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="<div>Set current</div><div>position<br></div>" 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="<div>Add a/b limit<br></div>" 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="<div>session</div><div>manager<br></div>" 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<br>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="<div>notifier</div>" 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="2731" dy="963" 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<br>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<br>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="<div>SoloTool</div>" 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="<span style="color: rgb(0 , 0 , 0)">Set volume</span>" 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="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> @@ -391,7 +22,7 @@ <mxCell id="ZtjfeE3uwfRsFhnWfLYL-44" value="80" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> <mxGeometry x="40" y="476" width="80" height="80" as="geometry" /> </mxCell> - <mxCell id="ZtjfeE3uwfRsFhnWfLYL-45" value="96<br>stop" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> + <mxCell id="ZtjfeE3uwfRsFhnWfLYL-45" value="96<br>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<br>play/<br>pause" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> @@ -569,75 +200,51 @@ </mxGraphModel> </diagram> <diagram id="R-0UAU87gWX4lK6NCzNs" name="Web wireframe"> - <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"> + <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="Song list" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="80" y="80" width="240" height="440" as="geometry" /> + <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="320" y="80" width="440" height="40" as="geometry" /> + <mxGeometry x="380" y="280" width="260" height="40" as="geometry" /> </mxCell> - <mxCell id="0goJ5iq8U8227kam6OUo-3" value="Speed slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="360" y="160" width="360" height="40" as="geometry" /> + <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="720" y="160" width="40" height="40" as="geometry" /> + <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="320" y="160" width="40" height="40" as="geometry" /> + <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="320" y="240" width="440" height="40" as="geometry" /> + <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="320" y="320" width="70" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-1" value="Seek<br>-25%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="390" y="320" width="50" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-2" value="Seek<br>-5%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="440" y="320" width="50" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-3" value="Seek<br>-1%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="490" y="320" width="50" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-5" value="Seek<br>+1%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="540" y="320" width="50" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-6" value="Seek<br>+5%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="590" y="320" width="50" height="40" as="geometry" /> - </mxCell> - <mxCell id="e6dzTLVl2QyovQL1D1hT-9" value="Seek<br>+25%" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="640" y="320" width="50" height="40" as="geometry" /> + <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="690" y="320" width="70" height="40" as="geometry" /> - </mxCell> - <mxCell id="2VOf0fCjGpZdvwMfuWx9-1" value="Set A" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="320" y="400" width="120" height="40" as="geometry" /> + <mxGeometry x="600" y="200" width="80" height="80" as="geometry" /> </mxCell> - <mxCell id="2VOf0fCjGpZdvwMfuWx9-2" value="Set B" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="440" y="400" width="120" height="40" as="geometry" /> + <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="eBDQebIGrGTMiAxLC66R-1" value="Previous AB" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="560" y="400" width="100" height="40" as="geometry" /> - </mxCell> - <mxCell id="eBDQebIGrGTMiAxLC66R-2" value="Next AB" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="660" y="400" width="100" height="40" as="geometry" /> + <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-1" value="Toggle AB" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="320" y="480" width="80" height="40" as="geometry" /> + <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-2" value="Stop" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="400" y="480" width="80" height="40" as="geometry" /> + <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="e5je7AeTKV-z7aj2oazw-3" value="Start" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="480" y="480" width="80" height="40" as="geometry" /> + <mxCell id="ZINFS9bsx5oSfdTS2e79-1" value="Full<br>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="e5je7AeTKV-z7aj2oazw-4" value="Jump to A" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="560" y="480" width="200" height="40" 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/gui-project/pyproject.toml b/gui-project/pyproject.toml deleted file mode 100644 index 1e6fcf4..0000000 --- a/gui-project/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[project] -name = "solo_tool_gui" -authors = [ - { name = "Eddy Pedroni", email = "epedroni@pm.me" }, -] -description = "A Qt5-based GUI frontend for the solo_tool library" -requires-python = ">=3.12" -dependencies = [ - "PyQt5>=5.6", - "solo_tool" -] -dynamic = ["version"] - -[project.optional-dependencies] -dev = [ -] - -[project.gui-scripts] -solo-tool-gui = "solo_tool_gui:main" - diff --git a/gui-project/src/MainWindow.py b/gui-project/src/MainWindow.py 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() @@ -1,6 +1,4 @@ pkgconf -gst-plugins-good - -qt5-multimedia - -vlc +make +gcc +mpv @@ -1,6 +1,6 @@ # 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 @@ -10,16 +10,16 @@ Non-Python dependencies are listed in pacman.txt and should be manually installe ## Usage -To set up the environment and run the tests, just use `make`: +To set up the environment and run the tests, run `make`: ``` make ``` -The GUI can then be executed in the venv: +The web GUI can also be run with `make`: ``` -./venv/bin/solo_tool_gui +make gui ``` 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: @@ -30,7 +30,7 @@ Alternatively, the tool can be executed in headless mode. In this case all it do ## 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 running the headless binary. 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`. diff --git a/requirements.txt b/requirements.txt index 7c19832..459ff68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +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/handlers.py b/solo-tool-project/src/solo_tool/handlers.py index 13e982b..1820e86 100644 --- a/solo-tool-project/src/solo_tool/handlers.py +++ b/solo-tool-project/src/solo_tool/handlers.py @@ -2,7 +2,15 @@ from collections.abc import Callable from solo_tool.solo_tool import SoloTool -def changeSong(st: SoloTool, delta: int) -> Callable[[], None]: +def playPause(st: SoloTool) -> Callable[[], None]: + def f(): + if st.playing: + st.pause() + else: + st.play() + return f + +def songRelative(st: SoloTool, delta: int) -> Callable[[], None]: def f(): if st.song is None: st.song = 0 @@ -10,12 +18,67 @@ def changeSong(st: SoloTool, delta: int) -> Callable[[], None]: st.song += delta return f +def restartOrPreviousSong(st: SoloTool, threshold: float) -> Callable[[], None]: + def f(): + if st.position < threshold and st.song > 0: + st.song -= 1 + else: + st.position = 0.0 + return f + +def songAbsolute(st: SoloTool, index: int, followUp: Callable[[], None]=None) -> Callable[[], None]: + def f(): + st.song = index + if followUp is not None: + followUp() + return f + def seekRelative(st: SoloTool, delta: float) -> Callable[[], None]: def f(): st.position += delta return f +def seekAbsolute(st: SoloTool, 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 3dc8ec6..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 @@ -32,36 +32,36 @@ class MidiController: def _registerHandlers(self): self._handlers = { - 96 : self._soloTool.stop, + 96 : handlers.seekAbsolute(self._soloTool, 0.0), 114 : self._soloTool.jump, - 112 : self._playPause, - #118 : self._soloTool.previousStoredAbLimits, - #119 : self._soloTool.nextStoredAbLimits, + 112 : handlers.playPause(self._soloTool), + 118 : handlers.keyPointRelative(self._soloTool, -1), + 119 : handlers.keyPointRelative(self._soloTool, 1), 117 : handlers.positionToKeyPoint(self._soloTool), - 48 : handlers.changeSong(self._soloTool, -1), + 48 : handlers.songRelative(self._soloTool, -1), 49 : handlers.seekRelative(self._soloTool, -0.25), 50 : handlers.seekRelative(self._soloTool, -0.05), 51 : handlers.seekRelative(self._soloTool, -0.01), 52 : handlers.seekRelative(self._soloTool, 0.01), 53 : handlers.seekRelative(self._soloTool, 0.05), 54 : handlers.seekRelative(self._soloTool, 0.25), - 55 : handlers.changeSong(self._soloTool, 1), + 55 : handlers.songRelative(self._soloTool, 1), } for i in range(0, 8): volume = round(MidiController.MIN_PLAYBACK_VOLUME + MidiController.PLAYBACK_VOLUME_STEP * i, 1) - self._handlers[i] = self._createSetPlaybackVolumeCallback(volume) + 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): @@ -71,24 +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 _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)) @@ -101,16 +89,6 @@ class MidiController: lastColumnLit = t1 - t2 + 1 self._lightRowUntilColumn(1, lastColumnLit, MidiController.LED_YELLOW) - def _createSetPlaybackRateCallback(self, rate): - def f(): - self._soloTool.rate = rate - return f - - def _createSetPlaybackVolumeCallback(self, volume): - def f(): - self._soloTool.volume = volume - return f - def _setButtonLED(self, row, col, colour): self._midiWrapper.sendMessage(MidiController.BUTTON_MATRIX[row][col], colour, MidiController.LIGHT_CONTROL_CHANNEL) @@ -119,13 +97,13 @@ 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.volume) @@ -134,10 +112,10 @@ class MidiController: self._updateRateRow(self._soloTool.rate) # playback control - self._setButtonLED(6, 0, MidiController.LED_RED) - self._updatePlayPauseButton(self._soloTool.isPlaying()) + self._setButtonLED(6, 0, 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) diff --git a/solo-tool-project/src/solo_tool/notifier.py b/solo-tool-project/src/solo_tool/notifier.py index 73b84b7..5b3539c 100644 --- a/solo-tool-project/src/solo_tool/notifier.py +++ b/solo-tool-project/src/solo_tool/notifier.py @@ -3,7 +3,9 @@ class Notifier: PLAYBACK_VOLUME_EVENT = 1 PLAYBACK_RATE_EVENT = 2 CURRENT_SONG_EVENT = 3 - CURRENT_KEY_POINT_EVENT = 3 + SONG_LIST_EVENT = 4 + CURRENT_KEY_POINT_EVENT = 5 + KEY_POINT_LIST_EVENT = 6 def __init__(self, player): self._callbacks = dict() diff --git a/solo-tool-project/src/solo_tool/player_mpv.py b/solo-tool-project/src/solo_tool/player_mpv.py new file mode 100644 index 0000000..ff7fd1a --- /dev/null +++ b/solo-tool-project/src/solo_tool/player_mpv.py @@ -0,0 +1,53 @@ +import mpv + +class Player: + def __init__(self): + self._player = mpv.MPV() + self._player.loop = "inf" + self._playingStateCallback = self._dummyCallback + self._volumeCallback = self._dummyCallback + self._player.observe_property("pause", lambda name, value: self._playingStateCallback()) + self._player.observe_property("volume", lambda name, value: self._volumeCallback()) + + def __del__(self): + self._player.close() + + def _dummyCallback(self): + pass + + def play(self): + self._player.pause = False + + def pause(self): + self._player.pause = True + + def isPlaying(self): + return not self._player.pause + + def setPlaybackRate(self, rate): + self._player.speed = rate + + def getPlaybackRate(self): + return self._player.speed + + def setPlaybackPosition(self, position): + self._player.percent_pos = int(position * 100) + + def getPlaybackPosition(self): + return float(self._player.percent_pos or 0.0) / 100.0 + + def setPlaybackVolume(self, volume): + self._player.volume = int(volume * 100) + + def getPlaybackVolume(self): + return float(self._player.volume) / 100.0 + + def setCurrentSong(self, path): + self.pause() + self._player.play(str(path)) + + def setPlayingStateChangedCallback(self, callback): + self._playingStateCallback = callback + + def setPlaybackVolumeChangedCallback(self, callback): + self._volumeCallback = callback diff --git a/solo-tool-project/src/solo_tool/player_vlc.py b/solo-tool-project/src/solo_tool/player_vlc.py deleted file mode 100644 index 283102e..0000000 --- a/solo-tool-project/src/solo_tool/player_vlc.py +++ /dev/null @@ -1,55 +0,0 @@ -import vlc - -class Player: - def __init__(self): - self._player = vlc.MediaPlayer() - - def play(self): - self._player.play() - - def stop(self): - self._player.stop() - - def pause(self): - self._player.pause() - - def isPlaying(self): - playing = self._player.is_playing() == 1 - return playing - - def setPlaybackRate(self, rate): - self._player.set_rate(rate) - - def getPlaybackRate(self): - return self._player.get_rate() - - def setPlaybackPosition(self, position): - self._player.set_position(position) - - def getPlaybackPosition(self): - return self._player.get_position() - - def setPlaybackVolume(self, volume): - self._player.audio_set_volume(int(volume * 100)) - - def getPlaybackVolume(self): - return self._player.audio_get_volume() / 100.0 - - def setCurrentSong(self, path): - self._player.stop() - media = vlc.Media(path) - self._player.set_media(media) - - def setPlayingStateChangedCallback(self, callback): - events = [ - vlc.EventType.MediaPlayerStopped, - vlc.EventType.MediaPlayerPlaying, - vlc.EventType.MediaPlayerPaused - ] - manager = self._player.event_manager() - for e in events: - manager.event_attach(e, callback) - - def setPlaybackVolumeChangedCallback(self, callback): - manager = self._player.event_manager() - manager.event_attach(vlc.EventType.MediaPlayerAudioVolume, callback) diff --git a/solo-tool-project/src/solo_tool/session_manager.py b/solo-tool-project/src/solo_tool/session_manager.py index 9744b57..cd5ebf7 100644 --- a/solo-tool-project/src/solo_tool/session_manager.py +++ b/solo-tool-project/src/solo_tool/session_manager.py @@ -1,11 +1,11 @@ import json from . import SoloTool -def loadSession(file: str) -> SoloTool: +def loadSession(file: str, songPool: str, player=None) -> SoloTool: with open(file, "r") as f: session = json.load(f) - st = SoloTool() + st = SoloTool(songPool, player=player) for i, entry in enumerate(session): songPath = entry["path"] diff --git a/solo-tool-project/src/solo_tool/solo_tool.py b/solo-tool-project/src/solo_tool/solo_tool.py index 147a7b9..92b5595 100644 --- a/solo-tool-project/src/solo_tool/solo_tool.py +++ b/solo-tool-project/src/solo_tool/solo_tool.py @@ -1,23 +1,36 @@ import os +from pathlib import Path from .notifier import Notifier -from .player_vlc import Player +from .player_mpv import Player class SoloTool: - def __init__(self, playerOverride=None): - self._player = Player() if playerOverride is None else playerOverride + def __init__(self, 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 __del__(self): + del self._player + def _updateSong(self, index): + previousSong = self._song self._song = index - path = self._songs[index] - self._player.setCurrentSong(path) + self._player.pause() + self._player.setCurrentSong(self._songPool / self._songs[index]) self._notifier.notify(Notifier.CURRENT_SONG_EVENT, index) - self._keyPoint = 0.0 + + previousKp = self._keyPoint + self._keyPoint = self.keyPoints[0] if len(self.keyPoints) > 0 else 0.0 + if previousKp != self._keyPoint: + self._notifier.notify(Notifier.CURRENT_KEY_POINT_EVENT, self._keyPoint) + + if previousSong is None or self._keyPoints[previousSong] != self._keyPoints[index]: + self._notifier.notify(Notifier.KEY_POINT_LIST_EVENT, self.keyPoints) @staticmethod def _keyPointValid(kp: float) -> bool: @@ -27,11 +40,17 @@ class SoloTool: def songs(self) -> list[str]: return self._songs.copy() - def addSong(self, path: str) -> None: + def addSong(self, fileName: str) -> None: + path = self._songPool / fileName if not os.path.isfile(path): - raise FileNotFoundError() - self._songs.append(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: @@ -49,17 +68,18 @@ class SoloTool: def keyPoints(self) -> list[float]: if self._song is None: return None - return self._keyPoints[self._song] + return self._keyPoints[self._song].copy() @keyPoints.setter def keyPoints(self, new: list[float]) -> None: if new is not None and self._song is not None: sanitized = sorted(list(set([p for p in new if SoloTool._keyPointValid(p)]))) - self._keyPoints[self._song] = sanitized + self._keyPoints[self._song] = sanitized.copy() + self._notifier.notify(Notifier.KEY_POINT_LIST_EVENT, self.keyPoints) @property def keyPoint(self) -> float: - return self._keyPoint + return float(self._keyPoint) if self._keyPoint is not None else None @keyPoint.setter def keyPoint(self, new: float) -> None: @@ -73,10 +93,8 @@ class SoloTool: def pause(self): self._player.pause() - def stop(self): - self._player.stop() - - def isPlaying(self): + @property + def playing(self) -> bool: return self._player.isPlaying() def jump(self): @@ -108,10 +126,21 @@ class SoloTool: @position.setter def position(self, new: float) -> None: - # TODO stop playback before changing position? if new is not None and new != self._player.getPlaybackPosition(): self._player.setPlaybackPosition(min(max(0.0, new), 1.0)) + def registerSongSelectionCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) + + def registerSongListCallback(self, callback): + self._notifier.registerCallback(Notifier.SONG_LIST_EVENT, callback) + + def registerKeyPointSelectionCallback(self, callback): + self._notifier.registerCallback(Notifier.CURRENT_KEY_POINT_EVENT, callback) + + def registerKeyPointListCallback(self, callback): + self._notifier.registerCallback(Notifier.KEY_POINT_LIST_EVENT, callback) + def registerPlayingStateCallback(self, callback): self._notifier.registerCallback(Notifier.PLAYING_STATE_EVENT, callback) @@ -121,9 +150,3 @@ class SoloTool: def registerRateCallback(self, callback): self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback) - def registerCurrentSongCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) - - def registerCurrentKeyPointCallback(self, callback): - self._notifier.registerCallback(Notifier.CURRENT_KEY_POINT_EVENT, callback) - diff --git a/solo-tool-project/test/fixtures.py b/solo-tool-project/test/fixtures.py new file mode 100644 index 0000000..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 9588f9f..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,7 +19,7 @@ rwd25PcButton = 49 previousSongButton = 48 playPauseButton = 112 -stopButton = 96 +jumpToStartButton = 96 nextKeyPositionButton = 119 previousKeyPositionButton = 118 @@ -52,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() @@ -67,99 +58,70 @@ 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() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - - playerMock.state = PlayerMock.PAUSED - playerMock.simulatePlayingStateChanged() + mockPlayer.playing = False + mockPlayer.simulatePlayingStateChanged() assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_YELLOW, 0) - playerMock.state = PlayerMock.PLAYING - playerMock.simulatePlayingStateChanged() - assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) - -def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock): - soloTool.addSong("test.flac") - soloTool.song = 0 +def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() soloTool.keyPoint = 0.5 - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 midiWrapperMock.simulateInput(jumpToKeyPositionButton) - assert playerMock.position == 0.5 + assert mockPlayer.position == 0.5 -def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: +# TODO implement +def test_jumpToStartButton(uut, midiWrapperMock, soloTool, mockPlayer): + pass + +def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + for s in testSongs: soloTool.addSong(s) uut.connect() - assert playerMock.currentSong == None - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[0] - + assert mockPlayer.currentSong == testSongs[0] midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[1] + assert mockPlayer.currentSong == testSongs[1] - midiWrapperMock.simulateInput(nextSongButton) - assert playerMock.currentSong == songs[1] + for _ in testSongs: + midiWrapperMock.simulateInput(nextSongButton) + assert mockPlayer.currentSong == testSongs[-1] midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] + assert mockPlayer.currentSong == testSongs[-2] - midiWrapperMock.simulateInput(previousSongButton) - assert playerMock.currentSong == songs[0] + for _ in testSongs: + midiWrapperMock.simulateInput(previousSongButton) + assert mockPlayer.currentSong == testSongs[0] -def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" +def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): keyPoints = [0.2, 0.1] - soloTool.addSong(song) - soloTool.song = 0 + soloTool.addSong(testSongs[0]) soloTool.keyPoints = keyPoints uut.connect() @@ -167,18 +129,18 @@ def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, playe assert soloTool.keyPoint == 0.0 midiWrapperMock.simulateInput(nextKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 midiWrapperMock.simulateInput(nextKeyPositionButton) - soloTool.keyPoint == 0.2 + assert soloTool.keyPoint == 0.2 midiWrapperMock.simulateInput(previousKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 midiWrapperMock.simulateInput(previousKeyPositionButton) - soloTool.keyPoint == 0.1 + assert soloTool.keyPoint == 0.1 -def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackRateButtons(uut, midiWrapperMock, soloTool, mockPlayer): playbackRateOptions = { 16 : (0.5, [LED_YELLOW] * 1 + [LED_OFF] * 7), 17 : (0.6, [LED_YELLOW] * 2 + [LED_OFF] * 6), @@ -190,18 +152,18 @@ def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock): 23 : (1.2, [LED_YELLOW] * 8) } uut.connect() - assert playerMock.rate == 1.0 + assert mockPlayer.rate == 1.0 for t, button in enumerate(playbackRateOptions): midiWrapperMock.sentMessages.clear() midiWrapperMock.simulateInput(button) - assert playerMock.rate == playbackRateOptions[button][0] + assert mockPlayer.rate == playbackRateOptions[button][0] for i, colour in enumerate(playbackRateOptions[button][1]): assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) -def test_playbackRateLeds(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackRateLeds(uut, midiWrapperMock, soloTool, mockPlayer): playbackRateOptions = [ (0.00, [LED_OFF] * 8), (0.49, [LED_OFF] * 8), @@ -231,19 +193,19 @@ def test_playbackRateLeds(uut, midiWrapperMock, soloTool, playerMock): (1.5, [LED_YELLOW] * 8) ] uut.connect() - assert playerMock.rate == 1.0 + assert mockPlayer.rate == 1.0 for t, (rate, leds) in enumerate(playbackRateOptions): print(t) midiWrapperMock.sentMessages.clear() soloTool.rate = rate - assert playerMock.rate == rate + assert mockPlayer.rate == rate for i, colour in enumerate(leds): assert midiWrapperMock.sentMessages[i] == (16 + i, colour, 0) -def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, mockPlayer): playbackVolumeOptions = { 0 : (0.5, [LED_GREEN] * 1 + [LED_OFF] * 7), 1 : (0.6, [LED_GREEN] * 2 + [LED_OFF] * 6), @@ -255,18 +217,18 @@ def test_playbackVolumeButtons(uut, midiWrapperMock, soloTool, playerMock): 7 : (1.2, [LED_GREEN] * 8) } uut.connect() - assert playerMock.volume == 1.0 + assert mockPlayer.volume == 1.0 for t, button in enumerate(playbackVolumeOptions): midiWrapperMock.sentMessages.clear() midiWrapperMock.simulateInput(button) - assert playerMock.volume == playbackVolumeOptions[button][0] + assert mockPlayer.volume == playbackVolumeOptions[button][0] for i, colour in enumerate(playbackVolumeOptions[button][1]): assert midiWrapperMock.sentMessages[i] == (i, colour, 0) -def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, playerMock): +def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, mockPlayer): playbackVolumeOptions = [ (0.00, [LED_OFF] * 8), (0.49, [LED_OFF] * 8), @@ -296,13 +258,13 @@ def test_playbackVolumeLeds(uut, midiWrapperMock, soloTool, playerMock): (1.5, [LED_GREEN] * 8) ] uut.connect() - assert playerMock.volume == 1.0 + assert mockPlayer.volume == 1.0 for t, (volume, leds) in enumerate(playbackVolumeOptions): midiWrapperMock.sentMessages.clear() soloTool.volume = volume - assert playerMock.volume == volume + assert mockPlayer.volume == volume for i, colour in enumerate(leds): assert midiWrapperMock.sentMessages[i] == (i, colour, 0) @@ -321,7 +283,7 @@ def test_connectDisconnect(uut, midiWrapperMock): [(i, LED_GREEN, 0) for i in range(0, 6)] + # volume row [(i, LED_YELLOW, 0) for i in range(16, 22)] + # playback rate row [ - (stopButton, LED_RED, 0), + (jumpToStartButton, LED_YELLOW, 0), (playPauseButton, LED_YELLOW, 0), (jumpToKeyPositionButton, LED_YELLOW, 0), (previousKeyPositionButton, LED_RED, 0), @@ -351,76 +313,67 @@ def test_connectDisconnect(uut, midiWrapperMock): assert set(midiWrapperMock.sentMessages) == set(teardownMessages) -def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, playerMock): - songs = [ - "test.flac", - "test.mp3" - ] - for s in songs: +def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + for s in testSongs: soloTool.addSong(s) uut.connect() - soloTool.song = 0 soloTool.play() - assert playerMock.state == PlayerMock.PLAYING + assert mockPlayer.playing assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_GREEN, 0) soloTool.song = 1 - assert playerMock.state == PlayerMock.STOPPED + assert not mockPlayer.playing assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0) -def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" - soloTool.addSong(song) - soloTool.song = 0 +def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() - playerMock.position = 0.3 + mockPlayer.position = 0.3 midiWrapperMock.simulateInput(setKeyPositionButton) assert soloTool.keyPoint == 0.3 - playerMock.position = 0.5 + mockPlayer.position = 0.5 midiWrapperMock.simulateInput(setKeyPositionButton) assert soloTool.keyPoint == 0.5 - playerMock.position = 0.7 + mockPlayer.position = 0.7 midiWrapperMock.simulateInput(jumpToKeyPositionButton) - assert playerMock.position == 0.5 + assert mockPlayer.position == 0.5 -def test_seekButtons(uut, midiWrapperMock, soloTool, playerMock): - song = "test.flac" - soloTool.addSong(song) - soloTool.song = 0 +def test_seekButtons(uut, midiWrapperMock, soloTool, mockPlayer, testSongs): + soloTool.addSong(testSongs[0]) uut.connect() - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 midiWrapperMock.simulateInput(fwd25PcButton) - assert playerMock.position == 0.25 + assert mockPlayer.position == 0.25 midiWrapperMock.simulateInput(fwd5PcButton) - assert playerMock.position == 0.30 + assert mockPlayer.position == 0.30 midiWrapperMock.simulateInput(fwd1PcButton) - assert playerMock.position == 0.31 + assert mockPlayer.position == 0.31 midiWrapperMock.simulateInput(fwd25PcButton) midiWrapperMock.simulateInput(fwd25PcButton) midiWrapperMock.simulateInput(fwd25PcButton) - assert playerMock.position == 1.0 + assert mockPlayer.position == 1.0 midiWrapperMock.simulateInput(rwd25PcButton) - assert playerMock.position == 0.75 + assert mockPlayer.position == 0.75 midiWrapperMock.simulateInput(rwd5PcButton) - assert playerMock.position == 0.70 + assert mockPlayer.position == 0.70 midiWrapperMock.simulateInput(rwd1PcButton) - assert playerMock.position == 0.69 + assert mockPlayer.position == 0.69 midiWrapperMock.simulateInput(rwd25PcButton) midiWrapperMock.simulateInput(rwd25PcButton) midiWrapperMock.simulateInput(rwd25PcButton) - assert playerMock.position == 0.0 + assert mockPlayer.position == 0.0 diff --git a/solo-tool-project/test/notifier_unittest.py b/solo-tool-project/test/notifier_unittest.py index 115d21a..5749149 100644 --- a/solo-tool-project/test/notifier_unittest.py +++ b/solo-tool-project/test/notifier_unittest.py @@ -38,6 +38,7 @@ def test_allEvents(uut): checkEvent(uut, Notifier.PLAYBACK_RATE_EVENT) checkEvent(uut, Notifier.CURRENT_SONG_EVENT) checkEvent(uut, Notifier.CURRENT_KEY_POINT_EVENT) + checkEvent(uut, Notifier.KEY_POINT_LIST_EVENT) def test_eventWithoutRegisteredCallbacks(uut): uut.notify(Notifier.PLAYING_STATE_EVENT, 0) @@ -59,7 +60,7 @@ def test_eventsWithMockPlayer(uut, mockPlayer): assert called assert receivedValue == expectedValue - mockPlayer.state = 1 + mockPlayer.playing = True mockPlayer.volume = 75 checkEvent(Notifier.PLAYING_STATE_EVENT, mockPlayer.simulatePlayingStateChanged, True) diff --git a/solo-tool-project/test/player_mock.py b/solo-tool-project/test/player_mock.py index 3162e0f..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/session_manager_unittest.py b/solo-tool-project/test/session_manager_unittest.py index 8658032..cff3b35 100644 --- a/solo-tool-project/test/session_manager_unittest.py +++ b/solo-tool-project/test/session_manager_unittest.py @@ -1,24 +1,34 @@ import pytest from json import loads -import pathlib -import shutil +import os from solo_tool.session_manager import loadSession, saveSession -from solo_tool.solo_tool import SoloTool +from fixtures import songPool, soloTool, mockPlayer, testSongs @pytest.fixture -def prepared_tmp_path(tmp_path): - testFiles = [ - "test.flac", - "test.mp3", - "test_session.json" - ] - for f in testFiles: - shutil.copy(pathlib.Path(f), tmp_path) - return tmp_path - -def test_loadSession(prepared_tmp_path): - soloTool = loadSession(prepared_tmp_path / "test_session.json") +def testSessionFile(tmp_path, testSongs): + contents = """[ + { + "path" : "test.flac", + "key_points" : [] + }, + { + "path" : "test.mp3", + "key_points" : [0.1, 0.3] + } +]""" + + basePath = tmp_path / "sessions" + sessionFile = basePath / "test-session.json" + + os.mkdir(basePath) + with open(sessionFile, "w") as f: + f.write(contents) + + return sessionFile + +def test_loadSession(songPool, testSessionFile, mockPlayer): + soloTool = loadSession(testSessionFile, songPool, player=mockPlayer) assert soloTool.songs == ["test.flac", "test.mp3"] @@ -28,31 +38,28 @@ def test_loadSession(prepared_tmp_path): soloTool.song = 1 assert soloTool.keyPoints == [0.1, 0.3] -def test_saveSession(prepared_tmp_path): - soloTool = SoloTool() +def test_saveSession(soloTool, testSessionFile, tmp_path): soloTool.addSong("test.flac") soloTool.addSong("test.mp3") soloTool.song = 1 soloTool.keyPoints = [0.1, 0.3] - testFile = prepared_tmp_path / "test_session_saved.json" + testFile = tmp_path / "test_session_saved.json" saveSession(soloTool, testFile) with open(testFile, "r") as f: savedSession = loads(f.read()) - with open(prepared_tmp_path / "test_session.json", "r") as f: + with open(testSessionFile, "r") as f: testSession = loads(f.read()) assert savedSession == testSession -def test_loadAndSaveEmptySession(prepared_tmp_path): - emptyFile = prepared_tmp_path / "empty_session.json" - - soloTool = SoloTool() +def test_loadAndSaveEmptySession(songPool, soloTool, tmp_path): + emptyFile = tmp_path / "empty_session.json" saveSession(soloTool, emptyFile) - reloadedTool = loadSession(emptyFile) + reloadedTool = loadSession(emptyFile, songPool) assert reloadedTool.songs == [] diff --git a/solo-tool-project/test/solo_tool_integrationtest.py b/solo-tool-project/test/solo_tool_integrationtest.py index 2a818ed..7b274a3 100644 --- a/solo-tool-project/test/solo_tool_integrationtest.py +++ b/solo-tool-project/test/solo_tool_integrationtest.py @@ -1,42 +1,14 @@ -import pathlib -import shutil -import pytest - -from solo_tool.solo_tool import SoloTool -from player_mock import Player as MockPlayer - -@pytest.fixture -def mockPlayer(): - return MockPlayer() - -@pytest.fixture -def uut(mockPlayer): - return SoloTool(mockPlayer) - -@pytest.fixture -def prepared_tmp_path(tmp_path): - testFiles = [ - "test.flac", - "test.mp3", - "test_session.json" - ] - for f in testFiles: - shutil.copy(pathlib.Path(f), tmp_path) - - return tmp_path +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.rate = 0.5 @@ -107,145 +79,8 @@ def test_sanitizePlaybackVolume(uut): uut.volume = 150.0 assert uut.volume == 150.0 -def test_addAndSelectSongs(uut, mockPlayer): - songs = [ - "test.mp3", - "test.flac" - ] - - # Songs are added one by one - for song in songs: - uut.addSong(song) - - # Songs are not selected automatically - assert mockPlayer.currentSong == None - assert uut.song == None - - # Song order is preserved - assert uut.songs == songs - - # Modifying the song list directly has no effect - uut.songs.append("something") - assert uut.songs == songs - - # Songs are selected by index - for i, s in enumerate(uut.songs): - uut.song = i - assert mockPlayer.currentSong == uut.songs[i] - assert uut.song == i - - # The current song cannot be de-selected - uut.song = None - assert uut.song == len(uut.songs) - 1 - - # Non-existent songs cannot be selected - uut.song = -1 - assert uut.song == len(uut.songs) - 1 - - uut.song = 2 - assert uut.song == len(uut.songs) - 1 - -def test_addAndJumpToKeyPoints(uut, mockPlayer): - uut.addSong("test.flac") - uut.addSong("test.mp3") - - def checkJump(before, expectedAfter): - mockPlayer.position = before - uut.jump() - assert mockPlayer.position == expectedAfter - - # Key points are None as long as no song is selected - uut.keyPoints = [0.1, 0.2] - uut.keyPoint = 0.5 - assert uut.keyPoints is None - assert uut.keyPoint is None - - uut.song = 0 - - # Once a song is selected, jump to start by default - assert uut.keyPoint == 0.0 - checkJump(0.5, 0.0) - - # By default songs have an empty list of key points - assert uut.keyPoints == [] - - uut.keyPoints = [0.2, 0.4, 0.1, 0.2] - - # Added key points are not automatically selected - assert uut.keyPoint == 0.0 - checkJump(0.1, 0.0) - - # Any key point can be selected - uut.keyPoint = uut.keyPoints[0] - checkJump(0.0, uut.keyPoints[0]) - - uut.keyPoint = 0.5 - checkJump(0.0, 0.5) - -def test_sanitizeKeyPoint(uut): - song = "test.flac" - uut.addSong(song) - uut.song = 0 - uut.keyPoints = [0.2, 0.4, 0.1, 0.2, None, -0.5, 1.0, 1.5] - - # Added key points are automatically de-duplicated, sanitized and sorted to ascending order - assert uut.keyPoints == [0.1, 0.2, 0.4] - - # Key point and key point list cannot be none - uut.keyPoint = 0.5 - - uut.keyPoint = None - assert uut.keyPoint == 0.5 - - uut.keyPoints = None - assert uut.keyPoints == [0.1, 0.2, 0.4] - - # Valid key points are in [0, 1) - uut.keyPoint = -0.1 - assert uut.keyPoint == 0.5 - - uut.keyPoint = 1.0 - assert uut.keyPoint == 0.5 - - uut.keyPoint = 0.999 - assert uut.keyPoint == 0.999 - -def test_keyPointsPerSong(uut, mockPlayer): - songs = [ - ("test.flac", [0.0, 0.5]), - ("test.mp3", [0.1]) - ] - - # Key points list is set for the selected song - for i, (song, keyPoints) in enumerate(songs): - uut.addSong(song) - uut.song = i - uut.keyPoints = keyPoints - - # Key points list is automatically loaded when the song selection changes - # Active key point is always reset to 0 when song selection changes - for i, (song, keyPoints) in enumerate(songs): - uut.keyPoint = 0.5 - uut.song = i - assert uut.keyPoints == keyPoints - assert uut.keyPoint == 0.0 - - # Key points are copied, not stored by reference - for i, (song, keyPoints) in enumerate(songs): - uut.song = i - keyPoints.append(1.0) - assert 1.0 not in uut.keyPoints - -def test_addInexistentSong(uut, mockPlayer): - song = "not/a/real/file" - - with pytest.raises(FileNotFoundError): - uut.addSong(song) - -def test_playingStateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.song = 0 +def test_playingStateNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) called = False receivedValue = None @@ -256,7 +91,7 @@ def test_playingStateNotification(uut, mockPlayer): uut.registerPlayingStateCallback(callback) - assert mockPlayer.state == MockPlayer.STOPPED + assert not mockPlayer.playing assert not called uut.play() @@ -273,22 +108,8 @@ def test_playingStateNotification(uut, mockPlayer): uut.pause() assert not called - uut.play() - assert called - assert receivedValue == True - called = False - - uut.stop() - assert called - assert receivedValue == False - called = False - uut.stop() - assert not called - -def test_playbackVolumeNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.song = 0 +def test_playbackVolumeNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) called = False receivedValue = None @@ -309,10 +130,8 @@ def test_playbackVolumeNotification(uut, mockPlayer): uut.volume = 0.3 assert not called -def test_playbackRateNotification(uut, mockPlayer): - song = "test.flac" - uut.addSong(song) - uut.song = 0 +def test_playbackRateNotification(uut, mockPlayer, testSongs): + uut.addSong(testSongs[0]) called = False receivedValue = None @@ -333,75 +152,3 @@ def test_playbackRateNotification(uut, mockPlayer): uut.rate = 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" - ] - - # Adding a song does not trigger a notification - uut.addSong(songs[0]) - assert not called - - # Selecting a song for the first time triggers - uut.song = 0 - assert called - assert receivedValue == 0 - called = False - - uut.addSong(songs[1]) - assert not called - - # Selecting the same song does not trigger - uut.song = 0 - assert not called - - uut.song = 1 - assert called - assert receivedValue == 1 - called = False - -def test_currentKeyPointNotification(uut): - called = False - receivedValue = None - def callback(value): - nonlocal called, receivedValue - called = True - receivedValue = value - - uut.registerCurrentKeyPointCallback(callback) - assert not called - - song = "test.flac" - uut.addSong(song) - uut.song = 0 - - # Selecting a song for the first time sets the key point to 0.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 - 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 Binary files differdeleted file mode 100644 index 9164735..0000000 --- a/solo-tool-project/test/test.flac +++ /dev/null diff --git a/solo-tool-project/test/test.mp3 b/solo-tool-project/test/test.mp3 Binary files differdeleted file mode 100644 index 3c353b7..0000000 --- a/solo-tool-project/test/test.mp3 +++ /dev/null diff --git a/solo-tool-project/test/test_session.json b/solo-tool-project/test/test_session.json deleted file mode 100644 index 49c2d42..0000000 --- a/solo-tool-project/test/test_session.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "path" : "test.flac", - "key_points" : [] - }, - { - "path" : "test.mp3", - "key_points" : [0.1, 0.3] - } -] diff --git a/web-project/pyproject.toml b/web-project/pyproject.toml index 440812e..844de72 100644 --- a/web-project/pyproject.toml +++ b/web-project/pyproject.toml @@ -8,10 +8,10 @@ authors = [ { name = "Eddy Pedroni", email = "epedroni@pm.me" }, ] description = "A NiceGUI-based web frontend for the solo_tool library" -requires-python = ">=3.12" +requires-python = ">=3.13" dependencies = [ "nicegui==2.11.1", - "solo_tool" + "solo_tool>=2.0" ] dynamic = ["version"] diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py index f854e1a..54b18de 100644 --- a/web-project/src/solo_tool_web.py +++ b/web-project/src/solo_tool_web.py @@ -1,56 +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 -st = SoloTool() -st.loadSession("/home/eddy/music/solos/practice.json") - -def _createSeekHandler(delta): - def f(): - newPosition = st.getPlaybackPosition() + delta - newPosition = min(1.0, max(0.0, newPosition)) - st.setPlaybackPosition(newPosition) - return f - -def main(): - with ui.splitter(value=30) as splitter: - splitter.style('width: 100%; height: 100%;') - with splitter.before: - with ui.list().props('dense separator'): - for song in st.getSongs(): - ui.item(song) - with splitter.after: - ui.slider(min=0, max=1.2, value=1.0, step=0.01, on_change=lambda e: st.setPlaybackVolume(e.value)) - - with ui.row().classes("w-full justify-between no-wrap"): - ui.button('-5%', on_click=lambda: st.setPlaybackRate(max(0.5, st.getPlaybackRate() - 0.05))) - ui.slider(min=0.5, max=1.2, step=0.05, value=st.getPlaybackRate(), on_change=lambda e: st.setPlaybackRate(e.value)) - ui.button('+5%', on_click=lambda: st.setPlaybackRate(min(1.2, st.getPlaybackRate() + 0.05))) - - ui.slider(min=0, max=100, value=0) - - with ui.row().classes("w-full justify-between no-wrap"): - ui.button('Prev', on_click=st.previousSong) - ui.button('-25%', on_click=_createSeekHandler(-0.25)) - ui.button('-5%', on_click=_createSeekHandler(-0.05)) - ui.button('-1%', on_click=_createSeekHandler(-0.01)) - ui.button('+1%', on_click=_createSeekHandler(0.01)) - ui.button('+5%', on_click=_createSeekHandler(0.05)) - ui.button('+25%', on_click=_createSeekHandler(0.25)) - ui.button('Next', on_click=st.nextSong) - - with ui.row().classes("w-full justify-between no-wrap"): - ui.button('Set A') - ui.button('Set B') - ui.button('Previous AB') - ui.button('Next AB') - - with ui.row().classes("w-full justify-between no-wrap"): - ui.button('Toggle AB', on_click=lambda: st.setAbLimitEnable(not st.isAbLimitEnabled())) - ui.button('Stop', on_click=st.stop) - ui.button('Play', on_click=st.play) - ui.button('Jump to A', on_click=st.jumpToA) - ui.run() - -if __name__ in {'__main__', '__mp_main__'}: - main() +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])) |