Речь пойдёт о нюансах использования Web Audio API и Web MIDI API для синтеза звука в браузере, методах датабендинга и сонификации, UX при использовании клавиатуры и мыши в музыкальных целях, а также почему браузер ungoogled-chromium лучше Google Chrome
Введение
Последние два с половиной года я, в свободное от работы время, разрабатывал небольшой инструмент под названием Binary synth, который переводит любой файл в звук или последовательность MIDI-сообщений. Вы можете найти демо здесь, исходный код здесь, а как это может звучать здесь, здесь и здесь.
В музыкальном плане я вас сильно не удивлю, учитывая, что уже давно существуют такие термины, как ambient или IDM, смысл которых я уже плохо понимаю, но в общем и целом с этой штукой можно генерировать различные звуковые текстуры.
В процессе работы над этим инструментом, я столкнулся с нюансами упомянутых выше API и приобрёл некоторый опыт и специфические знания, которыми хотел бы поделиться.
Очень кратко о стеке: весь код полностью на фронтенде на Vue3, а все ассеты в виде стилей, скриптов, иконок и шрифтов при сборке зашиваются в один html-файл, поэтому инструмент можно сохранить в виде index.html на компьютер и он будет полностью работать офлайн без интернета.
В статье не будет ничего про фреймворк или основы используемых API, а скорее про практику создания инструмента и полноценного применения браузерных технологий в музыкальных целях.
Культурология или как я к этому пришёл
Я хотел бы сразу ответить на вопрос зачем переводить файлы в звук и откуда эта идея вообще появилась.
Начать можно с такой мысли, что во всей музыке кроме вокала музыканту нужен внешний технический объект — инструмент. В каком-то смысле, вся музыка делится на вокал и всё остальное, потому что с вокалом ты как бы и есть инструмент. В остальных случаях музыканту требуется какая-то техника, то есть какой-то объект, который кем-то был создан, может быть, им самим, может быть, промышленностью, может быть, корпорациями какими-то, может быть, энтузиастами. Но так или иначе музыкант зависит от производителей инструментов и от их технической эволюции и так далее. Даже в консерватории есть отдельно вокал, и есть отдельно инструменталисты, так называемые. И дирижёр тоже есть хоровой и оркестровый.
При этом понятно, что на очень разных инструментах можно сыграть одну и ту же так называемую ноту, то есть на скрипке, на фортепиано, везде есть ноты. Но при этом мы очень чётко всегда, конечно, отличаем, где звучит один инструмент, а где другой — через тембр. Я бы не сказал, что у слова тембр есть какие-то внятные определения, но я это объясняю так. Нота — это просто частота звука. А у нас есть физический инструмент, а если, например, он ещё и акустический, то он состоит из деталей, из металла, дерева и так далее. Когда эта частота издаётся, весь инструмент дребезжит. Внутри него происходит много разных не совсем контролируемых процессов, которые проявляют себя как ещё одни звуки. И вот эта вся совокупность и сумма — это и есть тембр, который, в общем-то, ценится. И в каком-то смысле тембр — это весь тот, грубо говоря, мусор, который образуется вокруг ноты, вокруг частоты. И частота, по сути, это просто некий входной импульс, который порождает внутри какой-то элемент хаоса.
И если говорить про инструменты, которые зависят от электричества, то они бывают аналоговые и цифровые. И есть большой такой спор про аналоговое и цифровое, он, наверное, всем более-менее известен, но вкратце, он заключается в том, что в каждом резисторе — бездна. То есть, если у тебя аналоговый инструмент, ты просто пропускаешь электричество через какие-то компоненты. У тебя в руках стихия, которой ты как бы пытаешься управлять. И это очень ценится, потому что аналоговые инструменты часто могут вести себя вообще непредсказуемо. А если вы знаете, есть такой инструмент Поливокс, который в последнее время стал очень популярен в определённых кругах. Это позднесоветский синтезатор, который был сделан из военных запчастей. И музыканты его очень ценят, потому что он неуправляемый, потому что он очень в каком-то смысле плохо работает. И в целом все аналоговые инструменты зависят чуть ли не от колебания напряжения в розетке, от температуры, от возраста и так далее. И это скорее не баг, а фича, потому что именно это порождает глубину во всём этом, какие-то сложные тембры из этой всей взаимосвязи и другое ощущение от процесса.
Но если мы говорим о цифровых инструментах, о цифровом синтезе, то есть где у нас присутствует компьютер, то становится очень быстро очевидно, что цифровой синтез и вообще компьютерная музыка звучит обычно более стерильно, как такая беззубая, пресная. Многие цифровые синтезаторы поэтому пытаются найти источник хаоса, который бы мог разнообразить и обогатить тембр, сделать его более живым и интересным, менее роботизированным. Именно хаос — это то, что делает звук более слушабельным, интересным, драматургичным.
И один из способов, как можно этот источник хаоса и энтропии добыть — databending. Это такая художественная и музыкальная практика, распространившаяся примерно с конца 2000-х годов. Вкратце, смысл в том, что мы берём, например, какую-нибудь картинку и открываем её в текстовом редакторе, и там на нас сыпется огромная куча какой-то мешанины, мы вносим точечно какие-то изменения, и у нас появляются глитчевые эффекты на этой картинке. Это делают и с видео, и с фото, и с музыкой. К примеру, распространённым приёмом является использование аудио-редактора Audacity, которым можно открыть графические изображения и получить аудио-дорожку. По сути databending — это манипулирование файлами не предназначенной для этого программой. И частным случаем датабендинга является сонификация. Это использование не-звуковых данных для перевода их в звук. Я думаю многие слышали плейлист от NASA со «звуками планет», у них есть целый раздел про сонификации. Ну а самый прагматичный пример — это счетчик Гейгера.
Собственно, вся идея в том, чтобы пользуясь методами датабендинга и сонификации в качестве источника хаоса для синтеза звука использовать файлы, их бинарный код.
Переводим бинарный код в герцы
Ещё раз кратко — я сделал инструмент, который переводит любые файлы в звук. Все файлы на компьютере — это просто набор нулей и единиц. По сути тексты. У которых в алфавите две буквы: ноль и один. Когда мы смотрим на файлы таким образом, они все одинаковые. То есть мы как бы уничтожаем смысл у всех файлов, когда мы смотрим на них под таким микроскопом. Там нет больше mp3, docx, видео и т.д.
На самом деле, даже если два раза подряд сделать какую-то фотографию на цифровую камеру, то с точки зрения нулей и единиц там всегда будет чуть-чуть иначе. И как именно, мы не знаем. То есть с точки зрения человека мы видим то же самое, но вообще, под микроскопом, оно совсем другое.
Инструмент реализует то, что называется live electronics. Компьютер, по тексту нулей и единиц, синтезирует звук, либо генерирует MIDI-сообщения, управляющие внешними устройствами, которые уже будут генерировать звук.
Мы можем взять любой файл и загрузить его в инструмент. И тогда интерфейс будет выглядеть таким образом:

У нас есть левая часть — панель управления, и правая, где отображается само содержимое загруженного файла.
А принцип работы и перевода довольно простой. Мы делим сплошные нули и единицы на слова по 8 или 16 символов (один или два байта) и их переводим в частоту в герцах и по очереди их читаем. Если дошли до конца, то либо заканчиваем, либо начинаем заново по кругу. Можно настроить диапазон частот, по которым, имея 256 или 65 536 комбинаций нулей и единиц, равномерно распределяются частоты в герцах. Например, если диапазон у нас 0-256, то в 8-битном режиме работать будет так:
00000000 — 0 герц,
00000001 — 1 герц,
00000010 — 2 герц,
…
Получить бинарный код файлов можно с помощью FileReader и Uint8Array / Uint16Array. Только нужно помнить, что при нечётном числе байтов в файле получить Uint16Array можно, заполнив нулями недостающее. Это можно сделать с помощью ArrayBuffer.transfer, который не очень поддерживается пока, но вот полифилл:
// Polyfill for ArrayBuffer.transfer
if (!ArrayBuffer.transfer) {
ArrayBuffer.transfer = function (source, length) {
if (!(source instanceof ArrayBuffer)) throw new TypeError('Source must be an instance of ArrayBuffer')
if (length <= source.byteLength) return source.slice(0, length)
let sourceView = new Uint8Array(source)
let destView = new Uint8Array(new ArrayBuffer(length))
destView.set(sourceView)
return destView.buffer
}
}
const reader = new FileReader()
reader.addEventListener('loadend', async (event) => {
isLoading.value = false
if (event.target.result.byteLength <= 499) {
status.startAndEndOfList[1] = event.target.result.byteLength - 1
} else {
status.startAndEndOfList = [settings.fragment.from, settings.fragment.to]
}
// For files with an odd number of bytes we cannot create a Uint16Array
// So we can fill the missing with zeros
let binary8 = new Uint8Array(event.target.result)
let binary16 = null
if (event.target.result.byteLength % 2) {
let transferedBuffer = ArrayBuffer.transfer(event.target.result, event.target.result.byteLength + 1)
binary16 = new Uint16Array(transferedBuffer)
} else {
binary16 = new Uint16Array(event.target.result)
}
file.$patch({
binary8: binary8,
binary16: binary16,
loaded: true,
})
})
Само вычисление частот тоже тривиально. У нас есть т.н. continuous-режим, когда частоты переводятся непрерывно, и tempered, когда мы приводим частоты к нотам в рамках 12-ступенного равномерно темперированного строя. Плюс, 8-битный и 16-битный режим. Итого четыре режима. Для начала мы вычисляем коэффициенты для каждого режима, которые представляют собой разность диапазона частот, делённую на количество комбинаций:
const frequencyCoefficients = computed(() => {
return {
continuous8: (settings.frequenciesRange.to - settings.frequenciesRange.from) / 256,
continuous16: (settings.frequenciesRange.to - settings.frequenciesRange.from) / 65536,
tempered8: (settings.notesRange.to - settings.notesRange.from) / 256,
tempered16: (settings.notesRange.to - settings.notesRange.from) / 65536,
}
})
А затем коэффициент умножаем на десятичное представление бинарного слова + смещение, равное минимальному значению диапазона; в темперированном режиме просто получаем порядковый номер ноты из заранее заготовленного массива всех нот:
export function getFrequency(byte, bitness, mode, coefficients, minimumFrequency, minimumNote) {
if (mode === 'continuous') {
if (byte === 0) return 0.01 + minimumFrequency
if (bitness === '8') return coefficients.continuous8 * byte + minimumFrequency
if (bitness === '16') return coefficients.continuous16 * byte + minimumFrequency
}
if (mode === 'tempered') {
if (bitness === '8') return notes[Math.floor(coefficients.tempered8 * byte) + Math.round(minimumNote)]
if (bitness === '16') return notes[Math.floor(coefficients.tempered16 * byte) + Math.round(minimumNote)]
}
}
Забегая немного вперёд, обратите внимание, что при byte === 0 мы обязательно прибавляем очень маленькое значение 0.01. Это делается для того, чтобы осциллятор никогда не уходил в генерацию 0 герц, так как тогда будут неприятные щелчки звука.
В режиме генерации MIDI всё несколько иначе. В tempered-режиме мы просто возвращаем порядковый номер ноты из заранее заготовленного массива всех нот. В continuous-режиме, так как в MIDI нельзя воспроизводить любые частоты, мы находим ближайшую меньшую ноту к нашей частоте и с помощью pitch повышаем её до нужной:
export function getMIDINote(byte, bitness, mode, coefficients, minimumFrequency, minimumNote) {
// Note number + pitch is returned
// 1. Calculate frequency
// 2. Find the nearest lower note in the array to this frequency
// 3. Calculate the difference between this note and the original frequency
// 4. Convert this difference into a pitch value
if (mode === 'continuous') {
// 1.
if (byte === 0) frequency = minimumFrequency
if (bitness === '8') frequency = coefficients.continuous8 * byte + minimumFrequency
if (bitness === '16') frequency = coefficients.continuous16 * byte + minimumFrequency
// 2.
nearbyValues = getNearbyValues(frequency, notes)
// 3.
percent = toFixedNumber(((frequency - nearbyValues[0]) / (nearbyValues[1] - nearbyValues[0])) * 100, 1)
// 4.
// The pitch value in MIDI is from 0 to 16383, 8191 is the normal state (middle)
// 8192 divisions are two semitones, so one semitone is 4096 divisions
// We want to make a smooth transition between halftones, so we need to define a shift up to 4096
pitchValue = Math.floor((percent / 100) * 4096) + 8191
if (notes.indexOf(nearbyValues[0]) < 0) {
return [0, pitchValue]
} else {
return [notes.indexOf(nearbyValues[0]), pitchValue]
}
}
// The note number returned
if (mode === 'tempered') {
if (bitness === '8') return [Math.floor(coefficients.tempered8 * byte) + minimumNote]
if (bitness === '16') return [Math.floor(coefficients.tempered16 * byte) + minimumNote]
}
}
Собственно, на этом этапе мы можем получать частоты, либо номера нот. Далее о том, как эти данные использовать в Web Audio API и Web MIDI API.
Нюансы Web Audio API и немного о гранулярном синтезе
Про само API есть множество статей на Хабре, ну и в конце концов есть MDN, поэтому расписывать основы здесь нет необходимости.
Аудио-граф Binary synth очень прост: осциллятор с несколькими видами волн, low pass фильтр, LFO для амплитудной модуляции, и паннер для управления панорамированием между левым и правым каналами. Граф довольно тривиален и звучание было бы таким же, если бы нельзя было увеличивать скорость чтения файла и плодить несколько вкладок.
// Connection
filter.value
.connect(gain.value)
.connect(masterGain.value)
.connect(panner.value)
.connect(audioContext.value.destination)
lfoDepth.value.connect(masterGain.value.gain)
// Осциллятор к фильтру цепляется отдельно при каждом нажатии на play
// Также добавлю, что все элементы графа это ref, поэтому обращаемся через .value
Есть такая вещь под названием гранулярный синтез — это когда мы просто берём звук, расщепляем его на маленькие кусочки и с этими кусочками что-то делаем. Высыпаем их на слушателей, как-то их рекомбинируем и так далее.
И есть такая очень интересная, занятная штука про звук, связанная с тем, что звуку, чтобы «состояться», нужно некоторое время. Любая нота — это частота. Частота — это количество повторений за секунду. Соответственно, у нас есть какой-то период, то есть некий фрагмент, сколько нужно времени, чтобы один раз всколыхнуться, чтобы это колыхание потом повторялось и нами воспринималось как звук. И здесь можно взглянуть на картинку ниже:

В инструменте есть возможность играть не весь файл, а только его фрагмент и мы можем сделать этот фрагмент очень небольшим и быстро повторять. Допустим, у нас есть две команды (то есть частоты), которые зациклены. И у нас есть время readingSpeed (по сути скорость чтения), дающееся на воспроизведение каждой частоты. И если эта скорость меньше, чем период волны, то волна не успевает полностью состояться. От неё остаётся кусочек, который называется wavelet. И когда мы эти кусочки быстро повторяем, мы получаем новый тембр. Каждый элемент повтора можно назвать акустическим пикселем, пользуясь терминами гранулярного синтеза.
Более того, в браузере есть метафора вкладки, которая позволяет параллельно запустить несколько инстансов инструмента. И их звуковые потоки будут взаимно влиять друг на друга, причём даже если генерировать частоты в неслышимом диапазоне.
Для оптимизации вычислений инструмент делит файл на чанки (листы) по 500 команд, которые перелистываются. Время до следующего перелистывания планируется через setTimeout, это штука весьма неточная для планирования времени, но в контексте синтеза дроун-нойз-эмбиент текстур оказывается очень на руку на высоких скоростях чтения.
Однако, возникает проблема с тем, что setTimeout и setInterval при blur-событии (то есть, если мы ушли с вкладки или свернули браузер) начинают выполнятся максимум раз в секунду или около того. Например, если у нас интервал на 200мс, то в blur-режиме будет срабатывать 5 интервалов сразу раз в секунду, то есть они накапливаются и пачкой запускаются. Это большая проблема если нужно переключаться между браузером и DAW. Для решения этой проблемы я использовал пакет worker-timers, который подменяет нативные таймеры на их запуск в воркерах, которые не подвержены таким оптимизациям.
Переходить между командами (с одной частоты на другую) можно мгновенно, линейно или экспоненциально. Для этого у осциллятора есть методы setValueAtTime, linearRampToValueAtTime и exponentialRampToValueAtTime, как указано в коде ниже. Обратите внимание, что при мгновенном изменении мы не устанавливаем просто oscillator.frequency напрямую через присваивание, а используем метод setValueAtTime, чтобы не было щелчков. Более того, при нажатии на play или stop, ручка громкости устанавливается в целевое значение через небольшое время, чтобы опять же нивелировать щелчки из-за резкого начала и обрыва синтеза звука.
import { getFrequency } from '../assets/js/getFrequency.js'
import { getRandomTimeGap } from '../assets/js/helpers.js'
export function useOscillatorScheduler(settings, audioContext, oscillator, bynaryInSelectedBitness, frequencyCoefficients) {
function computeFrequency(binaryValue) {
return getFrequency(
binaryValue,
settings.bitness,
settings.frequencyMode,
frequencyCoefficients.value,
settings.frequenciesRange.from,
settings.notesRange.from
)
}
function scheduleOscillatorValue(command, targetTime) {
// At high reading speeds, there are unacceptable values
const isExponential = settings.transitionType === 'exponential'
const safeValue = isFinite(command) ? command : isExponential ? 0.01 : 0
switch (settings.transitionType) {
case 'immediately':
oscillator.value.frequency.setValueAtTime(safeValue, targetTime)
break
case 'linear':
oscillator.value.frequency.linearRampToValueAtTime(safeValue, targetTime)
break
case 'exponential':
oscillator.value.frequency.exponentialRampToValueAtTime(safeValue, targetTime)
break
}
}
function planOscillatorList(startOfList, endOfList) {
for (let binaryID = startOfList, index = 0; binaryID <= endOfList; binaryID++, index++) {
const command = computeFrequency(bynaryInSelectedBitness.value[binaryID])
const time = audioContext.value.currentTime + (index * settings.readingSpeed + getRandomTimeGap(settings.isRandomTimeGap, settings.readingSpeed))
scheduleOscillatorValue(command, time)
}
}
return {
getRandomTimeGap,
computeFrequency,
scheduleOscillatorValue,
planOscillatorList,
}
}
Если хочется обработать звук из инструмента в DAW, можно воспользоваться виртуальными кабелями, как например этим, для Mac это BlackHole. В виртуальный кабель нужно отдавать все системные звуки, а в DAW настроить input device как выход виртуального кабеля.
Чтобы убрать задержку, в Windows придётся поставить ASIO, самый популярный вариант это ASIO4All, но я рекомендую FlexASIO. На Mac таких проблем нет.
Ещё одна интересная вещь, это установка Sample rate. При разных значениях этого параметра можно получать довольно разное звучание на одних и тех же настройках:
3000 Гц
44100 Гц
250000 Гц
768000 Гц
То есть, частоту дискретизации можно использовать как дополнительный параметр синтеза, но есть одна проблема. Узнать диапазон допустимых частот дискретизации из-под JS, по всей видимости, невозможно. Если кто-то из читателей знает как, то, пожалуйста, сообщите мне. Поэтому я написал небольшой хак:
// There is probably no API for getting a range of possible sample rates.
// We are using a hack, intentionally creating an error (sampleRate = 1 cannot be),
// catching it and intercepting the error text,
// from which you can find out the range
// Returns the { minimumSampleRate, maximumSampleRate } object
function getSampleRateRange() {
let sampleRateRange = null
try {
new AudioContext({ sampleRate: 1 })
} catch (nativeError) {
const error = String(nativeError)
sampleRateRange = error
.slice(error.indexOf('[') + 1, error.indexOf(']'))
.split(', ')
.map((rate) => Number(rate))
}
return { minimum: sampleRateRange[0], maximum: sampleRateRange[1] }
}
Мы генерируем ошибку, отлавливаем её и из её текста получаем допустимый диапазон. Диапазон на моём ноутбуке до 768000, что никак невозможно на встроенном чипе Realtek моего ноутбука, да и далеко не каждая аудиокарта на такое способна, но с точки зрения прагматики изменение значений в этом диапазоне меняет звук, что является хорошей возможностью для творческого поиска. Технически, API позволяет генерировать неслышимые частоты и не выдавать никаких ошибок, но на самом деле происходит просто алиасинг.
Ещё один нюанс в том, что менять частоту дискретизации на лету нельзя, потому что надо полностью пересоздавать аудио-граф.
Нюансы Web MIDI API
Сразу говорю, что чтобы использовать MIDI на Windows нужно поставить виртуальный порт, например loopMIDI. На Mac таких проблем нет, вы легко найдёте инструкцию как создать через настройки виртуальный MIDI. Тестировать MIDI можно через этот монитор, а отправлять MIDI-сообщения можно в виртуальный аналог DX7.
Теперь к коду. Здесь планирование листа полностью на таймерах, так что отсутствие worker-timers было бы катастрофой. Использование Web MIDI API сопряжено с некоторым пониманием самого протокола MIDI. Вы можете найти официальную документацию по нему, но мне лично понадобились вот эти команды:
// MIDI messages
export default {
noteOff(note, velocity, port, channel) {
port.send([0x80 + Number(channel), note, velocity])
},
noteOn(note, velocity, port, channel) {
port.send([0x90 + Number(channel), note, velocity])
},
pitch(value, port, channel) {
port.send([0xe0 + Number(channel), value & 0x7f, value >> 7])
},
allSoundOff(port, channel) {
port.send([0xb0 + Number(channel), 0x78, 0])
},
modulation(value, port, channel) {
port.send([0xb0 + Number(channel), 0x01, value])
},
}
Каналы это, кстати, довольно удобно, потому что можно в разных вкладках открыть несколько инстансов инструмента и управлять разными MIDI-девайсами параллельно. То есть, вы подключаете браузер к виртуальному MIDI-порту, а какой-нибудь Ableton этот порт слушает. На разных дорожках у вас разные инструменты, слушающие разные каналы.
Надо помнить про такую особенность, что нужно обязательно, когда цепляемся к каналу, подавать в него первоначальное значение модуляции:
if (outputs.value[0]) {
settings.midi.noMIDIPortsFound = false
settings.midi.port = midi.outputs.get(outputs.value[0].id)
port.value = outputs.value[0].id
sendMIDIMessage.modulation(settings.midi.modulation, settings.midi.port, settings.midi.channel)
} else {
settings.midi.noMIDIPortsFound = true
}
Сам алгоритм MIDI-режима также прост, мы планируем массив таймаутов, поочерёдно вызывая noteOn и noteOff:
function planMidiList(startOfList, endOfList, indexOffset = 0) {
clearMidiTimeouts()
for (let binaryID = startOfList, index = 0; binaryID <= endOfList; binaryID++, index++) {
commands.value[index] = getMIDINote(
bynaryInSelectedBitness.value[binaryID],
settings.bitness,
settings.frequencyMode,
frequencyCoefficients.value,
settings.frequenciesRange.from,
settings.notesRange.from
)
const timeoutedNote = playNote.bind(null, index)
const delay = ((index + indexOffset) * settings.readingSpeed + getRandomTimeGap(settings.isRandomTimeGap, settings.readingSpeed))
midiTimeoutIDs.value[index] = setTimeout(timeoutedNote, delay * 1000)
}
}
При работе с MIDI в браузере я наткнулся на неприятную вещь. У меня есть MIDI-контроллер, и в случае, если браузер подключён к MIDI, контроллер отказывается работать, но если сначала подключить контроллер, а затем браузер, то всё ок. Как это пофиксить я пока решения не нашёл, на других веб-проектах с MIDI заметил такие же проблемы.
Решение с UX
У любого нормального инструмента всегда есть физические какие-то кнопки или рычаги управления, которые создают особенное тактильное ощущение. А в случае с компьютером у нас есть только клавиатура и мышь. Мы можем обложиться какими-то контроллерами, но мне хотелось подумать, как можно было бы этим управлять, обойдясь клавиатурой и мышью. И своё решение я называю interactive input.

У многих полей ввода есть клавиатурное сокращение. Если нажать соответствующую клавишу, то сразу наводится фокус на этот инпут. И если зажать эту клавишу и одновременно поводить мышью, то мы можем изменять значение слайдом мыши. При этом возникает проблема в том, что есть величины, которые нужно быстро изменять, а есть величины, которые очень медленно нужно изменять. И для этого есть множитель. Мы нажимаем либо Shift, либо Ctrl и можем то, что мы изменяем, умножить на 10, 100, 1000 или на 0.1, 0.01 и так далее. И мы тогда маленьким движением руки можем резко увеличивать значение, либо наоборот управлять очень тонко.
function activateInteractiveMode(event) {
if (!isInteractiveMode.value && event.code === props.keyCode && !event.ctrlKey) {
input.value.focus()
document.addEventListener('mousemove', mousemoveHandler)
isPressed = true
}
// Shift increase, Ctrl decrease factor
if (isPressed) {
if (event.code === 'ShiftLeft') inputValueFactor.value *= 10
if (event.code === 'ControlLeft') inputValueFactor.value /= 10
}
}
Но с мышью есть проблема, что когда она упирается в край экрана, мы больше не можем увеличивать значение поля ввода. Для решения этой проблемы я использовал Pointer Lock API. Вкратце, мы просто отключаем курсор и считаем только дельту от изначального положения. Это используется, например, в играх вроде шутеров, где курсора нет и вы можете бесконечно вращать камеру.
if (!isMoved) {
if (!document.pointerLockElement) {
await input.value.requestPointerLock({
unadjustedMovement: true,
})
}
isInteractiveMode.value = true
initialInputValue = inputValue.value
isMoved = true
}
currentX += event.movementX
Используется interactive input примерно так:
<InteractiveInput
:validValue="settings.frequenciesRange.to"
@valueFromInput="validateFrequenciesRangeTo($event)"
step="0.1"
keyCode="KeyS"
letter="S"
/>
function validateFrequenciesRangeTo(newValue) {
if (isNaN(newValue)) {
return
} else if (newValue > (settings.midiMode ? 12543 : settings.sampleRate / 2)) {
settings.frequenciesRange.to = settings.midiMode ? 12543 : settings.sampleRate / 2
} else if (newValue <= settings.frequenciesRange.from) {
settings.frequenciesRange.to = settings.frequenciesRange.from + 1
} else {
settings.frequenciesRange.to = newValue
}
}
Из компонента приходят значения, которые мы валидируем снаружи и мутируем стейт.
Таким образом мышь становится важным контроллером, управляющим звуком. Поэтому нужна более-менее нормальная мышь. В игроманские мыши я не верю и считаю маркетингом, достаточно не самой дешёвой просто, желательно лазерной и с dpi 3000-4000. Ну и вся тактильность от инструмента оказывается на столе, поэтому я лично себе купил коврик Ikea.
Лучший браузер
Здесь будут скорее субъективные ощущения, чем строгие замеры, да и я не стал морочить голову со специализированным ПО для замер CPU и воспользовался диспетчером задач Windows и Performance monitor из DevTools, так что если кто-то захочет более толково что-то померить, будет круто. Здесь же скорее результаты навскидку.
Если смотреть на Performance monitor, то не совсем понятно, как этот инструмент измеряет CPU. Я заметил, что когда там цифра вроде 70% CPU, в диспетчере задач Windows потребление подскакивает на 7% и так далее в такой пропорции. Усложняет измерение ещё тот факт, что при открытом DevTools потребление CPU в диспетчере задач начинает скакать с 0 до 10%. Поэтому, если просто ориентироваться на метрики диспетчера задач, а также субъективные ощущения от использования инструмента, то картина следующая.
Firefox крайне не рекомендуется, в среднем потребление 4.2% CPU, но в звуке бывают щелчки и зависания иногда, да и сам звук немного другой в отличие от chromium-подобных.
Chromium-подобные потребляют порядка 7-8% CPU на больших скоростях, но всё работает субъективно лучше. Влияют соседние вкладки, а также расширения, поэтому лучше использовать инкогнито-режим, там потребление падает в среднем до 6% на моём ноутбуке.
Но есть ещё такой нюанс, что уж если мы хотим использовать компьютер в качестве музыкального инструмента, особенно для live electronics, то есть генерации звука на лету, то для повышения стабильности работы и уменьшения возможных задержек становится важно экономить любые ресурсы компьютера, так как параллельно может работать ещё какое-то ПО вроде Ableton.
Chrome, как известно, потребляет много ресурсов и я пытался найти какую-то chromium-подобную альтернативу и нашёл проект ungoogled-chromium. По сути это опен-сорсный Chromium, из которого убрали всякое присутствие Google. По метрикам этот браузер потребляет чуть-чуть меньше процессора и существенно меньше RAM. Так что я могу рекомендовать для музыкальных нужд именно его (да, я дошёл до того, что у меня под мой инструмент отдельный браузер).
Выводы
Использовать для музыкальных нужд язык Javascript — извращение или нет? Я думаю очевидно, что для каких-то строгих, предсказуемых задач со звуком, где нужна максимальная точность, нужно использовать другие языки и конечно не зависеть от браузера. Вроде как IRCAM реализовал Web Audio API под Nodejs и в теории от браузера можно отказаться, но всё равно производительность не будет топовой, конечно.
Но, так как мой инструмент разработан для, скажем так, художественных целей, где в таких музыкальных жанрах, как нойз или эмбиент допускается или даже требуется «шероховатость» и некоторая доля непредсказуемости, то в целом инструмент годится для своих целей. Более того, одним из преимуществ такой разработки является дистрибуция — у тебя приложение запускается просто по переходу по URL, легко скачивается и ничего дополнительно не требует и работает на любой ОС.
Но если вдруг кто-то захочет реализовать нечто подобное на других языках — welcome.
KarmaCraft
Очень интересный проект! Когда можно будет попробовать релизную?
max_alyokhin Автор
Спасибо! Уже всё в релизе
KarmaCraft
Сбили с толку слова "Вы можете найти демо здесь". Тогда респект!