Source code for scamp_extensions.engraving.note_graph

"""
Module containing the :class:`PartNoteGraph` class, which makes it possible to draw the notes of a
:class:`~scamp.performance.PerformancePart` to an SVG file. Ultimately, I plan to make it so that you
can save a whole Performance to an SVG file or even to a PDF.
"""

#  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  #
#  This file is part of SCAMP (Suite for Computer-Assisted Music in Python)                      #
#  Copyright © 2020 Marc Evanstein <marc@marcevanstein.com>.                                     #
#                                                                                                #
#  This program is free software: you can redistribute it and/or modify it under the terms of    #
#  the GNU General Public License as published by the Free Software Foundation, either version   #
#  3 of the License, or (at your option) any later version.                                      #
#                                                                                                #
#  This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;     #
#  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     #
#  See the GNU General Public License for more details.                                          #
#                                                                                                #
#  You should have received a copy of the GNU General Public License along with this program.    #
#  If not, see <http://www.gnu.org/licenses/>.                                                   #
#  ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++  #
from __future__ import annotations
from numbers import Real
from typing import Tuple, Callable, Sequence
from scamp import EnvelopeSegment, Performance, PerformancePart

# drawsvg is an optional dependency (install with scamp_extensions[engraving]); importing this module
# without it is fine, but the rendering functions below raise a clear error when it is actually needed.
try:
    import drawsvg
except ImportError:
    drawsvg = None


def _require_drawsvg():
    if drawsvg is None:
        raise ImportError("The engraving extension requires the 'drawsvg' package. "
                          "Install it with: pip install scamp_extensions[engraving]")


from scamp import Envelope

# -------------------------------------------------- Color/gradients --------------------------------------------------

_default_cm_envelope_red = Envelope.from_levels((0, 0, 78, 151, 211, 250, 255, 255, 255))
_default_cm_envelope_green = Envelope.from_levels((0, 0, 0, 0, 0, 30, 170, 250, 255))
_default_cm_envelope_blue = Envelope.from_levels((0, 81, 122, 118, 64, 0, 0, 97, 255))


[docs] def default_color_map(intensity): """ The color map used by a :class:`PartNoteGraph` unless another is given: a blue-to-red gradient. :param intensity: a value from 0 to 1 :return: the corresponding (red, green, blue) tuple, each component from 0 to 255 """ return _default_cm_envelope_red.value_at(intensity), \ _default_cm_envelope_green.value_at(intensity), \ _default_cm_envelope_blue.value_at(intensity)
[docs] def rgb_to_hex(rgb): """ Convert an (red, green, blue) tuple, each component from 0 to 255, to a hex color string like "#ff7a00". :param rgb: the (red, green, blue) tuple to convert """ return '#%02x%02x%02x' % tuple(int(x) for x in rgb)
[docs] def make_intensity_gradient(envelope, start_x, end_x, color_map=default_color_map, value_range=None): """ Create an SVG linear gradient that spans horizontally from `start_x` to `end_x`, taking its color at each point from the value of the given envelope there. Used to color a note whose color parameter changes over the course of that note. :param envelope: the envelope whose value determines the color along the gradient :param start_x: x coordinate at which the gradient starts :param end_x: x coordinate at which the gradient ends :param color_map: function from the interval [0, 1] to an (red, green, blue) tuple :param value_range: the (min, max) range of envelope values to map onto that interval; if None, the envelope's values are taken to already run from 0 to 1 """ _require_drawsvg() envelope = envelope.duplicate() envelope.normalize_to_duration(1) if value_range is not None: envelope.shift_vertical(-value_range[0]) envelope.scale_vertical(1/(value_range[1] - value_range[0])) # subdivide envelope segments until none of them are covering more than 0.1 in range gaps_too_big = True while gaps_too_big: gaps_too_big = False for segment in envelope.segments: if abs(segment.end_level - segment.start_level) > 0.1: envelope.insert_interpolated((segment.start_time + segment.end_time) / 2) gaps_too_big = True grad = drawsvg.LinearGradient(start_x, 0, end_x, 0) for segment in envelope.segments: grad.add_stop(segment.start_time, rgb_to_hex(color_map(segment.start_level))) grad.add_stop(1, rgb_to_hex(color_map(envelope.end_level()))) return grad
[docs] def get_fill(parameter, start_x, end_x, color_map=default_color_map, value_range=None): """ The SVG fill to use for a note whose color is governed by the given parameter value: a gradient if that value is an envelope, and a plain hex color if it is a constant. :param parameter: the value of the note's color parameter, either a number or an :class:`~expenvelope.envelope.Envelope` :param start_x: x coordinate at which the note starts :param end_x: x coordinate at which the note ends :param color_map: function from the interval [0, 1] to an (red, green, blue) tuple :param value_range: the (min, max) range of parameter values to map onto that interval; if None, the values are taken to already run from 0 to 1 """ if isinstance(parameter, Envelope): return make_intensity_gradient(parameter, start_x, end_x, color_map, value_range) else: return rgb_to_hex(color_map( parameter if value_range is None else (parameter - value_range[0]) / (value_range[1] - value_range[0]) ))
# -------------------------------------------------- Draw note -------------------------------------------------- def _get_unit_slope_vector(slope): return 1 / (slope ** 2 + 1) ** 0.5, slope / (slope ** 2 + 1) ** 0.5 def _get_segment_raw(height_segment: EnvelopeSegment, width_segment: EnvelopeSegment, fill, outline_width, outline_color): """Returns the shape of a single envelope segment as a list of three drawing elements (fill and both outlines)""" p1 = height_segment.start_time, height_segment.start_level p4 = height_segment.end_time, height_segment.end_level dist = ((height_segment.end_time - height_segment.start_time) ** 2 + (height_segment.end_level - height_segment.start_level) ** 2) ** 0.5 / 3 start_slope = height_segment.start_slope() start_unit_slope_vector = _get_unit_slope_vector(start_slope) start_unit_normal_vector = - start_unit_slope_vector[1], start_unit_slope_vector[0] p2 = height_segment.start_time + dist * start_unit_slope_vector[0], \ height_segment.start_level + dist * start_unit_slope_vector[1] end_slope = height_segment.end_slope() end_unit_slope_vector = _get_unit_slope_vector(end_slope) end_unit_normal_vector = - end_unit_slope_vector[1], end_unit_slope_vector[0] p3 = height_segment.end_time - dist * end_unit_slope_vector[0], \ height_segment.end_level - dist * end_unit_slope_vector[1] start_a = p1[0] + start_unit_normal_vector[0] * width_segment.start_level,\ p1[1] + start_unit_normal_vector[1] * width_segment.start_level start_b = p1[0] - start_unit_normal_vector[0] * width_segment.start_level,\ p1[1] - start_unit_normal_vector[1] * width_segment.start_level control_1a = p2[0] + start_unit_normal_vector[0] * width_segment.value_at(width_segment.start_time + width_segment.duration / 3),\ p2[1] + start_unit_normal_vector[1] * width_segment.value_at(width_segment.start_time + width_segment.duration / 3) control_1b = p2[0] - start_unit_normal_vector[0] * width_segment.value_at(width_segment.start_time + width_segment.duration / 3),\ p2[1] - start_unit_normal_vector[1] * width_segment.value_at(width_segment.start_time + width_segment.duration / 3) control_2a = p3[0] + end_unit_normal_vector[0] * width_segment.value_at(width_segment.start_time + width_segment.duration * 2 / 3),\ p3[1] + end_unit_normal_vector[1] * width_segment.value_at(width_segment.start_time + width_segment.duration * 2 / 3) control_2b = p3[0] - end_unit_normal_vector[0] * width_segment.value_at(width_segment.start_time + width_segment.duration * 2 / 3),\ p3[1] - end_unit_normal_vector[1] * width_segment.value_at(width_segment.start_time + width_segment.duration * 2 / 3) end_a = p4[0] + end_unit_normal_vector[0] * width_segment.end_level,\ p4[1] + end_unit_normal_vector[1] * width_segment.end_level end_b = p4[0] - end_unit_normal_vector[0] * width_segment.end_level,\ p4[1] - end_unit_normal_vector[1] * width_segment.end_level return [ drawsvg.Path(fill=fill, close=True, stroke='none'). M(*start_a).C(*control_1a, *control_2a, *end_a).L(*end_b). C(*control_2b, *control_1b, *start_b).L(*start_a), drawsvg.Path(stroke=outline_color, stroke_width=outline_width, fill='none'). M(*start_a).C(*control_1a, *control_2a, *end_a), drawsvg.Path(stroke=outline_color, stroke_width=outline_width, fill='none'). M(*start_b).C(*control_1b, *control_2b, *end_b), ] def _draw_note_raw(draw: drawsvg.Drawing, height_envelope: Envelope, width_envelope: Envelope, fill, outline_width, outline_color): """ Draws a note shape, based on envelopes in drawing coordinates. :param draw: the drawsvg.Drawing used :param height_envelope: an envelope representing the curve itself, normalized to drawing coordinates :param width_envelope: an envelope representing the curve width, normalized to drawing coordinates :param fill: the color or gradient to use :param outline_width: width of the stroke outline of the note :param outline_color: color of the outline of the note """ key_points = set(height_envelope.times).union(set(width_envelope.times)) for t in key_points: height_envelope.insert_interpolated(t) width_envelope.insert_interpolated(t) outlines = [] fill_chunks = [] fill_chunks.append(drawsvg.Circle(height_envelope.end_time(), height_envelope.end_level(), width_envelope.end_level(), fill=fill, stroke="none")) outlines.append(drawsvg.Circle(height_envelope.end_time(), height_envelope.end_level(), width_envelope.end_level(), fill="none", stroke=outline_color, stroke_width=outline_width)) for height_segment, width_segment in zip(height_envelope.segments, width_envelope.segments): fill_chunks.append(drawsvg.Circle(height_segment.start_time, height_segment.start_level, width_segment.start_level, fill=fill, stroke="none")) outlines.append(drawsvg.Circle(height_segment.start_time, height_segment.start_level, width_segment.start_level, fill='none', stroke=outline_color, stroke_width=outline_width)) fill_chunk, *segment_outlines = _get_segment_raw(height_segment, width_segment, fill, outline_width, outline_color) fill_chunks.append(fill_chunk) outlines.extend(segment_outlines) draw.extend(outlines) draw.extend(fill_chunks) def _draw_note_attack_only(draw: drawsvg.Drawing, height_envelope: Envelope, width_envelope: Envelope, fill, outline_width, outline_color): """ Draws just the attack of a note shape, based on envelopes in drawing coordinates. :param draw: the drawsvg.Drawing used :param height_envelope: an envelope representing the curve itself, normalized to drawing coordinates :param width_envelope: an envelope representing the curve width, normalized to drawing coordinates :param fill: the color or gradient to use :param outline_width: width of the stroke outline of the note :param outline_color: color of the outline of the note """ draw.append( drawsvg.Circle(height_envelope.start_time(), height_envelope.start_level(), width_envelope.start_level(), fill=fill, stroke=outline_color, stroke_width=outline_width) )
[docs] class PartNoteGraph: """ Class that takes a performance part and a bunch of drawing/visualization settings, and can render to a `class:svgDraw.Drawing`. :param performance_part: the PerformancePart on which to base this note graph :param height_parameter: the playback parameter of that governs each note's height on the graph; defaults to pitch :param height_parameter_range: range of values expected from the parameter that governs height. If the parameter is pitch, defaults to the min and max pitch found in the part. Otherwise defaults to (0, 1). :param width_parameter: the playback parameter of that governs each note's width on the graph; defaults to volume :param width_parameter_range: range of values expected from the parameter that governs width. If the parameter is pitch, defaults to the min and max pitch found in the part. Otherwise defaults to (0, 1). :param width_range: range of note widths mapped to in the drawing :param color_parameter: the parameter governing note color, if any (overrides fill_color); inactive by default :param color_parameter_range: range of values expected from the parameter that governs color. If the parameter is pitch, defaults to the min and max pitch found in the part. Otherwise defaults to (0, 1). :param color_map: function from the interval [0, 1] to an RGB color tuple, where 0 represents the color parameter at the bottom of its range and 1 at the top. :param time_range: time range to map horizontal space to; defaults to 0 to length of part :param fill_color: the fill color of note glyphs; overridden by color parameter, if active :param outline_color: note outline color :param outline_width: note outline width :param guide_lines: the values of the height parameter at which to draw horizontal guide lines. :param guide_line_width: width of the guide lines :param guide_line_color: color of the guide lines :param attack_only: if true, only draw the attack of each note as a circle. """ def __init__(self, performance_part: PerformancePart, height_parameter: str = "pitch", height_parameter_range: Tuple[Real, Real] = None, width_parameter: str = "volume", width_parameter_range: Tuple[Real, Real] = None, width_range: Tuple[Real, Real] = (1, 20), color_parameter: str = None, color_parameter_range: Tuple[Real, Real] = None, color_map: Callable[[Real], Tuple[Real, Real, Real]] = default_color_map, time_range: Tuple[Real, Real] = None, fill_color: str = "black", outline_color: str = "black", outline_width: Real = 1, guide_lines: Sequence[Real] = (), guide_line_width: Real = 2, guide_line_color: str = 'black', attack_only: bool = False): self.performance_part = performance_part self.height_parameter = height_parameter self.height_parameter_range = height_parameter_range if height_parameter_range is not None \ else self._get_part_pitch_range() if height_parameter == "pitch" else (0, 1) self.width_parameter = width_parameter self.width_parameter_range = width_parameter_range if width_parameter_range is not None \ else self._get_part_pitch_range() if width_parameter == "pitch" else (0, 1) self.width_range = width_range self.color_parameter = color_parameter self.color_parameter_range = color_parameter_range if color_parameter_range is not None \ else self._get_part_pitch_range() if color_parameter == "pitch" else (0, 1) self.color_map = color_map self.time_range = (0, performance_part.end_beat) if time_range is None else time_range self.fill_color = fill_color self.outline_color = outline_color self.outline_width = outline_width self.attack_only = attack_only self.guide_lines = guide_lines self.guide_line_width = guide_line_width self.guide_line_color = guide_line_color def _get_part_pitch_range(self): return min(note.pitch.min_level() if isinstance(note.pitch, Envelope) else note.pitch for note in self.performance_part.get_note_iterator()),\ max(note.pitch.max_level() if isinstance(note.pitch, Envelope) else note.pitch for note in self.performance_part.get_note_iterator())
[docs] def render(self, drawing: drawsvg.Drawing, bottom_left: Tuple[Real, Real], dimensions: Tuple[Real, Real]): """ Draw this note graph into an existing drawing, within the rectangle described by `bottom_left` and `dimensions`. :param drawing: the :class:`drawsvg.Drawing` to draw into :param bottom_left: the (x, y) coordinate of the bottom left corner of the graph :param dimensions: the (width, height) of the graph """ _require_drawsvg() for note in self.performance_part.get_note_iterator(): height = note.pitch if self.height_parameter == "pitch" \ else note.volume if self.height_parameter == "volume" \ else note.properties["param_" + self.height_parameter] \ if ("param_" + self.height_parameter) in note.properties else 0 height_envelope = height.duplicate() if isinstance(height, Envelope) else Envelope((height, height), (note.length_sum(),)) height_envelope.remove_segments_after(note.length_sum()) height_envelope.shift_vertical(-self.height_parameter_range[0]) # negative scale: drawsvg's y-axis points down, so higher pitch grows upward from bottom_left height_envelope.scale_vertical(-dimensions[1] / (self.height_parameter_range[1] - self.height_parameter_range[0])) height_envelope.shift_vertical(bottom_left[1]) height_envelope.scale_horizontal(dimensions[0] / (self.time_range[1] - self.time_range[0])) height_envelope.shift_horizontal(bottom_left[0] + dimensions[0] * (note.start_beat - self.time_range[0]) / (self.time_range[1] - self.time_range[0])) width = note.pitch if self.width_parameter == "pitch" \ else note.volume if self.width_parameter == "volume" \ else note.properties["param_" + self.width_parameter] \ if ("param_" + self.width_parameter) in note.properties else 0 width_envelope = width.duplicate() if isinstance(width, Envelope) else Envelope((width, width), (note.length_sum(),)) width_envelope.remove_segments_after(note.length_sum()) width_envelope.shift_vertical(-self.width_parameter_range[0]) width_envelope.scale_vertical((self.width_range[1] - self.width_range[0]) / (self.width_parameter_range[1] - self.width_parameter_range[0])) width_envelope.shift_vertical(self.width_range[0]) width_envelope.scale_horizontal(dimensions[0] / (self.time_range[1] - self.time_range[0])) width_envelope.shift_horizontal(bottom_left[0] + dimensions[0] * (note.start_beat - self.time_range[0]) / (self.time_range[1] - self.time_range[0])) if self.color_parameter is not None: color = note.pitch if self.color_parameter == "pitch" \ else note.volume if self.color_parameter == "volume" \ else note.properties.extra_playback_parameters[self.color_parameter] \ if self.color_parameter in note.properties.extra_playback_parameters else 0 note_fill = get_fill(color, height_envelope.start_time(), height_envelope.end_time(), self.color_map, self.color_parameter_range) else: note_fill = self.fill_color if self.attack_only: _draw_note_attack_only(drawing, height_envelope, width_envelope, note_fill, self.outline_width, self.outline_color) else: _draw_note_raw(drawing, height_envelope, width_envelope, note_fill, self.outline_width, self.outline_color) self._render_guide_lines(drawing, bottom_left, dimensions)
def _render_guide_lines(self, drawing: drawsvg.Drawing, bottom_left: Tuple[Real, Real], dimensions: Tuple[Real, Real]): for value in self.guide_lines: line_height = bottom_left[1] - (value - self.height_parameter_range[0]) / \ (self.height_parameter_range[1] - self.height_parameter_range[0]) * dimensions[1] drawing.append( drawsvg.Line( bottom_left[0], line_height, bottom_left[0] + dimensions[0], line_height, stroke_width=self.guide_line_width, stroke=self.guide_line_color ) )
[docs] def render_to_file(self, file_path, dimensions, bg_color=None, h_padding=100, v_padding=100, pixel_scale=2): """ Render this note graph to an SVG file of its own. :param file_path: path of the SVG file to write :param dimensions: the (width, height) of the image, padding included :param bg_color: background color of the image; left transparent if None :param h_padding: horizontal padding between the graph and the edge of the image :param v_padding: vertical padding between the graph and the edge of the image :param pixel_scale: factor by which to scale the image's pixel dimensions, for higher resolution """ _require_drawsvg() unpadded_dimensions = dimensions[0] - 2 * h_padding, dimensions[1] - 2 * v_padding d = drawsvg.Drawing(*dimensions) if bg_color is not None: d.append(drawsvg.Rectangle(0, 0, *dimensions, fill=bg_color)) self.render(d, (h_padding, dimensions[1] - v_padding), unpadded_dimensions) d.set_pixel_scale(pixel_scale) d.save_svg(file_path)