Opdracht 4.3: de Goldberg-Variationen#
Introductie#
De Goldberg-variationen (BWV 988) is een muziekstuk dat rondom 1741 door Johann Sebastian Bach geschreven is. Het betreft een Aria met een dertigtal variaties hier op. De naam van het werk is anekdotisch: hierin wordt gesuggereert dat Bach het werk schreef voor Johann Gottlieb Goldberg, de muziekleraar van graaf Hermann Carl von Keyserlinkgk, opdat deze zijn slapeloze nachten wat prettiger door kon komen. Hoewel het werk oorspronkelijk voor clavicimbel geschreven is, wordt het ook vaak op de piano uitgevoerd (hier bijvoorbeeld door niemand minder dan Glenn Gould).
In deze opgave gaan we met een RNN een eenendertigste variatie aan dit werk toevoegen. Er zijn natuurlijk verschillende manieren om dit probleem te adresseren. We zouden bijvoorbeeld de partituur kunnen scannen en op basis van beeldherkenning en -generatie hier een tweetal nieuwe pagina’s aan toevoegen.
Hier kiezen we er evenwel voor om de bladmuziek om te zetten in een tekstuele representatie. Dan wordt het maken van een nieuwe variatie feitelijk een kwestie van tekstgeneratie, waar LSTM’s en GRU’s goed in zijn. Er zijn gelukkig verschillende technieken om dit te doen (zie met name Marinescu, 2019), maar we gebruiken hier de midi-bestanden van Dave’s J.S. Bach page die we met behulp van py_midicsv hebben omgezet in csv-bestanden: één bestand voor elke variatie. Je vindt deze bestanden in de zip data/bwv988.zip.
In de cellen hieronder staan de verschillende onderdelen toegelicht. In grote lijnen ziet het stappenplan er als volgt uit:
importeer de noodzakelijke bibliotheken (om uiteindelijk de muziek te kunnen horen moet je waarschijnlijk py_midicsv nog even pip installen)
laad de databestanden in één grote lijst
preprocess de data
maak de vectoren
xen deyen one-hot-encode dezemaak en train het model
maak een methode die op basis van een
seedeen nieuwe sequentie genereert afzet die nieuwe sequentie om in een midi-bestand en geniet van de nieuwe variatie
Stap 0: importeer de noodzakelijke bibliotheken en afhankelijkheden#
Run de onderstaande cel
import os
import random
from pathlib import Path
from keras.utils import to_categorical
import numpy as np
import pandas as pd
from utils import *
Stap 1: imporeer de data#
Pak de zip met de data uit in de directory data (of ergens anders wat voor je werkt). Dit zijn csv-versies van de midi-bestanden die we van Dave’s J.S. Bach page hebben afgehaald – bekijk de individuele bestanden om een beeld te krijgen van hoe die dingen eruit zien. Loop door al deze bestanden en laad de inhoud hiervan in de lijst data. Je kunt gebruik maken van pathlib.Path.
data = []
# YOUR CODE HERE
len(data) # zou 31 moeten zijn (de aria en dertig variaties)
Stap 2: preprocessing#
Het preprocessen van de data kent op zich weer een aantal stappen. Omdat we uiteindelijk een karakter-gebaseerde sequentie-generator gaan maken, is het van belang om een lijstje te hebben van alle unieke karakters die in de data voorkomen. Verder moeten we die karakters om kunnen zetten in getallen, omdat we die getallen aan het model gaan voeren; en om uiteindelijk de tonen (karakters) weer terug te kunnen krijgen, moeten we ook die getallen weer om kunnen zetten in de corresponderende karakters.
# YOUR CODE HERE
# vervang None door de juiste code
chars = None
char_to_idx = None
idx_to_char = None
len(chars) # zou 55 moeten opleveren: wat representeert dit aantal?
Nu we karakters kunnen vertalen in getallen, kunnen we de data omzetten in de corresponderende indexen.
# YOUR CODE HERE:
# vervang None door de juiste code
encoded_sequence = None
len(encoded_sequence) # zou 309378 moeten opleveren
In de cel hieronder zetten we twee waarden die we verderop nodig hebben. Je kunt eventueel experimenteren met verschillende lenges van sequence_length.
vocab_size = len(chars)
sequence_length = 32
Stap 3: matrixen, vectoren en one hot encoding#
Maak nu de data-matrix X en de target-vector y op dezelfde manier als we hebben gedaan in opgave 4.2, bij die n-grams. Zie eventueel hetzelfde plaatje hieronder. Net als bij opgave 4.2 kun je hier prima standaard python-lijsten voor gebruiken; we zetten ze later om in numpy-datatypen.

X = []
y = []
# YOUR CODE HERE
Zet nu X en y om in one-hot encoded matrices met een breedte van vocab_size. Maak gebruik van tf.keras.utils.to_categorical (of je eigen uitwerking uit week 2).
# YOUR CODE HERE
Stap 4: maken en trainen van het model#
Als model maken we gebruik van een recurrent LSTM netwerk – gebruik de relevante klassen uit Keras.layers. Gebruik een Input als eerste laag (bedenk zelf wat de correcte waarden voor de shape-parameter zijn). Voeg minimaal twee LSTM-layers toe en zorg ervoor dat de laatste state hiervan aan de output wordt meegegeven - bestudeer de documentatie om te zien hoe je dat moet doen. Voeg als laatste laag een Dense toe met een grootte van vocab_size en ‘softmax’ als activatie-functie. Vergewis je ervan dat je begrijpt waarom je juist deze waarden moet gebruiken.
from keras.models import Sequential
from keras.layers import LSTM, Dense, Input
#vervang None door de juiste code
model = None
#YOUR CODE HERE
#model.compile(...)
model.summary()
Gebruik nu de methode fit om het model te trainen. Kies relevante opties voor epochs en voor batch_size. Let op: het trainen van het netwerk kan even duren. Op mijn macbook air uit 2020 met een M1-processor duurde één epoch een kleine vier minuten ⏳.
# YOUR CODE HERE
Stap 5: Nieuwe karakters voorspellen#
Maak nu de methode sample hieronder af. Deze methode krijg een seed mee die in het getrainde model gestopt wordt. Dit model voorspelt op basis van deze seed de verdeling van de waarschijnlijkheid van alle karakters uit vocab. Het stappenplan voor deze methode is als volgt:
one-hot encode de geëncodeerde
seedgebruik
model.predictom de index van het volgende karakter te voorspellenmaak gebruik van
utils.add_temperatatureom het geheel iets stochastischer (en dus interessanter) te makenkies één van de indexen uit de voorspelling, op basis van de distributie die het model heeft gegeven (bestudeer de documentatie van
numpy.random.choiceom te zien hoe je dit eenvoudig kunt doen.zet deze gekozen index om in het corresponderende karakter uit
vocab- maak gebruik van de dictionaryidx_to_chardie je hierboven hebt gemaakt.voeg dit karakter toe aan de gegenereerde tekst
voeg de gekozen index toe aan
encoded_seeden verwijder, om de grootte constant te houden, het eerste karakter.
def sample(model, seed_text, num_generate, temperature=1.0):
generated_text = seed_text
encoded_seed = [char_to_idx[char] for char in seed_text]
for _ in range(num_generate):
# YOUR CODE HERE
# zie het stappenplan in de cel hierboven.
return generated_text
Stap 6: een nieuwe variatie#
Gebruik nu de eerste paar maten van een variatie, of van de aria zelf, om met behulp van sample een nieuwe variatie te maken. Kies een relevante waarde voor num_generate zodat je voor een paar minuten karakters terugkrijgt. Let op: Het genereren van een fatsoenlijk stuk kan ook wel even duren.
Sla het gegenereerde bestand op en gebuik de scripts deprocess.py en csvtomidi.py om het uiteindelijke midi-bestand te creëren. Zie het voorbeeld hieronder (ervan uitgaande dat het gegenereerde bestand is opgeslagen onder de naam result.txt):
python deprocess.py -i result.txt -o tmp.txt --tempo 250000
python csvtomidi.py -i tmp.txt -o variatie31.mid
Je kunt midi afspelen met VLC, of je kunt het online omzetten naar bijvoorbeeld mp3. Speel het af en geniet van je eigen bach-creatie 🎼🎶.
seed_text = '' # kies een interessante string als seed
# YOUR CODE HERE
Verdere stappen#
Misschien vind je de nieuwe variatie nog niet zo heel fraai – misschien wel een beetje John Cage meets JS Bach. Je kunt proberen het model wat uit te breiden (bijvoorbeeld door een GRU laag toe te voegen), de sequence_length te vergroten of meer of juist minder karakters te genereren. Experimenteer hier wat mee en bekijk (of beluister eigenlijk) wat het schoonste resultaat oplevert. Of je kunt natuurlijk meer bestanden van Dave’s JS Bach Page halen en kijken of je een heel nieuw werk kunt genereren.
Credits
Deze opgave is gemaakt op basis van het genoemde artikel van Alexandru-Ion Marinescu en een vergelijkbaar experiment van Tobias van der Werf.