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 fromtick
usingticks_per_quarter
), where the length of a quarter note is 1.second
(float32) means the time unit is in seconds, the same as whatpretty_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
Attribute | Type | Description |
---|---|---|
ticks_per_quarter | int | Number of ticks per quarter note |
tpq | int | Alias for ticks_per_quarter |
tracks | TrackList | List of tracks in the score |
time_signatures | TimeSignatureList | List of time signatures in the score |
key_signatures | KeySignatureList | List of key signatures in the score |
tempos | TempoList | List of tempos in the score |
markers | TextMetaList | List of markers in the score |
Property-Like | Type | Description |
---|---|---|
ttype | Tick | Quarter | Second | Time unit of the score |
start () | int | The global start time of the score |
end () | int | The global end time of the score |
note_num () | int | The total number of notes in the score |
empty () | bool | Whether the score is empty |
Constructors & IO
Method | Description |
---|---|
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
Method | Description |
---|---|
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
Method | Description |
---|---|
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
Attribute | Type | Description |
---|---|---|
name | str | Name of the track |
program | int | Program number of the track (Instrument) |
is_drum | bool | Whether the track is a drum track |
notes | NoteList | List of notes in the track |
controls | ControlChangeList | List of control changes in the track |
pitch_bends | PitchBendList | List of pitch bends in the track |
pedals | PedalList | List of pedals in the track |
lyrics | TextMetaList | List of lyrics in the track |
Property-Like | Type | Description |
---|---|---|
ttype | Tick | Quarter | Second | Time unit of the track |
start () | int | The global start time of the track |
end () | int | The global end time of the track |
note_num () | int | The total number of notes in the track |
empty () | bool | Whether 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
Method | Description |
---|---|
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
Method | Description |
---|---|
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