Introduction
Symusic (Symbolic Music) is a high-performance cross-platform library for processing symbolic music data, with a primary focus on MIDI files. It offers lightning-fast processing capabilities that are hundreds of times faster (200x-500x) than traditional Python libraries like mido.
Project Overview
Symbolic music data, such as MIDI, Music XML, and ABC Notation, are essential formats for representing music in a machine-readable form. Symusic aims to provide an efficient backend for preprocessing these formats, particularly targeting applications in music information retrieval and deep learning.
The library is written in C++ with Python bindings via nanobind, making it both performant and accessible to a wide range of users. It offers a note-level representation of MIDI files (similar to miditoolkit) rather than an event-level representation (as in mido), which is more suitable for many music processing tasks.
Core Features
- Lightning-fast processing: 200-500 times faster than pure Python implementations
- Multiple time unit support: Work with ticks, quarter notes, or seconds
- Comprehensive MIDI handling: Load, manipulate, and save MIDI files
- Piano roll conversion: Convert symbolic music to piano roll representations
- Music synthesis: Generate audio from symbolic music using SoundFont technology
- Cross-platform compatibility: Works on Linux, macOS, and Windows
- Pythonic API: Easy to use for Python developers
- Multithreading support: Efficiently process large datasets with multiple CPU cores
- Serialization: Fast pickling for multiprocessing workflows
Installation
Pre-Built Wheels
The easiest way to install Symusic is using pip:
pip install symusic
We provide pre-built wheels for:
- Operating Systems: Linux, macOS, and Windows
- Architectures: X86 and ARM (except Windows ARM64)
- Python Versions: CPython 3.8, 3.9, 3.10, 3.11, 3.12, and PyPy 3.9
Installing from Source
If you need to build from source, you can use the source distribution on PyPI:
pip install --no-binary symusic symusic
Or clone the repository and install it:
git clone --recursive git@github.com:Yikai-Liao/symusic.git
pip install ./symusic
Compiler Requirements
Symusic is built with C++20 and requires a modern compiler:
- GCC: 11 or newer
- Clang: 15 or newer
- MSVC: Visual Studio 2022 recommended
If you don't have sudo permissions on Linux, you can install GCC through conda:
conda install conda-forge::gcc conda-forge::gxx
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
Why Symusic?
The previously dominant MIDI parsing backends (like mido, used by pretty_midi and miditoolkit) are too slow for large-scale symbolic music data preprocessing in modern deep learning workflows. Symusic addresses this limitation with its high-performance C++ implementation, making it possible to process and tokenize music data in real-time during training.
Our event-level MIDI parsing code has been separated into a lightweight and efficient header-only C++ library called minimidi for those who only need to parse MIDI files at the event level in C++.
Future plans include bindings for other languages such as Julia and Lua for even broader accessibility.
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)
Core Concepts
This section explains the fundamental concepts and data structures within Symusic.
- Score: The main container for musical data.
- Track: Represents individual instrument or voice tracks.
- Events: Detailed description of musical events like notes, tempos, etc.
- Time Units: How time is represented (ticks, quarters, seconds).
- Synthesis: Concepts related to audio synthesis using SoundFonts.
Score Concept
The Score
class is the central container in Symusic, representing a complete musical piece, analogous to a full score in music notation. It serves as the primary object for loading, manipulating, and saving musical data.
Purpose and Role
The Score
object aggregates all the necessary information to define a piece of music:
- Structure: Holds multiple
Track
objects, typically representing different instruments or voices. - Global Context: Contains events that apply to the entire piece, such as tempo changes, time signatures, and key signatures.
- Metadata: Stores information like markers and the fundamental time resolution (
ticks_per_quarter
).
It acts as the root of the musical data hierarchy and provides methods for operations that affect the entire piece.
See Score API Reference for detailed usage.
Key Components
- Tracks (
tracks
): A list (TrackList
) containingTrack
objects. This is where most note-level data resides. - Tempo Changes (
tempos
): A list (TempoList
) defining the speed of the music over time. - Time Signatures (
time_signatures
): A list (TimeSignatureList
) defining the meter (e.g., 4/4, 3/4) over time. - Key Signatures (
key_signatures
): A list (KeySignatureList
) defining the key (e.g., C major, A minor) over time. - Markers (
markers
): A list (TextMetaList
) for textual annotations at specific time points. - Ticks Per Quarter (
ticks_per_quarter
/tpq
): An integer defining the time resolution in ticks for tick-based time units. Crucial for interpreting MIDI timing.
Time Units Concept
A Score
object (and its contained tracks/events) operates within a specific time unit system: Tick
, Quarter
, or Second
. This is a core concept explained in detail here.
The Score
's time unit determines how the time
and duration
attributes of all its events are interpreted. You can convert a score between time units using the to()
method (e.g., score.to("second")
).
Conceptual Model
Think of a Score
as the root of a tree:
Score (e.g., ScoreTick)
│
├── ticks_per_quarter: int
│
├── Tracks (TrackList<TrackTick>)
│ ├── Track 1 (TrackTick)
│ │ ├── notes: NoteList<NoteTick>
│ │ ├── controls: ControlChangeList<ControlChangeTick>
│ │ └── ... (pitch_bends, pedals, lyrics)
│ └── Track 2 (TrackTick)
│ └── ...
│
├── Tempos (TempoList<TempoTick>)
│ ├── Tempo Event 1
│ └── ...
│
├── Time Signatures (TimeSignatureList<TimeSignatureTick>)
│ └── ...
│
├── Key Signatures (KeySignatureList<KeySignatureTick>)
│ └── ...
│
└── Markers (TextMetaList<TextMetaTick>)
└── ...
Working with Scores (Conceptual Flow)
- Load/Create: Instantiate a
Score
by loading a file (Score("file.mid")
) or creating an empty one (Score(tpq=480)
). - Inspect: Examine tracks, global events, and metadata.
- Manipulate: Apply transformations (e.g.,
shift_pitch
,clip
,sort
) or modify event lists directly. - Convert (Optional): Change time units using
to()
if needed for analysis or synthesis. - Save/Use: Save back to a file (
dump_midi
) or use for further processing (e.g., synthesis, feature extraction).
This flow highlights the Score
as the central object managing the musical data throughout its lifecycle.
Track Concept
The Track
class represents a single, independent sequence of musical events within a Score
. It typically corresponds to a single instrument or voice in a musical piece, analogous to a track in a DAW or a staff in traditional notation.
Purpose and Role
Within the Symusic data hierarchy, a Track
serves to group events associated with a specific musical part. This separation allows for:
- Instrument Assignment: Each track has metadata like
program
number (MIDI instrument) and anis_drum
flag, determining how its notes should be interpreted or synthesized. - Independent Processing: Operations like transposition, quantization, or applying specific MIDI effects (like channel volume or pan via Control Changes) are often performed at the track level.
- Structural Organization: Provides a clear way to manage complex multi-instrumental pieces.
See Track API Reference for detailed usage.
Key Components
A Track
primarily contains time-ordered lists of various Event
types specific to that track:
- Notes (
notes
): The core musical pitches with durations and velocities. - Control Changes (
controls
): MIDI CC messages for parameters like volume, pan, modulation, sustain, etc. - Pitch Bends (
pitch_bends
): Smooth pitch deviations. - Pedals (
pedals
): Specialized representation for sustain pedal on/off durations. - Lyrics (
lyrics
): Text syllables or words synchronized with the track's events.
Each of these is stored in a dedicated list object (e.g., NoteList
, ControlChangeList
) that offers efficient storage and manipulation.
Metadata
Beyond the event lists, a Track
holds identifying metadata:
name
: A string identifier (e.g., "Piano Left Hand").program
: The MIDI program number (0-127) suggesting the instrument sound.is_drum
: A boolean flag indicating if the track uses MIDI percussion mapping (often channel 10).
Time Unit Context
A Track
itself doesn't define its time unit; it operates within the time unit (Tick
, Quarter
, or Second
) of the Score
it belongs to. All time
and duration
attributes of events within the track are interpreted according to the parent Score
's ttype
. Converting the Score
's time unit automatically converts all contained tracks and events.
Relationship to Score
A Score
acts as the container for one or more Track
objects. While tracks hold the specific performance data (notes, CCs, etc.), the Score
provides the global context (tempo, time signatures, key signatures) needed to fully interpret the track data. For example, calculating the absolute start time in seconds of a note within a track requires accessing the tempo map stored in the Score
.
Working with Tracks (Conceptual Flow)
- Access: Obtain
Track
objects from aScore
'stracks
list (e.g.,score.tracks[0]
). - Inspect: Examine the track's metadata (
name
,program
,is_drum
) and its event lists (notes
,controls
, etc.). - Manipulate: Modify the track directly (e.g.,
track.shift_pitch(12)
) or modify its event lists (e.g.,track.notes.append(...)
,track.controls.sort()
). - Analyze: Calculate track-specific features (e.g., note density within the track, pitch range used).
- Structure: Add, remove, or reorder tracks within the
Score.tracks
list.
Events Concept
Events are the fundamental, time-stamped units of information in Symusic. They represent specific musical occurrences or annotations within a Track
or globally within a Score
.
Purpose and Role
Events capture the discrete actions and descriptive markers that make up a piece of symbolic music. They form the lowest level of the data hierarchy, contained within lists inside Track
and Score
objects.
See Events API Reference for detailed usage of each event type.
Common Characteristics
All event types share key features:
time
Attribute: Indicates when the event occurs, relative to the start of the score, measured in the parentScore
's time unit (Tick
,Quarter
, orSecond
).- Time Unit Parameterization: Event classes are templates based on the time unit (e.g.,
NoteTick
,TempoQuarter
,ControlChangeSecond
). - Specific Attributes: Each type has attributes holding its relevant data (e.g., a
Note
haspitch
,duration
,velocity
; aTempo
hasqpm
). - Containment: Events live inside specialized lists within
Track
orScore
objects (e.g.,Track.notes
,Score.tempos
).
Overview of Event Types
Here's a conceptual overview of the main event types:
Note
: The most common event, representing a pitched sound with timing, duration, and intensity.ControlChange
(CC): Generic MIDI control messages for expressive details (volume, pan, modulation, pedal status, etc.).Tempo
: Global event defining the playback speed (Quarter Notes Per Minute) from itstime
onwards.TimeSignature
: Global event defining the musical meter (e.g., 4/4) from itstime
onwards.KeySignature
: Global event defining the musical key (sharps/flats and major/minor) from itstime
onwards.PitchBend
: Track-specific event for smooth pitch variations.Pedal
: Track-specific, simplified representation of sustain pedal usage (time and duration).TextMeta
: Textual information linked to a specific time. Used for lyrics within tracks (Track.lyrics
) and markers globally (Score.markers
).
Global vs. Track Events
Understanding the scope of an event is crucial:
- Track Events (inside
Track.notes
,Track.controls
, etc.): Define the performance of a specific instrument/part. Examples:Note
,ControlChange
,PitchBend
,Pedal
, Lyrics (TextMeta
). - Global Events (inside
Score.tempos
,Score.time_signatures
, etc.): Define the overall context for all tracks. Examples:Tempo
,TimeSignature
,KeySignature
, Markers (TextMeta
).
Working with Events (Conceptual Flow)
- Access: Get event lists from
Score
orTrack
objects (e.g.,track.notes
,score.tempos
). - Iterate/Inspect: Loop through lists to examine individual event attributes.
- Modify: Change attributes of existing events (e.g.,
note.velocity = 100
). - Add/Remove: Append new events or remove existing ones from their lists (e.g.,
track.notes.append(...)
,score.tempos.pop(0)
). - Sort: Use the
sort()
method on event lists to maintain chronological order after modifications.
Remember that an event's time
and duration
(if applicable) are always interpreted based on the ttype
of the parent Score
.
Time Units Concept
Time representation is a fundamental concept in symbolic music processing. Symusic offers flexibility by supporting multiple time units, allowing users to work with timings in the system that best suits their specific task, whether it's preserving MIDI fidelity, analyzing musical structure, or synchronizing with audio.
Purpose and Role
Different applications benefit from different ways of measuring musical time:
- MIDI Editing/Preservation: Requires the exact tick-level timing defined in the MIDI file.
- Musical/Rhythmic Analysis: Benefits from time measured relative to musical durations (like quarter notes), abstracting away tempo variations.
- Audio Synthesis/Synchronization: Needs time measured in absolute seconds to align with real-world time and audio samples.
Symusic's time unit system directly addresses these varying requirements.
See Time Units API Reference for detailed usage.
The Three Time Units Explained
Tick
: Integer values (int
). Represents the finest time resolution specified in the MIDI file header (Score.ticks_per_quarter
). This is the raw timing from the source file, essential for lossless representation.Quarter
: Floating-point values (float
). Represents time relative to the duration of a quarter note, where1.0
equals one quarter note. This unit is useful for analyzing musical structure (e.g., a half note has duration2.0
) independent of tempo.Second
: Floating-point values (float
). Represents absolute time in seconds from the beginning of the piece. Calculating this requires considering theScore
's tempo map, as the duration of a tick or quarter note in seconds changes with tempo.
How Time Units are Applied (TType
)
Symusic uses a generic programming approach (C++ templates / Python TypeVar
) where core data structures like Score
, Track
, and event types (Note
, Tempo
, etc.) are parameterized by the time unit (TType
).
This means you interact with specific versions like:
ScoreTick
,TrackTick
,NoteTick
ScoreQuarter
,TrackQuarter
,NoteQuarter
ScoreSecond
,TrackSecond
,NoteSecond
The ttype
property of these objects indicates their current time unit.
Conversion Between Units
The to()
method is the standard way to convert Symusic objects between time units:
score_tick = Score("file.mid")
# Convert Score and all contained events to Second time unit
score_second = score_tick.to("second")
# Convert back to Tick
score_tick_again = score_second.to("tick")
Conversion Logic Overview:
- Tick <-> Quarter: Relatively simple scaling based on
ticks_per_quarter
. - Tick <-> Second: Requires processing the
Score
's tempo map to relate ticks to seconds at different points in time. This is more computationally intensive. - Quarter <-> Second: Usually involves an intermediate conversion through
Tick
.
Important Considerations:
- Conversions involving
Second
depend on theTempo
events in theScore
. - Converting from float (
Quarter
,Second
) to int (Tick
) involves rounding, which can introduce minor precision differences. - The
min_dur
parameter into("tick", min_dur=...)
is useful to prevent notes/pedals from collapsing to zero duration due to rounding.
Choosing and Using Time Units
- Default: Loading a MIDI file typically defaults to
Tick
. - Analysis: Use
Quarter
for rhythm/meter analysis,Second
for duration/tempo analysis. - Synthesis: Convert to
Second
for accurate audio rendering. - Saving MIDI: Convert back to
Tick
before callingdump_midi
for lossless saving (thoughdump_midi
can often handle the conversion automatically).
Understanding the active time unit is crucial for interpreting time
and duration
values and the effect of time-based operations like shift_time
.
Synthesis Concept
Symusic integrates audio synthesis capabilities, allowing the direct conversion of symbolic music data (represented by Score
objects) into audible sound waveforms. This process relies on SoundFont technology.
Purpose and Role
The synthesis feature bridges the gap between symbolic representation and actual sound, enabling:
- Auditioning: Listening to MIDI files or programmatically generated scores without external software.
- Audio Dataset Creation: Generating waveform datasets from symbolic sources for tasks like automatic transcription or instrument recognition training.
- Procedural Audio: Integrating dynamic music rendering into applications or games.
See Synthesizer API Reference for detailed usage.
Core Components & Process
The synthesis workflow involves several key parts:
Synthesizer
Class: The main object that orchestrates the synthesis. It's initialized with a SoundFont and parameters.- SoundFont (
.sf2
/.sf3
): A file containing instrument samples and playback rules. This defines the sound of the output. - Input
Score
: The symbolic music data to be rendered. render()
Method: Called on theSynthesizer
instance with theScore
. It performs the following steps:- Ensures the Score is in
Second
time unit (converts internally if needed). - Iterates through score events (notes, program changes, etc.).
- Uses track information (
program
,is_drum
) to select instruments from the SoundFont. - Triggers and mixes the corresponding audio samples according to note properties (pitch, velocity, timing, duration) and SoundFont rules.
- Ensures the Score is in
- Output
AudioData
: An object holding the resulting audio samples (NumPy compatible) and metadata (sample rate, channels). dump_wav()
Utility: Saves theAudioData
to a standard WAV file.
Key Concepts
- SoundFont Dependency: The quality and character of the synthesized audio depend heavily on the chosen SoundFont file. Different SoundFonts contain different instrument samples.
- Time Unit Requirement: Synthesis fundamentally operates in absolute time. Therefore, input
Score
objects usingTick
orQuarter
units are internally converted toSecond
during rendering. - Parameters (
sample_rate
,quality
): These control the technical aspects of the audio output (resolution, fidelity) and the computational trade-off during rendering.
Relationship to Other Concepts
- Relies on
Score
as the complete musical input. - Uses
Track
metadata (program
,is_drum
) for instrument selection. - Interprets
Note
events to trigger sounds. - Operates in the
Second
time unit domain.
By integrating these components, the synthesis feature provides a direct path from symbolic representation to audible sound within the Symusic library.
API Reference
This section provides detailed documentation for Symusic's API. Each class and function is documented with its parameters, return types, and usage examples.
Organization
The API reference is organized by the main components of the library:
- Score: The container for a complete musical piece
- Track: Individual tracks within a score
- Events: Musical events like notes, control changes, etc.
- Time Units: Different time representation systems
- Synthesizer: Audio synthesis functionality
- Utility Functions: Helper functions for common tasks
Type Conventions
Throughout the API documentation, you'll see the following type conventions:
ttype
: Time unit type (Tick, Quarter, or Second)unit
: The actual value type corresponding to the time unit (int for Tick, float for Quarter and Second)- Generics are often used to make functions work with different time unit types
Common Patterns
Most classes in Symusic follow these common design patterns:
- Time Unit Conversion: Most objects can be converted between different time units using the
to()
method - Inplace vs. Copy: Most modification operations have both inplace and copy versions
- Serialization: All objects support Python's pickle protocol for efficient serialization
- Factory Methods: Classes often provide factory methods (like
from_file
,from_midi
) for different creation paths
Getting Started
If you're new to Symusic, we recommend starting with the Score class documentation, as it's the main entry point for most tasks. For loading and saving files, check the I/O methods in the Score class.
For examples of how to use the API effectively, see the Tutorials and Examples sections.
Score API Reference
The Score
class is the top-level container for musical data in Symusic. It holds tracks and global events like tempo, time signatures, and key signatures.
Type Definition
Score
is a generic class parameterized by time unit:
ScoreTick
(Score<Tick>
in C++): Time as integer ticks.ScoreQuarter
(Score<Quarter>
in C++): Time as float quarter notes.ScoreSecond
(Score<Second>
in C++): Time as float seconds.
Attributes
Attribute | Type | Description |
---|---|---|
ticks_per_quarter | int | Ticks per quarter note resolution (TPQ / PPQ) |
tpq | int | Alias for ticks_per_quarter |
tracks | TrackList | List-like container for Track objects. |
time_signatures | TimeSignatureList | List-like container for TimeSignature events. |
key_signatures | KeySignatureList | List-like container for KeySignature events. |
tempos | TempoList | List-like container for Tempo events. |
markers | TextMetaList | List-like container for TextMeta marker events. |
Properties (Read-Only Methods)
Method | Return Type | Description |
---|---|---|
ttype | TimeUnit object | Returns the time unit object (Tick , Quarter , Second ). |
start() | int or float | Start time of the earliest event in the score. |
end() | int or float | End time of the latest event in the score. |
note_num() | int | Total number of notes across all tracks. |
track_num() | int | Number of tracks in the tracks list. |
empty() | bool | True if the score contains no tracks or events. |
Constructors
from symusic import Score, TimeUnit
from pathlib import Path
# Create an empty score with specified TPQ and time unit
s = Score(tpq: int = 960, ttype: str | TimeUnit = "tick")
# Load score from a file path
# Format (fmt) is usually inferred from extension, can be 'midi' or 'abc'
s = Score(path: str | Path, ttype: str | TimeUnit = "tick", fmt: str | None = None)
# Copy constructor (creates a deep copy)
s_copy = Score(other: Score, ttype: str | TimeUnit | None = None)
# If ttype is provided, performs deep copy AND time unit conversion.
# If ttype is None, keeps the original time unit.
Factory Methods (Alternative Constructors)
# Create empty score (equivalent to constructor)
s = Score.from_tpq(tpq: int = 960, ttype: str | TimeUnit = "tick")
# Load from file (equivalent to constructor)
s = Score.from_file(path: str | Path, ttype: str | TimeUnit = "tick", fmt: str | None = None)
# Load from MIDI data in memory
s = Score.from_midi(data: bytes, ttype: str | TimeUnit = "tick")
# Load from ABC notation string in memory
s = Score.from_abc(data: str, ttype: str | TimeUnit = "tick")
# Create from another score (deep copy + optional conversion)
s_copy = Score.from_other(other: Score, ttype: str | TimeUnit | None = None)
I/O Methods
# Save score to a MIDI file
# Automatically converts to Tick time unit if necessary.
score.dump_midi(path: str | Path) -> None
# Get MIDI file content as bytes
midi_bytes: bytes = score.dumps_midi()
# Save score to an ABC notation file
# Note: ABC support might be limited compared to MIDI.
score.dump_abc(path: str | Path, warn: bool = True) -> None
# Get ABC notation content as string
abc_string: str = score.dumps_abc(warn: bool = True)
Conversion Methods
# Create a copy (deep by default)
# use deep=False for shallow copy (rarely needed)
score_copy = score.copy(deep: bool = True)
# Convert score to a different time unit
# Returns a NEW Score object with the converted time unit.
# min_dur (only for to("tick")): Minimum duration in ticks for notes/pedals
# after conversion, prevents zero duration due to rounding.
score_new_ttype = score.to(ttype: str | TimeUnit, min_dur: int | float | None = None)
# Convert score to piano roll (numpy array)
# Requires score to be in Tick time unit (use resample first).
# modes: list of strings, e.g. ["onset", "frame", "offset"]
# pitch_range: tuple (min_pitch, max_pitch_exclusive), e.g. (0, 128)
# encode_velocity: bool (True for velocity values, False for binary 0/1)
piano_roll_array = score_tick.pianoroll(
modes: list[str] = ["onset", "frame"],
pitch_range: tuple[int, int] = (0, 128),
encode_velocity: bool = False
) -> np.ndarray # Shape: (modes, tracks, pitch_bins, time_steps)
Modification Methods
These methods modify the score. Use inplace=True
to modify the object directly, otherwise a new modified Score object is returned.
# Resample score to a specific TPQ (returns a ScoreTick)
# Useful before creating piano roll to control time resolution.
# min_dur: Minimum tick duration for notes/pedals after resampling.
score_resampled = score.resample(tpq: int, min_dur: int | None = None) -> ScoreTick
# Clip score events to a time range [start, end)
score_clipped = score.clip(
start: int | float,
end: int | float,
clip_end: bool = False, # If True, also clip notes ending after 'end'
inplace: bool = False
)
# Adjust event timings based on mapping original times to new times
# Performs linear interpolation for times between specified points.
score_adjusted = score.adjust_time(
original_times: list[int | float],
new_times: list[int | float],
inplace: bool = False
)
# Sort all event lists within the score (tracks are also sorted)
score_sorted = score.sort(reverse: bool = False, inplace: bool = False)
# Shift time of all events by an offset
score_shifted = score.shift_time(offset: int | float, inplace: bool = False)
# Shift pitch of all notes by a semitone offset
score_transposed = score.shift_pitch(offset: int, inplace: bool = False)
# Shift velocity of all notes by an offset
score_dynamics_shifted = score.shift_velocity(offset: int, inplace: bool = False)
Synthesis Methods (Convenience)
# Synthesize score to audio data (shortcut for using Synthesizer class)
# See Synthesizer API for details on sf_path, sample_rate, quality
audio_data = score.to_audio(
sf_path: str | Path | None = None, # Defaults to MuseScoreGeneral
sample_rate: int = 44100,
quality: int = 4 # Note: Default quality here might differ from Synthesizer constructor
) -> AudioData
Serialization (Pickling)
Score
objects support Python's pickle
protocol for efficient serialization, which is useful for saving/loading objects quickly and for use with multiprocessing
.
import pickle
# Save score object to a pickle file
with open("score.pkl", "wb") as f:
pickle.dump(score, f)
# Load score object from a pickle file
with open("score.pkl", "rb") as f:
loaded_score = pickle.load(f)
# Internal methods (rarely called directly)
# state: bytes = score.__getstate__()
# score.__setstate__(state)
Comparison Methods
# Check if two Score objects are equal (deep comparison)
is_equal: bool = (score1 == score2)
# Check if two Score objects are not equal
is_not_equal: bool = (score1 != score2)
Equality comparison checks if all attributes, tracks, and events are identical between the two scores.
String Representation
# Get a string summary of the score using repr()
repr_string: str = repr(score)
# Or simply print the score (which calls repr())
print(score)
The string representation (obtained via repr()
or print()
) provides a concise summary of the score's properties (TPQ, time unit, number of tracks, event counts). There is no separate .summary()
method.
Track API Reference
A Track
object represents a single instrument track within a Score
, containing its specific musical events.
Type Definition
Track
is a generic class parameterized by time unit:
TrackTick
(Track<Tick>
in C++)TrackQuarter
(Track<Quarter>
in C++)TrackSecond
(Track<Second>
in C++)
Attributes
Attribute | Type | Description |
---|---|---|
name | str | Name of the track (e.g., "Piano", "Drums"). |
program | int | MIDI program number (instrument ID, 0-127). |
is_drum | bool | True if the track is a percussion/drum track. |
notes | NoteList | List-like container for Note events. |
controls | ControlChangeList | List-like container for ControlChange events. |
pitch_bends | PitchBendList | List-like container for PitchBend events. |
pedals | PedalList | List-like container for Pedal events. |
lyrics | TextMetaList | List-like container for lyric TextMeta events. |
Properties (Read-Only Methods)
Method | Return Type | Description |
---|---|---|
ttype | TimeUnit object | Returns the time unit object (Tick , Quarter , Second ). |
start() | int or float | Start time of the earliest event in the track. |
end() | int or float | End time of the latest event in the track. |
note_num() | int | Number of notes in the notes list. |
empty() | bool | True if the track contains no events. |
Constructors
from symusic import Track, TimeUnit
# Create an empty track
t = Track(name: str = "", program: int = 0, is_drum: bool = False, ttype: str | TimeUnit = "tick")
# Copy constructor (creates a deep copy)
t_copy = Track(other: Track)
Modification Methods
Modify the track's events. Use inplace=True
to modify directly, otherwise a new Track
is returned.
# Clip track events to a time range [start, end)
t_clipped = track.clip(
start: int | float,
end: int | float,
clip_end: bool = False, # If True, also clip notes ending after 'end'
inplace: bool = False
)
# Sort all event lists within the track
t_sorted = track.sort(reverse: bool = False, inplace: bool = False)
# Adjust event timings based on mapping original times to new times
t_adjusted = track.adjust_time(
original_times: list[int | float],
new_times: list[int | float],
inplace: bool = False
)
# Shift time of all events by an offset
t_shifted = track.shift_time(offset: int | float, inplace: bool = False)
# Shift pitch of all notes by a semitone offset
t_transposed = track.shift_pitch(offset: int, inplace: bool = False)
# Shift velocity of all notes by an offset
t_dynamics_shifted = track.shift_velocity(offset: int, inplace: bool = False)
Conversion Methods
# Create a copy (deep by default)
track_copy = track.copy(deep: bool = True)
# Convert track to a different time unit
# Returns a NEW Track object with the converted time unit.
track_new_ttype = track.to(ttype: str | TimeUnit, min_dur: int | float | None = None)
# Convert track to piano roll (numpy array)
# Requires track to be in Tick time unit.
pr_array = track_tick.pianoroll(
modes: list[str] = ["onset", "frame"],
pitch_range: tuple[int, int] = (0, 128),
encode_velocity: bool = False
) -> np.ndarray # Shape: (modes, pitch_bins, time_steps)
Event List Methods
The attributes notes
, controls
, pitch_bends
, pedals
, and lyrics
are specialized list objects with additional methods beyond standard Python list methods:
# Example using track.notes (NoteList), other lists have similar methods
notes_list = track.notes
# Basic properties
start_time = notes_list.start()
end_time = notes_list.end()
is_empty = notes_list.empty()
# Copying
notes_copy = notes_list.copy(deep=True)
# Sorting (inplace by default)
# Default key sorts by time, duration, pitch, velocity
notes_list.sort(key=None, reverse=False, inplace=True)
# Sort by velocity (non-inplace)
notes_sorted_by_vel = notes_list.sort(key=lambda note: note.velocity, inplace=False)
# Check if sorted
is_sorted_by_default = notes_list.is_sorted(key=None, reverse=False)
is_sorted_by_pitch = notes_list.is_sorted(key=lambda note: note.pitch, reverse=False)
# Filtering (inplace by default)
# Keep only notes with pitch >= 60
notes_list.filter(lambda note: note.pitch >= 60, inplace=True)
# Time Adjustment (non-inplace by default)
adjusted_notes = notes_list.adjust_time([0, 480], [0, 500], inplace=False)
# Time Shifting (non-inplace by default)
shifted_notes = notes_list.shift_time(960, inplace=False)
# NumPy Conversion (for NoteList, ControlChangeList, etc.)
# Returns a dictionary mapping attribute names to numpy arrays
numpy_dict = notes_list.numpy()
# e.g., numpy_dict['time'], numpy_dict['duration'], ...
# Create list from NumPy arrays (Class Method)
from symusic import Note # Use the specific event factory
new_notes_list = Note.from_numpy(
time=np_time_array,
duration=np_duration_array,
pitch=np_pitch_array,
velocity=np_velocity_array,
ttype=track.ttype # Important: Specify the time unit!
)
Serialization (Pickling)
Track
objects support Python's pickle
protocol.
import pickle
# Save track object to a pickle file
with open("track.pkl", "wb") as f:
pickle.dump(track, f)
# Load track object from a pickle file
with open("track.pkl", "rb") as f:
loaded_track = pickle.load(f)
# Internal methods (rarely called directly)
# state: bytes = track.__getstate__()
# track.__setstate__(state)
Comparison Methods
# Check if two Track objects are equal (deep comparison)
is_equal: bool = (track1 == track2)
# Check if two Track objects are not equal
is_not_equal: bool = (track1 != track2)
Equality comparison checks if metadata and all event lists are identical.
String Representation
# Get a string summary of the track using repr()
repr_string: str = repr(track)
# Or simply print the track (which calls repr())
print(track)
The string representation (obtained via repr()
or print()
) provides a summary including name, program, drum status, and event counts. There is no separate .summary()
method.
Events API Reference
Events represent individual musical occurrences or annotations within a Score
or Track
. All event classes are parameterized by time unit (Tick
, Quarter
, or Second
).
Common Event Patterns & Methods
Most event classes share these characteristics and methods:
- Time Unit Parameterization: Classes exist for each time unit (e.g.,
NoteTick
,NoteQuarter
,NoteSecond
). Thettype
property returns the time unit. time
Attribute: The time at which the event occurs.- Comparison (
__eq__
,__ne__
): Events are comparable (e.g.,event1 == event2
). Equality checks if all attributes are identical. Events of different types or time units are never equal. Comparison (<
,>
, etc.) primarily uses time, with tie-breaking rules specific to the event type. - Copying: Support
copy.copy()
(shallow, usually sufficient as events are often simple data structures) andcopy.deepcopy()
. - Serialization (Pickling): Support
pickle.dump()
andpickle.load()
via__getstate__
and__setstate__
. shift_time(offset)
: Returns a new event shifted byoffset
.shift_time_inplace(offset)
: Modifies the event's time inplace.to(ttype, ...)
: Returns a new event converted to the targetttype
. Event types with duration (Note
,Pedal
) accept an optionalmin_dur
argument for theto("tick", min_dur=...)
conversion.- NumPy Conversion (for lists): Event lists like
NoteList
often have.numpy()
andEventFactory.from_numpy()
methods (See Track API).
Note
Represents a musical note.
Attributes:
time
(int
|float
): Start time.duration
(int
|float
): Duration.pitch
(int
): MIDI pitch (0-127).velocity
(int
): MIDI velocity (1-127). A velocity of 0 might indicate a note-off in some contexts, but Symusic typically uses explicit durations.
Specific Methods:
end()
: Returnstime + duration
.empty()
: ReturnsTrue
ifduration <= 0
orvelocity <= 0
.shift_pitch(offset)
: Returns a new note with shifted pitch.shift_pitch_inplace(offset)
: Shifts pitch inplace.shift_velocity(offset)
: Returns a new note with shifted velocity.shift_velocity_inplace(offset)
: Shifts velocity inplace.
Factory: symusic.Note(time, duration, pitch, velocity, ttype="tick")
ControlChange
Represents a MIDI Control Change (CC) message.
Attributes:
time
(int
|float
): Event time.number
(int
): CC number (0-127).value
(int
): CC value (0-127).
Factory: symusic.ControlChange(time, number, value, ttype="tick")
PitchBend
Represents a MIDI Pitch Bend message.
Attributes:
time
(int
|float
): Event time.value
(int
): Pitch bend value (-8192 to 8191, where 0 is center).
Factory: symusic.PitchBend(time, value, ttype="tick")
Pedal
Represents a sustain pedal event with duration.
Attributes:
time
(int
|float
): Pedal press (onset) time.duration
(int
|float
): Duration the pedal is held down.
Specific Methods:
end()
: Returnstime + duration
(pedal release time).
Factory: symusic.Pedal(time, duration, ttype="tick")
TimeSignature
Represents a time signature (meter) change. Global event stored in Score.time_signatures
.
Attributes:
time
(int
|float
): Time the time signature takes effect.numerator
(int
): Numerator (e.g., 4 in 4/4).denominator
(int
): Denominator (e.g., 4 in 4/4).
Factory: symusic.TimeSignature(time, numerator, denominator, ttype="tick")
KeySignature
Represents a key signature change. Global event stored in Score.key_signatures
.
Attributes:
time
(int
|float
): Time the key signature takes effect.key
(int
): Number of sharps (positive) or flats (negative). Corresponds to MIDI key signature meta-messagesf
value.tonality
(int
): 0 for major, 1 for minor. Corresponds to MIDImi
value.
Specific Methods:
degree()
: Calculates a numerical representation useful for harmonic analysis (implementation-defined).
Factory: symusic.KeySignature(time, key, tonality, ttype="tick")
Tempo
Represents a tempo change. Global event stored in Score.tempos
.
Attributes:
time
(int
|float
): Time the tempo takes effect.qpm
(float
): Tempo in Quarter Notes Per Minute (BPM).
Internal Attribute (Not directly exposed in Python):
mspq
(int
): Microseconds per quarter note (often used internally for precision).qpm
is derived from/converts to this.
Factory: symusic.Tempo(time, qpm, ttype="tick")
TextMeta
Represents a text event associated with a specific time.
Attributes:
time
(int
|float
): Time of the text event.text
(str
): The text content.
Usage:
- Stored in
Track.lyrics
for lyrics. - Stored in
Score.markers
for global markers.
Factory: symusic.TextMeta(time, text, ttype="tick")
Event Lists (e.g., NoteList
, TempoList
)
Events are stored in specialized list-like containers within Score
and Track
objects. See the Track API Reference for methods common to these lists (append
, sort
, filter
, numpy
, from_numpy
, etc.).
Time Units
Symusic supports multiple ways to represent time within musical events. This flexibility allows users to choose the most appropriate representation for their specific task. Time units are managed through the TimeUnit
factory and specific time unit classes.
The TimeUnit
Factory
The TimeUnit
object acts as a factory for obtaining specific time unit instances and converting string representations to time unit objects.
from symusic import TimeUnit
# Get specific time unit instances
tick_unit = TimeUnit.tick
quarter_unit = TimeUnit.quarter
second_unit = TimeUnit.second
# Convert from string
unit1 = TimeUnit("tick") # Returns TimeUnit.tick
unit2 = TimeUnit("quarter") # Returns TimeUnit.quarter
unit3 = TimeUnit("second") # Returns TimeUnit.second
While you can use string representations like "tick"
directly in most Symusic functions (e.g., score.to("quarter")
), using the explicit TimeUnit
instances (TimeUnit.tick
, TimeUnit.quarter
, TimeUnit.second
) is slightly more performant as it avoids string parsing.
Available Time Units
Symusic provides three distinct time unit representations:
1. TimeUnit.tick
- Description: Represents time using integer ticks, typically derived directly from the MIDI file's header (ticks per quarter note). This is the most precise representation for preserving the original timing information from a MIDI file.
- Underlying Type:
int
- Usage: Ideal for operations that require exact MIDI timing, lossless MIDI saving, or when the original timing resolution is critical.
- Associated Classes:
ScoreTick
,TrackTick
,NoteTick
, etc.
2. TimeUnit.quarter
- Description: Represents time in terms of quarter notes, using floating-point numbers. A value of
1.0
corresponds to the duration of one quarter note. This representation is often intuitive for musical analysis as it relates directly to musical note values. - Underlying Type:
float
- Usage: Useful for musical analysis based on note durations, comparing relative timings, or tasks where tempo variations are less important than rhythmic structure.
- Associated Classes:
ScoreQuarter
,TrackQuarter
,NoteQuarter
, etc.
3. TimeUnit.second
- Description: Represents time in absolute seconds, using floating-point numbers. This calculation takes into account the score's tempo changes.
- Underlying Type:
float
- Usage: Essential for synchronizing with audio, calculating real-world durations, synthesis, and applications where absolute timing is crucial.
- Associated Classes:
ScoreSecond
,TrackSecond
,NoteSecond
, etc.
Time Unit Conversion
Most Symusic objects (like Score
, Track
, and individual Events
) provide a to()
method to convert between different time units:
from symusic import Score, TimeUnit
# Load score (defaults to tick)
score_tick = Score("path/to/file.mid")
# Convert to quarter note time
score_quarter = score_tick.to(TimeUnit.quarter)
# Or using string:
# score_quarter = score_tick.to("quarter")
# Convert quarter to seconds
score_second = score_quarter.to(TimeUnit.second)
# Convert seconds back to ticks, ensuring minimum duration of 1 tick
score_tick_again = score_second.to(TimeUnit.tick, min_dur=1)
When converting to TimeUnit.tick
, the optional min_dur
parameter can prevent notes or pedals from having zero duration due to rounding during conversions.
Choosing the Right Time Unit
- Use tick for preserving MIDI fidelity and precise timing operations.
- Use quarter for rhythm-based analysis and musically intuitive timing.
- Use second for audio synchronization, synthesis, and real-world time calculations.
Many operations in Symusic are optimized for specific time units (e.g., synthesis works best with seconds, piano roll requires ticks after resampling). The library handles necessary conversions internally where possible, but understanding the different units helps in choosing the most efficient workflow.
Synthesizer
The Synthesizer
class provides functionality to render a Score
object into audio data using SoundFont (SF2/SF3) files.
Initialization
from symusic import Synthesizer
from pathlib import Path
# Initialize with a SoundFont path
synthesizer = Synthesizer(
sf_path: str | Path, # Path to the .sf2 or .sf3 file
sample_rate: int = 44100, # Desired sample rate in Hz
quality: int = 0 # Synthesis quality (0-6, higher means better quality but slower)
)
- sf_path: The path to the SoundFont file. You can use the built-in SoundFonts provided by
symusic.BuiltInSF2
orsymusic.BuiltInSF3
, or provide a path to your own file. - sample_rate: The sample rate for the output audio. Defaults to 44100 Hz.
- quality: Controls the trade-off between synthesis quality and performance. Higher values (up to 6) improve quality but increase computation time. The default is 0 (lowest quality).
Methods
render(score, stereo=True)
Synthesizes the given Score
object into audio.
from symusic import Score, AudioData
# Assume synthesizer and score are already initialized
audio_data: AudioData = synthesizer.render(
score: Score, # The Score object to synthesize
stereo: bool = True # Whether to render in stereo (True) or mono (False)
)
- score: The
Score
object to be rendered. It can be aScoreTick
,ScoreQuarter
, orScoreSecond
object. The synthesizer will internally convert it toScoreSecond
if necessary. - stereo: If
True
, the output audio will have two channels. IfFalse
, it will be mono. Defaults toTrue
.
Returns: An AudioData
object containing the rendered audio samples and metadata.
AudioData
Object
The render
method returns an AudioData
object with the following properties:
sample_rate
: (int) The sample rate of the audio data.channels
: (int) The number of audio channels (1 for mono, 2 for stereo).frames
: (int) The number of audio frames (samples per channel).__array__()
: Allows direct conversion to a NumPy array (np.array(audio_data)
). The resulting array has the shape(frames, channels)
.
Utility Function: dump_wav
Symusic provides a helper function to easily save AudioData
to a WAV file.
from symusic import dump_wav
# Assume audio_data is an AudioData object obtained from render()
dump_wav(
path: str | Path, # Output file path
data: AudioData, # The AudioData object to save
use_int16: bool = True # If True, save as 16-bit integer WAV (smaller file),
# otherwise save as 32-bit float WAV (higher precision).
)
Example Usage
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# 1. Load a score
score = Score("path/to/your/midi.mid")
# 2. Initialize the synthesizer with a built-in SoundFont
sf_path = BuiltInSF3.MuseScoreGeneral().path(download=True)
synthesizer = Synthesizer(sf_path=sf_path, sample_rate=44100, quality=4)
# 3. Render the score to audio
audio_data = synthesizer.render(score, stereo=True)
# 4. Save the audio to a WAV file
dump_wav("output_audio.wav", audio_data)
# 5. (Optional) Access audio data as a NumPy array
import numpy as np
audio_array = np.array(audio_data)
print(f"Audio shape: {audio_array.shape}")
print(f"Sample rate: {audio_data.sample_rate}")
Utility Functions API
Symusic provides a few standalone utility functions for convenience.
dump_wav
Saves an AudioData
object (typically obtained from Synthesizer.render()
) to a WAV file.
Signature:
from symusic import dump_wav, AudioData
from pathlib import Path
dump_wav(
path: str | Path, # Path where the WAV file will be saved.
data: AudioData, # The AudioData object containing audio samples.
use_int16: bool = True # Format flag. If True, saves as 16-bit integer WAV.
# If False, saves as 32-bit float WAV.
) -> None:
Parameters:
path
: The file path (as a string orpathlib.Path
) for the output WAV file.data
: TheAudioData
object returned by theSynthesizer.render()
method.use_int16
: Determines the output format.True
(default) results in a smaller 16-bit integer WAV file, suitable for most playback purposes.False
saves as a 32-bit floating-point WAV, preserving the full precision of the synthesizer's output but resulting in a larger file.
Example:
from symusic import Score, Synthesizer, dump_wav, BuiltInSF3
score = Score("input.mid")
synth = Synthesizer(BuiltInSF3.MuseScoreGeneral().path())
audio = synth.render(score)
# Save as standard 16-bit WAV
dump_wav("output_int16.wav", audio)
# Save as 32-bit float WAV
dump_wav("output_float32.wav", audio, use_int16=False)
Tutorials
This section contains detailed tutorials to help you get the most out of Symusic. Each tutorial focuses on a specific aspect of the library and provides step-by-step guidance with practical examples.
Available Tutorials
MIDI File Operations
Learn how to load, modify, and save MIDI files with Symusic. This tutorial covers basic operations like reading and writing MIDI files, as well as more advanced manipulations like transposition, time adjustment, and track operations.
Piano Roll Conversion
Discover how to convert between symbolic music data and piano roll representations. This tutorial explains the piano roll concept, shows how to create customized piano rolls, and provides techniques for visualizing and analyzing them.
Music Synthesis
Learn how to generate audio from symbolic music data using Symusic's synthesis capabilities. This tutorial covers SoundFont-based synthesis, audio processing, and advanced rendering techniques.
Data Processing
Explore how to process large collections of MIDI files efficiently. This tutorial demonstrates techniques for batch processing, multiprocessing, and data extraction for machine learning applications.
Tutorial Structure
Each tutorial follows a similar structure:
- Introduction: Brief overview of the topic
- Basic Concepts: Explanation of relevant concepts and theory
- Basic Usage: Simple examples to get started
- Advanced Usage: More complex examples and techniques
- Tips and Best Practices: Recommendations for effective use
- Troubleshooting: Solutions to common problems
Prerequisites
To follow along with these tutorials, you should have:
- Symusic installed (see the Installation section)
- Basic Python knowledge
- Familiarity with music concepts (helpful but not required)
Many tutorials use additional libraries like NumPy, Matplotlib, and SciPy for data processing and visualization. You can install these with:
pip install numpy matplotlib scipy
Additional Resources
If you're looking for more concise examples, check out the Examples section, which provides shorter, focused code snippets for specific tasks.
For detailed API documentation, refer to the API Reference section.
If you have a specific use case that isn't covered in these tutorials, feel free to open an issue on our GitHub repository for assistance.
MIDI File Operations Tutorial
This tutorial covers the basics of loading, inspecting, modifying, and saving MIDI files using Symusic.
Loading MIDI Files
The primary way to load a MIDI file is using the Score
constructor or the Score.from_file
class method.
from symusic import Score, TimeUnit
# Load a MIDI file (defaults to 'tick' time unit)
score_tick = Score("path/to/your/midi_file.mid")
# Check the time unit and ticks per quarter note
print(f"Time unit: {score_tick.ttype}")
print(f"Ticks per quarter: {score_tick.ticks_per_quarter}")
# Load specifying a different time unit
score_quarter = Score("path/to/your/midi_file.mid", ttype="quarter")
print(f"Time unit: {score_quarter.ttype}")
score_second = Score.from_file("path/to/your/midi_file.mid", ttype=TimeUnit.second)
print(f"Time unit: {score_second.ttype}")
Inspecting Score Contents
Once loaded, you can explore the Score
object's contents.
# Basic information
print(f"Number of tracks: {score_tick.track_num()}")
print(f"Total notes: {score_tick.note_num()}")
print(f"Score duration (ticks): {score_tick.end() - score_tick.start()}")
# Access tracks
print("\nTracks:")
for i, track in enumerate(score_tick.tracks):
print(f" Track {i}: Name='{track.name}', Program={track.program}, IsDrum={track.is_drum}, Notes={track.note_num()}")
# Access global events (e.g., tempos)
print("\nTempo Changes:")
for tempo in score_tick.tempos:
print(f" Time={tempo.time}, QPM={tempo.qpm}")
# Access notes in the first track
first_track = score_tick.tracks[0]
print("\nFirst 5 notes of Track 0:")
for note in first_track.notes[:5]:
print(f" Time={note.time}, Dur={note.duration}, Pitch={note.pitch}, Vel={note.velocity}")
Modifying MIDI Data
Symusic provides methods for various modifications. Most methods have an inplace
option.
Transposition
# Transpose the entire score up by 2 semitones (creates a new Score)
transposed_score = score_tick.shift_pitch(2)
# Transpose only the first track down by 1 semitone (inplace)
score_tick.tracks[0].shift_pitch_inplace(-1)
Time Adjustment
# Shift all events later by 960 ticks (inplace)
score_tick.shift_time_inplace(960)
# Clip the score to keep only events between time 1920 and 3840
clipped_score = score_tick.clip(1920, 3840)
Changing Tempo
from symusic import Tempo
# Change the tempo at the beginning
if score_tick.tempos:
score_tick.tempos[0].qpm = 100.0 # Set first tempo to 100 BPM
else:
# Add a tempo event if none exist
score_tick.tempos.append(Tempo(time=0, qpm=100.0))
# Add a gradual tempo change (ritardando)
# Convert to seconds for easier tempo manipulation if needed
# score_second = score_tick.to("second")
# ... manipulate tempo in seconds ...
# score_tick = score_second.to("tick")
Removing Tracks
# Remove the last track (inplace)
if score_tick.tracks:
score_tick.tracks.pop(-1)
# Remove drum tracks (creates a new list)
non_drum_tracks = [track for track in score_tick.tracks if not track.is_drum]
score_tick.tracks = non_drum_tracks # Replace the track list
Saving MIDI Files
Save the modified (or original) Score
object back to a MIDI file.
# Save the score to a new MIDI file
# Note: Saving always uses the 'tick' representation internally.
# If the score is not in 'tick', it will be converted automatically.
transposed_score.dump_midi("transposed_output.mid")
# Save the clipped score
clipped_score.dump_midi("clipped_output.mid")
Symusic automatically handles the conversion to the tick-based format required for MIDI files during the dump_midi
process.
Batch Processing MIDI Files
Symusic's speed makes it ideal for processing large collections of MIDI files, often combined with Python's multiprocessing
.
import multiprocessing as mp
from pathlib import Path
from symusic import Score
def process_midi(midi_path: Path, output_dir: Path):
try:
score = Score(midi_path)
# Example: Keep only piano tracks (program 0)
piano_tracks = [t for t in score.tracks if t.program == 0 and not t.is_drum]
if not piano_tracks:
return # Skip if no piano tracks
score.tracks = piano_tracks
score.dump_midi(output_dir / midi_path.name)
print(f"Processed: {midi_path.name}")
except Exception as e:
print(f"Error processing {midi_path.name}: {e}")
if __name__ == "__main__":
input_directory = Path("path/to/midi/collection")
output_directory = Path("path/to/output/dir")
output_directory.mkdir(exist_ok=True)
midi_files = list(input_directory.glob("*.mid"))
# Create tuples of arguments for starmap
args = [(mf, output_directory) for mf in midi_files]
# Use multiprocessing pool
with mp.Pool(processes=mp.cpu_count()) as pool:
pool.starmap(process_midi, args)
print("Batch processing complete.")
This tutorial provides a starting point for working with MIDI files in Symusic. Explore the Score
and Track
API documentation for more advanced methods and options.
Piano Roll Conversion
Piano rolls are a popular representation of symbolic music data, especially in music generation and analysis tasks. Symusic provides efficient tools to convert between MIDI and piano roll representations.
What is a Piano Roll?
A piano roll is a matrix representation of music where:
- The rows represent different pitch values
- The columns represent time steps
- The values in the matrix represent note presence, velocity, or other properties
Piano rolls are often used in machine learning models for music generation and analysis because they're easy to process using neural networks and other algorithms.
Basic Piano Roll Conversion
Symusic can convert a Score or Track object to a piano roll representation:
from symusic import Score
import matplotlib.pyplot as plt
# Load a MIDI file
score = Score("path/to/file.mid")
# Resample to reduce size (recommended before piano roll conversion)
# This makes the time grid more manageable
# tpq=6 means 6 ticks per quarter note (1/24 note resolution)
# min_dur=1 ensures notes are at least 1 time unit long
score_resampled = score.resample(tpq=6, min_dur=1)
# Convert the entire score to a piano roll
# This creates a 4D array: [modes, tracks, pitch, time]
pianoroll = score_resampled.pianoroll(
modes=["onset", "frame"], # Type of information to include
pitch_range=[0, 128], # Range of MIDI pitches (default: all 128)
encode_velocity=True # If True, use velocity values; if False, use binary (0/1)
)
# Visualize the piano roll (combined onset and frame for the first track)
plt.figure(figsize=(12, 6))
plt.imshow(pianoroll[0, 0] + pianoroll[1, 0], origin='lower', aspect='auto',
extent=[0, pianoroll.shape[3], 0, 128])
plt.title('Piano Roll (Track 0)')
plt.xlabel('Time (in ticks)')
plt.ylabel('Pitch (MIDI note number)')
plt.colorbar(label='Velocity')
plt.show()
Piano Roll Modes
Symusic supports different piano roll modes that capture different aspects of musical notes:
- onset: Only the beginning (attack) of each note is marked
- frame: The entire duration of each note is marked
- offset: Only the end (release) of each note is marked
You can specify one or more modes when creating a piano roll:
# Create a piano roll with all three modes
pianoroll = score_resampled.pianoroll(
modes=["onset", "frame", "offset"],
pitch_range=[0, 128],
encode_velocity=True
)
# Access each mode separately
onset_roll = pianoroll[0] # Shape: [tracks, pitch, time]
frame_roll = pianoroll[1] # Shape: [tracks, pitch, time]
offset_roll = pianoroll[2] # Shape: [tracks, pitch, time]
Single Track Piano Roll
You can also create a piano roll for a single track:
from symusic import Score
import matplotlib.pyplot as plt
# Load a MIDI file
score = Score("path/to/file.mid")
# Resample for better processing
score_resampled = score.resample(tpq=6, min_dur=1)
# Select a track
track = score_resampled.tracks[0] # First track
# Convert track to piano roll (creates a 3D array: [modes, pitch, time])
track_pianoroll = track.pianoroll(
modes=["onset", "frame", "offset"],
pitch_range=[0, 128],
encode_velocity=True
)
# The shape is different from score.pianoroll() - no track dimension
print(f"Track piano roll shape: {track_pianoroll.shape}") # [3, 128, time_steps]
# Visualize the track's piano roll
plt.figure(figsize=(12, 6))
plt.imshow(track_pianoroll[1], origin='lower', aspect='auto', # Show only the frame mode
extent=[0, track_pianoroll.shape[2], 0, 128])
plt.title(f'Piano Roll - {track.name}')
plt.xlabel('Time (in ticks)')
plt.ylabel('Pitch (MIDI note number)')
plt.colorbar(label='Velocity')
plt.show()
Customizing Piano Roll Range
You can focus on a specific pitch range by specifying the pitch_range
parameter:
# Create a piano roll for just the piano range (21-108)
piano_range_roll = track.pianoroll(
modes=["onset", "frame"],
pitch_range=[21, 109], # MIDI notes 21-108 (standard piano)
encode_velocity=True
)
# Create a piano roll for just the bass range
bass_range_roll = track.pianoroll(
modes=["frame"],
pitch_range=[24, 60], # Low range
encode_velocity=False # Binary (simpler visualization)
)
Binary vs. Velocity Encoding
The encode_velocity
parameter controls whether the piano roll contains velocity information:
# Velocity-encoded piano roll (values are actual velocities: 0-127)
velocity_roll = track.pianoroll(
modes=["frame"],
encode_velocity=True
)
# Binary piano roll (values are just 0 or 1)
binary_roll = track.pianoroll(
modes=["frame"],
encode_velocity=False
)
Binary encoding is often simpler for visualization and certain types of processing, while velocity encoding preserves more musical information.
Working with Piano Roll Data
Once you've created a piano roll, you can process it using NumPy operations:
import numpy as np
from symusic import Score
# Load and convert to piano roll
score = Score("path/to/file.mid").resample(tpq=4, min_dur=1)
pianoroll = score.pianoroll(modes=["frame"], encode_velocity=True)
# If working with a score piano roll, access a specific track
track_roll = pianoroll[0, 0] # Mode 0 (frame), track 0
# Basic statistics
active_notes = np.count_nonzero(track_roll)
total_cells = track_roll.size
activity = active_notes / total_cells
print(f"Note activity: {activity:.2%}")
# Find the highest and lowest pitches used
active_pitches = np.any(track_roll > 0, axis=1)
lowest_pitch = np.argmax(active_pitches)
highest_pitch = len(active_pitches) - np.argmax(active_pitches[::-1]) - 1
print(f"Pitch range: {lowest_pitch} to {highest_pitch}")
# Find the average velocity of active notes
avg_velocity = np.mean(track_roll[track_roll > 0])
print(f"Average velocity: {avg_velocity:.1f}")
# Create a "piano roll profile" (how often each pitch is used)
pitch_profile = np.sum(track_roll > 0, axis=1)
Advanced Visualization
You can create more advanced visualizations of piano rolls:
import matplotlib.pyplot as plt
import matplotlib.colors as colors
import numpy as np
from symusic import Score
# Load and prepare data
score = Score("path/to/file.mid").resample(tpq=6, min_dur=1)
track = score.tracks[0]
pianoroll = track.pianoroll(modes=["onset", "frame"], pitch_range=[21, 109], encode_velocity=True)
# Create a custom colormap for better visualization
cmap = plt.cm.viridis
norm = colors.Normalize(vmin=0, vmax=127)
# Create a figure with multiple subplots
fig, axs = plt.subplots(3, 1, figsize=(12, 10), sharex=True)
# Plot onset roll
onset = pianoroll[0]
im0 = axs[0].imshow(onset, origin='lower', aspect='auto', cmap=cmap, norm=norm)
axs[0].set_title('Note Onsets')
axs[0].set_ylabel('Pitch')
fig.colorbar(im0, ax=axs[0], label='Velocity')
# Plot frame roll
frame = pianoroll[1]
im1 = axs[1].imshow(frame, origin='lower', aspect='auto', cmap=cmap, norm=norm)
axs[1].set_title('Note Frames')
axs[1].set_ylabel('Pitch')
fig.colorbar(im1, ax=axs[1], label='Velocity')
# Plot combined view (onset + frame)
combined = np.maximum(onset * 0.7, frame) # Emphasize onsets
im2 = axs[2].imshow(combined, origin='lower', aspect='auto', cmap=cmap, norm=norm)
axs[2].set_title('Combined View')
axs[2].set_ylabel('Pitch')
axs[2].set_xlabel('Time (ticks)')
fig.colorbar(im2, ax=axs[2], label='Velocity')
# Adjust layout and display
plt.tight_layout()
plt.show()
Tips for Piano Roll Processing
Resampling for Efficient Processing
Always resample your score before converting to piano roll to avoid generating excessively large matrices:
# Bad: Direct conversion without resampling could create a huge matrix
# pianoroll = score.pianoroll(modes=["frame"])
# Good: Resample first to reduce the time resolution
score_resampled = score.resample(tpq=4, min_dur=1) # 16th note resolution
pianoroll = score_resampled.pianoroll(modes=["frame"])
Choosing the Right Resolution
The tpq
(ticks per quarter note) parameter in resample()
controls the time resolution:
- Lower values (e.g., 2-4) create smaller matrices but lose some timing precision
- Higher values (e.g., 8-12) preserve more detail but create larger matrices
- For most applications, values between 4-8 work well (1/16 to 1/32 note resolution)
Memory Management
Piano rolls can be memory-intensive for long pieces or high resolutions. Consider:
- Processing shorter segments if memory is an issue
- Using lower
tpq
values for initial exploration - Limiting the pitch range to the most relevant notes
- Using binary encoding (
encode_velocity=False
) to save memory
# Memory-efficient piano roll for a long piece
efficient_roll = score.resample(tpq=4, min_dur=1).pianoroll(
modes=["frame"],
pitch_range=[36, 96], # Focus on middle pitch range
encode_velocity=False # Binary encoding
)
Converting Back to Notes
Currently, Symusic doesn't provide built-in functionality to convert piano rolls back to Score or Track objects. This is because the conversion is complex and can be ambiguous, especially when dealing with overlapping notes.
If you need to convert piano roll data back to MIDI, consider using external libraries like pretty_midi
or implementing custom algorithms based on your specific use case.
Music Synthesis
Symusic provides a built-in synthesizer that can render MIDI files to audio using SoundFont (SF2/SF3) technology. The synthesizer is based on the prestosynth library and offers a simple yet powerful interface for audio rendering.
Basic Synthesis
The simplest way to synthesize a score is to use the Synthesizer
class:
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# Load a MIDI file
score = Score("path/to/file.mid")
# Create a synthesizer with default settings
synthesizer = Synthesizer(
sf_path=BuiltInSF3.MuseScoreGeneral().path(download=True),
sample_rate=44100,
quality=4 # Default quality setting
)
# Render the score to audio (returns an AudioData object)
audio_data = synthesizer.render(score, stereo=True)
# Save the audio to a WAV file
dump_wav("output.wav", audio_data)
Built-in SoundFonts
Symusic provides several built-in SoundFonts that you can use without having to find and download your own. These are accessed through the BuiltInSF3
class:
from symusic import BuiltInSF3
# Available built-in SoundFonts
sf_path = BuiltInSF3.MuseScoreGeneral().path(download=True) # MuseScore General (default)
sf_path = BuiltInSF3.FluidR3Mono().path(download=True) # FluidR3 Mono
sf_path = BuiltInSF3.FluidR3().path(download=True) # FluidR3 GM
The path()
method will return the path to the SoundFont file, downloading it if necessary when the download
parameter is set to True
.
Synthesizer Parameters
The Synthesizer
constructor takes several parameters that control the quality and characteristics of the audio output:
synthesizer = Synthesizer(
sf_path, # Path to the SoundFont file (string or Path)
sample_rate, # Sample rate in Hz (default: 44100)
quality # Synthesis quality (0-6, default: 0 in factory, 4 often good)
)
Working with Audio Data
The render
method returns an AudioData
object, which contains the synthesized audio data. You can manipulate this data directly or save it to a WAV file:
# Render a score
audio_data = synthesizer.render(score, stereo=True)
# Get audio properties
sample_rate = audio_data.sample_rate
num_channels = audio_data.channels
num_frames = audio_data.frames
# Access the raw audio data as a NumPy array
import numpy as np
np_array = np.array(audio_data) # Shape: [frames, channels]
# Manipulate the audio (example: amplify by 1.5)
np_array *= 1.5
# Save to a WAV file
dump_wav("output.wav", audio_data)
Time Unit Considerations
The synthesizer works most efficiently with scores in Second
time unit. When you pass a score in Tick
or Quarter
time unit, it will be automatically converted to Second
internally:
# Loading a score in any time unit works
score_tick = Score("file.mid", ttype="tick")
score_quarter = Score("file.mid", ttype="quarter")
score_second = Score("file.mid", ttype="second")
# All of these will produce the same audio
audio1 = synthesizer.render(score_tick, stereo=True)
audio2 = synthesizer.render(score_quarter, stereo=True)
audio3 = synthesizer.render(score_second, stereo=True)
Custom Synthesis Workflow
For more control over the synthesis process, you might want to modify the score before rendering:
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# Load a score
score = Score("input.mid")
# Modify the score (example: transpose up by 2 semitones)
transposed_score = score.shift_pitch(2)
# Modify tempo (example: increase tempo by 20%)
for tempo in transposed_score.tempos:
tempo.qpm *= 1.2
# Create a synthesizer
synthesizer = Synthesizer(
sf_path=BuiltInSF3.MuseScoreGeneral().path(download=True),
sample_rate=48000,
)
# Render to audio
audio_data = synthesizer.render(transposed_score, stereo=True)
# Save to WAV
dump_wav("output.wav", audio_data)
Handling Multiple Scores
You can reuse the same synthesizer instance to render multiple scores:
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# Create a synthesizer once
synthesizer = Synthesizer(
sf_path=BuiltInSF3.MuseScoreGeneral().path(download=True),
sample_rate=44100,
)
# Process multiple files
for i in range(1, 4):
# Load score
score = Score(f"input{i}.mid")
# Render to audio
audio = synthesizer.render(score, stereo=True)
# Save to WAV
dump_wav(f"output{i}.wav", audio)
Advanced: Audio Postprocessing
You can perform post-processing on the audio data using NumPy or other audio processing libraries:
import numpy as np
from scipy import signal
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# Load and render
score = Score("input.mid")
synthesizer = Synthesizer(
sf_path=BuiltInSF3.MuseScoreGeneral().path(download=True),
sample_rate=44100,
)
audio_data = synthesizer.render(score, stereo=True)
# Convert to NumPy array
audio_array = np.array(audio_data) # Shape: [frames, channels]
# Example: Apply a low-pass filter (cutoff at 4000 Hz)
b, a = signal.butter(4, 4000 / (audio_data.sample_rate / 2), 'low')
filtered_audio = signal.filtfilt(b, a, audio_array, axis=0)
# Example: Normalize the audio
max_val = np.max(np.abs(filtered_audio))
if max_val > 0:
normalized_audio = filtered_audio / max_val
# Convert back to AudioData (implementation depends on your use case)
# This is a simplified example - you'd need to create a new AudioData object
# with the processed samples
# Save the processed audio (assuming you have a way to convert back to AudioData)
dump_wav("processed_output.wav", audio_data)
Performance Considerations
Synthesis can be CPU-intensive, especially with high-quality settings. Here are some tips for optimizing performance:
- Use a lower
quality
setting for real-time or batch processing - Use a lower
sample_rate
if high fidelity isn't required - Consider rendering shorter segments of music if working with very long pieces
- For batch processing of many files, consider using multiprocessing
Troubleshooting
Common Issues
-
SoundFont not found: Make sure the SoundFont path is correct and the file exists
# Check if SoundFont exists before using it import os sf_path = BuiltInSF3.MuseScoreGeneral().path(download=True) assert os.path.exists(sf_path), f"SoundFont not found at {sf_path}"
-
Audio quality issues: Try increasing the quality parameter
synthesizer = Synthesizer(sf_path, sample_rate=44100, quality=5) # Higher quality
-
Memory errors: For very long or complex MIDI files, you might run into memory issues
# Process the score in smaller chunks (example implementation) def process_in_chunks(score, chunk_size=60): # 60 seconds per chunk result_chunks = [] total_duration = score.end() for start in range(0, int(total_duration), chunk_size): end = min(start + chunk_size, total_duration) chunk = score.clip(start, end) chunk_audio = synthesizer.render(chunk, stereo=True) result_chunks.append(np.array(chunk_audio)) # Combine chunks (simplified example) return np.concatenate(result_chunks, axis=0)
Data Processing Tutorial
Symusic's high performance makes it particularly well-suited for processing large datasets of symbolic music files, a common task in Music Information Retrieval (MIR) and machine learning.
Why Symusic for Data Processing?
- Speed: Loading and basic manipulation of MIDI files are significantly faster than pure Python libraries.
- Efficiency: C++ backend reduces memory overhead compared to Python object representations.
- Multiprocessing: Symusic objects are efficiently serializable (pickleable), making them work seamlessly with Python's
multiprocessing
module for parallel processing.
Common Data Processing Tasks
- Loading and Filtering: Reading a large dataset and selecting relevant files or tracks.
- Data Cleaning: Removing empty files, fixing timing issues, standardizing formats.
- Feature Extraction: Computing musical features (e.g., pitch histograms, note density, tempo).
- Representation Conversion: Converting MIDI to other formats like piano roll or custom event sequences for model input.
- Data Augmentation: Applying transformations like transposition or time stretching.
Using multiprocessing
Python's multiprocessing
library is the standard way to parallelize CPU-bound tasks across multiple cores. Symusic objects integrate well with this.
import multiprocessing as mp
from pathlib import Path
from symusic import Score
import time
import pickle # Symusic uses efficient pickling
def worker_function(midi_path: Path) -> dict | None:
"""Loads a MIDI file and extracts some basic features."""
try:
# Load the score
score = Score(midi_path)
# Skip empty scores
if score.empty():
return None
# Basic feature extraction
num_notes = score.note_num()
duration_seconds = score.to("second").end()
avg_tempo = 0
if score.tempos:
avg_tempo = sum(t.qpm for t in score.tempos) / len(score.tempos)
return {
"filename": midi_path.name,
"num_notes": num_notes,
"duration_sec": duration_seconds,
"avg_tempo": avg_tempo
}
except Exception as e:
print(f"Error processing {midi_path.name}: {e}")
return None
if __name__ == "__main__":
start_time = time.time()
input_dir = Path("path/to/your/midi/dataset")
midi_files = list(input_dir.glob("**/*.mid")) # Recursively find MIDI files
print(f"Found {len(midi_files)} MIDI files.")
# Determine number of processes (use all available cores)
num_processes = mp.cpu_count()
print(f"Using {num_processes} processes.")
# Process files in parallel
with mp.Pool(processes=num_processes) as pool:
# Use map to apply the worker function to each file path
results = pool.map(worker_function, midi_files)
# Filter out None results (errors or empty files)
valid_results = [r for r in results if r is not None]
end_time = time.time()
print(f"\nProcessed {len(valid_results)} files in {end_time - start_time:.2f} seconds.")
# Example: Save results to a file (e.g., JSON or CSV)
# import json
# with open("dataset_features.json", "w") as f:
# json.dump(valid_results, f, indent=2)
# Example: Print some stats
if valid_results:
avg_notes = sum(r['num_notes'] for r in valid_results) / len(valid_results)
avg_duration = sum(r['duration_sec'] for r in valid_results) / len(valid_results)
print(f"Average notes per file: {avg_notes:.1f}")
print(f"Average duration per file: {avg_duration:.1f} seconds")
Explanation:
worker_function
: This function takes a singlePath
object, loads the MIDI file usingScore
, performs some processing (feature extraction in this case), and returns the result (orNone
on error/empty score).if __name__ == "__main__":
: Essential formultiprocessing
to work correctly, especially on Windows.mp.Pool
: Creates a pool of worker processes.pool.map(worker_function, midi_files)
: This is the core of the parallel processing. It distributes themidi_files
list among the worker processes, each callingworker_function
on a subset of the files. It collects the results in order.- Result Handling: The collected
results
list contains the return values fromworker_function
for each file.
Tips for Large Datasets
- Error Handling: Include
try...except
blocks in your worker function to gracefully handle corrupted or problematic MIDI files without crashing the entire process. - Memory Management: While Symusic is efficient, processing extremely large files or extracting complex features might still consume significant memory. Monitor memory usage. If needed, process files individually or use generators if results don't all need to be in memory at once.
- Logging: For long-running jobs, use Python's
logging
module instead ofprint
for better tracking of progress and errors, especially when redirecting output to files. - Serialization Cost: Symusic's pickling is fast, but transferring large processed
Score
objects back from worker processes can still have overhead. If only extracted features are needed, return just those features from the worker function, not the entireScore
object. - Intermediate Results: For very large datasets, consider saving intermediate results periodically to avoid losing work in case of interruptions.
Example: Filtering and Saving Piano Tracks
This example processes a dataset, keeps only the piano tracks, and saves the modified files.
import multiprocessing as mp
from pathlib import Path
from symusic import Score
import time
def filter_piano_tracks(midi_path: Path, output_dir: Path):
"""Loads a MIDI, keeps only piano tracks, and saves to output_dir."""
try:
score = Score(midi_path)
if score.empty():
return f"Skipped (empty): {midi_path.name}"
# Find piano tracks (Program 0, not drums)
piano_tracks = [t for t in score.tracks if t.program == 0 and not t.is_drum]
if not piano_tracks:
return f"Skipped (no piano): {midi_path.name}"
# Create a new score with only piano tracks
# Important: Need to copy global events like tempos, time signatures
new_score = Score(score.ticks_per_quarter)
new_score.tracks = piano_tracks # Assign the filtered tracks
new_score.tempos = score.tempos.copy()
new_score.time_signatures = score.time_signatures.copy()
new_score.key_signatures = score.key_signatures.copy()
new_score.markers = score.markers.copy()
# Note: Deep copies might be needed depending on subsequent modifications
output_path = output_dir / midi_path.name
new_score.dump_midi(output_path)
return f"Processed: {midi_path.name}"
except Exception as e:
return f"Error processing {midi_path.name}: {e}"
if __name__ == "__main__":
start_time = time.time()
input_dir = Path("path/to/your/midi/dataset")
output_dir = Path("path/to/filtered/piano_midi")
output_dir.mkdir(parents=True, exist_ok=True)
midi_files = list(input_dir.glob("**/*.mid"))
print(f"Found {len(midi_files)} MIDI files.")
num_processes = mp.cpu_count()
print(f"Using {num_processes} processes.")
# Prepare arguments for starmap
args = [(mf, output_dir) for mf in midi_files]
with mp.Pool(processes=num_processes) as pool:
# Use starmap for functions taking multiple arguments
results = pool.starmap(filter_piano_tracks, args)
end_time = time.time()
print(f"\nFinished in {end_time - start_time:.2f} seconds.")
# Print summary/errors
processed_count = sum(1 for r in results if r.startswith("Processed"))
skipped_count = sum(1 for r in results if r.startswith("Skipped"))
error_count = sum(1 for r in results if r.startswith("Error"))
print(f"Processed: {processed_count}, Skipped: {skipped_count}, Errors: {error_count}")
# Optionally log the specific error messages
# for r in results:
# if r.startswith("Error"):
# print(r)
This tutorial demonstrates how to leverage Symusic's performance for efficient batch processing of symbolic music data using Python's standard multiprocessing tools.
Examples Index
This section provides focused code examples for specific tasks using Symusic. Unlike the Tutorials, these examples are shorter and aim to demonstrate a particular function or technique quickly.
Available Example Categories
- Music Analysis: Snippets for extracting features and statistics from musical data.
- Music Generation: Examples related to creating or manipulating musical structures programmatically (Note: Symusic focuses on processing existing data, but can be used to structure generated data).
- Data Preprocessing: Quick examples for cleaning, filtering, and preparing MIDI data for other applications.
(More examples will be added over time. Contributions are welcome!)
Music Analysis Examples
This page contains code snippets demonstrating how to use Symusic for music analysis tasks.
Calculate Pitch Histogram
from symusic import Score
import numpy as np
import matplotlib.pyplot as plt
score = Score("path/to/file.mid")
pitches = []
for track in score.tracks:
# Optional: Filter for non-drum tracks
if not track.is_drum:
for note in track.notes:
pitches.append(note.pitch)
if pitches:
plt.figure()
plt.hist(pitches, bins=np.arange(129) - 0.5, density=True)
plt.title("Pitch Distribution")
plt.xlabel("MIDI Pitch")
plt.ylabel("Probability")
plt.xlim([-0.5, 127.5])
plt.show()
else:
print("No notes found.")
Calculate Note Density
from symusic import Score
score = Score("path/to/file.mid", ttype="second") # Load in seconds
duration = score.end()
num_notes = score.note_num()
if duration > 0:
note_density = num_notes / duration
print(f"Note Density: {note_density:.2f} notes per second")
else:
print("Score has zero duration or no notes.")
Get Average Tempo
from symusic import Score
score = Score("path/to/file.mid")
if score.tempos:
# Simple average (might not be musically accurate if tempos change significantly)
avg_tempo = sum(t.qpm for t in score.tempos) / len(score.tempos)
print(f"Average Tempo (unweighted): {avg_tempo:.1f} BPM")
# Time-weighted average (more accurate)
score_sec = score.to("second")
total_duration = score_sec.end()
weighted_tempo_sum = 0
last_time = 0
for i, tempo in enumerate(score_sec.tempos):
current_time = tempo.time
duration_at_prev_tempo = current_time - last_time
if i > 0:
weighted_tempo_sum += score_sec.tempos[i-1].qpm * duration_at_prev_tempo
last_time = current_time
# Add contribution of last tempo
if score_sec.tempos:
weighted_tempo_sum += score_sec.tempos[-1].qpm * (total_duration - last_time)
if total_duration > 0:
weighted_avg_tempo = weighted_tempo_sum / total_duration
print(f"Average Tempo (time-weighted): {weighted_avg_tempo:.1f} BPM")
else:
print("No tempo information found.")
(More analysis examples will be added here.)
Music Generation Examples
While Symusic primarily focuses on processing existing symbolic music data, its data structures can be used to represent and manipulate generated music before saving it to MIDI or other formats.
Creating a Score Programmatically
from symusic import Score, Track, Note, Tempo, TimeSignature
# Create an empty score with standard ticks per quarter
score = Score(tpq=480)
# Add tempo and time signature
score.tempos.append(Tempo(time=0, qpm=120))
score.time_signatures.append(TimeSignature(time=0, numerator=4, denominator=4))
# Create a piano track
piano_track = score.tracks.append_track(name="Generated Piano", program=0)
# Generate a simple C major scale
pitches = [60, 62, 64, 65, 67, 69, 71, 72] # MIDI pitches for C4 to C5
current_time = 0
quarter_note_duration = 480 # Since tpq=480
for pitch in pitches:
note = Note(
time=current_time,
duration=quarter_note_duration,
pitch=pitch,
velocity=70
)
piano_track.notes.append(note)
current_time += quarter_note_duration
# Save the generated score
score.dump_midi("generated_scale.mid")
print("Generated C major scale saved to generated_scale.mid")
Manipulating Generated Structures
You can use Symusic's modification methods on programmatically created scores.
# Continuing from the previous example...
# Transpose the generated scale up by a perfect fifth (7 semitones)
transposed_score = score.shift_pitch(7)
transposed_score.dump_midi("generated_g_major_scale.mid")
# Create a version with double tempo
double_tempo_score = score.copy() # Create a copy first
for tempo in double_tempo_score.tempos:
tempo.qpm *= 2.0
double_tempo_score.dump_midi("generated_scale_double_tempo.mid")
# Create a version in quarter note time, shift rhythmically
score_quarter = score.to("quarter")
# Shift every other note later by an eighth note
for i, note in enumerate(score_quarter.tracks[0].notes):
if i % 2 == 1:
note.shift_time_inplace(0.5) # 0.5 quarter notes = eighth note
score_quarter.dump_midi("generated_scale_syncopated.mid")
Integrating with Generation Algorithms
Symusic can serve as the output stage for music generation algorithms (e.g., from neural networks, rule-based systems).
# Assume 'generated_events' is a list produced by your algorithm
# Each element could be like: ("note", time, duration, pitch, velocity)
# or ("tempo", time, qpm), etc.
def create_score_from_events(generated_events, tpq=480):
score = Score(tpq=tpq)
# Assume single track for simplicity
track = score.tracks.append_track(name="Generated Music")
for event_data in generated_events:
event_type = event_data[0]
if event_type == "note":
_, time, duration, pitch, velocity = event_data
track.notes.append(Note(time, duration, pitch, velocity))
elif event_type == "tempo":
_, time, qpm = event_data
score.tempos.append(Tempo(time, qpm))
# ... handle other event types ...
# It's good practice to sort events after adding them
score.sort()
return score
# Example generated events (simple melody)
generation_output = [
("tempo", 0, 100),
("note", 0, 480, 60, 80),
("note", 480, 480, 62, 80),
("note", 960, 960, 64, 80),
("tempo", 1920, 80),
("note", 1920, 480, 62, 70),
("note", 2400, 480, 60, 70),
]
generated_score = create_score_from_events(generation_output)
generated_score.dump_midi("output_from_algorithm.mid")
(More generation-related examples will be added here.)
Data Preprocessing Examples
This page shows examples of using Symusic for preprocessing MIDI data for machine learning.
(Code examples for cleaning data, feature extraction, tokenization, etc., will be added here.)
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
Cook Book
This page contains concise code snippets for common tasks using Symusic. If you're looking for more detailed explanations, please refer to the Tutorials and API Reference sections.
File Operations
Loading a MIDI file
from symusic import Score
# Load with default tick time unit
score = Score("path/to/file.mid")
# Load with quarter note time unit
score = Score("path/to/file.mid", ttype="quarter")
# Load with second time unit
score = Score("path/to/file.mid", ttype="second")
Saving a MIDI file
# Save to MIDI format
score.dump_midi("output.mid")
# Get MIDI bytes without saving to file
midi_bytes = score.dumps_midi()
Loading and saving ABC notation
# Load ABC notation
score = Score("path/to/file.abc", fmt="abc")
# Save to ABC notation
score.dump_abc("output.abc")
Time Unit Conversion
# Convert from ticks to quarter notes
score_quarter = score.to("quarter")
# Convert from any unit to seconds
score_second = score.to("second")
# Convert back to ticks with minimum duration
score_tick = score_second.to("tick", min_dur=1)
Basic Manipulations
Transposition
# Transpose up by 2 semitones (non-inplace)
transposed_score = score.shift_pitch(2)
# Transpose down by 3 semitones (inplace)
score.shift_pitch_inplace(-3)
Time Shifting
# Shift all events forward by 480 ticks (non-inplace)
shifted_score = score.shift_time(480)
# Shift all events backward by 1 quarter note (inplace)
score_quarter.shift_time_inplace(-1.0)
Velocity Adjustment
# Increase velocity by 10 (non-inplace)
louder_score = score.shift_velocity(10)
# Decrease velocity by 20 (inplace)
score.shift_velocity_inplace(-20)
Clipping
# Extract a section from the score (non-inplace)
excerpt = score.clip(start=960, end=1920)
# Extract a section and also clip notes that extend beyond the end time
excerpt = score.clip(start=960, end=1920, clip_end=True)
Track Operations
Creating a new track
# Create an empty score
score = Score(tpq=480)
# Add a piano track
piano_track = score.tracks.append_track(name="Piano", program=0, is_drum=False)
# Add a drum track
drum_track = score.tracks.append_track(name="Drums", program=0, is_drum=True)
Adding notes to a track
from symusic import Note
# Add individual notes
piano_track.notes.append(Note(time=0, duration=480, pitch=60, velocity=64))
piano_track.notes.append(Note(time=480, duration=480, pitch=64, velocity=64))
piano_track.notes.append(Note(time=960, duration=480, pitch=67, velocity=64))
# Add multiple notes using NumPy arrays
import numpy as np
times = np.array([0, 480, 960])
durations = np.array([480, 480, 480])
pitches = np.array([48, 55, 52])
velocities = np.array([64, 64, 64])
bass_notes = Note.from_numpy(times, durations, pitches, velocities, "tick")
for note in bass_notes:
piano_track.notes.append(note)
Working with specific tracks
# Get the first track
first_track = score.tracks[0]
# Iterate through all tracks
for track in score.tracks:
print(f"Track: {track.name}, Notes: {track.note_num()}")
# Extract a specific instrument
for track in score.tracks:
if track.program == 0 and not track.is_drum: # Piano
piano_track = track
elif track.is_drum: # Drums
drum_track = track
Global Events
Adding tempo changes
from symusic import Tempo
# Set initial tempo (120 BPM)
score.tempos.append(Tempo(time=0, qpm=120.0))
# Add a tempo change
score.tempos.append(Tempo(time=1920, qpm=100.0))
Adding time signatures
from symusic import TimeSignature
# Set 4/4 time signature
score.time_signatures.append(TimeSignature(time=0, numerator=4, denominator=4))
# Change to 3/4 at measure 5 (assuming 4/4 with 1920 ticks per measure)
score.time_signatures.append(TimeSignature(time=1920*4, numerator=3, denominator=4))
Adding key signatures
from symusic import KeySignature
# Set C major key signature (no sharps/flats)
score.key_signatures.append(KeySignature(time=0, key=0, tonality=0))
# Change to G major (1 sharp) at measure 9
score.key_signatures.append(KeySignature(time=1920*8, key=1, tonality=0))
Piano Roll Conversion
# Resample to reduce size before creating piano roll
score_resampled = score.resample(tpq=6, min_dur=1)
# Create piano roll for the entire score
pianoroll = score_resampled.pianoroll(
modes=["onset", "frame"], # Only include onset and frame information
pitch_range=[21, 109], # Piano range
encode_velocity=True # Use velocity values instead of binary
)
# Create piano roll for a single track
track_pianoroll = score_resampled.tracks[0].pianoroll(
modes=["frame"], # Only include frame information
pitch_range=[0, 128], # Full MIDI range
encode_velocity=False # Binary encoding (0 or 1)
)
Audio Synthesis
from symusic import Score, Synthesizer, BuiltInSF3, dump_wav
# Load a score
score = Score("input.mid")
# Create a synthesizer using a built-in SoundFont
synthesizer = Synthesizer(
sf_path=BuiltInSF3.MuseScoreGeneral().path(download=True),
sample_rate=44100,
quality=4
)
# Render the score to audio
audio_data = synthesizer.render(score, stereo=True)
# Save the audio to a WAV file
dump_wav("output.wav", audio_data)
Multiprocessing
import multiprocessing as mp
import pickle
from symusic import Score
def process_file(file_path):
# Load MIDI file
score = Score(file_path)
# Process the score (example: transpose up by 2 semitones)
processed_score = score.shift_pitch(2)
# Return or save the result
return processed_score
if __name__ == "__main__":
# List of MIDI files to process
file_paths = ["file1.mid", "file2.mid", "file3.mid"]
# Process files in parallel
with mp.Pool(processes=4) as pool:
results = pool.map(process_file, file_paths)
# Save the results
for i, processed_score in enumerate(results):
processed_score.dump_midi(f"processed_{i}.mid")
Analysis
# Get basic score information
print(f"Tracks: {score.track_num()}")
print(f"Notes: {score.note_num()}")
print(f"Duration: {score.end() - score.start()}")
# Analyze pitch distribution
pitches = []
for track in score.tracks:
for note in track.notes:
pitches.append(note.pitch)
import numpy as np
pitch_histogram = np.histogram(pitches, bins=128, range=(0, 127))
# Find average velocity
velocities = []
for track in score.tracks:
for note in track.notes:
velocities.append(note.velocity)
avg_velocity = np.mean(velocities)
print(f"Average velocity: {avg_velocity:.1f}")
Working with Specific Events
Control Changes
from symusic import ControlChange
# Add volume control (controller 7)
track.controls.append(ControlChange(time=0, number=7, value=100))
# Add expression control (controller 11)
track.controls.append(ControlChange(time=0, number=11, value=127))
# Add sustain pedal events (controller 64)
track.controls.append(ControlChange(time=0, number=64, value=127))
track.controls.append(ControlChange(time=960, number=64, value=0))
Pitch Bends
from symusic import PitchBend
# Add pitch bend events
track.pitch_bends.append(PitchBend(time=0, value=0)) # Center
track.pitch_bends.append(PitchBend(time=240, value=4096)) # Bend up
track.pitch_bends.append(PitchBend(time=480, value=0)) # Center again
Pedal Events
from symusic import Pedal
# Add sustain pedal with duration
track.pedals.append(Pedal(time=0, duration=960))
Lyrics
from symusic import TextMeta
# Add lyrics to a vocal track
track.lyrics.append(TextMeta(time=0, text="Hel-"))
track.lyrics.append(TextMeta(time=240, text="lo"))
track.lyrics.append(TextMeta(time=480, text="World!"))