Pull to refresh

Использование Audio API для создания вокодера

Reading time 14 min
Views 20K
В прошлой статье мы немного познакомились с возможностями Audio API и написали простенький визуализатор сигнала. Теперь настало время копнуть поглубже и распробовать новые фишки API. Но нам нужна цель, к которой мы будем стремиться, и в данном случае нашей целью будет как следует поиздеваться над входящим сигналом и его характеристиками. Другими словами, мы напишем маленький вокодер.

Так как итоговый код получился довольно-таки большим, то в статье будет рассмотрены наиболее важные и интересные с точки зрения Audio API фрагменты. Итоговый результат вы конечно же сможете посмотреть на демке.


Выбор источника сигнала


Итак Audio API поддерживает три вида источника сигнала:
  1. Источник созданный при помощи тэга audio
  2. Аудио буфер
  3. Внешний аудио поток (стрим) (микрофон или любой другой аудио стрим, в том числе внешний)

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

Для того чтобы достучаться до нашего источника, нам для начала нужно получить для этого разрешение пользователя и захватить audio — поток. И что же вы думаете, нам не придется городить тонны кода для этого, а всего лишь использовать одну функцию под названием getUserMedia. Эта волшебная ф-я принимает три аргумента:
  1. Тип данных, к которым запрашивается доступ. Представляет из себя объект вида —
    {video: true, audio: true}
  2. Функция захвата данных мультимедиа, которая в качестве аргумента получает захваченный поток.
  3. Функция обработки ошибок возникших во время захвата.

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

var d = document,
    w = window,
    context = null, dest = null, source = null;
var init = function () {
        try {
            var audioContext = w.AudioContext || w.webkitAudioContext;
            navigator.getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
            //Создаем контекст
            context = new audioContext();
            //выход по умолчанию
            dest = context.destination;
           
            var bufferLoader = new BufferLoader(context, ["effects/reverb.wav"], function (buffers) {
                navigator.getMedia({ audio: true }, function (striam) {
                    //Создаем интерфейс для получения данных из потока
                    source = context.createMediaStreamSource(striam);
                }, function (e) {
                    alert(e);
                });            
            });
            bufferLoader.load();
            
        } catch (e) {
            alert (e.message);
        }
    };


Рассмотрим, что же тут происходит. В начале мы создаем audioContext для нашей страницы (что это такое описано в предыдущей статье), далее мы видим новую функцию BufferLoader. Она занимается тем, что при помощи XHR2 подтягивает внешние аудио-файлы и бережно складирует их в буфер. В нашем случае она нам потребуется, чтобы подтянуть один аудио эффект, который будет описан ниже. Функция эта не стандартная и нам придется её написать.

//Подгрузка файлов в буффер
var BufferLoader = function (context, urlList, callback) {
        this.context = context;
        this.urlList = urlList;
        this.onload = callback;
        this.bufferList = new Array();
        this.loadCount = 0;
    };

    BufferLoader.prototype.load = function () {
        for (var i = 0; i < this.urlList.length; ++i) {
            this.loadBuffer(this.urlList[i], i);
        }
    };

    BufferLoader.prototype.loadBuffer = function (url, index) {
        var request = new XMLHttpRequest();
        request.open("GET", url, true);
        request.responseType = "arraybuffer";

        var loader = this;

        request.onload = function () {
            loader.context.decodeAudioData(
                request.response,
                function (buffer) {
                    if (!buffer) {
                        alert('error decoding file data: ' + url);
                        return;
                    }
                    loader.bufferList[index] = buffer;
                    if (++loader.loadCount == loader.urlList.length) {
                        loader.onload(loader.bufferList);
                    }
                },
                function (error) {
                    console.error('decodeAudioData error', error);
                }
            );
        }

        request.onerror = function () {
            alert('BufferLoader: XHR error');
        }

        request.send();
    };

После подгрузки эффекта, мы захватываем аудио поток и, если пользователь нам это позволил, то нам необходимо будет связать захваченный сигнал с нашим audio — контекстом. Для этого мы воспользуемся ф-й createMediaStreamSource. Теперь наш входной сигнал в полном нашем распоряжении и, уж поверьте, мы знатно над ним поиздеваемся.

Обработка и изменение сигнала


Пришло время нам написать функцию, которая будет безжалостно издеваться над входным потоком. Рассмотрим методы, которыми мы будем пользоваться:
  • createGain — Данный метод позволяет усиливать наш сигнал. Имеет один параметр gain — величина усиления
  • createConvolver — Данный метод позволяет произвести свертку сигналов (сложение). Метод имеет два параметра buffer — сюда пишется импульсная характеристика, с которой складывается входной сигнал. В нашем случае это внешний файл, который упоминался выше. Этот метод мы будем использовать для того, чтобы получить эффект постепенного уменьшения интенсивности звука при его многократных отражениях. На деле это не тривиальная задача и для её решения придется производить не хилые расчеты, но мы сжульничаем и воспользуемся готовой импульсной характеристикой, коих в интернете полно. Второй параметр normalize — указывает, можно ли будет масштабировать импульсную характеристику.
  • createDynamicsCompressor — Реализует эффект компрессии аудио-сигнала. Другими словами, компрессия позволяет сделать более узкой разницу между самым тихим и самым громким звуком. Метод имеет следующие параметры: threshold(Пороговый уровень, определяет значение, выше которого компрессор начинает ослаблять сигнал), ratio (определяет интенсивность ослабления сигнала), attack (это время, которое проходит между превышением порогового значения и моментом срабатывания компрессора. Эксперименты с этим параметром позволяют получить особые эффекты, например, можно сделать звук бас-барабана заметно четче), release (это время, которое проходит между тем, как уровень входного сигнала упал ниже порога, и моментом, когда компрессор перестает ослаблять сигнал.

Воспользуемся этими методами и накидаем нашу ф-ю преобразования:

var AudioModulation = function (buffers, source) {
        var am = this;
        //Общий усилитель
        var sourceGain = context.createGain();
        sourceGain.gain.value = 2;
        //Свертка сигнала с дорожкой для создания эффекта реверберации
        var sourceConvolver = context.createConvolver();
        sourceConvolver.buffer = buffers[0];
        //Добавляем компрессор частот
        var sourceCompressor = context.createDynamicsCompressor();
        sourceCompressor.threshold.value = -18.2;
        sourceCompressor.ratio.value = 4;
        //Соединяем все модули вместе
        source.connect(sourceGain);
        sourceGain.connect(sourceConvolver);
        sourceConvolver.connect(sourceCompressor);
        //Подключаем на выход
        sourceCompressor.connect(dest);
}


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

Кольцевая модуляция


Кольцевая модуляция — это аудио эффект, который был очень популярен в «лохматые» годы и применялся для создания голосов всякого рода монстров и роботов. Суть этого эффекта в том, что у нас есть два сигнала, один называется несущим (carrier) и является синтезированным сигналом произвольной частоты, а второй это модулирующий сигнал, и эти сигналы перемножаются. В итоге мы получаем новый сигнал с искажениями и металлическими нотками. Для реализации этого чуда рассмотрим следующие методы:
  • createOscillator — Этот метод позволяет генерировать сигналы произвольной частоты и формы. У него есть три параметра: type(Форма сигнала: 1 — синусоида, 2 — прямоугольный, 3 — пила, 4 — треугольный), frequency (частота сигнала), detune(расстройка — отклонение частоты. Каждая октава состоит из 1200 центов, и каждый полутон состоит из 100 центов. Указав расстройку 1200, Вы можете перейти на одну октаву вверх, а указав расстройку -1200 на одну октаву вниз.)
  • createBiquadFilter — Метод позволяет реализовать частотную фильтрацию. У метода есть четыре параметра: frequency(Частота, на которой базируется фильтр), gain(уровень усиление частоты), Q (Добротность),
    type — типы фильтрации, которые поддерживаются из коробки:
    1. lowpass — фильтр нижних частот (обрезает все, что выше выбранной частоты)
    2. highpass — высокочастотный фильтр (обрезает все, что ниже выбранной частоты)
    3. bandpass — полосовой фильтр (пропускает только определенную полосу частот)
    4. lowshelf — полка на низких частотах (означает, что усиливается или ослабляется все, что ниже выбранной частоты),
    5. highshelf — полка на высоких частотах (означает, что усиливается или ослабляется все, что выше выбранной частоты),
    6. peaking — узкополосный пиковый фильтр (усиливает определенную частоту, народное название – “фильтр-колокол”),
    7. notch — узкополосный режекторный фильтр (ослабляет определенную частоту, народное название – “фильтр-пробка”),
    8. allpass — фильтр, пропускающий все частоты сигнала с равным усилением, однако изменяющий фазу сигнала. Происходит это при изменении задержки пропускания по частотам. Обычно такой фильтр описывается одним параметром — частотой, на которой фазовый сдвиг достигает 90°.


Ну что же, этого нам вполне хватит, чтобы реализовать задуманное. В итоге ф-я AudioModulation преобразится в:
Код функции

var AudioModulation = function (buffers, source) {
        var am = this;
        //Общий усилитель
        var sourceGain = context.createGain();
        sourceGain.gain.value = 2;
        //Свертка сигнала с дорожкой для создания эффекта реверберации
        var sourceConvolver = context.createConvolver();
        sourceConvolver.buffer = buffers[0];
        //Добавляем компрессор частот
        var sourceCompressor = context.createDynamicsCompressor();
        sourceCompressor.threshold.value = -18.2;
        sourceCompressor.ratio.value = 4;
        //Соединяем все модули вместе
        source.connect(sourceGain);
        sourceGain.connect(sourceConvolver);
        sourceConvolver.connect(sourceCompressor);
        //Кольцевая модуляция
        var ringGain = this.ringModulation();
        sourceCompressor.connect(ringGain);
        //Подключаем на выход
        ringGain.connect(dest);
}
AudioModulation.prototype.ringModulation = function () {
       //Усилитель, несущего сигнала
        var ringGain = context.createGain();
        ringGain.gain.value = 1;
        //Несущий сигнал
        ringCarrier = context.createOscillator();
        //Создаем синусоидальный сигнал с частой 40Гц
        ringCarrier.type = ringCarrier.SINE;
        ringCarrier.frequency.value = 40;
        //На пол октавы вверх
        ringCarrier.detune.value = 600;
        //Создаем фильтр, который обрезает все что ниже 10Гц
        var ngHigpass = context.createBiquadFilter();
        ngHigpass.type = ngHigpass.HIGHPASS;
        ngHigpass.frequency.value = 10;
        //Применяем фильтр к созданному сигналу, а так же цепляем к нему усилитель
        ringCarrier.connect(ngHigpass);
        ngHigpass.connect(ringGain.gain);
        return ringGain;
    };


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

Фильтрация частот


Для начала создадим массив с настройками, по которым будем строить фильтры:

var filters = [{gain: 1,frequency: 40},{gain: 3,frequency: 120},....,{gain: -2,frequency: 16000}];

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

AudioModulation.prototype.setFilters = function (source) {
        var fil = [{ gain: 1, frequency: 40 }, { gain: 3, frequency: 120 }, { gain: -2, frequency: 16000}],
              out = null, ln = fil.length;

        for (var i = 0; i < ln; i++) {
            var loc = fil[i],
                  currFilter = null;
            
            currFilter = context.createBiquadFilter();
            currFilter.type = currFilter.HIGHSHELF;
            currFilter.gain.value = loc.gain;
            currFilter.Q.value = 1;
            currFilter.frequency.value = loc.frequency;
            
            if (!out) {
                source.connect(currFilter);
                out = currFilter;
            } else {
                out.connect(currFilter);
                out = currFilter;
            }
        }
        return out;
    };

В результате ф-я преобразования примет вид:
Код функции

var AudioModulation = function (buffers, source) {
        var am = this;
        //Общий усилитель
        var sourceGain = context.createGain();
        sourceGain.gain.value = 2;
        //Свертка сигнала с дорожкой для создания эффекта реверберации
        var sourceConvolver = context.createConvolver();
        sourceConvolver.buffer = buffers[0];
        //Добавляем компрессор частот
        var sourceCompressor = context.createDynamicsCompressor();
        sourceCompressor.threshold.value = -18.2;
        sourceCompressor.ratio.value = 4;
        //Соединяем все модули вместе
        source.connect(sourceGain);
        sourceGain.connect(sourceConvolver);
        sourceConvolver.connect(sourceCompressor);
        //Кольцевая модуляция
        var ringGain = this.ringModulation();
        sourceCompressor.connect(ringGain);
        //Подключаем фильтры
        var outFilters = this.setFilters(sourceCompressor);
        //Подключаем на выход
        outFilters.connect(dest);
}
//Кольцевая модуляция
AudioModulation.prototype.ringModulation = function () {
       //Усилитель, несущего сигнала
        var ringGain = context.createGain();
        ringGain.gain.value = 1;
        //Несущий сигнал
        ringCarrier = context.createOscillator();
        //Создаем синусоидальный сигнал с частой 40Гц
        ringCarrier.type = ringCarrier.SINE;
        ringCarrier.frequency.value = 40;
        //На пол октавы вверх
        ringCarrier.detune.value = 600;
        //Создаем фильтр, который обрезает все что ниже 10Гц
        var ngHigpass = context.createBiquadFilter();
        ngHigpass.type = ngHigpass.HIGHPASS;
        ngHigpass.frequency.value = 10;
        //Применяем фильтр к созданному сигналу, а так же цепляем к нему усилитель
        ringCarrier.connect(ngHigpass);
        ngHigpass.connect(ringGain.gain);
        return ringGain;
    };
//Фильтрация
AudioModulation.prototype.setFilters = function (source) {
        var fil = [{ gain: 1, frequency: 40 }, { gain: 3, frequency: 120 }, { gain: -2, frequency: 16000}],
              out = null, ln = fil.length;

        while (ln--) {
            var loc = fil[ln],
                  currFilter = null;
            
            currFilter = context.createBiquadFilter();
            currFilter.type = currFilter.HIGHSHELF;
            currFilter.gain.value = loc.gain;
            currFilter.Q.value = 1;
            currFilter.frequency.value = loc.frequency;
            
            if (!out) {
                source.connect(currFilter);
                out = currFilter;
            } else {
                out.connect(currFilter);
                out = currFilter;
            }
        }
        return out;
    };


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

Изменение тональности


Суть эффекта заключается в том, что к сигналу добавляется его копия, которая отстает от основного тона на любой интервал в пределах двух октав вверх или вниз. Это очень модный эффект и его реализация чертовски сложна, так что мы сделаем, так сказать, упрощенную версию.
Для того, чтобы начать работу над этим эффектом, нам будет необходим интерфейс, который бы позволял получать данные сигнала, которые мы могли бы изменять.
Для его создания мы воспользуемся Дискретным преобразованием Фурье (а если быть точнее, то его разновидностью оконным преобразованием Фурье) и методом, знакомым нам по прошлой статье, createScriptProcessor. Он принимает три параметра: buffer(размер кадра или окна данных, которые выбираются из сигнала в единицу времени), numberOfInputChannels (кол-во входных каналов), numberOfOutputChannels(кол-во выходных каналов). Результатом вызова этого метода и станет создание объекта интерфейса, который нам нужен. Полученный объект имеет свое событие onaudioprocess, которое отрабатывает каждый раз, когда происходит новая выборка данных из сигнала. Итого, преобразование нашего сигнала будет выглядеть следующим образом:

var currentGrainSize = 512
var currentOverLap = 0.50;
var currentShiftRatio = 0.77;
var node = context.createScriptProcessor(currentGrainSize, 1, 1);
//Создаем весовую последовательность конечной длины при помощи ф-и Окна Ханна (Одна из видов оконного преобразования Фурье)
node.grainWindow = hannWindow(currentGrainSize);
//Создаем буфер, который больше нашей выборки в два раза
node.buffer = new Float32Array(currentGrainSize* 2);
node.onaudioprocess = function (event) {
           //Входные данные
            var input = event.inputBuffer.getChannelData(0);
           //Выходные данные
            output = event.outputBuffer.getChannelData(0),
                ln = input.length;

            for (i = 0; i < ln; i++) {

                //Применяем оконного преобразования Фурье 
                input[i] *= this.grainWindow[i];

                //Перескакиваем на вторую половину буффера
                this.buffer[i] = this.buffer[i + currentGrainSize];

                //Обнуляем вторую половину
                this.buffer[i + currentGrainSize] = 0.0;
            }

            // Расчет смещения
            var grainData = new Float32Array(currentGrainSize * 2);
            for (var i = 0, j = 0.0; i < currentGrainSize; i++, j += currentShiftRatio) {
                var index = Math.floor(j) % currentGrainSize;
                var a = input[index];
                var b = input[(index + 1) % currentGrainSize];
                grainData[i] += linearInterpolation(a, b, j % 1.0) * this.grainWindow[i];
            }

            // Перекрытие
            for (i = 0; i < currentGrainSize; i += Math.round(currentGrainSize * (1 - currentOverLap))) {
                for (j = 0; j <= currentGrainSize; j++) {
                    this.buffer[i + j] += grainData[j];
                }
            }

            // Подаем обработанный поток на выход
            for (i = 0; i < currentGrainSize; i++) {
                output[i] = this.buffer[i];
            }
        }

Теперь, оперируя параметрами шага и перекрытия, мы можем получить эффект ускорения или замедления произношения. Для расчетов нам необходимо будет реализовать ф-и hannWindow (Ф-я расчета окна Ханна) и linearInterpolation(ф-я линейной интерполяции). Итоговый вариант нашей ф-и преобразования будет следующим:
Код функции

var AudioModulation = function (buffers, source) {
        var am = this, currentGrainSize = 512, currentOverLap = 0.50, currentShiftRatio = 0.77,
              node = context.createScriptProcessor(currentGrainSize, 1, 1);
        //Общий усилитель
        var sourceGain = context.createGain();
        sourceGain.gain.value = 2;
        //Свертка сигнала с дорожкой для создания эффекта реверберации
        var sourceConvolver = context.createConvolver();
        sourceConvolver.buffer = buffers[0];
        //Добавляем компрессор частот
        var sourceCompressor = context.createDynamicsCompressor();
        sourceCompressor.threshold.value = -18.2;
        sourceCompressor.ratio.value = 4;
        //Соединяем все модули вместе
        source.connect(sourceGain);
        sourceGain.connect(sourceConvolver);
        sourceConvolver.connect(sourceCompressor);
        //Кольцевая модуляция
        var ringGain = this.ringModulation();
        sourceCompressor.connect(ringGain);
        //Подключаем фильтры
        var outFilters = this.setFilters(sourceCompressor);
        //Подключаем на выход
        outFilters.connect(dest);

       //Создаем весовую последовательность конечной длины при помощи ф-и Окна Ханна (Одна из видов оконного преобразования Фурье)
       node.grainWindow = this.hannWindow(currentGrainSize);
       //Создаем буфер, который больше нашей выборки в два раза
       node.buffer = new Float32Array(currentGrainSize* 2);
       node.onaudioprocess = function (event) {
           //Входные данные
            var input = event.inputBuffer.getChannelData(0);
           //Выходные данные
            output = event.outputBuffer.getChannelData(0),
                ln = input.length;

            for (i = 0; i < ln; i++) {

                //Применяем оконного преобразования Фурье 
                input[i] *= this.grainWindow[i];

                //Перескакиваем на вторую половину буффера
                this.buffer[i] = this.buffer[i + currentGrainSize];

                //Обнуляем вторую половину
                this.buffer[i + currentGrainSize] = 0.0;
            }

            // Расчет смещения
            var grainData = new Float32Array(currentGrainSize * 2);
            for (var i = 0, j = 0.0; i < currentGrainSize; i++, j += currentShiftRatio) {
                var index = Math.floor(j) % currentGrainSize;
                var a = input[index];
                var b = input[(index + 1) % currentGrainSize];
                grainData[i] += am.linearInterpolation(a, b, j % 1.0) * this.grainWindow[i];
            }

            // Перекрытие
            for (i = 0; i < currentGrainSize; i += Math.round(currentGrainSize * (1 - currentOverLap))) {
                for (j = 0; j <= currentGrainSize; j++) {
                    this.buffer[i + j] += grainData[j];
                }
            }

            // Подаем обработанный поток на выход
            for (i = 0; i < currentGrainSize; i++) {
                output[i] = this.buffer[i];
            }
        }
}
AudioModulation.prototype.hannWindow = function (length) {
        var window = new Float32Array(length);
        for (var i = 0; i < length; i++) {
            window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (length - 1)));
        }
        return window;
};
AudioModulation.prototype.linearInterpolation = function (a, b, t) {
        return a + (b - a) * t;
};
//Кольцевая модуляция
AudioModulation.prototype.ringModulation = function () {
       //Усилитель, несущего сигнала
        var ringGain = context.createGain();
        ringGain.gain.value = 1;
        //Несущий сигнал
        ringCarrier = context.createOscillator();
        //Создаем синусоидальный сигнал с частой 40Гц
        ringCarrier.type = ringCarrier.SINE;
        ringCarrier.frequency.value = 40;
        //На пол октавы вверх
        ringCarrier.detune.value = 600;
        //Создаем фильтр, который обрезает все что ниже 10Гц
        var ngHigpass = context.createBiquadFilter();
        ngHigpass.type = ngHigpass.HIGHPASS;
        ngHigpass.frequency.value = 10;
        //Применяем фильтр к созданному сигналу, а так же цепляем к нему усилитель
        ringCarrier.connect(ngHigpass);
        ngHigpass.connect(ringGain.gain);
        return ringGain;
    };
//Фильтрация
AudioModulation.prototype.setFilters = function (source) {
        var fil = [{ gain: 1, frequency: 40 }, { gain: 3, frequency: 120 }, { gain: -2, frequency: 16000}],
              out = null, ln = fil.length;

        while (ln--) {
            var loc = fil[ln],
                  currFilter = null;
            
            currFilter = context.createBiquadFilter();
            currFilter.type = currFilter.HIGHSHELF;
            currFilter.gain.value = loc.gain;
            currFilter.Q.value = 1;
            currFilter.frequency.value = loc.frequency;
            
            if (!out) {
                source.connect(currFilter);
                out = currFilter;
            } else {
                out.connect(currFilter);
                out = currFilter;
            }
        }
        return out;
    };


Ну вот теперь, с чистой совестью, мы можем насладиться проделанной работой. Конечно, можно не останавливаться на достигнутом и, например, добавить визуализатор спектра, какой-нибудь модный эффект типа Phaser, но это уже на ваше усмотрение. Теперь, копнув глубже Audio API, становится понятно, что благодаря тем механизмам, которые сейчас доступны разработчикам, возможно реализовать практически любые эффекты и обработку звуковых сигналов. Вы ограничены только вашим воображением.
Конечный вариант с разными источника сигнала с интерфейсом управления вы можете посмотреть вот тут:

Полезная литература и источники:



P.S. Тестирование проводилось в браузерах Chrome и Opera, так что оптимально все будет работать именно в них. В остальных могут появляться различные ошибочки (которые постараюсь своевременно устранить). В IE можно даже не смотреть.
Tags:
Hubs:
+34
Comments 3
Comments Comments 3

Articles