aboutsummaryrefslogtreecommitdiffstats
path: root/web-project/src/solo_tool_web.py
blob: 49cddcd07a42c1904760757c28fe03e7e56a84b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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, deviceName):
    global sessionManager, recorder, metronome, volumeController
    sessionManager = SessionManager(sessionPath)
    recorder = Recorder(bufferSize, samplingRate, deviceName)
    metronome = Metronome(bufferSize, samplingRate, deviceName)
    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.")
@click.option("--device_name", type=str, default="XR18", help="Audio device for recording.")
def main(port, refresh, reload, session_path, buffer_size, sampling_rate, device_name):
    start(port, refresh, reload, session_path, buffer_size, sampling_rate, device_name)

# Hardcoded dev settings
if __name__ in {"__main__", "__mp_main__"}:
    start(8080, 0.5, True, "https://files.0xf7.com", 1024, 48000, "pipewire")
    #start(8080, 0.5, True, "/home/eddy/music", 1024, 48000)