From 235acbaf603c0664a47a9ecdd88cbaedbe3dcda2 Mon Sep 17 00:00:00 2001 From: Robert Lieck Date: Mon, 14 Oct 2024 22:27:14 +0100 Subject: [PATCH] web app example --- examples/plot_webapp.py | 59 ++++++++++++++++++++++++++ musicflower/__main__.py | 1 - musicflower/webapp.py | 92 ++++++++++++++++++++++++++++++----------- 3 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 examples/plot_webapp.py diff --git a/examples/plot_webapp.py b/examples/plot_webapp.py new file mode 100644 index 0000000..7d26203 --- /dev/null +++ b/examples/plot_webapp.py @@ -0,0 +1,59 @@ +""" +Web App Example +=============== + +This is a basic example for creating a MusicFlower web app with a custom visualiser. +""" + +# %% +# Custom Visualiser +# ----------------- +# +# Creating a web app with a custom visualiser is as easy as defining a single callback function + +import plotly.graph_objects as go + +def my_custom_visualiser(*, features, position, **kwargs): + return go.Figure(data=[go.Bar( + y=features[0][round(position * (len(features[0]) - 1))] + )]) + + +# %% +# We can now register this function as a visualiser and start up the web app + +from musicflower.webapp import WebApp + +WebApp() \ + .use_chroma_features() \ + .register_visualiser('Chroma Bars', ['chroma-features'], my_custom_visualiser) \ + .init() \ + # .run() # (uncomment this line!) + +# %% +# A slightly more elaborate version of the visualiser would be as follows + +from pitchtypes import EnharmonicPitchClass + +def my_custom_visualiser(*, features, position, **kwargs): + features = WebApp.check_features(features) + position = WebApp.position_idx(position, features=features) + data = features[position] + fig = go.Figure(data=[go.Bar( + x=[str(EnharmonicPitchClass(i)) for i in range(12)], + y=data + )]) + fig.update_yaxes(range=[0, 1]) + return WebApp.update_figure_layout(fig) + +# %% +# And the app can be set up with additional parameters + +app = WebApp(verbose=True) # print information about callbacks +app.use_chroma_features(200) # maximum time resolution +app.register_visualiser('Chroma Bars', ['chroma-features'], my_custom_visualiser) +app.init( + figure_height=500, # specify figure dimensions + # audio_file="/path/to/initial/audio/file.mp3", # audio file to load at start up +) +# app.run(debug=True) # run app in debug mode (uncomment this line!) diff --git a/musicflower/__main__.py b/musicflower/__main__.py index e85ed59..924aeb3 100644 --- a/musicflower/__main__.py +++ b/musicflower/__main__.py @@ -22,7 +22,6 @@ 'chroma-scape-features'], spectral_dome) app.init( - suppress_flask_logger=True, # suppress extensive logging, only show errors figure_width=1500, figure_height=800, ) diff --git a/musicflower/webapp.py b/musicflower/webapp.py index aa46dd7..babdab6 100644 --- a/musicflower/webapp.py +++ b/musicflower/webapp.py @@ -57,14 +57,32 @@ def init(self, n_sync_before_idle=10, audio_file=None, external_stylesheets=( - 'https://codepen.io/chriddyp/pen/bWLwgP.css', + # 'https://codepen.io/chriddyp/pen/bWLwgP.css', ), name=None, - suppress_flask_logger=False, + suppress_flask_logger=True, figure_width=None, figure_height=None, _debug_display_toggles=False, + dash_kwargs=(), ): + """ + + :param title: + :param update_title: + :param sync_interval_ms: + :param idle_interval_ms: + :param n_sync_before_idle: + :param audio_file: + :param external_stylesheets: + :param name: + :param suppress_flask_logger: suppress extensive logging, only show errors + :param figure_width: + :param figure_height: + :param _debug_display_toggles: + :param dash_kwargs: + :return: + """ self._debug_display_toggles = _debug_display_toggles external_stylesheets = list(external_stylesheets) + [dbc.themes.BOOTSTRAP] # always need that for settings modal if suppress_flask_logger: @@ -76,7 +94,8 @@ def init(self, external_stylesheets=external_stylesheets, name=name, figure_width=figure_width, - figure_height=figure_height) + figure_height=figure_height, + dash_kwargs=dash_kwargs) self._setup_audio_position_sync(sync_interval_ms=sync_interval_ms, idle_interval_ms=idle_interval_ms, n_sync_before_idle=n_sync_before_idle) @@ -101,9 +120,9 @@ def update_figure_layout(cls, figure, **kwargs): for v in ['height', 'width']: if v in kwargs and kwargs[v] is None: del kwargs[v] - # populate with some default value + # populate with some default values kwargs = dict( - transition_duration=100, + transition_duration=10, margin=dict(t=0, b=10, l=0, r=0), uirevision=True, ) | kwargs @@ -111,6 +130,22 @@ def update_figure_layout(cls, figure, **kwargs): figure.update_layout(**kwargs) return figure + @classmethod + def position_idx(cls, position, *, n=None, features=None): + """ + For a position in [0, 1] and features of length n, compute the corresponding index in {0, ..., n - 1}. + + :param position: number in [0, 1] + :param n: length of features + :param features: features to compute n as len(features) + :return: index + """ + if (n is None) == (features is None): + raise ValueError("Either 'n' or 'features' have to be provided (not both)") + if features is not None: + n = len(features) + return int(round(position * (n - 1))) + def use_chroma_features(self, n=None, name='chroma-features'): if n is None: self.register_feature_extractor(name, chroma_features) @@ -477,16 +512,19 @@ def _file_like_from_upload_content(cls, content): return BytesIO(base64.b64decode(content_string)) def _setup_layout(self, title, update_title, idle_interval_ms, audio_file, external_stylesheets, name, - figure_width, figure_height): + figure_width, figure_height, dash_kwargs=()): if name is None: name = __name__ - app = Dash( - name=name, - title=title, - update_title=update_title, - external_stylesheets=list(external_stylesheets), - ) + dash_kwargs = { + **dict(name=name, + title=title, + update_title=update_title, + external_stylesheets=list(external_stylesheets), + assets_folder=Path(os.path.dirname(os.path.realpath(__file__))) / "assets"), + **dict(dash_kwargs) + } + app = Dash(**dash_kwargs) # elements in webapp layout_content = [ @@ -540,8 +578,7 @@ def update_output(contents, name, interval, pre_loaded_contents, verbose=self.ve return no_update, pre_loaded_contents, no_update if contents is not None: # new audio file was uploaded - audio = html.Audio(src=contents, controls=True, id='_audio-controls', style={'width': '100%'}) - return html.Div([html.P(name), audio]), contents, name + return self._audio_element(audio_file=name, audio_src=contents), contents, name else: return no_update, no_update, no_update @@ -629,15 +666,13 @@ def toggle_modal(n1, n2, is_open): pass audio_src = f'data:audio/{extension};base64,{decoded_sound}' layout_content += [ - html.Div(id='_sound-file-display', children=html.Div([ - html.P(audio_file), - html.Audio(src=audio_src, controls=True, id='_audio-controls', style={'width': '100%'}), - ])), + self._audio_element(audio_file=audio_file, audio_src=audio_src), dcc.Store(id='_audio-content', data=audio_src), dcc.Interval(id='_initial-audio-content-update', interval=5000, max_intervals=1) ] else: - layout_content += [html.Div(id='_sound-file-display'), + layout_content += [ + self._audio_element(audio_file=audio_file), dcc.Store(id='_audio-content', data=None), dcc.Interval(id='_initial-audio-content-update', disabled=True)] @@ -719,8 +754,15 @@ def update_audio_position(current_pos, stored_pos, n_const_polls, current_interv # return frame, current_pos, no_update, 0 return current_pos, no_update, 0 + def _audio_element(self, audio_file, audio_src=None): + src = {} if audio_src is None else dict(src=audio_src) + return html.Div(id='_sound-file-display', children=html.Div([ + html.P(audio_file), + html.Audio(**src, controls=True, id='_audio-controls', style={'width': '100%'}), + ])) + def run(self, *args, **kwargs): - self.app.run_server(*args, **kwargs) + self.app.run(*args, **kwargs) def none_feature(*, audio, app): @@ -758,7 +800,7 @@ def chroma_features(*, audio, app, normalised=True): # hop_length=2048, ).T if normalised: - chroma = normaliser(features=chroma, app=app) + chroma = normaliser(features=[chroma], app=app) return chroma @@ -870,7 +912,7 @@ def single_fourier(*, features, position, app, component, **kwargs): features = WebApp.check_features(features) features[1] *= rad_to_deg fig = px.line_polar(r=features[0, :, component], theta=features[1, :, component]) - idx = int(position * (features.shape[1] - 1)) + idx = WebApp.position_idx(position, n=features.shape[1]) fig.add_trace(go.Scatterpolar( r=features[0, idx:idx + 1, component], theta=features[1, idx:idx + 1, component], @@ -889,7 +931,7 @@ def fourier_visualiser(*, features, position, app, binary_profiles=False, incl=N fig = make_subplots(rows=2, cols=3, start_cell="top-left", specs=specs, # subplot_titles=labels ) - idx = int(position * (features.shape[1] - 1)) + idx = WebApp.position_idx(position, n=features.shape[1]) # add Fourier components 1–5 for (component, row, col), l in zip([ (0, 0, 0), @@ -992,7 +1034,7 @@ def fourier_visualiser(*, features, position, app, binary_profiles=False, incl=N def circle_of_fifths_visualiser(*, features, position, app, ticks="binary", **kwargs): features = WebApp.check_features(features) features[1] *= rad_to_deg - idx = int(position * (features.shape[1] - 1)) + idx = WebApp.position_idx(position, n=features.shape[1]) component = 5 fig = go.Figure() # plot trace @@ -1069,7 +1111,7 @@ def circle_of_fifths_visualiser(*, features, position, app, ticks="binary", **kw def tonnetz_visualiser(*, features, position, app, unicode=True, **kwargs): features = WebApp.check_features(features, asfarray=False) - pos_idx = int(np.round(position * (len(features) - 1))) + pos_idx = WebApp.position_idx(position, features=features) features = np.array(features[pos_idx]) if features.max() > 0: features /= features.max()