Ga naar hoofdinhoud

Muziek componeren met TensorFlow 2.0 — Tutorial

In deze tutorial leer je hoe je generatieve modellen traint om muziek te componeren in TensorFlow 2.0.
Bijgewerkt 2 okt 2026  · 15 min lezen

Verken met AI

ChatGPTClaudePerplexity

banner introductie tot tensorflow in python

Deze tutorial is ontwikkeld rond TensorFlow 2.0 in Python, samen met de high-level Keras-API, die een grotere rol speelt in TensorFlow 2.0. Wil je meer leren over TensorFlow 2.0, bekijk dan Introduction to TensorFlow in Python op DataCamp. Voor een uitputtende review van de literatuur over deep learning voor muziek, zie Briot, Hadjerest en Pachet (2019), waarnaar we in deze tutorial regelmatig zullen verwijzen.

Generatieve modellen

Supervised machine learning-modellen kunnen worden onderverdeeld in twee categorieën: discriminatieve modellen en generatieve modellen. Discriminatieve modellen bepalen een beslissingsgrens en produceren een bijbehorende classificatie. Generatieve modellen creëren nieuwe instanties van een klasse.

Een discriminatief model voor muziek zou gebruikt kunnen worden om liedjes in verschillende genres te classificeren. Een generatief model zou liedjes in een bepaald genre kunnen componeren. In deze tutorial gebruiken we generatieve modellen om muziek te componeren.

Discriminatief vs. generatief
Deze figuur toont discriminatieve en generatieve modellen voor muziek.

Muziekrepresentatie

Voordat we een generatief model kunnen definiëren en trainen, moeten we eerst een dataset samenstellen. Voor muziek kan data worden weergegeven in een continue of discrete vorm. De meest voorkomende continue vorm is een audiosignaal, doorgaans opgeslagen als een WAV-bestand. We gebruiken in deze tutorial geen continue vormen, maar je kunt er meer over lezen in de Appendix. Veelvoorkomende discrete vormen zijn Musical Instrument Digital Interface (MIDI)-bestanden, pianoroll en tekst. We focussen op MIDI-bestanden en extraheren twee soorten symbolische objecten: noten en akkoorden.

Noten

Een noot is een symbolische representatie van een geluid. Voor onze doeleinden kan een noot worden beschreven door zijn toonhoogte en duur. De toonhoogte van een noot hangt samen met de frequentie van de trilling van de geluidsgolf, gemeten in hertz (Hz). Noten met een hogere toonhoogte hebben geluidsgolven met meer trillingen per seconde. De duur van een noot is de lengte van de periode waarin hij wordt gespeeld.

MIDI-bestanden geven de toonhoogte van een noot weer met een geheel getal tussen 0 en 127. Noten kunnen ook worden gerepresenteerd door een toonletter en een octaafnummer. Binnen hetzelfde octaaf zijn de toonhoogten, van laag naar hoog, als volgt geordend:

  1. C
  2. C#/D♭
  3. D
  4. E♭/D#
  5. E
  6. F
  7. F#/G♭
  8. G
  9. A♭/G#
  10. A
  11. B♭/A#
  12. B

Het octaaf wordt aangegeven met een subscript, zoals de 4 in A4 of de 7 in C7. Een hoger octaaf komt overeen met een hogere frequentie. Nemen we een willekeurige toonhoogte Xi, dan vertegenwoordigt Xi+1, precies één octaaf hoger, een geluidsgolf met tweemaal de frequentie van Xi.

Naast de toonhoogte gebruiken we ook de duur van een noot. De duur is een relatieve waarde, genormaliseerd op de lengte van een hele noot. De langste noot is een "grote" noot, die acht keer zo lang is als een hele noot. De kortste noot is een tweehonderdzesenvijftigste noot, oftewel 1/256e van de lengte van een hele noot.

Akkoorden

Een akkoord is een combinatie van twee of meer noten die gelijktijdig op hetzelfde instrument worden gespeeld. Kijken we naar monofone muziek — dus muziek gespeeld op één instrument — dan kunnen we akkoorden identificeren door aan te nemen dat alle noten die exact tegelijk klinken, deel uitmaken van hetzelfde akkoord. Deze aanname geldt niet bij polyfone muziek, waarin twee of meer instrumenten gelijktijdig spelen.

In het codeblok hieronder installeren we music21 en importeren vervolgens de converter-module, die we zullen gebruiken om MIDI-bestanden te parsen. We laden en parsen een klassiek gitaarstuk van Mauro Giuliani. Daarna passen we de methode .chordify() toe, die de akkoordreeks in de partituur reconstrueert, onder de aanname dat gelijktijdig gespeelde noten tot hetzelfde akkoord behoren.

!pip install music21
from music21 import converter

# Define data directory
data_dir = '../audio/'

# Parse MIDI file and convert notes to chords
score = converter.parse(data_dir+'giuliani.mid').chordify()

# Display as sheet music
print(score.show())

Nadat we de akkoordreeks in het MIDI-bestand hebben opgebouwd, kunnen we deze als bladmuziek weergeven met de methode .show(). Hiervoor is software nodig die bladmuziek kan maken en weergeven, zoals MuseScore, dat gratis beschikbaar is.

Bladmuziek gegenereerd uit MIDI
Deze figuur toont een MIDI-bestand weergegeven als bladmuziek.

Als alternatief kunnen we de onderliggende elementen als tekst bekijken en bewerken. Dat kan met de methode .show('text'). Aan het begin van elke regel zie je de offset tussen haakjes, die aangeeft hoeveel seconden na de start van de partituur een element verschijnt. Merk op dat slechts één van de elementen op seconde 0 een akkoord is. De overige elementen zijn metadata, waaronder het gebruikte instrument, het tempo, de toonsoort en de maatsoort. We gebruiken de toonsoort later, maar je kunt deze elementen nu negeren.

 print(score.show('text'))
{0.0} <music21.instrument.Guitar 'Guitar'>
{0.0} <music21.tempo.MetronomeMark animato Quarter=120.0>
{0.0} <music21.key.Key of C major>
{0.0} <music21.meter.TimeSignature 4/4>
{0.0} <music21.chord.Chord E3 G3>
{1.0} <music21.chord.Chord B2 G3>
{2.0} <music21.chord.Chord D3 G3>
...
{46.0} <music21.chord.Chord A3>
...
{88.0} <music21.chord.Chord G2 E3 G3 C4>
{90.0} <music21.chord.Chord G2 D3 G3 B3>
{92.0} <music21.chord.Chord C3 E3 G3 C4>

Na de metadata bestaan de resterende elementen uit akkoorden, in de volgorde waarin ze in het nummer voorkomen. Zo is <music21.chord.Chord E3 G3> het akkoord dat ontstaat door de noten E3 en G3 te spelen. Daarnaast is <music21.chord.Chord A3> de noot A3 die solo wordt gespeeld.

Voor sommige modellen gebruiken we zowel het akkoord als de duur, die meet hoelang het wordt gespeeld. We kunnen deze informatie verkrijgen door de methode .elements toe te passen en de .duration-attribuut te benaderen voor een willekeurig akkoord in de partituur, zoals in het volgende codeblok.

print(score.elements[10])
<music21.chord.Chord E3 A3>
print(score.elements[10].duration)
<music21.duration.Duration 1.0>

Codering van noten en akkoorden

In deze tutorial focussen we op discrete representaties van muziek die beschreven kunnen worden als reeksen noten, akkoorden en bijbehorende duren. Denk eraan dat akkoorden combinaties van noten zijn en dat het MIDI-formaat 128 afzonderlijke noten toelaat. Als we de featureset willen uitbreiden met alle tweeklanken (akkoorden van twee noten), neemt de omvang toe met 128!/[(128-2)!*2!] oftewel 8128 elementen. Het toevoegen van drieklanken breidt dit uit met 341.376, en vierklanken met nog eens 10.668.000.

Het voorbeeld dat we eerder lieten zien bevat noten en twee-, drie- en vierklanken. Als we one-hot encoding gebruiken en alle features meenemen, moeten we voorspellingen doen over vectoren met 11.017.632 dimensies. Daarom is het verstandig goed na te denken over de keuze van codering bij muziekgeneratieproblemen. Ook is het de moeite waard om transponeren van toonsoorten of data-augmentatie te overwegen, die beide in de Appendix worden besproken.

Met betrekking tot de keuze van codering bespreken Briot, Hadjerest en Pachet (2019) vier verschillende opties die vaak worden gebruikt in generatieve modellen voor muziek:

  1. One-hot encoding
  2. Many-hot encoding
  3. Multi-one-hot encoding
  4. Multi-many-one-hot encoding

Bij one-hot encoding worden muzikale symbolen, zoals noten en akkoorden, weergegeven als sparse vectoren met een één op de positie die overeenkomt met een bepaalde noot of een bepaald akkoord. Many-hot encoding representeert akkoorden, die uit meerdere noten bestaan, door een één te plaatsen op elke positie die overeenkomt met de noten.

In het diagram hieronder vergelijken we many-hot en one-hot encodings voor het geval dat we noten en tweeklanken hebben. Bij many-hot encodings worden de noten C4 en D4 weergegeven door vectoren met 128 elementen. Beide vectoren hebben overal nullen, behalve op de posities die overeenkomen met de elementen C4 en D4, die enen bevatten. Het akkoord C4+D4, het resultaat van het gelijktijdig spelen van C4 en D4, wordt geconstrueerd door enen te plaatsen op zowel de C4- als D4-positie.

Codering van noten en akkoorden
Dit diagram illustreert het verschil tussen one-hot en many-hot encoding.

De one-hot representatie van losse noten en tweeklanken bestaat uit vectoren met 8256 elementen. In elk geval is een vector sparse en bevat deze een 1 op de positie die overeenkomt met een unieke noot of een uniek akkoord. Merk op dat de many-hot representatie het gebruik van een multi-labelmodel vereist, waarbij meerdere klassen kunnen worden voorspeld voor één output. Het reduceert echter de omvang van de input- en outputvectoren aanzienlijk.

De meeste modellen in de literatuur gebruiken nog steeds one-hot encoding. Toch identificeren Briot, Hadjerest en Pachet (2019) verschillende muziekgeneratiemodellen die many-hot encoding gebruiken, waaronder de systemen RBMc, RNN-RBM en C-RBM, die allemaal zijn gebaseerd op een restricted Boltzmann machine (RBM)-architectuur.

Multi-one-hot encodings en multi-many-hot encodings zijn de generalisaties van one-hot en many-hot encoding voor het geval van polyfone muziek — dus meerdere instrumenten die gelijktijdig spelen. We beperken ons tot monofone muziek en bespreken deze opties verder niet in de tutorial.

Dataverzameling en -voorbereiding

De volgende stap is het verzamelen van een dataset waarmee we een generatief model kunnen trainen. We maken gebruik van data van het Mutopia Project, dat 2124 muziekstukken in MIDI-formaat bevat die tot het publieke domein behoren. Omdat de website per instrument is opgedeeld, kunnen we een relatief eenvoudig Python-script schrijven om alle gitaarstukken te scrapen, die we in deze tutorial gebruiken om modellen te trainen. Voor een overzicht van hoe je dit doet, zie de Appendix.

Stel dat we alle .mid-bestanden uit de gitaarsectie hebben opgeslagen in een map, save_dir. De volgende stap is om elk .mid-bestand in music21 te laden als een stream-object.

import os
from music21 import converter, pitch, interval

# Define save directory
save_dir = '../guitar/'

# Identify list of MIDI files
songList = os.listdir(save_dir)

# Create empty list for scores
originalScores = []

# Load and make list of stream objects
for song in songList:
    score = converter.parse(save_dir+song)
    originalScores.append(score)

Als onderdeel van onze dimensiereductiestrategie beperken we ons tot de stukken van Mauro Giuliani. Dat kunnen we doen door het bovenstaande codeblok als volgt te wijzigen.

# Identify list of MIDI files
songList = os.listdir(save_dir)
songList = [song for song in songList if song.lower().find('giuliani')>-1]

We moeten nog vier dingen doen voordat we kunnen beginnen met trainen:

  1. Polyfone muziek verwijderen en .chordify() toepassen op de resterende stream-objecten.
  2. De reeks noten, akkoorden en duren extraheren.
  3. Een mapping construeren die noten, akkoorden en duren naar integers vertaalt en omgekeerd.
  4. Streams opdelen in reeksen met vaste lengte die als input voor modellen kunnen dienen.

Laten we beginnen met het verwijderen van polyfone muziek uit de dataset. Dat kan door elk stream-object per instrument te partitioneren en dan te controleren of er maar één partitie is — dus of er slechts één instrument speelt. Dat doen we in het onderstaande codeblok.

from music21 import instrument

# Define function to test whether stream is monotonic
def monophonic(stream):
    try:
        length = len(instrument.partitionByInstrument(stream).parts)
    except:
        length = 0
    return length == 1

# Merge notes into chords
originalScores = [song.chordify() for song in originalScores]

Let op: als we .chordify() toepassen — weergegeven in de figuur hieronder — zonder polyfone muziek te verwijderen, voegen we per ongeluk noten van verschillende instrumenten samen tot één akkoord.

Noten omzetten naar akkoorden
Deze figuur toont hoe noten worden omgezet naar akkoorden.

Nu moeten we uit elke stream de noten, akkoorden en duren extraheren. We definiëren eerst drie lege lijsten: originalChords, originalDurations, originalKeys. We kunnen over de lijst met streams itereren. Voor elke stream identificeren en bewaren we alle noten, akkoorden en duren, evenals de toonsoort van het stuk.

from music21 import note, chord

# Define empty lists of lists
originalChords = [[] for _ in originalScores]
originalDurations = [[] for _ in originalScores]
originalKeys = []

# Extract notes, chords, durations, and keys
for i, song in enumerate(originalScores):
    originalKeys.append(str(song.analyze('key')))
    for element in song:
        if isinstance(element, note.Note):
            originalChords[i].append(element.pitch)
            originalDurations[i].append(element.duration.quarterLength)
        elif isinstance(element, chord.Chord):
            originalChords[i].append('.'.join(str(n) for n in element.pitches))
            originalDurations[i].append(element.duration.quarterLength)
    print(str(i))

We voeren verdere dimensiereductie door in het onderstaande codeblok door alleen muziek met de toonsoort C majeur te behouden, die toevallig het meest voorkomt in onze dataset. Ook gebruiken we in de rest van de tutorial het woord "akkoord" voor zowel akkoorden als noten.

# Create list of chords and durations from songs in C major
cMajorChords = [c for (c, k) in zip(originalChords, originalKeys) if (k == 'C major')]
cMajorDurations = [c for (c, k) in zip(originalDurations, originalKeys) if (k == 'C major')]

De volgende stap is het identificeren van de unieke set akkoorden en duren. Daarna construeren we woordenboeken die ze naar integers mappen. Als we het aantal unieke noten en akkoorden printen, zien we dat de stappen voor dimensiereductie het aantal hebben teruggebracht tot een beheersbare 267.

# Map unique chords to integers
uniqueChords = np.unique([i for s in originalChords for i in s])
chordToInt = dict(zip(uniqueChords, list(range(0, len(uniqueChords)))))

# Map unique durations to integers
uniqueDurations = np.unique([i for s in originalDurations for i in s])
durationToInt = dict(zip(uniqueDurations, list(range(0, len(uniqueDurations)))))

# Print number of unique notes and chords
print(len(uniqueChords))
267

# Print number of unique durations
print(len(uniqueDurations))
9

Nadat we ons model hebben getraind en voorspellingen hebben gedaan, willen we onze integer-voorspellingen terug mappen naar noten, akkoorden en duren. We inverteren hieronder de woordenboeken chordToInt en durationToInt.

# Invert chord and duration dictionaries
intToChord = {i: c for c, i in chordToInt.items()}
intToDuration = {i: c for c, i in durationToInt.items()}

Tot slot kunnen we onze trainingsreeksen definiëren, bestaande uit 32 opeenvolgende noten en akkoorden, samen met de bijbehorende duren. We doen dit eerst voor autoencoder-achtige modellen, waarbij onze set features en targets hetzelfde is.

# Define sequence length
sequenceLength = 32

# Define empty arrays for train data
trainChords = []
trainDurations = []

# Construct training sequences for chords and durations
for s in range(len(cMajorChords)):
    chordList = [chordToInt[c] for c in cMajorChords[s]]
    durationList = [durationToInt[d] for d in cMajorDurations[s]]
    for i in range(len(chordList) - sequenceLength):
        trainChords.append(chordList[i:i+sequenceLength])
        trainDurations.append(durationList[i:i+sequenceLength])

Muziekgeneratie

In deze sectie gebruiken we de dataset die we hebben samengesteld en voorbereid om muziek te genereren. We bekijken drie verschillende modellen voor muziekgeneratie, te beginnen met de eenvoudigste:

  1. De autoencoder
  2. De variational autoencoder (VAE)
  3. Het long short-term memory-model (LSTM)

Autoencoders

Een autoencoder bestaat uit twee netwerken, verticaal gestapeld en verbonden door een latente vector. De inputs van een autoencoder gaan eerst door een encoder-model, dat meestal uit één of meer dense lagen bestaat. De laatste laag in het encodermodel heet een latente vector. Dit is een bottleneck die afdwingt dat de features die in de encoder worden geëxtraheerd, worden samengeperst tot een klein aantal latente features.

De latente vector is verbonden met een decoder-model, dat de data upsamplet of decomprimeert. Het aantal knooppunten in elke opeenvolgende decoderlaag neemt toe. De uiteindelijke outputlaag heeft dezelfde dimensies als de inputlaag van het encodermodel.

In tegenstelling tot discriminatieve modellen, die proberen een beslissingsgrens te vinden, worden autoencoders getraind om de inputdata — in dit geval een lied — zo nauwkeurig mogelijk te reconstrueren, binnen de beperkingen die de grootte van de latente vector oplegt. Daarom gebruiken autoencoders simpelweg de inputs als target.

We gebruiken een autoencoder om ons eerste generatieve muziekmodel te bouwen. Om het zo eenvoudig mogelijk te houden, nemen we de architectuur over van het MiniBach-model, geïntroduceerd in Briot, Hadjerest en Pachet (2019), als vereenvoudiging van het DeepBach-model. Voor een ander voorbeeld van een autoencoder voor muziekgeneratie, zie het DeepHear-systeem.

De inputdata, zoals wij die hebben geconstrueerd, bestaat uit one-hot gecodeerde vectoren die noten en akkoorden representeren. We negeren duren voorlopig. Volgens de MiniBach-architectuur beginnen we met het flattenen van de inputvector.

# Convert to one-hot encoding and swap chord and sequence dimensions
trainChords = tf.keras.utils.to_categorical(trainChords).transpose(0,2,1)

# Convert data to numpy array of type float
trainChords = np.array(trainChords, np.float)

# Flatten sequence of chords into single dimension
trainChordsFlat = trainChords.reshape(nSamples, nChordsSequence)

Vervolgens definiëren we het aantal samples, nSamples, het aantal akkoorden en noten, nChords, en de grootte van de inputdimensie, inputDim. We zetten ook het aantal latente features, latentDim, op 2. Een laag aantal latente dimensies verlaagt de prestaties, maar maakt het wel gemakkelijk om visualisaties van de latente ruimte te genereren.

# Define number of samples, chords and notes, and input dimension
nSamples = trainChords.shape[0]
nChords = trainChords.shape[1]
inputDim = nChords * sequenceLength

# Set number of latent features
latentDim = 2

We definiëren nu de architectuur voor het model, te beginnen met de inputlaag voor het encodernetwerk, encoderInput, en de inputlaag voor het decodernetwerk, latent. Merk op dat de laag latent ook de output van het encodernetwerk is. Vervolgens definiëren we een dense laag, encoded, die de inputs met de latente vector verbindt, en nog een dense laag, decoded, die de latente vector met de outputvector verbindt.

Tot slot definiëren we een decodermodel, decoder, en onze autoencoder, autoencoder. Merk op dat .Model() argumenten inputs en outputs heeft. Voor het decoder-model geven we de latent-vector door als input en het decoded-netwerk als output. Voor de volledige autoencoder gebruiken we encoderInput als input en geven we het decoder-model door, geëvalueerd met de output van het encoder-netwerk, encoded, als input.

# Define encoder input shape
encoderInput = tf.keras.layers.Input(shape = (inputDim))

# Define decoder input shape
latent = tf.keras.layers.Input(shape = (latentDim))

# Define dense encoding layer connecting input to latent vector
encoded = tf.keras.layers.Dense(latentDim, activation = 'tanh')(encoderInput)

# Define dense decoding layer connecting latent vector to output
decoded = tf.keras.layers.Dense(inputDim, activation = 'sigmoid')(latent)

# Define the encoder and decoder models
encoder = tf.keras.Model(encoderInput, encoded)
decoder = tf.keras.Model(latent, decoded)

# Define autoencoder model
autoencoder = tf.keras.Model(encoderInput, decoder(encoded))

De architectuur voor een generieke autoencoder staat in de figuur hieronder. De bovenste helft van het netwerk is de encoder en de onderste helft de decoder. Ze zijn verbonden door de latente vector.

Autoencoder-netwerkarchitectuur
Deze figuur toont de architectuur van een autoencoder.

Met de architectuur voor de decoder en autoencoder gedefinieerd, resteren alleen nog het compileren van het model en trainen met de methode .fit(). Let op twee belangrijke details:

  1. Onze inputwaarden bestaan uit 0'en en 1'en, en onze voorspelde waarden zijn reële getallen tussen 0 en 1. We kunnen een binary_crossentropy-verliesfunctie gebruiken.
  2. Zowel de input- als de targetwaarden zijn hetzelfde: trainChordsFlat.
# Compile autoencoder model
autoencoder.compile(loss = 'binary_crossentropy', learning_rate = 0.01, optimizer='rmsprop')

# Train autoencoder
autoencoder.fit(trainChordsFlat, trainChordsFlat, epochs = 500)

De laatste stap is muziek genereren. Omdat we de reekslengte op 32 hebben gezet, neemt de autoencoder 32 akkoorden en noten als input en produceert een "lied" met vaste lengte dat bestaat uit een reeks van 32 akkoorden en noten. De autoencoder is getraind om outputs te genereren die sterk lijken op de input; ons doel is echter nieuwe muziek te genereren. Hiervoor geven we een willekeurig gegenereerde latente vector door aan het decoder-model, dat we als subnetwork van het autoencoder-model hebben gedefinieerd.

# Generate chords from randomly generated latent vector
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)

Merk op dat we de output van de autoencoder hebben omgevormd tot een array met dezelfde dimensies als onze oorspronkelijke input. Daarna namen we de argmax() over de eerste dimensie van de array. Dit levert een geheel getal op dat overeenkomt met een akkoord, dat we in het onderstaande codeblok identificeren.

# Identify chord sequence from integer sequence
chordSequence = [intToChord[c] for c in generatedChords]

Tot slot kunnen we met music21 een stream-object maken, het instrument instellen op gitaar, de door ons model gegenereerde akkoordreeks toevoegen en alles exporteren als een MIDI-bestand.

# Set location to save generated music
generated_dir = '../generated/'

# Generate stream with guitar as instrument
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Append notes and chords to stream object
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' ')))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' ')))

generatedStream.write('midi', fp=generated_dir+'autoencoder.mid')

De gif hieronder toont een deel van het trainingsproces van een autoencoder. De partituur die in elke stap wordt gegenereerd, is de reconstructie die hoort bij een specifieke latente toestandsvector. Elk frame vertegenwoordigt 5 trainingsepochen.

Autoencoder: evolutie van noot- en akkoordreeksen
Deze figuur toont verschuivingen in de latente ruimte in de tijd.

We kunnen ook beweging in de latente ruimte tijdens het trainen visualiseren, zoals we doen in de gif hieronder. Merk op dat de latente ruimte smal is en verschuift tussen trainingsepochen. Dit maakt het lastig om een hoogwaardige willekeurige latente staat te trekken en zal problematisch blijken wanneer we een latente vector met hogere dimensies gebruiken. In de volgende sectie laten we zien hoe we dit oplossen met een variational autoencoder (VAE).

Autoencoder: evolutie van de latente ruimte
Deze figuur toont verschuivingen in de latente ruimte in de tijd.

Tot slot kunnen we de audio-output van onze autoencoder bekijken. De akkoordreeks van het originele lied, willekeurig getrokken uit onze trainingsset, komt eerst. Vervolgens genereren we een akkoordreeks uit de autoencoder met de latente vector die hoort bij de akkoordreeks van het originele lied, wat het onderstaande lied oplevert.

Voorbeeld: origineel lied
Voorbeeld: gegenereerd lied

Variational autoencoder

Hoewel autoencoders goed geschikt zijn voor taken als denoising, compressie en decompressie, presteren ze minder goed als generatieve modellen. Variational autoencoders (VAE’s), geïntroduceerd door Kingma en Welling (2017), zijn ontworpen om de zwaktes van autoencoders als generatieve modellen te overwinnen en behoren tot de meest gebruikte methoden voor muziekgeneratie. Briot, Hadjerest en Pachet (2019) identificeren vier muziekgeneratiesystemen die op VAE’s zijn gebouwd: MusicVAE, VRAE, VRASH en GSLR-VAE. Ze verbeteren autoencoders op twee punten:

  1. In plaats van elke set inputs naar een punt in de latente ruimte te mappen, mapt een variational autoencoder elke set inputs naar een normale verdeling, gekarakteriseerd door een gemiddelde en een variantie.
  2. In plaats van de latente ruimte elke willekeurige gemiddelde en variantie toe te staan, dwingt een variational autoencoder af dat het gemiddelde en de natuurlijke logaritme van de variantie dicht bij 0 liggen.

We kunnen de eerste wijziging doorvoeren door de latente toestandsvector in de autoencoder te vervangen door drie lagen:

  1. Een mean-laag
  2. Een (log)variance-laag
  3. Een sampling-laag

Elke set inputs wordt gemapt naar één gemiddelde en de natuurlijke logaritme van de variantie, wat voldoende is om een normale verdeling te karakteriseren. Het model trekt vervolgens willekeurig punten uit de verdeling in de sampling-laag.

Het onderstaande codeblok definieert een functie die kan worden gebruikt om een sampling-laag te creëren. Deze neemt een gemiddelde en log-variantie als inputs. Vervolgens gebruikt het de submodule random van tensorflow om een tensor met punten, epsilon, te trekken uit een standaardnormale verdeling, die zowel een gemiddelde als een log-variantie van 0 heeft. We willen deze trekkingen vervolgens transformeren naar trekkingen uit verdelingen die worden gekarakteriseerd door de parameters mean en logVar. Hiervoor schalen we eerst de trekkingen, epsilon, met de standaarddeviaties van de bijbehorende verdelingen. Die kunnen we berekenen door logVar door 2 te delen en vervolgens te exponentiëren. Daarna tellen we het gemiddelde van de verdeling, mean, erbij op.

# Define function to generate sampling layer.
def sampling(params):
    mean, logVar = params
    batchSize = tf.shape(mean)[0]
    latentDim = tf.shape(mean)[1]
    epsilon = tf.random.normal(shape=(batchSize, latentDim))
    return mean + tf.exp(logVar / 2.0) * epsilon

We kunnen nu het autoencoder-model aanpassen om de lagen mean, logVar en sampling op te nemen. Merk op dat de laag encoded nu twee inputs krijgt en de laag encoder drie outputs heeft.

# Define the input and latent layer shapes.
encoderInput = tf.keras.layers.Input(shape = (inputDim))
latent = tf.keras.layers.Input(shape = (latentDim))

# Add mean and log variance layers.
mean = tf.keras.layers.Dense(latentDim)(encoderInput)
logVar = tf.keras.layers.Dense(latentDim)(encoderInput)

# Add sampling layer.
encoded = tf.keras.layers.Lambda(sampling, output_shape=(latentDim,))([mean, logVar])

# Define decoder layer.
decoded = tf.keras.layers.Dense(inputDim, activation = 'sigmoid')(latent)

# Define encoder and decoder layers.
encoder = tf.keras.Model(encoderInput, [mean, logVar, encoded])
decoder = tf.keras.Model(latent, decoded)

# Define variational autoencoder model.
vae = tf.keras.Model(encoderInput, decoder(encoded))

Net als bij het autoencoder-model is onze latente staat de output van de laag encoded. Er is echter een belangrijk verschil: in plaats van elke set inputs te mappen naar één latente staat, gebruiken we de laag sampling om ze te mappen naar willekeurige waarden, getrokken uit een normale verdeling met parameters mean en logVar. Dit betekent dat dezelfde input wordt geassocieerd met een verdeling van latente staten.

De laatste stap is het aanpassen van de verliesfunctie om discipline op te leggen aan de keuze van mean- en logVar-waarden door het model. We berekenen in het bijzonder de Kullback–Leibler-divergentie (KL) tussen elke verdeling gedefinieerd door de mean- en logVar-lagen en de standaardnormale verdeling, met een gemiddelde en log-variantie van 0. Hoe verder onze gemiddelden en log-varianties van 0 liggen, hoe hoger de KL-divergentiecomponent van het verlies zal zijn.

Een logische vraag is waarom we elke verdeling dicht bij een standaardnormaal willen dwingen. Dat doen we omdat we latente staten willen samplen en gebruiken om muziek te genereren. Als we de KL-divergentiepenalty aan de verliesfunctie toevoegen, kunnen we onafhankelijke trekkingen uit een standaardnormale verdeling gebruiken om latente staten te verkrijgen die waarschijnlijk kwalitatief goede muziek opleveren.

In het onderstaande codeblok laten we zien hoe je de verliesfunctie aanpast om de KL-divergentieterm op te nemen. De eerste component van de verliesfunctie is het binary crossentropy-verlies, berekend op de inputs van en outputs uit het vae-model. Dit gebruikten we ook voor de autoencoder. Het wordt een "reconstructieverlies" genoemd omdat het ons bestraft als we het oorspronkelijke lied niet reconstrueren. De KL-term is de gemiddelde KL-divergentie tussen de verdelingen in de latente ruimte en de standaardnormale verdeling. We combineren beide om het totale verlies, vaeLoss, te berekenen, voegen dat toe aan het model, compileren en trainen dan.

# Define the reconstruction loss.
reconstructionLoss = tf.keras.losses.binary_crossentropy(vae.inputs[0], vae.outputs[0])

# Define the Kullback-Liebler divergence term.
klLoss = -0.5 * tf.reduce_mean(1 + logVar - tf.square(mean) - tf.exp(logVar), axis = -1)

# Combine the reconstruction and KL loss terms.
vaeLoss = reconstructionLoss + klLoss

# Add the loss to the model.
vae.add_loss(vaeLoss)

# Compile the model.
vae.compile(batch_size = batchSize, learning_rate = 0.01, optimizer='rmsprop')

# Train the model.
vae.fit(trainChordsFlat, epochs = 500)

In de vorige sectie lieten we zien dat de latente ruimte voor de autoencoder niet goed gedraagt. Een deel van het doel van een VAE is om dit ontwerpprobleem in generatieve settings te overwinnen. De figuur hieronder toont de latente ruimte voor de VAE. We zien dat er aanzienlijke spreiding is over beide features. Bovendien zijn ze beide rond 0 gecentreerd en verschuiven ze niet tussen trainingsepochen. Dit maakt het veel eenvoudiger om willekeurig een hoogwaardige latente staat te samplen.

VAE: evolutie van de latente ruimte
Deze figuur toont verschuivingen in de latente ruimte in de tijd.

Tot slot gaan we over tot muziek genereren. We trekken een willekeurige latente vector uit de standaardnormale verdeling, geven die door aan de decoder, reshapen de output en nemen de argmax van elke kolom. Dit levert een reeks van 32 integers op, die overeenkomen met noten en akkoorden, die we vervolgens identificeren met het woordenboek intToChord. Daarna definiëren we een stream-object, voegen gitaar toe als instrument, voegen de reeks noten en akkoorden aan de stream toe en exporteren die als een MIDI-bestand.

# Generate integers from randomly drawn latent state.
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)

# Identify chords associated with integers.
chordSequence = [intToChord[c] for c in generatedChords]

# Initialize stream with guitar as instrument.
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Append notes and chords and export to MIDI file
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' ')))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' ')))

generatedStream.write('midi', fp=generated_dir+'vae.mid')

De drie onderstaande liedjes zijn door het model gegenereerd. De eerste twee liedjes zijn gebaseerd op latente staten die horen bij akkoordreeksen in de trainingsset. Het laatste lied is een lineaire combinatie van de latente staten van lied #1 en lied #2. Omdat de VAE de latente ruimte goed-gedragen maakt, kunnen we er redelijk zeker van zijn dat een lineaire combinatie van latente staten een hoogwaardige latente staat oplevert.

Voorbeeld: gegenereerd lied #1
Voorbeeld: gegenereerd lied #2
Voorbeeld: lineaire combinatie van lied #1 en #2

Wat we kunnen opmerken aan de op de autoencoder en VAE gebaseerde muziek, is dat een sterke melodie ontbreekt. We kunnen dit verbeteren door meer lagen aan het model toe te voegen en de trainingsset te vergroten. Of we gebruiken een model dat is ontworpen om sequentiële data te verwerken, zoals we in de volgende sectie doen.

Long short-term memory (LSTM)

Het laatste model dat we bekijken, is het long short-term memory-model (LSTM). Dit is een type recurrent neuraal netwerk (RNN) dat is aangepast om het vanishing gradient-probleem te voorkomen. Briot, Hadjerest en Pachet (2019) constateren dat recurrente modellen het meest worden gebruikt voor muziekgeneratie. Ze identificeren meer dan 20 muziekgeneratiesystemen die op een RNN- of LSTM-model steunen, waaronder BachBot, DeepBach, Performance-RNN en Hexahedria.

Het gebruik van een LSTM-model biedt twee voordelen ten opzichte van een autoencoder-gebaseerd model:

  1. Ze zijn ontworpen om sequentiële data te verwerken.
  2. Ze kunnen muzikale reeksen van elke lengte genereren.

In tegenstelling tot autoencoders en VAE’s, die de inputs als targets gebruiken, worden LSTM-gebaseerde generatieve muziekmodellen getraind om een voorspelling te doen over de volgende noot of het volgende akkoord in de reeks. Daarom moeten we onze train- en targetsets opnieuw opbouwen, wat we in het onderstaande codeblok doen. Omdat een LSTM-model ook het aantal te trainen parameters verlaagt, voegen we tevens duren toe aan het model.

# Set sequence length
sequenceLength = 32

# Define empty array for train data
trainChords = []
trainDurations = []
targetChords = []
targetDurations = []

# Construct train and target sequences for chords and durations
for s in range(len(cMajorChords)):
    chordList = [chordToInt[c] for c in cMajorChords[s]]
    durationList = [durationToInt[d] for d in cMajorDurations[s]]
    for i in range(len(chordList) - sequenceLength):
        trainChords.append(chordList[i:i+sequenceLength])
        trainDurations.append(durationList[i:i+sequenceLength])
        targetChords.append(chordList[i+1])
        targetDurations.append(durationList[i+1])

Let op dat trainChords en trainDurations op dezelfde manier zijn gedefinieerd als eerder in de tutorial. We hebben alleen targetChords en targetDurations toegevoegd, die bestaan uit het akkoord en de duur die volgen op de akkoorden en duren in de trainreeks. Vervolgens stellen we het aantal samples, akkoorden en duren in, evenals de inputdimensie. We gebruiken ook aparte embedding-lagen voor akkoorden en duren.

# Define number of samples, notes and chords, and durations
nSamples = trainChords.shape[0]
nChords = trainChords.shape[1]
nDurations = trainDurations.shape[1]

# Set the input dimension
inputDim = nChords * sequenceLength

# Set the embedding layer dimension
embedDim = 64

De volgende stap is het definiëren van de modelarchitectuur. We gebruiken een model met twee inputs en twee outputs, zodat we zowel akkoorden als duren kunnen voorspellen. We definiëren eerst de inputlagen, chordInput en durationInput, en geven deze lagen vervolgens door aan embedding-lagen, chordEmbedding en durationEmbedding. Dit zijn lookuptabellen die aan elk akkoord of elke duur een vector van 64 elementen koppelen. Embeddings stellen ons in staat om een sparse vectorrepresentatie, waarbij inputs one-hot gecodeerd zijn, te vervangen door een dense representatie, waarbij verwante noten en duren waarden hebben die dichter bij elkaar liggen.

De outputs van de twee embedding-lagen worden samengevoegd in mergeLayer. Vervolgens geven we de output van deze laag door aan twee LSTM-lagen. De eerste laag zet de parameter return_sequences op True. Als we deze parameter op False laten staan, zoals standaard gebeurt, geven de LSTM-cellen alleen de eindtoestanden terug en niet de tussenliggende sequentietoestanden. Recent werk laat zien dat het gebruik van tussenliggende toestanden de prestaties kan verbeteren. Dit gebeurt vaak in de context van een attention-laag. Ten slotte geven we de output van de LSTM-laag door aan een dense laag, die vervolgens wordt doorgegeven aan twee outputlagen: chordOutput en durationOutput. We definiëren daarna een keras functioneel API-model door de twee input- en twee outputlagen te specificeren.

# Define input layers
chordInput = tf.keras.layers.Input(shape = (None,))
durationInput = tf.keras.layers.Input(shape = (None,))

# Define embedding layers
chordEmbedding = tf.keras.layers.Embedding(nChords, embedDim, input_length = sequenceLength)(chordInput)
durationEmbedding = tf.keras.layers.Embedding(nDurations, embedDim, input_length = sequenceLength)(durationInput)

# Merge embedding layers using a concatenation layer
mergeLayer = tf.keras.layers.Concatenate(axis=1)([chordEmbedding, durationEmbedding])

# Define LSTM layer
lstmLayer = tf.keras.layers.LSTM(512, return_sequences=True)(mergeLayer)

# Define dense layer
denseLayer = tf.keras.layers.Dense(256)(lstmLayer)

# Define output layers
chordOutput = tf.keras.layers.Dense(nChords, activation = 'softmax')(denseLayer)
durationOutput = tf.keras.layers.Dense(nDurations, activation = 'softmax')(denseLayer)

# Define model
lstm = tf.keras.Model(inputs = [chordInput, durationInput], outputs = [chordOutput, durationOutput])

Met het model gedefinieerd, kunnen we nu compileren en beginnen met trainen. We hebben geprobeerd het hier zo eenvoudig mogelijk te houden, maar voor veel toepassingen is het logisch om regularisatielagen en extra dense- en LSTM-lagen toe te voegen. Merk op dat het model twee input- en twee outputlagen heeft, zoals weergegeven in het onderstaande diagram. Meer over modellen met meerdere inputs en outputs leer je in Introduction to TensorFlow in Python.

LSTM-modelarchitectuur
Deze figuur toont de architectuur van het LSTM-model.
# Compile the model
lstm.compile(loss='categorical_crossentropy', optimizer='rmsprop')

# Train the model
lstm.fit([trainChords, trainDurations], [targetChords, targetDurations],
            epochs=500, batch_size=64)

Nadat het trainingsproces is voltooid, kunnen we nieuwe liedjes genereren met het LSTM-model. Dat doen we door een initiële reeks van 32 akkoorden en duren aan te bieden, waarmee we onze eerste voorspellingen kunnen doen. Vervolgens voegen we die voorspellingen toe aan de akkoord- en duurreeksen, zodat we opnieuw een voorspelling kunnen doen op basis van de laatste 32 akkoorden en duren van de bijgewerkte reeks. Een voordeel van een LSTM-model is dat we onbeperkt kunnen itereren en zo liedjes van willekeurige lengte kunnen genereren.

# Define initial chord and duration sequences
initialChords = np.expand_dims(trainChords[0,:].copy(), 0)
initialDurations = np.expand_dims(trainDurations[0,:].copy(), 0)

# Define function to predict chords and durations
def predictChords(chordSequence, durationSequence):
    predictedChords, predictedDurations = model.predict(model.predict([chordSequence, durationSequence]))
    return np.argmax(predictedChords), np.argmax(predictedDurations)

# Define empty lists for generated chords and durations
newChords, newDurations = [], []

# Generate chords and durations using 500 rounds of prediction
for j in range(500):
    newChord, newDuration = predictChords(initialChords, initialDurations)
    newChords.append(newChord)
    newDurations.append(newDuration)
    initialChords[0][:-1] = initialChords[0][1:]
    initialChords[0][-1] = newChord
    initialDurations[0][:-1] = initialDurations[0][1:]
    initialDurations[0][-1] = newDuration

De laatste stap is om de gegenereerde muziek te exporteren naar een MIDI-bestand met music21. Merk op dat de code bijna identiek is aan wat we gebruikten voor de autoencoder-modellen, maar met één belangrijk verschil: we voegen nu zowel akkoorden als duren toe aan het stream-object.

# Create stream object and add guitar as instrument
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Add notes and durations to stream
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' '), quarterType = durationSequence[j]))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' '), quarterType = durationSequence[j]))

# Export as MIDI file
generatedStream.write('midi', fp=generated_dir+'lstm.mid')

Met de autoencoder- en VAE-modellen hadden we moeite om melodieën te genereren en konden we alleen liedjes met vaste lengte uitgeven. Met een LSTM-model kunnen we door te itereren over voorspellingen uitvoerreeksen van willekeurige lengte genereren, zoals we in het bovenstaande codeblok hebben gedaan. Ook wordt het relatief eenvoudiger om betere melodieën te genereren. Beide eigenschappen zie je in de onderstaande voorbeelden, die elk zijn geïnitialiseerd met een beginreeks van akkoorden en duren uit een sample in onze trainingsset.

Voorbeeld: gegenereerd lied #1
Voorbeeld: gegenereerd lied #2
Voorbeeld: gegenereerd lied #3

   banner introductie tot tensorflow in python cursus

Aanvullende aandachtspunten

Data

Een van de grootste uitdagingen bij de ontwikkeling van machine learning-modellen voor muziek is de beschikbaarheid van datasets, omdat muziek doorgaans niet vrij en legaal te downloaden is. Dit heeft er zelfs toe geleid dat de literatuur geen standaardpraktijkdataset heeft, zoals de MNIST-dataset voor beeldclassificatie. Er zijn echter enkele projecten die ofwel ruwe audio, symbolische notatie of afgeleide muzikale features voor een groot aantal liedjes bieden. Hieronder geven we een gedeeltelijke lijst van die projecten.

  1. Mutopia Project. Een archief met muziek in het publieke domein en Creative Commons in MIDI-, PDF- en LilyPond-formaat. Het bevat momenteel meer dan 2000 liedjes en is georganiseerd per instrument, componist, stijl en collectie.
  2. The Million Song Dataset. Bevat audiofeatures en metadata voor meer dan 300 GB aan populaire muziektracks, maar geen audiotracks.
  3. Free Music Archive. Bevat audio, genrelabels en features voor meer dan 100.000 tracks die vrij en legaal te downloaden zijn.

Sampling vs. determinisme

Toen we muziek genereerden met een LSTM-model, gebruikten we een deterministische aanpak voor de selectie van akkoorden en duren. Het model voorspelde waarschijnlijkheden voor elk van de 267 akkoorden en 9 duren, en we kozen de akkoorden en duren met de hoogste bijbehorende waarschijnlijkheden. Evenzo kozen we bij de autoencoder- en variational autoencoder-modellen de akkoorden met de hoogste bijbehorende outputwaarden.

Een alternatief voor deze aanpak is om te samplen uit de verdeling van outputs, in plaats van de akkoorden en duren met de hoogste waarschijnlijkheden te selecteren. Dit maakt het muziekgeneratieproces stochastisch in plaats van deterministisch. Dat betekent dat autoencoder- of VAE-modellen met dezelfde latente vector elke keer dat ze worden uitgevoerd verschillende liedjes genereren. Ook kan dezelfde inputreeks naar een LSTM-model verschillende liedjes opleveren.

Het onderstaande codeblok laat zien hoe we naar stochastische generatie van akkoorden en duren kunnen gaan door de functie predictChords() aan te passen. Merk op dat we de argmax()-operatie hebben vervangen door een sampling-operatie die de waarschijnlijkheden gebruikt om willekeurige trekkingen te doen uit de vectoren met akkoorden en duren.

# Define function to generate chords and durations stochastically
def predictChords(chordSequence, durationSequence):
    predictedChords, predictedDurations = model.predict([chordSequence, durationSequence])
    return np.random.choice(range(nChords), p = predictedChords), np.random.choice(range(nDurations), p = predictedDurations)

Voor een uitgebreide bespreking van de voor- en nadelen van stochastische en deterministische benaderingen van muziekgeneratie, zie sectie 6.6 van Briot, Hadjerest en Pachet (2019).

Embeddings

Bij het trainen van het LSTM-model gebruikten we een laag met embeddings, die elk inputakkoord en elke inputduur naar een dense vector van 64 elementen mapt. Met een embedding-laag vermijden we het gebruik van sparse, hoog-dimensionale vectoren als input. In plaats daarvan gebruiken we een laag-dimensionale representatie die ons in staat stelt relaties tussen verschillende akkoorden te identificeren.

In veel gevallen is het niet logisch om een embedding-laag te trainen. Vaak hebben we bij muziekgeneratieproblemen geen voldoende grote trainingssets. Het gebruik van voorgetrainde embeddings of embeddings die met unsupervised learning zijn geproduceerd — zoals we vaak doen bij tekstclassificatie en -generatie — stelt ons in staat het model te trainen wanneer dit anders onhaalbaar zou zijn.

Zie Chuan, Agres en Herremans (2018) voor een overzicht van het construeren van embeddings met een unsupervised learning-aanpak.

Generative adversarial networks

We hebben autoencoders, VAE’s en LSTM-modellen besproken als opties voor muziekgeneratie. Een andere optie, die in andere domeinen succesvol is gebleken voor generatieve modellering, is het generative adversarial network (GAN). Een GAN werkt door een discriminatief netwerk te combineren met een generatief netwerk. Het generatieve netwerk creëert samples, zoals de akkoordreeksen die onze LSTM- en autoencoder-modellen genereerden. Deze samples worden vervolgens gecombineerd in een trainingsset samen met echte samples — dus akkoordreeksen uit echte liedjes. Het discriminatieve model probeert samples te classificeren als echt of door de generator gemaakt. De generator probeert het discriminatieve model te misleiden zodat het de door hem gemaakte samples verkeerdelijk als echte liedjes classificeert. Het model wordt getraind totdat we een stabiel evolutionair evenwicht bereiken, waarbij noch de discriminator noch de generator nog voordeel kan behalen.

Voor wie GAN’s wil toepassen op muziekgeneratietaken, zijn de volgende bronnen de moeite waard:

  1. MuseGAN is een project gericht op het genereren van polyfone muziek met GAN’s. Het wordt geleverd met voorgetrainde modellen en 174.154 opgeschoonde en voorbereide liedjes in pianoroll-formaat.

  2. Magenta is een open-sourceproject gericht op de toepassing van generatieve modellering op creatieve taken, waaronder het maken van muziek. Je kunt de set tools van Magenta verkennen via een Colab-notebook.

Bronnen

Wil je meer leren over TensorFlow 2.0, bekijk dan Introduction to TensorFlow in Python op DataCamp. Voor een recente en uitgebreide review van methoden in muziekgeneratie met deep learning, zie Briot, Hadjerest en Pachet (2019). Voor extra tutorials over het genereren van muziek met deep learning-modellen, zie 1, 2, 3, 4 en 5, dat een compleet leerboek over generatieve modellen biedt.

Appendix

Continue representatie

WAV-bestanden zijn de meest voorkomende vorm van continue representatie omdat ze een ongecomprimeerd, rauw audiosignaal bevatten. Dit verschilt bijvoorbeeld van mp3-bestanden, die de bestandsgrootte comprimeren door informatie over het audiosignaal weg te laten.

Het codeblok hieronder laat zien hoe we de samplerate, rate, en het ruwe audiosignaal, signal, kunnen extraheren uit een WAV-bestand met een muziekstuk van Mozart. De samplerate is het aantal keren per seconde dat het audiosignaal wordt gesampled. In ons geval is dit 44100.

from scipy.io import wavfile

# Define data directory
data_dir = '../audio/'

# Extract sample rate and signal
rate, signal = wavfile.read(data_dir+'mozart.wav')

# Bound signal between -1 and 1
normalized_signal = signal / abs(max(signal))

Het ruwe audiosignaal, signal, is een S x C-tensor, waarbij S het aantal samples is en C het aantal kanalen. Voor de eenvoud beschouwen we het geval waarin C = 1. De onderstaande plot toont een interval van drie seconden van signal.

Deze figuur toont een grafiek van een genormaliseerd audiosignaal geëxtraheerd uit een WAV-bestand.

In het algemeen is het gebruik van ruwe audiosignalen om een model te trainen aanzienlijk uitdagender dan het gebruik van een discrete representatie. Een recente survey van deep learning-methoden voor muziekgeneratie onderzocht 32 verschillende methoden in de literatuur. Daarvan gebruikten er slechts drie continue representaties:

  1. Audio Style Transfer
  2. DeepAuto-Controller
  3. WaveNet

Transponeren van toonsoort

Een methode die kan worden gebruikt voor dimensiereductie of data-augmentatie is transponeren van toonsoort. Dit houdt in dat je de toonhoogte van elke noot met een vaste interval verschuift. Als een lied bijvoorbeeld een reeks bevat waarin de toonhoogte C3 wordt gevolgd door D3 en we een transpositie gebruiken die alle elementen met twee toonhoogten omhoog verschuift, dan wordt C3 D3 en D3 E3. Voor modellen die transponeren gebruiken, zie Lim, Rhyu en Lee (2018) en Sturm et al. (2016).

Als we transponeren gebruiken voor dimensiereductie, is ons doel om noten en akkoorden zo te verschuiven dat we het totale aantal unieke noten en akkoorden in onze dataset minimaliseren. Omdat muziek vaak is geschreven rond bepaalde groepen toonhoogten, zogenaamde toonsoorten, die goed samen klinken, kunnen we handig elk lied in onze dataset naar dezelfde toonsoort transponeren.

Dit keer importeren we drie submodules van music21:

  1. converter, waarmee we een MIDI-bestand laden en omzetten naar een stream-object.
  2. pitch, waarmee we onze doeltoon maken.
  3. interval, waarmee we de afstand tussen onze oorspronkelijke toonsoort en onze doeltoonsoort berekenen.
from music21 import converter, pitch, interval

# Define data directory
data_dir = '../audio/'

# Parse MIDI file
score = converter.parse(data_dir+'giuliani.mid')

# Identify and print original key
key = score.analyze('key')
print(key)
C major

# Compute interval between original key and target key
keyInterval = interval.Interval(key.tonic, pitch.Pitch('F'))

# Transpose song into F major
newScore = score.transpose(keyInterval)

# Print new key
print(newScore.analyze('key'))
F major

We passen eerst de methode .analyze('key') toe op score, die de toonsoort van het stream-object identificeert, namelijk C majeur. We berekenen vervolgens het interval tussen C majeur en onze doeltoonsoort, F majeur. Merk op dat we de toonhoogte van het key-object benaderen met .tonic. Verder creëren we de doeltoon met pitch.Pitch('F'). We wijzen de output toe aan keyInterval, dat de afstand tussen onze oorspronkelijke en doeltoonsoort aangeeft.

Ten slotte transponeren we de toonsoort met de methode .transpose() en geven het interval, keyInterval, door. Het printen van de nieuwe toonsoort bevestigt dat dit F majeur is.

Transponeren van octaaf

Als we verder willen gaan dan het transponeren van de toonsoort, kunnen we ook het octaaf hoger of lager transponeren. In plaats van C majeur naar F majeur te transponeren, kunnen we bijvoorbeeld C majeur naar C majeur in een lager of hoger octaaf transponeren. Als we voor elk lied dezelfde set octaven targeten, dwingen we al onze noten en akkoorden om te clusteren, waardoor we de dimensionaliteit van het probleem verminderen en het trainingsproces voor het model vereenvoudigen. In het bovenstaande codeblok zouden we dit kunnen bereiken door het octaaf van de toon te specificeren. In plaats van pitch.Pitch('F') te gebruiken om het interval te berekenen, zouden we bijvoorbeeld pitch.Pitch('F3') kunnen gebruiken.

Invariante toonsoort en octaaf

Een logisch alternatief voor dimensiereductie is data-augmentatie. In plaats van de set mogelijke inputs en outputs te verkleinen, breiden we de dataset uit door wijzigingen aan te brengen in de bestaande data. Dat kan ook met transponeren van toonsoort en octaaf. In plaats van alle liederen naar een gemeenschappelijke toonsoort te transponeren en hun noten in aangrenzende octaven te clusteren, zouden we alle liederen naar alle toonsoorten en alle octaven transponeren. Zo bereiken we invariante toonsoort en octaaf in het model. Zie Briot, Hadjerest en Pachet (2019) voor een bespreking van transponeren voor beide doeleinden.

Dataverzameling

We verzamelen de MIDI-bestanden om ons model te trainen van Project Mutopia. Voordat we gaan scrapen, controleren we eerst het robots-bestand, te vinden op https://www.mutopiaproject.org/robots.txt, om er zeker van te zijn dat we alle verzoeken over hoe geautomatiseerde interacties met de website moeten worden afgehandeld, respecteren. Op het moment dat deze tutorial werd geschreven, bevatte het robots-bestand de tekst in het onderstaande codeblok. User-agent: * geeft aan dat de volgende regel geldt voor alle gebruikers die met de website interageren. Disallow:, gevolgd door niets, betekent dat er geen beperkingen zijn voor scrapen.

# Allow crawling of all content
User-agent: *
Disallow:

We importeren eerst urlopen en urlretrieve uit urllib.request, die we gebruiken om GET-verzoeken te sturen voor elke pagina in de gitaarsectie en vervolgens elk MIDI-bestand te downloaden waarnaar op de pagina wordt gelinkt. We importeren BeautifulSoup om de HTML te parsen die door urlopen wordt teruggegeven, zodat we MIDI-bestandslinks kunnen identificeren. Tot slot gebruiken we de module time om 10 seconden te pauzeren tussen downloads om de belasting van de website te beperken.

Deze figuur toont een pagina in de gitaarsectie van de Mutopia-website met geïdentificeerde MIDI-bestanden.

Elke stap van de hoofdlus controleert of het aantal links op de pagina, linkCount, groter is dan 0. Zo ja, dan wordt de URL geconstrueerd voor de volgende pagina om naartoe te navigeren, url, met behulp van de strings url0, url1 en het liednummer, songNumber. Vervolgens wordt de url geopend, de HTML geparset, worden alle links op de pagina gevonden, linkCount op 0 gezet en vervolgens door elke link in de lijst gelopen. Als een link de substring .mid bevat, verwijst deze naar een MIDI-bestand en wordt deze met urlretrieve() gedownload naar de map die wordt gespecificeerd door save_dir. Tot slot verhogen we songNumber met 10, omdat elke pagina 10 liedjes bevat. We herhalen dit proces totdat we een pagina tegenkomen met minder dan 10 links.

!pip install bs4
from urllib.request import urlopen, urlretrieve
from bs4 import BeautifulSoup
import time

# Define save directory.
save_dir = '../guitar/'

# Define URL components
url0 = 'https://www.mutopiaproject.org/cgibin/make-table.cgi?startat='
url1 = '&searchingfor=&Composer=&Instrument=Guitar&Style=&collection=&id=&solo=&recent=&timelength=&timeunit=&lilyversion=&preview='

# Set initial values
songNumber = 0
linkCount = 10

# Locate and download each MIDI file
while linkCount > 0:
    url = url0 + str(songNumber) + url1
    html = urlopen(url)
    soup = BeautifulSoup(html.read())
    links = soup.find_all('a')
    linkCount = 0
    for link in links:
        href = link['href']
        if href.find('.mid') >= 0:
            linkCount = linkCount + 1
            urlretrieve(href, save_dir+href)
    songNumber += 10
    time.sleep(10.0)

Wil je meer leren over scrapen, bekijk dan DataCamp’s cursus Web Scraping with Python en de tutorial Web Scraping using Python.

Onderwerpen
Python
Machine Learning

Leer meer over Python en Machine Learning

Cursus

Introductie tot TensorFlow in Python

4 uur
56.5K
Leer de basis van neurale netwerken en hoe je deep learning-modellen kunt bouwen met TensorFlow.
Bekijk detailsRight Arrow
Start Cursus
Meer bekijkenRight Arrow
Gerelateerd

blog

AI vanaf nul leren in 2026: een complete gids van de experts

Ontdek alles wat je moet weten om in 2026 AI te leren, van tips om te beginnen tot handige resources en inzichten van industrie-experts.
Adel Nehme's photo

Adel Nehme

15 min

Meer BekijkenMeer Bekijken