В новый год всегда хочется чего-то нового. А даже если не хочется, то сознание, ещё не остывшее от работы, всё равно само возьмёт и подкинет какую-нибудь офигительную идею. И если в прошлом году накануне праздников я генерил нейросетью панельные дома, то в этом году меня занесло в совершенно неожиданную комбинацию технологий — NFT, модульные синтезаторы и javascript. Казалось бы, какая между ними связь? Обо всём понемногу читайте под катом.
Дисклеймер: я рассматриваю NFT не как способ заработка, а как способ обмена некоторыми приколюхами за некоторые ништяки, причём и те и другие существуют и имеют ценность только внутри отдельно взятой криптоплатформы. Все пересчёты на фиатные деньги нерепрезентативны и не несут смысла
Про генеративный арт
Если вкратце, то генарт — это когда художник не рисует картину, а пишет генератор картин. Причём (судя по общению с генеративщиками) философские корни этой движухи уходят куда-то в сторону демосцены и написания шейдеров, а не в модные нейросетки-стайлганы, как вы могли подумать — сетки как раз у них не в почёте. Генеративщики существуют давно, активно и красиво себя публикуют и туториалов по генарту в интернете очень много. А ещё есть вот такое русскоязычное сообщество, в котором очень легко нахвататься разных прикольных идей и которое подтолкнуло меня вот этим всем заняться.
Про NFT
Вцелом, что-то про нфт слышали все, а всего про нфт не знает никто. Важным мне кажется понимание того что разные блокчейны (как и разные площадки) имеют разные возможности и ограничения и нацелены на разную аудиторию. Так например на "эфирных" площадках из-за высокой цены газа придётся заплатить огромную комиссию при создании NFT-токена, но и количество людей использующих эфир немаленькое. Поэтому направление генарта на эфире развивается слабо — люди просто выкладывают в блокчейн картинки и видосики (пусть даже иногда и генеративные). Скажу сразу что ответить на вопрос зачем люди покупают за огромные деньги макак или камни я не могу — до такого понимания я ещё не преисполнился.
У других блокчейнов другие возможности. Тезос обеспечивает гораздо меньшие комиссии за исполнение и хранение кода, поэтому больше подходит для создания инфраструктуры для генарта. На блокчейне тезоса построены такие площадки как hic et nunc и набирающий обороты fxhash.xyz. На последней площадке я и остановил свой выбор, т.к она позволяет писать интерактивные NFT на javascript.
Про модульные синтезаторы
Модульные синтезаторы это что-то среднее между музыкальной школой и курсом цифрового сигнального процессинга. На хабре есть про них хорошая большая статья. Как явление они появились с развитием электроники в 50х-60х годах прошлого века, позже были заброшены по причине появления компьютеров и своей непригодности для массового рынка, а сейчас снова испытывают расцвет, потому что компьютеры стали мощными настолько, чтобы эмулировать модули даже в браузере на js. Модульные синтезаторы, особенно их современные компьютерные варианты, очень располагают к созданию генеративной музыки, так как позволяют абстрагироваться от музыкальной теории и оперировать звуком и логикой на самом низком уровне. Несложно догадаться, что сообщества генеративщиков и фанатов модульных синтезаторов довольно сильно пересекаются.
Осознав всё вышенаписанное (и будучи фанатом модульных синтов) я загорелся идеей сделать генеративный модульный синтезатор в формате NFT. Погуглив и не найдя ничего готового+красивого+на javascript, я понял что дело принимает радикальный оборот и придётся писать модуляр на js с нуля. Впереди как раз были все новогодние праздники.
Fxhash построен вокруг простой идеи — при покупке экземпляра арта у генератора артов, формируется уникальный хеш транзакции, который инициализирует детерминированный генератор псевдослучайных чисел. Использование этих случайных чисел при создании арта позволяет получать для каждой покупки свою уникальную картинку. Причём покупатель не может узнать, какой она будет, до самого момента подписания транзакции.
Для быстрого старта на платформе, создатели fxhash сделали шаблонный проект, в котором уже написан генератор случайных чисел fxrand(), инициализируемый хешем транзакции. Простейший пример генеративного токена также реализован в index.js — в ответ на некоторый хеш он просто выводит на экран последовательность случайных чисел, неизменную для этого хеша.
Но мы пойдём чуть дальше чем просто вывод чисел на экран и подключим библиотеку p5js, чтобы нарисовать какую-нибудь абстрактную картинку, основанную на псевдорандомных числах. Для этого надо написать 2 глобальных функции: setup() и draw(). В setup() происходит инициализация канваса, а в draw() отрисовывается случайная линия, основанная на хеше транзакции. Что касается графических примитивов, у p5js очень хорошая документация, можно быстро разобраться что к чему.
NFT-случайная линия на p5js
Всё, можно паковать код в zip архив, загружать в песочницу и смотреть на нфт-случайную линию!
Теперь пора заняться вещами посерьёзнее чем рисование случайных линий. Начнём строить модульную абстракцию с класса порта. Порт — это просто именованное хранилище числа с геттером и сеттером, имеющее своё визуальное представление (например в виде кружочка с подписанным названием). Задача порта — хранить число, переданное ему кабелем в момент вызова метода process() (подробнее о том что это за метод — чуть ниже) и отдавать это число владеющему портом модулю в момент востребования. Ещё порт должен уметь отдавать свои экранные координаты, чтобы движок мог правильно нарисовать кабель из одного порта в другой
Класс Port
class Port {
constructor(x, y, name) {
this.x = x;
this.y = y;
this.name = name;
}
set(value) {
this.value = value;
}
get() {
return this.value;
}
get_position() {
return [this.x, this.y];
}
draw() {
// тут надо нарисовать кружочек с координатами this.x
// и this.y и написать рядом с ним this.name
}
}
Дальше нам понадобятся кабели, чтобы передавать значения между портами. Кабель должен хранить в себе ссылки на порты, которые он соединяет, уметь отрисовывать себя и иметь метод process(), в котором он будет перекладывать значение входного порта в выходной. Добавим также параметры scale и offset, задающие масштабирование и сдвиг сигнала при передаче по кабелю.
Класс Wire
class Wire {
constructor(porta, portb, scale=1, offset=0) {
this.porta = porta;
this.portb = portb;
this.scale = scale;
this.offset = offset;
}
process() {
this.portb.set(this.porta.get() * this.scale + this.offset);
}
draw() {
let p1 = this.porta.get_position(); // координаты первого порта
let p2 = this.portb.get_position(); // координаты второго порта
// рисуем сплайн из точки p1 в точку p2
}
}
Теперь напишем базовый класс модуля, реализующий в себе всё те же методы process() и draw(), вызываемые на каждый семпл и каждый кадр соответственно. Так как у каждого модуля наверняка будут какие-то входные и выходные порты, сразу создадим под них переменные и методы добавления. Получится примерно так:
Класс Module
class Module {
constructor(x, y, w, h, name) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.name = name;
this.i = []; // входные порты
this.o = []; // выходные порты
}
add_input(port) {
this.i[port.name] = port;
}
add_output(port) {
this.o[port.name] = port;
}
process() {
// здесь будет происходить весь процессинг аудио
}
draw() {
for(var name in this.i) this.i[name].draw();
for(var name in this.o) this.o[name].draw();
}
}
Наконец, движок. Движок модульного синтезатора должен уметь делать три главных вещи:
С учётом этих требований, он будет выглядеть как-то так:
Класс Engine
class Engine {
constructor() {
this.modules = [];
this.wires = [];
this.OUT = new InvisiblePort();
}
add_module(m) {
this.modules.push(m);
}
add_wire(oport, iport) {
this.wires.push(new Wire(oport, iport))
}
draw() {
for (const m of this.modules) m.draw();
for (const w of this.wires) w.draw();
}
process() {
for (const w of this.wires) w.process();
for (const m of this.modules) m.process();
return this.OUT.get();
}
}
Осталось только прикрутить движок к аудиопроцессору webaudio и p5js. И если с p5js всё более-менее понятно (нужно просто вызвать из глобальной функции draw() метод draw() движка), то с аудио есть один нюанс. API onaudioprocess, которое я использовал в своём проекте, уже давно маркируется как deprecated, но так как оно показалось мне удобнее чем audioworklet, я использовал его. В теории этот способ может не работать в каких-то браузерах.
Взаимодействие движка и webaudio API
audioContext = new AudioContext();
let scriptNode = audioContext.createScriptProcessor(2048, 0, 1);
scriptNode.onaudioprocess = function(audioProcessingEvent) {
let outputBuffer = audioProcessingEvent.outputBuffer;
for (let channel = 0; channel < outputBuffer.numberOfChannels; channel++) {
let outputData = outputBuffer.getChannelData(channel);
for (let sample = 0; sample < outputData.length; sample++) {
outputData[sample] = engine.process() / 10.0;
}
}
}
scriptNode.connect(audioContext.destination);
Для коммуникации модулей друг с другом в реальных системах как правило используется интерфейс CV/GATE. CV это control voltage, управляющее напряжение от -10 до 10 вольт, а GATE — прямоугольный импульс +10В, триггер.
VCO
Напишем в качестве примера один, но самый главный модуль, без которого не обходится ни один патч — VCO. VCO расшифровывается как Voltage Controlled Oscillator — это модуль, осциллирующий на высоте тона, пропорциональной напряжениям на входах CV (control voltage) и FM (frequency modulation). Здесь есть важная деталь — высота тона осциллятора пропорциональна напряжениям на входах, если измерять её в октавах а не в герцах. Эта конвенция называется 1V/OCT, что значит что прирост напряжения на 1 вольт даёт прирост частоты на октаву, то есть в 2 раза. Зная это, напишем модуль VCO с двумя управляющими частотой входами CV и FM.
Класс VCO
class VCO extends Module {
constructor(name, freq, x=-1, y=-1) {
super(name, x, y, 30, 70);
this.freq = freq;
this.delta = Math.PI * 2 / (sample_rate / freq);
this.phase = 0;
this.add_input(new Port(x=8, y=38, r=7, 'CV'));
this.add_input(new Port(x=22, y=38, r=7, 'FM'));
this.add_output(new Port(x=22, y=62, r=7, 'OUT'));
this.value = 0;
this.mod = 0;
}
set_frequency(f) {
this.freq = f;
this.delta = Math.PI * 2 / (sample_rate / f);
}
draw() {
super.draw();
// здесь можно например нарисовать осциллограф
}
process() {
this.value = Math.sin(this.phase);
this.o['OUT'].set( this.value );
this.mod = this.i['CV'].get() + this.i['FM'].get();
this.phase += this.delta * Math.pow(2, this.mod);
if (this.phase > Math.PI * 2) this.phase -= Math.PI * 2;
}
}
Как работают другие типы модулей и как их написать можно подсмотреть например в исходниках VCV Rack. Вместо разбора их реализации расскажу, что делает и зачем нужен каждый из них.
Дисклеймер: я рассматриваю NFT не как способ заработка, а как способ обмена некоторыми приколюхами за некоторые ништяки, причём и те и другие существуют и имеют ценность только внутри отдельно взятой криптоплатформы. Все пересчёты на фиатные деньги нерепрезентативны и не несут смысла
Вступление
Про генеративный арт
Если вкратце, то генарт — это когда художник не рисует картину, а пишет генератор картин. Причём (судя по общению с генеративщиками) философские корни этой движухи уходят куда-то в сторону демосцены и написания шейдеров, а не в модные нейросетки-стайлганы, как вы могли подумать — сетки как раз у них не в почёте. Генеративщики существуют давно, активно и красиво себя публикуют и туториалов по генарту в интернете очень много. А ещё есть вот такое русскоязычное сообщество, в котором очень легко нахвататься разных прикольных идей и которое подтолкнуло меня вот этим всем заняться.
Про NFT
Вцелом, что-то про нфт слышали все, а всего про нфт не знает никто. Важным мне кажется понимание того что разные блокчейны (как и разные площадки) имеют разные возможности и ограничения и нацелены на разную аудиторию. Так например на "эфирных" площадках из-за высокой цены газа придётся заплатить огромную комиссию при создании NFT-токена, но и количество людей использующих эфир немаленькое. Поэтому направление генарта на эфире развивается слабо — люди просто выкладывают в блокчейн картинки и видосики (пусть даже иногда и генеративные). Скажу сразу что ответить на вопрос зачем люди покупают за огромные деньги макак или камни я не могу — до такого понимания я ещё не преисполнился.
У других блокчейнов другие возможности. Тезос обеспечивает гораздо меньшие комиссии за исполнение и хранение кода, поэтому больше подходит для создания инфраструктуры для генарта. На блокчейне тезоса построены такие площадки как hic et nunc и набирающий обороты fxhash.xyz. На последней площадке я и остановил свой выбор, т.к она позволяет писать интерактивные NFT на javascript.
Про модульные синтезаторы
Модульные синтезаторы это что-то среднее между музыкальной школой и курсом цифрового сигнального процессинга. На хабре есть про них хорошая большая статья. Как явление они появились с развитием электроники в 50х-60х годах прошлого века, позже были заброшены по причине появления компьютеров и своей непригодности для массового рынка, а сейчас снова испытывают расцвет, потому что компьютеры стали мощными настолько, чтобы эмулировать модули даже в браузере на js. Модульные синтезаторы, особенно их современные компьютерные варианты, очень располагают к созданию генеративной музыки, так как позволяют абстрагироваться от музыкальной теории и оперировать звуком и логикой на самом низком уровне. Несложно догадаться, что сообщества генеративщиков и фанатов модульных синтезаторов довольно сильно пересекаются.
Осознав всё вышенаписанное (и будучи фанатом модульных синтов) я загорелся идеей сделать генеративный модульный синтезатор в формате NFT. Погуглив и не найдя ничего готового+красивого+на javascript, я понял что дело принимает радикальный оборот и придётся писать модуляр на js с нуля. Впереди как раз были все новогодние праздники.
Пишем NFT на js
Fxhash построен вокруг простой идеи — при покупке экземпляра арта у генератора артов, формируется уникальный хеш транзакции, который инициализирует детерминированный генератор псевдослучайных чисел. Использование этих случайных чисел при создании арта позволяет получать для каждой покупки свою уникальную картинку. Причём покупатель не может узнать, какой она будет, до самого момента подписания транзакции.
fxrand()
Для быстрого старта на платформе, создатели fxhash сделали шаблонный проект, в котором уже написан генератор случайных чисел fxrand(), инициализируемый хешем транзакции. Простейший пример генеративного токена также реализован в index.js — в ответ на некоторый хеш он просто выводит на экран последовательность случайных чисел, неизменную для этого хеша.
p5js
Но мы пойдём чуть дальше чем просто вывод чисел на экран и подключим библиотеку p5js, чтобы нарисовать какую-нибудь абстрактную картинку, основанную на псевдорандомных числах. Для этого надо написать 2 глобальных функции: setup() и draw(). В setup() происходит инициализация канваса, а в draw() отрисовывается случайная линия, основанная на хеше транзакции. Что касается графических примитивов, у p5js очень хорошая документация, можно быстро разобраться что к чему.
NFT-случайная линия на p5js
Всё, можно паковать код в zip архив, загружать в песочницу и смотреть на нфт-случайную линию!
Пишем модульный синтезатор
Теперь пора заняться вещами посерьёзнее чем рисование случайных линий. Начнём строить модульную абстракцию с класса порта. Порт — это просто именованное хранилище числа с геттером и сеттером, имеющее своё визуальное представление (например в виде кружочка с подписанным названием). Задача порта — хранить число, переданное ему кабелем в момент вызова метода process() (подробнее о том что это за метод — чуть ниже) и отдавать это число владеющему портом модулю в момент востребования. Ещё порт должен уметь отдавать свои экранные координаты, чтобы движок мог правильно нарисовать кабель из одного порта в другой
Класс Port
class Port {
constructor(x, y, name) {
this.x = x;
this.y = y;
this.name = name;
}
set(value) {
this.value = value;
}
get() {
return this.value;
}
get_position() {
return [this.x, this.y];
}
draw() {
// тут надо нарисовать кружочек с координатами this.x
// и this.y и написать рядом с ним this.name
}
}
Дальше нам понадобятся кабели, чтобы передавать значения между портами. Кабель должен хранить в себе ссылки на порты, которые он соединяет, уметь отрисовывать себя и иметь метод process(), в котором он будет перекладывать значение входного порта в выходной. Добавим также параметры scale и offset, задающие масштабирование и сдвиг сигнала при передаче по кабелю.
Класс Wire
class Wire {
constructor(porta, portb, scale=1, offset=0) {
this.porta = porta;
this.portb = portb;
this.scale = scale;
this.offset = offset;
}
process() {
this.portb.set(this.porta.get() * this.scale + this.offset);
}
draw() {
let p1 = this.porta.get_position(); // координаты первого порта
let p2 = this.portb.get_position(); // координаты второго порта
// рисуем сплайн из точки p1 в точку p2
}
}
Теперь напишем базовый класс модуля, реализующий в себе всё те же методы process() и draw(), вызываемые на каждый семпл и каждый кадр соответственно. Так как у каждого модуля наверняка будут какие-то входные и выходные порты, сразу создадим под них переменные и методы добавления. Получится примерно так:
Класс Module
class Module {
constructor(x, y, w, h, name) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.name = name;
this.i = []; // входные порты
this.o = []; // выходные порты
}
add_input(port) {
this.i[port.name] = port;
}
add_output(port) {
this.o[port.name] = port;
}
process() {
// здесь будет происходить весь процессинг аудио
}
draw() {
for(var name in this.i) this.i[name].draw();
for(var name in this.o) this.o[name].draw();
}
}
Наконец, движок. Движок модульного синтезатора должен уметь делать три главных вещи:
- Рендерить звук, вызывая некоторый метод process() каждого модуля/кабеля для каждого аудиосемпла (т.е 44100 раз в секунду).
- Отрисовывать модули, вызывая метод draw() каждого из них с некоторым FPS.
- Уметь соединять модули проводами и пропагировать по ним сигнал.
С учётом этих требований, он будет выглядеть как-то так:
Класс Engine
class Engine {
constructor() {
this.modules = [];
this.wires = [];
this.OUT = new InvisiblePort();
}
add_module(m) {
this.modules.push(m);
}
add_wire(oport, iport) {
this.wires.push(new Wire(oport, iport))
}
draw() {
for (const m of this.modules) m.draw();
for (const w of this.wires) w.draw();
}
process() {
for (const w of this.wires) w.process();
for (const m of this.modules) m.process();
return this.OUT.get();
}
}
Осталось только прикрутить движок к аудиопроцессору webaudio и p5js. И если с p5js всё более-менее понятно (нужно просто вызвать из глобальной функции draw() метод draw() движка), то с аудио есть один нюанс. API onaudioprocess, которое я использовал в своём проекте, уже давно маркируется как deprecated, но так как оно показалось мне удобнее чем audioworklet, я использовал его. В теории этот способ может не работать в каких-то браузерах.
Взаимодействие движка и webaudio API
audioContext = new AudioContext();
let scriptNode = audioContext.createScriptProcessor(2048, 0, 1);
scriptNode.onaudioprocess = function(audioProcessingEvent) {
let outputBuffer = audioProcessingEvent.outputBuffer;
for (let channel = 0; channel < outputBuffer.numberOfChannels; channel++) {
let outputData = outputBuffer.getChannelData(channel);
for (let sample = 0; sample < outputData.length; sample++) {
outputData[sample] = engine.process() / 10.0;
}
}
}
scriptNode.connect(audioContext.destination);
Модули
Для коммуникации модулей друг с другом в реальных системах как правило используется интерфейс CV/GATE. CV это control voltage, управляющее напряжение от -10 до 10 вольт, а GATE — прямоугольный импульс +10В, триггер.
VCO
Напишем в качестве примера один, но самый главный модуль, без которого не обходится ни один патч — VCO. VCO расшифровывается как Voltage Controlled Oscillator — это модуль, осциллирующий на высоте тона, пропорциональной напряжениям на входах CV (control voltage) и FM (frequency modulation). Здесь есть важная деталь — высота тона осциллятора пропорциональна напряжениям на входах, если измерять её в октавах а не в герцах. Эта конвенция называется 1V/OCT, что значит что прирост напряжения на 1 вольт даёт прирост частоты на октаву, то есть в 2 раза. Зная это, напишем модуль VCO с двумя управляющими частотой входами CV и FM.
Класс VCO
class VCO extends Module {
constructor(name, freq, x=-1, y=-1) {
super(name, x, y, 30, 70);
this.freq = freq;
this.delta = Math.PI * 2 / (sample_rate / freq);
this.phase = 0;
this.add_input(new Port(x=8, y=38, r=7, 'CV'));
this.add_input(new Port(x=22, y=38, r=7, 'FM'));
this.add_output(new Port(x=22, y=62, r=7, 'OUT'));
this.value = 0;
this.mod = 0;
}
set_frequency(f) {
this.freq = f;
this.delta = Math.PI * 2 / (sample_rate / f);
}
draw() {
super.draw();
// здесь можно например нарисовать осциллограф
}
process() {
this.value = Math.sin(this.phase);
this.o['OUT'].set( this.value );
this.mod = this.i['CV'].get() + this.i['FM'].get();
this.phase += this.delta * Math.pow(2, this.mod);
if (this.phase > Math.PI * 2) this.phase -= Math.PI * 2;
}
}
Как работают другие типы модулей и как их написать можно подсмотреть например в исходниках VCV Rack. Вместо разбора их реализации расскажу, что делает и зачем нужен каждый из них.
- Sample&Hold — модуль который запоминает значение напряжения на входе по триггеру и выдаёт это значение в выход пока не произойдёт новый триггер.
- Delay — модуль задержки сигнала. Имеет параметры time, устанавливающий время задержки, dry/wet, отвечающий за смешивание исходного сигнала с задержанным и feedback, регулирующий величину обратной связи.
- Reverb — ревербератор, имитирующий естественное эхо большого помещения. Довольно сложная штука, реализованная на дилеях и фильтрах, как именно — можно посмотреть здесь и здесь
- Scale — он же quantizer, штука притягивающая напряжение на входе к ближайшему напряжению, соответствующему ноте находящейся в заданной тональности.
- Filter — частотный фильтр, low pass или high pass.
Пишем NFT модульный синтезатор на javascript ?
В новый год всегда хочется чего-то нового. А даже если не хочется, то сознание, ещё не остывшее от работы, всё равно само возьмёт и подкинет какую-нибудь офигительную идею. И если в прошлом году...
habr.com