Pull to refresh
2676.76
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Генерация музыки из изображений с помощью Python

Reading time 11 min
Views 7.3K
Original author: Victor Murcia

Сказать, что музыка является частью моей жизни, будет серьёзным преуменьшением. Я более 20 лет играю на гитаре, и каждый день слушаю других гитаристов. По правде говоря, прежде чем начать свой академический путь в качестве физхимика двенадцать лет назад, я был намерен строить музыкальную карьеру.

И хотя этим моим планам ну было суждено осуществиться, я всё равно продолжаю наслаждаться музыкой и делать с её помощью всякие крутые штуки. К примеру, этот проект возник из идеи написать программу, которая бы создавала музыку из изображений. В прошлом уже были довольно интересные попытки сделать нечто подобное, но вот результаты оказывались не особо музыкальными.

Далее в этой статье я расскажу о своём подходе к генерации из картинок аудиотреков, которые, имхо, звучат весьма неплохо. Здесь я опишу основные результаты и покажу некоторые удачные примеры программы. Если вы захотите посмотреть весь код, то он лежит на моём сайте и в репозитории GitHub. Я также создал с помощью Streamlit приложение, с которым вы можете поэкспериментировать здесь.

▍ Основная идея


Вот моя цепочка рассуждений:

  • изображения состоят из пикселей;
  • пиксели состоят из массивов чисел, определяющих цвет;
  • цвет выражается через цветовые пространства RGB, BGR либо HSV;
  • само цветовое пространство можно разбить на разделы;
  • музыкальные гаммы через звуковые интервалы подразделяются на ноты;
  • звук – это вибрация, в связи с чем каждая нота ассоциируется с частотой;
  • из всего этого следует, что подразделы цветового пространства можно сопоставлять с конкретными нотами в музыкальной гамме, имеющими соответствующую частоту.

Попробуем!

▍ Использование цветового пространства HSV


HSV или HSB – это цветовая модель, регулируемая тремя значениями – тоном, насыщенностью и яркостью.


Цилиндр HSV

Тон определяется «степенью, в которой стимул характеризуется похожим или отличающимся от стимулов, описываемых как красный, оранжевый, жёлтый, зелёный, синий, фиолетовый». Иными словами, тон представляет цвет.

Насыщенность определяется как «цветность области, оцениваемая пропорционально её яркости». То есть насыщенность отражает степень, до которой цвет смешан с белым.

Яркость определяется как «визуальное представление объекта, обусловленное степенью его освещённости». Иначе говоря, яркость отражает степень, в которой цвет смешан с чёрным.

Значения тона основных цветов:

  • оранжевый: 0–44
  • жёлтый: 44- 76
  • зелёный: 76–150
  • синий: 150–260
  • фиолетовый: 260–320
  • красный: 320–360

Я буду работать в цветовом пространстве HSV, потому что оно уже естественным образом разделено, что делает сопоставление с частотами более интуитивным. При этом канал тона (который в большей степени определяет цвет в этой модели) отделён от двух других каналов, что существенно всё упрощает.

Вот пример сравнения цветовых пространств изображения и код для их генерации:

# нужна функция, считывающая значение тона пикселя
hsv = cv2.cvtColor(ori_img, cv2.COLOR_BGR2HSV)
# построение изображения
fig, axs = plt.subplots(1, 3, figsize = (15,15))
names = ['BGR','RGB','HSV']
imgs  = [ori_img, img, hsv]
i = 0
for elem in imgs:
    axs[i].title.set_text(names[i])
    axs[i].imshow(elem)
    axs[i].grid(False)
    i += 1
plt.show()


Цветовые пространства. Автор оригинально изображения RGB — agsandrew

▍ Извлечение канала тона


Получив изображение в HSV, нам нужно извлечь значение тона (h) каждого пикселя. Это можно сделать с помощью вложенного цикла for, перебирающего изображение по высоте и ширине.

i=0 ; j=0
# инициализация массива, содержащего тон каждого пикселя изображения
hues = []
for i in range(height):
    for j in range(width):
        hue = hsv[i][j][0] # значение тона пикселя по координатам (i,j)
        hues.append(hue)

Получив массив значений h пикселей, я помещаю его в датафрейм Pandas.

В этом датафрейме, который я назвал pixels_df, слева указан порядковый номер пикселя, а справа его значение тона.


Создание pixels_df из значений h изображения

Сейчас датафрейм состоит из одного столбца hues, в котором каждая строка представляет канал h каждого пикселя загруженного изображения.

▍ Преобразование тонов в частоты


Мой изначальный замысел по преобразованию значений тона в частоту подразумевал сопоставление предопределённого набора частот со значением h. Вот соответствующая функция:

# определение частот, составляющих гамму ля минор гармонический
scale_freqs = [220.00, 246.94 ,261.63, 293.66, 329.63, 349.23, 415.30]
def hue2freq(h,scale_freqs):
    thresholds = [26 , 52 , 78 , 104,  128 , 154 , 180]
    note = scale_freqs[0]
    if (h <= thresholds[0]):
         note = scale_freqs[0]
    elif (h > thresholds[0]) & (h <= thresholds[1]):
        note = scale_freqs[1]
    elif (h > thresholds[1]) & (h <= thresholds[2]):
        note = scale_freqs[2]
    elif (h > thresholds[2]) & (h <= thresholds[3]):
        note = scale_freqs[3]
    elif (h > thresholds[3]) & (h <= thresholds[4]):    
        note = scale_freqs[4]
    elif (h > thresholds[4]) & (h <= thresholds[5]):
        note = scale_freqs[5]
    elif (h > thresholds[5]) & (h <= thresholds[6]):
        note = scale_freqs[6]
    else:
        note = scale_freqs[0]
    
    return note

Она получает значение h и массив частот. В данном примере для определения частот задействуется массив scale_freqs. Используемые в этом массиве частоты соответствуют гамме ля минор гармонический.

Далее я определяю массив значений порога thresholds для h, который буду использовать для преобразования h в частоту из scale_freqs при помощи лямбда-функции.

pixels_df['notes'] = pixels_df.apply(lambda row : hue2freq(row['hues'],scale_freqs), axis = 1) 


Датафрейм pixels_df, в котором частоты сопоставлены с каждым значением тона

▍ Преобразование массива NumPy в аудио


Круто! Теперь, имея массив частот, я преобразую столбец notes в массив NumPy frequencies, с помощью которого смогу сгенерировать аудио. Для этого я использую SciPy функцию wavfile.write и подходящий вид преобразования типа данных (для одномерных массивов это np.float32).

frequencies = pixels_df['notes'].to_numpy()

song = np.array([])
sr = 22050 # частота дискретизации
T = 0.1    # длительность 0.1 секунды
t = np.linspace(0, T, int(T*sr), endpoint=False) # переменная времени
#создание трека с помощью массива NumPy :]
#nPixels = int(len(frequencies))# все пиксели изображения
nPixels = 60
for i in range(nPixels):  
    val = frequencies[i]
    note  = 0.5*np.sin(2*np.pi*val*t) # представляет каждую ноту в виде синусоиды
    song  = np.concatenate([song, note]) # добавляет ноты в массив song для создания трека
    
ipd.Audio(song, rate=sr) # загружает массив NumPy в виде аудио

Вот трек, который я создал, используя первые 60 пикселей изображения ниже. Можно было задействовать все 230,400 пикселей, но тогда трек получился бы длиной в несколько часов.


Звучит весьма недурно, но мне хочется ещё над этим поработать.

▍ Добавление вариации по октавам


Я решил внести в программу эффект сдвига октав (то есть понижение или повышение звука). Используемая для конкретной ноты октава будет случайным образом выбираться из массива.

song = np.array([])
octaves = np.array([0.5,1,2])
sr = 22050 # частота дискретизации
T = 0.1    # длительность 0.1 секунды
t = np.linspace(0, T, int(T*sr), endpoint=False) # переменная времени
# создание трека с помощью массива NumPy :]
#nPixels = int(len(frequencies))# все пиксели изображения
nPixels = 60
for i in range(nPixels):
    octave = random.choice(octaves)
    val =  octave * frequencies[i]
    note  = 0.5*np.sin(2*np.pi*val*t)
    song  = np.concatenate([song, note])
ipd.Audio(song, rate=sr) # загрузка массива NumPy

Послушаем!


Превосходно! Мы получили некоторое разнообразие. Но все же у нас есть огромное число пикселей, так почему бы не использовать их путём случайного выбора?

song = np.array([])
octaves = np.array([1/2,1,2])
sr = 22050 # частота дискретизации
T = 0.1    # длительность 0.1 секунды
t = np.linspace(0, T, int(T*sr), endpoint=False) # переменная времени
# создание трека с помощью массива NumPy :]
#nPixels = int(len(frequencies))# все пиксели изображения
nPixels = 60
for i in range(nPixels):
    octave = random.choice(octaves)
    val =  octave * random.choice(frequencies)
    note  = 0.5*np.sin(2*np.pi*val*t)
    song  = np.concatenate([song, note])
ipd.Audio(song, rate=sr) # загрузка массива NumPy


Получился типа Calcucore! Теперь у нас, по сути, есть генератор треков, с которым можно вдоволь экспериментировать.

Я понимаю, прозвучит почти как мем, но «разве это не математический рок?»


▍ Генерация других гамм


Пока что я показал, как можно генерировать музыку из изображений, используя гамму ля минор гармонический. Но, несмотря на всю крутость этой гаммы, было бы неплохо внести немного разнообразия в начальную ноту (тонику) и добавить интервалы, помимо тех, что определяются её структурой. Это позволит нашей программе вносить в генерируемые треки куда больше нюансов и вариативности.

Чтобы это реализовать, первым делом мне нужно найти способ процедурно генерировать частоты для любой тоники, которую я захочу использовать. У Кэти Хе есть прекрасная статья, в которой она рассматривает возможность работы с музыкой при помощи Python. Я применил одну из представленных в той статье функций для сопоставления нот фортепиано с частотами:

def get_piano_notes():   
    # Белые клавиши указаны в верхнем регистре, а чёрные в нижнем
    octave = ['C', 'c', 'D', 'd', 'E', 'F', 'f', 'G', 'g', 'A', 'a', 'B']
    base_freq = 440 #Frequency of Note A4
    keys = np.array([x+str(y) for y in range(0,9) for x in octave])
    # Обрезка до стандартных 88 клавиш
    start = np.where(keys == 'A0')[0][0]
    end = np.where(keys == 'C8')[0][0]
    keys = keys[start:end+1]
    
    note_freqs = dict(zip(keys, [2**((n+1-49)/12)*base_freq for n in range(len(keys))]))
    note_freqs[''] = 0.0 # stop
    return note_freqs

Эта функция служит в качестве основы для моей программы генерации треков/гамм, и с её помощью можно создать словарь сопоставлений нот, соответствующих 88 клавишам стандартного фортепиано, с частотами в герцах, как это показано ниже:

# загрузка словаря нот
note_freqs = get_piano_notes()

Далее нужно определить интервалы гаммы в тонах, чтобы можно было индексировать ноты.

# Определение тонов. В верхнем регистре указаны белые клавиши, а в нижнем - чёрные
scale_intervals = ['A','a','B','C','c','D','d','E','F','f','G','g']

Теперь можно находить индекс нашей гаммы в списке тонов, приведённом выше. Это необходимо, поскольку далее я буду реиндексировать этот список, чтобы он начинался с нужной тоники.

# поиск индекса нужной клавиши
index = scale_intervals.index(whichKey)

# переопределение интервала гаммы, чтобы он начинался с определённой клавиши
new_scale = scale_intervals[index:12] + scale_intervals[:index]

После этого я смогу определять множество разных массивов гамм, в которых каждый элемент соответствует индексу из переиндексированного массива, полученного в предыдущем шаге.

  #выбор масштаба
    if whichScale == 'AEOLIAN':
        scale = [0, 2, 3, 5, 7, 8, 10]
    elif whichScale == 'BLUES':
        scale = [0, 2, 3, 4, 5, 7, 9, 10, 11]
    elif whichScale == 'PHYRIGIAN':
        scale = [0, 1, 3, 5, 7, 8, 10]
    elif whichScale == 'CHROMATIC':
        scale = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
    elif whichScale == 'DORIAN':
        scale = [0, 2, 3, 5, 7, 9, 10]
    elif whichScale == 'HARMONIC_MINOR':
        scale = [0, 2, 3, 5, 7, 8, 11]
    elif whichScale == 'LYDIAN':
        scale = [0, 2, 4, 6, 7, 9, 11]
    elif whichScale == 'MAJOR':
        scale = [0, 2, 4, 5, 7, 9, 11]
    elif whichScale == 'MELODIC_MINOR':
        scale = [0, 2, 3, 5, 7, 8, 9, 10, 11]
    elif whichScale == 'MINOR':    
        scale = [0, 2, 3, 5, 7, 8, 10]
    elif whichScale == 'MIXOLYDIAN':     
        scale = [0, 2, 4, 5, 7, 9, 10]
    elif whichScale == 'NATURAL_MINOR':   
        scale = [0, 2, 3, 5, 7, 8, 10]
    elif whichScale == 'PENTATONIC':    
        scale = [0, 2, 4, 7, 9]
    else:
        print('Invalid scale name')

Почти готово! Я также определю здесь интервалы на случай, если мне захочется использовать их при генерации музыки.

#создание словаря интервалов
#прима           = U0 #полутон         = ST
#большая секунда     = M2 #малая терция      = m3
#большая терция      = M3 #чистая кварта   = P4
#тритон = DT #чистая квинта    = P5
#малая секста      = m6 #большая секста      = M6
#малая септима    = m7 #большая септима    = M7
#октава           = O8
harmony_select = {'U0' : 1,
                      'ST' : 16/15,
                      'M2' : 9/8,
                      'm3' : 6/5,
                      'M3' : 5/4,
                      'P4' : 4/3,
                      'DT' : 45/32,
                      'P5' : 3/2,
                      'm6': 8/5,
                      'M6': 5/3,
                      'm7': 9/5,
                      'M7': 15/8,
                      'O8': 2
                     }
    

И вот теперь остался последний шаг, чтобы можно было начать генерировать треки.

# получение длины гаммы в нотах
nNotes = len(scale)

# инициализация массивов
freqs = []
#harmony = []
#harmony_val = harmony_select[makeHarmony]
for i in range(nNotes):
    note = new_scale[scale[i]] + str(whichOctave)
    freqToAdd = note_freqs[note]
    freqs.append(freqToAdd)
    #harmony.append(harmony_val*freqToAdd)

Прекрасно! Теперь у меня есть возможность экспериментировать с различными параметрами. Я могу устанавливать гамму, клавишу, октаву, интервал, количество пикселей, а также определять, будут ли пиксели выбираться случайным образом, и определять продолжительность каждой ноты.

Протестируем готовую программу на нескольких изображениях. Частота дискретизации во всех случаях составляет 22050 Гц, если иное не указано явно.

Тем, кто читает статью на смартфоне, я рекомендую слушать треки из следующего раздела в наушниках, поскольку некоторые низкие частоты стандартным динамиком телефона передаются плохо.

▍ Эксперимент с пиксельной графикой


Естественным полем для экспериментов является пиксельная графика. Вот один из множества треков, полученных на основе этого прекрасного произведения @Matej ‘Retro’ Jan. Данный трек был сгенерирован при использовании третьей октавы и тональности ля мажор. По-моему, звучит очень интересно. У меня он ассоциируется с прогулкой по небольшому жизнерадостному городку. На мой взгляд, этот трек отлично подойдёт в качестве интро для старой видеоигры.


Пиксельная версия China Mountains, Matej ‘Retro’ Jan, 2013, полученная на основе картины China Mountains Марты Наэль


▍ Песнь павлина


Этот трек с я получил, используя E Dorian и диапазон третьей октавы.




▍ Песнь воды


Этот трек мой любимый. Его я сгенерировал с использованием B Lydian и диапазона второй октавы. Мне кажется, он отлично звучал бы в качестве гитарного риффа. Напоминает музыку Dream Theatre.




▍ Песнь Каттерины


Моя кошка Каттерина приносит мне кучу радости. Этот трек я сделал, используя ля минор гармонический и диапазон третьей октавы. На мой взгляд, звучит очень классно.




▍ Добавление в треки интервалов через 2D-массивы NumPy


Моя программа позволяет добавлять в треки интервалы. Пользователь может выбирать, какой из них использовать (например, чистую квинту, малую сексту и т. п.), после чего с помощью показанных ранее программ из него выводится правильный диапазон нот. Ниже я покажу пример полученного из изображения трека, соответствующий интервал и результат их объединения с помощью 2D-массива NumPy в виде wav-файла.

Согласно документации для scipy.io.wavfile.write, если я хочу записать двухмерный массив в wav-файл, то его размеры должны соответствовать форме (Nsamples, Nchannels). Заметьте, что сейчас наш массив имеет форму (2, 264600), то есть количество Nchannels = 2, а Nsamples = 264600. Чтобы обеспечить правильность формы нашего массива для scipy.io.wavfile.write, я сперва его транспонирую. Этот трек был получен при использовании гармонического минора ля диез, диапазона второй октавы и малой терции.


Отражение неба в воде (Национальный парк Файордленд в Новой Зеландии). Фото из галлереи Mark Gray


▍ Добавление в треки эффектов с помощью библиотеки от Spotify


Великолепно! Хотя можно ещё многое сделать. Я собираюсь загрузить wav-файл и поиграться с ним, используя модуль pedalboard. Это прекрасный инструмент, и я настоятельно рекомендую вам с ним ознакомиться. Информацию о нём можно найти здесь и здесь.

Сначала я переделаю песнь воды, используя предлагаемые pedalboard настройки Compressor, Gain, Chorus, Phaser, Reverb и Ladder Filter. Вот результат:


Супер круто! Просто отпад! Представьте себе этот трек в сочетании с ударными или живыми инструментами.

Теперь я обработаю песнь Каттерины, используя Ladder Filter, Delay, Reverb и Pitch Shift. Вот результат:


Мм, довольно приятно!

Ну и в качестве последнего примера я заново сгенерирую трек на основе природного ландшафта из Национального парка, используя Ladder Filter, Delay, Reverb, Chorus, Pitch Shift и Phaser:


▍ Получение нот и номеров MIDI с помощью Librosa


Librosa – это прекрасный пакет, позволяющий производить с аудиоданными различные манипуляции. С ним я тоже советую ознакомиться. Здесь с его помощью я преобразовал частоты в ноты и номера MIDI.

MIDI формат позволяет использовать файлы на различных электронных инструментах, компьютерах и прочих аудио-устройствах, так что возможность сохранять в нём треки даст возможность экспериментировать с ними другим заинтересованным музыкантам или программистам.

Ниже показаны функции, которые можно использовать для сопоставления сгенерированных моей программой частот, чтобы получить музыкальные ноты и midi_numbers трека.

# преобразование частоты в ноту
catterina_df['notes'] = catterina_df.apply(lambda row : librosa.hz_to_note(row['frequencies']),
                                           axis = 1)  
# преобразование ноты в номер MIDI
catterina_df['midi_number'] = catterina_df.apply(lambda row : librosa.note_to_midi(row['notes']),
                                                 axis = 1)


Итоговый датафрейм трека, полученный с помощью librosa и pandas

▍ Получение трека в формате MIDI


Теперь, когда у меня есть датафрейм с частотами, нотами и номерами MIDI, я могу получить из него MIDI-файл, который позволит сгенерировать партитуру трека.

Создам я этот файл с помощью пакета midiutil, позволяющего генерировать MIDI-файлы на основе массивов MIDI-чисел. Этот инструмент позволяет изменять всяческие настройки вроде громкости, темпа и дорожек. Пока что я просто сделаю MIDI-файл с одной дорожкой.

# преобразование столбца номеров MIDI в массив NumPy
midi_number = catterina_df['midi_number'].to_numpy()

degrees  = list(midi_number) # номер MIDI ноты
track    = 0
channel  = 0
time     = 0   # в ударах
duration = 1   # в ударах
tempo    = 240  # в BPM
volume   = 100 # 0-127, согласно стандарту MIDI

MyMIDI = MIDIFile(1) # одна дорожка, по умолчанию устанавливается на 1 (дорожка темпа создаётся автоматически)
MyMIDI.addTempo(track,time, tempo)

for pitch in degrees:
    MyMIDI.addNote(track, channel, pitch, time, duration, volume)
    time = time + 1
with open("catterina.mid", "wb") as output_file:
    MyMIDI.writeFile(output_file)

▍ Заключение


Вот я и показал, как можно делать музыку из изображений и экспортировать её в wav-файлы для последующей обработки. Я также продемонстрировал использование этого метода для построения интервалов, которые могут получаться довольно сложными, богатыми и даже странными. Мне хочется добавить в свой инструмент ещё кое-что, но пока я это отложу. Поле для экспериментов здесь огромное!

Если у вас, как у музыканта, вдруг возникнут сложности с поиском нового звука, то попробуйте закинуть картинку в моё приложение, и, быть может, оно подкинет вам годную стартовую идею. Никогда не знаешь, откуда придёт вдохновение.

Для меня этот проект был очень интересен, и, надеюсь, вам его результаты тоже пришлись по нраву.

Для всех желающих я ещё раз продублирую ссылку на репозиторий GitHub и на приложение Streamlit.

Развлекайтесь, и благодарю за чтение!

Играй в нашу новую игру прямо в Telegram!
Tags:
Hubs:
+38
Comments 10
Comments Comments 10

Articles

Information

Website
ruvds.com
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
ruvds