Skip to content

Commit

Permalink
web app example
Browse files Browse the repository at this point in the history
  • Loading branch information
robert-lieck committed Oct 14, 2024
1 parent 68ab99c commit 235acba
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 26 deletions.
59 changes: 59 additions & 0 deletions examples/plot_webapp.py
Original file line number Diff line number Diff line change
@@ -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!)
1 change: 0 additions & 1 deletion musicflower/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
92 changes: 67 additions & 25 deletions musicflower/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -101,16 +120,32 @@ 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
# update and return figure
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)
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)]

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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],
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 235acba

Please sign in to comment.