diff options
| author | Eddy Pedroni <epedroni@pm.me> | 2025-02-22 22:59:52 +0100 | 
|---|---|---|
| committer | Eddy Pedroni <epedroni@pm.me> | 2025-02-22 22:59:52 +0100 | 
| commit | bef8eee0f63cc5ccb963be9467381e4e605e7f53 (patch) | |
| tree | a7d07ef9689a608c572216b3e54569014752f0e6 | |
| parent | 2bcd98fd69b3b4c36b0f6c985a2ea214b1d0ad60 (diff) | |
Remove AB concept, introduce key points, refactor SoloTool to use properties
| -rw-r--r-- | doc/diagram.drawio | 64 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/abcontroller.py | 82 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/notifier.py | 3 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/solo_tool.py | 167 | ||||
| -rw-r--r-- | solo-tool-project/test/abcontroller_unittest.py | 272 | ||||
| -rw-r--r-- | solo-tool-project/test/midi_launchpad_mini_integrationtest.py | 120 | ||||
| -rw-r--r-- | solo-tool-project/test/notifier_unittest.py | 3 | ||||
| -rw-r--r-- | solo-tool-project/test/session_manager_unittest.py | 137 | ||||
| -rw-r--r-- | solo-tool-project/test/solo_tool_controller_integrationtest.py | 2 | ||||
| -rw-r--r-- | solo-tool-project/test/solo_tool_integrationtest.py | 469 | ||||
| -rw-r--r-- | solo-tool-project/test/test_session.json | 7 | 
11 files changed, 338 insertions, 988 deletions
| diff --git a/doc/diagram.drawio b/doc/diagram.drawio index 13123d6..ee5ea5e 100644 --- a/doc/diagram.drawio +++ b/doc/diagram.drawio @@ -1,6 +1,6 @@ -<mxfile host="Electron" modified="2025-02-20T18:01:24.888Z" 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="rIT3cVqIH2baaT9wWlVp" version="22.1.2" type="device" pages="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="2840" dy="1751" 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="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" /> @@ -369,7 +369,7 @@      </mxGraphModel>    </diagram>    <diagram id="PyNSc7ezSt42GBdjTBvd" name="Launchpad"> -    <mxGraphModel dx="1562" dy="947" 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="1562" dy="963" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">        <root>          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-0" />          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-1" parent="ZtjfeE3uwfRsFhnWfLYL-0" /> @@ -439,10 +439,10 @@          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-98" value="82" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="220" y="476" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-99" value="98<br>toggle AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-99" value="98" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="220" y="564" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-100" value="114<br>jump to A" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-100" value="114<br>jump to key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="220" y="651" width="80" height="80" as="geometry" />          </mxCell>          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-102" value="3<br>volume<br>80%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> @@ -490,7 +490,7 @@          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-117" value="100" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="400" y="564" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-118" value="116<br>set A" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-118" value="116" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="400" y="651" width="80" height="80" as="geometry" />          </mxCell>          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-120" value="5<br>volume<br>100%" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> @@ -514,7 +514,7 @@          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-126" value="<div>101</div>" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="490" y="564" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-127" value="117<br>set B" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-127" value="117<br>set key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#ffe6cc;strokeColor=#d79b00;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="490" y="651" width="80" height="80" as="geometry" />          </mxCell>          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-129" value="6<br>volume<br>110%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> @@ -538,7 +538,7 @@          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-135" value="102" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="580" y="564" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-136" value="118<br>previous AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-136" value="118<br>previous key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#f8cecc;strokeColor=#b85450;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="580" y="651" width="80" height="80" as="geometry" />          </mxCell>          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-138" value="7<br>volume<br>120%" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> @@ -562,81 +562,81 @@          <mxCell id="ZtjfeE3uwfRsFhnWfLYL-144" value="103" style="rounded=1;whiteSpace=wrap;html=1;container=0;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="670" y="564" width="80" height="80" as="geometry" />          </mxCell> -        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-145" value="119<br>next AB" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1"> +        <mxCell id="ZtjfeE3uwfRsFhnWfLYL-145" value="119<br>next key position" style="rounded=1;whiteSpace=wrap;html=1;container=0;fillColor=#d5e8d4;strokeColor=#82b366;" parent="ZtjfeE3uwfRsFhnWfLYL-1" vertex="1">            <mxGeometry x="670" y="651" width="80" height="80" as="geometry" />          </mxCell>        </root>      </mxGraphModel>    </diagram>    <diagram id="R-0UAU87gWX4lK6NCzNs" name="Web wireframe"> -    <mxGraphModel dx="1562" dy="947" 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="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="0goJ5iq8U8227kam6OUo-1" value="Song list" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="0goJ5iq8U8227kam6OUo-2" value="Volume slider" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="0goJ5iq8U8227kam6OUo-3" value="Speed slider" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="0goJ5iq8U8227kam6OUo-4" value="Speed +5" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="0goJ5iq8U8227kam6OUo-5" value="Speed -5" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="E9TFIlIWBKXALOEyTYeL-1" value="Seek slider" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="E9TFIlIWBKXALOEyTYeL-2" value="Prev song" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="e6dzTLVl2QyovQL1D1hT-10" value="Next song" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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" />          </mxCell> -        <mxCell id="2VOf0fCjGpZdvwMfuWx9-2" value="Set B" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="eBDQebIGrGTMiAxLC66R-1" value="Previous AB" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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;" vertex="1" parent="1"> +        <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> -        <mxCell id="e5je7AeTKV-z7aj2oazw-1" value="Toggle AB" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="e5je7AeTKV-z7aj2oazw-2" value="Stop" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="e5je7AeTKV-z7aj2oazw-3" value="Start" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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> -        <mxCell id="e5je7AeTKV-z7aj2oazw-4" value="Jump to A" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> +        <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>        </root> diff --git a/solo-tool-project/src/solo_tool/abcontroller.py b/solo-tool-project/src/solo_tool/abcontroller.py deleted file mode 100644 index cec9fb2..0000000 --- a/solo-tool-project/src/solo_tool/abcontroller.py +++ /dev/null @@ -1,82 +0,0 @@ -from collections import namedtuple - -_AB = namedtuple("_AB", ["a", "b"]) - -class ABController: -    def __init__(self, enabled=True, callback=None): -        self._setPositionCallback = callback -        self._limits = {} # dictionary of all songs -        self._songLimits = None # list of limits for selected song -        self._currentLimits = _AB(0.0, 0.0) # a/b positions of active limit -        self._loadedIndex = None -        self._enabled = enabled - -    def _ensureSongExists(self, path): -        if path not in self._limits: -            self._limits[path] = [] - -    def setCurrentSong(self, path): -        self._ensureSongExists(path) -        self._songLimits = self._limits[path] -        self._loadedIndex = None - -    def storeLimits(self, aLimit, bLimit, song=None): -        if song is not None: -            self._ensureSongExists(song) -            songLimits = self._limits[song] -        else: -            songLimits = self._songLimits - -        if songLimits is None: -            return - -        ab = _AB(aLimit, bLimit) -        songLimits.append(ab) - -    def loadLimits(self, index): -        if not self._songLimits: -            return - -        if index >= 0 and index < len(self._songLimits): -            self._currentLimits = self._songLimits[index] -            self._loadedIndex = index - -    def nextStoredAbLimits(self): -        if self._loadedIndex is None: -            nextIndex = 0 -        else: -            nextIndex = self._loadedIndex + 1 -        self.loadLimits(nextIndex) - -    def previousStoredAbLimits(self): -        if self._loadedIndex is None: -            previousIndex = 0 -        else: -            previousIndex = self._loadedIndex - 1 -        self.loadLimits(previousIndex) - -    def setLimits(self, aLimit, bLimit): -        self._currentLimits = _AB(aLimit, bLimit) -        self._loadedIndex = None - -    def positionChanged(self, position): -        if position > self._currentLimits.b and self._setPositionCallback and self._enabled: -            self._setPositionCallback(self._currentLimits.a) - -    def setEnable(self, enable): -        self._enabled = enable - -    def isEnabled(self): -        return self._enabled - -    def getStoredLimits(self, song): -        return self._limits.get(song) - -    def getCurrentLimits(self): -        return self._currentLimits - -    def getLoadedIndex(self): -        return self._loadedIndex - -    def clear(self): -        self.__init__(enabled=self._enabled, callback=self._setPositionCallback) diff --git a/solo-tool-project/src/solo_tool/notifier.py b/solo-tool-project/src/solo_tool/notifier.py index 9f445b6..73b84b7 100644 --- a/solo-tool-project/src/solo_tool/notifier.py +++ b/solo-tool-project/src/solo_tool/notifier.py @@ -3,8 +3,7 @@ class Notifier:      PLAYBACK_VOLUME_EVENT = 1      PLAYBACK_RATE_EVENT = 2      CURRENT_SONG_EVENT = 3 -    CURRENT_AB_EVENT = 4 -    AB_LIMIT_ENABLED_EVENT = 5 +    CURRENT_KEY_POINT_EVENT = 3      def __init__(self, player):          self._callbacks = dict() diff --git a/solo-tool-project/src/solo_tool/solo_tool.py b/solo-tool-project/src/solo_tool/solo_tool.py index a4c7af8..884721b 100644 --- a/solo-tool-project/src/solo_tool/solo_tool.py +++ b/solo-tool-project/src/solo_tool/solo_tool.py @@ -1,6 +1,5 @@  import os -from .abcontroller import ABController  from .session_manager import loadSession, saveSession  from .notifier import Notifier  from .player_vlc import Player @@ -8,32 +7,32 @@ from .player_vlc import Player  class SoloTool:      def __init__(self, playerOverride=None):          self._player = Player() if playerOverride is None else playerOverride -        self._abController = ABController(enabled=False, callback=self._abControllerCallback)          self._notifier = Notifier(self._player) -        self._songList = [] +        self._songs = []          self._song = None +        self._keyPoints = [] +        self._keyPoint = None      def _updateSong(self, index):          self._song = index -        path = self._songList[index] +        path = self._songs[index]          self._player.setCurrentSong(path) -        self._abController.setCurrentSong(path)          self._notifier.notify(Notifier.CURRENT_SONG_EVENT, index) +        self._keyPoint = 0.0 -    def _abControllerCallback(self, position): -        self._player.setPlaybackPosition(position) - -    def tick(self): -        position = self._player.getPlaybackPosition() -        self._abController.positionChanged(position) +    @staticmethod +    def _keyPointValid(kp: float) -> bool: +        return kp is not None and kp >= 0.0 and kp < 1.0      @property -    def songList(self) -> list[str]: -        return self._songList +    def songs(self) -> list[str]: +        return self._songs.copy()      def addSong(self, path: str) -> None: -        if os.path.isfile(path): -            self._songList.append(path) +        if not os.path.isfile(path): +            raise FileNotFoundError() +        self._songs.append(path) +        self._keyPoints.append([])      @property      def song(self) -> int: @@ -41,64 +40,36 @@ class SoloTool:      @song.setter      def song(self, new: int) -> None: -        if new >= 0 and new < len(self._songList) and new != self._song: +        if new is None or new < 0 or new >= len(self._songs): +            raise ValueError() +        if new != self._song:              self._updateSong(new) -    def storeAbLimits(self, aLimit, bLimit): -        self._abController.storeLimits(aLimit, bLimit) +    @property +    def keyPoints(self) -> list[float]: +        if self._song is None: +            return None +        return self._keyPoints[self._song] + +    @keyPoints.setter +    def keyPoints(self, new: list[float]) -> None: +        if new is None: +            raise ValueError() +        if self._song is not None: +            sanitized = sorted(list(set([p for p in new if SoloTool._keyPointValid(p)]))) +            self._keyPoints[self._song] = sanitized -    def loadAbLimits(self, index): -        previous = self._abController.getLoadedIndex() -        self._abController.loadLimits(index) -        new = self._abController.getLoadedIndex() -        if previous != new: -            self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) -     -    def setAbLimits(self, aLimit, bLimit): -        self._abController.setLimits(aLimit, bLimit) +    @property +    def keyPoint(self) -> float: +        return self._keyPoint -    def getStoredAbLimits(self): -        if self._song is not None: -            return self._abController.getStoredLimits(self.songList[self._song]) -        else: -            return list() - -    def setAbLimitEnable(self, enable): -        previous = self._abController.isEnabled() -        self._abController.setEnable(enable) -        new = self._abController.isEnabled() -        if previous != new: -            self._notifier.notify(Notifier.AB_LIMIT_ENABLED_EVENT, new) - -    def isAbLimitEnabled(self): -        return self._abController.isEnabled() - -    def nextStoredAbLimits(self): -        previous = self._abController.getLoadedIndex() -        self._abController.nextStoredAbLimits() -        new = self._abController.getLoadedIndex() -        if previous != new: -            self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) - -    def previousStoredAbLimits(self): -        previous = self._abController.getLoadedIndex() -        self._abController.previousStoredAbLimits() -        new = self._abController.getLoadedIndex() -        if previous != new: -            self._notifier.notify(Notifier.CURRENT_AB_EVENT, new) - -    def jumpToA(self): -        a = self._abController.getCurrentLimits()[0] -        # XXX assumes that player.setPlaybackPosition is thread-safe! -        self._player.setPlaybackPosition(a) - -    def loadSession(self, path): -        with open(path, "r") as f: -            loadSession(f, self._songList, self._abController) - -    def saveSession(self, path): -        with open(path, "w") as f: -            saveSession(f, self._songList, self._abController) +    @keyPoint.setter +    def keyPoint(self, new: float) -> None: +        if not SoloTool._keyPointValid(new): +            raise ValueError() +        if self._song is not None and new != self._keyPoint: +            self._keyPoint = new +            self._notifier.notify(Notifier.CURRENT_KEY_POINT_EVENT, new)      def play(self):          self._player.play() @@ -112,43 +83,57 @@ class SoloTool:      def isPlaying(self):          return self._player.isPlaying() -    def setPlaybackRate(self, rate): -        previous = self._player.getPlaybackRate() -        self._player.setPlaybackRate(rate) -        new = self._player.getPlaybackRate() -        if previous != new: -            self._notifier.notify(Notifier.PLAYBACK_RATE_EVENT, new) +    def jump(self): +        self._player.setPlaybackPosition(self._keyPoint) -    def getPlaybackRate(self): +    @property +    def rate(self) -> float:          return self._player.getPlaybackRate() -    def setPlaybackPosition(self, position): -        self._player.setPlaybackPosition(position) +    @rate.setter +    def rate(self, new: float) -> None: +        if new is None or new <= 0.0: +            raise ValueError() +        if new != self._player.getPlaybackRate(): +            self._player.setPlaybackRate(new) +            self._notifier.notify(Notifier.PLAYBACK_RATE_EVENT, new) + +    @property +    def volume(self) -> float: +        return self._player.getPlaybackVolume() -    def getPlaybackPosition(self): -        return self._player.getPlaybackPosition() +    @volume.setter +    def volume(self, new: float) -> None: +        if new is None or new < 0.0: +            raise ValueError() +        if new != self._player.getPlaybackVolume(): +            self._player.setPlaybackVolume(new) +            self._notifier.notify(Notifier.PLAYBACK_VOLUME_EVENT, new) -    def setPlaybackVolume(self, volume): -        self._player.setPlaybackVolume(volume) +    @property +    def position(self) -> float: +        return self._player.getPlaybackPosition() -    def getPlaybackVolume(self): -        return self._player.getPlaybackVolume() +    @position.setter +    def position(self, new: float) -> None: +        if new is None or new < 0.0 or new >= 1.0: +            raise ValueError() +        # TODO stop playback before changing position? +        if new != self._player.getPlaybackPosition(): +            self._player.setPlaybackPosition(new)      def registerPlayingStateCallback(self, callback):          self._notifier.registerCallback(Notifier.PLAYING_STATE_EVENT, callback) -    def registerPlaybackVolumeCallback(self, callback): +    def registerVolumeCallback(self, callback):          self._notifier.registerCallback(Notifier.PLAYBACK_VOLUME_EVENT, callback) -    def registerPlaybackRateCallback(self, callback): +    def registerRateCallback(self, callback):          self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback)      def registerCurrentSongCallback(self, callback):          self._notifier.registerCallback(Notifier.CURRENT_SONG_EVENT, callback) -    def registerCurrentAbLimitsCallback(self, callback): -        self._notifier.registerCallback(Notifier.CURRENT_AB_EVENT, callback) - -    def registerAbLimitEnabledCallback(self, callback): -        self._notifier.registerCallback(Notifier.AB_LIMIT_ENABLED_EVENT, callback) +    def registerCurrentKeyPointCallback(self, callback): +        self._notifier.registerCallback(Notifier.CURRENT_KEY_POINT_EVENT, callback) diff --git a/solo-tool-project/test/abcontroller_unittest.py b/solo-tool-project/test/abcontroller_unittest.py deleted file mode 100644 index d2b7d31..0000000 --- a/solo-tool-project/test/abcontroller_unittest.py +++ /dev/null @@ -1,272 +0,0 @@ -from solo_tool.abcontroller import ABController -from collections import namedtuple - -TCase = namedtuple("TCase", ["currentPosition", "requestedPosition"]) -AB = namedtuple("AB", ["a", "b"]) -abLimits = AB(0.2, 0.4) - -def _checkLimits(uut, tests): -    requestedPosition = None -    def callback(newPosition): -        nonlocal requestedPosition -        requestedPosition = newPosition -     -    originalCallback = uut._setPositionCallback -    uut._setPositionCallback = callback - -    for t in tests: -        uut.positionChanged(t.currentPosition) -        assert requestedPosition == t.requestedPosition - -    uut._setPositionCallback = originalCallback - -def checkLimits(uut, aLimit, bLimit, fail=False): -    tests = [ -        TCase(aLimit - 0.1, None), -        TCase(aLimit, None), -        TCase(bLimit - 0.1, None), -        TCase(bLimit, None), -        TCase(bLimit + 0.1, aLimit if not fail else None) -    ] -    _checkLimits(uut, tests) -    if not fail: -        assert uut.getCurrentLimits()[0] == aLimit -        assert uut.getCurrentLimits()[1] == bLimit - -def checkDefaultLimits(uut): -    tests = [ -        TCase(0.0, None), -        TCase(0.1, 0.0), -        TCase(0.5, 0.0) -    ] -    _checkLimits(uut, tests) - -def test_oneSetOfLimits(): -    song = "/path/to/song" - -    uut = ABController() -    uut.setCurrentSong(song) -    uut.storeLimits(abLimits.a, abLimits.b) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 - -    checkLimits(uut, abLimits.a, abLimits.b) -    assert uut.getStoredLimits(song) == [abLimits] - -def test_multipleSetsOfLimits(): -    song = "/path/to/song" -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5), -        AB(0.0, 1.2) -    ] - -    uut = ABController() -    uut.setCurrentSong(song) -    for l in abLimits: -        uut.storeLimits(l.a, l.b) -     -    for i, l in enumerate(abLimits): -        uut.loadLimits(i) -        assert uut.getLoadedIndex() == i -        checkLimits(uut, l.a, l.b) - -    assert uut.getStoredLimits(song) == abLimits - -def test_multipleSongs(): -    songs = [ -        "/path/to/song", -        "/path/to/another/song" -    ] -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5) -    ] -    uut = ABController() -    for i, s in enumerate(songs): -        uut.storeLimits(abLimits[i].a, abLimits[i].b, s) - -    for i, s in enumerate(songs): -        uut.setCurrentSong(s) -        uut.loadLimits(0) -        assert uut.getLoadedIndex() == 0 -         -        checkLimits(uut, abLimits[i].a, abLimits[i].b) -        assert uut.getStoredLimits(s) == [abLimits[i]] - -def test_disableAbRepeat(): -    song = "/path/to/song" - -    uut = ABController() -    uut.setCurrentSong(song) -    uut.storeLimits(abLimits.a, abLimits.b) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 - -    assert uut.isEnabled() - -    uut.setEnable(False) -    checkLimits(uut, abLimits.a, abLimits.b, fail=True) -    assert not uut.isEnabled() - -    uut.setEnable(True) -    checkLimits(uut, abLimits.a, abLimits.b) -    assert uut.isEnabled() - -def test_storeLimitsToSpecificSong(): -    song = "/path/to/song" - -    uut = ABController() -    uut.storeLimits(abLimits.a, abLimits.b, song) -    uut.setCurrentSong(song) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 - -    checkLimits(uut, abLimits.a, abLimits.b) - -def test_storeLimitsWithoutCurrentSong(): -    uut = ABController() -    uut.storeLimits(abLimits.a, abLimits.b) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == None - -    checkDefaultLimits(uut) -     -def test_storeLimitsToSongWithoutCurrentSong(): -    song = "/path/to/song" -    uut = ABController() -    uut.storeLimits(abLimits.a, abLimits.b, song) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == None - -    checkDefaultLimits(uut) - -    uut.setCurrentSong(song) - -    checkDefaultLimits(uut) - -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 -     -    checkLimits(uut, abLimits.a, abLimits.b) -     -def test_storeLimitsToCurrentSongButDoNotSetCurrentLimits(): -    song = "/path/to/song" -    uut = ABController() -    uut.setCurrentSong(song) -    uut.storeLimits(abLimits.a, abLimits.b) -    assert uut.getLoadedIndex() == None - -    checkDefaultLimits(uut) - -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 - -    checkLimits(uut, abLimits.a, abLimits.b) -     -def test_getStoredLimitsOfInexistentSong(): -    song = "/path/to/song" -    uut = ABController() -    assert uut.getStoredLimits(song) == None - -def test_clearAbController(): -    songsWithLimits = [ -        ("/path/to/song", AB(0.2, 0.4)), -        ("/path/to/another/song", AB(0.3, 0.5)) -    ] - -    uut = ABController() -    for s in songsWithLimits: -        uut.storeLimits(s[1].a, s[1].b, s[0]) - -    for i, s in enumerate(songsWithLimits): -        assert uut.getStoredLimits(s[0]) == [s[1]] - -    uut.clear() - -    for i, s in enumerate(songsWithLimits): -        assert uut.getStoredLimits(s[0]) == None -     -def test_setTemporaryLimits(): -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5), -        AB(0.0, 1.2) -    ] -    uut = ABController() - -    for l in abLimits: -        uut.setLimits(l.a, l.b) -        assert uut.getLoadedIndex() == None -        checkLimits(uut, l.a, l.b) - -def test_setTemporaryLimitsWithCurrentSong(): -    songLimits = AB(0.2, 0.4) -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5), -        AB(0.0, 1.2) -    ] -    song = "/path/to/song" -    uut = ABController() -    uut.setCurrentSong(song) -    uut.storeLimits(songLimits.a, songLimits.b) -    uut.loadLimits(0) -    assert uut.getLoadedIndex() == 0 - -    for l in abLimits: -        uut.setLimits(l.a, l.b) -        checkLimits(uut, l.a, l.b) - -def test_defaultBehaviour(): -    uut = ABController() -    checkDefaultLimits(uut) - -def test_nextStoredLimit(): -    song = "/path/to/song" -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5) -    ] - -    uut = ABController() -    uut.setCurrentSong(song) -    for l in abLimits: -        uut.storeLimits(l.a, l.b) -     -    checkDefaultLimits(uut) - -    uut.nextStoredAbLimits() -    checkLimits(uut, abLimits[0].a, abLimits[0].b) - -    uut.nextStoredAbLimits() -    checkLimits(uut, abLimits[1].a, abLimits[1].b) - -    uut.nextStoredAbLimits() -    checkLimits(uut, abLimits[1].a, abLimits[1].b) - -def test_previousStoredLimit(): -    song = "/path/to/song" -    abLimits = [ -        AB(0.2, 0.4), -        AB(0.3, 0.5) -    ] - -    uut = ABController() -    uut.setCurrentSong(song) -    for l in abLimits: -        uut.storeLimits(l.a, l.b) -     -    checkDefaultLimits(uut) - -    uut.previousStoredAbLimits() -    checkLimits(uut, abLimits[0].a, abLimits[0].b) - -    uut.previousStoredAbLimits() -    checkLimits(uut, abLimits[0].a, abLimits[0].b) - -    uut.loadLimits(1) -    checkLimits(uut, abLimits[1].a, abLimits[1].b) - -    uut.previousStoredAbLimits() -    checkLimits(uut, abLimits[0].a, abLimits[0].b) diff --git a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py index c0d2b47..ec41ab2 100644 --- a/solo-tool-project/test/midi_launchpad_mini_integrationtest.py +++ b/solo-tool-project/test/midi_launchpad_mini_integrationtest.py @@ -1,6 +1,8 @@  import pytest  from mido import Message +pytestmark = pytest.mark.skip(reason="not yet implemented") +  from solo_tool.midi_controller_launchpad_mini import MidiController  from solo_tool.solo_tool import SoloTool  from player_mock import Player as PlayerMock @@ -22,12 +24,10 @@ previousSongButton = 48  playPauseButton = 112  stopButton = 96 -nextLimitButton = 119 -previousLimitButton = 118 -abToggleButton = 98 -jumpToAButton = 114 -setAButton = 116 -setBButton = 117 +nextKeyPositionButton = 119 +previousKeyPositionButton = 118 +setKeyPositionButton = 117 +jumpToKeyPositionButton = 114  class MidiWrapperMock:      def __init__(self): @@ -120,35 +120,16 @@ def test_startPauseButtonLed(uut, midiWrapperMock, playerMock, soloTool):      playerMock.simulatePlayingStateChanged()      assert midiWrapperMock.getLatestMessage() == (playPauseButton, MidiController.LED_GREEN, 0) -def test_abToggleButton(uut, midiWrapperMock, soloTool): -    uut.connect() - -    midiWrapperMock.simulateInput(abToggleButton) -    assert soloTool.isAbLimitEnabled() -    assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) - -    midiWrapperMock.simulateInput(abToggleButton) -    assert not soloTool.isAbLimitEnabled() -    assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) - -def test_abToggleButtonLed(uut, midiWrapperMock, soloTool): -    uut.connect() - -    soloTool.setAbLimitEnable(True) -    assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_GREEN, 0) - -    soloTool.setAbLimitEnable(False) -    assert midiWrapperMock.getLatestMessage() == (abToggleButton, MidiController.LED_RED, 0) - -def test_jumpToAButton(uut, midiWrapperMock, soloTool, playerMock): -    ab = (0.5, 0.6) +def test_jumpToKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock): +    soloTool.addSong("test.flac") +    soloTool.song = 0      uut.connect() -    soloTool.setAbLimits(ab[0], ab[1]) +    soloTool.keyPosition = 0.5      assert playerMock.position == 0.0 -    midiWrapperMock.simulateInput(jumpToAButton) -    assert playerMock.position == ab[0] +    midiWrapperMock.simulateInput(jumpToKeyPositionButton) +    assert playerMock.position == 0.5  def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock):      songs = [ @@ -172,47 +153,29 @@ def test_previousAndNextSongButtons(uut, midiWrapperMock, soloTool, playerMock):      midiWrapperMock.simulateInput(previousSongButton)      assert playerMock.currentSong == songs[0] -def test_previousAndNextAbButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_previousAndNextKeyPositionButtons(uut, midiWrapperMock, soloTool, playerMock):      song = "test.flac" -    abLimits = [ -        [0.2, 0.4], -        [0.1, 0.3] -    ] +    keyPositions = [0.2, 0.1]      soloTool.addSong(song)      soloTool.song = 0 -    soloTool.setAbLimitEnable(True) - -    for ab in abLimits: -        soloTool.storeAbLimits(ab[0], ab[1]) +    soloTool.keyPositions = keyPositions      uut.connect() -    def checkLimit(aLimit, bLimit): -        playerMock.position = bLimit - 0.1 -        soloTool.tick() -        assert playerMock.position == bLimit - 0.1 - -        playerMock.position = bLimit + 0.1 -        soloTool.tick() -        assert playerMock.position == aLimit - -    checkLimit(0.0, 0.0) - -    midiWrapperMock.simulateInput(nextLimitButton) -    checkLimit(abLimits[0][0], abLimits[0][1]) +    assert soloTool.keyPosition == 0.0 -    midiWrapperMock.simulateInput(nextLimitButton) -    checkLimit(abLimits[1][0], abLimits[1][1]) +    midiWrapperMock.simulateInput(nextKeyPositionButton) +    soloTool.keyPosition == 0.1 -    midiWrapperMock.simulateInput(nextLimitButton) -    checkLimit(abLimits[1][0], abLimits[1][1]) +    midiWrapperMock.simulateInput(nextKeyPositionButton) +    soloTool.keyPosition == 0.2 -    midiWrapperMock.simulateInput(previousLimitButton) -    checkLimit(abLimits[0][0], abLimits[0][1]) +    midiWrapperMock.simulateInput(previousKeyPositionButton) +    soloTool.keyPosition == 0.1 -    midiWrapperMock.simulateInput(previousLimitButton) -    checkLimit(abLimits[0][0], abLimits[0][1]) +    midiWrapperMock.simulateInput(previousKeyPositionButton) +    soloTool.keyPosition == 0.1  def test_playbackRateButtons(uut, midiWrapperMock, soloTool, playerMock):      playbackRateOptions = { @@ -406,41 +369,24 @@ def test_playingFeedbackWhenChangingSong(uut, midiWrapperMock, soloTool, playerM      assert playerMock.state == PlayerMock.STOPPED      assert midiWrapperMock.getLatestMessage() == (playPauseButton, LED_YELLOW, 0) -def test_setAbButtons(uut, midiWrapperMock, soloTool, playerMock): +def test_setKeyPositionButton(uut, midiWrapperMock, soloTool, playerMock):      song = "test.flac"      soloTool.addSong(song)      soloTool.song = 0 -    soloTool.setAbLimitEnable(True) -    abLimits = (0.6, 0.8) -    soloTool.storeAbLimits(abLimits[0], abLimits[1])      uut.connect() -    def checkLimit(aLimit, bLimit): -        playerMock.position = bLimit - 0.1 -        soloTool.tick() -        assert playerMock.position == bLimit - 0.1 - -        playerMock.position = bLimit + 0.1 -        soloTool.tick() -        assert playerMock.position == aLimit - -    # Set A limit      playerMock.position = 0.3 -    midiWrapperMock.simulateInput(setAButton) -    playerMock.position = 0.5 -    midiWrapperMock.simulateInput(jumpToAButton) +    midiWrapperMock.simulateInput(setKeyPositionButton) +    assert soloTool.keyPosition == 0.3 -    assert playerMock.position == 0.3 - -    # Set B limit -    playerMock.position = 0.4 -    midiWrapperMock.simulateInput(setBButton) -    checkLimit(0.3, 0.4) +    playerMock.position = 0.5 +    midiWrapperMock.simulateInput(setKeyPositionButton) +    assert soloTool.keyPosition == 0.5 -    # Selecting preset overrides manually set limits -    midiWrapperMock.simulateInput(nextLimitButton) -    checkLimit(abLimits[0], abLimits[1]) +    playerMock.position = 0.7 +    midiWrapperMock.simulateInput(jumpToKeyPositionButton) +    assert playerMock.position == 0.5  def test_seekButtons(uut, midiWrapperMock, soloTool, playerMock):      song = "test.flac" diff --git a/solo-tool-project/test/notifier_unittest.py b/solo-tool-project/test/notifier_unittest.py index 8a6e988..115d21a 100644 --- a/solo-tool-project/test/notifier_unittest.py +++ b/solo-tool-project/test/notifier_unittest.py @@ -37,8 +37,7 @@ def test_allEvents(uut):      checkEvent(uut, Notifier.PLAYBACK_VOLUME_EVENT)      checkEvent(uut, Notifier.PLAYBACK_RATE_EVENT)      checkEvent(uut, Notifier.CURRENT_SONG_EVENT) -    checkEvent(uut, Notifier.CURRENT_AB_EVENT) -    checkEvent(uut, Notifier.AB_LIMIT_ENABLED_EVENT) +    checkEvent(uut, Notifier.CURRENT_KEY_POINT_EVENT)  def test_eventWithoutRegisteredCallbacks(uut):      uut.notify(Notifier.PLAYING_STATE_EVENT, 0) diff --git a/solo-tool-project/test/session_manager_unittest.py b/solo-tool-project/test/session_manager_unittest.py index e74bab4..0edc252 100644 --- a/solo-tool-project/test/session_manager_unittest.py +++ b/solo-tool-project/test/session_manager_unittest.py @@ -1,114 +1,59 @@ -from solo_tool.session_manager import loadSession, saveSession -from json import loads, dumps -  import pytest +from json import loads +import pathlib +import shutil -testSession = [ -    { -        "path" : "/path/to/another/song", -        "ab_limits" : None -    }, -    { -        "path" : "/path/to/song", -        "ab_limits" : [ -            [0.1, 0.2], -            [0.3, 0.4] -        ] -    }, -    { -        "path" : "/path/to/something", -        "ab_limits" : [ -            [0.1, 0.2] -        ] -    } -] - -class ABControllerMock: -    def __init__(self): -        self.limits = dict() - -    def storeLimits(self, aLimit, bLimit, song="current"): -        if song not in self.limits:  -            self.limits[song] = list() -        self.limits[song].append([aLimit, bLimit]) -     -    def getStoredLimits(self, song): -        return self.limits.get(song) - -    def clear(self): -        self.__init__() - -class MockFile: -    def __init__(self, init=""): -        self.contents = init - -    def open(self, *args): -        pass - -    def write(self, s): -        self.contents += s +pytestmark = pytest.mark.skip(reason="not yet implemented") -    def read(self): -        return self.contents - -@pytest.fixture -def songListMock(): -    return [] +from solo_tool.session_manager import loadSession, saveSession +from solo_tool.solo_tool import SoloTool  @pytest.fixture -def abControllerMock(): -    return ABControllerMock() +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(songListMock, abControllerMock): -    sessionFile = MockFile(dumps(testSession)) -    loadSession(sessionFile, songListMock, abControllerMock) +def test_loadSession(prepared_tmp_path): +    soloTool = loadSession(prepared_tmp_path / "test_session.json") -    for i, entry in enumerate(testSession): -        expectedSong = entry["path"] -        expectedLimits = entry["ab_limits"] -        loadedSong = songListMock[i] -        loadedLimits = abControllerMock.limits.get(expectedSong) +    assert soloTool.songs == ["test.flac", "test.mp3"] -        assert loadedSong == expectedSong -        assert loadedLimits == expectedLimits +    soloTool.song = 0 +    assert soloTool.keyPositions == [] -def test_saveSession(songListMock, abControllerMock): -    for i, entry in enumerate(testSession): -        song = entry["path"] -        songListMock.append(song) +    soloTool.song = 1 +    assert soloTool.keyPositions == [0.1, 0.3] -        abLimits = entry["ab_limits"] -        if abLimits is not None: -            for l in abLimits: -                abControllerMock.storeLimits(l[0], l[1], song) +def test_saveSession(prepared_tmp_path): +    soloTool = SoloTool() +    soloTool.addSong("test.flac") +    soloTool.addSong("test.mp3") +    soloTool.keyPositions = [0.1, 0.3] -    sessionFile = MockFile() -    saveSession(sessionFile, songListMock, abControllerMock) +    testFile = prepared_tmp_path / "test_session_saved.json" +    saveSession(soloTool, testFile) -    savedSession = loads(sessionFile.read()) -    assert savedSession == testSession +    with open(testFile, "r") as f: +        savedSession = loads(f.read()) -def test_loadAndSaveEmptySession(songListMock, abControllerMock): -    sessionFile = MockFile() +    with open(prepared_tmp_path / "test_session.json", "r") as f: +        testSession = loads(f.read()) -    saveSession(sessionFile, songListMock, abControllerMock) -    assert loads(sessionFile.read()) == list() +    assert savedSession == testSession -    loadSession(sessionFile, songListMock, abControllerMock) +def test_loadAndSaveEmptySession(prepared_tmp_path): +    emptyFile = prepared_tmp_path / "empty_session.json" -    assert songListMock == list() -    for s in songListMock: -        assert abControllerMock.getStoredLimits(s) == None +    soloTool = SoloTool() -def test_loadSessionNotAdditive(songListMock, abControllerMock): -    sessionFile = MockFile(dumps(testSession)) -    loadSession(sessionFile, songListMock, abControllerMock) -    loadSession(sessionFile, songListMock, abControllerMock) +    saveSession(soloTool, emptyFile) +    reloadedTool = loadSession(emptyFile) -    songs = songListMock -    assert len(songs) == len(set(songs)) -    for s in songs: -        abLimits = abControllerMock.getStoredLimits(s) -        if abLimits is not None: -            abLimitStr = [f"[{l[0]}, {l[1]}] " for l in abLimits] -            assert len(abLimitStr) == len(set(abLimitStr)) +    assert reloadedTool.songs == [] +     diff --git a/solo-tool-project/test/solo_tool_controller_integrationtest.py b/solo-tool-project/test/solo_tool_controller_integrationtest.py index 9311483..e39e5f9 100644 --- a/solo-tool-project/test/solo_tool_controller_integrationtest.py +++ b/solo-tool-project/test/solo_tool_controller_integrationtest.py @@ -2,6 +2,8 @@ import pathlib  import shutil  import pytest +pytestmark = pytest.mark.skip(reason="not yet implemented") +  from solo_tool.solo_tool_controller import SoloToolController  from solo_tool.solo_tool import SoloTool diff --git a/solo-tool-project/test/solo_tool_integrationtest.py b/solo-tool-project/test/solo_tool_integrationtest.py index 3a15e36..94d5cef 100644 --- a/solo-tool-project/test/solo_tool_integrationtest.py +++ b/solo-tool-project/test/solo_tool_integrationtest.py @@ -25,15 +25,6 @@ def prepared_tmp_path(tmp_path):      return tmp_path -def checkLimit(uut, mockPlayer, aLimit, bLimit): -    mockPlayer.position = bLimit - 0.1 -    uut.tick() -    assert mockPlayer.position == bLimit - 0.1 - -    mockPlayer.position = bLimit + 0.1 -    uut.tick() -    assert mockPlayer.position == aLimit -  def test_playerControls(uut, mockPlayer):      assert mockPlayer.state == MockPlayer.STOPPED      assert uut.isPlaying() == False @@ -48,306 +39,189 @@ def test_playerControls(uut, mockPlayer):      assert uut.isPlaying() == False      assert mockPlayer.rate == 1.0 -    uut.setPlaybackRate(0.5) +    uut.rate = 0.5      assert mockPlayer.rate == 0.5 +    assert uut.rate == 0.5      assert mockPlayer.position == 0.0 -    uut.setPlaybackPosition(0.5) +    uut.position = 0.5      assert mockPlayer.position == 0.5 +    assert uut.position == 0.5      assert mockPlayer.volume == 1.0 -    uut.setPlaybackVolume(0.5) +    uut.volume = 0.5      assert mockPlayer.volume == 0.5 +    assert uut.volume == 0.5 -def test_addAndSetSongs(uut, mockPlayer): -    songs = [ -        "test.flac", -        "test.mp3" -    ] +def test_sanitizePlaybackRate(uut): +    # Valid rates are > 0.0 +    with pytest.raises(ValueError): +        uut.rate = -0.1 -    for s in songs: -        uut.addSong(s) -    assert mockPlayer.currentSong == None -     -    for i, s in enumerate(songs): -        uut.song = i -        assert mockPlayer.currentSong == songs[i] -        assert uut.song == i +    with pytest.raises(ValueError): +        uut.rate = 0.0 -def test_addAndSetAbLimits(uut, mockPlayer): -    song = "test.flac" -    abLimits = [ -        [0.2, 0.4], -        [0.1, 0.3] -    ] +    uut.rate = 1.0 +    uut.rate = 150.0 -    uut.addSong(song) -    uut.song = 0 +def test_sanitizePlaybackPosition(uut): +    # Valid positions are in [0, 1) +    with pytest.raises(ValueError): +        uut.position = -0.1 -    for ab in abLimits: -        uut.storeAbLimits(ab[0], ab[1]) +    uut.position = 0.0 +    uut.position = 0.999 -    mockPlayer.position = 0.0 -    uut.tick() -    assert mockPlayer.position == 0.0 +    with pytest.raises(ValueError): +        uut.position = 1.0 -    mockPlayer.position = 0.5 -    uut.tick() -    assert mockPlayer.position == 0.5 +def test_sanitizePlaybackVolume(uut): +    # Valid volumes are >= 0.0 +    with pytest.raises(ValueError): +        uut.volume = -0.1 -    uut.loadAbLimits(0) +    uut.volume = 0.0 +    uut.volume = 1.0 +    uut.volume = 150.0 -    uut.tick() -    assert mockPlayer.position == 0.5 +def test_addAndSelectSongs(uut, mockPlayer): +    songs = [ +        "test.mp3", +        "test.flac" +    ] -    uut.setAbLimitEnable(True) +    # Songs are added one by one +    for song in songs: +        uut.addSong(song) -    uut.tick() -    assert mockPlayer.position == 0.2 +    # Songs are not selected automatically +    assert mockPlayer.currentSong == None +    assert uut.song == None -    uut.tick() -    assert mockPlayer.position == 0.2 +    # Song order is preserved +    assert uut.songs == songs -    uut.loadAbLimits(1) -    uut.tick() -    assert mockPlayer.position == 0.2 +    # Modifying the song list directly has no effect +    uut.songs.append("something") +    assert uut.songs == songs -    mockPlayer.position = 0.8 -    uut.tick() -    assert mockPlayer.position == 0.1 +    # 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 -def test_abLimitEnabledGetter(uut): -    assert not uut.isAbLimitEnabled() +    # The current song cannot be de-selected +    with pytest.raises(ValueError): +        uut.song = None +    assert uut.song == len(uut.songs) - 1 -    uut.setAbLimitEnable(True) -    assert uut.isAbLimitEnabled() +    # Non-existent songs cannot be selected +    with pytest.raises(ValueError): +        uut.song = -1 +    assert uut.song == len(uut.songs) - 1 -    uut.setAbLimitEnable(False) -    assert not uut.isAbLimitEnabled() +    with pytest.raises(ValueError): +        uut.song = 2 +    assert uut.song == len(uut.songs) - 1 -def test_multipleSongsAndAbLimits(uut, mockPlayer): -    songs = [ -        "test.flac", -        "test.mp3" -    ] -    abLimits = [ -        [0.2, 0.4], -        [0.5, 0.7] -    ] -     -    for s in songs: -        uut.addSong(s) +def test_addAndJumpToKeyPoints(uut, mockPlayer): +    uut.addSong("test.flac") +    uut.addSong("test.mp3") -    for i, l in enumerate(abLimits): -        uut.song = i -        uut.storeAbLimits(l[0], l[1]) +    def checkJump(before, expectedAfter): +        mockPlayer.position = before +        uut.jump() +        assert mockPlayer.position == expectedAfter -    uut.setAbLimitEnable(True) -    -    for i, l in enumerate(abLimits): -        uut.song = i -        uut.loadAbLimits(0) +    # 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 -        mockPlayer.position = l[0] -        uut.tick() -        assert mockPlayer.position == l[0] - -        mockPlayer.position = l[1] + 0.1 -        uut.tick() -        assert mockPlayer.position == l[0] - -def test_storeAbLimitsWithoutSong(uut, mockPlayer): -    song = "test.flac" -    abLimit = [0.2, 0.4] -    overflow = abLimit[1] + 0.1 -    default = 0.0 -    mockPlayer.position = overflow -    uut.setAbLimitEnable(True) - -    uut.storeAbLimits(abLimit[0], abLimit[1]) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow -     -    uut.loadAbLimits(0) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    uut.song = 0 -    uut.addSong(song) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    # Once a song is selected, jump to start by default +    assert uut.keyPoint == 0.0 +    checkJump(0.5, 0.0) -    uut.loadAbLimits(0) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    # By default songs have an empty list of key points +    assert uut.keyPoints == [] -    uut.song = 0 -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    uut.keyPoints = [0.2, 0.4, 0.1, 0.2] -    uut.loadAbLimits(0) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    # Added key points are not automatically selected +    assert uut.keyPoint == 0.0 +    checkJump(0.1, 0.0) -    uut.storeAbLimits(abLimit[0], abLimit[1]) -    uut.tick() -    assert mockPlayer.position == default -    mockPlayer.position = overflow +    # Any key point can be selected +    uut.keyPoint = uut.keyPoints[0] +    checkJump(0.0, uut.keyPoints[0]) -    uut.loadAbLimits(0) -    uut.tick() -    assert mockPlayer.position == abLimit[0] +    uut.keyPoint = 0.5 +    checkJump(0.0, 0.5) -def test_nextAndPreviousAbLimit(uut, mockPlayer): +def test_sanitizeKeyPoint(uut):      song = "test.flac" -    abLimits = [ -        [0.2, 0.4], -        [0.1, 0.3] -    ] -      uut.addSong(song)      uut.song = 0 -    uut.setAbLimitEnable(True) +    uut.keyPoints = [0.2, 0.4, 0.1, 0.2, None, -0.5, 1.0, 1.5] -    for ab in abLimits: -        uut.storeAbLimits(ab[0], ab[1]) +    # Added key points are automatically de-duplicated, sanitized and sorted to ascending order +    assert uut.keyPoints == [0.1, 0.2, 0.4] -    checkLimit(uut, mockPlayer, 0.0, 0.0) # default limits  +    # Key point and key point list cannot be none +    uut.keyPoint = 0.5 -    uut.nextStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) +    with pytest.raises(ValueError): +        uut.keyPoint = None +    assert uut.keyPoint == 0.5 -    uut.nextStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) +    with pytest.raises(ValueError): +        uut.keyPoints = None +    assert uut.keyPoints == [0.1, 0.2, 0.4] -    uut.nextStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[1][0], abLimits[1][1]) +    # Valid key points are in [0, 1) +    with pytest.raises(ValueError): +        uut.keyPoint = -0.1 -    uut.previousStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) +    with pytest.raises(ValueError): +        uut.keyPoint = 1.0 -    uut.previousStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) +    uut.keyPoint = 0.999 -def test_abLimitsWhenChangingSongs(uut, mockPlayer): +def test_keyPointsPerSong(uut, mockPlayer):      songs = [ -        "test.flac", -        "test.mp3" -    ] -    abLimits = [ -        [0.2, 0.4], -        [0.1, 0.3], -        [0.7, 0.8] +        ("test.flac", [0.0, 0.5]), +        ("test.mp3", [0.1])      ] -    uut.setAbLimitEnable(True) - -    for s in songs: -        uut.addSong(s) - -    uut.song = 0 -    for ab in abLimits: -        uut.storeAbLimits(ab[0], ab[1]) - -    uut.song = 1 -    uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) - -    uut.song = 0 -    uut.loadAbLimits(len(abLimits) - 1) -    checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) - -    uut.song = 1 -    checkLimit(uut, mockPlayer, abLimits[-1][0], abLimits[-1][1]) - -    uut.previousStoredAbLimits() -    checkLimit(uut, mockPlayer, abLimits[0][0], abLimits[0][1]) -def test_loadAndSaveSession(prepared_tmp_path): -    mockPlayer = MockPlayer() -    uut = SoloTool(mockPlayer) - -    loadedSessionFile = prepared_tmp_path / "test_session.json" -    savedSessionFile = prepared_tmp_path / "test_session_save.json" - -    uut.loadSession(loadedSessionFile) -    uut.saveSession(savedSessionFile) +    # 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 -    import json -    with open(loadedSessionFile, "r") as f: -        loadedSession = json.loads(f.read()) -         -    with open(savedSessionFile, "r") as f: -        savedSession = json.loads(f.read()) +    # 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 -    assert loadedSession == savedSession +    # 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_addInexistentFile(uut, mockPlayer): +def test_addInexistentSong(uut, mockPlayer):      song = "not/a/real/file" -    uut.addSong(song) -    uut.song = 0 - -    assert mockPlayer.currentSong == None - -def test_getters(uut, mockPlayer): -    song = "test.flac" -    abLimit = [0.2, 0.4] - -    uut.addSong(song) -    uut.song = 0 -    uut.storeAbLimits(abLimit[0], abLimit[1]) - -    assert uut.songList == [song] - -    limits = uut.getStoredAbLimits() -    assert len(limits) == 1 -    assert limits[0][0] == abLimit[0] -    assert limits[0][1] == abLimit[1] - -    mockPlayer.position = 0.8 -    assert uut.getPlaybackPosition() == 0.8 - -    mockPlayer.volume = 0.8 -    assert uut.getPlaybackVolume() == 0.8 - -    mockPlayer.rate = 0.5 -    assert uut.getPlaybackRate() == 0.5 - -def test_setTemporaryLimits(uut, mockPlayer): -    song = "test.flac" -    abLimits = [ -        [0.2, 0.4], -        [0.1, 0.4] -    ] -    overflow = 0.5 - -    uut.setAbLimitEnable(True) -    mockPlayer.position = overflow -    uut.addSong(song) -    uut.song = 0 -    uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) -    uut.loadAbLimits(0) - -    uut.setAbLimits(abLimits[1][0], abLimits[1][1]) -    uut.tick() -    assert mockPlayer.position == abLimits[1][0] - -def test_jumpToA(uut, mockPlayer): -    abLimits = (0.2, 0.4) -    initialPosition = 0.8 - -    mockPlayer.position = initialPosition -     -    uut.jumpToA() -    assert mockPlayer.position == 0.0 # default AB controller A limit - -    uut.setAbLimits(abLimits[0], abLimits[1]) -    uut.jumpToA() -    assert mockPlayer.position == abLimits[0] +    with pytest.raises(FileNotFoundError): +        uut.addSong(song)  def test_playingStateNotification(uut, mockPlayer):      song = "test.flac" @@ -404,16 +278,16 @@ def test_playbackVolumeNotification(uut, mockPlayer):          called = True          receivedValue = value -    uut.registerPlaybackVolumeCallback(callback) +    uut.registerVolumeCallback(callback)      assert not called -    uut.setPlaybackVolume(0.3) +    uut.volume = 0.3      assert called      assert receivedValue == 0.3      called = False -    uut.setPlaybackVolume(0.3) +    uut.volume = 0.3      assert not called  def test_playbackRateNotification(uut, mockPlayer): @@ -428,16 +302,16 @@ def test_playbackRateNotification(uut, mockPlayer):          called = True          receivedValue = value -    uut.registerPlaybackRateCallback(callback) +    uut.registerRateCallback(callback)      assert not called -    uut.setPlaybackRate(0.5) +    uut.rate = 0.5      assert called      assert receivedValue == 0.5      called = False -    uut.setPlaybackRate(0.5) +    uut.rate = 0.5      assert not called  def test_currentSongNotification(uut): @@ -455,9 +329,12 @@ def test_currentSongNotification(uut):          "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 @@ -466,6 +343,7 @@ def test_currentSongNotification(uut):      uut.addSong(songs[1])      assert not called +    # Selecting the same song does not trigger      uut.song = 0      assert not called @@ -474,7 +352,7 @@ def test_currentSongNotification(uut):      assert receivedValue == 1      called = False -def test_currentAbNotification(uut): +def test_currentKeyPointNotification(uut):      called = False      receivedValue = None      def callback(value): @@ -482,76 +360,29 @@ def test_currentAbNotification(uut):          called = True          receivedValue = value -    uut.registerCurrentAbLimitsCallback(callback) +    uut.registerCurrentKeyPointCallback(callback)      assert not called      song = "test.flac"      uut.addSong(song)      uut.song = 0 -    abLimits = [ -        (0.2, 0.3), -        (0.4, 0.5) -    ] -    uut.storeAbLimits(abLimits[0][0], abLimits[0][1]) -    assert not called -    uut.storeAbLimits(abLimits[1][0], abLimits[1][1]) -    assert not called - -    uut.loadAbLimits(0) -    assert called -    assert receivedValue == 0 -    called = False - -    uut.loadAbLimits(0) -    assert not called - -    uut.loadAbLimits(1) +    # Selecting a song for the first time sets the key point to 0.0      assert called -    assert receivedValue == 1 +    assert receivedValue == 0.0      called = False -    uut.previousStoredAbLimits() +    # Changing the key point triggers a notification +    uut.keyPoint = 0.5      assert called -    assert receivedValue == 0 -    called = False - -    uut.previousStoredAbLimits() -    assert not called -     -    uut.nextStoredAbLimits() -    assert called -    assert receivedValue == 1 -    called = False - -    uut.nextStoredAbLimits() -    assert not called - -def test_abLimitEnabledNotification(uut): +    assert receivedValue == 0.5      called = False -    receivedValue = None -    def callback(value): -        nonlocal called, receivedValue -        called = True -        receivedValue = value -    uut.registerAbLimitEnabledCallback(callback) +    # Adding list of key points does not trigger a notification +    uut.keyPoints = [0.2, 0.4]      assert not called -    uut.setAbLimitEnable(False) +    # Assigning the same key point again does not trigger a notification +    uut.keyPoint = 0.5      assert not called -    assert receivedValue is None -    uut.setAbLimitEnable(True) -    assert called -    assert receivedValue == True -    called = False -    receivedValue = None - -    uut.setAbLimitEnable(True) -    assert not called -    assert receivedValue is None - -    uut.setAbLimitEnable(False) -    assert called -    assert receivedValue == False diff --git a/solo-tool-project/test/test_session.json b/solo-tool-project/test/test_session.json index f48b792..aed1e11 100644 --- a/solo-tool-project/test/test_session.json +++ b/solo-tool-project/test/test_session.json @@ -1,13 +1,10 @@  [      {          "path" : "test.flac", -        "ab_limits" : null +        "key_positions" : null      },      {          "path" : "test.mp3", -        "ab_limits" : [ -            [0.1, 0.2], -            [0.3, 0.4] -        ] +        "key_positions" : [0.1, 0.3]      }  ] | 
