diff options
| author | Eddy Pedroni <epedroni@pm.me> | 2026-01-01 17:57:27 +0100 |
|---|---|---|
| committer | Eddy Pedroni <epedroni@pm.me> | 2026-01-01 17:57:27 +0100 |
| commit | 8ea2b64ff798af913dcba64baace8d2536bf0b18 (patch) | |
| tree | f85ea2f371055e67c629909df4897aec2f4bbad2 | |
| parent | 88ce99d87889cdf953af611ef09d7a12b6d23747 (diff) | |
Add Android app wrapper around web interface
| -rw-r--r-- | doc/diagram.drawio | 127 | ||||
| -rw-r--r-- | solo-tool-project/pyproject.toml | 4 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/recorder.py | 75 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/session_manager.py | 88 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/solo_tool.py | 14 | ||||
| -rw-r--r-- | solo-tool-project/src/solo_tool/storage.py | 87 | ||||
| -rw-r--r-- | solo-tool-project/test/session_manager_unittest.py | 2 | ||||
| -rw-r--r-- | web-project/src/recording.py | 111 | ||||
| -rw-r--r-- | web-project/src/solo_tool_web.py | 18 |
9 files changed, 420 insertions, 106 deletions
diff --git a/doc/diagram.drawio b/doc/diagram.drawio index 3c37bc7..62d4789 100644 --- a/doc/diagram.drawio +++ b/doc/diagram.drawio @@ -1,4 +1,4 @@ -<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"> +<mxfile host="Electron" modified="2025-12-31T14:40:34.185Z" 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="L9KHjWv5AXX6uLd6jYx6" version="22.1.2" type="device" pages="3"> <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> @@ -200,26 +200,20 @@ </mxGraphModel> </diagram> <diagram id="R-0UAU87gWX4lK6NCzNs" name="Web wireframe"> - <mxGraphModel dx="1302" dy="803" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0"> + <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="Key point list" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="160" y="80" width="80" height="240" as="geometry" /> - </mxCell> - <mxCell id="0goJ5iq8U8227kam6OUo-2" value="Volume slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="380" y="280" width="260" height="40" as="geometry" /> - </mxCell> - <mxCell id="0goJ5iq8U8227kam6OUo-3" value="Speed" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="280" y="280" width="60" height="40" as="geometry" /> + <mxGeometry x="680" y="80" width="80" height="280" as="geometry" /> </mxCell> - <mxCell id="0goJ5iq8U8227kam6OUo-4" value="Speed +5" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="340" y="280" width="40" height="40" as="geometry" /> + <mxCell id="0goJ5iq8U8227kam6OUo-2" value="Song volume slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxGeometry x="240" y="280" width="440" height="40" as="geometry" /> </mxCell> - <mxCell id="0goJ5iq8U8227kam6OUo-5" value="Speed -5" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> - <mxGeometry x="240" y="280" width="40" height="40" as="geometry" /> + <mxCell id="0goJ5iq8U8227kam6OUo-3" value="Speed slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxGeometry x="240" y="320" width="320" height="40" as="geometry" /> </mxCell> - <mxCell id="E9TFIlIWBKXALOEyTYeL-1" value="Seek slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxCell id="E9TFIlIWBKXALOEyTYeL-1" value="Song seek slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="240" y="120" width="440" height="40" as="geometry" /> </mxCell> <mxCell id="E9TFIlIWBKXALOEyTYeL-2" value="Prev song" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> @@ -231,20 +225,113 @@ <mxCell id="2VOf0fCjGpZdvwMfuWx9-2" value="Key point slider" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="240" y="160" width="440" height="40" as="geometry" /> </mxCell> - <mxCell id="e5je7AeTKV-z7aj2oazw-2" value="Stop" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxCell id="e5je7AeTKV-z7aj2oazw-2" value="Play/pause" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="320" y="200" width="80" height="80" as="geometry" /> </mxCell> - <mxCell id="e5je7AeTKV-z7aj2oazw-3" value="Play/pause" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxCell id="e5je7AeTKV-z7aj2oazw-3" value="Set" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="400" y="200" width="100" height="80" as="geometry" /> </mxCell> <mxCell id="e5je7AeTKV-z7aj2oazw-4" value="Jump" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="500" y="200" width="100" height="80" as="geometry" /> </mxCell> - <mxCell id="ZINFS9bsx5oSfdTS2e79-1" value="Full<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 id="ZINFS9bsx5oSfdTS2e79-1" value="Full<br>screen" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> + <mxGeometry x="640" y="80" width="40" height="40" as="geometry" /> + </mxCell> + <mxCell id="ZINFS9bsx5oSfdTS2e79-4" value="Song name" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1"> + <mxGeometry x="280" y="80" width="280" height="40" as="geometry" /> + </mxCell> + <mxCell id="FSXJR3qsPtWyKxkSXf7B-4" value="Save" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="600" y="80" width="40" height="40" as="geometry" /> + </mxCell> + <mxCell id="FSXJR3qsPtWyKxkSXf7B-5" value="Home" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="560" y="80" width="40" height="40" as="geometry" /> + </mxCell> + <mxCell id="FSXJR3qsPtWyKxkSXf7B-6" value="Burger" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="240" y="80" width="40" height="40" as="geometry" /> + </mxCell> + <mxCell id="FSXJR3qsPtWyKxkSXf7B-1" value="Song list" style="rounded=0;whiteSpace=wrap;html=1;strokeColor=default;dashed=1;fillColor=default;" vertex="1" parent="1"> + <mxGeometry x="120" y="80" width="120" height="280" as="geometry" /> + </mxCell> + <mxCell id="K_0XtMrkDRZaAgXwCXOi-3" value="Play" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="600" y="320" width="40" height="40" as="geometry" /> </mxCell> - <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 id="K_0XtMrkDRZaAgXwCXOi-4" value="Rec" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="560" y="320" width="40" height="40" as="geometry" /> + </mxCell> + <mxCell id="K_0XtMrkDRZaAgXwCXOi-6" value="Up" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="640" y="320" width="40" height="40" as="geometry" /> + </mxCell> + </root> + </mxGraphModel> + </diagram> + <diagram id="Y4Ghim34UWfIzGFWpSIp" name="Recording state machine"> + <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="m4dBRZqiv5qoeC6Vf523-3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;startArrow=classic;startFill=1;" edge="1" parent="1" source="m4dBRZqiv5qoeC6Vf523-1" target="m4dBRZqiv5qoeC6Vf523-2"> + <mxGeometry relative="1" as="geometry" /> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-6" value="Click record" style="edgeLabel;html=1;align=left;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="m4dBRZqiv5qoeC6Vf523-3"> + <mxGeometry x="-0.325" y="-1" relative="1" as="geometry"> + <mxPoint x="1" y="13" as="offset" /> + </mxGeometry> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-10" value="" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="m4dBRZqiv5qoeC6Vf523-1" target="m4dBRZqiv5qoeC6Vf523-9"> + <mxGeometry relative="1" as="geometry" /> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-14" value="Click play" style="edgeLabel;html=1;align=left;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="m4dBRZqiv5qoeC6Vf523-10"> + <mxGeometry x="-0.0993" y="-1" relative="1" as="geometry"> + <mxPoint x="-19" y="26" as="offset" /> + </mxGeometry> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-6" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="m4dBRZqiv5qoeC6Vf523-1" target="h-RRU4YisuIuMyhp2gHc-5"> + <mxGeometry relative="1" as="geometry" /> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-7" value="Click upload" style="edgeLabel;html=1;align=left;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="h-RRU4YisuIuMyhp2gHc-6"> + <mxGeometry x="0.21" y="4" relative="1" as="geometry"> + <mxPoint x="-38" y="14" as="offset" /> + </mxGeometry> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-1" value="Idle" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;" vertex="1" parent="1"> + <mxGeometry x="280" y="120" width="80" height="80" as="geometry" /> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="m4dBRZqiv5qoeC6Vf523-2" target="m4dBRZqiv5qoeC6Vf523-9"> + <mxGeometry relative="1" as="geometry" /> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-4" value="Click play" style="edgeLabel;html=1;align=left;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="h-RRU4YisuIuMyhp2gHc-3"> + <mxGeometry x="-0.1429" y="-1" relative="1" as="geometry"> + <mxPoint x="-30" y="11" as="offset" /> + </mxGeometry> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-2" value="Recording" style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;" vertex="1" parent="1"> + <mxGeometry x="280" y="280" width="80" height="80" as="geometry" /> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="m4dBRZqiv5qoeC6Vf523-9" target="m4dBRZqiv5qoeC6Vf523-1"> + <mxGeometry relative="1" as="geometry"> + <Array as="points"> + <mxPoint x="170" y="160" /> + </Array> + </mxGeometry> + </mxCell> + <mxCell id="m4dBRZqiv5qoeC6Vf523-9" value="Configure player" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="130" y="280" width="80" height="80" as="geometry" /> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="h-RRU4YisuIuMyhp2gHc-5" target="m4dBRZqiv5qoeC6Vf523-1"> + <mxGeometry relative="1" as="geometry"> + <Array as="points"> + <mxPoint x="480" y="100" /> + <mxPoint x="320" y="100" /> + </Array> + </mxGeometry> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-10" value="Upload done" style="edgeLabel;html=1;align=left;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="h-RRU4YisuIuMyhp2gHc-9"> + <mxGeometry x="-0.394" y="-1" relative="1" as="geometry"> + <mxPoint x="-79" y="11" as="offset" /> + </mxGeometry> + </mxCell> + <mxCell id="h-RRU4YisuIuMyhp2gHc-5" value="Upload" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1"> + <mxGeometry x="440" y="120" width="80" height="80" as="geometry" /> </mxCell> </root> </mxGraphModel> diff --git a/solo-tool-project/pyproject.toml b/solo-tool-project/pyproject.toml index 841ee46..40d09d8 100644 --- a/solo-tool-project/pyproject.toml +++ b/solo-tool-project/pyproject.toml @@ -14,7 +14,9 @@ dependencies = [ "python-rtmidi", "sip", "mido", - "python-mpv" + "python-mpv", + "pyaudio", + "pydub-ng" ] [project.optional-dependencies] diff --git a/solo-tool-project/src/solo_tool/recorder.py b/solo-tool-project/src/solo_tool/recorder.py new file mode 100644 index 0000000..fd2df02 --- /dev/null +++ b/solo-tool-project/src/solo_tool/recorder.py @@ -0,0 +1,75 @@ +import pyaudio as pa +from pathlib import Path + +class Recording: + def __init__(self, frames: [], channels: int, samplingRate: int, sampleFormat: int): + self._frames = frames + self._channels = channels + self._samplingRate = samplingRate + self._sampleFormat = sampleFormat + + def writeWav(self, file: Path) -> None: + import wave + with wave.open(str(file), "wb") as wf: + wf.setnchannels(self._channels) + wf.setsampwidth(self._sampleFormat) + wf.setframerate(self._samplingRate) + wf.writeframes(b''.join(self._frames)) + + def writeMp3(self, file: Path) -> None: + from pydub import AudioSegment + segment = AudioSegment( + data=b''.join(self._frames), + sample_width=self._sampleFormat, + frame_rate=self._samplingRate, + channels=self._channels + ) + segment.export(str(file), format="mp3", bitrate="320k") + +class Recorder: + def __init__(self, bufferSize: int, samplingRate: int): + self._bufferSize = bufferSize + self._samplingRate = samplingRate + self._sampleFormat = pa.paInt16 + self._channels = 2 + self._pa = pa.PyAudio() + self._stream = None + self._frames = [] + + def __del__(self): + if self.recording: + self._stream.stop_stream() + self._stream.close() + + def _callback(self, inData, frameCount, timeInfo, statusFlags): + if statusFlags != pa.paNoError: + print(f"Recorder callback got status {hex(statusFlags)}, aborting") + return (None, pa.paAbort) + + self._frames.append(inData) + return (None, pa.paContinue) + + def startRecording(self) -> None: + if self.recording: + return + + self._frames.clear() + self._stream = self._pa.open(format=self._sampleFormat, + channels=self._channels, + rate=self._samplingRate, + frames_per_buffer=self._bufferSize, + input=True, + stream_callback=self._callback) + + def stopRecording(self) -> Recording: + if not self.recording: + return None + self._stream.stop_stream() + self._stream.close() + self._stream = None + return Recording(self._frames, self._channels, self._samplingRate, self._pa.get_sample_size(self._sampleFormat)) + + @property + def recording(self) -> bool: + return self._stream is not None + diff --git a/solo-tool-project/src/solo_tool/session_manager.py b/solo-tool-project/src/solo_tool/session_manager.py index 8624207..7d98106 100644 --- a/solo-tool-project/src/solo_tool/session_manager.py +++ b/solo-tool-project/src/solo_tool/session_manager.py @@ -1,12 +1,7 @@ -from typing import Protocol -from abc import abstractmethod -from . import SoloTool - from pathlib import Path -from glob import glob -import json -import requests -from os import getenv + +from . import SoloTool +from .storage import FileSystemStorageBackend, FileBrowserStorageBackend class SessionManager(): def __init__(self, sessionPath: str): @@ -15,17 +10,17 @@ class SessionManager(): from re import search match = search(r"^([a-z0-9]+://)", sessionPath) if not match or match.group(0) == "file://": - self._backend = _FileSystemBackend(sessionPath) + self._backend = FileSystemStorageBackend(sessionPath) elif match.group(0) in ["http://", "https://"]: - self._backend = _FileBrowserBackend(sessionPath) + self._backend = FileBrowserStorageBackend(sessionPath) else: raise ValueError(f"Unsupported session path: {sessionPath}") def getSessions(self) -> list[str]: - return self._backend.listIds() + return self._backend.listSessions() def loadSession(self, id: str, player=None) -> SoloTool: - session = self._backend.read(id) + session = self._backend.readSession(id) st = SoloTool(player=player) for i, entry in enumerate(session): @@ -48,70 +43,7 @@ class SessionManager(): } session.append(entry) - self._backend.write(session, id) - -class _Backend(Protocol): - @abstractmethod - def listIds(self) -> list[str]: - raise NotImplementedError - - @abstractmethod - def read(self, id: str) -> dict: - raise NotImplementedError - - @abstractmethod - def write(self, session: dict, id: str) -> None: - raise NotImplementedError - -class _FileSystemBackend(_Backend): - def __init__(self, sessionPath: str): - self._sessionPath = Path(sessionPath) - - def listIds(self) -> list[str]: - return [Path(f).stem for f in glob(f"{self._sessionPath}/*.json")] - - def read(self, id: str) -> dict: - with open(self._sessionPath / f"{id}.json", "r") as f: - session = json.load(f) - return session - - def write(self, session: dict, id: str) -> None: - with open(self._sessionPath / f"{id}.json", "w") as f: - json.dump(session, f) - -class _FileBrowserBackend(_Backend): - def __init__(self, serverUrl: str): - self._baseUrl = serverUrl - self._username = getenv("ST_USER") - self._password = getenv("ST_PASS") - self._apiKey = self._getApiKey() - - def listIds(self) -> list[str]: - url = f"{self._baseUrl}/api/resources" - response = self._request("GET", url) - return [item["name"][0:-5] for item in response.json()["items"] if item["extension"] == ".json"] - - def read(self, id: str) -> dict: - url = f"{self._baseUrl}/api/raw/{id}.json" - response = self._request("GET", url) - return json.loads(response.content) - - def write(self, session: dict, id: str) -> None: - url = f"{self._baseUrl}/api/resources/{id}.json" - self._request("PUT", url, json=session) - - def _getApiKey(self) -> str: - response = requests.post(f"{self._baseUrl}/api/login", json={"username":self._username, "password":self._password}) - return response.content - - def _request(self, verb: str, url: str, **kwargs): - headers = {"X-Auth" : self._apiKey} - response = requests.request(verb, url, headers=headers, **kwargs) - if response.status_code == requests.codes.UNAUTHORIZED: - # if unauthorized, the key might have expired - self._apiKey = self._getApiKey() - headers["X-Auth"] = self._apiKey - response = requests.request(verb, url, headers=headers, **kwargs) - response.raise_for_status() - return response + self._backend.writeSession(session, id) + def saveRecording(self, recording: Path, destination: str) -> None: + self._backend.writeRecording(recording, destination) diff --git a/solo-tool-project/src/solo_tool/solo_tool.py b/solo-tool-project/src/solo_tool/solo_tool.py index e8474e6..c4acaf8 100644 --- a/solo-tool-project/src/solo_tool/solo_tool.py +++ b/solo-tool-project/src/solo_tool/solo_tool.py @@ -12,12 +12,14 @@ class SoloTool: self._keyPoints = [] self._keyPoint = None self._volumes = [] + self._adHoc = False def __del__(self): del self._player def _updateSong(self, index): previousSong = self._song + self._adHoc = False self._song = index self._player.pause() self._player.setCurrentSong(self._songs[index]) @@ -151,3 +153,15 @@ class SoloTool: def registerRateCallback(self, callback): self._notifier.registerCallback(Notifier.PLAYBACK_RATE_EVENT, callback) + def playAdHoc(self, file) -> None: + self._adHoc = True + self._player.setCurrentSong(file) + + def backToNormal(self) -> None: + self._adHoc = False + self._player.setCurrentSong(self._songs[self._song]) + + @property + def playingAdHoc(self) -> bool: + return self._adHoc + diff --git a/solo-tool-project/src/solo_tool/storage.py b/solo-tool-project/src/solo_tool/storage.py new file mode 100644 index 0000000..0c5577f --- /dev/null +++ b/solo-tool-project/src/solo_tool/storage.py @@ -0,0 +1,87 @@ +from typing import Protocol +from abc import abstractmethod + +from pathlib import Path +from glob import glob +import json +import requests +from os import getenv + +class StorageBackend(Protocol): + @abstractmethod + def listSessions(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def readSession(self, id: str) -> dict: + raise NotImplementedError + + @abstractmethod + def writeSession(self, session: dict, id: str) -> None: + raise NotImplementedError + + @abstractmethod + def writeRecording(self, recording: Path, destination: str) -> None: + raise NotImplementedError + +class FileSystemStorageBackend(StorageBackend): + def __init__(self, storagePath: str): + self._storagePath = Path(storagePath) + + def listSessions(self) -> list[str]: + #return [Path(f).stem for f in glob(f"{self._storagePath / "sessions"}/*.json")] + return [Path(f).stem for f in glob(str(self._storagePath / "sessions" / "*.json"))] + + def readSession(self, id: str) -> dict: + with open(self._storagePath / "sessions" / f"{id}.json", "r") as f: + session = json.load(f) + return session + + def writeSession(self, session: dict, id: str) -> None: + with open(self._storagePath / "sessions" / f"{id}.json", "w") as f: + json.dump(session, f) + + def writeRecording(self, recording: Path, destination: str) -> None: + pass + +class FileBrowserStorageBackend(StorageBackend): + def __init__(self, serverUrl: str): + self._baseUrl = serverUrl + self._username = getenv("ST_USER") + self._password = getenv("ST_PASS") + self._apiKey = self._getApiKey() + + def listSessions(self) -> list[str]: + url = f"{self._baseUrl}/api/resources/sessions" + response = self._request("GET", url) + return [item["name"][0:-5] for item in response.json()["items"] if item["extension"] == ".json"] + + def readSession(self, id: str) -> dict: + url = f"{self._baseUrl}/api/raw/sessions/{id}.json" + response = self._request("GET", url) + return json.loads(response.content) + + def writeSession(self, session: dict, id: str) -> None: + url = f"{self._baseUrl}/api/resources/sessions/{id}.json" + self._request("PUT", url, json=session) + + def writeRecording(self, recording: Path, destination: str) -> None: + url = f"{self._baseUrl}/api/resources/recordings/{destination}" + with open(recording, "rb") as file: + self._request("POST", url, {"Content-Type" : "audio/mpeg"}, data=file) + + def _getApiKey(self) -> str: + response = requests.post(f"{self._baseUrl}/api/login", json={"username":self._username, "password":self._password}) + return response.content + + def _request(self, verb: str, url: str, moreHeaders: dict={}, **kwargs): + headers = moreHeaders | {"X-Auth" : self._apiKey} + response = requests.request(verb, url, headers=headers, **kwargs) + if response.status_code == requests.codes.UNAUTHORIZED: + # if unauthorized, the key might have expired + self._apiKey = self._getApiKey() + headers["X-Auth"] = self._apiKey + response = requests.request(verb, url, headers=headers, **kwargs) + response.raise_for_status() + return response + diff --git a/solo-tool-project/test/session_manager_unittest.py b/solo-tool-project/test/session_manager_unittest.py index 5786b23..ace1ccb 100644 --- a/solo-tool-project/test/session_manager_unittest.py +++ b/solo-tool-project/test/session_manager_unittest.py @@ -25,7 +25,7 @@ def testSessionFile(sessionPath, testSongs): @pytest.fixture def sessionManager(sessionPath): - return SessionManager(str(sessionPath)) + return SessionManager(str(sessionPath.parent)) def test_loadSession(sessionManager, mockPlayer, testSessionFile): sessions = sessionManager.getSessions() diff --git a/web-project/src/recording.py b/web-project/src/recording.py new file mode 100644 index 0000000..2afd567 --- /dev/null +++ b/web-project/src/recording.py @@ -0,0 +1,111 @@ +from pathlib import Path +from contextlib import contextmanager +from asyncio import sleep +from tempfile import TemporaryDirectory +from datetime import date + +from nicegui import ui, run + +_recording = None + +@contextmanager +def _disable(button: ui.button): + button.disable() + try: + yield + finally: + button.enable() + +async def _stopRecording(recordButton, uploadButton, recorder, wavFile): + with _disable(recordButton): + global _recording + _recording = recorder.stopRecording() + await run.cpu_bound(_recording.writeWav, wavFile) + uploadButton.enable() + +def _makeRecordCallback(playButton, recordButton, uploadButton, soloTool, recorder, wavFile): + async def f(): + if recorder.recording: + await _stopRecording(recordButton, uploadButton, recorder, wavFile) + else: + if soloTool.playingAdHoc: + soloTool.backToNormal() + uploadButton.disable() + recorder.startRecording() + playButton.enable() + return f + +def _makePlayCallback(playButton, recordButton, uploadButton, soloTool, recorder, wavFile): + async def f(): + with _disable(playButton): + if recorder.recording: + await _stopRecording(recordButton, uploadButton, recorder, wavFile) + + if soloTool.playingAdHoc: + soloTool.backToNormal() + else: + soloTool.playAdHoc(wavFile) + soloTool.play() + return f + +def _makeUploadCallback(playButton, recordButton, uploadButton, tempDir, sessionManager): + async def f(): + with ui.dialog() as dialog, ui.card(): + fileName = ui.input(label='File name', value="jam.mp3") + with ui.row(): + ui.button('Upload', color='positive', on_click=lambda: dialog.submit(fileName.value)) + ui.button('Cancel', color='negative' ,on_click=lambda: dialog.submit(None)) + + fileName = await dialog + if fileName is None: + return + + playButton.disable() + recordButton.disable() + uploadButton.disable() + + def on_dismiss(): + playButton.enable() + recordButton.enable() + uploadButton.enable() + n = ui.notification(timeout=None, position='bottom-right', type='ongoing', spinner=True, on_dismiss=on_dismiss, icon='check') + + n.message = f'Converting to .mp3...' + mp3File = Path(tempDir.name) / fileName + await run.cpu_bound(_recording.writeMp3, mp3File) + + n.message = 'Uploading...' + folderName = date.today().isoformat() + try: + await run.io_bound(sessionManager.saveRecording, mp3File, f"{folderName}/{fileName}") + except: + n.spinner = False + n.icon = 'error' + n.message = 'Upload failed!' + n.close_button = 'Close' + return + + n.spinner = False + n.message = 'Done!' + await sleep(2) + n.dismiss() + return f + +def recordingControls(soloTool, recorder, sessionManager): + tempDir = TemporaryDirectory(prefix="solotool-") + wavFile = Path(tempDir.name) / "st_recording.wav" + + with ui.button_group().classes('').style('height: 40px'): + recordButton = ui.button(icon='fiber_manual_record', color='negative') \ + .bind_icon_from(recorder, 'recording', lambda recording: 'radio_button_unchecked' if recording else 'fiber_manual_record') + + playButton = ui.button(icon='hearing') \ + .bind_icon_from(soloTool, 'playingAdHoc', lambda adHoc: 'close' if adHoc else 'hearing') + playButton.disable() + + uploadButton = ui.button(icon='cloud_upload') + uploadButton.disable() + + recordButton.on_click(_makeRecordCallback(playButton, recordButton, uploadButton, soloTool, recorder, wavFile)) + playButton.on_click(_makePlayCallback(playButton, recordButton, uploadButton, soloTool, recorder, wavFile)) + uploadButton.on_click(_makeUploadCallback(playButton, recordButton, uploadButton, tempDir, sessionManager)) diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py index 3c68585..02ffdac 100644 --- a/web-project/src/solo_tool_web.py +++ b/web-project/src/solo_tool_web.py @@ -10,6 +10,9 @@ from urllib.parse import unquote from solo_tool import SoloTool, handlers from solo_tool.session_manager import SessionManager from solo_tool.midi_controller_actition import ActitionController +from solo_tool.recorder import Recorder + +from recording import recordingControls def fileName(path: str) -> str: return unquote(basename(splitext(path)[0])) @@ -30,6 +33,7 @@ def songList(st: SoloTool, songDrawer) -> None: sessions = {} sessionManager = None midiPedal = ActitionController() +recorder = Recorder(1024, 48000) def makeKeyboardHandler(st: SoloTool): def handleKey(e: events.KeyEventArguments): @@ -103,15 +107,17 @@ def sessionPage(sessionId: str): 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 + # Volume slider + 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') + + # Playback rate and recording controls 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') + recordingControls(st, recorder, sessionManager) @ui.page('/') def landingPage(): @@ -151,4 +157,4 @@ def main(port, refresh, reload, session_path): # Hardcoded dev settings if __name__ in {"__main__", "__mp_main__"}: start(8080, 0.5, False, "https://files.0xf7.com") - #start(8080, 0.5, True, "/home/eddy/music/sessions") + #start(8080, 0.5, True, "/home/eddy/music") |
