Introducción a la programación en Python

clase 09c
MIDIUtil

En el índice de paquetes de Python PyPI se pueden encontrar numerosos paquetes con funciones para manejar datos MIDI de diferentes maneras. Uno muy utilizado es pretty_midi, que ofrece múltiples funcionalidades para leer y escribir archivos con formato Standard MIDI File (SMF), así como para procesar y analizar información MIDI.

En esta oportunidad vamos a ver MIDIUtil, un paquete muy sencillo de usar y con todas las funcionalidades necesarias para escribir datos en archivos con formato SMF.

Standard MIDI File

El Standard MIDI File (SMF) es un formato de archivo que permite guardar secuencias de mensajes MIDI de una manera estandarizada, y que por tanto puede ser leído correctamente por cualquier dispositivo o programa que adhiera al estándar, como secuenciadores o programas de notación musical. Por convención, los archivos con este formato llevan la extensión .mid.

Hay dos tipos básicos de SMF: en el tipo 0, toda la información está contenida en una única pista (track), mientras que el tipo 1 puede contener información en diferentes pistas. Existe además un tipo 2 que prácticamente no es utilizado. Por defecto, vamos a utilizar el tipo 1.

El protocolo MIDI tiene definidos numerosos tipos de mensajes, que pueden ser enviados por cualquiera de los 16 canales diferentes existentes en el MIDI (del 0 al 15). En principio vamos a ocuparnos de los tipos de mensaje más importantes para poder crear una secuencia, como los mensajes para prender notas, los que establecen el tempo, y los que asignan un sonido o programa determinado a un canal.

Aparte de mensajes MIDI, un SMF puede tener otros datos, como por ejemplo nombres de las pistas o tracks, indicación de compás y armaduras de clave, etcétera.

MIDIUtil

El paquete MIDIUtil provee el módulo MIDIFile, que contiene funciones y métodos para escribir datos en archivos con formato Standard MIDI File.

Se trata de un paquete externo que no forma parte de la biblioteca estándar de Python, y por tanto para poder utilizarlo, debe ser instalado. Esto puede hacerse con el comando pip desde la línea de comando, tal como ya vimos:

pip install midiutil

Una vez que el paquete fue correctamente instalado, estará disponible para ser importado y utilizado en nuestros programas.

Los pasos básicos para crear un archivo SMF mínimo, son:

  1. importar el módulo MIDIFile del paquete midiutil:
    from midiutil import MIDIFile

  2. crear un objeto de tipo MIDIFile mediante la función MIDIFile(), y asignarle un nombre:
    objeto_MIDI = MIDIFile()

  3. agregar una o más notas al objeto, mediante el método .addNote():
    objeto_MIDI.addNote(...)

  4. escribir el objeto a un archivo, con el método .writeFile:
    objeto_MIDI.writeFile(output_file)

El módulo también cuenta con métodos para agregar otros tipos de elementos, como veremos más adelante.

Veamos con más detalle la función y los métodos mencionados arriba.

MIDIFile()

Esta función crea un objeto de tipo MIDIFile, que se asigna a una variable. Tiene varios argumentos opcionales, todos ellos con valores por defecto. Los más importantes son:

  • numTracks: cantidad de pistas (por defecto, una). El nombre de este argumento se puede omitir, y poner solamente el número.
  • ticks_per_quarternote: cantidad de pulsos básicos (ticks) en que se subdivide la figura de negra (por defecto, 960). Esto determina la resolución rítmica, ya que todos los eventos se van a cuantizar a un tick. Los valores habituales son 120, 240, 384, 480 y 960.
  • eventtime_is_ticks: recibe un valor booleano, y determina cómo se van a interpretar los valores de tiempo (momento de inicio y duración de las notas). Si es True, los valores se interpretan como ticks y tienen que ser números enteros. Por defecto es False, y los valores se interpretan como múltiplos de la figura de negra, pudiendo ser números de coma flotante.

En general, conviene dejar los valores por defecto, con lo que la función se llama pasándole solamente el número de pistas que se quiere tenga el archivo.

.addNote()

Este método se utiliza para agregar notas a un objeto MIDIFile creado previamente, y debe recibir varios parámetros obligatorios, según este formato:

.addNote(track, channel, pitch, time, duration, velocity)

  • track: número de pista en la cual se va a agregar la nota (comenzando en 0). Debe ser coherente con la cantidad de pistas que tiene el objeto.
  • channel: número de canal MIDI en el cual se va a prender la nota (0 a 15).
  • pitch: número de tecla MIDI (0 a 127)
  • time, duration: tiempo de inicio y duración de la nota. Por defecto, se interpretan en relación a la figura de negra, pero si se estableció eventtime_is_ticks=True al crear el objeto MIDIFile, se interpretan en cantidad de ticks.
  • velocity: velocidad a la que se acciona la nota (0 a 127). Según el programa, suele estar asociado a la dinámica.

.writeFile()

Finalmente, este método escribe un objeto MIDIFile a un archivo. Recibe como argumento un objeto de tipo file, creado previamente con la función open() en modo escritura binaria ('wb').

Como se vio anteriormente, es de buena práctica abrir los archivos dentro de una construcción with, para asegurar que se cierren correctamente.

El siguiente programa utiliza los elementos vistos hasta ahora, para crear un archivo SMF mínimo, conteniendo solamente una nota:

In [1]:
# 1. se importa el módulo
from midiutil import MIDIFile

# 2. se crea un objeto MIDIFile, con una sola pista
mi_MIDI = MIDIFile(1)

# se definen las variables para la nota
track = 0   # número de pista
pitch = 60  # tecla MIDI C4
channel = 0
time = 0  # tiempo de inicio, en negras
duration = 4    # tiempo de duración, en negras (redonda)
velocity = 100

# 3. se agrega una nota
mi_MIDI.addNote(track, channel, pitch, time, duration, velocity)

# 4. se escribe el archivo
with open("MIDIFile_1.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

Como no se definieron más parámetros del archivo MIDI, por defecto se utiliza un sonido de piano (programa 1), un compás de 4/4 y un tempo de 120 negras por minuto. Más adelante veremos cómo se pueden ajustar cada uno de esos parámetros.

Los archivos MIDI suelen tener cantidades más o menos grandes de notas, y sería muy engorroso escribir una línea de código como la vista arriba para ingresar cada una de ellas. Resulta más eficiente tener secuencias de notas representadas como listas, y recorrerlas con un iterador for para generar una directiva .addNote para cada nota.

Por ejemplo, podemos tener una lista de notas como tuplas con el número de nota MIDI y la duración, tal como vimos en el ejemplo 5.1:

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1), (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]

El siguiente programa agrega una nota al objeto MIDIFile por cada elemento de la lista:

In [2]:
from midiutil import MIDIFile

mi_MIDI = MIDIFile(1)

# variables generales para todas las notas
track = 0
channel = 0
velocity = 100

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1),
               (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]

# tiempo inicial
time = 0

for key, dur in lista_notas:
    mi_MIDI.addNote(track, channel, key, time, dur, velocity)
    time += dur # incrementa el tiempo de inicio para la siguiente nota

with open("MIDIFile_2.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

Se puede dar forma de función al código, para poder reutilizarlo. La siguiente función recibe como argumentos el objeto MIDIFile al cual se van a agregar las notas, y la lista de notas con número de nota MIDI y duración. Asigna valores fijos a los demás parámetros de las notas.

(Se asume que las notas solo tienen información de número de nota MIDI y duración, y que la velocity se establece en la función. Se debería adaptar en caso de que las tuplas de las notas incluyan también información de velocity.)

In [4]:
def lista_to_MIDI(objeto_midi, lista_notas):
    track = 0
    channel = 0
    velocity = 100
    time = 0
    for key, dur in lista_notas:
        objeto_midi.addNote(track, channel, key, time, dur, velocity)
        time += dur

Utilizando esta función, el programa anterior se puede modificar de esta forma:

In [5]:
from midiutil import MIDIFile

mi_MIDI = MIDIFile(1)

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1),
               (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]

# llamada a la función para agregar las notas de la lista al objeto
lista_to_MIDI(mi_MIDI, lista_notas)

with open("MIDIFile_3.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

La función anterior se puede hacer más flexible, modificándola para que acepte parámetros opcionales:

In [6]:
def lista_to_MIDI(objeto_midi, lista_notas, time=0, track=0, channel=0, velocity=100):
    for key, dur in lista_notas:
        objeto_midi.addNote(track, channel, key, time, dur, velocity)
        time += dur

En el programa siguiente se llama a la función con argumentos adicionales, para que la secuencia comience en la tercera negra, y la velocity tenga un valor de 60.

In [7]:
from midiutil import MIDIFile

mi_MIDI = MIDIFile(1)

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1),
               (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]


lista_to_MIDI(mi_MIDI, lista_notas, 2, velocity=60)

with open("MIDIFile_4.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

La función se puede extender aún más para hacerla más flexible y darle más funcionalidades, a través de más parámetros opcionales. En la siguiente versión, la función agrega parámetros para controlar el transporte en cantidad de semitonos (por defecto = 0), y el factor de multiplicación temporal, para aumentación o disminución rítmica (por defecto = 1).

In [8]:
def lista_to_MIDI(objeto_midi, lista_notas, time=0, transporte=0, factor_temporal=1,
                  track=0, channel=0, velocity=100):
    for key, dur in lista_notas:
        key += transporte
        dur *= factor_temporal
        objeto_midi.addNote(track, channel, key, time, dur, velocity)
        time += dur

En el siguiente programa, esta nueva versión de la función es utilizada para generar la secuencia MIDI transportada cinco semitonos descendentes, y con duplicación de las duraciones de las notas.

In [9]:
from midiutil import MIDIFile

mi_MIDI = MIDIFile(1)

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1),
               (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]

# transporte 5 semitonos descendentes y duplicación de duraciones
lista_to_MIDI(mi_MIDI, lista_notas, 0, -5, 2)

with open("MIDIFile_5.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

El módulo también cuenta con métodos para agregar otros tipos de elementos, además de notas. Entre otros, los más importantes son:

  • .addTempo(): agrega una indicación metronómica
  • .addTimeSignature(): agrega una indicación de compás
  • .addProgramChange(): agrega un cambio de programa (timbre de instrumento)
  • .addTrackName(): asigna un nombre a una pista
  • .addKeySignature(): agrega una armadura de clave

Veamos la sintaxis de cada uno de esos métodos, con ejemplos de su uso.

.addTimeSignature(track, time, numerator, denominator, clocks_per_tick)

Agrega un mensaje de indicación de compás.

  • track: número de pista
  • time: tiempo (por defecto, en negras)
  • numerator: numerador (número entero)
  • denominator: denominador (número entero, como exponente de 2)
  • clocks_per_tick: simplemente poner el valor por defecto (24)

El denominador se interpreta como el exponente de 2, y por tanto 1 corresponde a un denominador 2 (blanca), 2 al denominador 4 (negra), 3 al denominador 8 (corchea), etcétera. Por ejemplo, una indicación de compás 4/4 (compás por defecto) tendría numerador 4 y denominador 2, mientras que un 6/8 tendría numerador 6 y denominador 3.

.addTempo(track, time, tempo)

Agrega una indicación metronómica de tempo.

  • track: número de pista (en los SMF de formato 1, se ignora)
  • time: tiempo (por defecto, en negras)
  • tempo: cantidad de negras por minuto (número entero)

Hay que destacar que el tempo se expresa siempre en términos de la figura de negra, independientemente de la indicación de compás.

En el siguiente programa se toma una lista de notas con una duración total de 3 (tres negras o seis corcheas), que se agrega dos veces a la secuencia. En el primer compás se introduce una indicación de compás de 3/4, y en el segundo de 6/8.

In [10]:
from midiutil import MIDIFile

mi_MIDI = MIDIFile(1)

# variables generales para todas las notas
track = 0
channel = 0
velocity = 100

# lista de notas con duración 3
lista_notas = [(72, 1), (62, 0.5), (59, 0.5), (56, 0.5), (67, 0.5)]

# agrega dos veces las notas de la lista: en el tiempo 0 y en el tiempo 3
lista_to_MIDI(mi_MIDI, lista_notas, 0)
lista_to_MIDI(mi_MIDI, lista_notas, 3)

# agrega una indicación metronómica
mi_MIDI.addTempo(track, 0, 60)

# en el primer compás agrega incicación de compás 3/4
mi_MIDI.addTimeSignature(track, 0, 3, 2, 24)
# en el segundo compás agrega incicación de compás 6/8
mi_MIDI.addTimeSignature(track, 3, 6, 3, 24)

with open("MIDIFile_6.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)

.addProgramChange(track, channel, time, program)

Agrega un mensaje de cambio de programa o timbre (Program Change).

  • track: número de pista
  • channel: canal MIDI (0-15)
  • time: tiempo (por defecto, en negras)
  • program: número de programa (0-127)

La lista de instrumentos asociados a cada número de programa en el General MIDI se puede consultar en esta tabla. El módulo maneja los números de 0 a 127, en vez de 1 a 128, por lo que hay que restar 1 a los números de la tabla.

.addTrackName(track, time, trackName)

Agrega un nombre a una pista.

  • track: número de pista
  • time: tiempo (habitualmente, 0)
  • trackName: nombre de la pista (cadena de caracteres)

En el siguiente programa, se crea un objeto MIDI con dos pistas, y la lista de notas se agrega en dos pistas con canales diferentes y distinto transporte. A cada pista se le asigna un nombre, y al canal respectivo un programa diferente.

In [11]:
from midiutil import MIDIFile

# crea un objeto MIDI con dos pistas
mi_MIDI = MIDIFile(2)

lista_notas = [(72, 1.5), (62, 1.75), (59, 0.25), (56, 1), (67, 1.5), (70, 1),
               (66, 0.5), (57, 0.5), (63, 0.75), (76, 0.75), (73, 0.75), (65, 0.75)]

# agrega la lista de notas en dos pistas con canales MIDI diferentes,
# con tiempos de inicio y transportes diferente
lista_to_MIDI(mi_MIDI, lista_notas, 0)
lista_to_MIDI(mi_MIDI, lista_notas, 1, -14, track=1, channel=1)

# agrega una indicación metronómica
mi_MIDI.addTempo(track, 0, 60)

# agrega nombres y cambios de programa a las pistas
mi_MIDI.addProgramChange(0, 0, 0, 71) # clarinete
mi_MIDI.addTrackName(0, 0, "Clarinete")
mi_MIDI.addProgramChange(1, 1, 0, 70) # fagot
mi_MIDI.addTrackName(1, 1, "Fagot")

with open("MIDIFile_7.mid", "wb") as output_file:
    mi_MIDI.writeFile(output_file)