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 solo_tool.metronome import Metronome from solo_tool.external_volume import VolumeControllerXR18 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 metronome = None volumeController = 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.card(): with ui.list().props('bordered separator'): for song in sessionManager.getSongs(): def addSong(s): st.addSong(s) manageSongsDialog.close() with ui.item(on_click=partial(addSong, song)): with ui.item_section().props('avatar'): ui.icon('music_note') with ui.item_section(): ui.item_label(basename(song)) # 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') def songName(index): if index is None: return "Select a song" else: names = fileName(st.songs[index]).split(" - ") return names[1] if len(names) > 1 else names[0] ui.label().bind_text_from(st, 'song', songName).classes('text-lg') with ui.row().classes('items-center justify-start'): buttonSize = "18px" bpm = ui.knob(value=90, min=10, max=300, step=1, show_value=True, color='white')#, track_color='white') def toggleMetronome(): if metronome.running: metronome.stop() else: metronome.start(bpm.value, 600) ui.button(on_click=toggleMetronome) \ .props(f'flat dense round color=white size={buttonSize}') \ .bind_icon_from(metronome, "running", lambda running: "timer_off" if running else "timer") ui.button(icon='home', on_click=lambda: ui.navigate.to("/")) \ .props(f'flat dense round color=white size={buttonSize}') def save(): sessionManager.saveSession(st, sessionId) ui.button(icon='save', on_click=save) \ .props(f'flat dense round color=white size={buttonSize}') ui.button(icon='fullscreen', on_click=fullscreen.toggle) \ .props(f'flat dense round color=white size={buttonSize}') # 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'): iconSize = "20px" ui.icon('music_note').props(f"size={iconSize}").classes('q-px-xs') ui.slider(min=0, max=1.25, step=0.01).classes('q-px-xs').bind_value(st, 'volume') ui.icon('headphones').props(f"size={iconSize}").classes('q-px-xs') ui.slider(value=0.0, min=0, max=1.0, step=0.01).classes('q-px-xs').bind_value(volumeController, 'headphoneVolume') ui.icon('speaker').props(f"size={iconSize}").classes('q-px-xs') ui.slider(value=0.0, min=0, max=1.0, step=0.01).classes('q-px-xs').bind_value(volumeController, 'speakerVolume') # 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, metronome, volumeController sessionManager = SessionManager(sessionPath) recorder = Recorder(bufferSize, samplingRate) metronome = Metronome(bufferSize, samplingRate) volumeController = VolumeControllerXR18("XR18-7EE3D3.0xf7.com") 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, True, "https://files.0xf7.com", 1024, 48000) #start(8080, 0.5, True, "/home/eddy/music", 1024, 48000)