aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEddy Pedroni <epedroni@pm.me>2026-01-01 17:57:27 +0100
committerEddy Pedroni <epedroni@pm.me>2026-01-01 17:57:27 +0100
commit8ea2b64ff798af913dcba64baace8d2536bf0b18 (patch)
treef85ea2f371055e67c629909df4897aec2f4bbad2
parent88ce99d87889cdf953af611ef09d7a12b6d23747 (diff)
Add Android app wrapper around web interface
-rw-r--r--doc/diagram.drawio127
-rw-r--r--solo-tool-project/pyproject.toml4
-rw-r--r--solo-tool-project/src/solo_tool/recorder.py75
-rw-r--r--solo-tool-project/src/solo_tool/session_manager.py88
-rw-r--r--solo-tool-project/src/solo_tool/solo_tool.py14
-rw-r--r--solo-tool-project/src/solo_tool/storage.py87
-rw-r--r--solo-tool-project/test/session_manager_unittest.py2
-rw-r--r--web-project/src/recording.py111
-rw-r--r--web-project/src/solo_tool_web.py18
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&lt;br&gt;screen" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
- <mxGeometry x="640" y="280" width="40" height="40" as="geometry" />
+ <mxCell id="ZINFS9bsx5oSfdTS2e79-1" value="Full&lt;br&gt;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")