diff --git a/.github/workflows/partitura_unittests.yml b/.github/workflows/partitura_unittests.yml index 22eeedc0..1b75dcce 100644 --- a/.github/workflows/partitura_unittests.yml +++ b/.github/workflows/partitura_unittests.yml @@ -2,9 +2,9 @@ name: Partitura Unittests on: push: - branches: [master, develop] + branches: [main, develop] pull_request: - branches: [master, develop] + branches: [develop] jobs: test: diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..1ccf124a --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,21 @@ + +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.8" + +#mkdocs: +# configuration: mkdocs.yml + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md index d83d80c5..df5b95c6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,29 @@ Release Notes ============= + +Version 1.1.1 (Released on 2022-10-31) +-------------------------------------- + +New features: + +* New minor feature : Adding midi pitch to freq for synthesizer add reference-based midi pitch to freq #163 + +Bug fixes: + +* Documentation Fix of ReadTheDocs +* Bug fix Bug synthesizing scores with pickup measures #166 Synthesizing score with pick up measure +* Bug Fix of kern import Kern import fix #160 +* Bug Fix of Musicxml import repeat infer Bug with musicxml Import #161 +* Bug fix Note array with empty voice Note array from note list with empty voice bug. #159 +* Fix synthesizing scores with pickup measures #167 + +Other changes: + +* Encoding declaration on all files. +* Renaming master branch as main + + Version 1.0.0 (Released on 2022-09-20) -------------------------------------- diff --git a/README.md b/README.md index 5367181e..8c0f2621 100644 --- a/README.md +++ b/README.md @@ -241,8 +241,8 @@ agreement No. 670035 project ["Con Espressione"](https://www.jku.at/en/institute and the Austrian Science Fund (FWF) under grant P 29840-G26 (project ["Computer-assisted Analysis of Herbert von Karajan's Musical Conducting Style"](https://karajan-research.org/programs/musical-interpretation-karajan))

- - + +

[//]: # () diff --git a/docs/Makefile b/docs/Makefile index d4bb2cbb..d0c3cbf1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,8 +5,8 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build +SOURCEDIR = source +BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/make.bat b/docs/make.bat index 2119f510..9534b018 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,35 +1,35 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..9fb4fbd6 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +partitura +sphinx>=5 +nbsphinx diff --git a/docs/Tutorial/notebook.ipynb b/docs/source/Tutorial/notebook.ipynb similarity index 99% rename from docs/Tutorial/notebook.ipynb rename to docs/source/Tutorial/notebook.ipynb index f8aa74ca..fc75b60c 100644 --- a/docs/Tutorial/notebook.ipynb +++ b/docs/source/Tutorial/notebook.ipynb @@ -39,7 +39,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "facial-quarterly", "metadata": { "colab": { @@ -47,25 +47,12 @@ }, "id": "PeabdL1k7YC4", "outputId": "fcb7d1be-27a1-4c79-c5d3-8cbfa54cae44", - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: partitura in /home/manos/Desktop/JKU/codes/partitura (1.0.0)\r\n", - "Requirement already satisfied: numpy in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (1.21.2)\r\n", - "Requirement already satisfied: scipy in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (1.7.1)\r\n", - "Requirement already satisfied: lxml in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (4.6.3)\r\n", - "Requirement already satisfied: lark-parser in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (0.12.0)\r\n", - "Requirement already satisfied: xmlschema in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (1.8.0)\r\n", - "Requirement already satisfied: mido in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from partitura) (1.2.10)\r\n", - "Requirement already satisfied: elementpath<3.0.0,>=2.2.2 in /home/manos/miniconda3/envs/partitura/lib/python3.8/site-packages (from xmlschema->partitura) (2.3.2)\r\n", - "fatal: destination path 'partitura_tutorial' already exists and is not an empty directory.\r\n" - ] + "scrolled": true, + "pycharm": { + "is_executing": true } - ], + }, + "outputs": [], "source": [ "# Install partitura\n", "! pip install partitura\n", @@ -73,7 +60,8 @@ "# To be able to access helper modules in the repo for this tutorial\n", "# (not necessary if the jupyter notebook is run locally instead of google colab)\n", "!git clone https://github.com/CPJKU/partitura_tutorial.git\n", - " \n", + "import warnings\n", + "warnings.filterwarnings('ignore')\n", "import sys, os\n", "sys.path.insert(0, os.path.join(os.getcwd(), \"partitura_tutorial\", \"content\"))\n", "sys.path.insert(0,'/content/partitura_tutorial/content')\n" @@ -122,7 +110,7 @@ "application/vnd.jupyter.widget-view+json": { "version_major": 2, "version_minor": 0, - "model_id": "6918ecbb7839408cb384594317cddb27" + "model_id": "51b999065d4e4460b960ff64e7507006" } }, "metadata": {}, @@ -319,7 +307,7 @@ "outputs": [ { "data": { - "text/plain": "[,\n ,\n ]" + "text/plain": "[,\n ,\n ]" }, "execution_count": 5, "metadata": {}, @@ -839,16 +827,7 @@ "execution_count": 26, "id": "passing-lending", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/manos/Desktop/JKU/codes/partitura/partitura/io/importmidi.py:128: UserWarning: change of Tempo to mpq = 500000 and resulting seconds per tick = 0.000125at time: 0.0\n", - " warnings.warn(\n" - ] - } - ], + "outputs": [], "source": [ "# Note array from a performance\n", "\n", @@ -912,11 +891,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "[(5.6075 , 5.5025 , 72, 37, 1, 0, 'n0')\n", - " (5.63375, 5.47625, 60, 27, 1, 0, 'n1')\n", - " (6.07 , 5.04 , 72, 45, 1, 0, 'n2')\n", - " (6.11125, 4.99875, 60, 26, 1, 0, 'n3')\n", - " (6.82625, 4.28375, 60, 39, 1, 0, 'n4')]\n" + "[(5.6075 , 5.5025 , 72, 37, 0, 0, 'n0')\n", + " (5.63375, 5.47625, 60, 27, 0, 0, 'n1')\n", + " (6.07 , 5.04 , 72, 45, 0, 0, 'n2')\n", + " (6.11125, 4.99875, 60, 26, 0, 0, 'n3')\n", + " (6.82625, 4.28375, 60, 39, 0, 0, 'n4')]\n" ] } ], @@ -1089,14 +1068,6 @@ "text": [ "[(0.25, 47, 1) (1.25, 47, 1) (2.25, 47, 1) (3. , 68, 1) (3.25, 47, 1)]\n" ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/manos/Desktop/JKU/codes/partitura/partitura/directions.py:533: UserWarning: error parsing \"ritenuto\" (UnexpectedCharacters)\n", - " warnings.warn('error parsing \"{}\" ({})'.format(string, type(e).__name__))\n" - ] } ], "source": [ @@ -1354,16 +1325,7 @@ "execution_count": 40, "id": "rolled-cloud", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/tmp/ipykernel_907569/209301002.py:4: DeprecationWarning: `create_part` is deprecated as an argument to `load_match`; use `create_score` instead.\n", - " performed_part, alignment, score_part = pt.load_match(match_fn, create_part=True)\n" - ] - } - ], + "outputs": [], "source": [ "# path to the match\n", "match_fn = os.path.join(MATCH_DIR, 'Chopin_op10_no3_p01.match')\n", @@ -1567,7 +1529,7 @@ "\n", "Thank you for trying out partitura! We hope it serves you well. \n", "\n", - "If you miss a particular functionality or encounter a bug, we appreciate it if you raise an issue on github: https://github.com/CPJKU/partitura/issues" + "If you miss a particular functionality or encounter a bug, we appreciate it if you raise an issue on GitHub: https://github.com/CPJKU/partitura/issues" ] } ], diff --git a/docs/conf.py b/docs/source/conf.py similarity index 97% rename from docs/conf.py rename to docs/source/conf.py index 51fa874a..4236e434 100644 --- a/docs/conf.py +++ b/docs/source/conf.py @@ -14,7 +14,7 @@ import sys import pkg_resources -sys.path.insert(0, os.path.abspath("../partitura")) +sys.path.insert(0, os.path.abspath("../../partitura")) # The master toctree document. master_doc = "index" @@ -29,9 +29,9 @@ # built documents. # # The short X.Y version. -version = "1.1.0" # pkg_resources.get_distribution("partitura").version +version = "1.1.1" # pkg_resources.get_distribution("partitura").version # The full version, including alpha/beta/rc tags. -release = "1.1.0" +release = "1.1.1" # # The full version, including alpha/beta/rc tags # release = pkg_resources.get_distribution("partitura").version diff --git a/docs/genindex.rst b/docs/source/genindex.rst similarity index 100% rename from docs/genindex.rst rename to docs/source/genindex.rst diff --git a/docs/images/aknowledge_logo.png b/docs/source/images/aknowledge_logo.png similarity index 100% rename from docs/images/aknowledge_logo.png rename to docs/source/images/aknowledge_logo.png diff --git a/docs/images/aknowledge_logo_negative.png b/docs/source/images/aknowledge_logo_negative.png similarity index 100% rename from docs/images/aknowledge_logo_negative.png rename to docs/source/images/aknowledge_logo_negative.png diff --git a/docs/images/erc_fwf_logos.jpg b/docs/source/images/erc_fwf_logos.jpg similarity index 100% rename from docs/images/erc_fwf_logos.jpg rename to docs/source/images/erc_fwf_logos.jpg diff --git a/docs/images/score_example.png b/docs/source/images/score_example.png similarity index 100% rename from docs/images/score_example.png rename to docs/source/images/score_example.png diff --git a/docs/images/score_example_1.png b/docs/source/images/score_example_1.png similarity index 100% rename from docs/images/score_example_1.png rename to docs/source/images/score_example_1.png diff --git a/docs/images/score_example_2.png b/docs/source/images/score_example_2.png similarity index 100% rename from docs/images/score_example_2.png rename to docs/source/images/score_example_2.png diff --git a/docs/index.rst b/docs/source/index.rst similarity index 100% rename from docs/index.rst rename to docs/source/index.rst diff --git a/docs/introduction.rst b/docs/source/introduction.rst similarity index 100% rename from docs/introduction.rst rename to docs/source/introduction.rst diff --git a/docs/modules/partitura.musicanalysis.rst b/docs/source/modules/partitura.musicanalysis.rst similarity index 100% rename from docs/modules/partitura.musicanalysis.rst rename to docs/source/modules/partitura.musicanalysis.rst diff --git a/docs/modules/partitura.performance.rst b/docs/source/modules/partitura.performance.rst similarity index 100% rename from docs/modules/partitura.performance.rst rename to docs/source/modules/partitura.performance.rst diff --git a/docs/modules/partitura.rst b/docs/source/modules/partitura.rst similarity index 100% rename from docs/modules/partitura.rst rename to docs/source/modules/partitura.rst diff --git a/docs/modules/partitura.score.rst b/docs/source/modules/partitura.score.rst similarity index 100% rename from docs/modules/partitura.score.rst rename to docs/source/modules/partitura.score.rst diff --git a/docs/modules/partitura.utils.rst b/docs/source/modules/partitura.utils.rst similarity index 100% rename from docs/modules/partitura.utils.rst rename to docs/source/modules/partitura.utils.rst diff --git a/partitura/__init__.py b/partitura/__init__.py index 62ea9e1c..f278849c 100644 --- a/partitura/__init__.py +++ b/partitura/__init__.py @@ -1,7 +1,9 @@ -"""The top level of the package contains functions to load and save +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +The top level of the package contains functions to load and save data, display rendered scores, and functions to estimate pitch spelling, voice assignment, and key signature. - """ import pkg_resources diff --git a/partitura/directions.py b/partitura/directions.py index e0bce335..6c2cd289 100644 --- a/partitura/directions.py +++ b/partitura/directions.py @@ -1,12 +1,12 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- """ -Parse textual directions that occur in a score (in a MusicXML they are -encoded as ), and if possible, convert them to a specific -score.Direction class or subclass. For example "cresc." will produce a -`score.DynamicLoudnessDirection` instance, and "Allegro molto" will produce a -`score.ConstantTempoDirection` instance. If the meaning of the direction cannot -be inferred, a `score.Words` instance is returned. +This module contains methods to Parse textual directions that occur in a score +(in a MusicXML they are encoded as ), and if possible, convert +them to a specific score.Direction class or subclass. For example "cresc." will +produce a `score.DynamicLoudnessDirection` instance, and "Allegro molto" will +produce a `score.ConstantTempoDirection` instance. If the meaning of the +direction cannot be inferred, a `score.Words` instance is returned. The functionality is provided by the function `parse_words` """ diff --git a/partitura/display.py b/partitura/display.py index 61a52378..31ece28a 100644 --- a/partitura/display.py +++ b/partitura/display.py @@ -1,9 +1,9 @@ #!/usr/bin/env python - -"""This module defines a function "show" that creates a rendering of one +# -*- coding: utf-8 -*- +""" +This module defines a function "show" that creates a rendering of one or more parts or partgroups and opens it using the desktop default application. - """ import platform diff --git a/partitura/io/__init__.py b/partitura/io/__init__.py index ba21845f..e90c82d5 100644 --- a/partitura/io/__init__.py +++ b/partitura/io/__init__.py @@ -1,3 +1,8 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +This module contains methods for importing and exporting symbolic music formats. +""" from typing import Union from .importmusicxml import load_musicxml diff --git a/partitura/io/exportaudio.py b/partitura/io/exportaudio.py index 61ede0ca..563b3009 100644 --- a/partitura/io/exportaudio.py +++ b/partitura/io/exportaudio.py @@ -1,14 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- """ -Synthesize Partitura object to wav using additive synthesis +This module contains methods to synthesize Partitura object to wav using +additive synthesis """ -from typing import Union, Optional +from typing import Union, Optional, Callable, Dict, Any import numpy as np from scipy.io import wavfile + from partitura.score import ScoreLike from partitura.performance import PerformanceLike -from partitura.utils.synth import synthesize, SAMPLE_RATE +from partitura.utils.synth import synthesize, SAMPLE_RATE, A4 from partitura.utils.misc import PathLike @@ -18,11 +22,12 @@ def save_wav( input_data: Union[ScoreLike, PerformanceLike, np.ndarray], out: Optional[PathLike] = None, - samplerate=SAMPLE_RATE, - envelope_fun="linear", - tuning="equal_temperament", + samplerate: int = SAMPLE_RATE, + envelope_fun: Union[str, Callable] = "linear", + tuning: Union[str, Callable] = "equal_temperament", + tuning_kwargs: Dict[str, Any] = {"a4": A4}, harmonic_dist: Optional[Union[str, int]] = None, - bpm: Union[float, int] = 60, + bpm: Union[float, np.ndarray, Callable] = 60, ) -> Optional[np.ndarray]: """ Export a score (a `Score`, `Part`, `PartGroup` or list of `Part` instances), @@ -39,15 +44,34 @@ def save_wav( the audio signal as an array (see `audio_signal` below). samplerate: int The sample rate of the audio file in Hz. The default is 44100Hz. - envelope_fun: {"linear", "exp" } + envelope_fun: {"linear", "exp" } or callable The type of envelop to apply to the individual sine waves. - tuning: {"equal_temperament", "natural"} + If "linear" or "exp", the methods `lin_in_lin_out` and `exp_in_exp_out` + in `partitura.utils.synth` will be used. Otherwise this argument should + be a callable. See `lin_in_lin_out` for more details. + tuning: {"equal_temperament", "natural"} or callable. + The tuning system to use. If the value is "equal_temperament", + 12 tone equal temperament implemented in + `partitura.utils.music.midi_pitch_to_frequency` will be used. If the value is + "natural", the function `partitura.utils.synth.midi_pitch_to_tempered_frequency` + will be used. Note that `midi_pitch_to_tempered_frequency` computes + intervals (and thus, frequencies) with respect to a reference note + (A4 by default) and uses the interval ratios specified by + `partitura.utils.synth.FIVE_LIMIT_INTERVAL_RATIOS`. See the documentation of + these functions for more information. If a callable is provided, function should + get MIDI pitch as input and return frequency in Hz as output. + tuning_kwargs : dict + Dictionary of keyword arguments to be passed to the tuning function + specified in `tuning`. See `midi_pitch_to_tempered_frequency` and + `midi_pitch_to_frequency` for more information on their keyword arguments. harmonic_dist : int, "shepard" or None (optional) Distribution of harmonics. If an integer, it is the number of harmonics to be considered. If "shepard", it uses Shepard tones. Default is None (i.e., only consider the fundamental frequency) - bpm : int - The bpm to render the output (if the input is a score-like object) + bpm : float, np.ndarray, callable + The bpm to render the output (if the input is a score-like object). + See `partitura.utils.music.performance_notearray_from_score_notearray` + for more information on this parameter. Returns ------- @@ -60,6 +84,7 @@ def save_wav( samplerate=samplerate, envelope_fun=envelope_fun, tuning=tuning, + tuning_kwargs=tuning_kwargs, harmonic_dist=harmonic_dist, bpm=bpm, ) diff --git a/partitura/io/exportmatch.py b/partitura/io/exportmatch.py index 428f3b6c..f8b847cd 100644 --- a/partitura/io/exportmatch.py +++ b/partitura/io/exportmatch.py @@ -5,7 +5,7 @@ """ import numpy as np -from typing import List, Optional, Union, Iterable +from typing import List, Optional, Iterable from scipy.interpolate import interp1d from partitura.io.importmatch import ( diff --git a/partitura/io/exportmidi.py b/partitura/io/exportmidi.py index 91e64da3..1e8740ec 100644 --- a/partitura/io/exportmidi.py +++ b/partitura/io/exportmidi.py @@ -1,9 +1,12 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +""" +This module contains methods for exporting MIDI files +""" import numpy as np from collections import defaultdict, OrderedDict -from typing import Union, Optional, Iterable +from typing import Optional, Iterable from mido import MidiFile, MidiTrack, Message, MetaMessage diff --git a/partitura/io/exportmusicxml.py b/partitura/io/exportmusicxml.py index be06abf5..2434c291 100644 --- a/partitura/io/exportmusicxml.py +++ b/partitura/io/exportmusicxml.py @@ -1,5 +1,8 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +""" +This module contains methods for exporting MusicXML files. +""" import math from collections import defaultdict from lxml import etree diff --git a/partitura/io/importkern.py b/partitura/io/importkern.py index 2c4983a6..361b32a4 100644 --- a/partitura/io/importkern.py +++ b/partitura/io/importkern.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains methods for importing Humdrum Kern files. +""" import re import warnings @@ -516,7 +521,7 @@ def find_lcm(self, doc): match = re.findall(r"([0-9]+)([a-g]|[A-G]|r|\.)", kern_string) durs, _ = zip(*match) x = np.array(list(map(lambda x: int(x), durs))) - divs = np.lcm.reduce(np.unique(x)) + divs = np.lcm.reduce(np.unique(x[x != 0])) return float(divs) / 4.00 diff --git a/partitura/io/importmei.py b/partitura/io/importmei.py index 3bffa464..0995b1b1 100644 --- a/partitura/io/importmei.py +++ b/partitura/io/importmei.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains methods for importing MEI files. +""" from lxml import etree from xmlschema.names import XML_NAMESPACE import partitura.score as score diff --git a/partitura/io/importmidi.py b/partitura/io/importmidi.py index ea9778b2..ffa6e97f 100644 --- a/partitura/io/importmidi.py +++ b/partitura/io/importmidi.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains methods for importing MIDI files. +""" import warnings from collections import defaultdict @@ -68,6 +72,7 @@ def load_performance_midi( filename: Union[PathLike, mido.MidiFile], default_bpm: Union[int, float] = 120, merge_tracks: bool = False, + time_in_divs: bool = False, ) -> performance.Performance: """Load a musical performance from a MIDI file. @@ -111,7 +116,10 @@ def load_performance_midi( mpq = 60 * (10 ** 6 / default_bpm) # convert MIDI ticks in seconds - time_conversion_factor = mpq / (ppq * 10 ** 6) + if time_in_divs: + time_conversion_factor = 1 + else: + time_conversion_factor = mpq / (ppq * 10 ** 6) notes = [] controls = [] @@ -134,8 +142,11 @@ def load_performance_midi( if msg.type == "set_tempo": mpq = msg.tempo - - time_conversion_factor = mpq / (ppq * 10 ** 6) + + if time_in_divs: + time_conversion_factor = 1 + else: + time_conversion_factor = mpq / (ppq * 10 ** 6) warnings.warn( ( @@ -225,7 +236,7 @@ def load_performance_midi( for i, note in enumerate(notes): note["id"] = f"n{i}" - pp = performance.PerformedPart(notes, controls=controls, programs=programs) + pp = performance.PerformedPart(notes, controls=controls, programs=programs, ppq = ppq) perf = performance.Performance( id=doc_name, diff --git a/partitura/io/importmusicxml.py b/partitura/io/importmusicxml.py index df875b40..f4235e0c 100644 --- a/partitura/io/importmusicxml.py +++ b/partitura/io/importmusicxml.py @@ -1,6 +1,8 @@ #!/usr/bin/env python - # -*- coding: utf-8 -*- +""" +This module contains methods for importing MusicXML files. +""" import os import warnings @@ -347,6 +349,27 @@ def _parse_parts(document, part_dict): "Single measure bracket is assumed" ) + # Complete repeats without end. + volta_repeats = list() + for o in part.iter_all(score.Repeat, mode="starting"): + if o.end is None: + # if len(o.start.starting_objects[score.Repeat]) > 0: + # starting = list(o.start.starting_objects[score.Repeat].keys())[0] + # # if unstarted repeat from volta, continue for now + # if len(starting.end.ending_objects[score.Repeat]) > 0: + # # if repeat from volta, continue for now + # volta_repeats.append(o) + # continue + + starting_repeats = [r for r in part.iter_all(score.Repeat) if r.start is not None] + end_times = [r.start.t for r in starting_repeats] + [part._points[-1].t] + end_time_id = np.searchsorted(end_times, o.start.t+1) + part.add(o, None, end_times[end_time_id]) + warnings.warn( + "Found repeat without end\n" + "Ending point {} is assumend".format(end_times[end_time_id]) + ) + # complete unstarted repeats volta_repeats = list() for o in part.iter_all(score.Repeat, mode="ending"): diff --git a/partitura/io/importnakamura.py b/partitura/io/importnakamura.py index cf3b9fe2..22e82c34 100644 --- a/partitura/io/importnakamura.py +++ b/partitura/io/importnakamura.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- """ This module contains methods for parsing score-to-performance alignments diff --git a/partitura/io/musescore.py b/partitura/io/musescore.py index 36d115a8..f5b2eb76 100644 --- a/partitura/io/musescore.py +++ b/partitura/io/musescore.py @@ -1,8 +1,8 @@ #!/usr/bin/env python - -"""This module contains functionality to use the MuseScore program as a +# -*- coding: utf-8 -*- +""" +This module contains functionality to use the MuseScore program as a backend for loading and rendering scores. - """ import platform @@ -25,7 +25,6 @@ ) - class MuseScoreNotFoundException(Exception): pass diff --git a/partitura/musicanalysis/__init__.py b/partitura/musicanalysis/__init__.py index 4385a324..7c684ed8 100644 --- a/partitura/musicanalysis/__init__.py +++ b/partitura/musicanalysis/__init__.py @@ -1,8 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Tools for music analysis. - +This module contains tools for estimating key signature, time signature, +pitch spelling, voice information, tonal tension, as well as methods for +deriving note-level features and performance encodings. """ from .voice_separation import estimate_voices diff --git a/partitura/musicanalysis/key_identification.py b/partitura/musicanalysis/key_identification.py index ffb3dde7..1fc5fa35 100644 --- a/partitura/musicanalysis/key_identification.py +++ b/partitura/musicanalysis/key_identification.py @@ -1,7 +1,12 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- """ -Krumhansl and Shepard key estimation +This module implements Krumhansl and Schmuckler key estimation method. +References +---------- +.. [2] Krumhansl, Carol L. (1990) "Cognitive foundations of musical pitch", + Oxford University Press, New York. """ import numpy as np from scipy.linalg import circulant diff --git a/partitura/musicanalysis/meter.py b/partitura/musicanalysis/meter.py index ba8d6f86..eff61c8c 100644 --- a/partitura/musicanalysis/meter.py +++ b/partitura/musicanalysis/meter.py @@ -1,21 +1,22 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Meter numerator, Beat, and Tempo estimation. +This module implements methods for Meter numerator, Beat, and Tempo estimation. -Implementation adapted from Jakob Woegerbauer +Implementation adapted from Jakob Woegerbauer based on a model published by Simon Dixon.[1] References ---------- -.. [1] Simon Dixon (2001), Automatic extraction of +.. [1] Simon Dixon (2001), Automatic extraction of tempo and beat from expressive performances. Journal of New Music Research, 30(1):39–58 """ import warnings import numpy as np + # import scipy.spatial.distance as distance # from scipy.interpolate import interp1d @@ -26,24 +27,25 @@ MAX = 9999999999999 MIN_INTERVAL = 0.01 MAX_INTERVAL = 2 # in seconds -CLUSTER_WIDTH = 1/12 # in seconds +CLUSTER_WIDTH = 1 / 12 # in seconds N_CLUSTERS = 100 INIT_DURATION = 10 # in seconds TIMEOUT = 10 # in seconds TOLERANCE_POST = 0.4 # propotion of beat_interval TOLERANCE_PRE = 0.2 # proportion of beat_interval -TOLERANCE_INNER = 1/12 -CORRECTION_FACTOR = 1/4 # higher => more correction (speed changes) +TOLERANCE_INNER = 1 / 12 +CORRECTION_FACTOR = 1 / 4 # higher => more correction (speed changes) MAX_AGENTS = 100 # delete low-scoring agents when there are more than MAX_AGENTS -CHORD_SPREAD_TIME = 1/12 # for onset aggregation +CHORD_SPREAD_TIME = 1 / 12 # for onset aggregation -class MultipleAgents(): +class MultipleAgents: """ Class to compute inter onset interval clusters - and to instantiate a number of agents to + and to instantiate a number of agents to approximate beat positions. """ + def run(self, onsets, salience): self.clusters = [] self.agents = [] @@ -72,9 +74,9 @@ def setup_clusters(self, onsets): # create inter-onset interval clusters self.clusters = [] for i in range(len(onsets)): - for j in range(i+1, len(onsets)): - ioi = onsets[j]-onsets[i] - if ioi < MIN_INTERVAL: + for j in range(i + 1, len(onsets)): + ioi = onsets[j] - onsets[i] + if ioi < MIN_INTERVAL: continue if ioi > MAX_INTERVAL: break @@ -92,7 +94,7 @@ def setup_clusters(self, onsets): i = 0 while i < len(self.clusters): c_i = self.clusters[i] - i = i+1 + i = i + 1 j = i while j < len(self.clusters): if abs(c_i.interval - self.clusters[j].interval) < CLUSTER_WIDTH: @@ -109,12 +111,12 @@ def setup_clusters(self, onsets): c.interval *= 2 while c.interval > MAX_INTERVAL: c.interval /= 2 - + # merge again i = 0 while i < len(self.clusters): c_i = self.clusters[i] - i = i+1 + i = i + 1 j = i while j < len(self.clusters): if abs(c_i.interval - self.clusters[j].interval) < CLUSTER_WIDTH: @@ -127,11 +129,12 @@ def setup_clusters(self, onsets): for c_i in self.clusters: for c_j in self.clusters: n = round(c_j.interval / c_i.interval) - if abs(c_i.interval - n*c_j.interval) < CLUSTER_WIDTH: + if abs(c_i.interval - n * c_j.interval) < CLUSTER_WIDTH: c_i.score += Cluster.relationship_factor(n) * len(c_j.iois) self.clusters = sorted(self.clusters, key=lambda x: x.score, reverse=True)[ - :N_CLUSTERS] + :N_CLUSTERS + ] def init_tracking(self, onsets, salience): self.agents = [] @@ -146,7 +149,7 @@ def init_tracking(self, onsets, salience): self.agents.append(a) i += 1 - def track(self, onsets, salience): + def track(self, onsets, salience): for e_i in range(len(onsets)): e = onsets[e_i] new_agents = [] @@ -155,10 +158,13 @@ def track(self, onsets, salience): if e - a.lastBeat() > TIMEOUT: remove_agents.append(a) else: - while a.prediction + TOLERANCE_POST*a.beat_interval < e: - a.history.append((a.prediction, 0)) + while a.prediction + TOLERANCE_POST * a.beat_interval < e: + a.history.append((a.prediction, 0)) a.prediction += a.beat_interval - if a.prediction - TOLERANCE_PRE*a.beat_interval <= e and e <= a.prediction + TOLERANCE_POST*a.beat_interval: + if ( + a.prediction - TOLERANCE_PRE * a.beat_interval <= e + and e <= a.prediction + TOLERANCE_POST * a.beat_interval + ): if abs(a.prediction - e) > TOLERANCE_INNER: a_new = Agent() a_new.beat_interval = a.beat_interval @@ -167,10 +173,12 @@ def track(self, onsets, salience): a_new.score = a.score new_agents.append(a_new) err = e - a.prediction - a.beat_interval = a.beat_interval + err*CORRECTION_FACTOR + a.beat_interval = a.beat_interval + err * CORRECTION_FACTOR a.prediction = e + a.beat_interval a.history.append((e, salience[e_i])) - a.score += (1-abs(err/a.beat_interval)/2.) * salience[e_i] + a.score += (1 - abs(err / a.beat_interval) / 2.0) * salience[ + e_i + ] for a in remove_agents: self.agents.remove(a) @@ -181,35 +189,42 @@ def track(self, onsets, salience): agents_all = self.agents[:] self.agents = [] for i in range(len(agents_all)): - for j in range(i+1, len(agents_all)): + for j in range(i + 1, len(agents_all)): if duplicate[i] > 0 or duplicate[j] > 0: continue - if abs(agents_all[i].beat_interval - agents_all[j].beat_interval) < 0.01 \ - and abs(agents_all[i].lastBeat() - agents_all[j].lastBeat()) < 0.02: + if ( + abs(agents_all[i].beat_interval - agents_all[j].beat_interval) + < 0.01 + and abs(agents_all[i].lastBeat() - agents_all[j].lastBeat()) + < 0.02 + ): if agents_all[i].score > agents_all[j].score: duplicate[j] += 1 else: duplicate[i] += 1 break - self.agents = sorted(np.asarray(agents_all)[(duplicate < 1)].tolist( - ), key=lambda x: x.score, reverse=True)[:MAX_AGENTS] + self.agents = sorted( + np.asarray(agents_all)[(duplicate < 1)].tolist(), + key=lambda x: x.score, + reverse=True, + )[:MAX_AGENTS] self.agents = sorted(self.agents, key=lambda x: x.score, reverse=True) -class Cluster(): +class Cluster: """ Class for inter onset interval clusters. - + Parameters ---------- ioi : float - an initial inter onset interval - + an initial inter onset interval + """ - + def __init__(self, ioi) -> None: self.iois = np.zeros(0) self.score = 0 @@ -217,24 +232,25 @@ def __init__(self, ioi) -> None: self.addIoi(ioi) def getK(self, ioi): - diff = abs(self.interval-ioi) + diff = abs(self.interval - ioi) if diff < CLUSTER_WIDTH: return diff return False def addIoi(self, ioi): self.iois = np.append(self.iois, ioi) - self.interval = np.sum(self.iois)/len(self.iois) + self.interval = np.sum(self.iois) / len(self.iois) @staticmethod def relationship_factor(d): if 1 <= d and d <= 4: - return 6-d + return 6 - d elif 5 <= d and d <= 8: return 1 return 0 -class Agent(): + +class Agent: """ Class for beat induction agents. """ @@ -246,43 +262,47 @@ def __init__(self) -> None: self.score = 0 def lastBeat(self): - i = len(self.history)-1 + i = len(self.history) - 1 while i > 0 and self.history[i][1] == 0: - i-=1 + i -= 1 return self.history[i][0] def getTempo(self): - return 60.0 * (len(self.history)-1) / (self.history[-1][0]-self.history[0][0]) - + return ( + 60.0 * (len(self.history) - 1) / (self.history[-1][0] - self.history[0][0]) + ) + def getTimeSignatureNum(self): possibleNums = [2, 3, 4, 6, 9, 12, 24] - bestVal = {num:0 for num in possibleNums} + bestVal = {num: 0 for num in possibleNums} salience = list(zip(*self.history))[1] sumSalience = sum(salience) f = 1.005 for num in possibleNums: - for startIdx in range(num): + for startIdx in range(num): dbs = len(salience[startIdx::num]) if dbs > 1: - downbeatSalience = sum(salience[startIdx::num])/dbs - sumSalience = sum(salience[:(dbs-1)*num]) - otherSalience = (sumSalience-downbeatSalience*dbs)/((num-1)*(dbs-1)) + downbeatSalience = sum(salience[startIdx::num]) / dbs + sumSalience = sum(salience[: (dbs - 1) * num]) + otherSalience = (sumSalience - downbeatSalience * dbs) / ( + (num - 1) * (dbs - 1) + ) else: downbeatSalience = 0 otherSalience = 1 - - ratio = downbeatSalience/otherSalience + + ratio = downbeatSalience / otherSalience bestVal[num] = max(bestVal[num], ratio) - bestNum = max(bestVal, key=bestVal.get) - + bestNum = max(bestVal, key=bestVal.get) + return bestNum def estimate_time(note_info): """ Estimate tempo, meter (currently only time signature numerator), and beats - + Parameters ---------- note_info : structured array, `Part` or `PerformedPart` @@ -293,19 +313,19 @@ def estimate_time(note_info): onset and duration information of both score and performance, (e.g., containing both `onset_beat` and `onset_sec`), the score information will be preferred. - + Returns ------- dict Tempo, meter, and beat information - """ + """ note_array = ensure_notearray(note_info) onset_kw, _ = get_time_units_from_note_array(note_array) onsets_raw = note_array[onset_kw] - + # aggregate notes in clusters - aggregated_notes = [(0,0)] + aggregated_notes = [(0, 0)] for note_on in onsets_raw: prev_note_on = aggregated_notes[-1][0] prev_note_salience = aggregated_notes[-1][1] @@ -313,14 +333,11 @@ def estimate_time(note_info): aggregated_notes[-1] = (note_on, prev_note_salience + 1) else: aggregated_notes.append((note_on, 1)) - - print(aggregated_notes) + + print(aggregated_notes) onsets, saliences = list(zip(*aggregated_notes)) - + ma = MultipleAgents() ma.run(onsets, saliences) - - return dict(tempo=ma.getTempo(), - meter_numerator=ma.getNum(), - beats=ma.getBeats()) - \ No newline at end of file + + return dict(tempo=ma.getTempo(), meter_numerator=ma.getNum(), beats=ma.getBeats()) diff --git a/partitura/musicanalysis/note_features.py b/partitura/musicanalysis/note_features.py index bce4be86..58d0f9d0 100644 --- a/partitura/musicanalysis/note_features.py +++ b/partitura/musicanalysis/note_features.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains methods to compute note-level features. +""" import sys import warnings import numpy as np diff --git a/partitura/musicanalysis/performance_codec.py b/partitura/musicanalysis/performance_codec.py index 64957e1d..59cebae1 100644 --- a/partitura/musicanalysis/performance_codec.py +++ b/partitura/musicanalysis/performance_codec.py @@ -1,3 +1,9 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module implements a codec to encode and decode expressive performances to a set of +expressive parameters. +""" import numpy as np import numpy.lib.recfunctions as rfn try: diff --git a/partitura/musicanalysis/pitch_spelling.py b/partitura/musicanalysis/pitch_spelling.py index 191492e3..d2470dc7 100644 --- a/partitura/musicanalysis/pitch_spelling.py +++ b/partitura/musicanalysis/pitch_spelling.py @@ -1,11 +1,12 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- """ -Pitch Spelling using the ps13 algorithm. +This module contains methods for estimation pitch spelling using the ps13 algorithm. References ---------- - - +.. [4] Meredith, D. (2006). "The ps13 Pitch Spelling Algorithm". Journal + of New Music Research, 35(2):121. """ import numpy as np from collections import namedtuple diff --git a/partitura/musicanalysis/tonal_tension.py b/partitura/musicanalysis/tonal_tension.py index 7c3b7232..08778647 100644 --- a/partitura/musicanalysis/tonal_tension.py +++ b/partitura/musicanalysis/tonal_tension.py @@ -1,8 +1,8 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Spiral array representation and tonal tension profiles using Herreman and -Chew's tension ribbons +This module contains methods to compute Chew's spiral array representation +and the tonal tension profiles using Herreman and Chew's tension ribbons References ---------- diff --git a/partitura/musicanalysis/voice_separation.py b/partitura/musicanalysis/voice_separation.py index e0328d50..d714e807 100644 --- a/partitura/musicanalysis/voice_separation.py +++ b/partitura/musicanalysis/voice_separation.py @@ -1,7 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -"""Voice Separation using Chew and Wu's algorithm. - +""" +This module contains methods for voice separation using Chew and Wu's algorithm. + +References +---------- +.. [6] Chew, E. and Wu, Xiaodan (2004) "Separating Voices in + Polyphonic Music: A Contig Mapping Approach". In Uffe Kock, + editor, "Computer Music Modeling and Retrieval". Springer + Berlin Heidelberg. """ from collections import defaultdict from statistics import mode diff --git a/partitura/performance.py b/partitura/performance.py index c0a6c1a9..c0482944 100644 --- a/partitura/performance.py +++ b/partitura/performance.py @@ -1,11 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - -"""This module contains a lightweight ontology to represent a performance in a +""" +This module contains a lightweight ontology to represent a performance in a MIDI-like format. A performance is defined at the highest level by a :class:`~partitura.performance.PerformedPart`. This object contains performed notes as well as continuous control parameters, such as sustain pedal. - """ @@ -48,6 +47,8 @@ class PerformedPart(object): The threshold above which sustain pedal values are considered to be equivalent to on. For values below the threshold the sustain pedal is treated as off. Defaults to 64. + ppq : int + Parts per Quarter (ppq) of the MIDI encoding. Defaults to 480. Attributes ---------- @@ -73,6 +74,7 @@ def __init__( controls: List[dict] = None, programs: List[dict] = None, sustain_pedal_threshold: int = 64, + ppq: int = 480 ) -> None: super().__init__() self.id = id @@ -80,6 +82,7 @@ def __init__( self.notes = notes self.controls = controls or [] self.programs = programs or [] + self.ppq = ppq self.sustain_pedal_threshold = sustain_pedal_threshold diff --git a/partitura/score.py b/partitura/score.py index 9b3bb49c..95f533ad 100644 --- a/partitura/score.py +++ b/partitura/score.py @@ -1,13 +1,12 @@ +#!/usr/bin/python # -*- coding: utf-8 -*- - - -"""This module defines an ontology of musical elements to represent +""" +This module defines an ontology of musical elements to represent musical scores, such as measures, notes, slurs, words, tempo and loudness directions. A score is defined at the highest level by a `Part` object (or a hierarchy of `Part` objects, in a `PartGroup` object). This object serves as a timeline at which musical elements are registered in terms of their start and end times. - """ from copy import copy @@ -4587,12 +4586,12 @@ def merge_parts(parts, reassign="voice"): note_arrays = [part.note_array(include_staff=True) for part in parts] # find the maximum number of voices for each part (voice number start from 1) maximum_voices = [ - max(note_array["voice"]) if max(note_array["voice"]) != 0 else 1 + max(note_array["voice"], default=0) if max(note_array["voice"], default=0) != 0 else 1 for note_array in note_arrays ] # find the maximum number of staves for each part (staff number start from 0 but we force them to 1) maximum_staves = [ - max(note_array["staff"]) if max(note_array["staff"]) != 0 else 1 + max(note_array["staff"], default=0) if max(note_array["staff"], default=0) != 0 else 1 for note_array in note_arrays ] diff --git a/partitura/utils/__init__.py b/partitura/utils/__init__.py index 2e8edd76..e43b6280 100644 --- a/partitura/utils/__init__.py +++ b/partitura/utils/__init__.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Top level of the utilities module. +""" from partitura.utils.generic import ( ComparableMixin, @@ -51,7 +55,7 @@ synthesize ) -from .misc import ( +from partitura.utils.misc import ( PathLike, get_document_name, deprecated_alias, @@ -72,4 +76,5 @@ "pitch_spelling_to_note_name", "show_diff", "PrettyPrintTree", + "synthesize", ] diff --git a/partitura/utils/generic.py b/partitura/utils/generic.py index 36b8b5cf..6ac2e919 100644 --- a/partitura/utils/generic.py +++ b/partitura/utils/generic.py @@ -1,5 +1,8 @@ -#!/usr/bin/env python - +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +This module contains generic class- and numerical-related utilities +""" import warnings from collections import defaultdict from textwrap import dedent diff --git a/partitura/utils/misc.py b/partitura/utils/misc.py index 9819f77e..da02564d 100644 --- a/partitura/utils/misc.py +++ b/partitura/utils/misc.py @@ -1,3 +1,8 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +This module contains miscellaneous utilities. +""" import functools import os import warnings diff --git a/partitura/utils/music.py b/partitura/utils/music.py index 31733263..c7c73361 100644 --- a/partitura/utils/music.py +++ b/partitura/utils/music.py @@ -1,11 +1,15 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains music related utilities +""" from collections import defaultdict import re import warnings import numpy as np from scipy.interpolate import interp1d from scipy.sparse import csc_matrix -from typing import Union +from typing import Union, Callable from partitura.utils.generic import find_nearest, search, iter_current_next MIDI_BASE_CLASS = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11} @@ -413,7 +417,7 @@ def pitch_spelling_to_note_name(step, alter, octave): def midi_pitch_to_frequency( - midi_pitch: Union[int, float, np.ndarray], a4: Union[int, float] = A4 + midi_pitch: Union[int, float, np.ndarray], a4: Union[int, float] = A4 ) -> Union[float, np.ndarray]: """ Convert MIDI pitch to frequency in Hz. This method assumes equal temperament. @@ -435,8 +439,8 @@ def midi_pitch_to_frequency( def frequency_to_midi_pitch( - freq: Union[int, float, np.ndarray], - a4: Union[int, float] = A4, + freq: Union[int, float, np.ndarray], + a4: Union[int, float] = A4, ) -> Union[int, np.ndarray]: """ Convert frequency to MIDI pitch. This method assumes equal temperament. @@ -1601,15 +1605,10 @@ def note_array_from_part_list( kwargs["include_divs_per_quarter"] = True is_score = True if isinstance(part, Part): - na = note_array_from_part( - part, - **kwargs - ) + na = note_array_from_part(part, **kwargs) elif isinstance(part, PartGroup): na = note_array_from_part_list( - part.children, - unique_id_per_part=unique_id_per_part, - **kwargs + part.children, unique_id_per_part=unique_id_per_part, **kwargs ) elif isinstance(part, PerformedPart): na = part.note_array() @@ -1622,7 +1621,9 @@ def note_array_from_part_list( if is_score: # rescale if parts have different divs - divs_per_parts = [part[0]["divs_pq"] for part in note_array] + divs_per_parts = [ + part_na[0]["divs_pq"] for part_na in note_array if len(part_na) + ] lcm = np.lcm.reduce(divs_per_parts) time_multiplier_per_part = [int(lcm / d) for d in divs_per_parts] for na, time_mult in zip(note_array, time_multiplier_per_part): @@ -2297,7 +2298,11 @@ def note_array_from_note_list( # Sanitize voice information no_voice_idx = np.where(note_array["voice"] == -1)[0] - max_voice = note_array["voice"].max() + try: + max_voice = note_array["voice"].max() + except ValueError: # raised if `note_array["voice"]` is empty. + note_array["voice"] = 0 + max_voice = 0 note_array["voice"][no_voice_idx] = max_voice + 1 # sort by onset and pitch @@ -2556,19 +2561,25 @@ def performance_from_part(part, bpm=100, velocity=64): ---------- part: Part The part from which we want to generate a performed part - bpm : float - Beats per minute - velocity: float or int - The MIDI velocity for all notes. + bpm : float, np.ndarray or callable + Beats per minute to generate the performance. If a the value is a float, + the performance will be generated with a constant tempo. If the value is + a np.ndarray, it has to be an array with two columns where the first + column is score time in beats and the second column is the tempo. If a + callable is given, the function is assumed to map score onsets in beats + to tempo values. Default is 100 bpm. + velocity: int, np.ndarray or callable + MIDI velocity of the performance. If a the value is an int, the + performance will be generated with a constant MIDI velocity. If the + value is a np.ndarray, it has to be an array with two columns where + the first column is score time in beats and the second column is the + MIDI velocity. If a callable is given, the function is assumed to map + score time in beats to MIDI velocity. Default is 64. Returns ------- ppart: PerformedPart - - Potential extensions - -------------------- - * allow for bpm to be a callable or an 2D array with columns (onset, bpm) - * allow for velocity to be a callable or a 2D array (onset, velocity) + A PerformedPart object with the generated performance. """ from partitura.score import Part from partitura.performance import PerformedPart @@ -2579,6 +2590,52 @@ def performance_from_part(part, bpm=100, velocity=64): f"`partitura.score.Part` instance, not {type(part)}" ) + snote_array = part.note_array() + + pnote_array = performance_notearray_from_score_notearray( + snote_array=snote_array, bpm=bpm, velocity=velocity + ) + + ppart = PerformedPart.from_note_array(pnote_array) + + return ppart + + +def performance_notearray_from_score_notearray( + snote_array: np.ndarray, + bpm: [float, np.ndarray, Callable] = 100.0, + velocity: Union[int, np.ndarray, Callable] = 64, +) -> np.ndarray: + """ + Generate a performance note array from a score note array + + Parameters + ---------- + snote_array : np.ndarray + A score note array. + bpm : float, np.ndarray or callable + Beats per minute to generate the performance. If a the value is a float, + the performance will be generated with a constant tempo. If the value is + a np.ndarray, it has to be an array with two columns where the first + column is score time in beats and the second column is the tempo. If a + callable is given, the function is assumed to map score onsets in beats + to tempo values. Default is 100 bpm. + velocity: int, np.ndarray or callable + MIDI velocity of the performance. If a the value is an int, the + performance will be generated with a constant MIDI velocity. If the + value is a np.ndarray, it has to be an array with two columns where + the first column is score time in beats and the second column is the + MIDI velocity. If a callable is given, the function is assumed to map + score time in beats to MIDI velocity. Default is 64. + + + Returns + ------- + pnote_array : np.ndarray + A performance note array based on the score with the specified tempo + and velocity. + """ + ppart_fields = [ ("onset_sec", "f4"), ("duration_sec", "f4"), @@ -2588,10 +2645,37 @@ def performance_from_part(part, bpm=100, velocity=64): ("channel", "i4"), ("id", "U256"), ] - snote_array = part.note_array() pnote_array = np.zeros(len(snote_array), dtype=ppart_fields) + if isinstance(velocity, np.ndarray): + + if velocity.ndim == 2: + + velocity_fun = interp1d( + x=velocity[:, 0], + y=velocity[:, 1], + kind="previous", + bounds_error=False, + fill_value=(velocity[0, 1], velocity[-1, 1]), + ) + pnote_array["velocity"] = np.round( + velocity_fun(snote_array["onset_beat"]), + ).astype(int) + + else: + pnote_array["velocity"] = np.round(velocity).astype(int) + + elif callable(velocity): + # The velocity parameter is a callable that returns a + # velocity value for each score onset + pnote_array["velocity"] = np.round( + velocity(snote_array["onset_beat"]), + ).astype(int) + + else: + pnote_array["velocity"] = int(velocity) + unique_onsets = np.unique(snote_array["onset_beat"]) # Cast as object to avoid warnings, but seems to work well # in numpy version 1.20.1 @@ -2602,23 +2686,51 @@ def performance_from_part(part, bpm=100, velocity=64): iois = np.diff(unique_onsets) - bp = 60 / float(bpm) + if callable(bpm) or isinstance(bpm, np.ndarray): + + if callable(bpm): + # bpm parameter is a callable that returns a bpm value + # for each score onset + bp = 60 / bpm(unique_onsets) + bp_duration = ( + 60 / bpm(snote_array["onset_beat"]) * snote_array["duration_beat"] + ) + + elif isinstance(bpm, np.ndarray): + + if bpm.ndim != 2: + raise ValueError("`bpm` should be a 2D array") + + bpm_fun = interp1d( + x=bpm[:, 0], + y=bpm[:, 1], + kind="previous", + bounds_error=False, + fill_value=(bpm[0, 1], bpm[-1, 1]), + ) + bp = 60 / bpm_fun(unique_onsets) + bp_duration = ( + 60 / bpm_fun(snote_array["onset_beat"]) * snote_array["duration_beat"] + ) + + p_onsets = np.r_[0, np.cumsum(iois * bp[:-1])] + pnote_array["duration_sec"] = bp_duration * snote_array["duration_beat"] + + else: + # convert bpm to beat period + bp = 60 / float(bpm) + p_onsets = np.r_[0, np.cumsum(iois * bp)] + pnote_array["duration_sec"] = bp * snote_array["duration_beat"] - # TODO: allow for variable bpm and velocity - pnote_array["duration_sec"] = bp * snote_array["duration_beat"] - pnote_array["velocity"] = int(velocity) pnote_array["pitch"] = snote_array["pitch"] pnote_array["id"] = snote_array["id"] - p_onsets = np.r_[0, np.cumsum(iois * bp)] for ix, on in zip(unique_onset_idxs, p_onsets): # ix has to be cast as integer depending on the # numpy version... pnote_array["onset_sec"][ix.astype(int)] = on - ppart = PerformedPart.from_note_array(pnote_array) - - return ppart + return pnote_array def get_time_maps_from_alignment( diff --git a/partitura/utils/synth.py b/partitura/utils/synth.py index ae264d22..792148f1 100644 --- a/partitura/utils/synth.py +++ b/partitura/utils/synth.py @@ -1,9 +1,12 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- """ -Synthesize Partitura Part or Note array to wav using additive synthesis +This module contains methods for synthesizing score- or performance-like +objects using additive synthesis TODO +---- * Add other tuning systems? - """ from typing import Union, Tuple, Dict, Optional, Any, Callable @@ -17,6 +20,7 @@ ensure_notearray, get_time_units_from_note_array, midi_pitch_to_frequency, + performance_notearray_from_score_notearray, ) TWO_PI = 2 * np.pi @@ -39,6 +43,23 @@ 12: 2, } +# symmetric five limit temperament with supertonic = 10:9 +FIVE_LIMIT_INTERVAL_RATIOS = { + 0: 1, + 1: 16 / 15, + 2: 10 / 9, + 3: 6 / 5, + 4: 5 / 4, + 5: 4 / 3, + 6: 7 / 5, + 7: 3 / 2, + 8: 8 / 5, + 9: 5 / 3, + 10: 9 / 5, + 11: 15 / 8, + 12: 2 +} + def midi_pitch_to_natural_frequency( midi_pitch: Union[int, float, np.ndarray], @@ -71,10 +92,6 @@ def midi_pitch_to_natural_frequency( C4 is a descending major sixth with respect to A4, E5 is descending perfect fourth computed with respect to A5, etc.). - - TODO - ---- - * compute intervals with given reference pitch. """ octave = (midi_pitch // 12) - 1 @@ -102,6 +119,50 @@ def midi_pitch_to_natural_frequency( return freqs +def midi_pitch_to_tempered_frequency( + midi_pitch: Union[int, float, np.ndarray], + reference_midi_pitch: Union[int, float] = 69, + reference_frequency: float = A4, + interval_ratios: Dict[int, float] = FIVE_LIMIT_INTERVAL_RATIOS, +) -> Union[float, np.ndarray]: + """ + Convert MIDI pitch to frequency in Hz using + a temperament given as frequency ratios above + a reference pitch. + + Parameters + ---------- + midi_pitch: int, float or ndarray + MIDI pitch of the note(s). + reference_midi_pitch : int or float (optional) + midi pitch of the reference pitch. By default is 69 (A4). + reference_frequency : int (optional) + Frequency of A4 in Hz. By default is 440 Hz. + interval_ratios: dict + Dictionary of interval ratios from the reference + + Returns + ------- + freq : float or ndarray + Frequency of the note(s). + """ + + interval = (midi_pitch - reference_midi_pitch) % 12 + octave = (midi_pitch - reference_midi_pitch) // 12 + adjusted_reference_frequency = reference_frequency / (2.0 ** -octave) + + if isinstance(interval, (int, float)): + interval = np.array([interval], dtype=int) + + ratios = np.array([interval_ratios[abs(itv)] for itv in interval]) + + freqs = adjusted_reference_frequency * ratios + + if isinstance(midi_pitch, (int, float)): + freqs = float(freqs) + return freqs + + def exp_in_exp_out( num_frames: int, dtype: type = DTYPE, @@ -273,10 +334,10 @@ def synthesize( note_info, samplerate: int = SAMPLE_RATE, envelope_fun: str = "linear", - tuning: str = "equal_temperament", + tuning: Union[str, Callable] = "equal_temperament", tuning_kwargs: Dict[str, Any] = {"a4": A4}, harmonic_dist: Optional[Union[str, int]] = None, - bpm: Union[float, int] = 60, + bpm: Union[float, np.ndarray, Callable] = 60, ) -> np.ndarray: """ Synthesize a partitura object with note information @@ -291,13 +352,29 @@ def synthesize( The sample rate of the audio file in Hz. envelope_fun: {"linear", "exp" } The type of envelop to apply to the individual sine waves. - tuning: {"equal_temperament", "natural"} + tuning: {"equal_temperament", "natural"} or callable. + The tuning system to use. If the value is "equal_temperament", + 12 tone equal temperament implemented in `midi_pitch_to_frequency` will + be used. If the value is "natural", the function + `midi_pitch_to_tempered_frequency` will be used. Note that + `midi_pitch_to_tempered_frequency` computes the intervals (and thus, + frequencies) with respect to a reference note (A4 by default) and uses the + interval ratios specified by `FIVE_LIMIT_INTERVAL_RATIOS`. See + the documentation of these functions for more information. If a callable + is provided, function should get MIDI pitch as input and return + frequency in Hz as output. + tuning_kwargs : dict + Dictionary of keyword arguments to be passed to the tuning function + specified in `tuning`. See `midi_pitch_to_tempered_frequency` and + `midi_pitch_to_frequency` for more information on their keyword arguments. harmonic_dist : int, "shepard" or None (optional) Distribution of harmonics. If an integer, it is the number of harmonics to be considered. If "shepard", it uses Shepard tones. Default is None (i.e., only consider the fundamental frequency) - bpm : int - The bpm to render the output (if the input is a score-like object) + bpm : float, np.ndarray or callable + The bpm to render the output (if the input is a score-like object). + See `partitura.utils.music.performance_notearray_from_score_notearray` + for more information on this parameter. Returns ------- @@ -313,10 +390,13 @@ def synthesize( # If the input is a score, convert score time to seconds if onset_unit != "onset_sec": - beat2sec = 60 / bpm - onsets = note_array[onset_unit] * beat2sec - offsets = (note_array[onset_unit] + note_array[duration_unit]) * beat2sec - duration = note_array[duration_unit] * beat2sec + pnote_array = performance_notearray_from_score_notearray( + snote_array=note_array, + bpm=bpm, + ) + onsets = pnote_array["onset_sec"] + offsets = pnote_array["onset_sec"] + pnote_array["duration_sec"] + duration = pnote_array["duration_sec"] else: onsets = note_array["onset_sec"] offsets = note_array["onset_sec"] + note_array["duration_sec"] @@ -343,7 +423,14 @@ def synthesize( if tuning == "equal_temperament": freq_in_hz = midi_pitch_to_frequency(pitch, **tuning_kwargs) elif tuning == "natural": - freq_in_hz = midi_pitch_to_natural_frequency(pitch, **tuning_kwargs) + freq_in_hz = midi_pitch_to_tempered_frequency(pitch, **tuning_kwargs) + elif callable(tuning): + freq_in_hz = tuning(pitch, **tuning_kwargs) + + else: + raise ValueError( + "`tuning` must be 'equal_temperament', 'natural' or a callable" + ) if harmonic_dist is None: diff --git a/setup.py b/setup.py index e02add30..e68a70aa 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ EMAIL = "partitura-users@googlegroups.com" AUTHOR = "Maarten Grachten, Carlos Cancino-Chacón, Silvan Peter, Emmanouil Karystinaios, Francesco Foscarin, Thassilo Gadermaier" REQUIRES_PYTHON = ">=3.6" -VERSION = "1.1.0" +VERSION = "1.1.1" # What packages are required for this module to be executed? REQUIRED = ["numpy", "scipy", "lxml", "lark-parser", "xmlschema", "mido"] @@ -59,6 +59,8 @@ "assets/musicxml.xsd", "assets/score_example.mid", "assets/score_example.musicxml", + "assets/score_example.krn", + "assets/score_example.mei", ] }, install_requires=REQUIRED, diff --git a/tests/__init__.py b/tests/__init__.py index f3c3b6ad..ea98732e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,5 @@ -# encoding: utf-8 +#!/usr/bin/env python +# -*- coding: utf-8 -*- # pylint: skip-file """ This module contains tests. diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 3130c9f1..606c0759 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module includes tests for deprecation utilities. +""" import unittest import warnings import numpy as np diff --git a/tests/test_kern.py b/tests/test_kern.py index 1fb663dc..86cfb3f1 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -1,5 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ -This file contains test functions for KERN import and export. +This module contains test functions for KERN import and export. """ import unittest @@ -32,14 +34,14 @@ def test_examples(self): for fn in KERN_TESTFILES: part = merge_parts(load_kern(fn)) ka = ensure_notearray(part) - self.assertTrue(True == True) + self.assertTrue(True) def test_tie_mismatch(self): fn = KERN_TIES[0] part = merge_parts(load_kern(fn)) - self.assertTrue(True == True) + self.assertTrue(True) # if __name__ == "__main__": diff --git a/tests/test_key_estimation.py b/tests/test_key_estimation.py index d08f0339..1902901d 100644 --- a/tests/test_key_estimation.py +++ b/tests/test_key_estimation.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the key estimation methods. +""" import unittest from partitura import EXAMPLE_MUSICXML diff --git a/tests/test_load_performance.py b/tests/test_load_performance.py index 313ed711..9319b561 100644 --- a/tests/test_load_performance.py +++ b/tests/test_load_performance.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for the load_performance method - +This module contains test functions for the `load_performance` method """ import unittest @@ -10,7 +10,7 @@ from partitura import load_performance, EXAMPLE_MIDI from partitura.io import NotSupportedFormatError -from partitura.performance import PerformedPart, Performance +from partitura.performance import Performance class TestLoadScore(unittest.TestCase): diff --git a/tests/test_load_score.py b/tests/test_load_score.py index b6297217..e7b3c8f5 100644 --- a/tests/test_load_score.py +++ b/tests/test_load_score.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for the load_score method - +This module contains test functions for the `load_score` method. """ import unittest diff --git a/tests/test_match_import.py b/tests/test_match_import.py index 0eb4df83..d92a93ae 100644 --- a/tests/test_match_import.py +++ b/tests/test_match_import.py @@ -1,17 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for Matchfile import - +This module contains test functions for Matchfile import """ - -import logging import unittest -from tempfile import TemporaryFile +import numpy as np -from tests import MATCH_IMPORT_EXPORT_TESTFILES +from tests import MATCH_IMPORT_EXPORT_TESTFILES, MOZART_VARIATION_FILES from partitura.io.importmatch import MatchFile, parse_matchline -from partitura import load_match +from partitura import load_match, load_score, load_performance class TestLoadMatch(unittest.TestCase): @@ -62,13 +60,80 @@ def test_match_lines(self): mo = parse_matchline(ml) self.assertTrue(mo.matchline, ml) - # def test_load_match(self): - # for fn in MATCH_IMPORT_EXPORT_TESTFILES: + def test_load_match(self): + + perf_match, alignment, score_match = load_match( + filename=MOZART_VARIATION_FILES["match"], + create_score=True, + first_note_at_zero=True, + ) + + pna_match = perf_match.note_array() + sna_match = score_match.note_array() + + perf_midi = load_performance( + filename=MOZART_VARIATION_FILES["midi"], + first_note_at_zero=True, + ) + + pna_midi = perf_midi.note_array() + score_musicxml = load_score( + filename=MOZART_VARIATION_FILES["musicxml"], + ) + + sna_musicxml = score_musicxml.note_array() + + for note in alignment: + + # check score info in match and MusicXML + if "score_id" in note: + + idx_smatch = np.where(sna_match["id"] == note["score_id"])[0] + idx_sxml = np.where(sna_musicxml["id"] == note["score_id"])[0] + + self.assertTrue( + sna_match[idx_smatch]["pitch"] == sna_musicxml[idx_sxml]["pitch"] + ) + + self.assertTrue( + np.isclose( + sna_match[idx_smatch]["onset_beat"], + sna_match[idx_sxml]["onset_beat"], + ) + ) + + self.assertTrue( + np.isclose( + sna_match[idx_smatch]["duration_beat"], + sna_match[idx_sxml]["duration_beat"], + ) + ) + + # check performance info in match and MIDI + if "performance_id" in note: + + idx_pmatch = np.where(pna_match["id"] == note["performance_id"])[0] + idx_pmidi = np.where(pna_midi["id"] == note["performance_id"])[0] + + self.assertTrue( + pna_match[idx_pmatch]["pitch"] == pna_midi[idx_pmidi]["pitch"] + ) + + self.assertTrue( + np.isclose( + pna_match[idx_pmatch]["onset_sec"], + pna_match[idx_pmidi]["onset_sec"], + ) + ) - # # parse match file - # ppart, alignment, spart = load_match(fn, create_part=True) - # self.assertTrue(1, 1) + self.assertTrue( + np.isclose( + pna_match[idx_pmatch]["duration_sec"], + pna_match[idx_pmidi]["duration_sec"], + ) + ) + if __name__ == "__main__": diff --git a/tests/test_mei.py b/tests/test_mei.py index 0fd58b33..ae897083 100644 --- a/tests/test_mei.py +++ b/tests/test_mei.py @@ -1,5 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ -This file contains test functions for MEI export +This module contains test functions for MEI export """ import unittest diff --git a/tests/test_merge_parts.py b/tests/test_merge_parts.py index 70e486c8..5496e697 100644 --- a/tests/test_merge_parts.py +++ b/tests/test_merge_parts.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the utilities for merging parts. +""" import numpy as np import logging import unittest diff --git a/tests/test_metrical_position.py b/tests/test_metrical_position.py index bdc6388e..4b4aecf2 100644 --- a/tests/test_metrical_position.py +++ b/tests/test_metrical_position.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for the metrical position computation - +This module contains test functions for the metrical position computation """ import unittest diff --git a/tests/test_midi_export.py b/tests/test_midi_export.py index 1b088058..077f6f8a 100644 --- a/tests/test_midi_export.py +++ b/tests/test_midi_export.py @@ -1,5 +1,8 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +""" +This module cotains tests for exporting MIDI file methods. +""" import logging from collections import defaultdict, Counter, OrderedDict import unittest diff --git a/tests/test_midi_import.py b/tests/test_midi_import.py index a4de9dff..cf31f169 100644 --- a/tests/test_midi_import.py +++ b/tests/test_midi_import.py @@ -1,5 +1,8 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +""" +This module contains tests for importing MIDI files. +""" import logging from collections import defaultdict, Counter from operator import itemgetter diff --git a/tests/test_nakamura.py b/tests/test_nakamura.py index 998c0a28..cf6d9753 100644 --- a/tests/test_nakamura.py +++ b/tests/test_nakamura.py @@ -1,8 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for the import of Nakamura et al.'s match and +This module contains test functions for the import of Nakamura et al.'s match and corresp file formats. - """ import unittest diff --git a/tests/test_new_divs.py b/tests/test_new_divs.py index 6456ba1d..b0f828ee 100644 --- a/tests/test_new_divs.py +++ b/tests/test_new_divs.py @@ -1,5 +1,8 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +""" +This module contains tests for methods for handling divisions and time signatures. +""" import logging import unittest diff --git a/tests/test_note_array.py b/tests/test_note_array.py index c66fc336..c96c9ce8 100644 --- a/tests/test_note_array.py +++ b/tests/test_note_array.py @@ -1,7 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ This module contains the test cases for testing the note_array attribute of the Part class. - """ import unittest diff --git a/tests/test_note_features.py b/tests/test_note_features.py index b242563f..93004fa3 100644 --- a/tests/test_note_features.py +++ b/tests/test_note_features.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for methods for generating note-level features. +""" import unittest from tests import ( METRICAL_POSITION_TESTFILES, diff --git a/tests/test_parangonada.py b/tests/test_parangonada.py index 6c32ad92..b8f620e4 100644 --- a/tests/test_parangonada.py +++ b/tests/test_parangonada.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for MusicXML import and export. - +This module contains test functions for Parangonada import and export. """ import logging diff --git a/tests/test_part_properties.py b/tests/test_part_properties.py index 6c774704..af3854bf 100644 --- a/tests/test_part_properties.py +++ b/tests/test_part_properties.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for part properties. +""" import unittest import partitura from partitura import score diff --git a/tests/test_performance.py b/tests/test_performance.py index f53609c0..2c864348 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for the `performance` module - +This module contains test functions for the `performance` module """ import unittest import numpy as np diff --git a/tests/test_performance_codec.py b/tests/test_performance_codec.py index 6c932237..6a23d373 100644 --- a/tests/test_performance_codec.py +++ b/tests/test_performance_codec.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for Performance Array Calculations - +This module contains test functions for Performance Array Calculations """ import unittest import numpy as np diff --git a/tests/test_pianoroll.py b/tests/test_pianoroll.py index 23886e77..33361a34 100644 --- a/tests/test_pianoroll.py +++ b/tests/test_pianoroll.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for methods for computing piano rolls. +""" import numpy as np import logging import unittest diff --git a/tests/test_pitch_spelling.py b/tests/test_pitch_spelling.py index c8e34dd3..b509b3b9 100644 --- a/tests/test_pitch_spelling.py +++ b/tests/test_pitch_spelling.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the pitch spelling algorithms. +""" import numpy as np import unittest diff --git a/tests/test_quarter_adjust.py b/tests/test_quarter_adjust.py index 49217daf..aa89789c 100644 --- a/tests/test_quarter_adjust.py +++ b/tests/test_quarter_adjust.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for adjusting quarter durations +""" import logging import unittest diff --git a/tests/test_rest_array.py b/tests/test_rest_array.py index d498f720..2bff16be 100644 --- a/tests/test_rest_array.py +++ b/tests/test_rest_array.py @@ -1,7 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ This module contains the test cases for testing the note_array attribute of the Part class. - """ import unittest diff --git a/tests/test_synth.py b/tests/test_synth.py index ace725ee..fb676ed3 100644 --- a/tests/test_synth.py +++ b/tests/test_synth.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the synthesis methods. +""" import unittest import numpy as np @@ -6,6 +11,7 @@ from partitura.utils.synth import ( midi_pitch_to_natural_frequency, + midi_pitch_to_tempered_frequency, exp_in_exp_out, lin_in_lin_out, additive_synthesis, @@ -16,9 +22,26 @@ from partitura import save_wav +from tests import WAV_TESTFILES + RNG = np.random.RandomState(1984) -from tests import WAV_TESTFILES + +class TestMidiPitchToTemperedFrequency(unittest.TestCase): + def test_octaves(self): + # all As + midi_pitch = np.arange(6) + 10 + freq_ratios = [1.1, 1.2, 1.3, 1.44, 1.5, 1.55] + # compute frequencies + frequency = midi_pitch_to_tempered_frequency( + midi_pitch, + reference_midi_pitch=10, + reference_frequency=100, + interval_ratios=freq_ratios, + ) + freq_ratios_new = frequency / 100 + # make test + self.assertTrue(np.allclose(freq_ratios, freq_ratios_new)) class TestMidiPitchToNaturalFrequency(unittest.TestCase): @@ -158,6 +181,30 @@ def test_export(self): self.assertTrue(sr_rec == sr) self.assertTrue(len(rec_audio) == len(original_audio)) + self.assertTrue( + np.allclose( + rec_audio / rec_audio.max(), + original_audio / original_audio.max(), + atol=1e-4, + ) + ) + + def test_errors(self): + + # wrong envelope + try: + audio_signal = synthesize( + note_info=self.score, + samplerate=8000, + envelope_fun="wrong keyword", + tuning="equal_temperament", + bpm=60, + ) + # This test should fail + self.assertTrue(False) + except ValueError: + self.assertTrue(True) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_time_estimation.py b/tests/test_time_estimation.py index 52121bcd..d04cd44f 100644 --- a/tests/test_time_estimation.py +++ b/tests/test_time_estimation.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the methods for estimating metrical information. +""" import numpy as np import unittest @@ -71,4 +76,4 @@ def testtempo(self): ) - \ No newline at end of file + diff --git a/tests/test_times.py b/tests/test_times.py index d1ac1371..2c25b130 100644 --- a/tests/test_times.py +++ b/tests/test_times.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for testing conversions from beats and quarters. +""" import unittest import partitura.score as score diff --git a/tests/test_tonal_tension.py b/tests/test_tonal_tension.py index 18c15843..b7a6f970 100644 --- a/tests/test_tonal_tension.py +++ b/tests/test_tonal_tension.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the methods computing tonal tension. +""" import unittest from partitura import ( diff --git a/tests/test_utils.py b/tests/test_utils.py index 44185eeb..5af2bf01 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the utility methods. +""" import unittest import partitura import numpy as np from partitura.utils import music -from tests import MATCH_IMPORT_EXPORT_TESTFILES, VOSA_TESTFILES +from tests import MATCH_IMPORT_EXPORT_TESTFILES, VOSA_TESTFILES, MOZART_VARIATION_FILES + RNG = np.random.RandomState(1984) @@ -127,3 +133,229 @@ def test_performance_from_part(self): # check that that the performance corresponds to the expected tempo self.assertTrue(np.allclose(60 / beat_period, bpm)) + + def get_tempo_curve(self, score_onsets, performance_onsets): + """ + Get tempo curve + """ + unique_sonsets = np.unique(score_onsets) + # Ensure that everything is sorted (I'm just paranoid ;) + unique_sonsets.sort() + unique_ponsets = np.unique(performance_onsets) + # Ensure that everything is sorted + unique_ponsets.sort() + + bp = np.diff(unique_ponsets) / np.diff(unique_sonsets) + + # Beats per minute for each of the unique onsets + # the last bpm is just assuming that the tempo remains + # constant after the last onset. + bpm = np.r_[60 / bp, 60 / bp[-1]] + + return bpm + + def test_performance_notearray_from_score_notearray_bpm(self): + """ + Test possibilities for bpm argument in + utils.music.performance_notearray_from_score_notearray + """ + score = partitura.load_score(MOZART_VARIATION_FILES["musicxml"]) + + score_note_array = score.note_array() + + unique_onsets = np.unique(score_note_array["onset_beat"]) + unique_onsets.sort() + # Test constant tempo + bpm = 30 + velocity = 65 + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm, + velocity=velocity, + ) + + self.assertTrue( + np.allclose( + self.get_tempo_curve( + score_note_array["onset_beat"], + perf_note_array["onset_sec"], + ), + bpm, + ) + ) + + # Test callable tempo + def bpm_fun(onset): + """ + Test function the first half of the piece will be played + twice as fast + """ + if isinstance(onset, (int, float)): + onset = np.array([onset]) + + bpm = np.zeros(len(onset), dtype=float) + + midpoint = (unique_onsets.max() - unique_onsets.min()) / 2 + bpm[np.where(onset <= midpoint)[0]] = 120 + bpm[np.where(onset > midpoint)[0]] = 60 + + return bpm + + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm_fun, + velocity=velocity, + ) + + bpm = self.get_tempo_curve( + score_note_array["onset_beat"], + perf_note_array["onset_sec"], + ) + + midpoint = (unique_onsets.max() - unique_onsets.min()) / 2 + + self.assertTrue( + np.allclose( + bpm[np.where(unique_onsets <= midpoint)[0]], + 120, + ) + ) + + self.assertTrue(np.allclose(bpm[np.where(unique_onsets > midpoint)[0]], 60)) + + # Test tempo as an array + bpm_expected = 40 * RNG.rand(len(unique_onsets)) + 30 + + # Test using 1d array + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=np.column_stack((unique_onsets, bpm_expected)), + velocity=velocity, + ) + + bpm_predicted = self.get_tempo_curve( + score_note_array["onset_beat"], + perf_note_array["onset_sec"], + ) + + # do not consider the last element, since get_tempo_curve only computes + # the tempo up to the last onset (otherwise offsets need to be considered) + self.assertTrue(np.allclose(bpm_expected[:-1], bpm_predicted[:-1], atol=1e-3)) + + try: + # This should trigger an error because bpm_expected is a 1D array + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm_expected, + velocity=velocity, + ) + self.assertTrue(False) + + except ValueError: + # We are expecting the previous code to trigger an error + self.assertTrue(True) + + def get_velocity_curves(self, velocity, score_onsets): + """ + Get velocity curve by aggregating MIDI velocity values for + each onset + """ + unique_onsets = np.unique(score_onsets) + # Ensure that everything is sorted (I'm just paranoid ;) + unique_onsets.sort() + + unique_onset_idxs = [np.where(score_onsets == uo)[0] for uo in unique_onsets] + velocity_curve = np.array([velocity[ui].mean() for ui in unique_onset_idxs]) + + return velocity_curve + + def test_performance_notearray_from_score_notearray_velocity(self): + """ + Test velocity arguments in + utils.music.performance_notearray_from_score_notearray + """ + score = partitura.load_score(MOZART_VARIATION_FILES["musicxml"]) + + score_note_array = score.note_array() + + unique_onsets = np.unique(score_note_array["onset_beat"]) + unique_onsets.sort() + + # Test constant velocity + bpm = 120 + velocity = 65 + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm, + velocity=velocity, + ) + + self.assertTrue(all(perf_note_array["velocity"] == velocity)) + + # Test callable velocity + def vel_fun(onset): + """ + Test function the first half of the piece will be played + twice as loud + """ + if isinstance(onset, (int, float)): + onset = np.array([onset]) + + vel = np.zeros(len(onset), dtype=float) + + midpoint = (unique_onsets.max() - unique_onsets.min()) / 2 + vel[np.where(onset <= midpoint)[0]] = 120 + vel[np.where(onset > midpoint)[0]] = 60 + + return vel + + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm, + velocity=vel_fun, + ) + + vel = self.get_velocity_curves( + perf_note_array["velocity"], score_note_array["onset_beat"] + ) + + midpoint = (unique_onsets.max() - unique_onsets.min()) / 2 + + self.assertTrue( + np.allclose( + vel[np.where(unique_onsets <= midpoint)[0]], + 120, + ) + ) + + self.assertTrue(np.allclose(vel[np.where(unique_onsets > midpoint)[0]], 60)) + + # Test tempo as an array + vel_expected = np.round(40 * RNG.rand(len(unique_onsets)) + 30) + + # Test using 1d array + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + velocity=np.column_stack((unique_onsets, vel_expected)), + bpm=bpm, + ) + + vel_predicted = self.get_velocity_curves( + perf_note_array["velocity"], + score_note_array["onset_beat"], + ) + + self.assertTrue(np.allclose(vel_expected, vel_predicted, atol=1e-3)) + + try: + # This should trigger an error because vel_expected is a 1D array + perf_note_array = music.performance_notearray_from_score_notearray( + snote_array=score_note_array, + bpm=bpm, + velocity=vel_expected, + ) + self.assertTrue(False) + + except ValueError: + # We are expecting the previous code to trigger an error + self.assertTrue(True) diff --git a/tests/test_voice_estimation.py b/tests/test_voice_estimation.py index 0128f0d5..5c8716eb 100644 --- a/tests/test_voice_estimation.py +++ b/tests/test_voice_estimation.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module contains tests for the voice estimation methods. +""" import numpy as np import unittest diff --git a/tests/test_xml.py b/tests/test_xml.py index 1df7cf47..8323fb54 100755 --- a/tests/test_xml.py +++ b/tests/test_xml.py @@ -1,7 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ - -This file contains test functions for MusicXML import and export. - +This module contains test functions for MusicXML import and export. """ import logging