From 472a1bf1a00fe818c4e9c39f13cd02056691f8a7 Mon Sep 17 00:00:00 2001 From: Maarten Grachten Date: Fri, 8 Nov 2019 10:14:04 +0100 Subject: [PATCH] various; see CHANGES.md --- CHANGES.rst | 21 ++++++++++++ partitura/__init__.py | 4 +-- partitura/display.py | 50 ++++++++++++++++------------ partitura/io/musescore.py | 50 ++++++++++++++++++++-------- partitura/score.py | 70 +++++++++++++++++++++++++++------------ setup.py | 7 ++-- 6 files changed, 140 insertions(+), 62 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4d6abb35..4210eea8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,27 @@ Release Notes ============= +Version 0.3.4 (Released on 2019-11-08) +-------------------------------------- + +API changes: + +* Rename `out_fmt` kwarg to `fmt` in `show` +* Add `dpi` kwarg to `show` +* Rename `show` function to `render` + +New features: + +* Save rendered scores to image file using `render` +* Add `wedge` attribute to DynamicLoudnessMarkings to differentiate them + from textual directions + +Bug fixes: + +* Do not crash when calling time_signature_map on empty Part instances +* Fix bug in repeat unfolding + + Version 0.3.3 (Released on 2019-11-04) -------------------------------------- diff --git a/partitura/__init__.py b/partitura/__init__.py index fe39af4c..8daeb02b 100644 --- a/partitura/__init__.py +++ b/partitura/__init__.py @@ -12,7 +12,7 @@ from .io.importmidi import load_score_midi, load_performance_midi from .io.exportmidi import save_score_midi, save_performance_midi from .io.importmatch import load_match -from .display import show +from .display import render from . import musicanalysis @@ -26,4 +26,4 @@ __all__ = ['load_musicxml', 'save_musicxml', 'musicxml_to_notearray', 'load_score_midi', 'save_score_midi', 'load_via_musescore', 'load_performance_midi', 'save_performance_midi', - 'show', 'EXAMPLE_MUSICXML'] + 'render', 'EXAMPLE_MUSICXML'] diff --git a/partitura/display.py b/partitura/display.py index 1cea30c8..e0893d30 100644 --- a/partitura/display.py +++ b/partitura/display.py @@ -14,11 +14,11 @@ from tempfile import NamedTemporaryFile, TemporaryFile from partitura import save_musicxml -from partitura.io.musescore import show_musescore +from partitura.io.musescore import render_musescore LOGGER = logging.getLogger(__name__) -__all__ = ['show'] +__all__ = ['render'] # def ly_install_msg(): # """Issue a platform specific installation suggestion for lilypond @@ -37,9 +37,11 @@ # 'http://lilypond.org/') # return s -def show(part, out_fmt='png'): - """Show a rendering of one or more parts or partgroups using the - desktop default application. +def render(part, fmt='png', dpi=90, out_fn=None): + """Create a rendering of one or more parts or partgroups. + + The function can save the rendered image to a file (when `out_fn` + is specified), or shown in the default image viewer application. Rendering is first attempted through musecore, and if that fails through lilypond. If that also fails the function returns without @@ -49,33 +51,37 @@ def show(part, out_fmt='png'): ---------- part : :class:`partitura.score.Part` or :class:`partitura.score.PartGroup` or list of these The score content to be displayed - out_fmt : {'png', 'pdf'}, optional + fmt : {'png', 'pdf'}, optional The image format of the rendered material + out_fn : str or None, optional + The path of the image output file. If None, the rendering will + be displayed in a viewer. """ - img_fn = show_musescore(part, out_fmt) - + img_fn = render_musescore(part, fmt, out_fn, dpi) + if img_fn is None or not os.path.exists(img_fn): - img_fn = show_lilypond(part, out_fmt) + img_fn = render_lilypond(part, fmt, out_fn) if img_fn is None or not os.path.exists(img_fn): return None - # NOTE: the temporary image file will not be deleted. - if platform.system() == 'Linux': - subprocess.call(['xdg-open', img_fn]) - elif platform.system() == 'Darwin': - subprocess.call(['open', img_fn]) - elif platform.system() == 'Windows': - os.startfile(img_fn) - - -def show_lilypond(part, out_fmt='png'): - if out_fmt not in ('png', 'pdf'): + if not out_fn: + # NOTE: the temporary image file will not be deleted. + if platform.system() == 'Linux': + subprocess.call(['xdg-open', img_fn]) + elif platform.system() == 'Darwin': + subprocess.call(['open', img_fn]) + elif platform.system() == 'Windows': + os.startfile(img_fn) + + +def render_lilypond(part, fmt='png', out_fn=None): + if fmt not in ('png', 'pdf'): print('warning: unsupported output format') return - prvw_sfx = '.preview.{}'.format(out_fmt) + prvw_sfx = '.preview.{}'.format(fmt) with TemporaryFile() as xml_fh, \ NamedTemporaryFile(suffix=prvw_sfx, delete=False) as img_fh: @@ -102,7 +108,7 @@ def show_lilypond(part, out_fmt='png'): # convert lilypond format (read from pipe of ps1) to image, and save to # temporary filename - cmd2 = ['lilypond', '--{}'.format(out_fmt), + cmd2 = ['lilypond', '--{}'.format(fmt), '-dno-print-pages', '-dpreview', '-o{}'.format(img_stem), '-'] try: diff --git a/partitura/io/musescore.py b/partitura/io/musescore.py index d5d6da51..9e1d23ec 100644 --- a/partitura/io/musescore.py +++ b/partitura/io/musescore.py @@ -92,19 +92,23 @@ def load_via_musescore(fn): return load_musicxml(xml_fh.name) -def show_musescore(part, out_fmt, dpi=90): +def render_musescore(part, fmt, out_fn=None, dpi=90): """Render a part using musescore. Parameters ---------- part : Part Part to be rendered - out_fmt : {'png', 'pdf'} + fmt : {'png', 'pdf'} Output image format + out_fn : str or None, optional + The path of the image output file, if not specified, the + rendering will be saved to a temporary filename. Defaults to + None. dpi : int, optional - Image resolution. This option is ignored when `out_fmt` is + Image resolution. This option is ignored when `fmt` is 'pdf'. Defaults to 90. - + """ mscore_exec = find_musescore3() @@ -112,23 +116,26 @@ def show_musescore(part, out_fmt, dpi=90): return None - if out_fmt not in ('png', 'pdf'): + if fmt not in ('png', 'pdf'): LOGGER.warning('warning: unsupported output format') return None - + with NamedTemporaryFile(suffix='.musicxml') as xml_fh, \ - NamedTemporaryFile(suffix='.{}'.format(out_fmt), delete=False) as img_fh: + NamedTemporaryFile(suffix='.{}'.format(fmt)) as img_fh: - save_musicxml(part, xml_fh) - cmd = [mscore_exec, '-T', '10', '-r', '{}'.format(dpi), '-o', img_fh.name, xml_fh.name] + save_musicxml(part, xml_fh.name) + cmd = [mscore_exec, '-T', '10', '-r', '{}'.format(dpi), '-o', img_fh.name, xml_fh.name] try: - ps = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # ps = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + ps = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if ps.returncode != 0: - LOGGER.error('Command {} failed with code {}' - .format(cmd, ps.returncode)) + LOGGER.error('Command {} failed with code {}; stdout: {}; stderr: {}' + .format(cmd, ps.returncode, ps.stdout.decode('UTF-8'), + ps.stderr.decode('UTF-8'))) return None except FileNotFoundError as f: @@ -137,7 +144,24 @@ def show_musescore(part, out_fmt, dpi=90): .format(' '.join(cmd), f)) return None + LOGGER.error('Command {} returned with code {}; stdout: {}; stderr: {}' + .format(cmd, ps.returncode, ps.stdout.decode('UTF-8'), + ps.stderr.decode('UTF-8'))) + name, ext = os.path.splitext(img_fh.name) - return '{}-1{}'.format(name, ext) + if fmt == 'png': + img_fn = '{}-1{}'.format(name, ext) + else: + img_fn = '{}{}'.format(name, ext) + + # print(img_fn, os.path.exists(img_fn)) + if os.path.exists(img_fn): + if out_fn: + shutil.copy(img_fn, out_fn) + return out_fn + else: + return img_fn + else: + return None diff --git a/partitura/score.py b/partitura/score.py index da6773dc..b080c1e5 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -129,16 +129,23 @@ def time_signature_map(self): tss = np.array([(ts.start.t, ts.beats, ts.beat_type) for ts in self.iter_all(TimeSignature)]) if len(tss) == 0: - # warn assumption - tss = np.array([(self.first_point.t, 4, 4), - (self.last_point.t, 4, 4)]) + # default time sig + beats, beat_type = 4, 4 + LOGGER.warning('No time signatures found, assuming {}/{}' + .format(beats, beat_type)) + if self.first_point is None: + t0, tN = 0, 0 + else: + t0 = self.first_point.t + tN = self.last_point.t + tss = np.array([(t0, beats, beat_type), + (tN, beats, beat_type)]) elif tss[0, 0] > self.first_point.t: tss = np.vstack(((self.first_point.t, tss[0, 1], tss[0, 2]), tss)) - tss = np.vstack((tss, - (self.last_point.t, tss[-1, 1], tss[-1, 2]))) - return interp1d(tss[:, 0], tss[:, 1:], axis=0, kind='previous') + return interp1d(tss[:, 0], tss[:, 1:], axis=0, kind='previous', + bounds_error=False, fill_value='extrapolate') def _time_interpolator(self, quarter=False, inv=False): @@ -1524,7 +1531,7 @@ def __init__(self, text, raw_text=None, staff=None): def __str__(self): if self.raw_text is not None: - return '{} "{}" ("{}")'.format(type(self).__name__, self.text, self.raw_text) + return '{} "{}" raw_text="{}"'.format(type(self).__name__, self.text, self.raw_text) else: return '{} "{}"'.format(type(self).__name__, self.text) @@ -1546,6 +1553,12 @@ class DynamicLoudnessDirection(DynamicDirection, LoudnessDirection): def __init__(self, *args, wedge=False, **kwargs): super().__init__(*args, **kwargs) self.wedge = wedge + def __str__(self): + if self.wedge: + return '{} wedge'.format(super().__str__()) + else: + return super().__init__() + class DynamicTempoDirection(DynamicDirection, TempoDirection): pass class IncreasingLoudnessDirection(DynamicLoudnessDirection): pass @@ -2180,24 +2193,27 @@ def make_score_variants(part): sv.add_segment(rep_start, ending1.start) ending2 = next(rep_end.iter_starting(Ending), None) + if ending2: # add the first occurrence of the repeat sv.add_segment(ending2.start, ending2.end) + + # new_sv includes the 1/2 ending repeat, which means: + # 1. from repeat start to repeat end (which includes ending 1) + new_sv.add_segment(rep_start, rep_end) + # 2. from repeat start to ending 1 start + new_sv.add_segment(rep_start, ending1.start) + # 3. ending 2 start to ending 2 end + new_sv.add_segment(ending2.start, ending2.end) + + # new score time will be the score time + t_end = ending2.end + else: # ending 1 without ending 2, should not happen normally - pass - - # new_sv includes the 1/2 ending repeat, which means: - # 1. from repeat start to repeat end (which includes ending 1) - new_sv.add_segment(rep_start, rep_end) - # 2. from repeat start to ending 1 start - new_sv.add_segment(rep_start, ending1.start) - # 3. ending 2 start to ending 2 end - new_sv.add_segment(ending2.start, ending2.end) - - # update the score time - t_score = ending2.end - + LOGGER.warning('ending 1 without ending 2') + # new score time will be the score time + t_end = ending1.end else: # add the first occurrence of the repeat sv.add_segment(rep_start, rep_end) @@ -2207,12 +2223,13 @@ def make_score_variants(part): new_sv.add_segment(rep_start, rep_end) # update the score time - t_score = rep_end + t_end = rep_end # add both score variants new_svs.append(sv) new_svs.append(new_sv) - + t_score = t_end + svs = new_svs # are we at the end of the piece already? @@ -2240,12 +2257,21 @@ def add_measures(part): Part instance """ + timesigs = np.array([(ts.start.t, ts.beats) for ts in part.iter_all(TimeSignature)], dtype=np.int) + + if len(timesigs) == 0: + LOGGER.warning('No time signatures found, not adding measures') + return + start = part.first_point.t end = part.last_point.t + if start == end: + return + # make sure we cover time from the start of the timeline if len(timesigs) == 0 or timesigs[0, 0] > start: timesigs = np.vstack(([[start, 4]], timesigs)) diff --git a/setup.py b/setup.py index 39456ae9..11bd1ead 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ EMAIL = 'partitura-users@googlegroups.com' AUTHOR = 'Maarten Grachten, Carlos Cancino-Chacón, and Thassilo Gadermaier' REQUIRES_PYTHON = '>=3.5' -VERSION = '0.3.3' +VERSION = '0.3.4' # What packages are required for this module to be executed? REQUIRED = [ @@ -43,7 +43,7 @@ # Import the README and use it as the long-description. # Note: this will only work if 'README.md' is present in your MANIFEST.in file! try: - with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = '\n' + f.read() except FileNotFoundError: long_description = DESCRIPTION @@ -63,7 +63,8 @@ version=about['__version__'], description=DESCRIPTION, long_description=long_description, - long_description_content_type='text/markdown', + # long_description_content_type='text/markdown', + long_description_content_type='text/x-rst', keywords=KEYWORDS, author=AUTHOR, author_email=EMAIL,