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 from tick using ticks_per_quarter), where the length of a quarter note is 1.
  • second (float32) means the time unit is in seconds, the same as what pretty_midi does.
from symusic import Score, TimeUnit

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

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

Dump

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

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

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

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

Pickle & Multi Processing

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

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

from symusic import Score
import pickle
import multiprocessing as mp

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

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

Accessing the Score

Basic Information

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

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

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


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

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

# Tracks
s.tracks            # list of tracks

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

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

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

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

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

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

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

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

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

Struct of Array (SOA)

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

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

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

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

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

Piano Roll

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

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

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

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

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

You could also visualize the piano roll using matplotlib

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

Synthesis

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

from symusic import Score, Synthesizer, BuiltInSF3 ,dump_wav

s = Score("xxx.mid")

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

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

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

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

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

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) containing Track 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)

  1. Load/Create: Instantiate a Score by loading a file (Score("file.mid")) or creating an empty one (Score(tpq=480)).
  2. Inspect: Examine tracks, global events, and metadata.
  3. Manipulate: Apply transformations (e.g., shift_pitch, clip, sort) or modify event lists directly.
  4. Convert (Optional): Change time units using to() if needed for analysis or synthesis.
  5. 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 an is_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)

  1. Access: Obtain Track objects from a Score's tracks list (e.g., score.tracks[0]).
  2. Inspect: Examine the track's metadata (name, program, is_drum) and its event lists (notes, controls, etc.).
  3. Manipulate: Modify the track directly (e.g., track.shift_pitch(12)) or modify its event lists (e.g., track.notes.append(...), track.controls.sort()).
  4. Analyze: Calculate track-specific features (e.g., note density within the track, pitch range used).
  5. 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:

  1. time Attribute: Indicates when the event occurs, relative to the start of the score, measured in the parent Score's time unit (Tick, Quarter, or Second).
  2. Time Unit Parameterization: Event classes are templates based on the time unit (e.g., NoteTick, TempoQuarter, ControlChangeSecond).
  3. Specific Attributes: Each type has attributes holding its relevant data (e.g., a Note has pitch, duration, velocity; a Tempo has qpm).
  4. Containment: Events live inside specialized lists within Track or Score 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 its time onwards.
  • TimeSignature: Global event defining the musical meter (e.g., 4/4) from its time onwards.
  • KeySignature: Global event defining the musical key (sharps/flats and major/minor) from its time 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)

  1. Access: Get event lists from Score or Track objects (e.g., track.notes, score.tempos).
  2. Iterate/Inspect: Loop through lists to examine individual event attributes.
  3. Modify: Change attributes of existing events (e.g., note.velocity = 100).
  4. Add/Remove: Append new events or remove existing ones from their lists (e.g., track.notes.append(...), score.tempos.pop(0)).
  5. 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

  1. 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.
  2. Quarter: Floating-point values (float). Represents time relative to the duration of a quarter note, where 1.0 equals one quarter note. This unit is useful for analyzing musical structure (e.g., a half note has duration 2.0) independent of tempo.
  3. Second: Floating-point values (float). Represents absolute time in seconds from the beginning of the piece. Calculating this requires considering the Score'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 the Tempo events in the Score.
  • Converting from float (Quarter, Second) to int (Tick) involves rounding, which can introduce minor precision differences.
  • The min_dur parameter in to("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 calling dump_midi for lossless saving (though dump_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:

  1. Synthesizer Class: The main object that orchestrates the synthesis. It's initialized with a SoundFont and parameters.
  2. SoundFont (.sf2/.sf3): A file containing instrument samples and playback rules. This defines the sound of the output.
  3. Input Score: The symbolic music data to be rendered.
  4. render() Method: Called on the Synthesizer instance with the Score. 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.
  5. Output AudioData: An object holding the resulting audio samples (NumPy compatible) and metadata (sample rate, channels).
  6. dump_wav() Utility: Saves the AudioData 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 using Tick or Quarter units are internally converted to Second 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:

  1. Time Unit Conversion: Most objects can be converted between different time units using the to() method
  2. Inplace vs. Copy: Most modification operations have both inplace and copy versions
  3. Serialization: All objects support Python's pickle protocol for efficient serialization
  4. 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.

Concepts: Score

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

AttributeTypeDescription
ticks_per_quarterintTicks per quarter note resolution (TPQ / PPQ)
tpqintAlias for ticks_per_quarter
tracksTrackListList-like container for Track objects.
time_signaturesTimeSignatureListList-like container for TimeSignature events.
key_signaturesKeySignatureListList-like container for KeySignature events.
temposTempoListList-like container for Tempo events.
markersTextMetaListList-like container for TextMeta marker events.

Properties (Read-Only Methods)

MethodReturn TypeDescription
ttypeTimeUnit objectReturns the time unit object (Tick, Quarter, Second).
start()int or floatStart time of the earliest event in the score.
end()int or floatEnd time of the latest event in the score.
note_num()intTotal number of notes across all tracks.
track_num()intNumber of tracks in the tracks list.
empty()boolTrue 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.

Concepts: Track

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

AttributeTypeDescription
namestrName of the track (e.g., "Piano", "Drums").
programintMIDI program number (instrument ID, 0-127).
is_drumboolTrue if the track is a percussion/drum track.
notesNoteListList-like container for Note events.
controlsControlChangeListList-like container for ControlChange events.
pitch_bendsPitchBendListList-like container for PitchBend events.
pedalsPedalListList-like container for Pedal events.
lyricsTextMetaListList-like container for lyric TextMeta events.

Properties (Read-Only Methods)

MethodReturn TypeDescription
ttypeTimeUnit objectReturns the time unit object (Tick, Quarter, Second).
start()int or floatStart time of the earliest event in the track.
end()int or floatEnd time of the latest event in the track.
note_num()intNumber of notes in the notes list.
empty()boolTrue 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).

Concepts: Events

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). The ttype 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) and copy.deepcopy().
  • Serialization (Pickling): Support pickle.dump() and pickle.load() via __getstate__ and __setstate__.
  • shift_time(offset): Returns a new event shifted by offset.
  • shift_time_inplace(offset): Modifies the event's time inplace.
  • to(ttype, ...): Returns a new event converted to the target ttype. Event types with duration (Note, Pedal) accept an optional min_dur argument for the to("tick", min_dur=...) conversion.
  • NumPy Conversion (for lists): Event lists like NoteList often have .numpy() and EventFactory.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(): Returns time + duration.
  • empty(): Returns True if duration <= 0 or velocity <= 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(): Returns time + 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-message sf value.
  • tonality (int): 0 for major, 1 for minor. Corresponds to MIDI mi 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 or symusic.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 a ScoreTick, ScoreQuarter, or ScoreSecond object. The synthesizer will internally convert it to ScoreSecond if necessary.
  • stereo: If True, the output audio will have two channels. If False, it will be mono. Defaults to True.

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 or pathlib.Path) for the output WAV file.
  • data: The AudioData object returned by the Synthesizer.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:

  1. Introduction: Brief overview of the topic
  2. Basic Concepts: Explanation of relevant concepts and theory
  3. Basic Usage: Simple examples to get started
  4. Advanced Usage: More complex examples and techniques
  5. Tips and Best Practices: Recommendations for effective use
  6. Troubleshooting: Solutions to common problems

Prerequisites

To follow along with these tutorials, you should have:

  1. Symusic installed (see the Installation section)
  2. Basic Python knowledge
  3. 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:

  1. Processing shorter segments if memory is an issue
  2. Using lower tpq values for initial exploration
  3. Limiting the pitch range to the most relevant notes
  4. 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:

  1. Use a lower quality setting for real-time or batch processing
  2. Use a lower sample_rate if high fidelity isn't required
  3. Consider rendering shorter segments of music if working with very long pieces
  4. For batch processing of many files, consider using multiprocessing

Troubleshooting

Common Issues

  1. 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}"
    
  2. Audio quality issues: Try increasing the quality parameter

    synthesizer = Synthesizer(sf_path, sample_rate=44100, quality=5)  # Higher quality
    
  3. 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

  1. Loading and Filtering: Reading a large dataset and selecting relevant files or tracks.
  2. Data Cleaning: Removing empty files, fixing timing issues, standardizing formats.
  3. Feature Extraction: Computing musical features (e.g., pitch histograms, note density, tempo).
  4. Representation Conversion: Converting MIDI to other formats like piano roll or custom event sequences for model input.
  5. 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:

  1. worker_function: This function takes a single Path object, loads the MIDI file using Score, performs some processing (feature extraction in this case), and returns the result (or None on error/empty score).
  2. if __name__ == "__main__":: Essential for multiprocessing to work correctly, especially on Windows.
  3. mp.Pool: Creates a pool of worker processes.
  4. pool.map(worker_function, midi_files): This is the core of the parallel processing. It distributes the midi_files list among the worker processes, each calling worker_function on a subset of the files. It collects the results in order.
  5. Result Handling: The collected results list contains the return values from worker_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 of print 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 entire Score 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!"))