├── docs ├── statistics.md ├── figs │ ├── musegan_logo.png │ ├── pianoroll-example.png │ └── pianoroll-example-5tracks.png ├── _config.yml ├── _includes │ ├── audio_player.html │ ├── icon_link.html │ └── video_player.html ├── comparisons.md ├── representation.md ├── index.md ├── labels.md ├── _layouts │ └── default.html ├── dataset.md └── _sass │ └── jekyll-theme-minimal.scss ├── .github └── FUNDING.yml ├── src ├── pypianoroll │ ├── version.py │ ├── __init__.py │ ├── utilities.py │ ├── track.py │ ├── plot.py │ └── multitrack.py ├── config.py ├── utils.py ├── npz_to_audio.py ├── matcher.py ├── README.md ├── binarizer.py ├── collector.py ├── derive_id_lists_tagtraum.py ├── derive_id_lists_amg.py ├── cleanser.py ├── constants.py ├── derive_labels_amg.py ├── derive_id_lists_lastfm.py ├── merger_5.py ├── merger_17.py └── converter.py ├── data └── labels.tar.gz ├── scripts ├── download_labels.sh ├── download_lmd.sh ├── download_tagtraum.sh ├── download_amg.sh ├── batch_synthesize.sh ├── download_lastfm.sh ├── derive_labels.sh ├── derive_lpd.sh └── synthesize.sh ├── LICENSE ├── README.md └── .gitignore /docs/statistics.md: -------------------------------------------------------------------------------- 1 | # Statistics 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: salu133445 2 | -------------------------------------------------------------------------------- /src/pypianoroll/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | -------------------------------------------------------------------------------- /data/labels.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salu133445/lakh-pianoroll-dataset/HEAD/data/labels.tar.gz -------------------------------------------------------------------------------- /docs/figs/musegan_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salu133445/lakh-pianoroll-dataset/HEAD/docs/figs/musegan_logo.png -------------------------------------------------------------------------------- /docs/figs/pianoroll-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salu133445/lakh-pianoroll-dataset/HEAD/docs/figs/pianoroll-example.png -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | title: Lakh Pianoroll Dataset 3 | description: A collection of 174,154 multitrack pianorolls 4 | -------------------------------------------------------------------------------- /docs/figs/pianoroll-example-5tracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salu133445/lakh-pianoroll-dataset/HEAD/docs/figs/pianoroll-example-5tracks.png -------------------------------------------------------------------------------- /docs/_includes/audio_player.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_includes/icon_link.html: -------------------------------------------------------------------------------- 1 |  {{ include.text }} -------------------------------------------------------------------------------- /docs/_includes/video_player.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | """Define configuration variables 2 | """ 3 | CONFIG = { 4 | 'multicore': 40, # the number of cores to use (1 to disable multiprocessing) 5 | 'beat_resolution': 24, # temporal resolution (in time step per beat) 6 | 'time_signatures': ['4/4'] # '3/4', '2/4' 7 | } 8 | -------------------------------------------------------------------------------- /scripts/download_labels.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script downloads the labels for Million Song Dataset (MSD) from Last.fm 3 | # Dataset, Million Song Dataset Benchmarks and Tagtraum genre annotations. 4 | # Usage: download_all.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | "${DIR}/download_lastfm.sh" 7 | "${DIR}/download_amg.sh" 8 | "${DIR}/download_tagtraum.sh" 9 | -------------------------------------------------------------------------------- /scripts/download_lmd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script downloads the Lakh MIDI Dataset (LMD; 3 | # see https://colinraffel.com/projects/lmd/). 4 | # Usage: download_lmd.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | DST="${DIR}/../data/lmd/" 7 | mkdir -p "$DST" 8 | wget -P "$DST" "http://hog.ee.columbia.edu/craffel/lmd/lmd_full.tar.gz" 9 | wget -P "$DST" "http://hog.ee.columbia.edu/craffel/lmd/match_scores.json" 10 | tar zxf "$DST/lmd_full.tar.gz" -C "$DST" 11 | -------------------------------------------------------------------------------- /scripts/download_tagtraum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script downloads the labels for Million Song Dataset (MSD) from Tagtraum 3 | # genre annotations (see http://www.tagtraum.com/msd_genre_datasets.html). 4 | # Usage: download_tagtraum.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | DST="${DIR}/../data/tagtraum/" 7 | mkdir -p "$DST" 8 | wget -P "$DST" "http://www.tagtraum.com/genres/msd_tagtraum_cd1.cls.zip" 9 | wget -P "$DST" "http://www.tagtraum.com/genres/msd_tagtraum_cd2.cls.zip" 10 | wget -P "$DST" "http://www.tagtraum.com/genres/msd_tagtraum_cd2c.cls.zip" 11 | unzip "$DST/*.zip" -d "$DST" 12 | -------------------------------------------------------------------------------- /scripts/download_amg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script downloads the labels for Million Song Dataset (MSD) from Million 3 | # Song Dataset Benchmarks (see http://www.ifs.tuwien.ac.at/mir/msd/). 4 | # Usage: download_amg.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | DST="${DIR}/../data/amg/" 7 | mkdir -p "$DST" 8 | wget -P "$DST" "http://www.ifs.tuwien.ac.at/mir/msd/partitions/msd-MAGD-genreAssignment.cls" 9 | wget -P "$DST" "http://www.ifs.tuwien.ac.at/mir/msd/partitions/msd-topMAGD-genreAssignment.cls" 10 | wget -P "$DST" "http://www.ifs.tuwien.ac.at/mir/msd/partitions/msd-MASD-styleAssignment.cls" 11 | -------------------------------------------------------------------------------- /scripts/batch_synthesize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script synthesizes in parallel a batch of multitrack pianorolls (.npz) in 3 | # a directory to mp3 files. 4 | # Usage: 5 | # batch_synthesize.sh [-b bit_rate] [-f sampling_rate] [-k] [-t tempo] [-y] \ 6 | # [dst] [src] [n_jobs] 7 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 8 | 9 | task(){ 10 | relpath="${1#$3}" 11 | "$4/synthesize.sh" "${@:4}" "$2/${relpath%.npz}.mp3" "$1" 12 | } 13 | 14 | N_JOBS="${@: -1}" 15 | SRC="${@: -2:1}" 16 | DST="${@: -3:1}" 17 | 18 | export -f task 19 | find "$SRC" -maxdepth 99 -type f -name *.npz | parallel -j "$N_JOBS" task {} \ 20 | "$DST" "$SRC" "$DIR" "${@:1:$#-3}" 21 | -------------------------------------------------------------------------------- /scripts/download_lastfm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script downloads the labels for Million Song Dataset (MSD) from Last.fm 3 | # Dataset (see https://labrosa.ee.columbia.edu/millionsong/lastfm). 4 | # Usage: download_lastfm.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | DST="${DIR}/../data/lastfm/" 7 | mkdir -p "$DST" 8 | wget -P "$DST" "http://labrosa.ee.columbia.edu/millionsong/sites/default/files/lastfm/lastfm_train.zip" 9 | wget -P "$DST" "http://labrosa.ee.columbia.edu/millionsong/sites/default/files/lastfm/lastfm_test.zip" 10 | wget -P "$DST" "http://labrosa.ee.columbia.edu/millionsong/sites/default/files/lastfm/lastfm_unique_tags.txt" 11 | unzip "$DST/*.zip" -d "$DST" 12 | -------------------------------------------------------------------------------- /src/pypianoroll/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pypianoroll 3 | =========== 4 | A python package for handling multi-track piano-rolls. 5 | 6 | Features 7 | -------- 8 | - handle piano-rolls of multiple tracks with metadata 9 | - utilities for manipulating piano-rolls 10 | - save to and load from .npz files using efficient sparse matrix format 11 | - parse from and write to MIDI files 12 | 13 | """ 14 | from pypianoroll.version import __version__ 15 | from pypianoroll.track import Track 16 | from pypianoroll.multitrack import Multitrack 17 | from pypianoroll.plot import plot_pianoroll, save_animation 18 | from pypianoroll.utilities import ( 19 | is_pianoroll, assign_constant, binarize, clip, copy, load, pad, 20 | pad_to_multiple, pad_to_same, parse, plot, save, transpose, 21 | trim_trailing_silence, write) 22 | -------------------------------------------------------------------------------- /docs/comparisons.md: -------------------------------------------------------------------------------- 1 | # Comparisons 2 | 3 | ## Comparison of different subsets 4 | 5 | | subset | size | matched to MSD | one entry per song | 6 | |:--------:|:-------:|:--------------:|:------------------:| 7 | | full | 174,154 | X | X | 8 | | matched | 115,160 | O | X | 9 | | cleansed | 21,425 | O | O | 10 | 11 | ## Comparison of different versions 12 | 13 | | version | number of tracks | tracks | 14 | |:-------:|:----------------:|:-----------------------------------:| 15 | | LPD | - | - | 16 | | LPD-5 | 5 | Drums, Piano, Guitar, Bass, Strings | 17 | | LPD-17 | 17 | Drums, Piano, Chromatic Percussion, Organ, Guitar,
Bass, Strings, Ensemble, Brass, Reed, Pipe, Synth Lead,
Synth Pad, Synth Effects, Ethnic, Percussive, Sound Effects | 18 | -------------------------------------------------------------------------------- /scripts/derive_labels.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script derives the ID lists for different labels in the Last.fm Dataset, 3 | # Million Song Dataset Benchmarks and Tagtraum genre annotations. 4 | # Usage: derive_id_lists.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | SRC_DIR="$DIR/../src/" 7 | DATA_DIR="$DIR/../data/" 8 | RESULT_DIR="$DATA_DIR/labels/" 9 | LPD_ROOT="$DATA_DIR/lpd/" 10 | 11 | python "$SRC_DIR/derive_id_lists_lastfm.py" "$RESULT_DIR/lastfm/" \ 12 | "$DATA_DIR/lastfm/" "$LPD_ROOT/cleansed_ids.txt" 13 | python "$SRC_DIR/derive_id_lists_amg.py" "$RESULT_DIR/amg/" \ 14 | "$DATA_DIR/amg/msd-topMAGD-genreAssignment.cls" "$LPD_ROOT/cleansed_ids.txt" 15 | python "$SRC_DIR/derive_id_lists_tagtraum.py" "$RESULT_DIR/tagtraum/" \ 16 | "$DATA_DIR/tagtraum/msd_tagtraum_cd2c.cls" "$LPD_ROOT/cleansed_ids.txt" 17 | python "$SRC_DIR/derive_labels_amg.py" "$RESULT_DIR/amg/" \ 18 | "$DATA_DIR/amg/msd-MASD-styleAssignment.cls" "$LPD_ROOT/cleansed_ids.txt" 19 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | """This file defines some handy utility functions. 2 | """ 3 | import os 4 | import errno 5 | 6 | def make_sure_path_exists(path): 7 | """Create intermidate directories if the path does not exist.""" 8 | try: 9 | os.makedirs(path) 10 | except OSError as exception: 11 | if exception.errno != errno.EEXIST: 12 | raise 13 | 14 | def msd_id_to_dirs(msd_id): 15 | """Given an MSD ID, generate the path prefix. 16 | E.g. TRABCD12345678 -> A/B/C/TRABCD12345678""" 17 | return os.path.join(msd_id[2], msd_id[3], msd_id[4], msd_id) 18 | 19 | def change_prefix(path, src, dst): 20 | """Return the path with its prefix changed from `src` to `dst`.""" 21 | return os.path.join(dst, os.path.relpath(path, src)) 22 | 23 | def findall_endswith(postfix, root): 24 | """Traverse `root` recursively and yield all files ending with `postfix`.""" 25 | for dirpath, _, filenames in os.walk(root): 26 | for filename in filenames: 27 | if filename.endswith(postfix): 28 | yield os.path.join(dirpath, filename) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hao-Wen Dong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/npz_to_audio.py: -------------------------------------------------------------------------------- 1 | """This file synthesizes a multitrack pianoroll (.npz) to a .wav file.""" 2 | import os.path 3 | import argparse 4 | import scipy.io.wavfile 5 | import pypianoroll 6 | from utils import make_sure_path_exists 7 | 8 | def parse_args(): 9 | """Parse and return the command line arguments.""" 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('dst', help="path to save the synthesized audio") 12 | parser.add_argument('src', 13 | help="path to the source pianoroll file (in .npz)") 14 | parser.add_argument('--fs', type=int, default=44100, 15 | help="sampling rate in Hz (default to 44100)") 16 | parser.add_argument('--tempo', type=float, 17 | help="tempo in bpm (default to follow the MIDI file)") 18 | args = parser.parse_args() 19 | return args.dst, args.src, args.fs, args.tempo 20 | 21 | def main(): 22 | """Main function.""" 23 | dst, src, fs, tempo = parse_args() 24 | make_sure_path_exists(os.path.dirname(dst)) 25 | multitrack = pypianoroll.Multitrack(src) 26 | pm = multitrack.to_pretty_midi(tempo) 27 | waveform = pm.fluidsynth() 28 | scipy.io.wavfile.write(dst, fs, waveform) 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /src/matcher.py: -------------------------------------------------------------------------------- 1 | """This script writes the IDs of songs that have been matched to Million Song 2 | Dataset (MSD) to a file. 3 | """ 4 | import argparse 5 | import os.path 6 | import json 7 | 8 | def parse_args(): 9 | """Return the parsed command line arguments.""" 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('filepath', help="path to save the resulting list") 12 | parser.add_argument('src', help="root path to the source dataset") 13 | parser.add_argument('match_scores_path', 14 | help="path to the match scores file") 15 | args = parser.parse_args() 16 | return args.filepath, args.src, args.match_scores_path 17 | 18 | def main(): 19 | """Main function.""" 20 | filepath, src, match_scores_path = parse_args() 21 | 22 | with open(match_scores_path) as f: 23 | match_score_dict = json.load(f) 24 | 25 | with open(filepath, 'w') as f: 26 | for msd_id in match_score_dict: 27 | for midi_md5 in match_score_dict[msd_id]: 28 | npz_path = os.path.join(src, midi_md5[0], midi_md5 + '.npz') 29 | if os.path.isfile(npz_path): 30 | f.write("{} {}\n".format(midi_md5, msd_id)) 31 | 32 | print("Matched ID list successfully saved.") 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Python code 2 | 3 | - `config.py`: define configuration variables 4 | - `utils.py`: define some handy utility functions 5 | 6 | ## Derive Lakh Pianoroll Dataset (LPD) from Lakh MIDI Dataset (LMD) 7 | 8 | - `converter.py`: derive _LPD-full_ from _LMD-full_ 9 | - `merger_5.py`: derive _LPD-5-full_ from _LPD-full_ 10 | - `merger_17.py`: derive _LPD-17-full_ from _LPD-full_ 11 | - `matcher.py`: derive `matched_ids.txt` 12 | - `cleanser.py`: derive `cleansed_ids.txt` 13 | - `collector.py`: create different subsets of _LPD-full_ with the matched and 14 | the cleansed ID lists 15 | 16 | ## Derive the labels for the LPD 17 | 18 | - `derive_id_lists_lastfm.py`: derive the ID lists for different labels in the 19 | Last.fm Dataset 20 | - `derive_id_lists_amg.py`: derive the ID lists for different labels in the MSD 21 | AllMusic Top Genre Dataset (TopMAGD) provided in the Million Song Dataset 22 | Benchmarks 23 | - `derive_labels_amg.py`: derive the labels in the MSD AllMusic Style Dataset 24 | (MASD) provided in the Million Song Dataset Benchmarks 25 | - `derive_id_lists_tagtraum.py`: derive the ID lists for different labels in 26 | the Tagtraum genre annotations 27 | 28 | ## Synthesize audio files for the LPD 29 | 30 | - `npz_to_audio.py`: synthesize a multitrack pianoroll (.npz) to a .wav file 31 | -------------------------------------------------------------------------------- /scripts/derive_lpd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script derives the Lakh Pianoroll Dataset (LPD) from the Lakh MIDI 3 | # Dataset (LMD). 4 | # Usage: run.sh 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 6 | SRC_DIR="$DIR/../src/" 7 | 8 | # Define the root directories of the LMD dataset and the resulting LPD dataset 9 | LMD_ROOT="$DIR/../data/lmd/" 10 | LPD_ROOT="$DIR/../data/lpd/" 11 | 12 | # Convert MIDI files to multitrack pianorolls 13 | python "$SRC_DIR/converter.py" "$LMD_ROOT/lmd_full/" "$LPD_ROOT/lpd/lpd_full/" \ 14 | --midi-info-path "$LPD_ROOT/midi_info.json" 15 | 16 | # Merge multitrack pianorolls to 5-track or 17-track pianorolls 17 | python "$SRC_DIR/merger_5.py" "$LPD_ROOT/lpd/lpd_full/" \ 18 | "$LPD_ROOT/lpd_5/lpd_5_full/" 19 | python "$SRC_DIR/merger_17.py" "$LPD_ROOT/lpd/lpd_full/" \ 20 | "$LPD_ROOT/lpd_17/lpd_17_full/" 21 | 22 | # Find out the ids of songs matched to MSD (according to LMD-matched) 23 | python "$SRC_DIR/matcher.py" "$LPD_ROOT/matched_ids.txt" \ 24 | "$LPD_ROOT/lpd/lpd_full/" "$LMD_ROOT/match_scores.json" 25 | 26 | # Find out the ids of songs to be collected into the cleansed dataset 27 | python "$SRC_DIR/cleanser.py" "$LPD_ROOT/cleansed_ids.txt" \ 28 | "$LPD_ROOT/lpd/lpd_full/" "$LMD_ROOT/match_scores.json" \ 29 | "$LPD_ROOT/midi_info.json" 30 | 31 | # Collect corresponding songs to different collecteions 32 | for version in "lpd" "lpd_5" "lpd_17"; do 33 | for subset in "matched" "cleansed"; do 34 | python "$SRC_DIR/collector.py" "$LPD_ROOT/$version/${version}_full/" \ 35 | "$LPD_ROOT/$version/${version}_$subset/" "$LPD_ROOT/${subset}_ids.txt" 36 | done 37 | done 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source Code for Deriving Lakh Pianoroll Dataset (LPD) 2 | 3 | > The derived dataset using the default settings is available 4 | [here](https://salu133445.github.io/lakh-pianoroll-dataset/dataset). 5 | 6 | 1. Download Lakh MIDI Dataset (LMD) with the following script. 7 | 8 | ```sh 9 | ./scripts/download_lmd.sh 10 | ``` 11 | 12 | (Or, download it manually [here](http://colinraffel.com/projects/lmd/).) 13 | 2. Set the variables `LMD_ROOT` and `LPD_ROOT` in `run.sh` and variables in 14 | `config.py` to proper values. 15 | 3. Derive all subsets and versions of LPD, `matched_ids.txt` and 16 | `cleansed_ids.txt` with the following script. 17 | 18 | ```sh 19 | ./scripts/derive_lpd.sh 20 | ``` 21 | 22 | ## Derive the labels for the LPD 23 | 24 | > The derived labels can be found at `data/labels.tar.gz`. 25 | 26 | 1. Download the labels with the following script. 27 | 28 | ```sh 29 | ./scripts/download_labels.sh 30 | ``` 31 | 32 | 2. Derive the labels with the following script. 33 | 34 | ```sh 35 | ./scripts/derive_labels.sh 36 | ``` 37 | 38 | ## Synthesize audio files for the LPD 39 | 40 | 1. Install [GNU Parallel](https://www.gnu.org/software/parallel/) to run the 41 | synthesizer in parallel mode. 42 | 2. Synthesize audio files from multitrack pianorolls with the following script. 43 | 44 | ```sh 45 | ./scripts/batch_synthesize.sh ./data/lpd/lpd/lpd_cleansed/ \ 46 | ./data/synthesized/lpd_cleansed 20 47 | ``` 48 | 49 | (The above command will synthesize all the multitrack pianorolls in 50 | the _LPD-cleansed_ subset with 20 parallel jobs.) 51 | -------------------------------------------------------------------------------- /src/binarizer.py: -------------------------------------------------------------------------------- 1 | """This script binarizes a collection of multitrack pianorolls. 2 | """ 3 | import os.path 4 | import argparse 5 | from pypianoroll import Multitrack 6 | from utils import make_sure_path_exists, change_prefix, findall_endswith 7 | from config import CONFIG 8 | if CONFIG['multicore'] > 1: 9 | import joblib 10 | 11 | def parse_args(): 12 | """Return the parsed command line arguments.""" 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument('src', help="root path to the source dataset") 15 | parser.add_argument('dst', help="root path to the destination dataset") 16 | args = parser.parse_args() 17 | return args.src, args.dst 18 | 19 | def binarizer(filepath, src, dst): 20 | """Load and binarize a multitrack pianoroll and save the resulting 21 | multitrack pianoroll to the destination directory.""" 22 | # Load and binarize the multitrack pianoroll 23 | multitrack = Multitrack(filepath) 24 | multitrack.binarize() 25 | 26 | # Save the binarized multitrack pianoroll 27 | result_path = change_prefix(filepath, src, dst) 28 | make_sure_path_exists(os.path.dirname(result_path)) 29 | multitrack.save(result_path) 30 | 31 | def main(): 32 | """Main function.""" 33 | src, dst = parse_args() 34 | make_sure_path_exists(dst) 35 | 36 | if CONFIG['multicore'] > 1: 37 | joblib.Parallel(n_jobs=CONFIG['multicore'], verbose=5)( 38 | joblib.delayed(binarizer)(npz_path, src, dst) 39 | for npz_path in findall_endswith('.npz', src)) 40 | else: 41 | for npz_path in findall_endswith('.npz', src): 42 | binarizer(npz_path, src, dst) 43 | 44 | print("Dataset successfully binarized.") 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # vscode 104 | .vscode/* 105 | 106 | # experiments 107 | .backup/ 108 | analysis/ 109 | data/ 110 | logs/ 111 | -------------------------------------------------------------------------------- /src/collector.py: -------------------------------------------------------------------------------- 1 | """This script creates a subset of a dataset according to an ID list. 2 | """ 3 | import argparse 4 | import os.path 5 | import shutil 6 | from utils import make_sure_path_exists, msd_id_to_dirs 7 | from config import CONFIG 8 | if CONFIG['multicore'] > 1: 9 | import joblib 10 | 11 | def parse_args(): 12 | """Return the parsed command line arguments.""" 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument('src', help="root path to the source dataset") 15 | parser.add_argument('dst', help="root path to the destination dataset") 16 | parser.add_argument('id_list_path', help="path to the ID list file") 17 | args = parser.parse_args() 18 | return args.src, args.dst, args.id_list_path 19 | 20 | def collector(midi_md5, msd_id, src, dst): 21 | """Copy a multitrack pianoroll to the destination directory.""" 22 | npz_path = os.path.join(src, midi_md5[0], midi_md5 + '.npz') 23 | result_path = os.path.join(dst, msd_id_to_dirs(msd_id), midi_md5 + '.npz') 24 | make_sure_path_exists(os.path.dirname(result_path)) 25 | shutil.copyfile(npz_path, result_path) 26 | 27 | def main(): 28 | """Main function.""" 29 | src, dst, id_list_path = parse_args() 30 | make_sure_path_exists(dst) 31 | 32 | with open(id_list_path) as f: 33 | id_list = [line.split() for line in f] 34 | 35 | if CONFIG['multicore'] > 1: 36 | joblib.Parallel(n_jobs=CONFIG['multicore'], verbose=5)( 37 | joblib.delayed(collector)(midi_md5, msd_id, src, dst) 38 | for midi_md5, msd_id in id_list) 39 | else: 40 | for midi_md5, msd_id in id_list: 41 | collector(midi_md5, msd_id, src, dst) 42 | 43 | print("Subset successfully collected for: {}".format(id_list_path)) 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /docs/representation.md: -------------------------------------------------------------------------------- 1 | # Representation 2 | 3 | ## Pianoroll 4 | 5 | Pianoroll is a music storing format which represents a music piece by a 6 | score-like matrix. The vertical and horizontal axes represent note pitch and 7 | time, respectively. The values represent the velocities of the notes. 8 | 9 | The time axis can either be in _absolute timing_ or in _symbolic timing_. For 10 | absolute timing, the actual timing of note occurrence is used. For symbolic 11 | timing, the tempo information is removed and thereby each beat has the same 12 | length. 13 | 14 | In LPD, we use symbolic timing and the temporal resolution is set to 24 per beat 15 | in order to cover common temporal patterns such as triplets and 32th notes. The 16 | note pitch has 128 possibilities, covering from C-1 to G9. For example, a bar in 17 | 4/4 time with only one track can be represented as a 96 x 128 matrix. 18 | 19 | > Note that during the conversion from MIDI files to pianorolls, an additional 20 | minimal-length (of one time step) pause is added between two consecutive 21 | (without a pause) notes of the same pitch to distinguish them from one single 22 | note. 23 | 24 | ![pianoroll-example](figs/pianoroll-example.png) 25 |

Example pianoroll

26 | 27 | ## Multitrack Pianoroll 28 | 29 | We represent a multitrack music piece with a _multitrack pianoroll_, which is a 30 | set of pianorolls where each pianoroll represents one specific track in the 31 | original music piece. That is, a _M_-track music piece will be converted into a 32 | set of _M_ pianorolls. For instance, a bar in 4/4 time with _M_ tracks can be 33 | represented as a 96 x 128 x _M_ tensor. 34 | 35 | pianoroll-example-5tracks 36 |

Example five-track pianorolls

37 | 38 | > The above pianoroll visualizations are produced using 39 | [Pypianoroll](https://salu133445.github.io/pypianoroll/). 40 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | The Lakh Pianoroll Dataset (LPD) is a collection of 174,154 2 | [multitrack pianorolls](representation) derived from the 3 | [Lakh MIDI Dataset](http://colinraffel.com/projects/lmd/) (LMD). 4 | 5 | ## Getting the dataset 6 | 7 | We provide multiple subsets and versions of the dataset (see 8 | [here](comparisons)). The dataset is available [here](dataset). 9 | 10 | ## Using LPD 11 | 12 | The multitrack pianorolls in LPD are stored in a special format for efficient 13 | I/O and to save space. We recommend to load the data with 14 | [Pypianoroll](https://salu133445.github.io/pypianoroll/) (The dataset is created 15 | using Pypianoroll v0.3.0.). See [here](https://salu133445.github.io/pypianoroll/save_load.html) 16 | to learn how the data is stored and how to load the data properly. 17 | 18 | ## License 19 | 20 | Lakh Pianoroll Dataset is a derivative of 21 | [Lakh MIDI Dataset](http://colinraffel.com/projects/lmd/) by 22 | [Colin Raffel](http://colinraffel.com), used under 23 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). 24 | Lakh Pianoroll Dataset is licensed under 25 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) by 26 | [Hao-Wen Dong](https://salu133445.github.io) and 27 | [Wen-Yi Hsiao](https://github.com/wayne391). 28 | 29 | Please cite the following papers if you use Lakh Pianoroll Dataset in a 30 | published work. 31 | 32 | - Hao-Wen Dong, Wen-Yi Hsiao, Li-Chia Yang, and Yi-Hsuan Yang, 33 | "__MuseGAN: Multi-track Sequential Generative Adversarial Networks for 34 | Symbolic Music Generation and Accompaniment__," 35 | in _Proceedings of the 32nd AAAI Conference on Artificial Intelligence_ 36 | (AAAI), 2018. 37 | 38 | - Colin Raffel, 39 | "__Learning-Based Methods for Comparing Sequences, with Applications to 40 | Audio-to-MIDI Alignment and Matching__," 41 | _PhD Thesis_, 2016. 42 | 43 | ## Related projects 44 | 45 | - [MuseGAN](https://salu133445.github.io/musegan/) 46 | - [LeadSheetGAN](https://liuhaumin.github.io/LeadsheetArrangement/) 47 | -------------------------------------------------------------------------------- /scripts/synthesize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script synthesizes a multitrack pianoroll (in .npz) to an mp3 file. 3 | # Usage: 4 | # synthesize.sh [-b bit_rate] [-f sampling_rate] [-k] [-t tempo] [-y] [dst] \ 5 | # [src] 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" 7 | SRC_DIR="$DIR/../src/" 8 | 9 | function usage { 10 | echo "Usage:" 11 | echo " synthesize.sh [-b bit_rate] [-f sampling_rate] [-k] [-t tempo] \\" 12 | echo " [dst] [src]" 13 | echo "Options:" 14 | echo " -b bit_rate bit rate in bit/sec (default to 192k)" 15 | echo " -f sampling_rate sampling rate in Hz (default to 44100)" 16 | echo " -h display help" 17 | echo " -k keep the intermediately-created .wav file" 18 | echo " -t tempo tempo in bpm (default to follow the MIDI file)" 19 | echo " -y overwrite output file if it exists" 20 | exit 1 21 | } 22 | 23 | if [[ $# -eq 0 ]] 24 | then 25 | usage 26 | fi 27 | 28 | SRC="${@: -1}" 29 | DST="${@:(-2):1}" 30 | BR="192k" 31 | FS=44100 32 | KEEP=false 33 | TEMPO=0 34 | YES=false 35 | while [[ $# -gt 0 ]] 36 | do 37 | key="$1" 38 | case $key in 39 | -b|--br|--bit_rate) 40 | BR="$2" 41 | shift 2 42 | ;; 43 | -f|--fs|--sampling_rate) 44 | FS="$2" 45 | shift 2 46 | ;; 47 | -k|--keep|--keep_wav) 48 | KEEP=true 49 | shift 50 | ;; 51 | -t|--tempo) 52 | TEMPO="$2" 53 | shift 2 54 | ;; 55 | -h|--help) 56 | usage 57 | ;; 58 | -y) 59 | YES=true 60 | shift 61 | ;; 62 | *) 63 | shift 64 | ;; 65 | esac 66 | done 67 | 68 | if [ "$TEMPO" -gt 0 ]; then 69 | python2 "$SRC_DIR/npz_to_audio.py" "${DST%.mp3}.wav" "$SRC" --fs "$FS" \ 70 | --tempo "$TEMPO" 71 | else 72 | python2 "$SRC_DIR/npz_to_audio.py" "${DST%.mp3}.wav" "$SRC" --fs "$FS" 73 | fi 74 | 75 | if $YES; then 76 | ffmpeg -y -i "${DST%.mp3}.wav" -vn -ar "$FS" -ab "$BR" -f mp3 "$DST" 77 | else 78 | ffmpeg -i "${DST%.mp3}.wav" -vn -ar "$FS" -ab "$BR" -f mp3 "$DST" 79 | fi 80 | 81 | if ! $KEEP; then 82 | rm -rf "${DST%.mp3}.wav" 83 | fi 84 | -------------------------------------------------------------------------------- /src/derive_id_lists_tagtraum.py: -------------------------------------------------------------------------------- 1 | """This file derives the ID lists for different labels in the Tagtraum genre 2 | annotations (see http://www.tagtraum.com/msd_genre_datasets.html).""" 3 | import os.path 4 | import argparse 5 | from utils import make_sure_path_exists 6 | 7 | def parse_args(): 8 | """Return the parsed command line arguments.""" 9 | parser = argparse.ArgumentParser() 10 | parser.add_argument('result_dir', help="path to save the ID lists") 11 | parser.add_argument('src', help="path to the labels") 12 | parser.add_argument('subset_ids_path', 13 | help="path to the ID list of the target subset") 14 | args = parser.parse_args() 15 | return args.result_dir, args.src, args.subset_ids_path 16 | 17 | def main(): 18 | """Main function.""" 19 | result_dir, src, subset_ids_path = parse_args() 20 | 21 | # Parse the label of each song 22 | tag_dict = {} 23 | with open(src) as f: 24 | for line in f: 25 | if line.startswith('#'): 26 | continue 27 | elif len(line.split()) == 2: 28 | tag_dict[line.split()[0]] = line.split()[1] 29 | elif len(line.split()) > 2: 30 | tag_dict[line.split()[0]] = '-'.join(line.split()[1:]) 31 | 32 | tags = set(tag_dict.values()) 33 | id_lists = {tag: [] for tag in tags} 34 | 35 | # Load the IDs of the songs in the subset 36 | with open(subset_ids_path) as f: 37 | subset_ids = [line.rstrip('\n').split()[1] for line in f] 38 | 39 | # Loop over all the songs in the subsets 40 | for msd_id in subset_ids: 41 | tag = tag_dict.get(msd_id) 42 | if tag is None: 43 | continue 44 | # Add the ID to the corresponding tag 45 | id_lists[tag].append(msd_id) 46 | 47 | # Save the ID lists to files 48 | make_sure_path_exists(result_dir) 49 | for tag in tags: 50 | filename = 'id_list_{}.txt'.format(tag) 51 | with open(os.path.join(result_dir, filename), 'w') as f: 52 | for msd_id in id_lists[tag]: 53 | f.write(msd_id + '\n') 54 | 55 | print("ID lists for Tagtraum genre annotations successfully saved.") 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /docs/labels.md: -------------------------------------------------------------------------------- 1 | # Labels 2 | 3 | We derived the labels for the Lakh Pianoroll Dataset (LPD) from three different 4 | sources: the Last.fm Dataset, the Million Song Dataset (MSD) Benchmarks and 5 | the Tagtraum genre annotations. Note that these labels are derived based on the 6 | mapping between the Lakh MIDI Dataset (LMD) and the MSD, which may contain 7 | incorrect pairs (see [here](https://colinraffel.com/projects/lmd/)). 8 | 9 | > All the labels below can be downloaded as a ZIP file [here](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/Ebyo4GUGkIhKop7TAIw98agB1qZ8NVerIvT5W9Nry9Cn5Q?e=xjgOiR). Please refer to the original sources for the license information. 10 | 11 | ## Last.fm Dataset 12 | 13 | - [id_lists_lastfm.tar.gz](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EbVdGIKAuC9Hn5BTkCLhIzMBRaF-X91EvI77ZSTs9aO2lA?e=rgxLjm): 14 | we derived the ID lists for most common labels in the Last.fm Dataset (see 15 | [here](http://millionsongdataset.com/lastfm/)). 16 | 17 | ## Million Song Dataset Benchmarks 18 | 19 | - [id_lists_amg.tar.gz](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EbqjoeUiIj9DhODGZOIVBIMBTK1PhDN86rRb29_iTmfhuQ): 20 | we derived the ID lists for all the labels in the MSD AllMusic Top Genre 21 | Dataset (TopMAGD) provided in the Million Song Dataset Benchmarks (see 22 | [here](http://www.ifs.tuwien.ac.at/mir/msd/)). 23 | - [masd_labels.txt](https://ucsdcloud-my.sharepoint.com/:t:/g/personal/h3dong_ucsd_edu/Ec1B0IGvabhJgzvhfskjKlQBcQQuePIGOpmM67O5twpbxg?e=yZpCdz): 24 | we also derived the labels from the MSD AllMusic Style Dataset (MASD) provided 25 | in the Million Song Dataset Benchmarks. Each entry has at most one label. 26 | - [masd_labels_cleansed.txt](https://ucsdcloud-my.sharepoint.com/:t:/g/personal/h3dong_ucsd_edu/EWait2PjXF9Pk7T_8D_zu0ABfDi-kWCBOI1M7PqLsMvLIA?e=Mcwz2I): 27 | this is a cleansed version of *masd_labels.txt*, where less common labels are 28 | discarded. 29 | 30 | ## Tagtraum genre annotations 31 | 32 | - [id_lists_tagtraum.tar.gz](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EaPOfwt7maBHkxAAtKv5U58BdRSJp3c-_5TS18opcskkCA?e=D1Dhts): 33 | we derived the ID lists for all the labels in the Tagtraum genre annotations 34 | (see [here](http://www.tagtraum.com/msd_genre_datasets.html)). 35 | -------------------------------------------------------------------------------- /src/derive_id_lists_amg.py: -------------------------------------------------------------------------------- 1 | """This file derives the ID lists for different labels in the MSD AllMusic Top 2 | Genre Dataset (TopMAGD) provided in the Million Song Dataset Benchmarks ( 3 | see http://www.ifs.tuwien.ac.at/mir/msd/).""" 4 | import os.path 5 | import argparse 6 | from utils import make_sure_path_exists 7 | 8 | def parse_args(): 9 | """Return the parsed command line arguments.""" 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('result_dir', help="path to save the ID lists") 12 | parser.add_argument('src', help="path to the labels") 13 | parser.add_argument('subset_ids_path', 14 | help="path to the ID list of the target subset") 15 | args = parser.parse_args() 16 | return args.result_dir, args.src, args.subset_ids_path 17 | 18 | def main(): 19 | """Main function.""" 20 | result_dir, src, subset_ids_path = parse_args() 21 | 22 | # Parse the label of each song 23 | tag_dict = {} 24 | with open(src) as f: 25 | for line in f: 26 | if line.startswith('#'): 27 | continue 28 | elif len(line.split()) == 2: 29 | tag_dict[line.split()[0]] = line.split()[1] 30 | elif len(line.split()) > 2: 31 | tag_dict[line.split()[0]] = '-'.join(line.split()[1:]) 32 | 33 | tags = set(tag_dict.values()) 34 | id_lists = {tag: [] for tag in tags} 35 | 36 | # Load the IDs of the songs in the subset 37 | with open(subset_ids_path) as f: 38 | subset_ids = [line.rstrip('\n').split()[1] for line in f] 39 | 40 | # Loop over all the songs in the subset 41 | for msd_id in subset_ids: 42 | tag = tag_dict.get(msd_id) 43 | if tag is None: 44 | continue 45 | # Add the ID to the corresponding tag 46 | id_lists[tag].append(msd_id) 47 | 48 | # Save the ID lists to files 49 | make_sure_path_exists(result_dir) 50 | for tag in tags: 51 | filename = 'id_list_{}.txt'.format(tag) 52 | with open(os.path.join(result_dir, filename), 'w') as f: 53 | for msd_id in id_lists[tag]: 54 | f.write(msd_id + '\n') 55 | 56 | print("ID lists for Million Song Dataset Benchmarks successfully saved.") 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /src/cleanser.py: -------------------------------------------------------------------------------- 1 | """This script cleanses a dataset and write the qualified IDs to a file. 2 | 3 | Cleansing Rules 4 | --------------- 5 | 6 | - Remove those having more than one time signature change events 7 | - Remove those having a time signature other than 4/4 8 | - Remove those whose first beat not starting from time zero 9 | - Keep only one file that has the highest confidence score in matching for 10 | each song 11 | 12 | """ 13 | import argparse 14 | import os.path 15 | import json 16 | from config import CONFIG 17 | 18 | def parse_args(): 19 | """Return the parsed command line arguments.""" 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument('filepath', help="path to save the resulting list") 22 | parser.add_argument('src', help="root path to the source dataset") 23 | parser.add_argument('match_scores_path', 24 | help="path to the match scores file") 25 | parser.add_argument('midi_info_path', help="path to the MIDI info file") 26 | 27 | args = parser.parse_args() 28 | return args.filepath, args.src, args.match_scores_path, args.midi_info_path 29 | 30 | def midi_filter(midi_info): 31 | """Return True for qualified MIDI files and False for unwanted ones.""" 32 | if midi_info['first_beat_time'] > 0.0: 33 | return False 34 | if midi_info['constant_time_signature'] not in CONFIG['time_signatures']: 35 | return False 36 | return True 37 | 38 | def main(): 39 | """Main function.""" 40 | filepath, src, match_scores_path, midi_info_path = parse_args() 41 | with open(match_scores_path) as f: 42 | match_score_dict = json.load(f) 43 | with open(midi_info_path) as f: 44 | midi_info = json.load(f) 45 | 46 | with open(filepath, 'w') as f: 47 | for msd_id in match_score_dict: 48 | midi_md5s = sorted(match_score_dict[msd_id], 49 | key=match_score_dict[msd_id].get, reverse=True) 50 | for midi_md5 in midi_md5s: 51 | npz_path = os.path.join(src, midi_md5[0], midi_md5 + '.npz') 52 | if os.path.isfile(npz_path): 53 | if midi_filter(midi_info[midi_md5]): 54 | f.write("{} {}\n".format(midi_md5, msd_id)) 55 | break 56 | 57 | print("Cleansed ID list successfully saved.") 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | """This file defines the mapping between the labels and the codes.""" 2 | LABEL_NUM_MAP = { 3 | 'Big_Band': 0, 4 | 'Blues_Contemporary': 1, 5 | 'Country_Traditional': 2, 6 | 'Dance': 3, 7 | 'Electronica': 4, 8 | 'Experimental': 5, 9 | 'Folk_International': 6, 10 | 'Gospel': 7, 11 | 'Grunge_Emo': 8, 12 | 'Hip_Hop_Rap': 9, 13 | 'Jazz_Classic': 10, 14 | 'Metal_Alternative': 11, 15 | 'Metal_Death': 12, 16 | 'Metal_Heavy': 13, 17 | 'Pop_Contemporary': 14, 18 | 'Pop_Indie': 15, 19 | 'Pop_Latin': 16, 20 | 'Punk': 17, 21 | 'Reggae': 18, 22 | 'RnB_Soul': 19, 23 | 'Rock_Alternative': 20, 24 | 'Rock_College': 21, 25 | 'Rock_Contemporary': 22, 26 | 'Rock_Hard': 23, 27 | 'Rock_Neo_Psychedelia': 24 28 | } 29 | 30 | NUM_LABEL_MAP = { 31 | 0: 'Big_Band', 32 | 1: 'Blues_Contemporary', 33 | 2: 'Country_Traditional', 34 | 3: 'Dance', 35 | 4: 'Electronica', 36 | 5: 'Experimental', 37 | 6: 'Folk_International', 38 | 7: 'Gospel', 39 | 8: 'Grunge_Emo', 40 | 9: 'Hip_Hop_Rap', 41 | 10: 'Jazz_Classic', 42 | 11: 'Metal_Alternative', 43 | 12: 'Metal_Death', 44 | 13: 'Metal_Heavy', 45 | 14: 'Pop_Contemporary', 46 | 15: 'Pop_Indie', 47 | 16: 'Pop_Latin', 48 | 17: 'Punk', 49 | 18: 'Reggae', 50 | 19: 'RnB_Soul', 51 | 20: 'Rock_Alternative', 52 | 21: 'Rock_College', 53 | 22: 'Rock_Contemporary', 54 | 23: 'Rock_Hard', 55 | 24: 'Rock_Neo Psychedelia' 56 | } 57 | 58 | CLEANSED_LABELS = [2, 3, 11, 14, 15, 16, 20, 21, 22, 23] 59 | 60 | CLEANSED_NUM_LABEL_MAP = { 61 | 0: 'Country_Traditional', # 2 62 | 1: 'Dance', # 3 63 | 2: 'Metal_Alternative', # 11 64 | 3: 'Pop_Contemporary', # 14 65 | 4: 'Pop_Indie', # 15 66 | 5: 'Pop_Latin', # 16 67 | 6: 'Rock_Alternative', # 20 68 | 7: 'Rock_College', # 21 69 | 8: 'Rock_Contemporary', # 22 70 | 9: 'Rock_Hard' # 23 71 | } 72 | 73 | CLEANSED_LABEL_NUM_MAP = { 74 | 'Country_Traditional': 0, # 2 75 | 'Dance': 1, # 3 76 | 'Metal_Alternative': 2, # 11 77 | 'Pop_Contemporary': 3, # 14 78 | 'Pop_Indie': 4, # 15 79 | 'Pop_Latin': 5, # 16 80 | 'Rock_Alternative': 6, # 20 81 | 'Rock_College': 7, # 21 82 | 'Rock_Contemporary': 8, # 22 83 | 'Rock_Hard': 9 # 23 84 | } 85 | -------------------------------------------------------------------------------- /src/derive_labels_amg.py: -------------------------------------------------------------------------------- 1 | """This file derives the labels in the MSD AllMusic Style Dataset (MASD) 2 | provided in the Million Song Dataset Benchmarks (see 3 | http://www.ifs.tuwien.ac.at/mir/msd/).""" 4 | import os.path 5 | import argparse 6 | from utils import make_sure_path_exists 7 | from constants import LABEL_NUM_MAP, CLEANSED_LABELS 8 | 9 | def parse_args(): 10 | """Return the parsed command line arguments.""" 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument('result_dir', help="path to save the ID lists") 13 | parser.add_argument('src', help="path to the labels") 14 | parser.add_argument('subset_ids_path', 15 | help="path to the ID list of the target subset") 16 | args = parser.parse_args() 17 | return args.result_dir, args.src, args.subset_ids_path 18 | 19 | def main(): 20 | """Main function.""" 21 | result_dir, src, subset_ids_path = parse_args() 22 | 23 | # Parse the label of each song 24 | id_label_masd = {} 25 | with open(src) as f: 26 | for line in f: 27 | if line.startswith('#'): 28 | continue 29 | id_label_masd[line.split()[0]] = LABEL_NUM_MAP[line.split()[1]] 30 | 31 | # Load the IDs of the songs in the subset 32 | with open(subset_ids_path) as f: 33 | subset_ids = [line.rstrip('\n').split()[1] for line in f] 34 | 35 | # Loop over all the songs in the subset 36 | collected = {} 37 | for msd_id in subset_ids: 38 | label = id_label_masd.get(msd_id) 39 | if label is None: 40 | continue 41 | collected[msd_id] = label 42 | 43 | # Save the ID label pairs to a file 44 | make_sure_path_exists(result_dir) 45 | filepath = os.path.join(result_dir, 'masd_labels.txt') 46 | with open(filepath, 'w') as f: 47 | f.write("# msd_id, label_num\n") 48 | for msd_id in collected: 49 | f.write("{} {}\n".format(msd_id, collected[msd_id])) 50 | print("Labels successfully saved.") 51 | 52 | # Save the cleansed ID label pairs to a file 53 | cleansed = {} 54 | for msd_id in collected: 55 | if collected[msd_id] in CLEANSED_LABELS: 56 | cleansed[msd_id] = CLEANSED_LABELS.index(collected[msd_id]) 57 | filepath = os.path.join(result_dir, 'masd_labels_cleansed.txt') 58 | with open(filepath, 'w') as f: 59 | f.write("# msd_id, label_num\n") 60 | for msd_id in cleansed: 61 | f.write("{} {}\n".format(msd_id, cleansed[msd_id])) 62 | print("Cleansed labels successfully saved.") 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /src/derive_id_lists_lastfm.py: -------------------------------------------------------------------------------- 1 | """This file derives the ID lists for different labels in the Last.fm Dataset 2 | (see https://labrosa.ee.columbia.edu/millionsong/lastfm).""" 3 | import json 4 | import os.path 5 | import argparse 6 | from utils import make_sure_path_exists, msd_id_to_dirs 7 | 8 | # Define the tags to be considered 9 | TAGS = [ 10 | 'sad', 'Love', 'cool', 'electro', 'happy', 'indie', '70s', 'classic-rock', 11 | 'Progressive-rock', 'Mellow', 'oldies', 'british', 'piano', 'electronica', 12 | 'sexy', 'experimental', 'techno', 'funk', 'folk', 'psychedelic', 'hardcore', 13 | 'Favorite', 'pop', '60s', 'downtempo', 'relax', 'amazing', 'blues', 'House', 14 | 'catchy', 'Favourites', '80s', '00s', 'country', 'chillout', 'chill', 15 | 'electronic', 'punk', 'Soundtrack', 'fun', 'favourite', 'lounge', 'reggae', 16 | 'favorites', 'trance', 'american', 'cover', 'alternative', 'party', 17 | 'Awesome', 'melancholy', 'acoustic', 'dance', 'Hip-Hop', 'soul', 'classic', 18 | 'metal', 'guitar', 'rap', 'beautiful', 'loved', 'jazz', 'rock', 19 | 'heard-on-Pandora', '90s', 'female', 'ambient', 'instrumental', 'rnb' 20 | ] 21 | 22 | def parse_args(): 23 | """Return the parsed command line arguments.""" 24 | parser = argparse.ArgumentParser() 25 | parser.add_argument('result_dir', help="path to save the ID lists") 26 | parser.add_argument('src', help="path to the labels") 27 | parser.add_argument('subset_ids_path', 28 | help="path to the ID list of the target subset") 29 | args = parser.parse_args() 30 | return args.result_dir, args.src, args.subset_ids_path 31 | 32 | def main(): 33 | """Main function.""" 34 | result_dir, src, subset_ids_path = parse_args() 35 | 36 | id_lists = {tag: [] for tag in TAGS} 37 | 38 | # Load the IDs of the songs in the subset 39 | with open(subset_ids_path) as f: 40 | subset_ids = [line.rstrip('\n').split()[1] for line in f] 41 | 42 | # Loop over all the songs in the subsets 43 | for msd_id in subset_ids: 44 | for dataset in ('lastfm_train', 'lastfm_test'): 45 | filepath = os.path.join( 46 | src, dataset, msd_id_to_dirs(msd_id) + '.json') 47 | if os.path.exists(filepath): 48 | with open(filepath) as f: 49 | data = json.load(f) 50 | # Loop over all the tags annotated to the song 51 | for tag_freq_pair in data['tags']: 52 | if tag_freq_pair[0] in TAGS: 53 | # Add the ID to the corresponding tag 54 | id_lists[tag_freq_pair[0]].append(msd_id) 55 | 56 | # Save the ID lists to files 57 | make_sure_path_exists(result_dir) 58 | for tag in TAGS: 59 | filename = 'id_list_{}.txt'.format(tag.lower()) 60 | with open(os.path.join(result_dir, filename), 'w') as f: 61 | for msd_id in id_lists[tag]: 62 | f.write(msd_id + '\n') 63 | 64 | print("ID lists for Last.fm Dataset successfully saved.") 65 | 66 | if __name__ == "__main__": 67 | main() 68 | -------------------------------------------------------------------------------- /src/merger_5.py: -------------------------------------------------------------------------------- 1 | """This script merges multitrack pianorolls to five-track pianorolls. 2 | """ 3 | import os.path 4 | import argparse 5 | from pypianoroll import Multitrack, Track 6 | from utils import make_sure_path_exists, change_prefix, findall_endswith 7 | from config import CONFIG 8 | if CONFIG['multicore'] > 1: 9 | import joblib 10 | 11 | TRACK_INFO = ( 12 | ('Drums', 0), 13 | ('Piano', 0), 14 | ('Guitar', 24), 15 | ('Bass', 32), 16 | ('Strings', 48), 17 | ) 18 | 19 | def parse_args(): 20 | """Return the parsed command line arguments.""" 21 | parser = argparse.ArgumentParser() 22 | parser.add_argument('src', help="root path to the source dataset") 23 | parser.add_argument('dst', help="root path to the destination dataset") 24 | args = parser.parse_args() 25 | return args.src, args.dst 26 | 27 | def get_merged(multitrack): 28 | """Merge the multitrack pianorolls into five instrument families and 29 | return the resulting multitrack pianoroll object.""" 30 | track_lists_to_merge = [[] for _ in range(5)] 31 | for idx, track in enumerate(multitrack.tracks): 32 | if track.is_drum: 33 | track_lists_to_merge[0].append(idx) 34 | elif track.program//8 == 0: 35 | track_lists_to_merge[1].append(idx) 36 | elif track.program//8 == 3: 37 | track_lists_to_merge[2].append(idx) 38 | elif track.program//8 == 4: 39 | track_lists_to_merge[3].append(idx) 40 | elif track.program < 96 or 104 <= track.program < 112: 41 | track_lists_to_merge[4].append(idx) 42 | 43 | tracks = [] 44 | for idx, track_list_to_merge in enumerate(track_lists_to_merge): 45 | if track_list_to_merge: 46 | merged = multitrack[track_list_to_merge].get_merged_pianoroll('max') 47 | tracks.append(Track(merged, TRACK_INFO[idx][1], (idx == 0), 48 | TRACK_INFO[idx][0])) 49 | else: 50 | tracks.append(Track(None, TRACK_INFO[idx][1], (idx == 0), 51 | TRACK_INFO[idx][0])) 52 | return Multitrack(None, tracks, multitrack.tempo, multitrack.downbeat, 53 | multitrack.beat_resolution, multitrack.name) 54 | 55 | def merger(filepath, src, dst): 56 | """Load and merge a multitrack pianoroll and save to the given path.""" 57 | # Load and merge the multitrack pianoroll 58 | multitrack = Multitrack(filepath) 59 | merged = get_merged(multitrack) 60 | 61 | # Save the merged multitrack pianoroll 62 | result_path = change_prefix(filepath, src, dst) 63 | make_sure_path_exists(os.path.dirname(result_path)) 64 | merged.save(result_path) 65 | 66 | def main(): 67 | """Main function.""" 68 | src, dst = parse_args() 69 | make_sure_path_exists(dst) 70 | 71 | if CONFIG['multicore'] > 1: 72 | joblib.Parallel(n_jobs=CONFIG['multicore'], verbose=5)( 73 | joblib.delayed(merger)(npz_path, src, dst) 74 | for npz_path in findall_endswith('.npz', src)) 75 | else: 76 | for npz_path in findall_endswith('.npz', src): 77 | merger(npz_path, src, dst) 78 | 79 | if __name__ == "__main__": 80 | main() 81 | -------------------------------------------------------------------------------- /src/merger_17.py: -------------------------------------------------------------------------------- 1 | """This script merges multitrack pianorolls to seventeen-track pianorolls. 2 | """ 3 | import os.path 4 | import argparse 5 | from pypianoroll import Multitrack, Track 6 | from utils import make_sure_path_exists, change_prefix, findall_endswith 7 | from config import CONFIG 8 | if CONFIG['multicore'] > 1: 9 | import joblib 10 | 11 | TRACK_INFO = ( 12 | ('Drums', 0), 13 | ('Piano', 0), 14 | ('Chromatic Percussion', 8), 15 | ('Organ', 16), 16 | ('Guitar', 24), 17 | ('Bass', 32), 18 | ('Strings', 40), 19 | ('Ensemble', 48), 20 | ('Brass', 56), 21 | ('Reed', 64), 22 | ('Pipe', 72), 23 | ('Synth Lead', 80), 24 | ('Synth Pad', 88), 25 | ('Synth Effects', 96), 26 | ('Ethnic', 104), 27 | ('Percussive', 112), 28 | ('Sound Effects', 120), 29 | ) 30 | 31 | def parse_args(): 32 | """Return the parsed command line arguments.""" 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument('src', help="root path to the source dataset") 35 | parser.add_argument('dst', help="root path to the destination dataset") 36 | args = parser.parse_args() 37 | return args.src, args.dst 38 | 39 | def get_merged(multitrack): 40 | """Merge the multitrack pianorolls into sixteen instrument families and 41 | return the resulting multitrack pianoroll object.""" 42 | track_lists_to_merge = [[] for _ in range(17)] 43 | for idx, track in enumerate(multitrack.tracks): 44 | if track.is_drum: 45 | track_lists_to_merge[0].append(idx) 46 | else: 47 | track_lists_to_merge[track.program//8 + 1].append(idx) 48 | 49 | tracks = [] 50 | for idx, track_list_to_merge in enumerate(track_lists_to_merge): 51 | if track_list_to_merge: 52 | merged = multitrack[track_list_to_merge].get_merged_pianoroll('max') 53 | tracks.append(Track(merged, TRACK_INFO[idx][1], (idx == 0), 54 | TRACK_INFO[idx][0])) 55 | else: 56 | tracks.append(Track(None, TRACK_INFO[idx][1], (idx == 0), 57 | TRACK_INFO[idx][0])) 58 | return Multitrack(None, tracks, multitrack.tempo, multitrack.downbeat, 59 | multitrack.beat_resolution, multitrack.name) 60 | 61 | def merger(filepath, src, dst): 62 | """Load and merge a multitrack pianoroll and save to the given path.""" 63 | # Load and merge the multitrack pianoroll 64 | multitrack = Multitrack(filepath) 65 | merged = get_merged(multitrack) 66 | 67 | # Save the merged multitrack pianoroll 68 | result_path = change_prefix(filepath, src, dst) 69 | make_sure_path_exists(os.path.dirname(result_path)) 70 | merged.save(result_path) 71 | 72 | def main(): 73 | """Main function.""" 74 | src, dst = parse_args() 75 | make_sure_path_exists(dst) 76 | 77 | if CONFIG['multicore'] > 1: 78 | joblib.Parallel(n_jobs=CONFIG['multicore'], verbose=5)( 79 | joblib.delayed(merger)(npz_path, src, dst) 80 | for npz_path in findall_endswith('.npz', src)) 81 | else: 82 | for npz_path in findall_endswith('.npz', src): 83 | merger(npz_path, src, dst) 84 | 85 | if __name__ == "__main__": 86 | main() 87 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% seo %} 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 |

20 | Hao-Wen Dong, 21 | Wen-Yi Hsiao, 22 | Li-Chia Yang, 23 | Yi-Hsuan Yang 24 |

25 |

26 | Music and AI Lab,
Research Center for IT Innovation,
Academia Sinica 27 |

28 |
29 | 30 | 31 | 32 | 33 | 49 |
50 |
51 | 52 | {{ content }} 53 | 54 |
55 | 59 |
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/converter.py: -------------------------------------------------------------------------------- 1 | """This script converts a collection of MIDI files to multitrack pianorolls. 2 | """ 3 | import os 4 | import json 5 | import argparse 6 | import warnings 7 | import pretty_midi 8 | from pypianoroll import Multitrack 9 | from utils import make_sure_path_exists, change_prefix, findall_endswith 10 | from config import CONFIG 11 | if CONFIG['multicore'] > 1: 12 | import joblib 13 | 14 | warnings.filterwarnings('ignore') 15 | 16 | def parse_args(): 17 | """Return the parsed command line arguments.""" 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument('src', help="root path to the source dataset") 20 | parser.add_argument('dst', help="root path to the destination dataset") 21 | parser.add_argument('--midi-info-path', dest='midi_info_path', 22 | help="path to save the MIDI info dictionary") 23 | args = parser.parse_args() 24 | return args.src, args.dst, args.midi_info_path 25 | 26 | def get_midi_info(pm): 27 | """Return useful information from a MIDI object.""" 28 | if pm.time_signature_changes: 29 | pm.time_signature_changes.sort(key=lambda x: x.time) 30 | first_beat_time = pm.time_signature_changes[0].time 31 | else: 32 | first_beat_time = pm.estimate_beat_start() 33 | 34 | tc_times, tempi = pm.get_tempo_changes() 35 | 36 | if len(pm.time_signature_changes) == 1: 37 | time_sign = '{}/{}'.format(pm.time_signature_changes[0].numerator, 38 | pm.time_signature_changes[0].denominator) 39 | else: 40 | time_sign = None 41 | 42 | midi_info = { 43 | 'first_beat_time': first_beat_time, 44 | 'num_time_signature_change': len(pm.time_signature_changes), 45 | 'constant_time_signature': time_sign, 46 | 'constant_tempo': tempi[0] if len(tc_times) == 1 else None 47 | } 48 | 49 | return midi_info 50 | 51 | def converter(filepath, src, dst): 52 | """Convert a MIDI file to a multi-track piano-roll and save the 53 | resulting multi-track piano-roll to the destination directory. Return a 54 | tuple of `midi_md5` and useful information extracted from the MIDI file. 55 | """ 56 | try: 57 | midi_md5 = os.path.splitext(os.path.basename(filepath))[0] 58 | multitrack = Multitrack(beat_resolution=CONFIG['beat_resolution'], 59 | name=midi_md5) 60 | 61 | pm = pretty_midi.PrettyMIDI(filepath) 62 | multitrack.parse_pretty_midi(pm) 63 | midi_info = get_midi_info(pm) 64 | 65 | result_dir = change_prefix(os.path.dirname(filepath), src, dst) 66 | make_sure_path_exists(result_dir) 67 | multitrack.save(os.path.join(result_dir, midi_md5 + '.npz')) 68 | 69 | return (midi_md5, midi_info) 70 | 71 | except: 72 | return None 73 | 74 | def main(): 75 | """Main function.""" 76 | src, dst, midi_info_path = parse_args() 77 | make_sure_path_exists(dst) 78 | midi_info = {} 79 | 80 | if CONFIG['multicore'] > 1: 81 | kv_pairs = joblib.Parallel(n_jobs=CONFIG['multicore'], verbose=5)( 82 | joblib.delayed(converter)(midi_path, src, dst) 83 | for midi_path in findall_endswith('.mid', src)) 84 | for kv_pair in kv_pairs: 85 | if kv_pair is not None: 86 | midi_info[kv_pair[0]] = kv_pair[1] 87 | else: 88 | for midi_path in findall_endswith('.mid', src): 89 | kv_pair = converter(midi_path, src, dst) 90 | if kv_pair is not None: 91 | midi_info[kv_pair[0]] = kv_pair[1] 92 | 93 | if midi_info_path is not None: 94 | with open(midi_info_path, 'w') as f: 95 | json.dump(midi_info, f) 96 | 97 | print("{} files have been successfully converted".format(len(midi_info))) 98 | 99 | if __name__ == "__main__": 100 | main() 101 | -------------------------------------------------------------------------------- /docs/dataset.md: -------------------------------------------------------------------------------- 1 | # Dataset 2 | 3 | ## LPD 4 | 5 | [lpd-full](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EdbL-YEk6n5PsBsUiy4WNCEBIF7i9ODsH709TI8BgeQc7g?e=jh9WEI) 6 | contains 174,154 multitrack pianorolls derived from the 7 | [Lakh MIDI Dataset](http://colinraffel.com/projects/lmd/) (LMD). 8 | 9 | ## LPD-matched 10 | 11 | [lpd-matched](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/ETLEA8kl0TlBjl6ckYW_tfMB5X09pPLgNJnUgdHqXgjY0A?e=785xwp) 12 | contains 115,160 multitrack pianorolls derived from the matched version of LMD. 13 | These files are matched to entries in the Million Song Dataset (MSD). To make 14 | use of the metadata provided by MSD, we refer users to the 15 | [demo page](http://colinraffel.com/projects/lmd/) of LMD. 16 | 17 | [matched_ids.txt](https://ucsdcloud-my.sharepoint.com/:t:/g/personal/h3dong_ucsd_edu/EacvvyoMWYREgAJQ4sZPsCwB31rdpyo-scvts-BWBx4xtg?e=aN0Jqm) 18 | provides a list of all file IDs and the matched MSD IDs in the matched subset. 19 | 20 | ## LPD-cleansed 21 | 22 | [lpd-cleansed](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EejJ_myDTa9Jsiswlk5DqHoByCaZygtKqDL2BYEXT6AD5w?e=JYe0j8) 23 | contains 21,425 multitrack pianorolls collected from _lpd-matched_ with the 24 | following rules. Note that _lpd-cleansed_ contains songs from ALL genres, which 25 | is different from the description on the paper. 26 | 27 | - Remove those having more than one time signature change events 28 | - Remove those having a time signature other than 4/4 29 | - Remove those whose first beat not starting from time zero 30 | - Keep only one file that has the highest confidence score in matching for each 31 | song\* 32 | 33 | [cleansed_ids.txt](https://ucsdcloud-my.sharepoint.com/:t:/g/personal/h3dong_ucsd_edu/EfmNwSfQ0yhIobAwvFY7kysBpmgTIuFXqL8klAeS6IN_zQ?e=Nxiy75) 34 | provides a list of all file IDs and the matched MSD IDs in the cleansed subset. 35 | 36 | \* _The matching confidence scores come with the LMD, which is the confidence of 37 | whether the MIDI file match any entry in the MSD._ 38 | 39 | ## MIDI Info Dictionary 40 | 41 | [midi_info.json](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EVIrPx2DgztEuKCHEgfyoSkBQrIVbbHbShcxZD406_LCTQ?e=IVhn0r) 42 | contains useful information lost during the conversion from LMD to LPD. It was 43 | used to create _lpd-cleansed_. 44 | 45 | - `first_beat_time`: the actual timing of the first beat 46 | - `num_time_signature_change`: the number of time signature change events 47 | - `constant_time_signature`: the _only_ time signature used (`None` if it 48 | changes within a song) 49 | - `constant_tempo`: the _only_ tempo (in bpm) used (`None` if it changes within 50 | a song) 51 | 52 | [midi_info_v2.json](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EeCuOWLxyRlIkRVHFdyURokBASAhkkoh9A82DEgBzc69cQ?e=jMpdcp) 53 | has the same values for `first_beat_time`, `num_time_signature_change` and 54 | `constant_time_signature`. However, `constant_tempo` is now a boolean value that 55 | indicates whether the tempo is constant throughout the song. There is an 56 | additional key `tempo` that stores the initial tempo value (in bpm). 57 | 58 | --- 59 | 60 | ## LPD-5 61 | 62 | In LPD-5, the tracks are merged into five common categories: _Drums_, _Piano_, 63 | _Guitar_, _Bass_ and _Strings_ according to the program numbers provided in the 64 | MIDI files. 65 | 66 | > Note that instruments out of the five categories are considered as part of the 67 | strings except those in the _Percussive_, _Sound effects_ and _Synth Effects_ 68 | families (see [here](https://en.wikipedia.org/wiki/General_MIDI#Program_change_events)). 69 | 70 | - [lpd-5-full](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EWaJUNAZJv1Gs9Z_qGaZ23EBt9VEfPB12z2uyq05g75XSA?e=b1QgOP) 71 | contains 174,154 five-track pianorolls derived from _lpd-full_. 72 | - [lpd-5-matched](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EYUdm4cUfaJHtbON5KSSJAkB96HLGgKC18MbvI1UbU4SMA?e=c0eWRw) 73 | contains 115,160 five-track pianorolls derived from _lpd-matched_. 74 | - [lpd-5-cleansed](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/Ebu0hMewNeRPs7wIHB0SPyIBoFuC1-rAU5yUK5q2flt1BA?e=uDLp37) 75 | contains 21,425 five-track pianorolls derived from _lpd-cleansed_. 76 | 77 | --- 78 | 79 | ## LPD-17 80 | 81 | In LPD-17, the tracks are merged into drums and sixteen instrument families 82 | according to the program numbers provided in the MIDI files and the 83 | specification of General MIDI (see [here](https://en.wikipedia.org/wiki/General_MIDI#Program_change_events)). 84 | The seventeen tracks are _Drums_, _Piano_, _Chromatic Percussion_, _Organ_, 85 | _Guitar_, _Bass_, _Strings_, _Ensemble_, _Brass_, _Reed_, _Pipe_, _Synth Lead_, 86 | _Synth Pad_, _Synth Effects_, _Ethnic_, _Percussive_ and _Sound Effects_. 87 | 88 | - [lpd-17-full](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EeGavVQ3QHtFg9PF6Kr-4-0BMzfIl5Tm4XEuhls_bCRHlQ?e=jUPrWH) 89 | contains 174,154 seventeen-track pianorolls derived from _lpd-full_. 90 | - [lpd-17-matched](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/ESTv8JEAKTFJhQD2oWowlYcB8Kk4iw1rpM6Rn1PIILwMww?e=GjpQnW) 91 | contains 115,160 seventeen-track pianorolls derived from _lpd-matched_. 92 | - [lpd-17-cleansed](https://ucsdcloud-my.sharepoint.com/:u:/g/personal/h3dong_ucsd_edu/EdaJWG-GdP1Kng2FRuFAHJkB7RF1koJsm0-g2d5AaUhi-A?e=2Xp9fF) 93 | contains 21,425 seventeen-track pianorolls derived from _lpd-cleansed_. 94 | -------------------------------------------------------------------------------- /src/pypianoroll/utilities.py: -------------------------------------------------------------------------------- 1 | """Utilities for manipulating multi-track and single-track piano-rolls. 2 | 3 | """ 4 | from copy import deepcopy 5 | import numpy as np 6 | from pypianoroll.track import Track 7 | from pypianoroll.multitrack import Multitrack 8 | 9 | def _check_supported(obj): 10 | """ 11 | Raise TypeError if the object is not a :class:`pypianoroll.Multitrack` 12 | or :class:`pypianoroll.Track` object. Otherwise, pass. 13 | 14 | """ 15 | if not (isinstance(obj, Multitrack) or isinstance(obj, Track)): 16 | raise TypeError("Support only `pypianoroll.Multitrack` and " 17 | "`pypianoroll.Track` class objects") 18 | 19 | def is_pianoroll(arr): 20 | """ 21 | Return True if the array is a standard piano-roll matrix. Otherwise, 22 | return False. Raise TypeError if the input object is not a numpy array. 23 | 24 | """ 25 | if not isinstance(arr, np.ndarray): 26 | raise TypeError("`arr` must be of np.ndarray type") 27 | if not (np.issubdtype(arr.dtype, np.bool_) 28 | or np.issubdtype(arr.dtype, np.number)): 29 | return False 30 | if arr.ndim != 2: 31 | return False 32 | if arr.shape[1] != 128: 33 | return False 34 | return True 35 | 36 | def assign_constant(obj, value): 37 | """ 38 | Assign a constant value to the nonzeros in the piano-roll(s). If a 39 | piano-roll is not binarized, its data type will be preserved. If a 40 | piano-roll is binarized, it will be casted to the type of `value`. 41 | 42 | Arguments 43 | --------- 44 | value : int or float 45 | The constant value to be assigned to the nonzeros of the 46 | piano-roll(s). 47 | 48 | """ 49 | _check_supported(obj) 50 | obj.assign_constant(value) 51 | 52 | def binarize(obj, threshold=0): 53 | """ 54 | Return a copy of the object with binarized piano-roll(s). 55 | 56 | Parameters 57 | ---------- 58 | threshold : int or float 59 | Threshold to binarize the piano-roll(s). Default to zero. 60 | 61 | """ 62 | _check_supported(obj) 63 | copied = deepcopy(obj) 64 | copied.binarize(threshold) 65 | return copied 66 | 67 | def clip(obj, lower=0, upper=127): 68 | """ 69 | Return a copy of the object with piano-roll(s) clipped by a lower bound 70 | and an upper bound specified by `lower` and `upper`, respectively. 71 | 72 | Parameters 73 | ---------- 74 | lower : int or float 75 | The lower bound to clip the piano-roll. Default to 0. 76 | upper : int or float 77 | The upper bound to clip the piano-roll. Default to 127. 78 | 79 | """ 80 | _check_supported(obj) 81 | copied = deepcopy(obj) 82 | copied.clip(lower, upper) 83 | return copied 84 | 85 | def copy(obj): 86 | """Return a copy of the object.""" 87 | _check_supported(obj) 88 | copied = deepcopy(obj) 89 | return copied 90 | 91 | def load(filepath): 92 | """ 93 | Return a :class:`pypianoroll.Multitrack` object loaded from a .npz file. 94 | 95 | Parameters 96 | ---------- 97 | filepath : str 98 | The file path to the .npz file. 99 | 100 | """ 101 | if not filepath.endswith('.npz'): 102 | raise ValueError("Only .npz files are supported") 103 | multitrack = Multitrack(filepath) 104 | return multitrack 105 | 106 | def pad(obj, pad_length): 107 | """ 108 | Return a copy of the object with piano-roll padded with zeros at the end 109 | along the time axis. 110 | 111 | Parameters 112 | ---------- 113 | pad_length : int 114 | The length to pad along the time axis with zeros. 115 | 116 | """ 117 | if not isinstance(obj, Track): 118 | raise TypeError("Support only `pypianoroll.Track` class objects") 119 | copied = deepcopy(obj) 120 | copied.pad(pad_length) 121 | return copied 122 | 123 | def pad_to_same(obj): 124 | """ 125 | Return a copy of the object with shorter piano-rolls padded with zeros 126 | at the end along the time axis to the length of the piano-roll with the 127 | maximal length. 128 | 129 | """ 130 | if not isinstance(obj, Multitrack): 131 | raise TypeError("Support only `pypianoroll.Multitrack` class objects") 132 | copied = deepcopy(obj) 133 | copied.pad_to_same() 134 | return copied 135 | 136 | def pad_to_multiple(obj, factor): 137 | """ 138 | Return a copy of the object with its piano-roll padded with zeros at the 139 | end along the time axis with the minimal length that make the length of 140 | the resulting piano-roll a multiple of `factor`. 141 | 142 | Parameters 143 | ---------- 144 | factor : int 145 | The value which the length of the resulting piano-roll will be 146 | a multiple of. 147 | 148 | """ 149 | if not isinstance(obj, Track): 150 | raise TypeError("Support only `pypianoroll.Track` class objects") 151 | copied = deepcopy(obj) 152 | copied.pad_to_multiple(factor) 153 | return copied 154 | 155 | def parse(filepath): 156 | """ 157 | Return a :class:`pypianoroll.Multitrack` object loaded from a MIDI 158 | (.mid, .midi, .MID, .MIDI) file. 159 | 160 | Parameters 161 | ---------- 162 | filepath : str 163 | The file path to the MIDI file. 164 | 165 | """ 166 | if not filepath.endswith(('.mid', '.midi', '.MID', '.MIDI')): 167 | raise ValueError("Only MIDI files are supported") 168 | multitrack = Multitrack(filepath) 169 | return multitrack 170 | 171 | def plot(obj, **kwargs): 172 | """ 173 | Plot the object. See :func:`pypianoroll.Multitrack.plot` and 174 | :func:`pypianoroll.Track.plot` for full documentation. 175 | 176 | """ 177 | _check_supported(obj) 178 | return obj.plot(**kwargs) 179 | 180 | def save(filepath, obj, compressed=True): 181 | """ 182 | Save the object to a .npz file. 183 | 184 | Parameters 185 | ---------- 186 | filepath : str 187 | The path to save the file. 188 | obj: `pypianoroll.Multitrack` objects 189 | The objecte to be saved. 190 | 191 | """ 192 | if not isinstance(obj, Multitrack): 193 | raise TypeError("Support only `pypianoroll.Multitrack` class objects") 194 | obj.save(filepath, compressed) 195 | 196 | def transpose(obj, semitone): 197 | """ 198 | Return a copy of the object with piano-roll(s) transposed by `semitones` 199 | semitones. 200 | 201 | Parameters 202 | ---------- 203 | semitone : int 204 | Number of semitones to transpose the piano-roll(s). 205 | 206 | """ 207 | _check_supported(obj) 208 | copied = deepcopy(obj) 209 | copied.transpose(semitone) 210 | return copied 211 | 212 | def trim_trailing_silence(obj): 213 | """ 214 | Return a copy of the object with trimmed trailing silence of the 215 | piano-roll(s). 216 | 217 | """ 218 | _check_supported(obj) 219 | copied = deepcopy(obj) 220 | length = copied.get_active_length() 221 | copied.pianoroll = copied.pianoroll[:length] 222 | return copied 223 | 224 | def write(obj, filepath): 225 | """ 226 | Write the object to a MIDI file. 227 | 228 | Parameters 229 | ---------- 230 | filepath : str 231 | The path to write the MIDI file. 232 | 233 | """ 234 | if not isinstance(obj, Multitrack): 235 | raise TypeError("Support only `pypianoroll.Multitrack` class objects") 236 | obj.write(filepath) 237 | -------------------------------------------------------------------------------- /docs/_sass/jekyll-theme-minimal.scss: -------------------------------------------------------------------------------- 1 | @import "fonts"; 2 | @import "rouge-github"; 3 | 4 | // Main wrappers 5 | body { 6 | overflow-x: hidden; 7 | margin-left: calc(100vw - 100%); 8 | padding: 50px; 9 | background-color: #fff; 10 | font: 16px/1.5 "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; 11 | font-weight: 400; 12 | color: #333; 13 | } 14 | 15 | .wrapper { 16 | margin: 0 auto; 17 | width: 1100px; 18 | } 19 | 20 | header { 21 | width: 260px; 22 | float: left; 23 | position: fixed; 24 | -webkit-font-smoothing: subpixel-antialiased; 25 | } 26 | 27 | section, footer { 28 | float: right; 29 | width: 740px; 30 | } 31 | 32 | footer { 33 | padding-bottom: 50px; 34 | -webkit-font-smoothing: subpixel-antialiased; 35 | } 36 | 37 | // Menu 38 | .main-menu-checkbox, .menu-toggle, .main-menu-close { 39 | display: none; 40 | } 41 | 42 | // Header 43 | .title a { 44 | color: #111; 45 | } 46 | 47 | .author-names { 48 | margin: 0 0 5px; 49 | } 50 | 51 | .main-menu .subpage-links { 52 | margin: 0 0 20px; 53 | } 54 | 55 | .main-menu .link-button:hover, .main-menu .link-button:focus { 56 | background: #eee; 57 | } 58 | 59 | // Video player 60 | .video-container { 61 | display: block; 62 | overflow: hidden; 63 | margin-left: auto; 64 | margin-right: auto; 65 | margin-bottom: 20px; 66 | width: 100%; 67 | max-width: 600px; 68 | } 69 | 70 | .video-inner-container { 71 | position: relative; 72 | overflow: hidden; 73 | width: 100%; 74 | height: 0; 75 | padding-bottom: 56.25%; 76 | } 77 | 78 | .video-container iframe { 79 | position: absolute; 80 | top: 0; 81 | left: 0; 82 | width: 100%; 83 | height: 100%; 84 | } 85 | 86 | // Special classes 87 | .caption { 88 | margin-top: -15px; 89 | text-align: center; 90 | } 91 | 92 | .caption-above { 93 | margin-bottom: 5px; 94 | text-align: center; 95 | } 96 | 97 | .switch-on-hover .hide-on-hover { 98 | display: inline-block; 99 | } 100 | 101 | .switch-on-hover .show-on-hover { 102 | display: none; 103 | } 104 | 105 | .switch-on-hover:hover .hide-on-hover { 106 | display: none; 107 | } 108 | 109 | .switch-on-hover:hover .show-on-hover { 110 | display: inline-block; 111 | } 112 | 113 | // Main components 114 | h1, h2, h3, h4, h5, h6 { 115 | margin: 0 0 20px; 116 | } 117 | 118 | p, ul, ol, table, pre, dl, audio { 119 | margin: 0 0 20px; 120 | } 121 | 122 | h1, h2, h3 { 123 | line-height: 1.1; 124 | color: #111; 125 | } 126 | 127 | h1 { 128 | font-size: 28px; 129 | } 130 | 131 | h4, h5, h6 { 132 | color: #222; 133 | } 134 | 135 | small { 136 | font-size: 11px; 137 | } 138 | 139 | strong { 140 | font-weight: 700; 141 | } 142 | 143 | a { 144 | text-decoration: none; 145 | color: #267CB9; 146 | } 147 | 148 | a:not(.nohover):hover, a:not(.nohover):focus { 149 | text-shadow: 0 0.015em #069,0 -0.015em #069,0.01em 0 #069,-0.01em 0 #069; 150 | color: #069; 151 | } 152 | 153 | footer small a { 154 | color: #555; 155 | } 156 | 157 | blockquote { 158 | border-left: 1px solid #e5e5e5; 159 | margin: 0; 160 | padding: 0 0 0 20px; 161 | font-style: italic; 162 | } 163 | 164 | code, pre { 165 | font-family: Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal, Consolas, Liberation Mono, DejaVu Sans Mono, Courier New, monospace; 166 | color: #333; 167 | } 168 | 169 | pre { 170 | border: 1px solid #e5e5e5; 171 | border-radius: 5px; 172 | padding: 8px 15px; 173 | background: #f8f8f8; 174 | overflow-x: auto; 175 | } 176 | 177 | table { 178 | margin-left: auto; 179 | margin-right: auto; 180 | border-collapse: collapse; 181 | } 182 | 183 | table audio, table .video-container { 184 | margin: 0; 185 | } 186 | 187 | th, td { 188 | border-bottom: 1px solid #e5e5e5; 189 | padding: 5px 10px; 190 | text-align: left; 191 | } 192 | 193 | dt { 194 | color: #444; 195 | font-weight: 700; 196 | } 197 | 198 | th { 199 | color: #444; 200 | } 201 | 202 | img, audio { 203 | display: block; 204 | margin-left: auto; 205 | margin-right: auto; 206 | } 207 | 208 | img { 209 | width: 100%; 210 | max-width: 600px; 211 | } 212 | 213 | audio { 214 | max-width: 100%; 215 | } 216 | 217 | hr { 218 | margin: 0 0 20px; 219 | border: 0; 220 | height: 1px; 221 | background: #e5e5e5; 222 | } 223 | 224 | footer hr { 225 | margin: 15px 0 0; 226 | } 227 | 228 | @media print, screen and (max-width: 1200px) { 229 | .wrapper { 230 | width: 860px; 231 | } 232 | 233 | header { 234 | width: 260px; 235 | } 236 | 237 | section, footer { 238 | width: 560px; 239 | } 240 | 241 | ul { 242 | padding-left: 20px; 243 | } 244 | } 245 | 246 | @media print, screen and (max-width: 960px) { 247 | // Main wrappers 248 | .wrapper { 249 | margin: 0; 250 | width: auto; 251 | } 252 | 253 | header, section, footer { 254 | position: static; 255 | float: none; 256 | width: auto; 257 | } 258 | 259 | section { 260 | margin: 0 0 20px; 261 | border: 1px solid #e5e5e5; 262 | border-width: 1px 0; 263 | padding: 20px 0; 264 | } 265 | 266 | // Menu 267 | .menu-toggle, .main-menu { 268 | position: absolute; 269 | right: 55px; 270 | top: 55px; 271 | } 272 | 273 | .menu-toggle { 274 | z-index: 998; 275 | display: block; 276 | padding: 5px 10px; 277 | color: #333; 278 | cursor: pointer; 279 | } 280 | 281 | .menu-toggle:hover { 282 | color:#069; 283 | } 284 | 285 | .main-menu { 286 | z-index: 999; 287 | display: none; 288 | min-width: 120px; 289 | background: #eee; 290 | } 291 | 292 | .main-menu .link-button { 293 | padding: 5px 10px; 294 | } 295 | 296 | .main-menu .link-button:hover, .main-menu .link-button:focus, .main-menu-close:hover { 297 | background: #ddd; 298 | } 299 | 300 | .main-menu-close { 301 | display: block; 302 | position: absolute; 303 | right: 0; 304 | top: 0; 305 | padding: 5px 10px; 306 | cursor: pointer; 307 | } 308 | 309 | .main-menu .subpage-links { 310 | margin: 0; 311 | } 312 | 313 | #main-menu-checkbox:checked ~ .main-menu { 314 | display: block; 315 | } 316 | 317 | #main-menu-checkbox:checked ~ .main-menu .main-menu-close { 318 | display: block; 319 | z-index: 1001; 320 | } 321 | 322 | #main-menu-checkbox:checked ~ .main-menu div { 323 | position: relative; 324 | z-index: 1000; 325 | } 326 | 327 | // Others 328 | .title { 329 | padding: 0 50px 0 0; 330 | } 331 | 332 | .author-info { 333 | font-size: small; 334 | } 335 | 336 | .author-info br { 337 | display: none; 338 | } 339 | 340 | .author-affiliations { 341 | margin: 0 0 5px; 342 | } 343 | 344 | hr { 345 | height: 0; 346 | border: 0; 347 | background: #fff; 348 | margin: 0; 349 | } 350 | } 351 | 352 | @media print, screen and (max-width: 720px) { 353 | body { 354 | word-wrap: break-word; 355 | } 356 | 357 | header { 358 | padding: 0; 359 | } 360 | 361 | header p.view { 362 | position: static; 363 | } 364 | 365 | pre, code { 366 | word-wrap: normal; 367 | } 368 | } 369 | 370 | @media print, screen and (max-width: 480px) { 371 | body { 372 | padding: 15px; 373 | } 374 | 375 | footer { 376 | padding-bottom: 15px; 377 | } 378 | 379 | .menu-toggle, .main-menu { 380 | right: 20px; 381 | top: 20px; 382 | } 383 | } 384 | 385 | @media print, screen and (max-height: 600px) { 386 | .author-info br { 387 | display: none; 388 | } 389 | 390 | .main-menu .subpage-links { 391 | margin: 0; 392 | } 393 | } 394 | 395 | @media print, screen and (max-height: 480px) { 396 | body { 397 | padding: 15px; 398 | } 399 | 400 | footer { 401 | padding-bottom: 15px; 402 | } 403 | 404 | .menu-toggle, .main-menu { 405 | right: 20px; 406 | top: 20px; 407 | } 408 | 409 | .author-info { 410 | font-size: small; 411 | } 412 | } 413 | 414 | @media print { 415 | body { 416 | padding: 0.4in; 417 | font-size: 12pt; 418 | color: #444; 419 | } 420 | 421 | ul { 422 | padding-left: 20px; 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /src/pypianoroll/track.py: -------------------------------------------------------------------------------- 1 | """Class for single-track piano-rolls with metadata. 2 | 3 | """ 4 | from copy import deepcopy 5 | from six import string_types 6 | import numpy as np 7 | from matplotlib import pyplot as plt 8 | from pypianoroll.plot import plot_pianoroll 9 | 10 | class Track(object): 11 | """ 12 | A single-track piano-roll container. 13 | 14 | Attributes 15 | ---------- 16 | pianoroll : np.ndarray, shape=(num_time_step, 128) 17 | Piano-roll matrix. First dimension represents time. Second dimension 18 | represents pitch. 19 | program: int 20 | Program number according to General MIDI specification. Available 21 | values are 0 to 127. Default to 0 (Acoustic Grand Piano). 22 | is_drum : bool 23 | Drum indicator. True for drums. False for other instruments. 24 | name : str 25 | Name of the track. 26 | 27 | """ 28 | 29 | def __init__(self, pianoroll=None, program=0, is_drum=False, 30 | name='unknown'): 31 | """ 32 | Initialize the object by assigning attributes. 33 | 34 | Parameters 35 | ---------- 36 | pianoroll : np.ndarray, shape=(num_time_step, 128) 37 | Piano-roll matrix. First dimension represents time. Second dimension 38 | represents pitch. Available data types are bool, int, float. 39 | program: int 40 | Program number according to General MIDI specification [1]. 41 | Available values are 0 to 127. Default to 0 (Acoustic Grand Piano). 42 | is_drum : bool 43 | Drum indicator. True for drums. False for other instruments. Default 44 | to False. 45 | name : str 46 | Name of the track. Default to 'unknown'. 47 | 48 | References 49 | ---------- 50 | [1] https://www.midi.org/specifications/item/gm-level-1-sound-set 51 | 52 | """ 53 | if pianoroll is None: 54 | self.pianoroll = np.zeros((0, 128), bool) 55 | else: 56 | self.pianoroll = pianoroll 57 | self.program = program 58 | self.is_drum = is_drum 59 | self.name = name 60 | 61 | self.check_validity() 62 | 63 | def __getitem__(self, val): 64 | return Track(self.pianoroll[val], self.program, self.is_drum, self.name) 65 | 66 | def __repr__(self): 67 | return ("Track(pianoroll={}, program={}, is_drum={}, name={})" 68 | .format(repr(self.pianoroll), self.program, self.is_drum, 69 | self.name)) 70 | 71 | def __str__(self): 72 | return ("pianoroll :\n{},\nprogram : {},\nis_drum : {},\nname : {}" 73 | .format(str(self.pianoroll), self.program, self.is_drum, 74 | self.name)) 75 | 76 | def assign_constant(self, value): 77 | """ 78 | Assign a constant value to all nonzeros in the piano-roll. If the 79 | piano-roll is not binarized, its data type will be preserved. If the 80 | piano-roll is binarized, it will be casted to the type of `value`. 81 | 82 | Arguments 83 | --------- 84 | value : int or float 85 | The constant value to be assigned to the nonzeros of the piano-roll. 86 | 87 | """ 88 | if self.is_binarized(): 89 | self.pianoroll = self.pianoroll * value 90 | return 91 | self.pianoroll[self.pianoroll.nonzero()] = value 92 | 93 | def binarize(self, threshold=0): 94 | """ 95 | Binarize the piano-roll. Do nothing if the piano-roll is already 96 | binarized. 97 | 98 | Parameters 99 | ---------- 100 | threshold : int or float 101 | Threshold to binarize the piano-rolls. Default to zero. 102 | 103 | """ 104 | if not self.is_binarized(): 105 | self.pianoroll = (self.pianoroll > threshold) 106 | 107 | def check_validity(self): 108 | """"Raise error if any invalid attribute found.""" 109 | # pianoroll 110 | if not isinstance(self.pianoroll, np.ndarray): 111 | raise TypeError("`pianoroll` must be of np.ndarray type") 112 | if not (np.issubdtype(self.pianoroll.dtype, np.bool_) 113 | or np.issubdtype(self.pianoroll.dtype, np.number)): 114 | raise TypeError("Data type of `pianoroll` must be of a subdtype of " 115 | "np.bool_ or np.number") 116 | if self.pianoroll.ndim != 2: 117 | raise ValueError("`pianoroll` must be a 2D numpy array") 118 | if self.pianoroll.shape[1] != 128: 119 | raise ValueError("The shape of `pianoroll` must be (num_time_step, " 120 | "128)") 121 | # program 122 | if not isinstance(self.program, int): 123 | raise TypeError("`program` must be of int type") 124 | if self.program < 0 or self.program > 127: 125 | raise ValueError("`program` must be in 0 to 127") 126 | # is_drum 127 | if not isinstance(self.is_drum, bool): 128 | raise TypeError("`is_drum` must be of boolean type") 129 | # name 130 | if not isinstance(self.name, string_types): 131 | raise TypeError("`name` must be of string type") 132 | 133 | def clip(self, lower=0, upper=127): 134 | """ 135 | Clip the piano-roll by an lower bound and an upper bound specified by 136 | `lower` and `upper`, respectively. 137 | 138 | Parameters 139 | ---------- 140 | lower : int or float 141 | The lower bound to clip the piano-roll. Default to 0. 142 | upper : int or float 143 | The upper bound to clip the piano-roll. Default to 127. 144 | 145 | """ 146 | self.pianoroll = self.pianoroll.clip(lower, upper) 147 | 148 | def copy(self): 149 | """ 150 | Return a copy of the object. 151 | 152 | Returns 153 | ------- 154 | copied : `pypianoroll.Track` object 155 | A copy of the object. 156 | 157 | """ 158 | copied = deepcopy(self) 159 | return copied 160 | 161 | def pad(self, pad_length): 162 | """ 163 | Pad the piano-roll with zeros at the end along the time axis. 164 | 165 | Parameters 166 | ---------- 167 | pad_length : int 168 | The length to pad along the time axis with zeros. 169 | 170 | """ 171 | self.pianoroll = np.pad(self.pianoroll, ((0, pad_length), (0, 0)), 172 | 'constant') 173 | 174 | def pad_to_multiple(self, factor): 175 | """ 176 | Pad the piano-roll with zeros at the end along the time axis with the 177 | minimal length that make the length of the resulting piano-roll a 178 | multiple of `factor`. 179 | 180 | Parameters 181 | ---------- 182 | factor : int 183 | The value which the length of the resulting piano-roll will be 184 | a multiple of. 185 | 186 | """ 187 | to_pad = factor - self.pianoroll.shape[0]%factor 188 | self.pianoroll = np.pad(self.pianoroll, ((0, to_pad), (0, 0)), 189 | 'constant') 190 | 191 | def get_pianoroll_copy(self): 192 | """ 193 | Return a copy of the piano-roll. 194 | 195 | Returns 196 | ------- 197 | copied : 198 | A copy of the piano-roll. 199 | 200 | """ 201 | copied = np.copy(self.pianoroll) 202 | return copied 203 | 204 | def get_active_length(self): 205 | """ 206 | Return the active length (i.e. without trailing silence) of the 207 | piano-roll (in time step). 208 | 209 | Returns 210 | ------- 211 | active_length : int 212 | Length of the piano-roll without trailing silence (in time step). 213 | 214 | """ 215 | non_zero_steps = np.any((self.pianoroll > 0), axis=1) 216 | inv_last_non_zero_step = np.argmax(np.flip(non_zero_steps, axis=0)) 217 | active_length = self.pianoroll.shape[0] - inv_last_non_zero_step 218 | return active_length 219 | 220 | def get_active_pitch_range(self): 221 | """ 222 | Return the active pitch range in tuple (lowest, highest). 223 | 224 | Parameters 225 | ---------- 226 | relative : bool 227 | True to return the relative pitch range , i.e. the corresponding 228 | indices in the pitch axis of the piano-roll. False to return the 229 | absolute pitch range. Default to False. 230 | 231 | Returns 232 | ------- 233 | lowest : int 234 | The lowest active pitch in the piano-roll. 235 | highest : int 236 | The highest active pitch in the piano-roll. 237 | 238 | """ 239 | if self.pianoroll.shape[1] < 1: 240 | raise ValueError("Cannot compute the active pitch range for an " 241 | "empty piano-roll") 242 | lowest = 0 243 | highest = 127 244 | while lowest < highest: 245 | if np.any(self.pianoroll[:, lowest]): 246 | break 247 | lowest += 1 248 | if lowest == highest: 249 | raise ValueError("Cannot compute the active pitch range for an " 250 | "empty piano-roll") 251 | while not np.any(self.pianoroll[:, highest]): 252 | highest -= 1 253 | 254 | return lowest, highest 255 | 256 | def is_binarized(self): 257 | """ 258 | Return True if the piano-roll is already binarized. Otherwise, return 259 | False. 260 | 261 | Returns 262 | ------- 263 | is_binarized : bool 264 | True if the piano-roll is already binarized; otherwise, False. 265 | 266 | """ 267 | is_binarized = np.issubdtype(self.pianoroll.dtype, np.bool_) 268 | return is_binarized 269 | 270 | def plot(self, filepath=None, beat_resolution=None, downbeats=None, 271 | preset='default', cmap='Blues', normalization='standard', 272 | xtick='auto', ytick='octave', xticklabel='on', yticklabel='auto', 273 | tick_loc=None, tick_direction='in', label='both', grid='both', 274 | grid_linestyle=':', grid_linewidth=.5): 275 | """ 276 | Plot the piano-roll or save a plot of the piano-roll. 277 | 278 | Parameters 279 | ---------- 280 | filepath : 281 | The filepath to save the plot. If None, default to save nothing. 282 | beat_resolution : int 283 | Resolution of a beat (in time step). Required and only effective 284 | when `xticklabel` is 'beat'. 285 | downbeats : list 286 | Indices of time steps that contain downbeats., i.e. the first time 287 | step of a bar. 288 | normalization : {'standard', 'auto', 'none'} 289 | The normalization method to apply to the piano-roll. Default to 290 | 'standard'. Only effective when `pianoroll` is not binarized. 291 | 292 | - For 'standard' normalization, the normalized values are given by 293 | N = P / 128, where P, N is the original and normalized piano-roll, 294 | respectively 295 | - For 'auto' normalization, the normalized values are given by 296 | N = (P - m) / (M - m), where P, N is the original and normalized 297 | piano-roll, respectively, and M, m is the maximum and minimum of 298 | the original piano-roll, respectively. 299 | - If 'none', no normalization will be applied to the piano-roll. In 300 | this case, the values of `pianoroll` should be in [0, 1] in order 301 | to plot it correctly. 302 | 303 | preset : {'default', 'plain', 'frame'} 304 | Preset themes for the plot. 305 | 306 | - In 'default' preset, the ticks, grid and labels are on. 307 | - In 'frame' preset, the ticks and grid are both off. 308 | - In 'plain' preset, the x- and y-axis are both off. 309 | 310 | cmap : `matplotlib.colors.Colormap` 311 | Colormap to use in :func:`matplotlib.pyplot.imshow`. Default to 312 | 'Blues'. Only effective when `pianoroll` is 2D. 313 | xtick : {'auto', 'beat', 'step', 'off'} 314 | Use beat number or step number as ticks along the x-axis, or 315 | automatically set to 'beat' when `beat_resolution` is given and set 316 | to 'step', otherwise. Default to 'auto'. 317 | ytick : {'octave', 'pitch', 'off'} 318 | Use octave or pitch as ticks along the y-axis. Default to 'octave'. 319 | xticklabel : {'on', 'off'} 320 | Indicate whether to add tick labels along the x-axis. Only effective 321 | when `xtick` is not 'off'. 322 | yticklabel : {'auto', 'name', 'number', 'off'} 323 | If 'name', use octave name and pitch name (key name when `is_drum` 324 | is True) as tick labels along the y-axis. If 'number', use pitch 325 | number. If 'auto', set to 'name' when `ytick` is 'octave' and 326 | 'number' when `ytick` is 'pitch'. Default to 'auto'. Only effective 327 | when `ytick` is not 'off'. 328 | tick_loc : tuple or list 329 | List of locations to put ticks. Availables elements are 'bottom', 330 | 'top', 'left' and 'right'. If None, default to ('bottom', 'left'). 331 | tick_direction : {'in', 'out', 'inout'} 332 | Put ticks inside the axes, outside the axes, or both. Default to 333 | 'in'. Only effective when `xtick` and `ytick` are not both 'off'. 334 | label : {'x', 'y', 'both', 'off'} 335 | Add label to the x-axis, y-axis, both or neither. Default to 'both'. 336 | grid : {'x', 'y', 'both', 'off'} 337 | Add grid to the x-axis, y-axis, both or neither. Default to 'both'. 338 | grid_linestyle : str 339 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linestyle' 340 | argument. 341 | grid_linewidth : float 342 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linewidth' 343 | argument. 344 | 345 | Returns 346 | ------- 347 | fig : `matplotlib.figure.Figure` object 348 | A :class:`matplotlib.figure.Figure` object. 349 | ax : `matplotlib.axes.Axes` object 350 | A :class:`matplotlib.axes.Axes` object. 351 | 352 | """ 353 | fig, ax = plt.subplots() 354 | if self.is_binarized(): 355 | normalization = 'none' 356 | plot_pianoroll(ax, self.pianoroll, self.is_drum, beat_resolution, 357 | downbeats, preset=preset, cmap=cmap, 358 | normalization=normalization, xtick=xtick, ytick=ytick, 359 | xticklabel=xticklabel, yticklabel=yticklabel, 360 | tick_loc=tick_loc, tick_direction=tick_direction, 361 | label=label, grid=grid, grid_linestyle=grid_linestyle, 362 | grid_linewidth=grid_linewidth) 363 | 364 | if filepath is not None: 365 | plt.savefig(filepath) 366 | 367 | return fig, ax 368 | 369 | def transpose(self, semitone): 370 | """ 371 | Transpose the piano-roll by a certain semitones, where positive 372 | values are for higher key, while negative values are for lower key. 373 | 374 | Parameters 375 | ---------- 376 | semitone : int 377 | Number of semitones to transpose the piano-roll. 378 | 379 | """ 380 | if semitone > 0 and semitone < 128: 381 | self.pianoroll[:, semitone:] = self.pianoroll[:, :(128 - semitone)] 382 | self.pianoroll[:, :semitone] = 0 383 | elif semitone < 0 and semitone > -128: 384 | self.pianoroll[:, :(128 + semitone)] = self.pianoroll[:, -semitone:] 385 | self.pianoroll[:, (128 + semitone):] = 0 386 | 387 | def trim_trailing_silence(self): 388 | """Trim the trailing silence of the piano-roll.""" 389 | length = self.get_active_length() 390 | self.pianoroll = self.pianoroll[:length] 391 | -------------------------------------------------------------------------------- /src/pypianoroll/plot.py: -------------------------------------------------------------------------------- 1 | """Module for plotting multi-track and single-track piano-rolls 2 | 3 | """ 4 | import numpy as np 5 | from matplotlib import pyplot as plt 6 | from moviepy.editor import VideoClip 7 | from moviepy.video.io.bindings import mplfig_to_npimage 8 | import pretty_midi 9 | 10 | def plot_pianoroll(ax, pianoroll, is_drum=False, beat_resolution=None, 11 | downbeats=None, preset='default', cmap='Blues', 12 | normalization='standard', xtick='auto', ytick='octave', 13 | xticklabel='on', yticklabel='auto', tick_loc=None, 14 | tick_direction='in', label='both', grid='both', 15 | grid_linestyle=':', grid_linewidth=.5): 16 | """ 17 | Plot a piano-roll given as a numpy array 18 | 19 | Parameters 20 | ---------- 21 | ax : matplotlib.axes.Axes object 22 | The :class:`matplotlib.axes.Axes` object where the piano-roll will 23 | be plotted on. 24 | pianoroll : np.ndarray 25 | The piano-roll to be plotted. The values should be in [0, 1] when 26 | `normalized` is False. 27 | 28 | - For a 2D array, shape=(num_time_step, num_pitch). 29 | - For a 3D array, shape=(num_time_step, num_pitch, num_channel), 30 | where channels can be either RGB or RGBA. 31 | 32 | is_drum : bool 33 | Drum indicator. True for drums. False for other instruments. Default 34 | to False. 35 | beat_resolution : int 36 | Resolution of a beat (in time step). Required and only effective 37 | when `xticklabel` is 'beat'. 38 | downbeats : list 39 | Indices of time steps that contain downbeats., i.e. the first time 40 | step of a bar. 41 | preset : {'default', 'plain', 'frame'} 42 | Preset themes for the plot. 43 | 44 | - In 'default' preset, the ticks, grid and labels are on. 45 | - In 'frame' preset, the ticks and grid are both off. 46 | - In 'plain' preset, the x- and y-axis are both off. 47 | 48 | cmap : `matplotlib.colors.Colormap` 49 | Colormap to use in :func:`matplotlib.pyplot.imshow`. Default to 50 | 'Blues'. Only effective when `pianoroll` is 2D. 51 | normalization : {'standard', 'auto', 'none'} 52 | The normalization method to apply to the piano-roll. Default to 53 | 'standard'. Only effective when `pianoroll` is not binarized. 54 | 55 | - For 'standard' normalization, the normalized values are given by 56 | N = P / 128, where P, N is the original and normalized piano-roll, 57 | respectively 58 | - For 'auto' normalization, the normalized values are given by 59 | N = (P - m) / (M - m), where P, N is the original and normalized 60 | piano-roll, respectively, and M, m is the maximum and minimum of 61 | the original piano-roll, respectively. 62 | - If 'none', no normalization will be applied to the piano-roll. In 63 | this case, the values of `pianoroll` should be in [0, 1] in order 64 | to plot it correctly. 65 | 66 | xtick : {'auto', 'beat', 'step', 'off'} 67 | Use beat number or step number as ticks along the x-axis, or 68 | automatically set to 'beat' when `beat_resolution` is given and set 69 | to 'step', otherwise. Default to 'auto'. 70 | ytick : {'octave', 'pitch', 'off'} 71 | Use octave or pitch as ticks along the y-axis. Default to 'octave'. 72 | xticklabel : {'on', 'off'} 73 | Indicate whether to add tick labels along the x-axis. Only effective 74 | when `xtick` is not 'off'. 75 | yticklabel : {'auto', 'name', 'number', 'off'} 76 | If 'name', use octave name and pitch name (key name when `is_drum` 77 | is True) as tick labels along the y-axis. If 'number', use pitch 78 | number. If 'auto', set to 'name' when `ytick` is 'octave' and 79 | 'number' when `ytick` is 'pitch'. Default to 'auto'. Only effective 80 | when `ytick` is not 'off'. 81 | tick_loc : tuple or list 82 | List of locations to put ticks. Availables elements are 'bottom', 83 | 'top', 'left' and 'right'. If None, default to ('bottom', 'left'). 84 | tick_direction : {'in', 'out', 'inout'} 85 | Put ticks inside the axes, outside the axes, or both. Default to 86 | 'in'. Only effective when `xtick` and `ytick` are not both 'off'. 87 | label : {'x', 'y', 'both', 'off'} 88 | Add label to the x-axis, y-axis, both or neither. Default to 'both'. 89 | grid : {'x', 'y', 'both', 'off'} 90 | Add grid to the x-axis, y-axis, both or neither. Default to 'both'. 91 | grid_linestyle : str 92 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linestyle' 93 | argument. 94 | grid_linewidth : float 95 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linewidth' 96 | argument. 97 | 98 | """ 99 | if pianoroll.ndim not in (2, 3): 100 | raise ValueError("`pianoroll` must be a 2D or 3D numpy array") 101 | if pianoroll.shape[1] != 128: 102 | raise ValueError("The shape of `pianoroll` must be (num_time_step, " 103 | "128)") 104 | if xtick not in ('auto', 'beat', 'step', 'off'): 105 | raise ValueError("`xtick` must be one of {'auto', 'beat', 'step', " 106 | "'none'}") 107 | if xtick is 'beat' and beat_resolution is None: 108 | raise ValueError("`beat_resolution` must be a number when `xtick` " 109 | "is 'beat'") 110 | if ytick not in ('octave', 'pitch', 'off'): 111 | raise ValueError("`ytick` must be one of {octave', 'pitch', 'off'}") 112 | if xticklabel not in ('on', 'off'): 113 | raise ValueError("`xticklabel` must be 'on' or 'off'") 114 | if yticklabel not in ('auto', 'name', 'number', 'off'): 115 | raise ValueError("`yticklabel` must be one of {'auto', 'name', " 116 | "'number', 'off'}") 117 | if tick_direction not in ('in', 'out', 'inout'): 118 | raise ValueError("`tick_direction` must be one of {'in', 'out'," 119 | "'inout'}") 120 | if label not in ('x', 'y', 'both', 'off'): 121 | raise ValueError("`label` must be one of {'x', 'y', 'both', 'off'}") 122 | if grid not in ('x', 'y', 'both', 'off'): 123 | raise ValueError("`grid` must be one of {'x', 'y', 'both', 'off'}") 124 | 125 | # plotting 126 | if pianoroll.ndim > 2: 127 | to_plot = pianoroll.transpose(1, 0, 2) 128 | else: 129 | to_plot = pianoroll.T 130 | if normalization == 'standard': 131 | to_plot = to_plot / 128. 132 | elif normalization == 'auto': 133 | max_value = np.max(to_plot) 134 | min_value = np.min(to_plot) 135 | to_plot = to_plot - min_value / (max_value - min_value) 136 | ax.imshow(to_plot, cmap=cmap, aspect='auto', vmin=0, vmax=1, origin='lower', 137 | interpolation='none') 138 | 139 | # tick setting 140 | if tick_loc is None: 141 | tick_loc = ('bottom', 'left') 142 | if xtick == 'auto': 143 | xtick = 'beat' if beat_resolution is not None else 'step' 144 | if yticklabel == 'auto': 145 | yticklabel = 'name' if ytick == 'octave' else 'number' 146 | 147 | if preset == 'plain': 148 | ax.axis('off') 149 | elif preset == 'frame': 150 | ax.tick_params(direction=tick_direction, bottom='off', top='off', 151 | left='off', right='off', labelbottom='off', 152 | labeltop='off', labelleft='off', labelright='off') 153 | else: 154 | labelbottom = 'on' if xticklabel != 'off' else 'off' 155 | labelleft = 'on' if yticklabel != 'off' else 'off' 156 | 157 | ax.tick_params(direction=tick_direction, bottom=('bottom' in tick_loc), 158 | top=('top' in tick_loc), left=('left' in tick_loc), 159 | right=('right' in tick_loc), labelbottom=labelbottom, 160 | labeltop='off', labelleft=labelleft, labelright='off') 161 | 162 | # x-axis 163 | if xtick == 'beat' and preset != 'frame': 164 | num_beat = pianoroll.shape[0]//beat_resolution 165 | xticks_major = beat_resolution * np.arange(0, num_beat) 166 | xticks_minor = beat_resolution * (0.5 + np.arange(0, num_beat)) 167 | xtick_labels = np.arange(1, 1 + num_beat) 168 | ax.set_xticks(xticks_major) 169 | ax.set_xticklabels('') 170 | ax.set_xticks(xticks_minor, minor=True) 171 | ax.set_xticklabels(xtick_labels, minor=True) 172 | ax.tick_params(axis='x', which='minor', width=0) 173 | 174 | # y-axis 175 | if ytick == 'octave': 176 | ax.set_yticks(np.arange(0, 128, 12)) 177 | if yticklabel == 'name': 178 | ax.set_yticklabels(['C{}'.format(i - 2) for i in range(11)]) 179 | elif ytick == 'step': 180 | ax.set_yticks(np.arange(0, 128)) 181 | if yticklabel == 'name': 182 | if is_drum: 183 | ax.set_yticklabels([pretty_midi.note_number_to_drum_name(i) 184 | for i in range(128)]) 185 | else: 186 | ax.set_yticklabels([pretty_midi.note_number_to_name(i) 187 | for i in range(128)]) 188 | 189 | # axis labels 190 | if label == 'x' or label == 'both': 191 | if xtick == 'step' or xticklabel == 'off': 192 | ax.set_xlabel('time (step)') 193 | else: 194 | ax.set_xlabel('time (beat)') 195 | 196 | if label == 'y' or label == 'both': 197 | if is_drum: 198 | ax.set_ylabel('key name') 199 | else: 200 | ax.set_ylabel('pitch') 201 | 202 | # grid 203 | if grid != 'off': 204 | ax.grid(axis=grid, color='k', linestyle=grid_linestyle, 205 | linewidth=grid_linewidth) 206 | 207 | # downbeat boarder 208 | if downbeats is not None and preset != 'plain': 209 | for step in downbeats: 210 | ax.axvline(x=step, color='k', linewidth=1) 211 | 212 | def save_animation(filepath, pianoroll, window, hop=1, fps=None, is_drum=False, 213 | beat_resolution=None, downbeats=None, preset='default', 214 | cmap='Blues', normalization='standard', xtick='auto', 215 | ytick='octave', xticklabel='on', yticklabel='auto', 216 | tick_loc=None, tick_direction='in', label='both', 217 | grid='both', grid_linestyle=':', grid_linewidth=.5, 218 | **kwargs): 219 | """ 220 | Save a piano-roll to an animation in video or GIF format. 221 | 222 | Parameters 223 | ---------- 224 | filepath : str 225 | Path to save the video file. 226 | pianoroll : np.ndarray 227 | The piano-roll to be plotted. The values should be in [0, 1] when 228 | `normalized` is False. 229 | 230 | - For a 2D array, shape=(num_time_step, num_pitch). 231 | - For a 3D array, shape=(num_time_step, num_pitch, num_channel), 232 | where channels can be either RGB or RGBA. 233 | 234 | window : int 235 | Window size to be applied to `pianoroll` for the animation. 236 | hop : int 237 | Hop size to be applied to `pianoroll` for the animation. 238 | fps : int 239 | Number of frames per second in the resulting video or GIF file. 240 | is_drum : bool 241 | Drum indicator. True for drums. False for other instruments. Default 242 | to False. 243 | beat_resolution : int 244 | Resolution of a beat (in time step). Required and only effective 245 | when `xticklabel` is 'beat'. 246 | downbeats : list 247 | Indices of time steps that contain downbeats., i.e. the first time 248 | step of a bar. 249 | normalization : {'standard', 'auto', 'none'} 250 | The normalization method to apply to the piano-roll. Default to 251 | 'standard'. Only effective when `pianoroll` is not binarized. 252 | 253 | - For 'standard' normalization, the normalized values are given by 254 | N = P / 128, where P, N is the original and normalized piano-roll, 255 | respectively 256 | - For 'auto' normalization, the normalized values are given by 257 | N = (P - m) / (M - m), where P, N is the original and normalized 258 | piano-roll, respectively, and M, m is the maximum and minimum of 259 | the original piano-roll, respectively. 260 | - If 'none', no normalization will be applied to the piano-roll. In 261 | this case, the values of `pianoroll` should be in [0, 1] in order 262 | to plot it correctly. 263 | 264 | preset : {'default', 'plain', 'frame'} 265 | Preset themes for the plot. 266 | 267 | - In 'default' preset, the ticks, grid and labels are on. 268 | - In 'frame' preset, the ticks and grid are both off. 269 | - In 'plain' preset, the x- and y-axis are both off. 270 | 271 | cmap : `matplotlib.colors.Colormap` 272 | Colormap to use in :func:`matplotlib.pyplot.imshow`. Default to 273 | 'Blues'. Only effective when `pianoroll` is 2D. 274 | xtick : {'auto', 'beat', 'step', 'off'} 275 | Use beat number or step number as ticks along the x-axis, or 276 | automatically set to 'beat' when `beat_resolution` is given and set 277 | to 'step', otherwise. Default to 'auto'. 278 | ytick : {'octave', 'pitch', 'off'} 279 | Use octave or pitch as ticks along the y-axis. Default to 'octave'. 280 | xticklabel : {'on', 'off'} 281 | Indicate whether to add tick labels along the x-axis. Only effective 282 | when `xtick` is not 'off'. 283 | yticklabel : {'auto', 'name', 'number', 'off'} 284 | If 'name', use octave name and pitch name (key name when `is_drum` 285 | is True) as tick labels along the y-axis. If 'number', use pitch 286 | number. If 'auto', set to 'name' when `ytick` is 'octave' and 287 | 'number' when `ytick` is 'pitch'. Default to 'auto'. Only effective 288 | when `ytick` is not 'off'. 289 | tick_loc : tuple or list 290 | List of locations to put ticks. Availables elements are 'bottom', 291 | 'top', 'left' and 'right'. If None, default to ('bottom', 'left'). 292 | tick_direction : {'in', 'out', 'inout'} 293 | Put ticks inside the axes, outside the axes, or both. Default to 294 | 'in'. Only effective when `xtick` and `ytick` are not both 'off'. 295 | label : {'x', 'y', 'both', 'off'} 296 | Add label to the x-axis, y-axis, both or neither. Default to 'both'. 297 | grid : {'x', 'y', 'both', 'off'} 298 | Add grid to the x-axis, y-axis, both or neither. Default to 'both'. 299 | grid_linestyle : str 300 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linestyle' 301 | argument. 302 | grid_linewidth : float 303 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linewidth' 304 | argument. 305 | 306 | """ 307 | def make_frame(t): 308 | """Return an image of the frame for time t""" 309 | fig = plt.gcf() 310 | ax = plt.gca() 311 | f_idx = int(t * fps) 312 | start = hop * f_idx 313 | end = start + window 314 | to_plot = pianoroll[start:end].T / 128. 315 | extent = (start, end - 1, 0, 127) 316 | ax.imshow(to_plot, cmap=cmap, aspect='auto', vmin=0, vmax=1, 317 | origin='lower', interpolation='none', extent=extent) 318 | 319 | if xtick == 'beat': 320 | next_major_idx = beat_resolution - start % beat_resolution 321 | if start % beat_resolution < beat_resolution//2: 322 | next_minor_idx = beat_resolution//2 - start % beat_resolution 323 | else: 324 | next_minor_idx = (beat_resolution//2 - start % beat_resolution 325 | + beat_resolution) 326 | xticks_major = np.arange(next_major_idx, window, beat_resolution) 327 | xticks_minor = np.arange(next_minor_idx, window, beat_resolution) 328 | if end % beat_resolution < beat_resolution//2: 329 | last_minor_idx = beat_resolution//2 - end % beat_resolution 330 | else: 331 | last_minor_idx = (beat_resolution//2 - end % beat_resolution 332 | + beat_resolution) 333 | xtick_labels = np.arange((start + next_minor_idx)//beat_resolution, 334 | (end + last_minor_idx)//beat_resolution) 335 | ax.set_xticks(xticks_major) 336 | ax.set_xticklabels('') 337 | ax.set_xticks(xticks_minor, minor=True) 338 | ax.set_xticklabels(xtick_labels, minor=True) 339 | ax.tick_params(axis='x', which='minor', width=0) 340 | 341 | return mplfig_to_npimage(fig) 342 | 343 | if xtick == 'auto': 344 | xtick = 'beat' if beat_resolution is not None else 'step' 345 | 346 | fig, ax = plt.subplots() 347 | plot_pianoroll(ax, pianoroll[:window], is_drum, beat_resolution, downbeats, 348 | preset=preset, cmap=cmap, normalization=normalization, 349 | xtick=xtick, ytick=ytick, xticklabel=xticklabel, 350 | yticklabel=yticklabel, tick_loc=tick_loc, 351 | tick_direction=tick_direction, label=label, grid=grid, 352 | grid_linestyle=grid_linestyle, grid_linewidth=grid_linewidth) 353 | 354 | num_frame = int((pianoroll.shape[0] - window) / hop) 355 | duration = int(num_frame / fps) 356 | animation = VideoClip(make_frame, duration=duration) 357 | if filepath.endswith('.gif'): 358 | animation.write_gif(filepath, fps, kwargs) 359 | else: 360 | animation.write_videofile(filepath, fps, kwargs) 361 | plt.close() 362 | -------------------------------------------------------------------------------- /src/pypianoroll/multitrack.py: -------------------------------------------------------------------------------- 1 | """Class for multi-track piano-rolls with metadata. 2 | 3 | """ 4 | from __future__ import division 5 | import json 6 | import zipfile 7 | from copy import deepcopy 8 | from six import string_types 9 | import numpy as np 10 | import matplotlib 11 | from matplotlib import pyplot as plt 12 | from matplotlib.patches import Patch 13 | from scipy.sparse import csc_matrix 14 | import pretty_midi 15 | from pypianoroll.track import Track 16 | from pypianoroll.plot import plot_pianoroll 17 | 18 | class Multitrack(object): 19 | """ 20 | A multi-track piano-roll container 21 | 22 | Attributes 23 | ---------- 24 | tracks : list 25 | List of :class:`pypianoroll.Track` objects. 26 | tempo : np.ndarray, shape=(num_time_step,), dtype=float 27 | Tempo array that indicates the tempo value (in bpm) at each time 28 | step. Length is the number of time steps. 29 | downbeat : np.ndarray, shape=(num_time_step,), dtype=bool 30 | Downbeat array that indicates whether the time step contains a 31 | downbeat, i.e. the first time step of a bar. Length is the number of 32 | time steps. 33 | beat_resolution : int 34 | Resolution of a beat (in time step). 35 | name : str 36 | Name of the multi-track piano-roll. 37 | 38 | """ 39 | def __init__(self, filepath=None, tracks=None, tempo=120.0, downbeat=None, 40 | beat_resolution=24, name='unknown'): 41 | """ 42 | Initialize the object by one of the following ways: 43 | - parsing a MIDI file 44 | - loading a .npz file 45 | - assigning values for attributes 46 | 47 | Notes 48 | ----- 49 | When `filepath` is given, ignore arguments `tracks`, `tempo`, `downbeat` 50 | and `name`. 51 | 52 | Parameters 53 | ---------- 54 | filepath : str 55 | File path to a MIDI file (.mid, .midi, .MID, .MIDI) to be parsed or 56 | a .npz file to be loaded. 57 | beat_resolution : int 58 | Resolution of a beat (in time step). Will be assigned to 59 | `beat_resolution` when `filepath` is not provided. Default to 24. 60 | tracks : list 61 | List of :class:`pypianoroll.Track` objects to be added to the track 62 | list when `filepath` is not provided. 63 | tempo : int or np.ndarray, shape=(num_time_step,), dtype=float 64 | Tempo array that indicates the tempo value (in bpm) at each time 65 | step. Length is the number of time steps. Will be assigned to 66 | `tempo` when `filepath` is not provided. If an integer is provided, 67 | it will be first converted to a numpy array. Default to 120.0. 68 | downbeat : list or np.ndarray, shape=(num_time_step,), dtype=bool 69 | Downbeat array that indicates whether the time step contains a 70 | downbeat, i.e. the first time step of a bar. Length is the number of 71 | time steps. Will be assigned to `downbeat` when `filepath` is not 72 | provided. If a list of indices is provided, it will be viewed as the 73 | time step indices of the down beats and converted to a numpy array. 74 | Default is None. 75 | name : str 76 | Name of the multi-track piano-roll. Default to 'unknown'. 77 | 78 | """ 79 | # parse input file 80 | if filepath is not None: 81 | if filepath.endswith(('.mid', '.midi', '.MID', '.MIDI')): 82 | self.beat_resolution = beat_resolution 83 | self.name = name 84 | self.parse_midi(filepath) 85 | elif filepath.endswith('.npz'): 86 | self.load(filepath) 87 | else: 88 | raise ValueError("Unsupported file type") 89 | else: 90 | if tracks is not None: 91 | self.tracks = tracks 92 | else: 93 | self.tracks = [Track()] 94 | if isinstance(tempo, (int, float)): 95 | self.tempo = np.array([tempo]) 96 | else: 97 | self.tempo = tempo 98 | if isinstance(downbeat, list): 99 | self.downbeat = np.zeros((max(downbeat) + 1,), bool) 100 | self.downbeat[downbeat] = True 101 | else: 102 | self.downbeat = downbeat 103 | self.beat_resolution = beat_resolution 104 | self.name = name 105 | self.check_validity() 106 | 107 | def __getitem__(self, val): 108 | if isinstance(val, tuple): 109 | if isinstance(val[0], int): 110 | tracks = [self.tracks[val[0]][val[1:]]] 111 | if isinstance(val[0], list): 112 | tracks = [self.tracks[i][val[1:]] for i in val[0]] 113 | else: 114 | tracks = [track[val[1:]] for track in self.tracks[val[0]]] 115 | if self.downbeat is not None: 116 | downbeat = self.downbeat[val[1]] 117 | else: 118 | downbeat = None 119 | return Multitrack(tracks=tracks, tempo=self.tempo[val[1]], 120 | downbeat=downbeat, 121 | beat_resolution=self.beat_resolution, 122 | name=self.name) 123 | if isinstance(val, list): 124 | tracks = [self.tracks[i] for i in val] 125 | else: 126 | tracks = self.tracks[val] 127 | return Multitrack(tracks=tracks, tempo=self.tempo, 128 | downbeat=self.downbeat, 129 | beat_resolution=self.beat_resolution, name=self.name) 130 | 131 | def __repr__(self): 132 | track_names = ', '.join([repr(track.name) for track in self.tracks]) 133 | return ("Multitrack(tracks=[{}], tempo={}, downbeat={}, beat_resolution" 134 | "={}, name={})".format(track_names, repr(self.tempo), 135 | repr(self.downbeat), 136 | self.beat_resolution, self.name)) 137 | 138 | def __str__(self): 139 | track_names = ', '.join([str(track.name) for track in self.tracks]) 140 | return ("tracks : [{}],\ntempo : {},\ndownbeat : {},\nbeat_resolution " 141 | ": {},\nname : {}".format(track_names, str(self.tempo), 142 | str(self.downbeat), 143 | self.beat_resolution, self.name)) 144 | 145 | def append_track(self, track=None, pianoroll=None, program=0, is_drum=False, 146 | name='unknown'): 147 | """ 148 | Append a multitrack.Track instance to the track list or create a new 149 | multitrack.Track object and append it to the track list. 150 | 151 | Parameters 152 | ---------- 153 | track : pianoroll.Track 154 | A :class:`pypianoroll.Track` instance to be appended to the track 155 | list. 156 | pianoroll : np.ndarray, shape=(num_time_step, 128) 157 | Piano-roll matrix. First dimension represents time. Second dimension 158 | represents pitch. Available datatypes are bool, int, float. 159 | program: int 160 | Program number according to General MIDI specification [1]. 161 | Available values are 0 to 127. Default to 0 (Acoustic Grand Piano). 162 | is_drum : bool 163 | Drum indicator. True for drums. False for other instruments. Default 164 | to False. 165 | name : str 166 | Name of the track. Default to 'unknown'. 167 | 168 | References 169 | ---------- 170 | [1] https://www.midi.org/specifications/item/gm-level-1-sound-set 171 | 172 | """ 173 | if track is not None: 174 | if not isinstance(track, Track): 175 | raise TypeError("`track` must be a pypianoroll.Track instance") 176 | track.check_validity() 177 | else: 178 | track = Track(pianoroll, program, is_drum, name) 179 | self.tracks.append(track) 180 | 181 | def assign_constant(self, value): 182 | """ 183 | Assign a constant value to the nonzeros in the piano-rolls. If a 184 | piano-roll is not binarized, its data type will be preserved. If a 185 | piano-roll is binarized, it will be casted to the type of `value`. 186 | 187 | Arguments 188 | --------- 189 | value : int or float 190 | The constant value to be assigned to the nonzeros of the 191 | piano-rolls. 192 | 193 | """ 194 | for track in self.tracks: 195 | track.assign_constant(value) 196 | 197 | def binarize(self, threshold=0): 198 | """ 199 | Binarize the piano-rolls of all tracks. Pass the track if its piano-roll 200 | is already binarized. 201 | 202 | Parameters 203 | ---------- 204 | threshold : int or float 205 | Threshold to binarize the piano-rolls. Default to zero. 206 | 207 | """ 208 | for track in self.tracks: 209 | track.binarize(threshold) 210 | 211 | def check_validity(self): 212 | """ 213 | Raise an error if any invalid attribute found. 214 | 215 | Raises 216 | ------ 217 | TypeError 218 | If an attribute has an invalid type. 219 | ValueError 220 | If an attribute has an invalid value (of the correct type). 221 | 222 | """ 223 | # tracks 224 | for track in self.tracks: 225 | if not isinstance(track, Track): 226 | raise TypeError("`tracks` must be a list of " 227 | "`pypianoroll.Track` instances") 228 | track.check_validity() 229 | # tempo 230 | if not isinstance(self.tempo, np.ndarray): 231 | raise TypeError("`tempo` must be of int or np.ndarray type") 232 | elif not np.issubdtype(self.tempo.dtype, np.number): 233 | raise TypeError("Data type of `tempo` must be of a subdtype of " 234 | "np.number") 235 | elif self.tempo.ndim != 1: 236 | raise ValueError("`tempo` must be a 1D numpy array") 237 | if np.any(self.tempo <= 0.0): 238 | raise ValueError("`tempo` must contains only positive numbers") 239 | # downbeat 240 | if self.downbeat is not None: 241 | if not isinstance(self.downbeat, np.ndarray): 242 | raise TypeError("`downbeat` must be of np.ndarray type") 243 | if not np.issubdtype(self.downbeat.dtype, np.bool_): 244 | raise TypeError("Data type of `downbeat` must be bool.") 245 | if self.downbeat.ndim != 1: 246 | raise ValueError("`downbeat` must be a 1D numpy array") 247 | # beat_resolution 248 | if not isinstance(self.beat_resolution, int): 249 | raise TypeError("`beat_resolution` must be of int type") 250 | if self.beat_resolution < 1: 251 | raise ValueError("`beat_resolution` must be a positive integer") 252 | if self.beat_resolution%2 > 0: 253 | raise ValueError("`beat_resolution` must be an even number") 254 | # name 255 | if not isinstance(self.name, string_types): 256 | raise TypeError("`name` must be of str type") 257 | 258 | def clip(self, lower=0, upper=127): 259 | """ 260 | Clip the piano-rolls by an lower bound and an upper bound specified by 261 | `lower` and `upper`, respectively. 262 | 263 | Parameters 264 | ---------- 265 | lower : int or float 266 | The lower bound to clip the piano-roll. Default to 0. 267 | upper : int or float 268 | The upper bound to clip the piano-roll. Default to 127. 269 | 270 | """ 271 | for track in self.tracks: 272 | track.clip(lower, upper) 273 | 274 | def copy(self): 275 | """ 276 | Return a copy of the object. 277 | 278 | Returns 279 | ------- 280 | copied : `pypianoroll.Multitrack` object 281 | A copy of the object. 282 | 283 | """ 284 | copied = deepcopy(self) 285 | return copied 286 | 287 | def pad_to_same(self): 288 | """ 289 | Pad shorter piano-rolls with zeros at the end along the time axis to the 290 | length of the piano-roll with the maximal length. 291 | 292 | """ 293 | maximal_length = self.get_maximal_length() 294 | for track in self.tracks: 295 | if track.pianoroll.shape[0] < maximal_length: 296 | track.pad(maximal_length - track.pianoroll.shape[0]) 297 | 298 | def get_active_length(self): 299 | """ 300 | Return the maximal active length (i.e. without trailing silence) of the 301 | piano-rolls (in time step). 302 | 303 | Returns 304 | ------- 305 | active_length : int 306 | The maximal active length (i.e. without trailing silence) of the 307 | piano-rolls (in time step). 308 | 309 | """ 310 | active_length = 0 311 | for track in self.tracks: 312 | now_length = track.get_active_length() 313 | if active_length < track.get_active_length(): 314 | active_length = now_length 315 | return active_length 316 | 317 | def get_downbeat_steps(self): 318 | """ 319 | Return a list of indices of time steps that contain downbeats. 320 | 321 | Returns 322 | ------- 323 | downbeat_steps : list 324 | Indices of time steps that contain downbeats. 325 | 326 | """ 327 | if self.downbeat is None: 328 | return [] 329 | downbeat_steps = np.nonzero(self.downbeat)[0].tolist() 330 | return downbeat_steps 331 | 332 | def get_empty_tracks(self): 333 | """ 334 | Return the indices of tracks with empty piano-rolls. 335 | 336 | Returns 337 | ------- 338 | empty_track_indices : list 339 | List of the indices of tracks with empty piano-rolls. 340 | 341 | """ 342 | empty_track_indices = [idx for idx, track in enumerate(self.tracks) 343 | if not np.any(track.pianoroll)] 344 | return empty_track_indices 345 | 346 | def get_maximal_length(self): 347 | """ 348 | Return the maximal length of the piano-rolls along the time axis (in 349 | time step). 350 | 351 | Returns 352 | ------- 353 | maximal_length : int 354 | The maximal length of the piano-rolls along the time axis (in time 355 | step). 356 | 357 | """ 358 | maximal_length = 0 359 | for track in self.tracks: 360 | if maximal_length < track.pianoroll.shape[0]: 361 | maximal_length = track.pianoroll.shape[0] 362 | return maximal_length 363 | 364 | def get_merged_pianoroll(self, mode='sum'): 365 | """ 366 | Return a merged piano-roll. 367 | 368 | Parameters 369 | ---------- 370 | mode : {'sum', 'max', 'any'} 371 | Indicate the merging function to apply along the track axis. Default 372 | to 'sum'. 373 | 374 | - In 'sum' mode, the piano-roll of the merged track is the summation 375 | of the collected piano-rolls. Note that for binarized piano-roll, 376 | integer summation is performed. 377 | - In 'max' mode, for each pixel, the maximal value among the 378 | collected piano-rolls is assigned to the merged piano-roll. 379 | - In 'any' mode, the value of a pixel in the merged piano-roll is 380 | True if any of the collected piano-rolls has nonzero value at that 381 | pixel; False if all piano-rolls are inactive (zero-valued) at that 382 | pixel. 383 | 384 | Returns 385 | ------- 386 | merged : np.ndarray, shape=(num_time_step, 128) 387 | The merged piano-rolls. 388 | 389 | """ 390 | if mode not in ('max', 'sum', 'any'): 391 | raise ValueError("`mode` must be one of {'max', 'sum', 'any'}") 392 | 393 | stacked = self.get_stacked_pianorolls() 394 | 395 | if mode == 'any': 396 | merged = np.any(stacked, axis=2) 397 | elif mode == 'sum': 398 | merged = np.sum(stacked, axis=2) 399 | elif mode == 'max': 400 | merged = np.max(stacked, axis=2) 401 | 402 | return merged 403 | 404 | def get_num_downbeat(self): 405 | """ 406 | Return the number of down beats. The return value is calculated based 407 | solely on `downbeat`. 408 | 409 | Returns 410 | ------- 411 | num_bar : int 412 | The number of down beats according to `downbeat`. 413 | 414 | """ 415 | num_downbeat = np.sum(self.downbeat) 416 | return num_downbeat 417 | 418 | def get_active_pitch_range(self): 419 | """ 420 | Return the overall active pitch range of the piano-rolls. 421 | 422 | Returns 423 | ------- 424 | lowest : int 425 | The lowest active pitch of the piano-rolls. 426 | highest : int 427 | The lowest highest pitch of the piano-rolls. 428 | 429 | """ 430 | lowest, highest = self.tracks[0].get_active_pitch_range() 431 | if len(self.tracks) > 1: 432 | for track in self.tracks[1:]: 433 | low, high = track.get_active_pitch_range() 434 | if low < lowest: 435 | lowest = low 436 | if high > highest: 437 | highest = high 438 | return lowest, highest 439 | 440 | def get_stacked_pianorolls(self): 441 | """ 442 | Return a stacked multi-track piano-roll. The shape of the return 443 | np.ndarray is (num_time_step, 128, num_track). 444 | 445 | Returns 446 | ------- 447 | stacked : np.ndarray, shape=(num_time_step, 128, num_track) 448 | The stacked piano-roll. 449 | 450 | """ 451 | multitrack = deepcopy(self) 452 | multitrack.pad_to_same() 453 | stacked = np.stack([track.pianoroll for track in multitrack.tracks], -1) 454 | return stacked 455 | 456 | def is_binarized(self): 457 | """ 458 | Return True if the pianorolls of all tracks are already binarized. 459 | Otherwise, return False. 460 | 461 | Returns 462 | ------- 463 | is_binarized : bool 464 | True if all the collected piano-rolls are already binarized; 465 | otherwise, False. 466 | 467 | """ 468 | for track in self.tracks: 469 | if not track.is_binarized(): 470 | return False 471 | return True 472 | 473 | def load(self, filepath): 474 | """ 475 | Load a .npz file. Supports only files previously saved by 476 | :meth:`pypianoroll.Multitrack.save`. 477 | 478 | Notes 479 | ----- 480 | Previous values of attributes will all be cleared. 481 | 482 | Parameters 483 | ---------- 484 | filepath : str 485 | The path to the .npz file. 486 | 487 | """ 488 | def reconstruct_sparse(target_dict, name): 489 | """ 490 | Return the reconstructed scipy.sparse.csc_matrix, whose components 491 | are stored in `target_dict` with prefix given as `name`. 492 | 493 | """ 494 | return csc_matrix((target_dict[name+'_csc_data'], 495 | target_dict[name+'_csc_indices'], 496 | target_dict[name+'_csc_indptr']), 497 | shape=target_dict[name+'_csc_shape']).toarray() 498 | 499 | with np.load(filepath) as loaded: 500 | if 'info.json' not in loaded: 501 | raise ValueError("Cannot find 'info.json' in the .npz file") 502 | info_dict = json.loads(loaded['info.json'].decode('utf-8')) 503 | self.name = info_dict['name'] 504 | self.beat_resolution = info_dict['beat_resolution'] 505 | 506 | self.tempo = loaded['tempo'] 507 | if 'downbeat' in loaded.files: 508 | self.downbeat = loaded['downbeat'] 509 | else: 510 | self.downbeat = None 511 | 512 | idx = 0 513 | self.tracks = [] 514 | while str(idx) in info_dict: 515 | pianoroll = reconstruct_sparse(loaded, 516 | 'pianoroll_{}'.format(idx)) 517 | track = Track(pianoroll, info_dict[str(idx)]['program'], 518 | info_dict[str(idx)]['is_drum'], 519 | info_dict[str(idx)]['name']) 520 | self.tracks.append(track) 521 | idx += 1 522 | 523 | self.check_validity() 524 | 525 | def merge_tracks(self, track_indices=None, mode='sum', program=0, 526 | is_drum=False, name='merged', remove_merged=False): 527 | """ 528 | Merge piano-rolls of tracks specified by `track_indices`. The merged 529 | track will have program number as given by `program` and drum indicator 530 | as given by `is_drum`. The merged track will be appended at the end of 531 | the track list. 532 | 533 | Parameters 534 | ---------- 535 | track_indices : list 536 | List of indices that indicates which tracks to merge. If None, 537 | default to merge all tracks. 538 | mode : {'sum', 'max', 'any'} 539 | Indicate the merging function to apply along the track axis. Default 540 | to 'sum'. 541 | 542 | - In 'sum' mode, the piano-roll of the merged track is the summation 543 | of the collected piano-rolls. Note that for binarized piano-roll, 544 | integer summation is performed. 545 | - In 'max' mode, for each pixel, the maximal value among the 546 | collected piano-rolls is assigned to the merged piano-roll. 547 | - In 'any' mode, the value of a pixel in the merged piano-roll is 548 | True if any of the collected piano-rolls has nonzero value at that 549 | pixel; False if all piano-rolls are inactive (zero-valued) at that 550 | pixel. 551 | 552 | program: int 553 | Program number to be assigned to the merged track. Available values 554 | are 0 to 127. 555 | is_drum : bool 556 | Drum indicator to be assigned to the merged track. 557 | name : str 558 | Name to be assigned to the merged track. Default to 'merged'. 559 | remove_merged : bool 560 | True to remove the merged tracks from the track list. False to keep 561 | them. Default to False. 562 | 563 | """ 564 | if mode not in ('max', 'sum', 'any'): 565 | raise ValueError("`mode` must be one of {'max', 'sum', 'any'}") 566 | 567 | merged = self[track_indices].get_merged_pianoroll(mode) 568 | 569 | merged_track = Track(merged, program, is_drum, name) 570 | self.append_track(merged_track) 571 | 572 | if remove_merged: 573 | self.remove_tracks(track_indices) 574 | 575 | def parse_midi(self, filepath, mode='max', algorithm='normal', 576 | binarized=False, compressed=True, collect_onsets_only=False, 577 | threshold=0, first_beat_time=None): 578 | """ 579 | Parse a MIDI file. 580 | 581 | Parameters 582 | ---------- 583 | filepath : str 584 | The path to the MIDI file. 585 | mode : {'sum', 'max'} 586 | Indicate the merging function to apply to duplicate notes. Default 587 | to 'max'. 588 | algorithm : {'normal', 'strict', 'custom'} 589 | Indicate the method used to get the location of the first beat. 590 | Notes before it will be dropped unless an incomplete beat before it 591 | is found (see Notes for details). Default to 'normal'. 592 | 593 | - The 'normal' algorithm estimate the location of the first beat by 594 | :meth:`pretty_midi.PrettyMIDI.estimate_beat_start`. 595 | - The 'strict' algorithm set the first beat at the event time of the 596 | first time signature change. If no time signature change event 597 | found, raise a ValueError. 598 | - The 'custom' algorithm take argument `first_beat_time` as the 599 | location of the first beat. 600 | 601 | binarized : bool 602 | True to binarize the parsed piano-rolls before merging duplicate 603 | notes. False to use the original parsed piano-rolls. Default to 604 | False. 605 | compressed : bool 606 | True to compress the pitch range of the parsed piano-rolls. False to 607 | use the original parsed piano-rolls. Deafault to True. 608 | collect_onsets_only : bool 609 | True to collect only the onset of the notes (i.e. note on events) in 610 | all tracks, where the note off and duration information are dropped. 611 | False to parse regular piano-rolls. 612 | threshold : int or float 613 | Threshold to binarize the parsed piano-rolls. Only effective when 614 | `binarized` is True. Default to zero. 615 | first_beat_time : float 616 | The location (in sec) of the first beat. Required and only effective 617 | when using 'custom' algorithm. 618 | 619 | Returns 620 | ------- 621 | midi_info : dict 622 | Contains additional information of the parsed MIDI file as fallows. 623 | 624 | - first_beat_time (float) : the location (in sec) of the first beat 625 | - incomplete_beat_at_start (bool) : indicate whether there is an 626 | incomplete beat before `first_beat_time` 627 | - num_time_signature_change (int) : the number of time signature 628 | change events 629 | - time_signature (str) : the time signature (in 'X/X' format) if 630 | there is only one time signature events. None if no time signature 631 | event found 632 | - tempo (float) : the tempo value (in bpm) if there is only one 633 | tempo change events. None if no tempo change event found 634 | 635 | Notes 636 | ----- 637 | If an incomplete beat before the first beat is found, an additional beat 638 | will be added before the (estimated) beat start time. However, notes 639 | before the (estimated) beat start time for more than one beat are 640 | dropped. 641 | 642 | """ 643 | pm = pretty_midi.PrettyMIDI(filepath) 644 | self.parse_pretty_midi(pm, mode, algorithm, binarized, compressed, 645 | collect_onsets_only, threshold, first_beat_time) 646 | 647 | def parse_pretty_midi(self, pm, mode='max', algorithm='normal', 648 | binarized=False, skip_empty_tracks=True, 649 | collect_onsets_only=False, threshold=0, 650 | first_beat_time=None): 651 | """ 652 | Parse a :class:`pretty_midi.PrettyMIDI` object. The data type of the 653 | resulting piano-rolls is automatically determined (int if 'mode' is 654 | 'sum', np.uint8 if `mode` is 'max' and `binarized` is False, bool if 655 | `mode` is 'max' and `binarized` is True). 656 | 657 | Parameters 658 | ---------- 659 | pm : `pretty_midi.PrettyMIDI` object 660 | The :class:`pretty_midi.PrettyMIDI` object to be parsed. 661 | mode : {'max', 'sum'} 662 | Indicate the merging function to apply to duplicate notes. Default 663 | to 'max'. 664 | algorithm : {'normal', 'strict', 'custom'} 665 | Indicate the method used to get the location of the first beat. 666 | Notes before it will be dropped unless an incomplete beat before it 667 | is found (see Notes for details). Default to 'normal'. 668 | 669 | - The 'normal' algorithm estimate the location of the first beat by 670 | :meth:`pretty_midi.PrettyMIDI.estimate_beat_start`. 671 | - The 'strict' algorithm set the first beat at the event time of the 672 | first time signature change. If no time signature change event 673 | found, raise a ValueError. 674 | - The 'custom' algorithm take argument `first_beat_time` as the 675 | location of the first beat. 676 | 677 | binarized : bool 678 | True to binarize the parsed piano-rolls before merging duplicate 679 | notes. False to use the original parsed piano-rolls. Default to 680 | False. 681 | skip_empty_tracks : bool 682 | True to remove tracks with empty piano-rolls and compress the pitch 683 | range of the parsed piano-rolls. False to retain the empty tracks 684 | and use the original parsed piano-rolls. Deafault to True. 685 | collect_onsets_only : bool 686 | True to collect only the onset of the notes (i.e. note on events) in 687 | all tracks, where the note off and duration information are dropped. 688 | False to parse regular piano-rolls. 689 | threshold : int or float 690 | Threshold to binarize the parsed piano-rolls. Only effective when 691 | `binarized` is True. Default to zero. 692 | first_beat_time : float 693 | The location (in sec) of the first beat. Required and only effective 694 | when using 'custom' algorithm. 695 | 696 | Notes 697 | ----- 698 | If an incomplete beat before the first beat is found, an additional beat 699 | will be added before the (estimated) beat start time. However, notes 700 | before the (estimated) beat start time for more than one beat are 701 | dropped. 702 | 703 | """ 704 | if mode not in ('max', 'sum'): 705 | raise ValueError("`mode` must be one of {'max', 'sum'}") 706 | if algorithm not in ('strict', 'normal', 'custom'): 707 | raise ValueError("`algorithm` must be one of 'normal', 'strict' " 708 | "and 'custom'") 709 | if algorithm == 'custom': 710 | if not isinstance(first_beat_time, (int, float)): 711 | raise TypeError("`first_beat_time` must be a number when " 712 | "using 'custom' algorithm") 713 | if first_beat_time < 0.0: 714 | raise ValueError("`first_beat_time` must be a positive number " 715 | "when using 'custom' algorithm") 716 | 717 | # Set first_beat_time for 'normal' and 'strict' modes 718 | if algorithm == 'normal': 719 | if pm.time_signature_changes: 720 | pm.time_signature_changes.sort(key=lambda x: x.time) 721 | first_beat_time = pm.time_signature_changes[0].time 722 | else: 723 | first_beat_time = pm.estimate_beat_start() 724 | elif algorithm == 'strict': 725 | if not pm.time_signature_changes: 726 | raise ValueError("No time signature change event found. Unable " 727 | "to set beat start time using 'strict' " 728 | "algorithm") 729 | pm.time_signature_changes.sort(key=lambda x: x.time) 730 | first_beat_time = pm.time_signature_changes[0].time 731 | 732 | # get tempo change event times and contents 733 | tc_times, tempi = pm.get_tempo_changes() 734 | arg_sorted = np.argsort(tc_times) 735 | tc_times = tc_times[arg_sorted] 736 | tempi = tempi[arg_sorted] 737 | 738 | beat_times = pm.get_beats(first_beat_time) 739 | if not len(beat_times): 740 | raise ValueError("Cannot get beat timings to quantize piano-roll") 741 | beat_times.sort() 742 | 743 | num_beat = len(beat_times) 744 | num_time_step = self.beat_resolution * num_beat 745 | 746 | # Parse downbeat array 747 | if not pm.time_signature_changes: 748 | self.downbeat = None 749 | else: 750 | self.downbeat = np.zeros((num_time_step, ), bool) 751 | self.downbeat[0] = True 752 | start = 0 753 | end = start 754 | for idx, tsc in enumerate(pm.time_signature_changes[:-1]): 755 | end += np.searchsorted(beat_times[end:], 756 | pm.time_signature_changes[idx+1].time) 757 | start_idx = start * self.beat_resolution 758 | end_idx = end * self.beat_resolution 759 | stride = tsc.numerator * self.beat_resolution 760 | self.downbeat[start_idx:end_idx:stride] = True 761 | start = end 762 | 763 | # Build tempo array 764 | one_more_beat = 2 * beat_times[-1] - beat_times[-2] 765 | beat_times_one_more = np.append(beat_times, one_more_beat) 766 | bpm = 60. / np.diff(beat_times_one_more) 767 | self.tempo = np.tile(bpm, (1, 24)).reshape(-1,) 768 | 769 | # Parse piano-roll 770 | self.tracks = [] 771 | for instrument in pm.instruments: 772 | if binarized: 773 | pianoroll = np.zeros((num_time_step, 128), bool) 774 | elif mode == 'max': 775 | pianoroll = np.zeros((num_time_step, 128), np.uint8) 776 | else: 777 | pianoroll = np.zeros((num_time_step, 128), int) 778 | 779 | pitches = np.array([note.pitch for note in instrument.notes 780 | if note.end > first_beat_time]) 781 | note_on_times = np.array([note.start for note in instrument.notes 782 | if note.end > first_beat_time]) 783 | beat_indices = np.searchsorted(beat_times, note_on_times) - 1 784 | remained = note_on_times - beat_times[beat_indices] 785 | ratios = remained / (beat_times_one_more[beat_indices + 1] 786 | - beat_times[beat_indices]) 787 | note_ons = ((beat_indices + ratios) 788 | * self.beat_resolution).astype(int) 789 | 790 | if collect_onsets_only: 791 | pianoroll[note_ons, pitches] = True 792 | elif instrument.is_drum: 793 | if binarized: 794 | pianoroll[note_ons, pitches] = True 795 | else: 796 | velocities = [note.velocity for note in instrument.notes 797 | if note.end > first_beat_time] 798 | pianoroll[note_ons, pitches] = velocities 799 | else: 800 | note_off_times = np.array([note.end for note in instrument.notes 801 | if note.end > first_beat_time]) 802 | beat_indices = np.searchsorted(beat_times, note_off_times) - 1 803 | remained = note_off_times - beat_times[beat_indices] 804 | ratios = remained / (beat_times_one_more[beat_indices + 1] 805 | - beat_times[beat_indices]) 806 | note_offs = ((beat_indices + ratios) 807 | * self.beat_resolution).astype(int) 808 | 809 | for idx, start in enumerate(note_ons): 810 | end = note_offs[idx] + 1 811 | velocity = instrument.notes[idx].velocity 812 | 813 | if velocity < 1: 814 | continue 815 | if binarized and velocity <= threshold: 816 | continue 817 | 818 | if start > 0 and start < num_time_step: 819 | if pianoroll[start - 1, pitches[idx]]: 820 | pianoroll[start - 1, pitches[idx]] = 0 821 | if end < num_time_step - 1: 822 | if pianoroll[end + 1, pitches[idx]]: 823 | end -= 1 824 | 825 | if binarized: 826 | if mode == 'sum': 827 | pianoroll[start:end, pitches[idx]] += 1 828 | elif mode == 'max': 829 | pianoroll[start:end, pitches[idx]] = True 830 | elif mode == 'sum': 831 | pianoroll[start:end, pitches[idx]] += velocity 832 | elif mode == 'max': 833 | maximum = np.maximum(pianoroll[start:end, pitches[idx]], 834 | velocity) 835 | pianoroll[start:end, pitches[idx]] = maximum 836 | 837 | if skip_empty_tracks and not np.any(pianoroll): 838 | continue 839 | 840 | track = Track(pianoroll, int(instrument.program), 841 | instrument.is_drum, instrument.name) 842 | self.tracks.append(track) 843 | 844 | self.check_validity() 845 | 846 | def plot(self, filepath=None, mode='separate', track_label='name', 847 | normalization='standard', preset='default', cmaps=None, 848 | xtick='auto', ytick='octave', xticklabel='on', yticklabel='auto', 849 | tick_loc=None, tick_direction='in', label='both', grid='both', 850 | grid_linestyle=':', grid_linewidth=.5): 851 | """ 852 | Plot the piano-rolls or save a plot of them. 853 | 854 | Parameters 855 | ---------- 856 | filepath : str 857 | The filepath to save the plot. If None, default to save nothing. 858 | mode : {'separate', 'stacked', 'hybrid'} 859 | Plotting modes. Default to 'separate'. 860 | 861 | - In 'separate' mode, all the tracks are plotted separately. 862 | - In 'stacked' mode, a color is assigned based on `cmaps` to the 863 | piano-roll of each track and the piano-rolls are stacked and 864 | plotted as a colored image with RGB channels. 865 | - In 'hybrid' mode, the drum tracks are merged into a 'Drums' track, 866 | while the other tracks are merged into an 'Others' track, and the 867 | two merged tracks are then plotted separately. 868 | 869 | track_label : {'name', 'program', 'family', 'off'} 870 | Add track name, program name, instrument family name or none as 871 | labels to the track. When `mode` is 'hybrid', all options other 872 | than 'off' will label the two track with 'Drums' and 'Others'. 873 | normalization : {'standard', 'auto', 'none'} 874 | The normalization method to apply to the piano-roll. Default to 875 | 'standard'. Only effective when `pianoroll` is not binarized. 876 | 877 | - For 'standard' normalization, the normalized values are given by 878 | N = P / 128, where P, N is the original and normalized piano-roll, 879 | respectively 880 | - For 'auto' normalization, the normalized values are given by 881 | N = (P - m) / (M - m), where P, N is the original and normalized 882 | piano-roll, respectively, and M, m is the maximum and minimum of 883 | the original piano-roll, respectively. 884 | - If 'none', no normalization will be applied to the piano-roll. In 885 | this case, the values of `pianoroll` should be in [0, 1] in order 886 | to plot it correctly. 887 | 888 | preset : {'default', 'plain', 'frame'} 889 | Preset themes for the plot. 890 | 891 | - In 'default' preset, the ticks, grid and labels are on. 892 | - In 'frame' preset, the ticks and grid are both off. 893 | - In 'plain' preset, the x- and y-axis are both off. 894 | 895 | cmaps : tuple or list 896 | List of `matplotlib.colors.Colormap` instances or colormap codes. 897 | 898 | - When `mode` is 'separate', each element will be passed to each 899 | call of :func:`matplotlib.pyplot.imshow`. Default to ('Blues', 900 | 'Oranges', 'Greens', 'Reds', 'Purples', 'Greys'). 901 | - When `mode` is stacked, a color is assigned based on `cmaps` to 902 | the piano-roll of each track. Default to ('hsv'). 903 | - When `mode` is 'hybrid', the first (second) element is used in the 904 | 'Drums' ('Others') track. Default to ('Blues', 'Greens'). 905 | 906 | xtick : {'auto', 'beat', 'step', 'off'} 907 | Use beat number or step number as ticks along the x-axis, or 908 | automatically set to 'beat' when `beat_resolution` is given and set 909 | to 'step', otherwise. Default to 'auto'. 910 | ytick : {'octave', 'pitch', 'off'} 911 | Use octave or pitch as ticks along the y-axis. Default to 'octave'. 912 | xticklabel : {'on', 'off'} 913 | Indicate whether to add tick labels along the x-axis. Only effective 914 | when `xtick` is not 'off'. 915 | yticklabel : {'auto', 'name', 'number', 'off'} 916 | If 'name', use octave name and pitch name (key name when `is_drum` 917 | is True) as tick labels along the y-axis. If 'number', use pitch 918 | number. If 'auto', set to 'name' when `ytick` is 'octave' and 919 | 'number' when `ytick` is 'pitch'. Default to 'auto'. Only effective 920 | when `ytick` is not 'off'. 921 | tick_loc : tuple or list 922 | List of locations to put ticks. Availables elements are 'bottom', 923 | 'top', 'left' and 'right'. If None, default to ('bottom', 'left'). 924 | tick_direction : {'in', 'out', 'inout'} 925 | Put ticks inside the axes, outside the axes, or both. Default to 926 | 'in'. Only effective when `xtick` and `ytick` are not both 'off'. 927 | label : {'x', 'y', 'both', 'off'} 928 | Add label to the x-axis, y-axis, both or neither. Default to 'both'. 929 | grid : {'x', 'y', 'both', 'off'} 930 | Add grid to the x-axis, y-axis, both or neither. Default to 'both'. 931 | grid_linestyle : str 932 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linestyle' 933 | argument. 934 | grid_linewidth : float 935 | Will be passed to :meth:`matplotlib.axes.Axes.grid` as 'linewidth' 936 | argument. 937 | 938 | Returns 939 | ------- 940 | fig : `matplotlib.figure.Figure` object 941 | A :class:`matplotlib.figure.Figure` object. 942 | axs : list 943 | List of :class:`matplotlib.axes.Axes` object. 944 | 945 | """ 946 | def get_track_label(track_label, track=None): 947 | """Convenient function to get track labels""" 948 | if track_label == 'name': 949 | return track.name 950 | elif track_label == 'program': 951 | return pretty_midi.program_to_instrument_name(track.program) 952 | elif track_label == 'family': 953 | return pretty_midi.program_to_instrument_class(track.program) 954 | elif track is None: 955 | return track_label 956 | 957 | def add_tracklabel(ax, track_label, track=None): 958 | """Convenient function for adding track labels""" 959 | if not ax.get_ylabel(): 960 | return 961 | ax.set_ylabel(get_track_label(track_label, track) + '\n\n' 962 | + ax.get_ylabel()) 963 | 964 | self.check_validity() 965 | if not self.tracks: 966 | raise ValueError("There is no track to plot") 967 | if mode not in ('separate', 'stacked', 'hybrid'): 968 | raise ValueError("`mode` must be one of {'separate', 'stacked', " 969 | "'hybrid'}") 970 | if track_label not in ('name', 'program', 'family', 'off'): 971 | raise ValueError("`track_label` must be one of {'name', 'program', " 972 | "'family'}") 973 | 974 | if self.is_binarized(): 975 | normalization = 'none' 976 | 977 | if cmaps is None: 978 | if mode == 'separate': 979 | cmaps = ('Blues', 'Oranges', 'Greens', 'Reds', 'Purples', 980 | 'Greys') 981 | elif mode == 'stacked': 982 | cmaps = ('hsv') 983 | else: 984 | cmaps = ('Blues', 'Greens') 985 | 986 | num_track = len(self.tracks) 987 | downbeats = self.get_downbeat_steps() 988 | 989 | if mode == 'separate': 990 | if num_track > 1: 991 | fig, axs = plt.subplots(num_track, sharex=True) 992 | else: 993 | fig, ax = plt.subplots() 994 | axs = [ax] 995 | 996 | for idx, track in enumerate(self.tracks): 997 | now_xticklabel = xticklabel if idx < num_track else 'off' 998 | plot_pianoroll(axs[idx], track.pianoroll, False, 999 | self.beat_resolution, downbeats, preset=preset, 1000 | cmap=cmaps[idx%len(cmaps)], 1001 | normalization=normalization, xtick=xtick, 1002 | ytick=ytick, xticklabel=now_xticklabel, 1003 | yticklabel=yticklabel, tick_loc=tick_loc, 1004 | tick_direction=tick_direction, label=label, 1005 | grid=grid, grid_linestyle=grid_linestyle, 1006 | grid_linewidth=grid_linewidth) 1007 | if track_label != 'none': 1008 | add_tracklabel(axs[idx], track_label, track) 1009 | 1010 | if num_track > 1: 1011 | fig.subplots_adjust(hspace=0) 1012 | 1013 | if filepath is not None: 1014 | plt.savefig(filepath) 1015 | 1016 | return (fig, axs) 1017 | 1018 | elif mode == 'stacked': 1019 | is_all_drum = True 1020 | for track in self.tracks: 1021 | if not track.is_drum: 1022 | is_all_drum = False 1023 | 1024 | fig, ax = plt.subplots() 1025 | stacked = self.get_stacked_pianorolls() 1026 | 1027 | colormap = matplotlib.cm.get_cmap(cmaps[0]) 1028 | cmatrix = colormap(np.arange(0, 1, 1 / num_track))[:, :3] 1029 | recolored = np.matmul(stacked.reshape(-1, num_track), cmatrix) 1030 | stacked = recolored.reshape(stacked.shape[:2] + (3, )) 1031 | 1032 | plot_pianoroll(ax, stacked, is_all_drum, self.beat_resolution, 1033 | downbeats, preset=preset, 1034 | normalization=normalization, xtick=xtick, 1035 | ytick=ytick, xticklabel=xticklabel, 1036 | yticklabel=yticklabel, tick_loc=tick_loc, 1037 | tick_direction=tick_direction, label=label, 1038 | grid=grid, grid_linestyle=grid_linestyle, 1039 | grid_linewidth=grid_linewidth) 1040 | 1041 | if track_label != 'none': 1042 | patches = [Patch(color=cmatrix[idx], 1043 | label=get_track_label(track_label, track)) 1044 | for idx, track in enumerate(self.tracks)] 1045 | plt.legend(handles=patches) 1046 | 1047 | if filepath is not None: 1048 | plt.savefig(filepath) 1049 | 1050 | return (fig, [ax]) 1051 | 1052 | elif mode == 'hybrid': 1053 | drums = [i for i, track in enumerate(self.tracks) if track.is_drum] 1054 | others = [i for i in range(len(self.tracks)) if i not in drums] 1055 | merged_drums = self.get_merged_pianoroll(drums) 1056 | merged_others = self.get_merged_pianoroll(others) 1057 | 1058 | fig, (ax1, ax2) = plt.subplots(2, sharex=True, sharey=True) 1059 | plot_pianoroll(ax1, merged_drums, True, self.beat_resolution, 1060 | downbeats, preset=preset, cmap=cmaps[0], 1061 | normalization=normalization, xtick=xtick, 1062 | ytick=ytick, xticklabel=xticklabel, 1063 | yticklabel=yticklabel, tick_loc=tick_loc, 1064 | tick_direction=tick_direction, label=label, 1065 | grid=grid, grid_linestyle=grid_linestyle, 1066 | grid_linewidth=grid_linewidth) 1067 | plot_pianoroll(ax2, merged_others, False, self.beat_resolution, 1068 | downbeats, preset=preset, cmap=cmaps[1], 1069 | normalization=normalization, 1070 | ytick=ytick, xticklabel=xticklabel, 1071 | yticklabel=yticklabel, tick_loc=tick_loc, 1072 | tick_direction=tick_direction, label=label, 1073 | grid=grid, grid_linestyle=grid_linestyle, 1074 | grid_linewidth=grid_linewidth) 1075 | fig.subplots_adjust(hspace=0) 1076 | 1077 | if track_label != 'none': 1078 | add_tracklabel(ax1, 'Drums') 1079 | add_tracklabel(ax2, 'Others') 1080 | 1081 | if filepath is not None: 1082 | plt.savefig(filepath) 1083 | 1084 | return (fig, [ax1, ax2]) 1085 | 1086 | def remove_empty_tracks(self): 1087 | """Remove tracks with empty piano-rolls.""" 1088 | empty_tracks = self.get_empty_tracks() 1089 | self.remove_tracks(empty_tracks) 1090 | 1091 | def remove_tracks(self, track_indices): 1092 | """ 1093 | Remove tracks specified by `track_indices`. 1094 | 1095 | Parameters 1096 | ---------- 1097 | track_indices : list 1098 | List of indices that indicates which tracks to remove. 1099 | 1100 | """ 1101 | self.tracks = [track for idx, track in enumerate(self.tracks) 1102 | if idx not in track_indices] 1103 | 1104 | def save(self, filepath, compressed=True): 1105 | """ 1106 | Save to a (compressed) .npz file, which can be later loaded by 1107 | :meth:`pypianoroll.Multitrack.load`. 1108 | 1109 | Notes 1110 | ----- 1111 | To reduce the file size, the collected piano-rolls are first converted 1112 | to instances of scipy.sparse.csc_matrix, whose component arrays are then 1113 | collected and saved to the .npz file. 1114 | 1115 | Parameters 1116 | ---------- 1117 | filepath : str 1118 | The path to write the .npz file. 1119 | compressed : bool 1120 | True to save to a compressed .npz file. False to save to a 1121 | uncompressed .npz file. Default to True. 1122 | 1123 | """ 1124 | def update_sparse(target_dict, sparse_matrix, name): 1125 | """ 1126 | Turn `sparse_matrix` into a scipy.sparse.csc_matrix and update its 1127 | component arrays to the `target_dict` with key as `name` postfixed 1128 | with its component type string. 1129 | 1130 | """ 1131 | csc = csc_matrix(sparse_matrix) 1132 | target_dict[name+'_csc_data'] = csc.data 1133 | target_dict[name+'_csc_indices'] = csc.indices 1134 | target_dict[name+'_csc_indptr'] = csc.indptr 1135 | target_dict[name+'_csc_shape'] = csc.shape 1136 | 1137 | self.check_validity() 1138 | array_dict = {'tempo': self.tempo} 1139 | info_dict = {'beat_resolution': self.beat_resolution, 1140 | 'name': self.name} 1141 | 1142 | if self.downbeat is not None: 1143 | array_dict['downbeat'] = self.downbeat 1144 | 1145 | for idx, track in enumerate(self.tracks): 1146 | update_sparse(array_dict, track.pianoroll, 1147 | 'pianoroll_{}'.format(idx)) 1148 | info_dict[str(idx)] = {'program': track.program, 1149 | 'is_drum': track.is_drum, 1150 | 'name': track.name} 1151 | 1152 | if not filepath.endswith('.npz'): 1153 | filepath += '.npz' 1154 | if compressed: 1155 | np.savez_compressed(filepath, **array_dict) 1156 | else: 1157 | np.savez(filepath, **array_dict) 1158 | 1159 | compression = zipfile.ZIP_DEFLATED if compressed else zipfile.ZIP_STORED 1160 | with zipfile.ZipFile(filepath, 'a') as zip_file: 1161 | zip_file.writestr('info.json', json.dumps(info_dict), compression) 1162 | 1163 | def to_pretty_midi(self, constant_tempo=None, constant_velocity=100): 1164 | """ 1165 | Convert to a :class:`pretty_midi.PrettyMIDI` instance. 1166 | 1167 | Notes 1168 | ----- 1169 | - Only constant tempo is supported by now. 1170 | - The velocities of the converted piano-rolls are clipped to [0, 127], 1171 | i.e. values below 0 and values beyond 127 are replaced by 127 and 0, 1172 | respectively. 1173 | - Adjacent nonzero values of the same pitch will be considered a single 1174 | note with their mean as its velocity. 1175 | 1176 | Parameters 1177 | ---------- 1178 | constant_tempo : int 1179 | The constant tempo value of the output object. If None, default to 1180 | use the first element of `tempo`. 1181 | constant_velocity : int 1182 | The constant velocity to be assigned to binarized tracks. Default to 1183 | 100. 1184 | 1185 | Returns 1186 | ------- 1187 | pm : `pretty_midi.PrettyMIDI` object 1188 | The converted :class:`pretty_midi.PrettyMIDI` instance. 1189 | 1190 | """ 1191 | for track in self.tracks: 1192 | if track.is_binarized(): 1193 | track.assign_constant(constant_velocity) 1194 | 1195 | self.check_validity() 1196 | pm = pretty_midi.PrettyMIDI(initial_tempo=self.tempo[0]) 1197 | 1198 | # TODO: Add downbeat support -> time signature change events 1199 | # TODO: Add tempo support -> tempo change events 1200 | if constant_tempo is None: 1201 | constant_tempo = self.tempo[0] 1202 | time_step_size = 60. / constant_tempo / self.beat_resolution 1203 | 1204 | for track in self.tracks: 1205 | instrument = pretty_midi.Instrument(program=track.program, 1206 | is_drum=track.is_drum, 1207 | name=track.name) 1208 | copied = track.copy() 1209 | copied.clip() 1210 | clipped = copied.pianoroll.astype(int) 1211 | binarized = clipped.astype(bool) 1212 | padded = np.pad(binarized, ((1, 1), (0, 0)), 'constant') 1213 | diff = np.diff(padded.astype(int), axis=0) 1214 | 1215 | for pitch in range(128): 1216 | note_ons = np.nonzero(diff[:, pitch] > 0)[0] 1217 | note_on_times = time_step_size * note_ons 1218 | note_offs = np.nonzero(diff[:, pitch] < 0)[0] 1219 | note_off_times = time_step_size * note_offs 1220 | 1221 | for idx, note_on in enumerate(note_ons): 1222 | velocity = np.mean(clipped[note_on:note_offs[idx], pitch]) 1223 | note = pretty_midi.Note(velocity=int(velocity), pitch=pitch, 1224 | start=note_on_times[idx], 1225 | end=note_off_times[idx]) 1226 | instrument.notes.append(note) 1227 | 1228 | instrument.notes.sort(key=lambda x: x.start) 1229 | pm.instruments.append(instrument) 1230 | 1231 | return pm 1232 | 1233 | def transpose(self, semitone): 1234 | """ 1235 | Transpose the piano-rolls by `semitones` semitones. 1236 | 1237 | Parameters 1238 | ---------- 1239 | semitone : int 1240 | Number of semitones transpose the piano-rolls. 1241 | 1242 | """ 1243 | for track in self.tracks(): 1244 | track.transpose(semitone) 1245 | 1246 | def trim_trailing_silence(self): 1247 | """Trim the trailing silence of the piano-roll.""" 1248 | active_length = self.get_active_length() 1249 | for track in self.tracks: 1250 | track = track[:active_length] 1251 | 1252 | def write(self, filepath): 1253 | """ 1254 | Write to a MIDI file. 1255 | 1256 | Parameters 1257 | ---------- 1258 | filepath : str 1259 | The path to write the MIDI file. 1260 | 1261 | """ 1262 | if not filepath.endswith(('.mid', '.midi', '.MID', '.MIDI')): 1263 | filepath = filepath + '.mid' 1264 | pm = self.to_pretty_midi() 1265 | pm.write(filepath) 1266 | --------------------------------------------------------------------------------