import numpy as np
from . import utils
import plotly.graph_objects as go
import colorsys
[docs]
class Polyloop:
"""
Represents a collection of polyloops, which are sequences of musical notes.
"""
def __init__(self, polyloops, measure_length=4, insert_rests=True):
"""
Initializes a Polyloop object.
Parameters:
- polyloops (dict or list): A dictionary of polyloops where keys are names and values are sequences,
or a list of polyloops. Each polyloop is expected to be in the form
[(pitch, duration, offset), ...]. If a list is provided, it will be
converted to a dict with keys 'polyloop 0', 'polyloop 1', etc.
- measure_length (int): The length of a measure in beats. Defaults to 4.
- insert_rests (bool): Whether to insert rests in the polyloops. Defaults to True.
"""
self.measure_length = measure_length
# Convert list to dict if needed
if not isinstance(polyloops, dict):
# If it's a single polyloop (list of tuples), wrap it in a list
if polyloops and isinstance(polyloops[0], tuple):
polyloops = [polyloops]
# Convert list of polyloops to dict
polyloops = {f'polyloop {i}': polyloop for i, polyloop in enumerate(polyloops)}
# Process each polyloop
self.polyloops = {
name: utils.fill_gaps_with_rests(polyloop) if insert_rests else polyloop
for name, polyloop in polyloops.items()
}
[docs]
def plot_polyloops(self, pulse=1/4, colors=None, renderer=None):
"""
Plots the given polyloops as a radar chart, including arcs to represent the duration of each note.
Parameters:
- pulse (float): The duration of each pulse in beats. Defaults to 1/4.
- colors (list): A list of colors to use for the plot. If not provided, a default color scheme will be used.
- renderer (str): The Plotly renderer to use. If None, will try to auto-detect the renderer.
Returns:
- fig (plotly.graph_objects.Figure): The generated radar chart figure.
"""
# Configure Plotly renderer for different environments
try:
import plotly.io as pio
# Only configure if renderer is specified or not already set
if renderer:
pio.renderers.default = renderer
# If no renderer is specified but we're in a known environment, configure automatically
elif 'marimo' in str(type(globals().get('mo', ''))):
pio.renderers.default = 'notebook'
except (ImportError, AttributeError):
pass
# Get polyloop names and sequences
polyloop_names = list(self.polyloops.keys())
polyloops = list(self.polyloops.values())
# Filter out rests
polyloops_without_rests = [[note for note in polyloop if note[0] is not None] for polyloop in polyloops]
n_polyloops = len(polyloops)
traces = []
if colors is None:
colors = [colorsys.hsv_to_rgb(i/n_polyloops, 1, 1) for i in range(n_polyloops)]
colors = ['rgba(%d, %d, %d, 0.5)' % (int(r*255), int(g*255), int(b*255)) for r, g, b in colors]
fig = go.Figure()
for i, (polyloop_name, polyloop) in enumerate(zip(polyloop_names, polyloops_without_rests)):
for pitch, duration, offset in polyloop:
start_theta, duration_theta = offset * 360 / self.measure_length, duration * 360 / self.measure_length
arc = np.linspace(start_theta, start_theta + duration_theta, 100) # Generate points for a smooth arc
r = [n_polyloops-i-1] * 100 # Constant radius for the arc
fig.add_trace(go.Scatterpolar(
r=r,
theta=arc % 360, # Ensure theta is within 0-360 range
mode='lines',
line=dict(color='rgba(60, 60, 60, 0.65)', width=8),
name=f'{polyloop_name} Duration',
showlegend=False
))
for pitch, duration, offset in polyloop:
start_theta, end_theta = offset * 360 / self.measure_length, (offset + duration) * 360 / self.measure_length
for theta in [start_theta, end_theta]:
fig.add_trace(go.Scatterpolar(
r=[n_polyloops-i-0.9, n_polyloops-i-1.1],
theta=[theta % 360, theta % 360],
mode='lines',
line=dict(color='Black', width=3),
name=f'{polyloop_name} Start/End',
showlegend=False
))
if polyloop:
start_thetas = [offset * 360 / self.measure_length for _, _, offset in polyloop]
start_thetas.append(start_thetas[0])
traces.append(go.Scatterpolar(
r=[n_polyloops-i-1]*(len(polyloop)+1), # Account for the loop closure
theta=start_thetas,
mode='lines',
line=dict(color='rgba(0, 0, 0, 0.65)', width=1),
fill='toself',
fillcolor=colors[i % len(colors)],
name=polyloop_name,
showlegend=True
))
for trace in reversed(traces):
fig.add_trace(trace)
tickvals = np.linspace(0, 360, int(self.measure_length/pulse), endpoint=False)
ticktext = [str(i % self.measure_length) for i in np.arange(0, self.measure_length, pulse)]
radial_tickvals = np.arange(0, n_polyloops, 1)
fig.update_layout(
polar=dict(
radialaxis=dict(
visible=True,
range=[n_polyloops, -0.1],
tickvals=radial_tickvals,
ticktext=polyloop_names
),
angularaxis=dict(
tickvals=tickvals,
ticktext=ticktext,
direction="clockwise",
rotation=90
)
),
template='none',
showlegend=True
)
fig.add_annotation(
x=0.5, y=0.5, text="↻", showarrow=False, font=dict(size=30, color='White'),
xref="paper", yref="paper"
)
return fig