Quick Start

Installation

Pre-Built Version

pip install symusic

We have pre-built wheels for Linux, macOS, and Windows, with support for both X86 and ARM architectures (Windows ARM64 is not supported yet).

The wheels are built with 3.8, 3.9, 3.10, 3.11 and 3.12 versions of CPython, and 3.9 version of PyPy.

Note that there are some memory leaks in CPython 3.12, and they would be reported by the nanobind framework when you import symusic in python 3.12. I hope that the CPython team would fix them soon.

From Source

For building from source, you could directly install from the source distribution on PyPI:

pip install --no-binary symusic symusic

Or you could clone the repository and install it:

git clone --recursive git@github.com:Yikai-Liao/symusic.git
pip install ./symusic

Note that symusic is built with C++20, and it does need some modern features, so we need to check the compatibility of the compiler.

  • gcc: >=11
  • clang: : >=15
  • msvc: Not tested, but it's recommended to use Visual Studio 2022

On Linux, if you don't have the sudo permission, you could install a gcc compiler from conda:

conda install conda-forge::gcc conda-forge::gxx

And then set the CC and CXX environment variables to the path of the gcc and g++:

CC=/path_to_conda_root/envs/env_name/bin/gcc CXX=/path_to_conda_root/envs/env_name/bin/g++ pip install --no-binary symusic symusic

Load

If you pass a file path (str or pathlib.Path) to the Score constructor, it would be dynamically dispatched to the from_file method. symusic will try to infer the file format from the file extension, or you can specify the format by passing the fmt argument.

from symusic import Score

Score("xxx.mid")
Score("xxx.txt", fmt="abc")

Score.from_file("xxx.mid")
Score.from_file("xxx.txt", fmt="abc")

You could also pass a ttype argument to the Score constructor to specify the type of time unit. There are three types of time unit: tick, quarter and second, while the default is tick.

A member function to is also provided for converting the time unit of the score.

  • tick (int32) is just the raw time unit in the MIDI file.
  • quarter (float32) is a normalized time unit (directly normalized from tick using ticks_per_quarter), where the length of a quarter note is 1.
  • second (float32) means the time unit is in seconds, the same as what pretty_midi does.
from symusic import Score, TimeUnit

# you could specify the time unit when loading a score
Score("xxx.mid", ttype=TimeUnit.quarter)
Score("xxx.txt", fmt="abc", ttype='second')

# or convert the time unit after loading
# an additional argument `min_dur` is optional, which is the minimum duration of a note
Score("xxx.mid", "quarter", ).to("tick", min_dur=1)
Score("xxx.txt", fmt="abc").to(TimeUnit.second)

Dump

Currently, we got two dumpers, dump_midi and dump_abc, which dump the score to a MIDI file and an ABC file respectively.

Before actually dumping the score, it would be automatically converted to the time unit tick, which is the raw time unit in the MIDI file.

So if you want to specify a minimum duration of a note, you need to call the to method manually before dumping.

from symusic import Score
s = Score("xxx.mid")
s.dump_midi("out.mid")
s.dump_abc("out.abc")

Pickle & Multi Processing

All the objects in symusic are pickleable (extremely fast because of the zpp_bits backend we use), so you can use multiprocessing to accelerate the processing.

Note that because of zpp_bits, pickle would be faster than loading from or dumping to a MIDI file.

from symusic import Score
import pickle
import multiprocessing as mp

s = Score("xxx.mid")
with open("out.pkl", "wb") as f:
    pickle.dump(s, f)
with open("out.pkl", "rb") as f:
    s = pickle.load(f)

with mp.Pool(4) as pool:
    print(pool.map(lambda x: x.to("second"), [s, s, s, s]))

Accessing the Score

Basic Information

The data structure of Score is quite simple, just following what miditoolkit and pretty_midi do.

from symusic import Score
s = Score("xxx.mid")

# ttype is a property of all the objects in symusic related to time
s.ttype             # the type of time unit, 'tick', 'quarter' or 'second'
s.ticks_per_quarter # or s.tpq


# Global Events
s.tempos            # list of tempo changes
s.time_signatures   # list of time signature changes
s.key_signatures    # list of key signature changes
s.lyrics            # list of lyrics
s.markers           # list of markers

s.start()           # return the start time of the score
s.end()             # return the end time of the score
s.empty()           # return True if the score is empty
s.note_num()        # return the number of notes in the score

# Tracks
s.tracks            # list of tracks

t = s.tracks[0]
t.ttype             # read-only property, the same as s.ttype
t.name              # track name
t.program           # program number
t.is_drum           # whether it's a drum track
t.notes             # list of notes
t.controls          # list of control changes
t.pitch_bends       # list of pitch bends
t.pedals            # list of pedals

t.start()           # return the start time of the track
t.end()             # return the end time of the track
t.empty()           # return True if the track is empty
t.note_num()        # return the number of notes in the track

# Note, the most important event
n = t.notes[0]
n.ttype             # read-only property, the same as s.ttype
n.time              # the NoteOn time or n.start
n.start             # the same as n.time
n.duration          # the duration of the note
n.end               # a read-only property, n.time + n.duration
n.end_time()        # a method that returns n.end
n.pitch             # the pitch of the note
n.velocity          # the velocity of the note
n.empty()           # duration <= 0 or velocity <= 0

The container of events in symusic like the NoteList is a binding of std::vector in c++, with a similar interface to list in python.

We also add some methods to the container, like start, end, empty:

from symusic import Score
notes = Score("xxx.mid").tracks[0].notes

notes.empty()       # return True if the note list is empty
notes.start()       # return the start time of the note list
notes.end()         # return the end time of the note list

notes1 = notes.copy()   # copy the note list
# the default key is (time, duration, pitch) for notes, (time, duration) for pedals
# for other events, the default key is "time"
notes2 = notes.sort(reversed=False, inplace=False)              # sort the notes
notes3 = notes.sort(lambda x: x.velocity)                       # you could also customize the key, but it is slower
notes4 = notes.filter(lambda x: x.pitch > 60, inplace=False)    # filter the notes

# adjust the time of the notes, the first list is the old time, the second list is the new time
# the time unit of the two lists should be the same as the time unit of the note list
# the semantic of adjust_time is the same as the method in pretty_midi
notes5 = notes.adjust_time([0, 10, notes.end()], [0, 20, notes.end() / 2])

Struct of Array (SOA)

symusic has a great numpy support. You could easily convert "a list of objects" in symusic to "a dict of numpy array`.

from symusic import Score
import numpy as np
from typing import Dict
s = Score("xxx.mid")

# get the numpy arrays
tempos: Dict[str, np.ndarray] = s.tempos.numpy()
notes: Dict[str, np.ndarray] = s.tracks[0].notes.numpy()

# access the array in dict
mspq = tempos["mspq"]   # a 1D numpy array, dtype is int32. mspq for microsecond per quarter note
start = notes["time"]   # a 1D numpy array, the dtype is determined by the time unit(ttype) of the score

# you could also convert the numpy arrays back to the list of objects
from symusic import Note
note_list1 = Note.from_numpy(**notes, ttype=s.ttype)
note_list2 = Note.from_numpy(
    time=notes["time"], duration=notes["duration"], pitch=notes["pitch"], velocity=notes["velocity"], ttype=s.ttype
)
# The note_list you get here is the same as s.tracks[0].notes

Piano Roll

symusic also supports a conversion to the piano roll, which is a 3D numpy array. But convert the piano roll back to the score is not supported yet since the complexity of the piano roll.

from symusic import Score
s = Score("xxx.mid")

# You'd better resample the score before converting it to the piano roll to reduce the size of it
# tpq=6 means that the minimum time unit is 1/6 quarter note or 1/24 note
# min_dur=1 means that the duration of a note is at least 1 time unit
s_quantized = s.resample(tpq=6, min_dur=1)

# 4D np array, [modes, tracks, pitch, time]
s_pianoroll = s_quantized.pianoroll(
    # the modes of the piano roll, which determines the "modes dim"
    # only the following modes are supported: "onset", "frame", "offset"
    # you could determine the order by yourself
    modes=["onset", "frame", "offset"], # List[str]
    pitch_range=[0, 128],   # the pitch range (half-open interval) of the piano roll, [0, 128) by default
    encode_velocity=True    # make the pianoroll binary or filled with velocity
)

# 3D np array, [modes, pitch, time]
t_pianoroll = s_quantized.tracks[0].pianoroll(
    modes=["onset", "frame", "offset"], pitch_range=[0, 128], encode_velocity=True
)

You could also visualize the piano roll using matplotlib

from symusic import Score
from matplotlib import pyplot as plt
s = Score("xxx.mid").resample(tpq=6, min_dur=1)
track = s.tracks[0]
pianoroll = track.pianoroll(modes=["onset", "frame"], pitch_range=[0, 128], encode_velocity=False)
# this will show the onset and frame of the piano roll in one figure
plt.imshow(pianoroll[0] + pianoroll[1], aspect="auto")
plt.show()

Synthesis

From version 0.4.0, symusic supports a simple synthesis of the score using SoundFont(2/3). It's our own implementation, called prestosynth, and you could find the repository here.

from symusic import Score, Synthesizer, BuiltInSF3 ,dump_wav

s = Score("xxx.mid")

# You could choose a builtin soundfont
# And the following one is the default soundfont if you don't specify it when creating a synthesizer
sf_path = BuiltInSF3.MuseScoreGeneral().path(download=True)

# sf3 and sf2 are both supported
sf_path = "path/to/your/soundfont.sf3"

synth = Synthesizer(
    sf_path = sf_path, # the path to the soundfont
    sample_rate = 44100, # the sample rate of the output wave, 44100 is the default value
)

# audio is a 2D numpy array of float32, [channels, time]
audio = synth.render(s, stereo=True) # stereo is True by default, which means you will get a stereo wave

# you could also dump the wave to a file
# use_int16 is True by default, which means the output wave is int16, otherwise float32
dump_wav("out.wav", audio, sample_rate=44100, use_int16=True)

API Reference

Score

Attributes & Properties

AttributeTypeDescription
ticks_per_quarterintNumber of ticks per quarter note
tpqintAlias for ticks_per_quarter
tracksTrackListList of tracks in the score
time_signaturesTimeSignatureListList of time signatures in the score
key_signaturesKeySignatureListList of key signatures in the score
temposTempoListList of tempos in the score
markersTextMetaListList of markers in the score
Property-LikeTypeDescription
ttypeTick | Quarter | SecondTime unit of the score
start()intThe global start time of the score
end()intThe global end time of the score
note_num()intThe total number of notes in the score
empty()boolWhether the score is empty

Constructors & IO

MethodDescription
Score(self, tpq: int = 960, ttype: GeneralTimeUnit)Create a new score with the given tpq
Score(self, other: Score, ttype: GeneralTimeUnit)Create a new score by deep copying the other score
Score(self, path: Union[str, Path], ttype: GeneralTimeUnit, fmt: Optional[str])Load a score from the given file path (midi or abc notation)
from_file(cls, path: Union[str, Path], ttype: GeneralTimeUnit, fmt: Optional[str]Load a score from the given file path (midi or abc notation)
from_midi(cls, data: bytes, ttype: GeneralTimeUnit)Load a score from the given midi data (bytes)
from_abc(cls, data: str, ttype: GeneralTimeUnit)Load a score from the given abc notation data (str)
from_tpq(cls, tpq: int = 960, ttype: GeneralTimeUnit)Create a new score with the given tpq
from_other(cls, other: Score, ttype: GeneralTimeUnit)Create a new score by deep copying the other score
dump_midi(self, path: Union[str, Path])Dump the score to a midi file at the given path
dumps_midi(self)Dump the score to a midi file and return the bytes
dump_abc(self, path: Union[str, Path])Dump the score to an abc file at the given path
dumps_abc(self)Dump the score to an abc file and return the str

Note that pickle is supported for serialization and deserialization.

Conversion

MethodDescription
copy(self, deep=True)Return a deep(default) or shallow copy of the score. Both copy.copy and copy.deepcopy functions are supported
to(self, ttype, min_dur: Optional[target_unit])Convert the score to a new Score with the given ttype
pianoroll(self, modes: List[str], pitch_range=(0, 128), encode_velocity=False)Only for TickScore. Convert the score to a 3D piano-roll matrix (numpy.ndarray) with the given modes. The pitch range and velocity encoding can be specified.

Modification

MethodDescription
resample(self, tpq: int, min_dur: Optional[int])Resample a score of any ttype into a new TickScore with the given tpq
clip(self, start: unit, end: unit, clip_end=False, inplace=False)Clip the score to the given range. If clip_end is True, notes will be clipped if they end after end
adjust_time(self, original_times: List[unit], new_times: List[unit], inplace=False)Adjust the time of the events in the score from the original times to the new times(Interpolating)
sort(self, reverse=False, inplace=True)Sort all events in the score by their default compare rules. The order of tracks would also be sorted.
shift_time(self, offset: unit, inplace=False)Shift the time of all the events in the score by the given offset
shift_pitch(self, offset: int, inplace=False)Shift the pitch of all the notes in the score by the given offset
shift_velocity(self, offset: int, inplace=False)Shift the velocity of all the notes in the score by the given offset

Track

Attributes & Properties

AttributeTypeDescription
namestrName of the track
programintProgram number of the track (Instrument)
is_drumboolWhether the track is a drum track
notesNoteListList of notes in the track
controlsControlChangeListList of control changes in the track
pitch_bendsPitchBendListList of pitch bends in the track
pedalsPedalListList of pedals in the track
lyricsTextMetaListList of lyrics in the track
Property-LikeTypeDescription
ttypeTick | Quarter | SecondTime unit of the track
start()intThe global start time of the track
end()intThe global end time of the track
note_num()intThe total number of notes in the track
empty()boolWhether the track is empty

Constructors

Track(
    self, name: str = "",
    program: int = 0,
    is_drum: bool = False,
    notes: smt.GeneralNoteList = None,
    controls: smt.GeneralControlChangeList = None,
    pitch_bends: smt.GeneralPitchBendList = None,
    pedals: smt.GeneralPedalList = None,
    ttype: smt.GeneralTimeUnit = TimeUnit.tick
)

You could also create a empty Track through Track.empty().

Conversion

MethodDescription
copy(self, deep=True)Return a deep(default) or shallow copy of the track. Both copy.copy and copy.deepcopy functions are supported
pianoroll(self, modes: List[str], pitch_range=(0, 128), encode_velocity=False)Only for TickTrack. Convert the track to a 2D piano-roll matrix (numpy.ndarray) with the given modes. The pitch range and velocity encoding can be specified.

Modification

MethodDescription
clip(self, start: unit, end: unit, clip_end=False, inplace=False)Clip the track to the given range. If clip_end is True, notes will be clipped if they end after end
adjust_time(self, original_times: List[unit], new_times: List[unit], inplace=False)Adjust the time of the events in the track from the original times to the new times(Interpolating)
sort(self, reverse=False, inplace=True)Sort all events in the track by their default compare rules.
shift_time(self, offset: unit, inplace=False)Shift the time of all the events in the track by the given offset
shift_pitch(self, offset: int, inplace=False)Shift the pitch of all the notes in the track by the given offset
shift_velocity(self, offset: int, inplace=False)Shift the velocity of all the notes in the track by the given offset

Motivation

Symusic aims to offer fast and efficient symbolic music data (e.g. MIDI, Music XML and ABC Notation) preprocessing backend.

The former dominant MIDI parsing backend is mido (used by pretty_midi and miditoolkit), which is written in pure python. However, it is too slow for the large-scale symbolic music data preprocessing task in the deep learning era, which makes it impossible to tokenize the needed data in real-time while training.

Out of the need, we developed this library. It is written in C++, offers a python binding using pybind11, and is over 100 times faster than mido. We parse the MIDI file to note level(similar to miditoolkit) instead of event level(mido), which is more suitable for the symbolic music data preprocessing task. More data formats like Music XML and ABC Notation will be supported in the future.

We separated the event-level MIDI parsing code into a lightweight and efficient header-only C++ library minimidi for those who only need to parse MIDI files to event level in C++.

In the future, we will also bind symusic to other languages like Julia and Lua for more convenient use.

Development

Environment

Since symusic is bound to python using nanobind, it would be a bit complicated to set a development environment.

The key problem is that, you need to let you IDE detect the right python evn on your system. Here, I recommend you to open your ide (like clion or vscode) in your terminal, after you have activated your python env.

conda activate symusic-env
clion path/to/symusic

You could also set your http proxy before open your ide in terminal.

The only requirement for symusic is nanobind. You could install it by:

pip install nanobind

Note that, cpython 3.12 get some memory leak problem, and it will be detected by nanobind. So I recommend you to use cpython <= 3.11 currently.

As for compiler, it should support c++20. clang (linux and mac), gcc and msvc all works (I have tested them).

Build

If you want to compile the python binding target, you need to set -DBUILD_PY:BOOL=ON when using cmake. Also, -DBUILD_TEST:BOOL=ON for some test cases.

If you are using clion, just set the cmake options to:

-DBUILD_TEST:BOOL=ON -DBUILD_PY:BOOL=ON

For installing symusic to your python env, you could use pip install . in the root directory of symusic. And everything is done.

PYI File

Here, we use nanobind-stubgen to generate the pyi file for symusic.core. And we wrote a generate_stub.sh to do this.

Note that the 0.1.4 version of nanobind-stubgen get some problem, so we use the 0.1.3 version here.

After generating the pyi file, you need run pre-commit run --all-files to format the pyi file.

And always check the git diff before committing. The import part of the auto generated pyi file is not correct, and you need to fix it manually. (Most of the time, you just need to keep the previous import part.)

Format

We use pre-commit to format the code. You could install it by:

pip install pre-commit

And then, run pre-commit install to install the pre-commit script

You could run it manually by pre-commit run --all-files, or just run it before committing by pre-commit run --all-files --hook-stage commit

The config is in pyproject.toml