aboutsummaryrefslogtreecommitdiffstats
path: root/web-project
diff options
context:
space:
mode:
Diffstat (limited to 'web-project')
-rw-r--r--web-project/pyproject.toml9
-rw-r--r--web-project/src/recording.py117
-rw-r--r--web-project/src/solo_tool_web.py226
3 files changed, 293 insertions, 59 deletions
diff --git a/web-project/pyproject.toml b/web-project/pyproject.toml
index 440812e..7320d37 100644
--- a/web-project/pyproject.toml
+++ b/web-project/pyproject.toml
@@ -8,10 +8,13 @@ authors = [
{ name = "Eddy Pedroni", email = "epedroni@pm.me" },
]
description = "A NiceGUI-based web frontend for the solo_tool library"
-requires-python = ">=3.12"
+requires-python = ">=3.13"
dependencies = [
- "nicegui==2.11.1",
- "solo_tool"
+ "nicegui==3.5.0",
+ "click==8.2.1",
+ "requests==2.32.5",
+ "solo_tool>=2.0",
+ "python-slugify==8.0.4"
]
dynamic = ["version"]
diff --git a/web-project/src/recording.py b/web-project/src/recording.py
new file mode 100644
index 0000000..4301b02
--- /dev/null
+++ b/web-project/src/recording.py
@@ -0,0 +1,117 @@
+from pathlib import Path
+from contextlib import contextmanager
+from asyncio import sleep
+from tempfile import TemporaryDirectory
+from datetime import date
+from re import sub
+
+from nicegui import ui, run
+from slugify import slugify
+
+_recording = None
+
+def _removeParens(string):
+ return sub(r'\(.*?\)', '', string).strip()
+
+@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, getCurrentSong):
+ async def f():
+ with ui.dialog() as dialog, ui.card():
+ defaultName = f"{slugify(_removeParens(getCurrentSong()))}.mp3"
+ fileName = ui.input(label='File name', value=defaultName)
+ 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, getCurrentSong):
+ 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, getCurrentSong))
diff --git a/web-project/src/solo_tool_web.py b/web-project/src/solo_tool_web.py
index f854e1a..25beb6f 100644
--- a/web-project/src/solo_tool_web.py
+++ b/web-project/src/solo_tool_web.py
@@ -1,56 +1,170 @@
-from nicegui import ui
-
-from solo_tool import SoloTool
-
-st = SoloTool()
-st.loadSession("/home/eddy/music/solos/practice.json")
-
-def _createSeekHandler(delta):
- def f():
- newPosition = st.getPlaybackPosition() + delta
- newPosition = min(1.0, max(0.0, newPosition))
- st.setPlaybackPosition(newPosition)
- return f
-
-def main():
- with ui.splitter(value=30) as splitter:
- splitter.style('width: 100%; height: 100%;')
- with splitter.before:
- with ui.list().props('dense separator'):
- for song in st.getSongs():
- ui.item(song)
- with splitter.after:
- ui.slider(min=0, max=1.2, value=1.0, step=0.01, on_change=lambda e: st.setPlaybackVolume(e.value))
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('-5%', on_click=lambda: st.setPlaybackRate(max(0.5, st.getPlaybackRate() - 0.05)))
- ui.slider(min=0.5, max=1.2, step=0.05, value=st.getPlaybackRate(), on_change=lambda e: st.setPlaybackRate(e.value))
- ui.button('+5%', on_click=lambda: st.setPlaybackRate(min(1.2, st.getPlaybackRate() + 0.05)))
-
- ui.slider(min=0, max=100, value=0)
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Prev', on_click=st.previousSong)
- ui.button('-25%', on_click=_createSeekHandler(-0.25))
- ui.button('-5%', on_click=_createSeekHandler(-0.05))
- ui.button('-1%', on_click=_createSeekHandler(-0.01))
- ui.button('+1%', on_click=_createSeekHandler(0.01))
- ui.button('+5%', on_click=_createSeekHandler(0.05))
- ui.button('+25%', on_click=_createSeekHandler(0.25))
- ui.button('Next', on_click=st.nextSong)
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Set A')
- ui.button('Set B')
- ui.button('Previous AB')
- ui.button('Next AB')
-
- with ui.row().classes("w-full justify-between no-wrap"):
- ui.button('Toggle AB', on_click=lambda: st.setAbLimitEnable(not st.isAbLimitEnabled()))
- ui.button('Stop', on_click=st.stop)
- ui.button('Play', on_click=st.play)
- ui.button('Jump to A', on_click=st.jumpToA)
- ui.run()
-
-if __name__ in {'__main__', '__mp_main__'}:
- main()
+import sys
+from os import getenv
+from os.path import basename, splitext
+from functools import partial
+from nicegui import ui, events
+import click
+from fastapi import HTTPException
+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]))
+
+@ui.refreshable
+def keyPointList(st: SoloTool) -> None:
+ with ui.list().props('separator'):
+ if st.keyPoints is not None:
+ for kp in st.keyPoints:
+ ui.item(f"{kp:0.2}", on_click=handlers.keyPointAbsolute(st, kp)).props('clickable v-ripple').classes('text-lg')
+
+@ui.refreshable
+def songList(st: SoloTool, songDrawer) -> None:
+ with ui.list().props('separator'):
+ for i, path in enumerate(st.songs):
+ ui.item(fileName(path), on_click=handlers.songAbsolute(st, i, lambda: songDrawer.hide())).props('clickable v-ripple')
+
+sessions = {}
+sessionManager = None
+midiPedal = ActitionController()
+recorder = None
+
+def makeKeyboardHandler(st: SoloTool):
+ restartOrPrevious = handlers.restartOrPreviousSong(st, 0.01)
+ nextSong = handlers.songRelative(st, 1)
+ playPause = handlers.playPause(st)
+ positionToKeyPoint = handlers.positionToKeyPoint(st)
+ def handleKey(e: events.KeyEventArguments):
+ if e.action.keyup and not e.action.repeat:
+ if e.key.control:
+ playPause()
+ elif e.key.shift:
+ st.jump()
+ elif e.key.arrow_left:
+ restartOrPrevious()
+ elif e.key.arrow_right:
+ nextSong()
+ elif e.key.arrow_down:
+ positionToKeyPoint()
+ return handleKey
+
+@ui.page('/{sessionId}')
+def sessionPage(sessionId: str):
+ if sessionId not in sessions:
+ raise HTTPException(status_code=404, detail=f"No session with ID {sessionId}")
+
+ fullscreen = ui.fullscreen()
+ ui.dark_mode().enable()
+ ui.colors(secondary='#ffc107')
+ ui.page_title(sessionId)
+
+ st = sessions[sessionId]
+ midiPedal.setSoloTool(st)
+ ui.keyboard(on_key=makeKeyboardHandler(st))
+
+ # Manage songs dialog
+ with ui.dialog() as manageSongsDialog:
+ ui.label("Under construction")
+
+ # Header
+ with ui.header().classes('items-center justify-between'):
+ with ui.row().classes('items-center justify-start'):
+ ui.button(icon='menu', on_click=lambda: songDrawer.toggle()).props('flat dense round color=white')
+ ui.label().bind_text_from(st, 'song', lambda index: fileName(st.songs[index]) if index is not None else "Select a song").classes('text-lg')
+ with ui.row().classes('items-center justify-start'):
+ ui.button(icon='home', on_click=lambda: ui.navigate.to("/")).props('flat dense round color=white')
+ def save(): sessionManager.saveSession(st, sessionId)
+ ui.button(icon='save', on_click=save).props('flat dense round color=white')
+ ui.button(icon='fullscreen', on_click=fullscreen.toggle).props('flat dense round color=white')
+
+ # Key points list
+ with ui.right_drawer(top_corner=True, bottom_corner=True).props('width=120 behavior=desktop'):
+ ui.label("Key Points").classes('text-lg')
+ keyPointList(st)
+ def addKeyPoint() -> None: st.keyPoints += [st.keyPoint]
+ ui.button(icon='add', on_click=addKeyPoint).props('flat round dense color=white')
+
+ # Song list
+ with ui.left_drawer(bottom_corner=True).props('overlay breakpoint=8000') as songDrawer:
+ songList(st, songDrawer)
+ ui.button(icon='add', on_click=manageSongsDialog.open).props('flat round dense color=white')
+
+ # Playback position
+ def setPosition(e) -> None: st.position = e.args
+ ui.slider(min=0, max=1.0, step=0.001) \
+ .bind_value_from(st, 'position') \
+ .on('change', setPosition) \
+ .props('thumb-size=0px track-size=16px')
+
+ # Key point position
+ ui.slider(min=0, max=1.0, step=0.001).bind_value(st, 'keyPoint').props('selection-color=transparent color=secondary')
+
+ # Play control
+ with ui.button_group().classes('w-full').style('height: 77px'):
+ buttonSize = "20px"
+ ui.button(icon='skip_previous', on_click=handlers.restartOrPreviousSong(st, 0.01)).props(f"size={buttonSize}").style('flex: 1')
+ ui.button(color='positive', on_click=handlers.playPause(st)).bind_icon_from(st, "playing", lambda playing: "pause" if playing else "play_arrow").props(f"size={buttonSize}").style('flex: 1')
+ ui.button(icon='vertical_align_bottom', on_click=handlers.positionToKeyPoint(st), color='negative').props(f"size={buttonSize}").style('flex: 2')
+ ui.button(icon='undo', on_click=st.jump, color='secondary').props(f"size={buttonSize}").style('flex: 2')
+ ui.button(icon='skip_next', on_click=handlers.songRelative(st, 1)).props(f"size={buttonSize}").style('flex: 1')
+
+ # 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')
+
+ recordingControls(st, recorder, sessionManager, lambda: fileName(st.songs[st.song]))
+
+@ui.page('/')
+def landingPage():
+ ui.dark_mode().enable()
+ ui.page_title("Solo Tool")
+
+ # Header
+ with ui.header().classes('items-center'):
+ ui.label("Choose a session").classes('text-lg')
+
+ for id, soloTool in sessions.items():
+ ui.button(id, on_click=partial(ui.navigate.to, f"/{id}"))
+
+def start(port, refresh, reload, sessionPath, bufferSize, samplingRate):
+ global sessionManager, recorder
+ sessionManager = SessionManager(sessionPath)
+ recorder = Recorder(bufferSize, samplingRate)
+
+ for id in sessionManager.getSessions():
+ songTool = sessionManager.loadSession(id)
+ songTool.registerKeyPointListCallback(lambda new: keyPointList.refresh())
+ songTool.registerSongSelectionCallback(lambda new: keyPointList.refresh())
+ songTool.registerSongListCallback(lambda new: songList.refresh())
+ sessions[id] = songTool
+ try:
+ ui.run(reload=reload, binding_refresh_interval=refresh, port=port)
+ except KeyboardInterrupt:
+ pass
+
+@click.command()
+@click.option("--port", type=int, default=8080, help="Port on which to bind.")
+@click.option("--refresh", type=float, default=0.5, help="Refresh interval in seconds.")
+@click.option("--reload/--no-reload", default=True, help="Auto-reload when files change.")
+@click.option("--session_path", default="https://files.0xf7.com", help="Look for sessions in this location.")
+@click.option("--buffer_size", type=int, default=128, help="Audio buffer size for recording.")
+@click.option("--sampling_rate", type=int, default=48000, help="Audio sampling rate for recording.")
+def main(port, refresh, reload, session_path, buffer_size, sampling_rate):
+ start(port, refresh, reload, session_path, buffer_size, sampling_rate)
+
+# Hardcoded dev settings
+if __name__ in {"__main__", "__mp_main__"}:
+ start(8080, 0.5, False, "https://files.0xf7.com", 1024, 48000)
+ #start(8080, 0.5, True, "/home/eddy/music", 1024, 48000)