Как я писал музыку из космических лучей

День добрый, камрады!

Я пока только начинающий музыкант, зато есть опыт в программировании. И почему бы не взять какие-нибудь данные и попробовать из аудиализировать (это как визуализировать, только… кэпъ)?

Тащемта, план таков:


  • Найти данные
  • Придумать, как сконвертировать их в звук
  • Подправить параметры конвертера, чтобы было покрасивше
  • Остались ещё силы? goto 1


Ловим космические лучи

Данные я взял с сайта: http://www.tien-shan.org/she/vardbaccess/index.html
Конкретно, я использовал данные «Вариационных измерений» космических лучей за дефолтный период — 31.03–10.04 — Tien-Shan и Tien-Shan Underground (чтобы можно было ещё проследить какие-то корреляции или гармонии). И для разнообразия данных я скачал измерения «Гамма-излучения», тоже за дефолтный период — в этот раз 5.04–10.04.

Tien-Shan данные выглядят так:

31.03.2020  00:00:00    3813    5504    5187    5251    4637    4071    4568    4998    4922    4858    4956    4271    3997    4358    4715    4077    4160    3980
31.03.2020  00:01:00    3653    5308    5413    5371    4691    4090    4617    5139    5009    4762    5172    4309    4208    4387    4923    4248    4092    4108
31.03.2020  00:02:00    3763    5309    5292    5298    4588    4105    4608    5072    5070    4745    4834    4158    3918    4284    5115    4233    4011    3972
…
09.04.2020  23:57:00    3855    5308    5239    5190    4531    4063    4537    5035    5084    4863    5089    4261    4122    4395    5394    4186    4167    4078
09.04.2020  23:58:00    3955    5492    5416    5406    4458    4037    4474    5122    4942    4733    5168    4330    4026    4357    5283    4059    4174    3857
09.04.2020  23:59:00    3811    5378    5334    5121    4472    3955    4334    4992    4940    4646    4822    4378    4137    4195    4880    4049    4002    3817

График Tien-Shan:
u6ksuqvsrkicyctmqushnt3jfcq.png

Структура данных Tien-Shan Underground идентична структуре выше, но сами значения сильно ниже:

31.03.2020  00:00:00    20  32  29  26  20  20  17  14  16
31.03.2020  00:01:00    14  16  17  27  20  27  9   15  7
31.03.2020  00:02:00    13  22  16  22  15  18  12  15  11
…
09.04.2020  23:57:00    17  19  20  27  20  17  11  18  16
09.04.2020  23:58:00    14  33  19  19  18  16  15  16  9
09.04.2020  23:59:00    16  31  23  25  21  22  16  10  14

График Tien-Shan Underground:
vrjl4ztxzvlaqgal81ick74hxzi.png

И данные гамма-излучения выглядят так:

05.04.2020  00:00:02    20640   5345    4726    3532    2365    2118    1261    891 655 334 252 194
05.04.2020  00:00:12    21160   5295    4666    3526    2365    2138    1250    852 627 309 235 190
05.04.2020  00:00:22    19581   5189    4562    3536    2401    2130    1229    820 618 324 243 172
…
09.04.2020  23:59:37    22381   5134    4505    3429    2274    2044    1223    823 569 275 205 154
09.04.2020  23:59:47    21917   5186    4577    3448    2273    2044    1166    803 563 293 214 165
09.04.2020  23:59:58    20930   5275    4644    3561    2382    2148    1248    868 609 289 232 176

График Radio point Mu1:
83pvtcipiwimprfy2gfetfsitaw.png

Если я буду использовать какие-то другие данные — я обновлю декларацию константных исходников (абзац выше)


Первая попытка

Первая попытка у нас влоб. У нас есть числа. И, например, Audacity тоже может принимать на вход числа. Но! Эти числа разные, поэтому нам нужно привести наши космические лучи во что-то понятное программам.

В данном случае, Audacity принимает float значения амплитуды от -1.00000 до 1.00000, где -1/1 = 0dB — максимальная громкость, которая снижается при приближении к нулю с обеих сторон.

Нам на руку играет то, что этот csv, судя по хелпу, парсит значения по (\s\t\n)+, так что мы даже сможем отформатировать наши данные, чтобы проще в них разбираться.


Немного о импорте данных в Audacity

Вот примеры raw данных для Audacity с их сайта:

Один цикл волны с частотой 1кГц:

;This is a comment and will be ignored.
0.07100 0.14056 0.20727 0.26978 0.32682 0.37724 0.42001 0.45428 0.47933 0.49468 0.50000 0.49518 0.48034 0.45575 0.42193 0.37957 0.32951 0.27277 0.21050 0.14397 0.07452 0.00356 -0.06747 -0.13713 -0.20402 -0.26677 -0.32411 -0.37489 -0.41807 -0.45278 -0.47831 -0.49415 -0.49997 -0.49566 -0.48131 -0.45721 -0.42384 -0.38188 -0.33218 -0.27575 -0.21373 -0.14738 -0.07804 -0.00712 0.06394 0.13370 0.20076 0.26375 0.32139 0.37252 0.41611 0.45125 0.47726 0.49359 0.49992 0.49612 0.48226 0.45864 0.42571 0.38416 0.33483 0.27871 0.21694 0.15078 0.08156 0.01068 -0.06040 -0.13027 -0.19749 -0.26072 -0.31866 -0.37014 -0.41412 -0.44971 -0.47618 -0.49301 -0.49984 -0.49655 -0.48319 -0.46004 -0.42757 -0.38643 -0.33747 -0.28166 -0.22015 -0.15417 -0.08507 -0.01425

Для стерео мы пишем два значения для каждого канала:

0.00000 -0.00000
0.10000 -0.10000
0.20000 -0.20000
0.30000 -0.30000
0.40000 -0.40000
0.50000 -0.50000
0.60000 -0.60000
0.70000 -0.70000
0.80000 -0.80000
0.90000 -0.90000
1.00000 -1.00000

Здесь у нас постепенное усиление громкости в обоих каналах, но правый относительно левого имеет обратную фазу (когда волна в левом идёт вверх, в правом — идёт вниз).

**Фазовая инверсия одного из каналов может вылиться в тишину, когда стерео совмещается в моно. Потому что равные значения с разным знаком аннигилируются в ноль, а ноль — это, как мы помним, тишина — 0dB.


PoC — Proof of Concept. ЧЧР — Что-то, что работает.

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

Для того, чтобы Audacity съел наши данные и не подавился, нам нужно сконвертировать данные в формат float [-1.00000–1.00000]. Для этого подведёт небольшую статистику по данным: минимум, максимум, медиана, среднеарифметическое и мода.

Берём первый попавшийся скриптовый язык (для меня это F12), и давайте напишем скрипт, который из массива нам посчитает все нужные данные.

Сначала разделим колонки. Для первого теста можно и вручную в саблайме с помощью Find & Replace и регулярки: (.+?\t){x}(.+?)\t.+?\n->\2,`, где х — это номер нужного столбца + 1 (там два сдвига вначале).

Получим csv вида:

3813,3790,3801,3833,3674,3822,3639,3848,3866,3794,3747,3938,3823,3989,3963,3852,3836,3694,3883,3748,3802,3884,3790,3684,3895,3872,3885,4011,3844,3901,3713,3870,3868,3772,3866,3939,3856,3720,3640,3929,3905,...

Дальше пишем функцию, которая нам будет считать арифметические данные:

a = [1, 2, 3, 4, 5]
function parse_array(arr) {
    console.log("Min: " + Math.min(...arr));
    console.log("Max: " + Math.max(...arr));
    console.log("Avg: " + arr.reduce((a, b) => a + b) / arr.length);
// console.log("Min: " + Math.min(...arr) + "; Max: " + Math.max(...arr) + "; Avg: " + arr.reduce((a, b) => a + b) / arr.length);
}

> parse_array(a)
    1
    5
    3

Отлично, теперь прогоняем через реальные данные, и получаем такую табличку для всех колонок в файле Tien-Shan:

1: Min: 3514; Max: 4192; Avg: 3844.4135528815705
2: Min: 4934; Max: 5741; Avg: 5358.663125307817
3: Min: 4945; Max: 5703; Avg: 5313.77323577007
4: Min: 4882; Max: 5725; Avg: 5281.23640329276
5: Min: 4219; Max: 4953; Avg: 4598.3294167311615
6: Min: 3712; Max: 4426; Avg: 4075.323506648843
7: Min: 4168; Max: 4966; Avg: 4530.841131358616
8: Min: 4576; Max: 5344; Avg: 4973.967775979737
9: Min: 4608; Max: 5478; Avg: 4976.573559417435
10: Min: 4459; Max: 5261; Avg: 4795.212692605362
11: Min: 4574; Max: 5380; Avg: 4989.718215718005
12: Min: 3944; Max: 4757; Avg: 4331.33553788785
13: Min: 0 (3771); Max: 4428; Avg: 4095.4295363399706
14: Min: 0 (4084); Max: 4865; Avg: 4407.586716386407
15: Min: 0 (4384); Max: 5394; Avg: 4866.680292689791
16: Min: 0 (3837); Max: 4703; Avg: 4238.36325898825
17: Min: 0 (3786); Max: 4661; Avg: 4204.146837402378
18: Min: 0 (3753); Max: 4596; Avg: 4152.545697600788

*В скобках минимальное значение не считая нуля, duh.

Давайте теперь подправим нашу функцию, чтобы она сразу конвертировала наши данные в float [-1.00000–1.00000] формат:

a = [1, 2, 3, 4, 5]
function parse_array(arr) {
    arr = arr.filter(a => a != 0); // to remove zeros
    min = Math.min(...arr);
    max = Math.max(...arr);
    avg = arr.reduce((a, b) => a + b) / arr.length;
   console.log("Min: " + min + "; Max: " + max + "; Avg: " + avg);

   floatify = function (a) {
      if (a == 0) return 0;
      if (a > avg) {
         return (a - avg) / (max - avg);
      } else {
         return (a - min) / (avg - min) - 1;
   }
   console.log(arr.map(a => floatify(a)));
}

> parse_array(a)
   Min: 1; Max: 5; Avg: 3
   [-1, -0.5, 0, 0.5, 1]

Получаем данные вида:

-0.09502086396735898
-0.579290635757401
-0.24635516765174703
-0.1646346436621775
-0.1313410968516121
-0.03448714249360363

Загружаем их в Audacity через Tools → Sample Data Import…

И получаем что-то подобное (первый столбец Tien-Shan):
jdagl_qqaxkbvipb58cr-zkaehu.png

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

Отбой.


Вторая попытка (неудачная)

Давайте пересмотрим исходные данные. Мне уже давно чесало мозг то, что столбцы в данных не подписаны. Поэтому я нашёл другую таблицу, где уже чуть больше понятно:

Timestamp    FractionalDate    UncorrectedCountRate[cts/min]    CorrectedCountRate[cts/min]    Pressure[mbar]
2020-03-11T00:00:00Z    71.0000000  7150    6702    991.00
2020-03-11T00:30:00Z    71.0208333  7205    6749    990.91
2020-03-11T01:00:00Z    71.0416667  7214    6750    990.75
2020-03-11T01:30:00Z    71.0625000  7250    6776    990.61
2020-03-11T02:00:00Z    71.0833333  7275    6792    990.45

И нужно поменять подход, потому что данные меняются слишком быстро и слишком хаотично.

Два варианта:
Использовать точки как значение амплитуды и добавить промежуточные колебания (данные→амплитуда)
Использовать точки как данные для косвенной информации: detune, частота итд


Overtone

Давайте, чтобы проще генерировать звуки и музыку, установим какое-нибудь ПО. Как раз я давно планировал познакомиться с языками программирования, на которых можно писать музыку. Так что я остановился на языке Overtone (только потому что вот: https://www.youtube.com/watch? v=imoWGsipe4k).

Ставим по инструкции: https://github.com/overtone/overtone
Запаситесь парой часов-дней в зависимости от опыта на установку и настройку Ovetone. Там нужны: Java, Clojure, Supercollider, Leiningen, JackD и обязательно будут проблемы между jackd, pulseaudio и alsa. Это же линукс!
На винде у меня не получилось установить Clojure и Leiningen.
Мак у меня есть, но на нём тоже линь, поэтому проверить не могу.


Clojure

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

У нас есть Clojure, и данные.
Проверим первое. В проекте, из которого вы запускаете lein repl (это в инструкциях по установке было), создадим файл с названием tone.clj, в нём у нас будет скрипт, который мы сможем запустить из repl-а.

Добавим в файл одну строку:

(demo 0.5 (sin-osc 440))

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

Переходим в
lein repl
Импортим ovetone
user=> (use ‘overtone.live)
И открываем наш файл
user=> (load-file "tone.clj”)

Слышим звук? Чудесно!
Не слышим? Что-то произошло не по инструкциям установки. Извините, я не смогу помочь без конкретностей…


Данные

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

Делается это не шибко сложно:

user=> (def a [1,2,3])
#'user/a
user=> (prn-str a)
"[1 2 3]\n"
user=> (spit "stored-array.dat" (prn-str a))
nil
user=> (slurp "stored-array.dat")
"[1 2 3]\n"

*это уже не ЖС, это Clojure, на котором запущен overtone.live — см инструкции

Заодно мы проверили, в каком виде экспортируется массив в Clojure. Нам нужно будет убрать запятые из наших исходных данных, обрамить их квадратными скобками, и, вероятно, даже схлопнуть в одну строку — это мы проверим.

UPD: Да, кложура парсит и \n символы, поэтому добавим небольшую функцию для комфорта и услады глаз:

user=> (defn trim [input] (clojure.string/replace input #"\n" ""))
#'user/trim

Теперь, если у нас есть переносы строк в данных, мы их легко потрём (\n, а не данные…):

user=> (trim (slurp "stored-array.dat”))
"[1 2 3]”

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


Третья попытка

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

Имя этой идее — Shadertoy.com

Ожидали? Я сам не ожидал. А там можно генерировать не только картинку, но и звук!
И всего лишь одной строкой:

// Возвращает vec2() для правого и левого канала
vec2 mainSound(float time) {
    // 6.2831 ~ 2pi
    // exp() экспоненциально падает, создавая затухание звука
    // Косинус для создания синусоиды с заданной частотой
    return vec2(sin(6.2831 * 440.0 * time) * exp(-2.0 * time));
}

**(ну… почти одной…, но самая важная — одна)

А ещё есть отличная особенность — shadertoy можно эмбеддить, поэтому вот вам нота ля:

Дальше научимся играть несколько нот:

// Возвращает vec2() для правого и левого канала
vec2 mainSound(float time) {
    vec2 result = vec2(sin(6.2831 * 440.0 * time) * exp(-2.0 * time));

    // One second later
    if (time > 1.0) {
        // (time - x) нужен, потому что нам нужно сбросить
        // начальное число для exp() функции, иначе результат
        // exp() будет глобальным и мы услышим только первую ноту
        result = vec2(sin(6.2831 * 262.0 * time) * exp(-2.0 * (time - 1.0)));
    }

    return result;
}

В туториале на Shadertoy кода чуть больше, но и мелодия повеселее!

Ещё пара вещей, чтобы звук был красивее — это:


  • Написать структуру для наших нот, чтобы было удобнее их создавать.
    struct Note {
    // в Герцах
    float frequency;
    // Пауза - на какой секунде сыграть ноту
    float offset;
    // Длительность дробью (1, 2, 0.25, 1/16…)
    float duration;
    };
  • Вынести вычисление амплитуды (конечного числа для return) в функцию:
    float noteFreq(Note note, float time) {
    // 6.2831 = 2pi
    // exp() экспоненциально падает, создавая затухание звука
    // Косинус для создания синусоиды с заданной частотой
    return cos(6.2831 * note.frequency * time) * exp(-1.0/note.duration * (time - note.offset));
    }
  • И подправлять частоту, полученную из «космического луча», подгоняя её под существующую ноту (в западной системе; это где 12 полутонов и вся классика).

    // Maps frequency to the nearest note from [scale]
    float nearestNote(float value) {
    // Найти последнюю ноту из набора, чья частота меньше заданной
    for (int i = 1; i < scale.length(); i++) {
        if (scale[i] > value) {
            return scale[i - 1];
        }
    }
    
    // Проблемы с входящими данными
    return scale[0];
    }

Оба блока надо добавить над vec2 mainSound(float time) {...} функцией.

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


Космические лучи

Пришла пора добавлять космические лучи в наш код. Я подготовил данные с того же Тянь-Шаня, благо смог придумать, как их оформить.

В задумке, чтобы не было слишком скучно, я взял три столбца из двух таблиц (два из надземной и один из подземной станции). Дополнительно, я изменил скорость и длительность «вызова» нот, получив мелодию с двумя ритмами: 2:1 (4:1) у высокой и бас партий и 3:1 у средней партии. Чтобы все партии закончились одновременно, я взял соответствующее количество значений для каждой из партий, пропорционально темпу. Получилось так:

const float leadTempoRatio = 2.0; // 2 ticks per second
const float midTempoRatio = 2.0/3.0; // 1.5 ticks/second
const float bassTempoRatio = 1.0; // 1 tick per second

// 200 items, see above
const float[] dataLead = float[] (5504.0, 5308.0, 5309.0, 5289.0, 5225.0, 5208.0, 5190.0, 5250.0, 5362.0, 5486.0, 5314.0, 5467.0, 5292.0, 5305.0, 5167.0, 5423.0, 5402.0, 5280.0, 5420.0, 5428.0, 5260.0, 5306.0, 5379.0, 5283.0, 5234.0, 5340.0, 5252.0, 5568.0, 5476.0, 5248.0, 5494.0, 5480.0, 5230.0, 5609.0, 5323.0, 5392.0, 5304.0, 5478.0, 5321.0, 5435.0, 5179.0, 5444.0, 5289.0, 5413.0, 5275.0, 5389.0, 5500.0, 5221.0, 5276.0, 5356.0, 5250.0, 5414.0, 5269.0, 5269.0, 5216.0, 5512.0, 5410.0, 5300.0, 5426.0, 5433.0, 5156.0, 5482.0, 5281.0, 5377.0, 5279.0, 5317.0, 5111.0, 5455.0, 5435.0, 5239.0, 5353.0, 5342.0, 5519.0, 5242.0, 5281.0, 5226.0, 5374.0, 5190.0, 5232.0, 5292.0, 5466.0, 5298.0, 5265.0, 5521.0, 5435.0, 5252.0, 5245.0, 5506.0, 5491.0, 5343.0, 5390.0, 5287.0, 5349.0, 5332.0, 5515.0, 5358.0, 5369.0, 5396.0, 5187.0, 5308.0, 5322.0, 5207.0, 5355.0, 5388.0, 5265.0, 5217.0, 5254.0, 5494.0, 5306.0, 5380.0, 5352.0, 5297.0, 5395.0, 5387.0, 5410.0, 5448.0, 5301.0, 5182.0, 5465.0, 5327.0, 5617.0, 5362.0, 5417.0, 5470.0, 5549.0, 5283.0, 5425.0, 5419.0, 5307.0, 5405.0, 5286.0, 5228.0, 5400.0, 5426.0, 5378.0, 5396.0, 5514.0, 5393.0, 5314.0, 5318.0, 5431.0, 5236.0, 5257.0, 5239.0, 5447.0, 5439.0, 5399.0, 5484.0, 5455.0, 5226.0, 5586.0, 5491.0, 5338.0, 5390.0, 5275.0, 5278.0, 5474.0, 5332.0, 5320.0, 5355.0, 5387.0, 5435.0, 5406.0, 5196.0, 5363.0, 5500.0, 5466.0, 5443.0, 5248.0, 5510.0, 5342.0, 5270.0, 5123.0, 5485.0, 5318.0, 5469.0, 5249.0, 5330.0, 5406.0, 5543.0, 5203.0, 5281.0, 5395.0, 5416.0, 5249.0, 5252.0, 5372.0, 5397.0, 5327.0, 5260.0, 5430.0, 5334.0, 5309.0, 5435.0, 5381.0, 5324.0, 5399.0, 5504.0, 5320.0, 5458.0);

// 150 items
const float[] dataMid = float[] (3813.0, 3653.0, 3763.0, 3790.0, 3801.0, 3833.0, 3674.0, 3822.0, 3639.0, 3848.0, 3866.0, 3794.0, 3747.0, 3938.0, 3823.0, 3989.0, 3963.0, 3852.0, 3836.0, 3694.0, 3883.0, 3748.0, 3802.0, 3884.0, 3790.0, 3684.0, 3895.0, 3872.0, 3885.0, 4011.0, 3844.0, 3901.0, 3713.0, 3870.0, 3868.0, 3772.0, 3866.0, 3939.0, 3856.0, 3720.0, 3640.0, 3929.0, 3905.0, 3811.0, 3811.0, 3899.0, 3699.0, 3868.0, 3892.0, 3746.0, 3878.0, 3778.0, 3894.0, 3740.0, 3709.0, 3710.0, 3812.0, 3856.0, 3811.0, 3935.0, 3850.0, 3859.0, 3800.0, 3748.0, 3725.0, 3814.0, 3897.0, 3745.0, 3763.0, 3833.0, 3964.0, 3770.0, 3846.0, 3776.0, 3945.0, 3791.0, 3799.0, 3709.0, 3922.0, 3825.0, 3804.0, 3869.0, 3829.0, 3770.0, 3838.0, 3820.0, 3734.0, 3979.0, 3765.0, 3764.0, 3857.0, 3861.0, 3869.0, 3787.0, 3963.0, 3780.0, 3847.0, 3759.0, 3857.0, 3782.0, 3711.0, 3843.0, 3909.0, 3839.0, 3811.0, 3874.0, 3849.0, 3883.0, 3925.0, 3752.0, 3847.0, 3731.0, 3824.0, 3905.0, 3901.0, 3926.0, 3897.0, 3751.0, 3896.0, 3752.0, 3854.0, 3936.0, 3767.0, 3812.0, 3933.0, 3889.0, 3808.0, 3703.0, 3948.0, 3883.0, 3872.0, 3762.0, 3870.0, 3899.0, 3818.0, 3900.0, 3774.0, 3951.0, 3818.0, 3893.0, 3821.0, 3823.0, 3801.0, 3833.0, 3744.0, 3769.0, 3864.0, 3923.0, 3974.0, 3810.0);

// 100 items
const float[] dataBass = float[] (20.0, 14.0, 13.0, 14.0, 10.0, 23.0, 16.0, 18.0, 6.0, 16.0, 18.0, 7.0, 15.0, 13.0, 14.0, 22.0, 12.0, 14.0, 12.0, 14.0, 19.0, 18.0, 8.0, 16.0, 13.0, 12.0, 21.0, 17.0, 22.0, 19.0, 11.0, 16.0, 16.0, 24.0, 16.0, 20.0, 18.0, 20.0, 17.0, 22.0, 23.0, 14.0, 17.0, 16.0, 23.0, 14.0, 15.0, 10.0, 12.0, 14.0, 20.0, 22.0, 14.0, 14.0, 20.0, 24.0, 14.0, 23.0, 20.0, 25.0, 14.0, 17.0, 21.0, 21.0, 18.0, 18.0, 13.0, 11.0, 21.0, 22.0, 16.0, 13.0, 24.0, 26.0, 29.0, 27.0, 18.0, 23.0, 15.0, 18.0, 8.0, 17.0, 24.0, 14.0, 12.0, 18.0, 16.0, 15.0, 25.0, 12.0, 19.0, 11.0, 18.0, 25.0, 21.0, 16.0, 19.0, 24.0, 19.0, 14.0);

Значения не самые красивые. А что важнее — они не несут никакого смысла в плане музыки (не в плане физики звука, а именно в плане музыки). Поэтому напишем конвертеры для каждой из партий (максимально субъективный код):

// Конвертирует значение высокой партии в "правильную” высокую частоту
// Можно не аккуратничать, потому что nearestNote()
// поправит все отклонения от "правильных” нот
float fixLead(float value) {
    return nearestNote(value / 8.0);
}

// Конвертирует значение средней партии в "правильную” среднюю частоту
float fixMid(float value) {
    return nearestNote(value / 10.0);
}

// Конвертирует значение бас партии в "правильную” бас частоту
float fixBass(float value) {
    return nearestNote(value * 7.0);
}

И последний штрих — это добавить нашу гамму — наш набор нот, которые будут звучать.
Один из главных секретов музыки — внутри музыкальной системы (в нашем случае это классический темперированный строй), чем больше следуешь правилам — тем приятнее получаются звуки.
В нашем случае правилом будет создание гамм. Точнее, поиск. Точнее, просто copy-paste, потому что гаммы уже давно придуманы :)

//"Правильные" ноты. Здесь - C Major гамма от C0 до B8
const float[] scale = float[] (16.35, 18.35, 20.60, 21.83, 24.50, 27.50, 30.87, 32.70, 36.71, 41.20, 43.65, 49.00, 55.00, 61.74, 65.41, 73.42, 82.41, 87.31, 98.00, 110.00, 123.47, 130.81, 146.83, 164.81, 174.61, 196.00, 220.00, 246.94, 261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25, 587.33, 659.25, 698.46, 783.99, 880.00, 987.77, 1046.50, 1174.66, 1318.51, 1396.91, 1567.98, 1760.00, 1975.53, 2093.00, 2349.32, 2637.02, 2793.83, 3135.96, 3520.00, 3951.07, 4186.01, 4698.63, 5274.04, 5587.65, 6271.93, 7040.00, 7902.13);

Сегодня это гамма До Мажор от C0 до B8, что значит, что у нас в распоряжении есть 8 октав по 7 нот. И все они будут звучать хорошо друг с другом (если не перестараться, конечно).

Именно к этим частотам будут «подтягиваться» наши космические звуки после того как мы прогоним их через fix*() функцию.

Важно: Нужно помнить, что сначала мы приводим число к частоте, а потом уже эту частоту приводим к конкретной ноте.

В итоге, в mainSound(float time) {...} функции нам нужно только пройти по всем нашим космическим данным, перевести их в частоту звука и вычислить амплитуду по уже давно известной функции noteFreq().

float result = 0.0;

// Высокая партия
for (int i = 0; i < dataLead.length(); i++) {
    // Высокая партия; Скорость - 2.0x, все ноты - четвертные (1/4), старт со 2 секунды
    Note noteLead = Note(fixLead(dataLead[i]), float(i) + 2.0, 1.0/4.0);

    // result = звук
    // += потому что мы накладываем звуки новых и ещё звучащих нот
    if (time > noteLead.offset) {
        float amplitude = noteFreq(noteLead, time);
        result += amplitude;
    }
}

// Возвращает vec2() для правого и левого канала
return vec2(result);

То же и для двух других партий. Это есть по ссылке в конце. Сначала бонус.


Бонус

У Shadertoy есть специфика — он сначала рендерит аудио, а потом играет аудио поверх реал-тайм рендера картинки. Поэтому извне в музыку не залезть. И сами данные музыки там тоже извне не извлечь. Поэтому пойдём на хитрость.

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

Для этого, перенесём весь код из вкладки Sound во вкладку Common, оставив только главную функцию:

// Звук рендерится перед запуском шейдера
// Поэтому нет возможности передать данные извне
// или достать данные из аудио
vec2 mainSound(float time) {
    // Генерируем шаг в аудио
    return _mainSound(time);
}

И создадим буфер для нашей новой текстуры (вкладка Buf A):

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
    // Генерируем шаг в аудио
    _mainSound(iTime);

    // Рендерим наш пиксель в текстуру
    fragColor = vec4(FRAG_COLOR);
}

Это позволяет нам пользоваться нашей генерацией в главном рендере дважды — в пререндере для музыки, и во время рендера для картинки.

И музыка и картинка имеют доступ к глобальной переменной времени (iTime или time). Поэтому мы можем сделать визуализацию «под фонограмму». Для простого отображения наших звуков, добавим ещё переменную для цвета каждой «ноты» в каждый момент времени (высокие ноты — синий; средние ноты — зеленый; низкие ноты — красный):

float result = 0.0;
vec4 frag_color = vec4(0.0);
frag_color.a = 1.0;

// High voice
for (int i = 0; i < dataLead.length(); i++) {
    // Высокая партия; Скорость - 2.0x, все ноты - четвертные (1/4), старт со 2 секунды
    Note noteLead = Note(fixLead(dataLead[i]), float(i) + 2.0, 1.0/4.0);

    // result = звук
    // frag_color = цвет пикселя
    // += потому что мы накладываем звуки новых и ещё звучащих нот
    if (time > noteLead.offset) {
        float amplitude = noteFreq(noteLead, time);
        result += amplitude;
        frag_color.b += amplitude;
    }
}

// Сохраняем значение пикселя
// Это используется во вкладке Buf A
FRAG_COLOR = frag_color;

// Возвращает vec2() для правого и левого канала
return vec2(result);

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


Уборка

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

Но я нашёл небольшой шейдер по созданию звёзд в космосе (https://www.shadertoy.com/view/XlfGRj) и добавил его в код. Выглядит потрясно!

Вот конечный вариант нашей космической музыки (с тем чужим шейдером):

Видео с выбранным мной звёздным небом я записал и выложил на ютуб: https://www.youtube.com/watch? v=r-4RJfCpepE

Если вдруг кому нужно, вот сгенерированный wav файл: https://soundcloud.com/arsenii-lisunov/cosmic-rays
**можно скачать…

Всем спасибо за ваш интерес!
Я не могу ручаться, что текст написан понятно, но могу обещать разжевать все непонятные моменты (которые я сам понимаю) в комментариях.

Всем ️

© Habrahabr.ru