DEV Community

Dmitry Romanoff
Dmitry Romanoff

Posted on

Automating MIDI Generation with Python: A Comprehensive Guide

MIDI (Musical Instrument Digital Interface) is a popular standard for representing music digitally. It allows composers and developers to create, modify, and manipulate music in a digital format. In this article, we will walk through a Python script that can generate a MIDI file using a sequence of notes, lengths, velocities, and even macros for repeated musical patterns.

Overview of the Script

The script provided uses the mido library, a powerful Python package that allows you to work with MIDI files. The goal of this script is to generate a MIDI file based on a sequence of notes and their properties such as length and velocity. Additionally, it provides the functionality of expanding macros into musical sequences, making it easier to reuse patterns.

Let’s break down the key components of the script and explain how everything works step by step.

1. Setting Up the Environment

First, we import the necessary modules. The mido library is used for handling MIDI file creation, message sending, and setting tempo. The os module ensures that the output folder exists before we save the generated file.

import mido
from mido import MidiFile, MidiTrack, Message
import os
Enter fullscreen mode Exit fullscreen mode

2. Mapping Notes to MIDI Numbers

In MIDI, each note corresponds to a unique number. For example, the note 'C4' is assigned to the number 60. The script uses a dictionary NOTE_TO_MIDI to map note names (like 'C', 'D#', etc.) to MIDI note numbers.

NOTE_TO_MIDI = {
    'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5, 'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11
}
Enter fullscreen mode Exit fullscreen mode

3. Converting Notes and Octaves to MIDI Numbers

The note_to_midi function takes a note (like 'C4' or 'D#3') and calculates the corresponding MIDI number. The formula adjusts for the octave as well, ensuring that C4 is always MIDI number 60.

def note_to_midi(note):
    note_name = note[:-1]  # Extract the note name (e.g., 'A', 'B', etc.)
    octave = int(note[-1])  # Extract the octave (e.g., '0', '2', etc.)
    return NOTE_TO_MIDI[note_name] + 12 * (octave + 1)
Enter fullscreen mode Exit fullscreen mode

4. Converting Length to MIDI Ticks

MIDI time is divided into ticks, which represent small time intervals. The length_to_ticks function converts musical note lengths (like whole, half, quarter, etc.) to their corresponding duration in MIDI ticks, based on the ticks per beat value.

def length_to_ticks(length, ticks_per_beat):
    if length == 1:
        return ticks_per_beat * 4  # Whole note
    elif length == 2:
        return ticks_per_beat * 2  # Half note
    elif length == 4:
        return ticks_per_beat  # Quarter note
    elif length == 8:
        return ticks_per_beat // 2  # Eighth note
    elif length == 16:
        return ticks_per_beat // 4  # Sixteenth note
    return ticks_per_beat  # Default to quarter note
Enter fullscreen mode Exit fullscreen mode

5. Generating the MIDI File

The generate_piano_midi function creates a new MIDI file by first setting up a track and defining the instrument (a piano by default, but this can be changed). It then processes the provided sequence of notes, lengths, and velocities, adding the appropriate MIDI messages (note on and note off) to the track.

def generate_piano_midi(sequence, tempo, output_file="output/piano_piece_2.mid", instrument_program=0):
    output_folder = "output"
    if not os.path.exists(output_folder):
        os.makedirs(output_folder)

    midi = MidiFile()
    track = MidiTrack()
    midi.tracks.append(track)

    track.append(Message('program_change', program=instrument_program))
    microseconds_per_beat = 60 * 1000000 // tempo
    track.append(mido.MetaMessage('set_tempo', tempo=microseconds_per_beat))

    ticks_per_beat = 480
    current_time = 0  # Start at time 0

    for i in range(0, len(sequence), 3):
        note = sequence[i]  # Note name (e.g., 'A0')
        length = sequence[i + 1]  # Length (e.g., 4 for quarter note)
        velocity = sequence[i + 2]  # Velocity (e.g., 100 for volume)

        if note == 'X':  # Handle pauses (no sound)
            track.append(Message('note_on', note=0, velocity=0, time=current_time))
            track.append(Message('note_off', note=0, velocity=0, time=length_to_ticks(length, ticks_per_beat)))
            current_time = 0
            continue

        midi_note = note_to_midi(note)
        duration = length_to_ticks(length, ticks_per_beat)

        track.append(Message('note_on', note=midi_note, velocity=velocity, time=current_time))
        track.append(Message('note_off', note=midi_note, velocity=velocity, time=duration))

        current_time = 0

    midi.save(output_file)
    print(f"MIDI file '{output_file}' has been saved!")
Enter fullscreen mode Exit fullscreen mode

6. Expanding Macros

A powerful feature of this script is its ability to expand macros. Macros are predefined sequences of notes that can be referenced by a shorthand notation. This allows you to easily reuse patterns throughout your musical sequence without repeating the same code.

def expand_macros(sequence, macros):
    expanded_sequence = []
    i = 0
    while i < len(sequence):
        if isinstance(sequence[i], str) and sequence[i].startswith('M'):  # Macro starts with 'M'
            macro_name = sequence[i]
            if macro_name in macros:
                expanded_sequence.extend(macros[macro_name])  # Expand the macro
            i += 1  # Skip the macro name
        else:
            expanded_sequence.append(sequence[i])
            i += 1
    return expanded_sequence
Enter fullscreen mode Exit fullscreen mode

7. Creating a Musical Sequence

You can define a musical sequence using a combination of macros and individual notes. For example:

macros = {
    'M001': ['C3', 8, 90, 'E3', 8, 85, 'G3', 8, 80, 'C4', 8, 75],
    'M002': ['C3', 8, 90, 'F3', 8, 85, 'A3', 8, 80, 'C4', 8, 75],
    'M003': ['B2', 8, 90, 'D3', 8, 85, 'G3', 8, 80, 'B3', 8, 75]
}

sequence = ['M001', 'M002', 'M003', 'M003', 'M001']
expanded_sequence = expand_macros(sequence, macros)
Enter fullscreen mode Exit fullscreen mode

8. Final Steps

Once the sequence is expanded, you can set a tempo (in beats per minute), select an instrument (e.g., piano or flute), and generate the MIDI file by calling the generate_piano_midi function:

tempo_bpm = 120  # Tempo in beats per minute
instrument_program = 1  # Flute

generate_piano_midi(expanded_sequence, tempo_bpm, instrument_program=instrument_program)
Enter fullscreen mode Exit fullscreen mode

Conclusion

This script provides a solid foundation for generating MIDI files in Python. By using the mido library and implementing features like note-to-MIDI conversion, macro expansion, and time duration calculation, you can easily automate the creation of musical compositions. Whether you're working on a music project, creating soundtracks, or just experimenting with MIDI, this script can save you time and open up creative possibilities.

If you haven’t yet, give it a try and create your own MIDI compositions with Python!


Complete Code

import mido
from mido import MidiFile, MidiTrack, Message
import os

# Mapping of note names to MIDI numbers
NOTE_TO_MIDI = {
    'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5, 'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11
}

# Function to convert note and octave to MIDI number
def note_to_midi(note):
    note_name = note[:-1]  # Extract the note name (e.g., 'A', 'B', etc.)
    octave = int(note[-1])  # Extract the octave (e.g., '0', '2', etc.)
    return NOTE_TO_MIDI[note_name] + 12 * (octave + 1)

# Function to convert length notation to MIDI ticks
def length_to_ticks(length, ticks_per_beat):
    if length == 1:
        return ticks_per_beat * 4  # Whole note
    elif length == 2:
        return ticks_per_beat * 2  # Half note
    elif length == 4:
        return ticks_per_beat  # Quarter note
    elif length == 8:
        return ticks_per_beat // 2  # Eighth note
    elif length == 16:
        return ticks_per_beat // 4  # Sixteenth note
    return ticks_per_beat  # Default to quarter note

# Function to generate MIDI file based on the given sequence and tempo
def generate_piano_midi(sequence, tempo, output_file="output/piano_piece_2.mid", instrument_program=0):
    output_folder = "output"
    if not os.path.exists(output_folder):
        os.makedirs(output_folder)

    midi = MidiFile()
    track = MidiTrack()
    midi.tracks.append(track)

    track.append(Message('program_change', program=instrument_program))
    microseconds_per_beat = 60 * 1000000 // tempo
    track.append(mido.MetaMessage('set_tempo', tempo=microseconds_per_beat))

    ticks_per_beat = 480
    current_time = 0

    for i in range(0, len(sequence), 3):
        note = sequence[i]  # Note name (e.g., 'A0')
        length = sequence[i + 1]  # Length (e.g., 4 for quarter note)
        velocity = sequence[i + 2]  # Velocity (e.g., 100 for volume)

        if note == 'X':  # Handle pauses (no sound)
            track.append(Message('note_on', note=0, velocity=0, time=current_time))
            track.append(Message('note_off', note=0, velocity=0, time=length_to_ticks(length, ticks_per_beat)))
            current_time = 0
            continue

        midi_note = note_to_midi(note)
        duration = length_to_ticks(length, ticks_per_beat)

        track.append(Message('note_on', note=midi_note, velocity=velocity, time=current_time))
        track.append(Message('note_off', note=midi_note, velocity=velocity, time=duration))

        current_time = 0

    midi.save(output_file)
    print(f"MIDI file '{output_file}' has been saved!")

# Function to expand macros
def expand_macros(sequence, macros):
    expanded_sequence = []
    i = 0
    while i < len(sequence):
        if isinstance(sequence[i], str) and sequence[i].startswith('M'):  # Macro starts with 'M'
            macro_name = sequence[i]
            if macro_name in macros:
                expanded_sequence.extend(macros[macro_name])  # Expand the macro
            i += 1  # Skip the macro name
        else:
            expanded_sequence.append(sequence[i])
            i += 1
    return expanded_sequence

# Example macros definition
macros = {
    'M001': ['C3', 8, 90, 'E3', 8, 85, 'G3', 8, 80, 'C4', 8, 75],
    'M002': ['C3', 8, 90, 'F3', 8, 85, 'A3', 8, 80, 'C4', 8, 75],
    'M003': ['B2', 8, 90, 'D3', 8, 85, 'G3', 8, 80, 'B3', 8, 75],
    'M004': ['C3', 8, 90, 'E3', 8, 85, 'G3', 8, 80, 'C4', 8, 75],
    'M005': ['C3', 8, 90, 'E3', 8, 85, 'A3', 8, 80, 'C4', 8, 75],
    'M006': ['D3', 8, 90, 'F#3', 8, 85, 'A3', 8, 80, 'C4', 8, 75],
    'M007': ['G2', 8, 90, 'B2', 8, 85, 'D3', 8, 80, 'G3', 8, 75],
    'M008': [ 'C3', 1, 90 ]
}

# Example sequence
sequence = [
    'M001',
    'M001',
    'M002',
    'M002',
    'M003',
    'M003',
    'M004',
    'M004',
    'M005',
    'M005',
    'M006',
    'M006',
    'M007',
    'M007',
    'M008'
]

# Expand macros in the sequence
expanded_sequence = expand_macros(sequence, macros)

# Tempo in beats per minute (e.g., 120 BPM)
tempo_bpm = 120

# Choose an instrument (e.g., piano, flute, violin, etc.)
instrument_program = 1  # Flute

# Generate the MIDI file with the expanded sequence and tempo
generate_piano_midi(expanded_sequence, tempo_bpm, instrument_program=instrument_program)
Enter fullscreen mode Exit fullscreen mode

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay