В этой статье мы поговорим о том, как с помощью ИИ генерировать музыку. Использовать мы будем обученную на хоралах И. С. Баха минимальную по количеству параметров модель GPT-2. А сама музыка будет представлена в виде текста.
Текстовое представление для музыкальных композиций
Идея использовать GPT-2 и текстовое представления музыки пришла из статьи https://arxiv.org/pdf/2008.06048.pdf. Авторы обучили GPT-2 используя собственный набор токенов и у них получились весьма неплохие результаты, с которыми можно ознакомиться по ссылке. Сам же метод текстового представления мелодии взят из статьи https://arxiv.org/pdf/1808.03715.pdf.
Основная идея здесь заключается в том, что мы вводим «открывающие» и «закрывающие» токены для каждого элемента музыкальной композиции. В частности, момент начала звучания ноты обозначается токеном NOTE_ON, а момент окончания – NOTE_OFF. При этом, чтобы обозначить высоту ноты используются числа от 0 до 127. Пример: NOTE_ON=76.
В музыкальном произведении чаще всего содержится несколько одновременно звучащих голосов, каждый такой голос представляет из себя отдельную дорожку и обозначается в нашем представлении токенами TRACK_START и TRACK_END. Если в исходном виде все дорожки музыкальной композиции звучат одновременно, то в текстовом представлении все они расположены последовательно друг за другом, что позволяет работать с музыкой как с обычным текстом.
В качестве исходного формата музыкальной композиции будем использовать MIDI. Этот формат удобен тем, что представляет музыку в удобном для чтении и интерпретации виде при минимальной потере информации о звучании.
Преобразование MIDI в текст
Итак, для парсинга MIDI будем использовать библиотеку music21. Далее будет показан полный процесс преобразования мелодии в вид, пригодный для GPT-2.
Для считывания мелодии будем пользоваться методом parse() из модуля converter. Далее будем в цикле проходить по каждой дорожке. В начале и конце каждой итерации будем добавлять в итоговую строку токены начала и конца дорожки (TRACK_START и TRACK_END).
def preprocess_score(score):
"""
Обработка мелодии
:param score: исходная мелодия, считанная из midi файла
:return: текстовое представление исходной мелодии
"""
cur_piece_str = [PIECE_START] # переменная с текстовым представлением мелодии
meta_info = {} # словарь с информацией о тональности и размере произведения
# идем по дорожкам исходной мелодии
for part in score.parts:
# добавляем токен начала дорожки
cur_piece_str.append(TRACK_START)
# получаем текстовое представление для дорожки
cur_track_str = preprocess_track(part, meta_info)
# добавляем текстовое представление дорожки в итоговую строку
cur_piece_str.extend(cur_track_str)
# добавляем токен окончания дорожки
cur_piece_str.append(TRACK_END)
return cur_piece_str
Каждая дорожка состоит из тактов. Будем в цикле проходить по тактам каждой дорожки, попутно вставляя токены начала и конца такта (BAR_START и BAR_END). При этом, мы не будем рассматривать композиции, где меняется тональность или размер, чтобы не смущать нашу модель.
def preprocess_track(track, meta_info):
"""
Обработка одной дорожки музыкальной композиции
:param track: исходная дорожка
:param meta_info: информация о тональности и размере мелодии в дорожке
:return: текстовое представление дорожки
"""
# инициализируем список с текстовым представлением дорожки
# в качестве инструмента указываем 0 - это фортепиано
# в теории можно указать любой другой
# DENSITY - это "разреженность" нот. Более подробно тут - https://arxiv.org/pdf/2008.06048.pdf.
track_txt = [f'{INSTRUMENT}=0', 'DENSITY=1']
# считываем текущую дорожку поэлементно
for elem_part in track:
# если текущий элемент является тактом, то обрабатываем такт
if isinstance(elem_part, music21.stream.base.Measure):
# добавляем токен начала такта
track_txt.append(BAR_START)
# получаем текстовое представление такта
cur_bar_info = preprocess_bar(elem_part)
# заполняем словарь с информацией о тональности и размере произведения
for info_key in ['Key', 'Beat duration', 'Beat count']:
if info_key in cur_bar_info.keys() and info_key not in meta_info.keys():
meta_info[info_key] = cur_bar_info[info_key]
elif info_key in cur_bar_info.keys() and info_key in meta_info.keys():
# исключаем случаи, когда в произведении меняется тональность или размер
if cur_bar_info[info_key] != meta_info[info_key]:
raise ValueError('Key or time signature was changed')
cur_bar_time_sig = meta_info['Beat count']
# обработка случая пустого такта
# если текущий такт пустой то заполняем его паузой такой длительности, чтоб она заполнила такт
if not cur_bar_info['bar_txt']:
track_txt.append(f'{TIME_SHIFT}={cur_bar_time_sig * 4}')
else:
# если в такте что-то есть, то вставляем эту информацию
# в список для текстового представления дорожки
track_txt.extend(cur_bar_info['bar_txt'])
# добавляем токен окончая такта
track_txt.append(BAR_END)
else:
pass
return track_txt
В каждом такте есть набор нот. Нам нужно пройти в цикле по каждой ноте и записать ее в текстовое представление, обозначая токенами NOTE_ON и NOTE_OFF, а также указывая высоту и длительность каждой ноты.
def preprocess_bar(bar):
"""
Обработка такта
:param bar: исходный такт
:return: текстовое представление такта
"""
bar_txt = [] # список для текстового представления такта
bar_dict = {} # вспомогательный словарь
# предыдущее значение смещения ноты относительно начала произведения плюс ее длительность
# измеряется в четвертях
prev_offset = 0.0
# считываем такт поэлементно
for elem_measure in bar:
# если текущий элемент является тональностью
if isinstance(elem_measure, music21.key.Key):
# добавляем в словарь информацию о тональности
bar_dict['Key'] = str(elem_measure.asKey())
# если текущий элемент является размером
elif isinstance(elem_measure, music21.meter.base.TimeSignature):
# добавляем информацию о размере
bar_dict['Beat duration'] = str(elem_measure.beatDuration.quarterLength)
bar_dict['Beat count'] = elem_measure.beatCount
bar_dict['Time signature'] = elem_measure
# если текущий элемент является нотой или паузой
elif isinstance(elem_measure, music21.note.Note):
if elem_measure.isRest:
# если нашли паузу, то в текстовое представление добавляем токен TIME_SHIFT
bar_txt.append(f'{TIME_SHIFT}={elem_measure.duration.quarterLength * 4}')
else:
# если элемент не пауза, значит - нота
# добавляем токены начала о конца ноты и токен длительности TIME_SHIFT
note_list = [f'{NOTE_ON}={elem_measure.pitch.midi}',
f'{TIME_SHIFT}={elem_measure.duration.quarterLength * 4}',
f'{NOTE_OFF}={elem_measure.pitch.midi}']
# смещение текущей ноты относительно начала композиции
cur_offset = elem_measure.offset
# если смещение текущей ноты относительно начала произведения
# больше чем смещение предыдущей плюc ее длительность,
# то нужно добавить паузу
if cur_offset - prev_offset > 0:
shift_duration = cur_offset - prev_offset
bar_txt.append(f'{TIME_SHIFT}='
f'{shift_duration * 4}')
prev_offset = cur_offset
# добавляем в текстовое представление такта текстовое представление ноты
bar_txt.extend(note_list)
# обновляем смещение
prev_offset += elem_measure.duration.quarterLength
else:
pass
bar_dict['bar_txt'] = bar_txt
return bar_dict
Модель для генерации текста
GPT-2 – хороший вариант для генерации текста. Есть несколько версий этой модели, в том числе и легковесная, что позволяет обучать ее на локальной машине, не прибегая к использованию внешних мощностей (Например, google colab).
Пользуясь проектом, опубликованным здесь, была обучена GPT-2 на кусочках хоралов длинной 2, 4 и 8 тактов. Проанализировав, результаты генераций всех трех версий, был сделан выбор в пользу 4-х тактовой модели.
Используемая здесь модель умеет генерировать кусочки мелодии длиной четыре такта. Поэтому, чтобы сгенерировать аккомпанемент большей длины, мы разбиваем исходную мелодию на отрезки по четыре такта и работаем с каждым отдельно. Затем, после генерации, соединяем получившиеся отрезки мелодии и получаем готовую композицию.
def sample(priming_sample_file, result_file):
"""
Генерация аккомпанемента по данной мелодии
:param priming_sample_file: файл с исходной мелодией в текстовом виде
:param result_file: файл, куда надо положить результат генерации в формате midi
:return: нет возвращаемого значения
"""
tokenizer_path = os.path.join(CUR_FILE_PATH, "gpt2model_4_bars", "tokenizer.json")
tokenizer = PreTrainedTokenizerFast(tokenizer_file=tokenizer_path)
tokenizer.add_special_tokens({'pad_token': '[PAD]'})
model_path = os.path.join(CUR_FILE_PATH, "gpt2model_4_bars", "best_model")
model = GPT2LMHeadModel.from_pretrained(model_path)
logger.info("Model loaded.")
with open(priming_sample_file, 'r') as hfile:
priming_sample = hfile.read()
# генерируем список четырехтактовых кусочков мелодии
generated_list = generate_music(priming_sample, model, tokenizer)
# соединяем все в единое целое
full_generation = concat_gen_list(generated_list)
# преобразовываем текст в midi и сохраняем в файл
note_seq.note_sequence_to_midi_file(token_sequence_to_note_sequence(full_generation), result_file)
Полный код генерации аккомпанемента можно найти в этом репозитории: https://github.com/Vitaliy1234/muse_it/tree/gpt_4_bars_exp
Посмотреть на примеры сгенерированных мелодий можно здесь: https://github.com/Vitaliy1234/muse_it/tree/gpt_4_bars_exp/gpt2_model/generations
Итог
В этой статье мы, воспользовавшись текстовым представлением мелодии, смогли сгенерировать аккомпанемент с помощью модели GPT-2. Конечно, качество генерируемых мелодий можно улучшить путем обучения более тяжелой версии GPT-2, но тот результат, что мы имеем, тоже весьма неплох.