Hướng dẫn này được xây dựng xoay quanh TensorFlow 2.0 trong Python, cùng với Keras API cấp cao đóng vai trò được tăng cường trong TensorFlow 2.0. Với những ai muốn tìm hiểu thêm về TensorFlow 2.0, hãy xem khóa Giới thiệu về TensorFlow trong Python trên DataCamp. Để có bài tổng quan đầy đủ về học sâu cho âm nhạc, xem Briot, Hadjerest, và Pachet (2019), tài liệu mà chúng tôi sẽ tham chiếu xuyên suốt hướng dẫn này.
Mô hình sinh
Các mô hình học máy có giám sát có thể được chia thành hai loại: mô hình phân biệt và mô hình sinh. Mô hình phân biệt xác định một biên quyết định và đưa ra phân loại tương ứng. Mô hình sinh tạo ra các thể hiện mới của một lớp.
Một mô hình phân biệt về âm nhạc có thể được dùng để phân loại bài hát theo các thể loại khác nhau. Một mô hình sinh có thể soạn các bài hát của một thể loại cụ thể. Trong hướng dẫn này, chúng ta sẽ sử dụng các mô hình sinh để soạn nhạc.
Phân biệt vs. Sinh
Biểu diễn âm nhạc
Trước khi có thể định nghĩa và huấn luyện một mô hình sinh, trước hết chúng ta phải tập hợp một tập dữ liệu. Với âm nhạc, dữ liệu có thể được biểu diễn dưới dạng liên tục hoặc rời rạc. Dạng liên tục phổ biến nhất là tín hiệu âm thanh, thường được lưu dưới dạng tệp WAV. Chúng ta sẽ không dùng dạng liên tục trong hướng dẫn này, nhưng bạn có thể đọc thêm trong phần Phụ lục. Các dạng rời rạc phổ biến gồm tệp Musical Instrument Digital Interface (MIDI), pianoroll và văn bản. Chúng ta sẽ tập trung vào tệp MIDI và trích xuất hai loại đối tượng ký hiệu: nốt và hợp âm.
Nốt
Một nốt là biểu diễn ký hiệu của âm thanh. Với mục đích của chúng ta, một nốt có thể được mô tả bởi cao độ và trường độ. Cao độ của nốt liên quan đến tần số dao động của sóng âm, đo bằng hertz (Hz). Nốt có cao độ cao hơn có sóng âm dao động nhiều hơn mỗi giây. Trường độ của nốt là độ dài khoảng thời gian nó được chơi.
Tệp MIDI biểu diễn cao độ của nốt bằng một số nguyên từ 0 đến 127. Nốt cũng có thể được biểu diễn bằng chữ cao độ và quãng tám. Trong cùng một quãng tám, các cao độ được sắp xếp từ tần số thấp đến cao như sau:
C
C#/D♭
D
E♭/D#
E
F
F#/G♭
G
A♭/G#
A
B♭/A#
B
Quãng tám được ký hiệu bằng chỉ số viết dưới, như số 4 trong A4 hoặc số 7 trong C7. Quãng tám cao hơn tương ứng với tần số cao hơn. Nếu lấy một cao độ bất kỳ Xi, thì cao độ Xi+1, cao hơn đúng một quãng tám, biểu diễn sóng âm có tần số gấp đôi Xi.
Bên cạnh cao độ, chúng ta cũng sẽ dùng trường độ của nốt. Trường độ là giá trị tương đối, được chuẩn hóa theo độ dài của nốt tròn (whole note). Nốt dài nhất là nốt “large”, dài gấp tám lần nốt tròn. Nốt ngắn nhất là nốt 1/256, dài bằng 1/256 độ dài nốt tròn.
Hợp âm
Hợp âm là sự kết hợp của từ hai nốt trở lên được chơi đồng thời trên cùng một nhạc cụ. Nếu xét nhạc đơn âm — tức nhạc được chơi trên một nhạc cụ duy nhất — ta có thể nhận diện hợp âm bằng giả định rằng mọi nốt được chơi cùng lúc thuộc cùng một hợp âm. Giả định này không còn đúng với nhạc đa âm, trong đó có từ hai nhạc cụ trở lên chơi cùng lúc.
Trong khối mã dưới đây, chúng ta cài đặt music21 rồi import mô-đun converter, dùng để phân tách tệp MIDI. Chúng ta sẽ tải và phân tích một bản guitar cổ điển của Mauro Giuliani. Sau đó áp dụng phương thức .chordify(), phương thức này tái tạo chuỗi hợp âm trong bản nhạc, giả định rằng các nốt được chơi đồng thời là một hợp âm.
!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 musicprint(score.show())Được hỗ trợ bởi
Sau khi dựng chuỗi hợp âm trong tệp MIDI, chúng ta có thể hiển thị dưới dạng bản nhạc bằng phương thức .show(). Việc này yêu cầu phần mềm có thể tạo và hiển thị bản nhạc, như MuseScore, có sẵn miễn phí.
Bản nhạc tạo từ MIDI
Ngoài ra, chúng ta có thể muốn xem và thao tác các phần tử nền tảng dưới dạng văn bản. Có thể làm điều này bằng phương thức .show('text'). Ở đầu mỗi dòng, ta thấy offset trong ngoặc, cho biết một phần tử xuất hiện sau bao nhiêu giây kể từ khi bắt đầu bản nhạc. Lưu ý chỉ một phần tử ở giây 0 là hợp âm. Các phần tử còn lại là siêu dữ liệu, bao gồm nhạc cụ dùng, tempo, giọng (key) và nhịp. Chúng ta sẽ sử dụng giọng ở phần sau, còn bây giờ bạn có thể bỏ qua các phần tử này.
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>Được hỗ trợ bởi
Sau phần siêu dữ liệu, các phần tử còn lại là hợp âm, được sắp theo thứ tự xuất hiện trong bài. Ví dụ, <music21.chord.Chord E3 G3> là hợp âm tạo bởi nốt E3 và G3. Ngoài ra, <music21.chord.Chord A3> là nốt A3 được chơi đơn lẻ.
Với một số mô hình, chúng ta sẽ dùng cả hợp âm và trường độ của nó, tức khoảng thời gian nó được chơi. Ta có thể lấy thông tin này bằng cách áp dụng phương thức .elements và truy cập thuộc tính .duration cho một hợp âm bất kỳ trong bản nhạc, như minh họa dưới đây.
print(score.elements[10])<music21.chord.Chord E3 A3>print(score.elements[10].duration)<music21.duration.Duration 1.0>Được hỗ trợ bởi
Mã hóa nốt và hợp âm
Xuyên suốt hướng dẫn, chúng ta sẽ tập trung vào các biểu diễn rời rạc của âm nhạc có thể mô tả bằng chuỗi nốt, hợp âm và trường độ liên quan. Nhớ rằng hợp âm là tổ hợp các nốt và định dạng MIDI cho phép dùng 128 nốt riêng lẻ. Nếu muốn mở rộng bộ đặc trưng để bao gồm mọi hợp âm hai nốt, kích thước tăng thêm 128!/[(128-2)!*2!] tức 8128 phần tử. Thêm hợp âm ba nốt tăng thêm 341.376, và thêm hợp âm bốn nốt tăng thêm 10.668.000.
Ví dụ trước đó có chứa nốt và hợp âm hai, ba, bốn nốt. Nếu dùng one-hot encoding và đưa vào tất cả đặc trưng, chúng ta sẽ cần dự đoán trên các vector chiều 11.017.632. Vì vậy, cần cân nhắc kỹ lựa chọn mã hóa trong bài toán sinh nhạc. Ta cũng có thể cân nhắc dùng chuyển giọng (key transposition) hoặc tăng cường dữ liệu, cả hai đều được bàn trong Phụ lục.
One-hot encoding biểu diễn các ký hiệu âm nhạc, như nốt và hợp âm, bằng các vector thưa với một số 1 tại vị trí tương ứng với nốt hoặc hợp âm cụ thể. Many-hot encoding biểu diễn hợp âm, gồm nhiều nốt, bằng cách đặt số 1 tại vị trí tương ứng với mỗi nốt.
Trong sơ đồ dưới đây, chúng ta so sánh many-hot và one-hot cho trường hợp có nốt và hợp âm hai nốt. Với many-hot, các nốt C4 và D4 được biểu diễn bởi vector 128 phần tử. Cả hai vector có số 0 ở mọi vị trí trừ các vị trí tương ứng C4 và D4, nơi có số 1. Hợp âm C4+D4, tức chơi đồng thời C4 và D4, được tạo bằng cách đặt số 1 ở cả hai vị trí C4 và D4.
Mã hóa nốt và hợp âm
Biểu diễn one-hot của nốt đơn và hợp âm hai nốt gồm các vector 8256 phần tử. Mỗi vector là thưa và chứa một số 1 tại vị trí tương ứng với một nốt hoặc hợp âm duy nhất. Lưu ý many-hot đòi hỏi dùng mô hình đa nhãn, nơi nhiều lớp có thể được dự đoán cho một đầu ra. Tuy nhiên, nó giảm đáng kể kích thước vector đầu vào và đầu ra.
Phần lớn mô hình trong tài liệu vẫn dùng one-hot. Tuy vậy, Briot, Hadjerest, và Pachet (2019) nêu một số mô hình sinh nhạc dùng many-hot, gồm các hệ thống RBMc, RNN-RBM và C-RBM, đều dựa trên kiến trúc restricted Boltzmann machine (RBM).
Multi-one-hot và multi-many-hot là tổng quát hóa của one-hot và many-hot cho trường hợp nhạc đa âm — tức nhiều nhạc cụ chơi đồng thời. Chúng ta sẽ giới hạn ở nhạc đơn âm và không bàn thêm các lựa chọn này.
Thu thập và chuẩn bị dữ liệu
Bước tiếp theo là thu thập một tập dữ liệu để huấn luyện mô hình sinh. Chúng ta sẽ dùng dữ liệu từ Dự án Mutopia, chứa 2124 tác phẩm ở định dạng MIDI thuộc phạm vi công cộng. Vì website được chia theo nhạc cụ, ta có thể viết một script Python khá đơn giản để quét toàn bộ các bản guitar, dùng để huấn luyện các mô hình trong hướng dẫn này. Xem tổng quan cách thực hiện trong Phụ lục.
Giả sử chúng ta đã lưu tất cả tệp .mid từ mục guitar vào một thư mục, save_dir. Bước tiếp theo là tải mỗi tệp .mid vào music21 dưới dạng đối tượng stream.
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 objectsfor song in songList:
score = converter.parse(save_dir+song)
originalScores.append(score)Được hỗ trợ bởi
Như một phần của chiến lược giảm chiều, chúng ta sẽ chỉ giữ các bài do Mauro Giuliani sáng tác. Có thể làm vậy bằng cách chỉnh sửa khối mã trên như sau.
# Identify list of MIDI files
songList = os.listdir(save_dir)
songList =[song for song in songList if song.lower().find('giuliani')>-1]Được hỗ trợ bởi
Chúng ta cần làm thêm bốn việc trước khi bắt đầu huấn luyện:
Loại bỏ nhạc đa âm và áp dụng .chordify() cho các đối tượng stream còn lại.
Trích xuất chuỗi nốt, hợp âm và trường độ.
Xây dựng ánh xạ chuyển nốt, hợp âm và trường độ thành số nguyên và ngược lại.
Chia các stream thành các chuỗi có độ dài cố định để dùng làm đầu vào cho mô hình.
Bắt đầu bằng việc loại bỏ nhạc đa âm khỏi tập dữ liệu. Ta có thể làm vậy bằng cách phân vùng mỗi đối tượng stream theo nhạc cụ rồi kiểm tra xem có đúng chỉ một phân vùng không — tức chỉ một nhạc cụ chơi. Chúng ta sẽ thực hiện trong khối mã dưới đây.
from music21 import instrument
# Define function to test whether stream is monotonicdefmonophonic(stream):try:
length =len(instrument.partitionByInstrument(stream).parts)except:
length =0return length ==1# Merge notes into chords
originalScores =[song.chordify()for song in originalScores]Được hỗ trợ bởi
Lưu ý nếu áp dụng .chordify() — như trong hình dưới — mà không loại nhạc đa âm, chúng ta sẽ vô tình gộp các nốt do nhạc cụ khác nhau chơi thành một hợp âm.
Chuyển nốt thành hợp âm
Giờ chúng ta cần trích xuất nốt, hợp âm và trường độ từ mỗi stream. Trước hết sẽ định nghĩa ba danh sách rỗng: originalChords, originalDurations, originalKeys. Ta có thể liệt kê qua danh sách các stream. Với mỗi stream, ta sẽ xác định và lưu tất cả nốt, hợp âm, trường độ cũng như giọng của tác phẩm.
from music21 import note, chord
# Define empty lists of lists
originalChords =[[]for _ in originalScores]
originalDurations =[[]for _ in originalScores]
originalKeys =[]# Extract notes, chords, durations, and keysfor i, song inenumerate(originalScores):
originalKeys.append(str(song.analyze('key')))for element in song:ifisinstance(element, note.Note):
originalChords[i].append(element.pitch)
originalDurations[i].append(element.duration.quarterLength)elifisinstance(element, chord.Chord):
originalChords[i].append('.'.join(str(n)for n in element.pitches))
originalDurations[i].append(element.duration.quarterLength)print(str(i))Được hỗ trợ bởi
Chúng ta sẽ tiếp tục giảm chiều ở khối mã dưới bằng cách chỉ giữ các bản có giọng Đô trưởng (C major), vốn là giọng phổ biến nhất trong tập dữ liệu. Từ đây về sau trong hướng dẫn, chúng ta sẽ dùng từ “hợp âm” để chỉ cả hợp âm và nốt.
# Create list of chords and durations from songs in C major
cMajorChords =[c for(c, k)inzip(originalChords, originalKeys)if(k =='C major')]
cMajorDurations =[c for(c, k)inzip(originalDurations, originalKeys)if(k =='C major')]Được hỗ trợ bởi
Bước kế là xác định tập hợp duy nhất các hợp âm và trường độ. Sau đó xây dựng các từ điển ánh xạ chúng thành số nguyên. Nếu in ra số lượng nốt và hợp âm duy nhất, ta sẽ thấy các bước giảm chiều đã đưa con số xuống mức có thể xử lý được: 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 chordsprint(len(uniqueChords))267# Print number of unique durationsprint(len(uniqueDurations))9Được hỗ trợ bởi
Sau khi huấn luyện mô hình và có dự đoán, chúng ta sẽ muốn ánh xạ các dự đoán số nguyên về lại nốt, hợp âm và trường độ. Hãy đảo ngược các từ điển chordToInt và durationToInt như dưới đây.
# Invert chord and duration dictionaries
intToChord ={i: c for c, i in chordToInt.items()}
intToDuration ={i: c for c, i in durationToInt.items()}Được hỗ trợ bởi
Cuối cùng, ta có thể định nghĩa các chuỗi huấn luyện, gồm 32 nốt và hợp âm liên tiếp, cùng trường độ tương ứng. Trước hết, chúng ta sẽ làm điều này cho các mô hình kiểu autoencoder, nơi bộ đặc trưng và mục tiêu là như nhau.
# Define sequence length
sequenceLength =32# Define empty arrays for train data
trainChords =[]
trainDurations =[]# Construct training sequences for chords and durationsfor s inrange(len(cMajorChords)):
chordList =[chordToInt[c]for c in cMajorChords[s]]
durationList =[durationToInt[d]for d in cMajorDurations[s]]for i inrange(len(chordList)- sequenceLength):
trainChords.append(chordList[i:i+sequenceLength])
trainDurations.append(durationList[i:i+sequenceLength])Được hỗ trợ bởi
Sinh nhạc
Trong phần này, chúng ta sẽ dùng tập dữ liệu đã thu thập và chuẩn bị để sinh nhạc. Chúng ta sẽ xét ba mô hình sinh nhạc khác nhau, bắt đầu từ đơn giản nhất:
Autoencoder
Variational Autoencoder (VAE)
Mô hình Long Short-Term Memory (LSTM)
Autoencoder
Một autoencoder gồm hai mạng, xếp chồng theo chiều dọc và nối với nhau bằng một vector tiềm ẩn (latent). Dữ liệu đầu vào trước hết được đưa vào mô hình bộ mã hóa (encoder), thường gồm một hoặc nhiều lớp dense. Lớp cuối của encoder được gọi là vector tiềm ẩn. Đây là nút thắt buộc các đặc trưng trích xuất trong encoder phải nén vào một số ít đặc trưng tiềm ẩn.
Vector tiềm ẩn nối với mô hình bộ giải mã (decoder), có nhiệm vụ upsample hoặc giải nén dữ liệu. Số nút trong mỗi lớp decoder kế tiếp tăng dần. Lớp đầu ra cuối cùng có cùng kích thước với lớp đầu vào của encoder.
Trái với các mô hình phân biệt cố gắng xác định biên quyết định, autoencoder được huấn luyện để tái tạo dữ liệu đầu vào — trong trường hợp này là một bài hát — chính xác nhất có thể, chịu ràng buộc bởi kích thước của vector tiềm ẩn. Vì thế, autoencoder dùng chính đầu vào làm mục tiêu.
Chúng ta sẽ dùng autoencoder để xây dựng mô hình sinh nhạc đầu tiên. Để đơn giản tối đa, chúng ta áp dụng kiến trúc MiniBach được giới thiệu trong Briot, Hadjerest, và Pachet (2019) như một giản lược của mô hình DeepBach. Một ví dụ khác về autoencoder sinh nhạc là hệ thống DeepHear.
Dữ liệu đầu vào, như chúng ta đã xây dựng, gồm các vector one-hot biểu diễn nốt và hợp âm. Tạm thời bỏ qua trường độ. Theo kiến trúc MiniBach, trước hết chúng ta sẽ làm phẳng vector đầu vào.
# 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)Được hỗ trợ bởi
Tiếp theo, ta định nghĩa số mẫu, nSamples, số hợp âm và nốt, nChords, và kích thước đầu vào, inputDim. Ta cũng đặt số đặc trưng tiềm ẩn, latentDim, bằng 2. Dùng ít chiều tiềm ẩn sẽ giảm hiệu năng nhưng cho phép trực quan hóa không gian tiềm ẩn dễ dàng.
# 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Được hỗ trợ bởi
Bây giờ ta định nghĩa kiến trúc mô hình, bắt đầu với lớp đầu vào cho mạng encoder, encoderInput, và lớp đầu vào cho mạng decoder, latent. Lưu ý lớp latent cũng là đầu ra của mạng encoder. Tiếp theo, định nghĩa một lớp dense, encoded, nối đầu vào tới vector tiềm ẩn và một lớp dense khác, decoded, nối vector tiềm ẩn tới vector đầu ra.
Cuối cùng, định nghĩa mô hình decoder, decoder, và autoencoder, autoencoder. Lưu ý .Model() có tham số inputs và outputs. Với mô hình decoder, ta truyền vector latent làm input và mạng decoded làm output. Với autoencoder đầy đủ, dùng encoderInput làm input và truyền mô hình decoder, được đánh giá bằng đầu ra của mạng encoder, encoded, làm input.
Kiến trúc của một autoencoder tổng quát được minh họa dưới đây. Nửa trên là encoder, nửa dưới là decoder. Chúng liên kết với nhau bởi vector tiềm ẩn.
Kiến trúc mạng Autoencoder
Sau khi định nghĩa kiến trúc cho decoder và autoencoder, bước còn lại là biên dịch mô hình rồi huấn luyện bằng phương thức .fit(). Có hai chi tiết quan trọng:
Giá trị đầu vào sẽ gồm 0 và 1, còn giá trị dự đoán là số thực trong khoảng 0 đến 1. Chúng ta có thể dùng hàm mất mát binary_crossentropy.
Cả giá trị đầu vào và mục tiêu đều giống nhau: trainChordsFlat.
# Compile autoencoder model
autoencoder.compile(loss ='binary_crossentropy', learning_rate =0.01, optimizer='rmsprop')# Train autoencoder
autoencoder.fit(trainChordsFlat, trainChordsFlat, epochs =500)Được hỗ trợ bởi
Bước cuối là sinh nhạc. Vì chúng ta đặt độ dài chuỗi bằng 32, autoencoder sẽ nhận 32 hợp âm và nốt làm đầu vào và tạo ra một “bài” có độ dài cố định gồm chuỗi 32 hợp âm và nốt. autoencoder được huấn luyện để tạo đầu ra rất giống đầu vào; tuy nhiên, mục tiêu của chúng ta là tạo nhạc mới. Để làm điều này, chúng ta sẽ truyền một vector tiềm ẩn ngẫu nhiên vào mô hình decoder, vốn được định nghĩa như một mạng con của mô hình autoencoder.
# Generate chords from randomly generated latent vector
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)Được hỗ trợ bởi
Lưu ý chúng ta đã reshape đầu ra của autoencoder thành một mảng có cùng kích thước với đầu vào ban đầu. Sau đó lấy argmax() theo chiều thứ nhất của mảng. Điều này trả về một giá trị số nguyên tương ứng với hợp âm, mà chúng ta sẽ xác định trong khối mã dưới đây.
# Identify chord sequence from integer sequence
chordSequence =[intToChord[c]for c in generatedChords]Được hỗ trợ bởi
Cuối cùng, chúng ta có thể tạo một đối tượng stream bằng music21, đặt nhạc cụ là guitar, nối chuỗi hợp âm do mô hình tạo ra, rồi xuất tất cả thành tệp MIDI.
# 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 objectfor j inrange(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')Được hỗ trợ bởi
Ảnh gif dưới đây cho thấy một phần quá trình huấn luyện của autoencoder. Bản nhạc được tạo ở mỗi bước là phép tái tạo tương ứng với một vector trạng thái tiềm ẩn cụ thể. Mỗi khung hình đại diện cho 5 epoch huấn luyện.
Autoencoder: Tiến hóa chuỗi nốt và hợp âm
Chúng ta cũng có thể trực quan hóa chuyển động trong không gian tiềm ẩn trong huấn luyện, như trong gif dưới. Lưu ý không gian tiềm ẩn hẹp và trôi dạt theo epoch. Điều này khiến việc rút một trạng thái tiềm ẩn ngẫu nhiên chất lượng cao trở nên khó, và sẽ đặc biệt rắc rối khi dùng vector tiềm ẩn nhiều chiều hơn. Ở phần tiếp theo, chúng ta sẽ cho thấy cách khắc phục bằng variational autoencoder (VAE).
Autoencoder: Tiến hóa không gian tiềm ẩn
Cuối cùng, chúng ta có thể xem đầu ra âm thanh từ autoencoder. Trước tiên là chuỗi hợp âm từ bài gốc, được lấy ngẫu nhiên từ tập huấn luyện. Sau đó, chúng ta sinh một chuỗi hợp âm từ autoencoder dùng vector tiềm ẩn gắn với chuỗi hợp âm của bài gốc, cho ra bài phía dưới.
Ví dụ: Bài gốc
Ví dụ: Bài sinh ra
Variational Autoencoder
Mặc dù autoencoder phù hợp với các tác vụ khử nhiễu, nén và giải nén, chúng không hoạt động tốt như các mô hình sinh. Variational autoencoder (VAE), được giới thiệu bởi Kingma và Welling (2017), được thiết kế để khắc phục điểm yếu của autoencoder trong vai trò mô hình sinh và là một trong những phương pháp được dùng phổ biến nhất để sinh nhạc. Briot, Hadjerest, và Pachet (2019) xác định bốn hệ thống sinh nhạc xây dựng trên VAE: MusicVAE, VRAE, VRASH và GSLR-VAE. Chúng cải thiện autoencoder theo hai khía cạnh:
Thay vì ánh xạ mỗi bộ đầu vào tới một điểm trong không gian tiềm ẩn, VAE ánh xạ mỗi bộ đầu vào tới một phân phối chuẩn, đặc trưng bởi kỳ vọng và phương sai.
Thay vì để không gian tiềm ẩn có bất kỳ kỳ vọng và phương sai nào, VAE ép kỳ vọng và log tự nhiên của phương sai tiến gần 0.
Ta có thể thực hiện thay đổi đầu tiên bằng cách thay vector trạng thái tiềm ẩn trong autoencoder bằng ba lớp:
Một lớp kỳ vọng (mean)
Một lớp (log) phương sai
Một lớp lấy mẫu
Mỗi bộ đầu vào ánh xạ tới một kỳ vọng và log phương sai, đủ để đặc trưng một phân phối chuẩn. Mô hình sau đó rút ngẫu nhiên các điểm từ phân phối ở lớp lấy mẫu.
Khối mã dưới định nghĩa một hàm để tạo lớp lấy mẫu. Hàm nhận kỳ vọng và log phương sai làm đầu vào. Sau đó dùng mô-đun random của tensorflow để rút một tensor điểm, epsilon, từ phân phối chuẩn tắc, có cả kỳ vọng và log phương sai bằng 0. Tiếp theo, chúng ta muốn biến đổi các mẫu rút được này thành các mẫu từ những phân phối đặc trưng bởi tham số mean và logVar. Để làm vậy, trước hết cần nhân các mẫu epsilon với độ lệch chuẩn của các phân phối tương ứng. Ta tính độ lệch chuẩn bằng cách chia logVar cho 2 rồi lấy mũ. Sau đó cộng thêm kỳ vọng mean.
# Define function to generate sampling layer.defsampling(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
Được hỗ trợ bởi
Giờ ta có thể chỉnh sửa mô hình autoencoder để tích hợp các lớp mean, logVar và sampling. Lưu ý lớp encoded giờ nhận hai đầu vào và lớp encoder có ba đầu ra.
# 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))Được hỗ trợ bởi
Tương tự mô hình autoencoder, trạng thái tiềm ẩn là đầu ra của lớp encoded. Tuy nhiên có một khác biệt quan trọng: thay vì ánh xạ mỗi bộ đầu vào tới một trạng thái tiềm ẩn duy nhất, chúng ta dùng lớp sampling để ánh xạ chúng tới các giá trị ngẫu nhiên rút từ phân phối chuẩn với tham số mean và logVar. Điều này có nghĩa cùng một đầu vào sẽ gắn với một phân phối các trạng thái tiềm ẩn.
Bước cuối là điều chỉnh hàm mất mát để áp đặt ràng buộc lên lựa chọn mean và logVar của mô hình. Cụ thể, chúng ta sẽ tính độ lệch Kullback–Leibler (KL) giữa mỗi phân phối do các lớp mean và logVar xác định với phân phối chuẩn tắc, có kỳ vọng và log phương sai bằng 0. Các kỳ vọng và log phương sai càng xa 0, thành phần mất mát KL càng cao.
Một câu hỏi tự nhiên là vì sao ta lại ép mỗi phân phối tiến gần chuẩn tắc. Lý do là chúng ta muốn lấy mẫu các trạng thái tiềm ẩn và dùng chúng để sinh nhạc. Nếu áp dụng phạt KL vào hàm mất mát, điều này sẽ cho phép dùng các mẫu độc lập từ phân phối chuẩn tắc để thu được các trạng thái tiềm ẩn có khả năng sinh ra những bản nhạc chất lượng cao.
Trong khối mã dưới, chúng ta cho thấy cách chỉnh hàm mất mát để đưa thêm hạng tử KL. Thành phần đầu tiên của hàm mất mát là mất mát binary crossentropy, tính trên đầu vào và đầu ra của mô hình vae. Đây cũng là thứ chúng ta dùng cho autoencoder. Nó được gọi là “mất mát tái tạo” vì nó phạt khi ta không tái tạo được bài gốc. Hạng tử KL là trung bình độ lệch KL giữa các phân phối không gian tiềm ẩn và chuẩn tắc. Chúng ta kết hợp cả hai để tính tổng mất mát, vaeLoss, thêm vào mô hình, biên dịch rồi huấn luyện.
# 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)Được hỗ trợ bởi
Ở phần trước, chúng ta cho thấy không gian tiềm ẩn của autoencoder không “đẹp”. Một phần mục đích của VAE là khắc phục khiếm khuyết thiết kế này trong bối cảnh sinh. Hình dưới cho thấy không gian tiềm ẩn của VAE. Ta thấy có độ phân tán đáng kể trên cả hai đặc trưng. Hơn nữa, cả hai đều tập trung quanh 0 và không trôi dạt theo epoch. Điều này sẽ giúp việc lấy mẫu ngẫu nhiên một trạng thái tiềm ẩn chất lượng trở nên dễ dàng hơn nhiều.
VAE: Tiến hóa không gian tiềm ẩn
Cuối cùng, chúng ta chuyển sang sinh nhạc. Chúng ta rút một vector tiềm ẩn ngẫu nhiên từ phân phối chuẩn tắc, truyền vào decoder, reshape đầu ra và lấy argmax của mỗi cột. Điều này trả về chuỗi 32 số nguyên, tương ứng với nốt và hợp âm, mà chúng ta sẽ xác định bằng từ điển intToChord. Sau đó, tạo một đối tượng stream, thêm guitar làm nhạc cụ, nối chuỗi nốt và hợp âm vào stream, rồi xuất thành tệp MIDI.
# 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 filefor j inrange(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')Được hỗ trợ bởi
Ba bài dưới đây được sinh bởi mô hình. Hai bài đầu dựa trên các trạng thái tiềm ẩn gắn với chuỗi hợp âm trong tập huấn luyện. Bài cuối là tổ hợp tuyến tính của trạng thái tiềm ẩn của bài #1 và #2. Vì VAE ép không gian tiềm ẩn “đẹp”, chúng ta có thể khá chắc rằng tổ hợp tuyến tính của các trạng thái tiềm ẩn sẽ cho một trạng thái tiềm ẩn chất lượng cao.
Ví dụ: Bài sinh #1
Ví dụ: Bài sinh #2
Ví dụ: Tổ hợp tuyến tính của bài #1 và #2
Một điều có thể quan sát từ nhạc sinh bởi autoencoder và VAE là thiếu giai điệu mạnh. Ta có thể cải thiện bằng cách thêm nhiều lớp vào mô hình và tăng kích thước tập huấn luyện. Hoặc ta có thể dùng một mô hình được thiết kế để xử lý dữ liệu tuần tự, như chúng ta sẽ làm ở phần sau.
Long Short-Term Memory (LSTM)
Mô hình cuối cùng chúng ta xét là LSTM. Đây là một dạng mạng nơ-ron hồi tiếp (RNN) được điều chỉnh để ngăn vấn đề tiêu biến gradient. Briot, Hadjerest, và Pachet (2019) nhận thấy các mô hình hồi tiếp là loại được dùng phổ biến nhất cho mục đích sinh nhạc. Họ liệt kê hơn 20 hệ thống sinh nhạc dựa vào RNN hoặc LSTM, bao gồm BachBot, DeepBach, Performance-RNN và Hexahedria.
Dùng LSTM đem lại hai lợi ích so với mô hình dựa trên autoencoder:
Chúng được thiết kế để xử lý dữ liệu tuần tự.
Chúng có thể sinh chuỗi nhạc có độ dài bất kỳ.
Khác với autoencoder và VAE dùng đầu vào làm mục tiêu, các mô hình LSTM sinh nhạc được huấn luyện để dự đoán nốt hoặc hợp âm tiếp theo trong chuỗi. Vì thế, chúng ta cần dựng lại tập train và target, như trong khối mã dưới. Vì dùng LSTM cũng giảm số tham số cần huấn luyện, chúng ta sẽ thêm cả trường độ vào mô hình.
# Set sequence length
sequenceLength =32# Define empty array for train data
trainChords =[]
trainDurations =[]
targetChords =[]
targetDurations =[]# Construct train and target sequences for chords and durationsfor s inrange(len(cMajorChords)):
chordList =[chordToInt[c]for c in cMajorChords[s]]
durationList =[durationToInt[d]for d in cMajorDurations[s]]for i inrange(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])Được hỗ trợ bởi
Lưu ý trainChords và trainDurations được định nghĩa giống như trước đó. Chúng ta chỉ thêm targetChords và targetDurations, gồm hợp âm và trường độ theo sau chuỗi hợp âm và trường độ trong tập train. Tiếp theo, ta đặt số mẫu, số hợp âm và trường độ, cũng như kích thước đầu vào. Chúng ta cũng sẽ dùng các lớp embedding riêng cho hợp âm và trường độ.
# 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Được hỗ trợ bởi
Bước tiếp theo là định nghĩa kiến trúc mô hình. Chúng ta sẽ dùng mô hình có hai đầu vào và hai đầu ra, để có thể dự đoán cả hợp âm và trường độ. Trước tiên định nghĩa các lớp đầu vào, chordInput và durationInput, rồi truyền chúng vào các lớp embedding, chordEmbedding và durationEmbedding. Đây là các bảng tra cứu gán một vector 64 phần tử cho mỗi hợp âm hoặc trường độ. Embedding cho phép thay thế biểu diễn vector thưa, nơi đầu vào được one-hot, bằng biểu diễn đặc, nơi các nốt và trường độ liên quan có giá trị gần nhau hơn.
Đầu ra của hai lớp embedding được nối trong mergeLayer. Sau đó, truyền đầu ra này vào hai lớp LSTM. Lớp đầu tiên đặt tham số return_sequences bằng True. Nếu để tham số này False như mặc định, các cell LSTM sẽ chỉ trả về trạng thái cuối cùng thay vì các trạng thái trung gian theo chuỗi. Nghiên cứu gần đây cho thấy tận dụng các trạng thái trung gian có thể cải thiện hiệu năng. Điều này thường được làm trong bối cảnh một lớp attention. Cuối cùng, chúng ta truyền đầu ra của lớp LSTM vào lớp dense, rồi tới hai lớp đầu ra: chordOutput và durationOutput. Sau đó định nghĩa mô hình API chức năng keras bằng cách chỉ định hai lớp đầu vào và hai lớp đầu ra.
Khi mô hình đã được định nghĩa, chúng ta có thể biên dịch và bắt đầu huấn luyện. Chúng ta cố gắng giữ mọi thứ đơn giản nhất có thể, nhưng với nhiều ứng dụng, sẽ hợp lý khi thêm các lớp regularization, dense và LSTM bổ sung. Lưu ý mô hình có hai lớp đầu vào và hai lớp đầu ra, như trong sơ đồ dưới. Bạn có thể tìm hiểu thêm về mô hình có nhiều đầu vào và đầu ra trong khóa Giới thiệu về TensorFlow trong Python.
Kiến trúc mô hình LSTM
# Compile the model
lstm.compile(loss='categorical_crossentropy', optimizer='rmsprop')# Train the model
lstm.fit([trainChords, trainDurations],[targetChords, targetDurations],
epochs=500, batch_size=64)Được hỗ trợ bởi
Sau khi quá trình huấn luyện hoàn tất, chúng ta có thể sinh bài mới bằng mô hình LSTM. Ta sẽ làm điều này bằng cách cung cấp một chuỗi khởi tạo gồm 32 hợp âm và trường độ, cho phép đưa ra dự đoán đầu tiên. Sau đó nối các dự đoán đó vào chuỗi hợp âm và trường độ, cho phép đưa ra dự đoán tiếp theo dựa trên 32 hợp âm và trường độ cuối cùng của chuỗi đã cập nhật. Một lợi thế của LSTM là chúng ta có thể lặp vô hạn, cho phép sinh bài có độ dài tùy ý.
# 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 durationsdefpredictChords(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 predictionfor j inrange(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
Được hỗ trợ bởi
Bước cuối là xuất nhạc đã sinh ra thành tệp MIDI bằng music21. Lưu ý mã gần như giống hệt với phần dùng autoencoder, nhưng có một điểm khác quan trọng: giờ chúng ta nối cả hợp âm và trường độ vào đối tượng stream.
# Create stream object and add guitar as instrument
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())# Add notes and durations to streamfor j inrange(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')Được hỗ trợ bởi
Với các mô hình autoencoder và VAE, chúng ta gặp khó khi tạo giai điệu và chỉ có thể xuất các bài có độ dài cố định. Dùng LSTM, ta có thể sinh chuỗi đầu ra có độ dài tùy ý bằng cách lặp qua các dự đoán, như trong khối mã trên. Việc tạo giai điệu hay hơn cũng sẽ tương đối dễ hơn. Chúng ta sẽ thấy cả hai đặc điểm này trong các ví dụ dưới, mỗi ví dụ được gieo hạt bởi một chuỗi khởi tạo hợp âm và trường độ từ một mẫu trong tập huấn luyện.
Ví dụ: Bài sinh #1
Ví dụ: Bài sinh #2
Ví dụ: Bài sinh #3
Các vấn đề bổ sung
Dữ liệu
Một trong những thách thức lớn đối với việc phát triển các mô hình học máy cho âm nhạc là khả năng tiếp cận tập dữ liệu, vì âm nhạc thường không được phép tải về tự do và hợp pháp. Trên thực tế, điều này đã ngăn cản cộng đồng học thuật đồng thuận về một tập dữ liệu chuẩn để thực hành, như MNIST cho phân loại ảnh. Tuy nhiên, có một vài dự án cung cấp hoặc âm thanh thô, ký hiệu nhạc, hoặc các đặc trưng âm nhạc dẫn xuất cho số lượng lớn bài hát. Chúng tôi liệt kê một phần các dự án đó dưới đây.
Dự án Mutopia. Lưu trữ nhạc phạm vi công cộng và creative commons ở định dạng MIDI, PDF và LilyPond. Hiện có hơn 2000 bài và được tổ chức theo nhạc cụ, nhà soạn nhạc, phong cách và bộ sưu tập.
Million Song Dataset. Chứa đặc trưng âm thanh và siêu dữ liệu cho hơn 300GB các bản nhạc phổ biến, nhưng không có tệp âm thanh.
Free Music Archive. Chứa âm thanh, nhãn thể loại và đặc trưng cho hơn 100.000 bản nhạc được phép tải xuống tự do và hợp pháp.
Lấy mẫu vs. tất định
Khi sinh nhạc bằng mô hình LSTM, chúng ta dùng cách tất định để chọn hợp âm và trường độ. Mô hình dự đoán xác suất cho mỗi trong số 267 hợp âm và 9 trường độ, và chúng ta chọn hợp âm và trường độ có xác suất cao nhất. Tương tự, với autoencoder và VAE, chúng ta chọn các hợp âm có giá trị đầu ra cao nhất.
Một cách thay thế là lấy mẫu từ phân phối đầu ra, thay vì chọn hợp âm và trường độ có xác suất cao nhất. Cách này sẽ khiến quá trình sinh nhạc trở nên ngẫu nhiên thay vì tất định. Có nghĩa là các mô hình autoencoder hoặc VAE với cùng vector tiềm ẩn sẽ sinh ra bài khác nhau mỗi lần chạy. Tương tự, cùng một chuỗi đầu vào cho mô hình LSTM có thể sinh ra các bài khác nhau.
Khối mã dưới cho thấy cách chuyển sang sinh hợp âm và trường độ ngẫu nhiên bằng cách chỉnh hàm predictChords(). Lưu ý chúng ta đã thay phép argmax() bằng phép lấy mẫu dùng các xác suất để rút ngẫu nhiên từ các vector hợp âm và trường độ.
# Define function to generate chords and durations stochasticallydefpredictChords(chordSequence, durationSequence):
predictedChords, predictedDurations = model.predict([chordSequence, durationSequence])return np.random.choice(range(nChords), p = predictedChords), np.random.choice(range(nDurations), p = predictedDurations)Được hỗ trợ bởi
Để thảo luận mở rộng về lợi ích của tiếp cận ngẫu nhiên và tất định trong sinh nhạc, xem mục 6.6 của Briot, Hadjerest, và Pachet (2019).
Embeddings
Khi huấn luyện mô hình LSTM, chúng ta dùng một lớp embedding, ánh xạ mỗi hợp âm và trường độ đầu vào tới một vector 64 phần tử đặc. Dùng embedding cho phép chúng ta tránh dùng các vector thưa, nhiều chiều làm đầu vào. Thay vào đó, dùng biểu diễn ít chiều cho phép nhận diện quan hệ giữa các hợp âm khác nhau.
Trong nhiều trường hợp, việc huấn luyện một lớp embedding có thể không hợp lý. Bởi chúng ta thường không có tập huấn luyện đủ lớn khi làm bài toán sinh nhạc. Dùng embedding tiền huấn luyện hoặc embedding tạo bằng học không giám sát — như thường làm với bài toán phân loại và sinh văn bản — sẽ cho phép huấn luyện mô hình khi khác đi sẽ không khả thi.
Chúng ta đã bàn về autoencoder, VAE và LSTM như các lựa chọn sinh nhạc. Một lựa chọn khác, đã chứng tỏ thành công trong các miền sinh khác, là mạng đối sinh (GAN). GAN hoạt động bằng cách kết hợp một mạng phân biệt với một mạng sinh. Mạng sinh tạo ra các mẫu, như chuỗi hợp âm do các mô hình LSTM và autoencoder tạo. Các mẫu này sau đó được kết hợp trong một tập huấn luyện cùng với các mẫu thật — tức chuỗi hợp âm từ các bài thực. Mô hình phân biệt cố gắng phân loại mẫu là thật hay do bộ sinh tạo ra. Bộ sinh cố gắng đánh lừa mô hình phân biệt để nó phân loại nhầm các mẫu do mình tạo ra là bài thật. Mô hình được huấn luyện tới khi đạt trạng thái cân bằng tiến hóa ổn định, nơi cả bộ phân biệt lẫn bộ sinh đều không thể đạt thêm lợi thế.
Với những ai quan tâm áp dụng GAN cho tác vụ sinh nhạc, có thể tham khảo các nguồn sau:
MuseGAN là một dự án hướng đến sinh nhạc đa âm bằng GAN. Dự án đi kèm các mô hình tiền huấn luyện và 174.154 bài đã được làm sạch và chuẩn bị ở định dạng pianoroll.
Magenta là dự án mã nguồn mở hướng tới ứng dụng mô hình sinh cho các tác vụ sáng tạo, bao gồm tạo nhạc. Bạn có thể khám phá bộ công cụ của Magenta qua một notebook Colab.
Tài nguyên
Với những ai muốn tìm hiểu thêm về TensorFlow 2.0, hãy xem khóa Giới thiệu về TensorFlow trong Python trên DataCamp. Để có bài tổng quan toàn diện và gần đây về các phương pháp sinh nhạc bằng học sâu, xem Briot, Hadjerest, và Pachet (2019). Để có thêm các hướng dẫn về sinh nhạc với mô hình học sâu, xem 1, 2, 3, 4, và 5, cung cấp một cách tiếp cận mang tính giáo trình đầy đủ về các mô hình sinh.
Phụ lục
Biểu diễn liên tục
Tệp WAV là dạng biểu diễn liên tục phổ biến nhất vì chứa tín hiệu âm thanh thô chưa nén. Điều này khác với tệp mp3, ví dụ, nén kích thước tệp bằng cách loại bỏ một phần thông tin về tín hiệu âm thanh.
Khối mã dưới đây cho thấy cách trích xuất tần số lấy mẫu, rate, và tín hiệu âm thanh thô, signal, từ một tệp WAV chứa một tác phẩm của Mozart. Tần số lấy mẫu là số lần lấy mẫu tín hiệu âm thanh mỗi giây. Trong trường hợp của chúng ta, giá trị này là 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))Được hỗ trợ bởi
Tín hiệu âm thanh thô, signal, là một tensor S x C, trong đó S là số mẫu và C là số kênh. Để đơn giản, chúng ta xét trường hợp C = 1. Biểu đồ dưới đây cho thấy một khoảng ba giây của signal.
Nói chung, dùng tín hiệu âm thanh thô để huấn luyện mô hình khó hơn đáng kể so với dùng biểu diễn rời rạc. Một khảo sát gần đây về các phương pháp học sâu cho sinh nhạc đã xem xét 32 phương pháp khác nhau trong tài liệu. Trong số đó, chỉ có ba phương pháp dùng biểu diễn liên tục:
Một phương pháp có thể dùng cho mục đích giảm chiều hoặc tăng cường dữ liệu là chuyển giọng. Việc này bao gồm dịch cao độ của mỗi nốt theo một khoảng cố định. Nếu, chẳng hạn, một bài có chuỗi trong đó cao độ C3 theo sau bởi D3 và ta dùng phép chuyển giọng tăng tất cả phần tử lên hai cao độ, thì C3 sẽ thành D3 và D3 sẽ thành E3. Với các mô hình dùng chuyển giọng, xem Lim, Rhyu, và Lee (2018) và Sturm et al. (2016).
Nếu dùng chuyển giọng nhằm giảm chiều, mục tiêu của chúng ta là dịch nốt và hợp âm sao cho tối thiểu hóa tổng số nốt và hợp âm duy nhất trong tập dữ liệu. Vì âm nhạc thường được viết quanh những nhóm cao độ gọi là giọng (key) nghe hài hòa khi chơi cùng nhau, chúng ta có thể thuận tiện chuyển mỗi bài trong tập dữ liệu về cùng một giọng.
Lần này, chúng ta sẽ import ba mô-đun phụ từ music21:
converter, dùng để tải tệp MIDI và chuyển thành đối tượng stream.
pitch, dùng để tạo cao độ mục tiêu.
interval, dùng để tính khoảng cách giữa giọng gốc và giọng đích.
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 keyprint(newScore.analyze('key'))
F major
Được hỗ trợ bởi
Trước hết chúng ta áp dụng phương thức .analyze('key') cho score, phương thức này xác định giọng của đối tượng stream, là Đô trưởng. Sau đó, ta tính khoảng cách giữa Đô trưởng và giọng mục tiêu, Fa trưởng. Lưu ý chúng ta truy cập cao độ của đối tượng key bằng .tonic. Hơn nữa, chúng ta tạo cao độ đích bằng pitch.Pitch('F'). Sau đó gán đầu ra cho keyInterval, cho biết khoảng cách giữa giọng gốc và giọng đích.
Cuối cùng, chúng ta chuyển giọng bằng phương thức .transpose() và truyền khoảng cách, keyInterval. In giọng mới xác nhận đó là Fa trưởng.
Chuyển quãng tám
Nếu muốn tiến xa hơn chuyển giọng, chúng ta cũng có thể chuyển quãng tám lên cao hoặc xuống thấp. Thay vì chuyển Đô trưởng sang Fa trưởng, chẳng hạn, chúng ta có thể chuyển Đô trưởng sang Đô trưởng ở quãng tám thấp hơn hoặc cao hơn. Nếu nhắm tới cùng một tập quãng tám cho mỗi bài, chúng ta sẽ buộc tất cả nốt và hợp âm cụm lại gần nhau, giảm chiều của bài toán và làm cho quá trình huấn luyện đơn giản hơn. Trong khối mã trên, chúng ta có thể đạt được điều này bằng cách chỉ định quãng tám của cao độ. Ví dụ, thay vì pitch.Pitch('F') để tính khoảng cách, ta có thể dùng pitch.Pitch('F3').
Bất biến theo giọng và quãng tám
Một lựa chọn tự nhiên thay cho giảm chiều là tăng cường dữ liệu. Tức là, thay vì giảm tập các đầu vào và đầu ra có thể có, chúng ta mở rộng tập dữ liệu bằng cách thay đổi dữ liệu hiện có. Chúng ta cũng có thể làm điều này bằng chuyển giọng và chuyển quãng tám. Tuy nhiên, thay vì chuyển tất cả bài về cùng giọng và gom nốt ở các quãng tám kề nhau, chúng ta sẽ chuyển tất cả bài sang mọi giọng và mọi quãng tám. Điều này giúp đạt tính bất biến theo giọng và quãng tám trong mô hình. Xem Briot, Hadjerest, và Pachet (2019) để thảo luận việc dùng chuyển giọng cho hai mục đích khác nhau.
Thu thập dữ liệu
Chúng ta sẽ thu thập các tệp MIDI để huấn luyện mô hình từ Project Mutopia. Trước khi quét, đầu tiên chúng ta sẽ kiểm tra tệp robots tại https://www.mutopiaproject.org/robots.txt, để đảm bảo tôn trọng mọi yêu cầu về cách xử lý tương tác tự động với website. Tại thời điểm viết hướng dẫn này, tệp robots chứa nội dung trong khối mã dưới. User-agent: * cho biết quy tắc sau áp dụng cho tất cả người dùng tương tác với website. Disallow:, theo sau là rỗng, nghĩa là không có hạn chế nào áp dụng cho việc quét.
# Allow crawling of all content
User-agent:*
Disallow:Được hỗ trợ bởi
Trước hết chúng ta import urlopen và urlretrieve từ urllib.request, dùng để gửi yêu cầu GET cho từng trang trong mục guitar rồi tải mỗi tệp MIDI được liên kết trên trang. Chúng ta import BeautifulSoup để phân tích HTML trả về bởi urlopen, cho phép xác định các liên kết tệp MIDI. Cuối cùng, dùng mô-đun time để tạm dừng 10 giây giữa các lần tải nhằm tránh gây áp lực lên tài nguyên của website.
Mỗi bước của vòng lặp chính sẽ kiểm tra liệu số liên kết trên trang linkCount có vượt quá 0 không. Nếu có, nó sẽ dựng URL cho trang tiếp theo để truy cập, url, dùng các chuỗi url0, url1 và số bài hát, songNumber. Sau đó mở URL, phân tích HTML, tìm tất cả liên kết trên trang, đặt lại linkCount về 0, rồi duyệt qua từng liên kết trong danh sách. Nếu một liên kết chứa chuỗi con .mid, thì nó trỏ tới tệp MIDI và được tải về bằng urlretrieve() vào thư mục save_dir. Cuối cùng, chúng ta tăng songNumber thêm 10, vì mỗi trang có 10 bài. Lặp lại quá trình này cho đến khi gặp một trang có ít hơn 10 liên kết.
!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 filewhile linkCount >0:
url = url0 +str(songNumber)+ url1
html = urlopen(url)
soup = BeautifulSoup(html.read())
links = soup.find_all('a')
linkCount =0for 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)Được hỗ trợ bởi
Hướng dẫn này được xây dựng xoay quanh TensorFlow 2.0 trong Python, cùng với Keras API cấp cao đóng vai trò được tăng cường trong TensorFlow 2.0. Với những ai muốn tìm hiểu thêm về TensorFlow 2.0, hãy xem khóa Giới thiệu về TensorFlow trong Python trên DataCamp. Để có bài tổng quan đầy đủ về học sâu cho âm nhạc, xem Briot, Hadjerest, và Pachet (2019), tài liệu mà chúng tôi sẽ tham chiếu xuyên suốt hướng dẫn này.
Mô hình sinh
Các mô hình học máy có giám sát có thể được chia thành hai loại: mô hình phân biệt và mô hình sinh. Mô hình phân biệt xác định một biên quyết định và đưa ra phân loại tương ứng. Mô hình sinh tạo ra các thể hiện mới của một lớp.
Một mô hình phân biệt về âm nhạc có thể được dùng để phân loại bài hát theo các thể loại khác nhau. Một mô hình sinh có thể soạn các bài hát của một thể loại cụ thể. Trong hướng dẫn này, chúng ta sẽ sử dụng các mô hình sinh để soạn nhạc.
Biểu diễn âm nhạc
Trước khi có thể định nghĩa và huấn luyện một mô hình sinh, trước hết chúng ta phải tập hợp một tập dữ liệu. Với âm nhạc, dữ liệu có thể được biểu diễn dưới dạng liên tục hoặc rời rạc. Dạng liên tục phổ biến nhất là tín hiệu âm thanh, thường được lưu dưới dạng tệp WAV. Chúng ta sẽ không dùng dạng liên tục trong hướng dẫn này, nhưng bạn có thể đọc thêm trong phần Phụ lục. Các dạng rời rạc phổ biến gồm tệp Musical Instrument Digital Interface (MIDI), pianoroll và văn bản. Chúng ta sẽ tập trung vào tệp MIDI và trích xuất hai loại đối tượng ký hiệu: nốt và hợp âm.
Nốt
Một nốt là biểu diễn ký hiệu của âm thanh. Với mục đích của chúng ta, một nốt có thể được mô tả bởi cao độ và trường độ. Cao độ của nốt liên quan đến tần số dao động của sóng âm, đo bằng hertz (Hz). Nốt có cao độ cao hơn có sóng âm dao động nhiều hơn mỗi giây. Trường độ của nốt là độ dài khoảng thời gian nó được chơi.
Tệp MIDI biểu diễn cao độ của nốt bằng một số nguyên từ 0 đến 127. Nốt cũng có thể được biểu diễn bằng chữ cao độ và quãng tám. Trong cùng một quãng tám, các cao độ được sắp xếp từ tần số thấp đến cao như sau:
Quãng tám được ký hiệu bằng chỉ số viết dưới, như số 4 trong A4 hoặc số 7 trong C7. Quãng tám cao hơn tương ứng với tần số cao hơn. Nếu lấy một cao độ bất kỳ Xi, thì cao độ Xi+1, cao hơn đúng một quãng tám, biểu diễn sóng âm có tần số gấp đôi Xi.
Bên cạnh cao độ, chúng ta cũng sẽ dùng trường độ của nốt. Trường độ là giá trị tương đối, được chuẩn hóa theo độ dài của nốt tròn (whole note). Nốt dài nhất là nốt “large”, dài gấp tám lần nốt tròn. Nốt ngắn nhất là nốt 1/256, dài bằng 1/256 độ dài nốt tròn.
Hợp âm
Hợp âm là sự kết hợp của từ hai nốt trở lên được chơi đồng thời trên cùng một nhạc cụ. Nếu xét nhạc đơn âm — tức nhạc được chơi trên một nhạc cụ duy nhất — ta có thể nhận diện hợp âm bằng giả định rằng mọi nốt được chơi cùng lúc thuộc cùng một hợp âm. Giả định này không còn đúng với nhạc đa âm, trong đó có từ hai nhạc cụ trở lên chơi cùng lúc.
Trong khối mã dưới đây, chúng ta cài đặt
music21rồi import mô-đunconverter, dùng để phân tách tệp MIDI. Chúng ta sẽ tải và phân tích một bản guitar cổ điển của Mauro Giuliani. Sau đó áp dụng phương thức.chordify(), phương thức này tái tạo chuỗi hợp âm trong bản nhạc, giả định rằng các nốt được chơi đồng thời là một hợp âm.Sau khi dựng chuỗi hợp âm trong tệp MIDI, chúng ta có thể hiển thị dưới dạng bản nhạc bằng phương thức
.show(). Việc này yêu cầu phần mềm có thể tạo và hiển thị bản nhạc, như MuseScore, có sẵn miễn phí.Ngoài ra, chúng ta có thể muốn xem và thao tác các phần tử nền tảng dưới dạng văn bản. Có thể làm điều này bằng phương thức
.show('text'). Ở đầu mỗi dòng, ta thấy offset trong ngoặc, cho biết một phần tử xuất hiện sau bao nhiêu giây kể từ khi bắt đầu bản nhạc. Lưu ý chỉ một phần tử ở giây 0 là hợp âm. Các phần tử còn lại là siêu dữ liệu, bao gồm nhạc cụ dùng, tempo, giọng (key) và nhịp. Chúng ta sẽ sử dụng giọng ở phần sau, còn bây giờ bạn có thể bỏ qua các phần tử này.Sau phần siêu dữ liệu, các phần tử còn lại là hợp âm, được sắp theo thứ tự xuất hiện trong bài. Ví dụ,
<music21.chord.Chord E3 G3>là hợp âm tạo bởi nốt E3 và G3. Ngoài ra,<music21.chord.Chord A3>là nốt A3 được chơi đơn lẻ.Với một số mô hình, chúng ta sẽ dùng cả hợp âm và trường độ của nó, tức khoảng thời gian nó được chơi. Ta có thể lấy thông tin này bằng cách áp dụng phương thức
.elementsvà truy cập thuộc tính.durationcho một hợp âm bất kỳ trong bản nhạc, như minh họa dưới đây.Mã hóa nốt và hợp âm
Xuyên suốt hướng dẫn, chúng ta sẽ tập trung vào các biểu diễn rời rạc của âm nhạc có thể mô tả bằng chuỗi nốt, hợp âm và trường độ liên quan. Nhớ rằng hợp âm là tổ hợp các nốt và định dạng MIDI cho phép dùng 128 nốt riêng lẻ. Nếu muốn mở rộng bộ đặc trưng để bao gồm mọi hợp âm hai nốt, kích thước tăng thêm 128!/[(128-2)!*2!] tức 8128 phần tử. Thêm hợp âm ba nốt tăng thêm 341.376, và thêm hợp âm bốn nốt tăng thêm 10.668.000.
Ví dụ trước đó có chứa nốt và hợp âm hai, ba, bốn nốt. Nếu dùng one-hot encoding và đưa vào tất cả đặc trưng, chúng ta sẽ cần dự đoán trên các vector chiều 11.017.632. Vì vậy, cần cân nhắc kỹ lựa chọn mã hóa trong bài toán sinh nhạc. Ta cũng có thể cân nhắc dùng chuyển giọng (key transposition) hoặc tăng cường dữ liệu, cả hai đều được bàn trong Phụ lục.
Về lựa chọn mã hóa, Briot, Hadjerest, và Pachet (2019) thảo luận bốn lựa chọn thường dùng trong các mô hình sinh nhạc:
One-hot encoding biểu diễn các ký hiệu âm nhạc, như nốt và hợp âm, bằng các vector thưa với một số 1 tại vị trí tương ứng với nốt hoặc hợp âm cụ thể. Many-hot encoding biểu diễn hợp âm, gồm nhiều nốt, bằng cách đặt số 1 tại vị trí tương ứng với mỗi nốt.
Trong sơ đồ dưới đây, chúng ta so sánh many-hot và one-hot cho trường hợp có nốt và hợp âm hai nốt. Với many-hot, các nốt C4 và D4 được biểu diễn bởi vector 128 phần tử. Cả hai vector có số 0 ở mọi vị trí trừ các vị trí tương ứng C4 và D4, nơi có số 1. Hợp âm C4+D4, tức chơi đồng thời C4 và D4, được tạo bằng cách đặt số 1 ở cả hai vị trí C4 và D4.
Biểu diễn one-hot của nốt đơn và hợp âm hai nốt gồm các vector 8256 phần tử. Mỗi vector là thưa và chứa một số 1 tại vị trí tương ứng với một nốt hoặc hợp âm duy nhất. Lưu ý many-hot đòi hỏi dùng mô hình đa nhãn, nơi nhiều lớp có thể được dự đoán cho một đầu ra. Tuy nhiên, nó giảm đáng kể kích thước vector đầu vào và đầu ra.
Phần lớn mô hình trong tài liệu vẫn dùng one-hot. Tuy vậy, Briot, Hadjerest, và Pachet (2019) nêu một số mô hình sinh nhạc dùng many-hot, gồm các hệ thống RBMc, RNN-RBM và C-RBM, đều dựa trên kiến trúc restricted Boltzmann machine (RBM).
Multi-one-hot và multi-many-hot là tổng quát hóa của one-hot và many-hot cho trường hợp nhạc đa âm — tức nhiều nhạc cụ chơi đồng thời. Chúng ta sẽ giới hạn ở nhạc đơn âm và không bàn thêm các lựa chọn này.
Thu thập và chuẩn bị dữ liệu
Bước tiếp theo là thu thập một tập dữ liệu để huấn luyện mô hình sinh. Chúng ta sẽ dùng dữ liệu từ Dự án Mutopia, chứa 2124 tác phẩm ở định dạng MIDI thuộc phạm vi công cộng. Vì website được chia theo nhạc cụ, ta có thể viết một script Python khá đơn giản để quét toàn bộ các bản guitar, dùng để huấn luyện các mô hình trong hướng dẫn này. Xem tổng quan cách thực hiện trong Phụ lục.
Giả sử chúng ta đã lưu tất cả tệp
.midtừ mục guitar vào một thư mục,save_dir. Bước tiếp theo là tải mỗi tệp.midvàomusic21dưới dạng đối tượngstream.Như một phần của chiến lược giảm chiều, chúng ta sẽ chỉ giữ các bài do Mauro Giuliani sáng tác. Có thể làm vậy bằng cách chỉnh sửa khối mã trên như sau.
Chúng ta cần làm thêm bốn việc trước khi bắt đầu huấn luyện:
.chordify()cho các đối tượngstreamcòn lại.Bắt đầu bằng việc loại bỏ nhạc đa âm khỏi tập dữ liệu. Ta có thể làm vậy bằng cách phân vùng mỗi đối tượng
streamtheo nhạc cụ rồi kiểm tra xem có đúng chỉ một phân vùng không — tức chỉ một nhạc cụ chơi. Chúng ta sẽ thực hiện trong khối mã dưới đây.Lưu ý nếu áp dụng
.chordify()— như trong hình dưới — mà không loại nhạc đa âm, chúng ta sẽ vô tình gộp các nốt do nhạc cụ khác nhau chơi thành một hợp âm.Giờ chúng ta cần trích xuất nốt, hợp âm và trường độ từ mỗi
stream. Trước hết sẽ định nghĩa ba danh sách rỗng:originalChords,originalDurations,originalKeys. Ta có thể liệt kê qua danh sách các stream. Với mỗi stream, ta sẽ xác định và lưu tất cả nốt, hợp âm, trường độ cũng như giọng của tác phẩm.Chúng ta sẽ tiếp tục giảm chiều ở khối mã dưới bằng cách chỉ giữ các bản có giọng Đô trưởng (C major), vốn là giọng phổ biến nhất trong tập dữ liệu. Từ đây về sau trong hướng dẫn, chúng ta sẽ dùng từ “hợp âm” để chỉ cả hợp âm và nốt.
Bước kế là xác định tập hợp duy nhất các hợp âm và trường độ. Sau đó xây dựng các từ điển ánh xạ chúng thành số nguyên. Nếu in ra số lượng nốt và hợp âm duy nhất, ta sẽ thấy các bước giảm chiều đã đưa con số xuống mức có thể xử lý được: 267.
Sau khi huấn luyện mô hình và có dự đoán, chúng ta sẽ muốn ánh xạ các dự đoán số nguyên về lại nốt, hợp âm và trường độ. Hãy đảo ngược các từ điển
chordToIntvàdurationToIntnhư dưới đây.Cuối cùng, ta có thể định nghĩa các chuỗi huấn luyện, gồm 32 nốt và hợp âm liên tiếp, cùng trường độ tương ứng. Trước hết, chúng ta sẽ làm điều này cho các mô hình kiểu autoencoder, nơi bộ đặc trưng và mục tiêu là như nhau.
Sinh nhạc
Trong phần này, chúng ta sẽ dùng tập dữ liệu đã thu thập và chuẩn bị để sinh nhạc. Chúng ta sẽ xét ba mô hình sinh nhạc khác nhau, bắt đầu từ đơn giản nhất:
Autoencoder
Một autoencoder gồm hai mạng, xếp chồng theo chiều dọc và nối với nhau bằng một vector tiềm ẩn (latent). Dữ liệu đầu vào trước hết được đưa vào mô hình bộ mã hóa (encoder), thường gồm một hoặc nhiều lớp dense. Lớp cuối của encoder được gọi là vector tiềm ẩn. Đây là nút thắt buộc các đặc trưng trích xuất trong encoder phải nén vào một số ít đặc trưng tiềm ẩn.
Vector tiềm ẩn nối với mô hình bộ giải mã (decoder), có nhiệm vụ upsample hoặc giải nén dữ liệu. Số nút trong mỗi lớp decoder kế tiếp tăng dần. Lớp đầu ra cuối cùng có cùng kích thước với lớp đầu vào của encoder.
Trái với các mô hình phân biệt cố gắng xác định biên quyết định, autoencoder được huấn luyện để tái tạo dữ liệu đầu vào — trong trường hợp này là một bài hát — chính xác nhất có thể, chịu ràng buộc bởi kích thước của vector tiềm ẩn. Vì thế, autoencoder dùng chính đầu vào làm mục tiêu.
Chúng ta sẽ dùng autoencoder để xây dựng mô hình sinh nhạc đầu tiên. Để đơn giản tối đa, chúng ta áp dụng kiến trúc MiniBach được giới thiệu trong Briot, Hadjerest, và Pachet (2019) như một giản lược của mô hình DeepBach. Một ví dụ khác về autoencoder sinh nhạc là hệ thống DeepHear.
Dữ liệu đầu vào, như chúng ta đã xây dựng, gồm các vector one-hot biểu diễn nốt và hợp âm. Tạm thời bỏ qua trường độ. Theo kiến trúc MiniBach, trước hết chúng ta sẽ làm phẳng vector đầu vào.
Tiếp theo, ta định nghĩa số mẫu,
nSamples, số hợp âm và nốt,nChords, và kích thước đầu vào,inputDim. Ta cũng đặt số đặc trưng tiềm ẩn,latentDim, bằng 2. Dùng ít chiều tiềm ẩn sẽ giảm hiệu năng nhưng cho phép trực quan hóa không gian tiềm ẩn dễ dàng.Bây giờ ta định nghĩa kiến trúc mô hình, bắt đầu với lớp đầu vào cho mạng encoder,
encoderInput, và lớp đầu vào cho mạng decoder,latent. Lưu ý lớplatentcũng là đầu ra của mạng encoder. Tiếp theo, định nghĩa một lớp dense,encoded, nối đầu vào tới vector tiềm ẩn và một lớp dense khác,decoded, nối vector tiềm ẩn tới vector đầu ra.Cuối cùng, định nghĩa mô hình decoder,
decoder, và autoencoder,autoencoder. Lưu ý.Model()có tham sốinputsvàoutputs. Với mô hìnhdecoder, ta truyền vectorlatentlàm input và mạngdecodedlàmoutput. Với autoencoder đầy đủ, dùngencoderInputlàm input và truyền mô hìnhdecoder, được đánh giá bằng đầu ra của mạng encoder,encoded, làm input.Kiến trúc của một autoencoder tổng quát được minh họa dưới đây. Nửa trên là encoder, nửa dưới là decoder. Chúng liên kết với nhau bởi vector tiềm ẩn.
Sau khi định nghĩa kiến trúc cho
decodervàautoencoder, bước còn lại là biên dịch mô hình rồi huấn luyện bằng phương thức.fit(). Có hai chi tiết quan trọng:binary_crossentropy.trainChordsFlat.Bước cuối là sinh nhạc. Vì chúng ta đặt độ dài chuỗi bằng 32,
autoencodersẽ nhận 32 hợp âm và nốt làm đầu vào và tạo ra một “bài” có độ dài cố định gồm chuỗi 32 hợp âm và nốt.autoencoderđược huấn luyện để tạo đầu ra rất giống đầu vào; tuy nhiên, mục tiêu của chúng ta là tạo nhạc mới. Để làm điều này, chúng ta sẽ truyền một vector tiềm ẩn ngẫu nhiên vào mô hìnhdecoder, vốn được định nghĩa như một mạng con của mô hìnhautoencoder.Lưu ý chúng ta đã reshape đầu ra của autoencoder thành một mảng có cùng kích thước với đầu vào ban đầu. Sau đó lấy
argmax()theo chiều thứ nhất của mảng. Điều này trả về một giá trị số nguyên tương ứng với hợp âm, mà chúng ta sẽ xác định trong khối mã dưới đây.Cuối cùng, chúng ta có thể tạo một đối tượng
streambằngmusic21, đặt nhạc cụ là guitar, nối chuỗi hợp âm do mô hình tạo ra, rồi xuất tất cả thành tệp MIDI.Ảnh gif dưới đây cho thấy một phần quá trình huấn luyện của autoencoder. Bản nhạc được tạo ở mỗi bước là phép tái tạo tương ứng với một vector trạng thái tiềm ẩn cụ thể. Mỗi khung hình đại diện cho 5 epoch huấn luyện.
Chúng ta cũng có thể trực quan hóa chuyển động trong không gian tiềm ẩn trong huấn luyện, như trong gif dưới. Lưu ý không gian tiềm ẩn hẹp và trôi dạt theo epoch. Điều này khiến việc rút một trạng thái tiềm ẩn ngẫu nhiên chất lượng cao trở nên khó, và sẽ đặc biệt rắc rối khi dùng vector tiềm ẩn nhiều chiều hơn. Ở phần tiếp theo, chúng ta sẽ cho thấy cách khắc phục bằng variational autoencoder (VAE).
Cuối cùng, chúng ta có thể xem đầu ra âm thanh từ autoencoder. Trước tiên là chuỗi hợp âm từ bài gốc, được lấy ngẫu nhiên từ tập huấn luyện. Sau đó, chúng ta sinh một chuỗi hợp âm từ autoencoder dùng vector tiềm ẩn gắn với chuỗi hợp âm của bài gốc, cho ra bài phía dưới.
Ví dụ: Bài gốc
Ví dụ: Bài sinh ra
Variational Autoencoder
Mặc dù autoencoder phù hợp với các tác vụ khử nhiễu, nén và giải nén, chúng không hoạt động tốt như các mô hình sinh. Variational autoencoder (VAE), được giới thiệu bởi Kingma và Welling (2017), được thiết kế để khắc phục điểm yếu của autoencoder trong vai trò mô hình sinh và là một trong những phương pháp được dùng phổ biến nhất để sinh nhạc. Briot, Hadjerest, và Pachet (2019) xác định bốn hệ thống sinh nhạc xây dựng trên VAE: MusicVAE, VRAE, VRASH và GSLR-VAE. Chúng cải thiện autoencoder theo hai khía cạnh:
Ta có thể thực hiện thay đổi đầu tiên bằng cách thay vector trạng thái tiềm ẩn trong autoencoder bằng ba lớp:
Mỗi bộ đầu vào ánh xạ tới một kỳ vọng và log phương sai, đủ để đặc trưng một phân phối chuẩn. Mô hình sau đó rút ngẫu nhiên các điểm từ phân phối ở lớp lấy mẫu.
Khối mã dưới định nghĩa một hàm để tạo lớp lấy mẫu. Hàm nhận kỳ vọng và log phương sai làm đầu vào. Sau đó dùng mô-đun
randomcủatensorflowđể rút một tensor điểm,epsilon, từ phân phối chuẩn tắc, có cả kỳ vọng và log phương sai bằng 0. Tiếp theo, chúng ta muốn biến đổi các mẫu rút được này thành các mẫu từ những phân phối đặc trưng bởi tham sốmeanvàlogVar. Để làm vậy, trước hết cần nhân các mẫuepsilonvới độ lệch chuẩn của các phân phối tương ứng. Ta tính độ lệch chuẩn bằng cách chialogVarcho 2 rồi lấy mũ. Sau đó cộng thêm kỳ vọngmean.Giờ ta có thể chỉnh sửa mô hình autoencoder để tích hợp các lớp
mean,logVarvàsampling. Lưu ý lớpencodedgiờ nhận hai đầu vào và lớpencodercó ba đầu ra.Tương tự mô hình autoencoder, trạng thái tiềm ẩn là đầu ra của lớp
encoded. Tuy nhiên có một khác biệt quan trọng: thay vì ánh xạ mỗi bộ đầu vào tới một trạng thái tiềm ẩn duy nhất, chúng ta dùng lớpsamplingđể ánh xạ chúng tới các giá trị ngẫu nhiên rút từ phân phối chuẩn với tham sốmeanvàlogVar. Điều này có nghĩa cùng một đầu vào sẽ gắn với một phân phối các trạng thái tiềm ẩn.Bước cuối là điều chỉnh hàm mất mát để áp đặt ràng buộc lên lựa chọn
meanvàlogVarcủa mô hình. Cụ thể, chúng ta sẽ tính độ lệch Kullback–Leibler (KL) giữa mỗi phân phối do các lớpmeanvàlogVarxác định với phân phối chuẩn tắc, có kỳ vọng và log phương sai bằng 0. Các kỳ vọng và log phương sai càng xa 0, thành phần mất mát KL càng cao.Một câu hỏi tự nhiên là vì sao ta lại ép mỗi phân phối tiến gần chuẩn tắc. Lý do là chúng ta muốn lấy mẫu các trạng thái tiềm ẩn và dùng chúng để sinh nhạc. Nếu áp dụng phạt KL vào hàm mất mát, điều này sẽ cho phép dùng các mẫu độc lập từ phân phối chuẩn tắc để thu được các trạng thái tiềm ẩn có khả năng sinh ra những bản nhạc chất lượng cao.
Trong khối mã dưới, chúng ta cho thấy cách chỉnh hàm mất mát để đưa thêm hạng tử KL. Thành phần đầu tiên của hàm mất mát là mất mát binary crossentropy, tính trên đầu vào và đầu ra của mô hình
vae. Đây cũng là thứ chúng ta dùng cho autoencoder. Nó được gọi là “mất mát tái tạo” vì nó phạt khi ta không tái tạo được bài gốc. Hạng tử KL là trung bình độ lệch KL giữa các phân phối không gian tiềm ẩn và chuẩn tắc. Chúng ta kết hợp cả hai để tính tổng mất mát,vaeLoss, thêm vào mô hình, biên dịch rồi huấn luyện.Ở phần trước, chúng ta cho thấy không gian tiềm ẩn của autoencoder không “đẹp”. Một phần mục đích của VAE là khắc phục khiếm khuyết thiết kế này trong bối cảnh sinh. Hình dưới cho thấy không gian tiềm ẩn của VAE. Ta thấy có độ phân tán đáng kể trên cả hai đặc trưng. Hơn nữa, cả hai đều tập trung quanh 0 và không trôi dạt theo epoch. Điều này sẽ giúp việc lấy mẫu ngẫu nhiên một trạng thái tiềm ẩn chất lượng trở nên dễ dàng hơn nhiều.
Cuối cùng, chúng ta chuyển sang sinh nhạc. Chúng ta rút một vector tiềm ẩn ngẫu nhiên từ phân phối chuẩn tắc, truyền vào decoder, reshape đầu ra và lấy argmax của mỗi cột. Điều này trả về chuỗi 32 số nguyên, tương ứng với nốt và hợp âm, mà chúng ta sẽ xác định bằng từ điển
intToChord. Sau đó, tạo một đối tượngstream, thêm guitar làm nhạc cụ, nối chuỗi nốt và hợp âm vàostream, rồi xuất thành tệp MIDI.Ba bài dưới đây được sinh bởi mô hình. Hai bài đầu dựa trên các trạng thái tiềm ẩn gắn với chuỗi hợp âm trong tập huấn luyện. Bài cuối là tổ hợp tuyến tính của trạng thái tiềm ẩn của bài #1 và #2. Vì VAE ép không gian tiềm ẩn “đẹp”, chúng ta có thể khá chắc rằng tổ hợp tuyến tính của các trạng thái tiềm ẩn sẽ cho một trạng thái tiềm ẩn chất lượng cao.
Ví dụ: Bài sinh #1
Ví dụ: Bài sinh #2
Ví dụ: Tổ hợp tuyến tính của bài #1 và #2
Một điều có thể quan sát từ nhạc sinh bởi autoencoder và VAE là thiếu giai điệu mạnh. Ta có thể cải thiện bằng cách thêm nhiều lớp vào mô hình và tăng kích thước tập huấn luyện. Hoặc ta có thể dùng một mô hình được thiết kế để xử lý dữ liệu tuần tự, như chúng ta sẽ làm ở phần sau.
Long Short-Term Memory (LSTM)
Mô hình cuối cùng chúng ta xét là LSTM. Đây là một dạng mạng nơ-ron hồi tiếp (RNN) được điều chỉnh để ngăn vấn đề tiêu biến gradient. Briot, Hadjerest, và Pachet (2019) nhận thấy các mô hình hồi tiếp là loại được dùng phổ biến nhất cho mục đích sinh nhạc. Họ liệt kê hơn 20 hệ thống sinh nhạc dựa vào RNN hoặc LSTM, bao gồm BachBot, DeepBach, Performance-RNN và Hexahedria.
Dùng LSTM đem lại hai lợi ích so với mô hình dựa trên autoencoder:
Khác với autoencoder và VAE dùng đầu vào làm mục tiêu, các mô hình LSTM sinh nhạc được huấn luyện để dự đoán nốt hoặc hợp âm tiếp theo trong chuỗi. Vì thế, chúng ta cần dựng lại tập train và target, như trong khối mã dưới. Vì dùng LSTM cũng giảm số tham số cần huấn luyện, chúng ta sẽ thêm cả trường độ vào mô hình.
Lưu ý
trainChordsvàtrainDurationsđược định nghĩa giống như trước đó. Chúng ta chỉ thêmtargetChordsvàtargetDurations, gồm hợp âm và trường độ theo sau chuỗi hợp âm và trường độ trong tập train. Tiếp theo, ta đặt số mẫu, số hợp âm và trường độ, cũng như kích thước đầu vào. Chúng ta cũng sẽ dùng các lớp embedding riêng cho hợp âm và trường độ.Bước tiếp theo là định nghĩa kiến trúc mô hình. Chúng ta sẽ dùng mô hình có hai đầu vào và hai đầu ra, để có thể dự đoán cả hợp âm và trường độ. Trước tiên định nghĩa các lớp đầu vào,
chordInputvàdurationInput, rồi truyền chúng vào các lớp embedding,chordEmbeddingvàdurationEmbedding. Đây là các bảng tra cứu gán một vector 64 phần tử cho mỗi hợp âm hoặc trường độ. Embedding cho phép thay thế biểu diễn vector thưa, nơi đầu vào được one-hot, bằng biểu diễn đặc, nơi các nốt và trường độ liên quan có giá trị gần nhau hơn.Đầu ra của hai lớp embedding được nối trong
mergeLayer. Sau đó, truyền đầu ra này vào hai lớp LSTM. Lớp đầu tiên đặt tham sốreturn_sequencesbằngTrue. Nếu để tham số nàyFalsenhư mặc định, các cell LSTM sẽ chỉ trả về trạng thái cuối cùng thay vì các trạng thái trung gian theo chuỗi. Nghiên cứu gần đây cho thấy tận dụng các trạng thái trung gian có thể cải thiện hiệu năng. Điều này thường được làm trong bối cảnh một lớp attention. Cuối cùng, chúng ta truyền đầu ra của lớp LSTM vào lớp dense, rồi tới hai lớp đầu ra:chordOutputvàdurationOutput. Sau đó định nghĩa mô hình API chức năngkerasbằng cách chỉ định hai lớp đầu vào và hai lớp đầu ra.Khi mô hình đã được định nghĩa, chúng ta có thể biên dịch và bắt đầu huấn luyện. Chúng ta cố gắng giữ mọi thứ đơn giản nhất có thể, nhưng với nhiều ứng dụng, sẽ hợp lý khi thêm các lớp regularization, dense và LSTM bổ sung. Lưu ý mô hình có hai lớp đầu vào và hai lớp đầu ra, như trong sơ đồ dưới. Bạn có thể tìm hiểu thêm về mô hình có nhiều đầu vào và đầu ra trong khóa Giới thiệu về TensorFlow trong Python.
Sau khi quá trình huấn luyện hoàn tất, chúng ta có thể sinh bài mới bằng mô hình LSTM. Ta sẽ làm điều này bằng cách cung cấp một chuỗi khởi tạo gồm 32 hợp âm và trường độ, cho phép đưa ra dự đoán đầu tiên. Sau đó nối các dự đoán đó vào chuỗi hợp âm và trường độ, cho phép đưa ra dự đoán tiếp theo dựa trên 32 hợp âm và trường độ cuối cùng của chuỗi đã cập nhật. Một lợi thế của LSTM là chúng ta có thể lặp vô hạn, cho phép sinh bài có độ dài tùy ý.
Bước cuối là xuất nhạc đã sinh ra thành tệp MIDI bằng
music21. Lưu ý mã gần như giống hệt với phần dùng autoencoder, nhưng có một điểm khác quan trọng: giờ chúng ta nối cả hợp âm và trường độ vào đối tượngstream.Với các mô hình autoencoder và VAE, chúng ta gặp khó khi tạo giai điệu và chỉ có thể xuất các bài có độ dài cố định. Dùng LSTM, ta có thể sinh chuỗi đầu ra có độ dài tùy ý bằng cách lặp qua các dự đoán, như trong khối mã trên. Việc tạo giai điệu hay hơn cũng sẽ tương đối dễ hơn. Chúng ta sẽ thấy cả hai đặc điểm này trong các ví dụ dưới, mỗi ví dụ được gieo hạt bởi một chuỗi khởi tạo hợp âm và trường độ từ một mẫu trong tập huấn luyện.
Ví dụ: Bài sinh #1
Ví dụ: Bài sinh #2
Ví dụ: Bài sinh #3
Các vấn đề bổ sung
Dữ liệu
Một trong những thách thức lớn đối với việc phát triển các mô hình học máy cho âm nhạc là khả năng tiếp cận tập dữ liệu, vì âm nhạc thường không được phép tải về tự do và hợp pháp. Trên thực tế, điều này đã ngăn cản cộng đồng học thuật đồng thuận về một tập dữ liệu chuẩn để thực hành, như MNIST cho phân loại ảnh. Tuy nhiên, có một vài dự án cung cấp hoặc âm thanh thô, ký hiệu nhạc, hoặc các đặc trưng âm nhạc dẫn xuất cho số lượng lớn bài hát. Chúng tôi liệt kê một phần các dự án đó dưới đây.
Lấy mẫu vs. tất định
Khi sinh nhạc bằng mô hình LSTM, chúng ta dùng cách tất định để chọn hợp âm và trường độ. Mô hình dự đoán xác suất cho mỗi trong số 267 hợp âm và 9 trường độ, và chúng ta chọn hợp âm và trường độ có xác suất cao nhất. Tương tự, với autoencoder và VAE, chúng ta chọn các hợp âm có giá trị đầu ra cao nhất.
Một cách thay thế là lấy mẫu từ phân phối đầu ra, thay vì chọn hợp âm và trường độ có xác suất cao nhất. Cách này sẽ khiến quá trình sinh nhạc trở nên ngẫu nhiên thay vì tất định. Có nghĩa là các mô hình autoencoder hoặc VAE với cùng vector tiềm ẩn sẽ sinh ra bài khác nhau mỗi lần chạy. Tương tự, cùng một chuỗi đầu vào cho mô hình LSTM có thể sinh ra các bài khác nhau.
Khối mã dưới cho thấy cách chuyển sang sinh hợp âm và trường độ ngẫu nhiên bằng cách chỉnh hàm
predictChords(). Lưu ý chúng ta đã thay phépargmax()bằng phép lấy mẫu dùng các xác suất để rút ngẫu nhiên từ các vector hợp âm và trường độ.Để thảo luận mở rộng về lợi ích của tiếp cận ngẫu nhiên và tất định trong sinh nhạc, xem mục 6.6 của Briot, Hadjerest, và Pachet (2019).
Embeddings
Khi huấn luyện mô hình LSTM, chúng ta dùng một lớp embedding, ánh xạ mỗi hợp âm và trường độ đầu vào tới một vector 64 phần tử đặc. Dùng embedding cho phép chúng ta tránh dùng các vector thưa, nhiều chiều làm đầu vào. Thay vào đó, dùng biểu diễn ít chiều cho phép nhận diện quan hệ giữa các hợp âm khác nhau.
Trong nhiều trường hợp, việc huấn luyện một lớp embedding có thể không hợp lý. Bởi chúng ta thường không có tập huấn luyện đủ lớn khi làm bài toán sinh nhạc. Dùng embedding tiền huấn luyện hoặc embedding tạo bằng học không giám sát — như thường làm với bài toán phân loại và sinh văn bản — sẽ cho phép huấn luyện mô hình khi khác đi sẽ không khả thi.
Xem Chuan, Agres, và Herremans (2018) để tổng quan cách xây dựng embedding bằng phương pháp học không giám sát.
Generative Adversarial Networks
Chúng ta đã bàn về autoencoder, VAE và LSTM như các lựa chọn sinh nhạc. Một lựa chọn khác, đã chứng tỏ thành công trong các miền sinh khác, là mạng đối sinh (GAN). GAN hoạt động bằng cách kết hợp một mạng phân biệt với một mạng sinh. Mạng sinh tạo ra các mẫu, như chuỗi hợp âm do các mô hình LSTM và autoencoder tạo. Các mẫu này sau đó được kết hợp trong một tập huấn luyện cùng với các mẫu thật — tức chuỗi hợp âm từ các bài thực. Mô hình phân biệt cố gắng phân loại mẫu là thật hay do bộ sinh tạo ra. Bộ sinh cố gắng đánh lừa mô hình phân biệt để nó phân loại nhầm các mẫu do mình tạo ra là bài thật. Mô hình được huấn luyện tới khi đạt trạng thái cân bằng tiến hóa ổn định, nơi cả bộ phân biệt lẫn bộ sinh đều không thể đạt thêm lợi thế.
Với những ai quan tâm áp dụng GAN cho tác vụ sinh nhạc, có thể tham khảo các nguồn sau:
MuseGAN là một dự án hướng đến sinh nhạc đa âm bằng GAN. Dự án đi kèm các mô hình tiền huấn luyện và 174.154 bài đã được làm sạch và chuẩn bị ở định dạng pianoroll.
Magenta là dự án mã nguồn mở hướng tới ứng dụng mô hình sinh cho các tác vụ sáng tạo, bao gồm tạo nhạc. Bạn có thể khám phá bộ công cụ của Magenta qua một notebook Colab.
Tài nguyên
Với những ai muốn tìm hiểu thêm về TensorFlow 2.0, hãy xem khóa Giới thiệu về TensorFlow trong Python trên DataCamp. Để có bài tổng quan toàn diện và gần đây về các phương pháp sinh nhạc bằng học sâu, xem Briot, Hadjerest, và Pachet (2019). Để có thêm các hướng dẫn về sinh nhạc với mô hình học sâu, xem 1, 2, 3, 4, và 5, cung cấp một cách tiếp cận mang tính giáo trình đầy đủ về các mô hình sinh.
Phụ lục
Biểu diễn liên tục
Tệp WAV là dạng biểu diễn liên tục phổ biến nhất vì chứa tín hiệu âm thanh thô chưa nén. Điều này khác với tệp mp3, ví dụ, nén kích thước tệp bằng cách loại bỏ một phần thông tin về tín hiệu âm thanh.
Khối mã dưới đây cho thấy cách trích xuất tần số lấy mẫu,
rate, và tín hiệu âm thanh thô,signal, từ một tệp WAV chứa một tác phẩm của Mozart. Tần số lấy mẫu là số lần lấy mẫu tín hiệu âm thanh mỗi giây. Trong trường hợp của chúng ta, giá trị này là 44100.Tín hiệu âm thanh thô,
signal, là một tensorSxC, trong đóSlà số mẫu vàClà số kênh. Để đơn giản, chúng ta xét trường hợpC= 1. Biểu đồ dưới đây cho thấy một khoảng ba giây củasignal.Nói chung, dùng tín hiệu âm thanh thô để huấn luyện mô hình khó hơn đáng kể so với dùng biểu diễn rời rạc. Một khảo sát gần đây về các phương pháp học sâu cho sinh nhạc đã xem xét 32 phương pháp khác nhau trong tài liệu. Trong số đó, chỉ có ba phương pháp dùng biểu diễn liên tục:
Chuyển giọng (Key Transposition)
Một phương pháp có thể dùng cho mục đích giảm chiều hoặc tăng cường dữ liệu là chuyển giọng. Việc này bao gồm dịch cao độ của mỗi nốt theo một khoảng cố định. Nếu, chẳng hạn, một bài có chuỗi trong đó cao độ C3 theo sau bởi D3 và ta dùng phép chuyển giọng tăng tất cả phần tử lên hai cao độ, thì C3 sẽ thành D3 và D3 sẽ thành E3. Với các mô hình dùng chuyển giọng, xem Lim, Rhyu, và Lee (2018) và Sturm et al. (2016).
Nếu dùng chuyển giọng nhằm giảm chiều, mục tiêu của chúng ta là dịch nốt và hợp âm sao cho tối thiểu hóa tổng số nốt và hợp âm duy nhất trong tập dữ liệu. Vì âm nhạc thường được viết quanh những nhóm cao độ gọi là giọng (key) nghe hài hòa khi chơi cùng nhau, chúng ta có thể thuận tiện chuyển mỗi bài trong tập dữ liệu về cùng một giọng.
Lần này, chúng ta sẽ import ba mô-đun phụ từ
music21:converter, dùng để tải tệp MIDI và chuyển thành đối tượngstream.pitch, dùng để tạo cao độ mục tiêu.interval, dùng để tính khoảng cách giữa giọng gốc và giọng đích.Trước hết chúng ta áp dụng phương thức
.analyze('key')choscore, phương thức này xác định giọng của đối tượngstream, là Đô trưởng. Sau đó, ta tính khoảng cách giữa Đô trưởng và giọng mục tiêu, Fa trưởng. Lưu ý chúng ta truy cập cao độ của đối tượngkeybằng.tonic. Hơn nữa, chúng ta tạo cao độ đích bằngpitch.Pitch('F'). Sau đó gán đầu ra chokeyInterval, cho biết khoảng cách giữa giọng gốc và giọng đích.Cuối cùng, chúng ta chuyển giọng bằng phương thức
.transpose()và truyền khoảng cách,keyInterval. In giọng mới xác nhận đó là Fa trưởng.Chuyển quãng tám
Nếu muốn tiến xa hơn chuyển giọng, chúng ta cũng có thể chuyển quãng tám lên cao hoặc xuống thấp. Thay vì chuyển Đô trưởng sang Fa trưởng, chẳng hạn, chúng ta có thể chuyển Đô trưởng sang Đô trưởng ở quãng tám thấp hơn hoặc cao hơn. Nếu nhắm tới cùng một tập quãng tám cho mỗi bài, chúng ta sẽ buộc tất cả nốt và hợp âm cụm lại gần nhau, giảm chiều của bài toán và làm cho quá trình huấn luyện đơn giản hơn. Trong khối mã trên, chúng ta có thể đạt được điều này bằng cách chỉ định quãng tám của cao độ. Ví dụ, thay vì
pitch.Pitch('F')để tính khoảng cách, ta có thể dùngpitch.Pitch('F3').Bất biến theo giọng và quãng tám
Một lựa chọn tự nhiên thay cho giảm chiều là tăng cường dữ liệu. Tức là, thay vì giảm tập các đầu vào và đầu ra có thể có, chúng ta mở rộng tập dữ liệu bằng cách thay đổi dữ liệu hiện có. Chúng ta cũng có thể làm điều này bằng chuyển giọng và chuyển quãng tám. Tuy nhiên, thay vì chuyển tất cả bài về cùng giọng và gom nốt ở các quãng tám kề nhau, chúng ta sẽ chuyển tất cả bài sang mọi giọng và mọi quãng tám. Điều này giúp đạt tính bất biến theo giọng và quãng tám trong mô hình. Xem Briot, Hadjerest, và Pachet (2019) để thảo luận việc dùng chuyển giọng cho hai mục đích khác nhau.
Thu thập dữ liệu
Chúng ta sẽ thu thập các tệp MIDI để huấn luyện mô hình từ Project Mutopia. Trước khi quét, đầu tiên chúng ta sẽ kiểm tra tệp robots tại https://www.mutopiaproject.org/robots.txt, để đảm bảo tôn trọng mọi yêu cầu về cách xử lý tương tác tự động với website. Tại thời điểm viết hướng dẫn này, tệp robots chứa nội dung trong khối mã dưới.
User-agent: *cho biết quy tắc sau áp dụng cho tất cả người dùng tương tác với website.Disallow:, theo sau là rỗng, nghĩa là không có hạn chế nào áp dụng cho việc quét.Trước hết chúng ta import
urlopenvàurlretrievetừurllib.request, dùng để gửi yêu cầu GET cho từng trang trong mục guitar rồi tải mỗi tệp MIDI được liên kết trên trang. Chúng ta importBeautifulSoupđể phân tích HTML trả về bởiurlopen, cho phép xác định các liên kết tệp MIDI. Cuối cùng, dùng mô-đuntimeđể tạm dừng 10 giây giữa các lần tải nhằm tránh gây áp lực lên tài nguyên của website.Mỗi bước của vòng lặp chính sẽ kiểm tra liệu số liên kết trên trang
linkCountcó vượt quá 0 không. Nếu có, nó sẽ dựng URL cho trang tiếp theo để truy cập,url, dùng các chuỗiurl0,url1và số bài hát,songNumber. Sau đó mở URL, phân tích HTML, tìm tất cả liên kết trên trang, đặt lạilinkCountvề 0, rồi duyệt qua từng liên kết trong danh sách. Nếu một liên kết chứa chuỗi con.mid, thì nó trỏ tới tệp MIDI và được tải về bằngurlretrieve()vào thư mụcsave_dir. Cuối cùng, chúng ta tăngsongNumberthêm 10, vì mỗi trang có 10 bài. Lặp lại quá trình này cho đến khi gặp một trang có ít hơn 10 liên kết.Với những ai muốn tìm hiểu thêm về scraping, xem khóa Web Scraping với Python và bài hướng dẫn Web Scraping bằng Python của DataCamp.