Как сделать звуковую волну голосовых сообщений вконтакте

Опубликовано: 01 March 2020 08:03Обновлено: 30 April 2020 12:04
Как сделать звуковую волну голосовых сообщений вконтакте

Приветствую. В этой статье мы разберем, как отобразить звуковую дорожку ввиде волн. Самое интересное, это будет реализовано на front-ende , т. е. прямо в браузере.


Меньше слов, больше дела...


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


Теперь создадим файл index.js куда будем писать код.



Шаг 1.


Скачиваем трэк.


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, который представляет собой массив чисел.


Шаг 2.


Работаем с 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.


Шаг 3.


Увеличиваем производительность. Обычно 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, тем самым увеличив производительность.


Шаг 4.


Теперь создадим методы, которые будут обрабатывать 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
    });
  });
};


Куча кода. Давай разберемся, что же он делает.



Начнем с функции workWithData.


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);
};

Нормализуем данные так, чтобы они не превышали единицу.


Далее следует getAudioWave.


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 и не занимать память.



Шаг 5.


Начинаем отрисовку. Как и вконтакте, мы будем использовать 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);
};


Шаг 6.


Теперь нам нужно вызвать вызвать весь этот код на нашей странице.

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/


https://waveform.prototyping.bbc.co.uk/