Генерация музыки с помощью GPT-2

2417e03a49c3362056eafdb1985013a4.jpg

В этой статье мы поговорим о том, как с помощью ИИ генерировать музыку. Использовать мы будем обученную на хоралах И.С. Баха минимальную по количеству параметров модель 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, но тот результат, что мы имеем, тоже весьма неплох.

© Habrahabr.ru