J’ai récemment offert un Otamatone à ma fille. Il s’agit d’un petit jouet de musique électronique japonais. D’une main on choisit la hauteur de la note sur un manche. De l’autre on module l’intensité du son en pressant les joues de l’instrument.

Un otamatone

C’est assez rigolo et ça permet assez simplement de massacrer des airs connus. Je me suis demandé jusqu’où on pouvait aller ainsi. Etait-il plus difficile de reconnaître une musique dont on a perturbé la hauteur des notes ou bien la rythmique ? Que se passe-t-il si on fusionne deux musiques en prenant la séquence des hauteurs de l’une et la séquence des durées de l’autre ?

De questions en essais, je me suis retrouvé à injecter la mélodie de Mario dans la musique du premier niveau de Sonic et réciproquement. Au passage, ça m’a permis de regarder un peu sous le capot du format Midi. Tout cela a été fait avec mido (Midi Object) en Python.

Pour télécharger les morceaux altérés, ça se passe ici :

  1. La mélodie de Mario injectée dans Sonic
  2. La mélodie de Sonic injectée dans Mario

Plan de l’article

Installation et premier pas avec mido

Installation

Mido se repose nativement1 sur rtmidi qui est une API pour les entrées/sorties Midi temps réel. Ce dernier utilise à son tour sur les librairies de JACK (JACK Audio Connection Kit) qui est un serveur de son spécialisé dans l’exécution synchrone des clients Midi. On doit donc installer tout ce petit monde.

$ sudo apt-get install libjack-dev
$ pip install python-rtmidi
$ pip install mido

Mido se contente uniquement de gérer les entrées/sorties Midi mais absolument pas de dire que tel flux MIDI doit être converti et joué sur la sortie son. Pour ce faire, il est nécessaire de configurer un convertisseur Midi vers Wave. Mon choix s’est rapidement porté sur Timidity.

$ sudo apt-get install timidity timidity-interfaces-extra

Lecture d’un fichier midi avec Python

Il faut tout d’abord lancer un serveur d’écoute via la commande suivante :

$ timidity -iA -B2,8 -Os1l -s 44100
Requested buffer size 2048, fragment size 1024
ALSA pcm 'default' set buffer size 2048, period size 680 bytes
TiMidity starting in ALSA server mode
Opening sequencer port: 128:0 128:1 128:2 128:3

Les options sont détaillées dans le man :

-iA Launch TiMidity++ as ALSA sequencer client.

-B n,m, –buffer-fragments=n,m For the Linux/FreeBSD/OSS/ALSA/Windows sound driver, selects the number of buffer fragments in interactive mode. Increasing the number of frag‐ ments may reduce choppiness when many processes are running. It will make TiMidity++ seem to respond sluggishly to fast forward, rewind, and vol‐ ume controls, and it will throw the status display off sync. Specify a fragments number of 0 to use the maximum number of fragments available.

-s freq, –sampling-freq=freq Sets the resampling frequency (Hz or kHz). Not all sound devices are capable of all frequencies – an approximate frequency may be selected, de‐ pending on the implementation.

Finalement l’option -Os1l indique que la sortie se fera vers ALSA (s) avec un encodage 16 bits (1) linéaire (l).

Sous Python maintenant, la commande mido.get_output_names() retourne les sorties Midi détectées2. Comme attendu, Timidity apparaît bien avec les quatre ports par défaut 128:0, 128:1, 128:2 et 128:3.

mido.get_output_names()
['Midi Through:Midi Through Port-0 14:0',
 'Midi Through:Midi Through Port-0 14:0',
 'TiMidity:TiMidity port 0 128:0',
 'TiMidity:TiMidity port 1 128:1',
 'TiMidity:TiMidity port 2 128:2',
 'TiMidity:TiMidity port 3 128:3']

Le petit script ci-dessous permet de lire un fichier Midi. L’exemple correspond ici au thème du niveau Green Hill Zone de Sonic. J’en profite pour ouvrir une petite parenthèse pour glisser l’excellente version Jazz de Jon Batiste.

import mido

mid = mido.MidiFile('./sonic_greenhill.mid')

outport = mido.open_output('aseqdump:aseqdump 131:0')
outport.reset()

for msg in mid.play():
    outport.send(msg)

outport.close()

Patchage est un outil GNU GPL permettant de visualiser et gérer les flux Midi. Si je l’exécute en même temps que le script Python, j’obtiens le schéma ci-dessous, on constate que la connection est bien celle attendue.

Patchage en action

Petit aparté sur aseqdump

Pour mémoire parce que c’était aussi amusant à mettre en place, aseqdump est un port d’écoute qui imprime sur sa sortie standard les commandes Midi qu’il reçoit.

On lance une écoute de cette façon :

$ aseqdump
Waiting for data at port 131:0. Press Ctrl+C to end.
Source  Event                  Ch  Data

Parallèlement on voit apparaître ce nouveau serveur d’écoute avec mido (dernière ligne) :

mido.get_output_names()
Out[6]: 
['Midi Through:Midi Through Port-0 14:0',
 'Midi Through:Midi Through Port-0 14:0',
 'TiMidity:TiMidity port 0 128:0',
 'TiMidity:TiMidity port 1 128:1',
 'TiMidity:TiMidity port 2 128:2',
 'TiMidity:TiMidity port 3 128:3',
 'aseqdump:aseqdump 131:0']

Il suffit alors de le sélectionner en lieu et place de Timidity. Lorsque les commandes midi seront envoyées, elles s’afficheront sur la sortie d’aseqdump :

$ aseqdump
Waiting for data at port 131:0. Press Ctrl+C to end.
Source  Event                  Ch  Data
  0:1   Port subscribed            132:0 -> 131:0
132:0   Control change          0, controller 123, value 0
132:0   Control change          0, controller 121, value 0
132:0   Control change          1, controller 123, value 0
[...]
132:0   Note on                 2, note 48, velocity 100
132:0   Note on                 3, note 83, velocity 110
132:0   Note on                 3, note 84, velocity 110
132:0   Note on                 3, note 89, velocity 110
132:0   Note on                 3, note 88, velocity 110
132:0   Note on                 4, note 60, velocity 68
132:0   Control change          2, controller 11, value 25
132:0   Note on                 2, note 47, velocity 100
132:0   Control change          2, controller 11, value 30
132:0   Control change          2, controller 11, value 35
132:0   Control change          2, controller 93, value 40
132:0   Control change          2, controller 91, value 40
132:0   Pitch bend              2, value 8090
132:0   Note on                 2, note 46, velocity 100
132:0   Control change          2, controller 11, value 40
132:0   Control change          2, controller 11, value 45
132:0   Note on                 2, note 45, velocity 100
132:0   Control change          2, controller 11, value 50
132:0   Pitch bend              2, value 7990
132:0   Control change          4, controller 10, value 45
132:0   Control change          2, controller 93, value 40
132:0   Control change          2, controller 91, value 40
132:0   Control change          2, controller 10, value 9
132:0   Note on                 2, note 44, velocity 100
[...]

Quelques généralités sur le format Midi

Les spécifications des fichiers Midi peuvent être récupérées sur midi.org, moyennant une inscription en ligne (gratuite). J’en propose ici un résumé pertinent uniquement pour ce que je cherche à faire. Je passe à côté de toutes les subtilités liées aux instruments Midi réels. Par ailleurs, comme j’ai découvert le détail du format Midi à l’occasion de l’écriture de cet article, j’espère ne pas écrire des trucs trop faux…

Le format Midi fait quoi qu’il en soit le distingo entre :

  • les pistes (en anglais track)
  • les canaux (en anglais channels)

A un canal est associé un instrument Midi qui est une collection de SoundFont. Il existe un total de 16 canaux et 128 instruments. On associe un instrument à un canal avec avec la commande Midi program_change. L’implémentation de cette fonctionnalité avec Mido se fait ainsi :

mido.Message('program_change', channel=channel, program=program)

La gestion des pistes dépend de la version du fichier Midi. Il existe 3 versions différentes :

  • version 0 : il n’existe qu’une seule piste
  • version 1 : c’est la version la plus répandue. Les messages Midi sont sauvegardés sur plusieurs pistes et toutes les pistes sont jouées en même temps (lecture synchrone). Un canal peut apparaître sur plusieurs pistes simultanément ce qui, par la suite, pourra permettre de régler plus finement la musique. Ainsi la caisse claire d’une batterie peut appartenir à une piste donnée alors que les autres tambours appartiennent à d’autres pistes. Le musicien pourra ensuite très facilement augmenter ou diminuer le volume de la caisse claire sans toucher aux autres tambours.
  • version 2 : si j’ai bien compris, cette fois-ci les pistes sont indépendantes les unes des autres (lecture asynchrone). C’est apparemment un format peu répandu.

D’un point de vue données, les fichiers Midi sont organisés en Chunks. Les Chunks sont des briques qui contiennent des données soit globales (on parle alors de Header Chunk) soit les différents streams Midi (les Track Chunks).

Par ailleurs, les commandes Midi sont soit des System messages soit des Channel messages. Les premiers concernent des directives globales, les seconds sont propres à chaque canal.

La gestion du temps est bien entendu un point critique du format Midi. Un fichier Midi commence par définir le tempo général du morceau à l’aide de la directive set_tempo. Cette directive peut par ailleurs réapparaître plus loin lors de l’exécution du morceau. Le tempo (ou beat per minute, bpm en anglais) représente le nombre de battements par minute. Au format Midi, le tempo est le nombre de micro-secondes par battement. Autrement dit, plus le tempo musical augmente et plus le tempo Midi diminue ! Les fonctions bpm2tempo() et tempo2bpm() permettent de faire le lien.

mido.bpm2tempo(120)
500000

L’unité temporelle fondamentale du format Midi est le tick. Je n’ai pas cherché précisément sa définition mais on peut connaître le nombre de ticks par battement à l’aide de la propriété ticks_per_beat de la classe MidiFile.

mid = mido.MidiFile('blue.mid')
mid.ticks_per_beat
480

Lecture de la gamme de Do

Essayons d’explorer la gamme de Do. L’exécution d’une note se traduit par une commande Midi note_on suivi d’une commande note_off. Cela traduit le fait que, sur un instrument Midi, l’ordinateur sait quand le musicien appuie sur une touche (note_on donc) et quand il la relâche (note_off). Mais il n’existe à ma connaissance pas de commande contenant directement la durée de la note.

Les prototypes sous Mido de ces deux commandes sont les suivantes :

mido.Message('note_on', channel=channel, note=note, velocity=v_on, time=t_on)
mido.Message('note_off', channel=channel, note=note, velocity=v_off, time=t_off)

Ces fonctions attendent donc a minima 4 arguments :

  • channel : le numéro de canal sur lequel on souhaite jouer la note. Il est compris entre 0 et 15.
  • note : le numéro de note au format Midi (la correspondance avec les notes réelles est donnée ci-dessous, source: linuxrouen.fr). Ce nombre est compris entre 0 et 127.
  • velocity : l’intensité avec laquelle le musicien joue la note (cela traduit directement l’intensité sonore de la note). Ce nombre est compris entre 0 et 127.
  • time : cette valeur correspond au delta time du format de fichier Midi3. Il m’a donné pas mal de fil à retordre. En gros, pour un message donné avec une valeur de delta time, cela correspond au temps à attendre après la précédente commande Midi. Son unité est le tick (défini au début de l’article)

Les notes Midi

Voici le code Python qui va jouer les notes de la gamme de Do majeur, de Do à Do (8 notes donc) avec 1 seconde par note.

import mido

# Ouverture de la sortie Midi vers Timidity
outport = mido.open_output('TiMidity:TiMidity port 0 128:0')
outport.reset()

# Création d'un fichier Midi
mid = mido.MidiFile()

# Crtéation d'une piste
track = mido.MidiTrack()

# Ajout de la piste au fichier
mid.tracks.append(track)
 
channel = 0
program = 52 # On essaie de choisir un instrument sans trop de sustain...
bpm = 60

notes = [60, 62, 64, 65, 67, 69, 71, 72]
velocity = 100

# Définition du tempo
msg = mido.MetaMessage('set_tempo', tempo = mido.bpm2tempo(bpm))
track.append(msg)

# Association canal/programme (i.e. instrument)
msg  = mido.Message('program_change', channel=channel, program=program)
track.append(msg)


# On joue les notes successivement, sans pause

for note in notes:
    
    # Puisque time = 0, alors les notes vont s'enchainer sans pause
    msg = mido.Message('note_on', channel=channel, note=note, velocity=velocity, time=0)
    track.append(msg)
    
    # Puisque time = nb de ticks de la note = nb de ticks par battement
    # alors chaque note va durer un battement, donc 1 seconde
    msg = mido.Message('note_off', channel=channel, note=note, velocity=velocity, time=mid.ticks_per_beat)
    track.append(msg)


# On joue le morceau
for msg in mid.play():
    outport.send(msg)

# Fermeture de la sortie Midi
outport.close()

On vérifie que le morceau dure bien 8 secondes :

mid.length
8.0

Exploration des 128 instruments

Juste pour le geste, voici le code qui explore les 128 instruments définis par défaut par Timidity. Je suppose qu’il est possible de les changer par d’autres, mais je n’ai pas cherché plus loin.

import mido

# Ouverture de la sortie Midi vers Timidity
outport = mido.open_output('TiMidity:TiMidity port 0 128:0')
outport.reset()

# Création d'un fichier Midi
mid = mido.MidiFile()

# Crtéation d'une piste
track = mido.MidiTrack()

# Ajout de la piste au fichier
mid.tracks.append(track)
 
channel = 0
bpm = 240 # On va un peu plus vite parce que 128 instruments, c'est long !

# Do majeur = Do-Mi-Sol
notes = [60, 64, 67]
velocity = 100

# Définition du tempo
msg = mido.MetaMessage('set_tempo', tempo = mido.bpm2tempo(bpm))
track.append(msg)

for program in range(128):
    
    # Association canal/programme (i.e. instrument)
    msg  = mido.Message('program_change', channel=channel, program=program)
    track.append(msg)
    
    for (k, note) in enumerate(notes):
        # On attend un battement soit 60/bpm = 0.25 s de pause entre chaque triolet
        # On attend rien du tout si ce sont les notes du triolet cf. le *(k==0)
        msg = mido.Message('note_on', channel=channel, note=note, velocity=velocity, time=mid.ticks_per_beat*(k==0))
        track.append(msg)
        
        # La note dure un battement, soit 0.25s
        msg = mido.Message('note_off', channel=channel, note=note, velocity=velocity, time=mid.ticks_per_beat)
        track.append(msg)
        
        # Au final, chaque instrument occupera 0.25 s de pause + 0.75 s de musique = 1s
        # ce qui fait au total un durée attendue de 128 s (un peu plus de 2 minutes)

print('- Duration : %f' % mid.length)

for msg in mid.play():
    outport.send(msg)

outport.close()

Mélanger Sonic et Mario

Le site Video Game Music Archive permet de récupérer au format Midi les musiques des jeux vidéos rétro.

Le script ci-dessous va venir injecter la mélodie de Mario dans la musique du niveau Green Hill Zone de Sonic.

Une fois qu’on a vu les scripts précédents, ce code ne présente aucune difficulté. Je ne me suis pas préoccupé des autres messages que j’ai laissé tels quels. La seule petite difficulté aura finalement été d’identifier la piste et le canal des deux lignes mélodiques.

Le résultat est plutôt rigolo. Il faut bien remarquer que seule la hauteur des notes de la piste mélodique a été modifiée. Tout le reste (autres pistes, mais aussi intensité de chaque note de la mélodie, bends éventuels et autres effets) demeure inchangé.

import mido

# On lit la rythmique et les instruments de Sonic
# Mais les hauteurs de note de channel_sonic sont celles du channel_mario de Mario

mid_sonic = mido.MidiFile('./sonic_greenhill_zone.mid')
mid_mario = mido.MidiFile('./mario.mid')

track_sonic = 1 # Track contenant le channel de la mélodie
track_mario = 1

channel_sonic = 0 # Channel contenant la mélodie
channel_mario = 0

gain = 30 # Valeur à ajouter sur l'intensité (velocity) de la ligne mélodique


# Step 1 : on lit la suite des notes de mario

print('- Constructing notes sequence of Mario melody...')

note_on_list = []
note_off_list = []


for msg in mid_mario.tracks[track_mario]:
    if (msg.type == 'note_on') and (msg.channel == channel_mario):
        note_on_list.append(msg.note)
    elif (msg.type == 'note_off') and (msg.channel ==  channel_mario):
        note_off_list.append(msg.note)

n_on = len(note_on_list)
n_off = len(note_off_list)

print('processed track %d : %d note_on msg / %d note_off msg...' % (track_mario, n_on, n_off) )

    
# Step 2 : on bidouille la track de Sonic

print('Injecting Mario melody inside Sonic music...')

track = mid_sonic.tracks[track_sonic]
pos_on = 0
pos_off = 0

for msg in track:
    if (msg.type=='note_on') and (msg.channel == channel_sonic):
        msg.note = note_on_list[pos_on % n_on]
        msg.velocity = min(msg.velocity + gain, 127)
        pos_on += 1
    elif (msg.type=='note_off') and (msg.channel == channel_sonic):
        msg.note = note_off_list[pos_off % n_off]
        msg.velocity = min(msg.velocity + gain, 127)
        pos_off += 1


# Step 3 : now listen the song of Apocalypse

print('Playing the sound of madness...')

outport = mido.open_output('TiMidity:TiMidity port 0 128:0')
outport.reset()

for msg in mid_sonic.play():
    outport.send(msg)


outport.close()

mid_sonic.save('altered_sonic.mid')

Puisque les pistes et canaux des mélodies sont identiques d’un morceau à l’autre, la construction inverse (la mélodie de Sonic dans la musique de Mario) s’obtient rapidement en faisant :

(mid_sonic, mid_mario) = (mid_mario, mid_sonic)

Indiana Jones chez Jurassic Park

Le code présenté ici n’est pas robuste pour deux raisons :

  1. la première est que la fin des notes Midi peut être codée différemment soit par un message note_off (comme vu dans cet article) soit par un message note_on avec une vélocité nulle. Le standard Midi stipule noir sur blanc que les applications gérant du Midi doivent être en mesure de gérer les deux cas de figure. Ce n’est pas le cas ici. Néanmoins, ce problème ne pose pas de grosses difficultés : il suffit, par exemple, de convertir à la volée tous les messages note_on de vélocité nulle en message note_off sans perte d’information4.

  2. plus problématique, et bien que cela ne se soit pas présenté dans les quelques exemples de fichiers Midi que j’ai pu traiter, les canaux peuvent très bien être répartis sur plusieurs pistes différentes. Pistes qui, dans la norme de fichier Midi 1, seront lues de façon synchrone. Mon code fait implicitement l’hypothèse qu’un canal n’apparaît que dans une et une seule piste.

Voici un code Python qui cette fois-ci va créé un fichier Midi from scratch avec juste un piano qui va jouer une mélodie mélangeant :

  1. le vecteur des hauteurs musicales du thème d’Indiana Jones
  2. le vecteur des durées musicales du thème de Jurassic Park
import mido

# Cette fois-ci nous ne gardons que la ligne mélodique au piano
# On prend le vecteur des hauteurs de Jurassic Park qu'on combine au vecteur des durées d'Indiana Jones
# On ignore tous les autres effets éventuels

mid_indiana = mido.MidiFile('./indiana.mid')
mid_jp = mido.MidiFile('./jp.mid')


track_indiana = 6 # Track contenant le channel de la mélodie
track_jp = 1

channel_indiana = 5 # Channel contenant la mélodie
channel_jp = 0



mid = mido.MidiFile()          # Nous pouvons créer un nouveau fichier en appelant MidiFile
track_out = mido.MidiTrack()   # Track de la ligne mélodique fusionnée au piano
mid.tracks.append(track_out)


# Step 1 : on lit la suite des notes de Jurassic Park

print('- Constructing notes sequence of Jurassic Park melody...')

note_on_list = []
note_off_list = []


for msg in mid_jp.tracks[track_jp]:
    if (msg.type == 'note_on') and (msg.channel == channel_jp) and (msg.velocity>0):
        note_on_list.append(msg.note)
    elif (msg.type == 'note_on') and (msg.channel == channel_jp) and (msg.velocity==0):
        note_off_list.append(msg.note)
    elif (msg.type == 'note_off') and (msg.channel ==  channel_jp):
        note_off_list.append(msg.note)

n_on = len(note_on_list)
n_off = len(note_off_list)

print('processed track %d : %d note_on msg / %d note_off msg...' % (track_jp, n_on, n_off) )

    
# Step 2 : on bidouille la track de la copie de Sonic

print('Mixing Indiana melody and Jurassic Park melody...')

track = mid_indiana.tracks[track_indiana]
pos_on = 0
pos_off = 0
first = 1

for msg in track:
    if (msg.type=='note_on') and (msg.channel == channel_indiana):
        m = msg.copy()
        m.note = note_on_list[pos_on % n_on]
        
        if first == 1:
            m.time = 0
            first = 0

        m.channel=0
        track_out.append(m)
        pos_on += 1
    elif (msg.type=='note_off') and (msg.channel == channel_indiana):
        m = msg.copy()
        m.note = note_off_list[pos_off % n_off]
        m.channel=0
        track_out.append(m)
        pos_off += 1


# Step 3 : now listen the song of Apocalypse

print('Playing the sound of madness...')

outport = mido.open_output('TiMidity:TiMidity port 0 128:0')
outport.reset()

for msg in mid.play():
    outport.send(msg)


outport.close()

mid.save('melody_jp__rythmn_indy.mid')

On peut faire de même pour le mélange inverse, je ne détaille pas la procédure. Remarquons qu’on prend bien en compte la dualité note_on/note_off pour traiter le cas des fins de note (c’est un peu artisanal mais enfin ça fonctionne). En revanche, je fais toujours l’hypothèse que la ligne mélodique appartient toujours à un seul canal…

Afin de partager ça sur Internet, j’ai décidé de créer des petites vidéos contenant uniquement la mélodie transformée en mp3. Cela se fait en ligne de commande en mélangeant timidity (pour la conversion Midi>Wave) et ffmpeg (pour la création de vidéo). Le prototype est le suivant :

timidity -Ow -o - melody_indy__rythmn_jp.mid | ffmpeg -loop 1 -i main.png -i - -acodec libmp3lame -vcodec libx264 -tune stillimage -shortest -ab 64k mix_b_a.mkv

Les options de Timidity :

  • -0w demande une conversion en Wave
  • -o - demande une impression sur la sortie standard (pratique pour piper vers ffmpeg)

Les options de ffmpeg :

  • -loop 1 -i main.png -tune still image -shortest inclut l’image “main.png”, explications ici
  • -acodec libmp3lame -ab 64k convertit la sortie audio en mp3, explications ici

Le résultat a été publié sur YouTube pour savoir si les gens peuvent reconnaître les deux mélodies (ça me semble plus simple dans le deuxième cas)

Références

Résumé des commandes Midi

Spécification des fichiers Midi

Meta-messages Midi avec Mido

Un excellent tutoriel de prise en main de Mido sur le site de linuxrouen.fr

Un générateur de messages Midi

  1. la documentation de mido indique cependant que quatre autres backends sont envisageable, à savoir PortMidi, Pygame, rtmidi-python et Amidi 

  2. j’ignore à quoi correspondent les deux sorties “Midi Through:Midi Through Port-0 14:0” et aucun son ne sort des enceintes si je les sélectionne 

  3. à noter que ce delta time n’apparaît pas dans les purs messages Midi car ça n’aurait aucun sens. Le message Midi est délivré quand il est émis. 

  4. la transformation inverse n’est toutefois pas totalement équivalent puisque dans ce cas, on perd l’information de la vélocité du note_off. J’ignore si cela a un impact réel quelconque. Cela représenterait, si je comprends bien, la puissance avec laquelle le musicien enlève le doigt de la touche ?