Приветствую. В этой статье мы разберем, как отобразить звуковую дорожку ввиде волн. Самое интересное, это будет реализовано на front-ende , т. е. прямо в браузере.
Меньше слов, больше дела...
Для начала нужно выбрать трэк. Я как раз таки взял из вк свое голосовое сообщение, там только белый шум, поэтому вот сам трэк.
Теперь создадим файл index.js куда будем писать код.
Скачиваем трэк.
const downloadBufferFromAudio = async (url) => { try { const response = await fetch(url); return response.arrayBuffer(); } catch (error) { throw new Error("Failed to download audio file " + error); } };
Из функции видно, что для работы с аудио нам понадобится ArrayBuffer, который представляет собой массив чисел.
Работаем с Web Audio Api. Web Audio Api позволяет выбирать аудио ресурсы, добавлять эффекты, создавать визуализации, что мы и сделаем.
const getAudioBuffer = async (buffer) => { try { const AudioContext = window.AudioContext || window.webkitAudioContext; const context = new AudioContext(); const audioBuffer = await context.decodeAudioData(buffer); return audioBuffer; } catch (error) { throw new Error("Failed to decode audio data " + error); } };
Получаем AudioBuffer.
Увеличиваем производительность. Обычно AudioBuffer содержит огромное количество данных, которые нужно обработать, что негативано сказывается на работе сайта. Мы устраним эту проблему с помощью Web Worker Api. Сделаем InlineWorker.
class InlineWorker { self; functionBody = ''; constructor(func) { const match = func.toString().trim().match(/^function\s*\w*\s*\([\w\s,]*\)\s*{([\w\W]*?)}$/); this.functionBody = match ? match[1] : ''; this.self = { onerror: null, onmessage: null, terminate: () => {}, dispatchEvent: () => false, removeEventListener: () => {}, postMessage: (m) => {}, addEventListener: (m, f) => {} }; func.bind(this.self, this.self); } get work() { return new Worker( URL.createObjectURL(new Blob([this.functionBody], { type: 'text/javascript' })) ); } }
Данный класс позволит выполнять функции и получать их значения в Worker, тем самым увеличив производительность.
Теперь создадим методы, которые будут обрабатывать AudioBuffer и возвращать массив чисел для отрисовки волны звука.
const workWithData = () => { return new InlineWorker(function(self) { self.addEventListener('message', evt => { const audioBuffer = evt.data.audioBuffer; const filterData = (audioBuffer) => { const rawData = audioBuffer.channels[0]; const samples = 100; const blockSize = Math.floor(rawData.length / samples); const filteredData = []; for (let i = 0; i < samples; i++) { const blockStart = blockSize * i; let sum = 0; for (let j = 0; j < blockSize; j++) { sum = sum + Math.abs(rawData[blockStart + j]); } filteredData.push(sum / blockSize); } return filteredData; }; const normalizeData = (filteredData) => { const multiplier = Math.pow(Math.max(...filteredData), -1); return filteredData.map(n => n * multiplier); }; const data = normalizeData(filterData(audioBuffer)); self.postMessage({ audioWave: data }); }); }).work; }; const getAudioWave = (audioBuffer) => { return new Promise(res => { const worker = workWithData(); worker.addEventListener('message', evt => { const audioWave = evt.data.audioWave; res(audioWave); worker.terminate(); }); const audioBufferClone = { length: audioBuffer.length, channels: [], sampleRate: audioBuffer.sampleRate }; for (let channel = 0; channel < audioBuffer.numberOfChannels; ++channel) { audioBufferClone.channels[channel] = audioBuffer.getChannelData(channel); } worker.postMessage({ audioBuffer: audioBufferClone }); }); };
Куча кода. Давай разберемся, что же он делает.
return new InlineWorker(function(self) {
Здесь мы возвращаем Worker и передаем функцию в качестве параметра, которая поможет посчитать аудио волну.
const filterData = (audioBuffer) => { const rawData = audioBuffer.channels[0]; // Берем звуковой канал const samples = 100; // тут задаем число полос аудио волны const blockSize = Math.floor(rawData.length / samples); // высчитываем размер блока для сэмпла. const filteredData = []; for (let i = 0; i < samples; i++) { const blockStart = blockSize * i; // позиция первого блока в сэмпле let sum = 0; for (let j = 0; j < blockSize; j++) { sum = sum + Math.abs(rawData[blockStart + j]); // находим сумму всех сэмплов в блоке } filteredData.push(sum / blockSize); // в итоге получаем среднее значение данного сэмпла } return filteredData; };
const normalizeData = (filteredData) => { const multiplier = Math.pow(Math.max(...filteredData), -1); return filteredData.map(n => n * multiplier); };
Нормализуем данные так, чтобы они не превышали единицу.
const audioBufferClone = { length: audioBuffer.length, channels: [], sampleRate: audioBuffer.sampleRate }; for (let channel = 0; channel < audioBuffer.numberOfChannels; ++channel) { audioBufferClone.channels[channel] = audioBuffer.getChannelData(channel); } worker.postMessage({ audioBuffer: audioBufferClone });
Клонируем наш AudioBuffer и отправляем в Worker. Сам же Worker не может принять настоящий AudioBuffer, поскольку не умеет его копировать.
worker.addEventListener('message', evt => { const audioWave = evt.data.audioWave; res(audioWave); worker.terminate(); });
Здесь же выполняется получение результата.
worker.terminate();
Это необходимо для того, чтобы завершить работу Workera и не занимать память.
Начинаем отрисовку. Как и вконтакте, мы будем использовать svg.
Сперва создадим пустую страницу index.html и добавим туда следующее.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <svg style="width: 320px; height: 20px"> <path style="stroke-linejoin: round; stroke-linecap: round; stroke-width: 2px; fill: none; stroke: #6287ae;" id="path-wave" /> </svg> </body> </html>
Теперь нам нужно в path добавить нашу волну. Приступим...
const startDrawSoundWave = async () => { const pathElement = document.getElementById("path-wave") as any; const buffer = await downloadBufferFromAudio( "https://psv4.userapi.com/c205216//u11437372/audiomsg/d6/49b910d132.mp3" ); const audioBuffer = await getAudioBuffer(buffer); const audioWave = await getAudioWave(audioBuffer); let path = ""; for (let i = 0, r = 0; r < audioWave.length; r++) (i = Math.floor(10 * audioWave[r] * 0.95)), 0 == i && (i = 0.5), (path += "M" + (3 * r + 1) + "," + (10 - i) + "v" + 2 * i + "Z"); pathElement.setAttribute('d', path); };
Теперь нам нужно вызвать вызвать весь этот код на нашей странице.
index.html должен выглядеть так.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <svg style="width: 320px; height: 20px"> <path style="stroke-linejoin: round; stroke-linecap: round; stroke-width: 2px; fill: none; stroke: #6287ae;" id="path-wave" /> </svg> <script src="./index.js"></script> </body> </html>
Открыв страницу в браузере, можно увидеть нашу волну.
Посмотрим как она выглядит вконтакте.
Почти... Есть над чем работать.
----
Для более глубокого изучения предлагаю след. ресурсы:
https://css-tricks.com/making-an-audio-waveform-visualizer-with-vanilla-javascript/