Безграничное поле для экспериментов: как я превращаю картинки в музыку с помощью Python

Оленка Пилипчак

Data Scientist (а по совместительству — физик и химик) Виктор Мурсия в своем блоге на Medium пишет, что музыка была с ним всегда. Он слушает ее каждый день и играет на гитаре больше 20 лет. Перед тем, как начать свое академическое физико-химическое путешествие, Виктор задумывался о музыкальной карьере. Передаем ему слово.


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

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

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

Моя стратегия и ход мыслей были таковы: 

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

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

Давайте попробуем!

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

HSV — это цветовое пространство, контролируемое тремя значениями: оттенок, насыщенность и яркость.

HSV-цилиндр / Источник: Wikipedia

Оттенок — это уровень, к которому раздражитель может быть описан как похожий или отличный от раздражителей, которые описаны как красный, оранжевый, желтый, зеленый, синий, фиолетовый. По сути, оттенок — это цвет.

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

Яркость определяется как восприятие, вызванное яркостью визуальной цели. Другими словами, насыщенность означает количество черного в этом цвете.

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

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

Я буду работать в цветовом пространстве HSV, поскольку оно уже разделено, поэтому дальнейшее отображение частот будет более интуитивным. 

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

#Need function that reads pixel hue value 
hsv = cv2.cvtColor(ori_img, cv2.COLOR_BGR2HSV)
#Plot the image
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. Авторские изображения — Victor Murcia

Исключение канала оттенка

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

i=0 ; j=0
#Initialize array the will contain Hues for every pixel in image
hues = [] 
for i in range(height):
    for j in range(width):
        hue = hsv[i][j][0] #This is the hue value at pixel coordinate (i,j)
        hues.append(hue)

Теперь, когда у меня есть массив, содержащий значение H для каждого пикселя, я размещу этот результат в pandas dataframe. Каждая строка dataframe — это пиксель, и, следовательно, каждый столбец будет содержать информацию об этом пикселе. Я назову этот dataframe pixels_df.

Вот он:

Сейчас dataframe состоит из одного столбца под названием «оттенки», где каждая строка представляет H канал для каждого пикселя на изображении, которое я загрузил.

Превращение оттенков в частоты

Моя начальная идея по преобразованию значения оттенка в частоту предполагала отображение между заранее определенным набором частот и значением H. Функция отображения показана ниже:

#Define frequencies that make up A-Harmonic Minor Scale
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 и массив, содержащий частоты для отображения H как inputs. В примере используется массив под названием scale_freqs для определения частот. Используемые в частоте scale_freqs соответствуют минорной гармонической гамме.

Затем определяется массив пороговых значений для H. Этот массив можно использовать для преобразования H в частоту scale_freqs с помощью лямбда-функции:

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

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

А теперь, когда у меня есть массив частот, я конвертирую столбец notes в массив numpy, который называется frequencies, поскольку потом я могу использовать это для создания аудиофайла, который можно воспроизвести.

Для этого я могу использовать функцию wavfile.write, которая встроена в scipy, и убедиться, что я использую соответствующее преобразование типа данных (для 1D-массивов это np.float32):

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

song = np.array([]) 
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
nPixels = 60
for i in range(nPixels):  
    val = frequencies[i]
    note  = 0.5*np.sin(2*np.pi*val*t) #Represent each note as a sign wave
    song  = np.concatenate([song, note]) #Add notes into song array to make song
    
ipd.Audio(song, rate=sr) # load a NumPy array as audio

Послушайте песню, которую я создал, используя первые 60 пикселей из изображения ниже (я мог бы попытаться использовать все 230 400 пикселей этой картинки, но песня будет длиться несколько часов):

 

Классно вышло! Но я бы еще немного ее усовершенствовал.

Добавляем октавы

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

song = np.array([]) 
octaves = np.array([0.5,1,2])
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
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) # load a NumPy array

Давайте послушаем! 

 

Прекрасно! Теперь звучит интереснее. Но у нас есть все эти пиксели: попробуем использовать их, выбирая частоты из случайных пикселей?

song = np.array([]) 
octaves = np.array([1/2,1,2])
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
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) # load a NumPy array

 

Мне нравится: теперь это фактически генератор песен, с которым можно играться сколько угодно!

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

Создание других шкал

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

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

def get_piano_notes():   
    # White keys are in Uppercase and black keys (sharps) are in lowercase
    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])
    # Trim to standard 88 keys
    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 клавишам стандартного пианино, на частоты в единицах Гц:

#Load note dictionary
note_freqs = get_piano_notes()

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

#Define tones. Upper case are white keys in piano. Lower case are black keys
scale_intervals = ['A','a','B','C','c','D','d','E','F','f','G','g']

А теперь мы можем найти индекс гаммы в предыдущем списке тонов. Это нужно, потому что я потом переиндексирую список, чтобы он начинался с нужной нам тоники:

#Find index of desired key
index = scale_intervals.index(whichKey)

#Redefine scale interval so that scale intervals begins with whichKey
new_scale = scale_intervals[index:12] + scale_intervals[:index]

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

#Choose scale
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')

Почти готово! Я также определю здесь интервалы для использования:

#Make harmony dictionary (i.e. fundamental, perfect fifth, major third, octave)
#unison           = U0 #semitone         = ST
#major second     = M2 #minor third      = m3 
#major third      = M3 #perfect fourth   = P4
#diatonic tritone = DT #perfect fifth    = P5
#minor sixth      = m6 #major sixth      = M6
#minor seventh    = m7 #major seventh    = M7
#octave           = 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
                     }

И теперь я могу взять результаты предыдущих шагов и создать песню!

#Get length of scale (i.e., how many notes in scale)
nNotes = len(scale)

#Initialize arrays
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 в качестве исходного изображения. Эта песня была создана с использованием третьей октавы в качестве основы в тональности ля мажор. Достаточно мило, не правда ли? 

Пиксельные китайские горы / Источник: Matej ‘Retro’ Jan

Песня павлина

Эта песня создана с использованием E Dorian и третьей октавы:

Источник: Anna Om

Песня воды

Эта песня была создана с использованием B Lydian и диапазона второй октавы. Моя любимая! Будет звучать очень круто как гитарный риф.

Автор фото: John Salatas

Песня Катерины

Я обожаю свою кошку Катерину. Эта песня создана с использованием гармонического минора А и диапазона третьей октавы. Звучит очень хорошо, как по мне. 

Добавляем гармонию в песне с помощью 2D-массивов NumPy

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

Ниже я покажу пример песни, созданной из изображения и соответствующей гармонии, объединенной в single .wav file с помощью двумерного массива numpy.

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

Обратите внимание, какова сейчас форма нашего массива (2, 264 600). Это означает, что у нас есть Nchannels = 2 и Nsamples = 264600. Чтобы убедиться, что у нашего numpy-массива правильная форма для scipy.io.wavfile.write, я сначала транспонирую массив. Песня создана с использованием гармонического минора A#, диапазона второй октавы и минорной терционной гармонии.

Добавление эффектов к нашим песням с помощью Pedalboard Library от Spotify

А еще я собираюсь загрузить файлы .wav и выполнить некоторые дополнительные манипуляции с ними с помощью pedalboard-модуля от Spotify. Вы можете прочитать больше о библиотеке pedalboard здесь и здесь.

Сначала я повторно обработаю «Песню воды», которую показал ранее, используя Compressor, Gain, Chorus, Phaser, Reverb и Ladder Filter.

Вот результат:

 

Теперь я повторно обработаю песню «Катерина», используя Ladder Filter, Delay, Reverb и PitchShift:

 

Звучит отлично!

И еще поиграюсь с песней, созданной из пейзажа. Использую LadderFilter, Delay, Reverb, Chorus, PitchShift и Phaser:

Использование Librosa для отображения других музыкальных величин

Librosa — отличный пакет, позволяющий выполнять различные операции над звуковыми данными. Попробуйте его обязательно! Здесь я использовал его, чтобы преобразовывать частоты в Notes и Midi Numbers.

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

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

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

#Convert frequency to a note
catterina_df['notes'] = catterina_df.apply(lambda row : librosa.hz_to_note(row['frequencies']), 
                                           axis = 1)  
#Convert note to a midi number
catterina_df['midi_number'] = catterina_df.apply(lambda row : librosa.note_to_midi(row['notes']), 
                                                 axis = 1)

Создание MIDI из песни

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

Чтобы создать MIDI-файл, я воспользуюсь пакетом midiutil. Этот пакет позволяет создавать MIDI-файлы из массива MIDI-номеров. Вы можете настроить свой файл разными способами, настроив громкость, темп и дорожки. Пока я сделаю только однодорожный MIDI-файл:

#Convert midi number column to a numpy array
midi_number = catterina_df['midi_number'].to_numpy()

degrees  = list(midi_number) # MIDI note number
track    = 0
channel  = 0
time     = 0   # In beats
duration = 1   # In beats
tempo    = 240  # In BPM
volume   = 100 # 0-127, as per the MIDI standard

MyMIDI = MIDIFile(1) # One track, defaults to format 1 (tempo track
                     # automatically created)
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 для дальнейшей обработки. А еще как с помощью этого метода можно построить гармонии, и из этого создать более сложные, насыщенные и/или странные гармонии. Тут просто безграничное поле для экспериментов!

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

Автор: Виктор Мурсия

Текст адаптировала Евгения Козловская

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023