--------------------------------------------------------------------------------
/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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------