Работаем с аудио: прогресс и визуализация данных

  • Tutorial


Доброго времени суток, друзья!

Хочу поделиться опытом работы с аудио. Под «аудио» я подразумеваю HTMLAudioElement и Web Audio API.

Что будем делать?

Мы создадим нечто вроде плеера для одного трека (о полноценном проигрывателе — в одной из следующих статей).

Условия:

  • Возможность загрузки файла из любого места на жестком диске как по нажатию кнопки, так и перетаскиванием.
  • Круговой графический и текстовый индикаторы прогресса.
  • Текстовый индикатор громкости звука.
  • Визуализация аудио данных.
  • Управление плеером с помощью клавиатуры.

В сети полно материалов как по HTMLAudioElement, так и по WAAPI, поэтому я сделаю акцент на практической составляющей. Кроме аудио, мы будем работать с drag-drop и canvas.

Без дальнейших предисловий…

Да, чуть не забыл: за основу «визуалайзера» взял работу одного из хабровчан. Не могу найти его по поиску. Буду признателен за ссылочку.

Вот как выглядит наша разметка:

<p>click or drag</p>
<div dropzone>
    <img src="https://thebestcode.ru/media/audioProgress&Visualizer/plus.png" alt="#">
    <input type="file" accept="audio/*">
</div>
<canvas></canvas>

У нас есть подсказка (параграф), кнопка (картинка + «инпут» с атрибутом accept в контейнере с атрибутом dropzone)
и холст.

Как видим, ничего необычного.

В стилях также ничего сверхъестественного.

CSS:
@font-face {
  font-family: "Nova Mono", monospace;
  src: url("https://thebestcode.ru/media/audioProgress&Visualizer/font.ttf");
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  height: 100vh;
  background: radial-gradient(circle, #666, #222);
  display: flex;
  justify-content: center;
  align-items: center;
}

p {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -70px);
  color: #ddd;
  text-align: center;
  text-transform: uppercase;
  font-family: "Nova Mono", monospace;
  font-size: 0.8em;
  font-weight: bold;
  letter-spacing: 2px;
  user-select: none;
}

span {
  display: block;
  font-size: 1.6em;
}

div {
  width: 100px;
  height: 100px;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px dashed #ddd;
  border-radius: 10%;
  cursor: pointer;
}

img {
  width: 70px;
  height: 70px;
  filter: invert();
}

input {
  display: none;
}

canvas {
  display: none;
}


Переходим к JS.

Объявляем основные переменные:

let dropZone = document.querySelector("div"),
    input = document.querySelector("input"),
    file,
    text,
    progress,
    volume,
    audio,
    // массив для частот
    frequencyArray;

Работаем с drag-drop:

// "сбрасывание" элемента
dropZone.ondrop = e => {
    // отключаем поведение браузера по умолчанию
    e.preventDefault();
    // осуществляем проверку
    if (e.dataTransfer.items[0].kind == "file") {
        // получаем файл
        file = e.dataTransfer.items[0].getAsFile();
    } else return;
    // передаем файл
    playTrack(file);
};

// элемент над "зоной"
dropZone.ondragover = e => {
    // отключаем поведение браузера по умолчанию
    e.preventDefault();
};

// клик по зоне
dropZone.onclick = () => {
    // кликаем по инпуту
    input.click();
    // при изменении инпута
    input.onchange = () => {
        // получаем файл
        file = input.files[0];
        // передаем файл
        playTrack(file);
    };
};

Двигаемся дальше.

Объявляем переменные для холста:

let C = document.querySelector("canvas"),
    $ = C.getContext("2d"),
    W = (C.width = innerWidth),
    H = (C.height = innerHeight),
    centerX = W / 2,
    centerY = H / 2,
    radius,
    // эту переменную мы будем использовать для определения текущего прогресса
    piece,
    // количество колонок
    bars = 200,
    x,
    y,
    xEnd,
    yEnd,
    // ширина колонки
    barWidth = 2,
    // высота колонки
    barHeight,
    // цвет колонки
    lineColor;

Приступаем к основной функции (весь наш дальнейший код будет находиться в этой функции):

function playTrack(file) {
    // код плеера
}

Убираем зону (она нам больше не нужна), меняем текст параграфа, инициализируем переменные для звука и прогресса:

dropZone.style.display = "none";

text = document.querySelector("p");

text.style.transform = "translate(-50%,-50%)";

text.innerHTML = `progress: <span class="progress"></span> <br> volume: <span class="volume"></span>`;

volume = document.querySelector(".volume");

progress = document.querySelector(".progress");

Колдуем со звуком:

audio = new Audio();
// аудио контекст представляет собой объект, состоящий из аудио модулей
// он управляет созданием узлов и выполняет обработку (декодирование) аудио данных
context = new AudioContext();

// анализатор представляет собой узел, содержащий актуальную (т.е. постоянно обновляющуюся) информацию о частотах и времени воспроизведения
// он используется для анализа и визуализации аудио данных
analyser = context.createAnalyser();

// метод URL.createObjectURL() создает DOMString, содержащий URL с указанием на объект, заданный как параметр
// он позволяет загружать файлы из любого места на жестком диске
// время жизни URL - сессия браузера
audio.src = URL.createObjectURL(file);

// определяем источник звука
source = context.createMediaElementSource(audio);

// подключаем к источнику звука анализатор
source.connect(analyser);

// подключаем к анализатору "выход" звука - акустическая система устройства
analyser.connect(context.destination);

// получаем так называемый байтовый массив без знака на основе длины буфера
// данный массив содержит информацию о частотах
/*let bufferLength = analyser.frequencyBinCount;
let frequencyArray = new Uint8Array(bufferLength);*/
frequencyArray = new Uint8Array(analyser.frequencyBinCount);

// запускаем воспроизведение
audio.play();

// включаем повтор воспроизведения
audio.loop = true;

Добавляем возможность управления плеером (у нас нет кнопок, поэтому управлять плеером можно только с помощью клавиатуры):

document.addEventListener("keydown", e => {
    // необходимость использования try/catch обусловлена странным поведением Chrome, связанным с вычислением громкости звука
    // попробуйте убрать try/catch и выводить в консоль громкость звука (console.log(audio.volume)) после каждого изменения
    // при приближении к 0 и 1 (согласно спецификации значение громкости звука варьируется от 0 до 1) получаем странные значения, которые нивелируют проверки типа if(audio.volume>0 && audio.volume<1)
    // использование в проверках "неточных" значений вроде 0.1 и 0.9 решает проблему исключений, но приводит к некорректному изменению громкости звука
    // исключения работе плеера не мешают, но раздражают
    try {
        // отключаем стандартный функционал клавиатуры
        e.preventDefault()

        // пробел
        if (e.keyCode == 32) {
            // пуск/пауза
            audio.paused ? audio.play() : audio.pause();
        // enter
        } else if (e.keyCode == 13) {
            // стоп
            audio.load();
        // стрелка вправо
        } else if (e.keyCode == 39) {
            // время воспроизведения + 10 секунд
            audio.currentTime += 10;
        // стрелка влево
        } else if (e.keyCode == 37) {
            // время воспроизведения - 10 секунд
            audio.currentTime -= 10;
        // стрелка вниз
        } else if (e.keyCode == 40) {
            // громкость звука - 10%
            audio.volume -= 0.1;
        // стрелка вверх
        } else if (e.keyCode == 38) {
            // громкость звука + 10%
            audio.volume += 0.1;
        }
    // скрываем исключения
    } catch {
        return;
    }
});

// добавляем подсказку
console.log(
    " Use Keyboard: \n Space to Play/Pause \n Enter to Stop \n Arrows to Change \n Time and Volume"
);

Следующая часть — анимация. Вызываем соответствующую функцию:

startAnimation();

Сама функция выглядит следующим образом:

function startAnimation() {
    // включаем холст
    C.style.display = "block";

    // определяем текущий прогресс (текущее время воспроизведения / продолжительность трека)
    piece = audio.currentTime / audio.duration;

    // устанавливаем радиус круга
    // мы будем использовать два радиуса: один для прогресса, другой для визуализации частот
    radius = 105;
    
    // очищаем холст
    $.clearRect(0, 0, W, H);
    
    // рисуем круговой прогресс
    $.beginPath();
    $.arc(centerX, centerY, radius, 0, Math.PI * (2 * piece));
    $.lineWidth = 30;
    $.stroke();
    
    // выводим значение громкости
    volume.innerText = Math.trunc(audio.volume * 100) + "%";
    
    // выводим значение прогресса
    progress.innerText = Math.trunc(piece * 100) + "%";

    // копируем данные о частотах в frequencyArray
    analyser.getByteFrequencyData(frequencyArray);
    // делаем итерацию по количеству колонок
    for (let i = 0; i < bars; i++) {
        // увеличиваем радиус
        radius = 120;

        // переводим количество колонок в радианы
        rads = Math.PI * 2 / bars;

        // определяем высоту колонок
        barHeight = frequencyArray[i] * 0.6;
        
        // двигаемся от 0 по часовой стрелке
        x = centerX + Math.cos(rads * i) * radius;
        y = centerY + Math.sin(rads * i) * radius;
        xEnd = centerX + Math.cos(rads * i) * (radius + barHeight);
        yEnd = centerY + Math.sin(rads * i) * (radius + barHeight);
        
        // рисуем колонки
        drawBar(x, y, xEnd, yEnd, barWidth, frequencyArray[i]);
    }

    // зацикливаем анимацию
    requestAnimationFrame(startAnimation);
}

И в завершение — рендеринг колонок:

// имена переменных можно было не менять, но очень хотелось
function drawBar(x1, y1, x2, y2, width, frequency) {
    // цвет колонок меняется от светло-голубого до темно-синего
    lineColor = "rgb(" + frequency + ", " + frequency + ", " + 205 + ")";
    
    // рисуем линии
    $.strokeStyle = lineColor;
    $.lineWidth = width;
    $.beginPath();
    $.moveTo(x1, y1);
    $.lineTo(x2, y2);
    $.stroke();
}

Результат можно посмотреть здесь.

Благодарю за внимание.

Комментарии 12

    0
    Добавлю, что в Chrome и Safari playTrack() можно запустить только синхронно из события, порожденного пользовательским взаимодействием (ondrop, ochange, onclick, ontouch, etc). Другими словами, автопроигрывание запрещено (слава богу?).
      +1
      Все началось со злоупотребления атрибутом autoplay тегов «audio» и «video» (и методом play сразу после window.onload), это сильно раздражало пользователей. В случае с видео включить автопроигрывание можно только в беззвучном режиме (muted), аудио запускается только «по желанию» пользователя
      0
      Впечатляет
        0

        Атрибут dropzone не нужен.

          0
          // добавляем подсказку
          console.log(
          " Use Keyboard: \n Space to Play/Pause \n Enter to Stop \n Arrows to Change \n Time and Volume"
          );
          Подсказку в консоль? Не очень хорошая идея, лучше текстом или при наведении на иконку.

          $ = C.getContext(«2d»)
          Имя переменной по-моему неудачное. Можно подумать, что используется jQuery.

          Но в целом материал очень полезный. Странно, что такие статьи не выходят на главную.
            0
            С подсказкой экспериментировал, вариант с console показался оптимальным, но, возможно, Вы правы. Использование $ в качестве ссылки на контекст холста является распространенной практикой (удобно: все методы холста начинаются с контекста)
            0
            Давайте попробуем разобраться что происходит тут:
            необходимость использования try/catch обусловлена странным поведением Chrome, связанным с вычислением громкости звука. Попробуйте убрать try/catch и выводить в консоль громкость звука (console.log(audio.volume)) после каждого изменения при приближении к 0 и 1 (согласно спецификации значение громкости звука варьируется от 0.1 до 1) получаем странные значения, которые нивелируют проверки типа if(audio.volume>0 && audio.volume<1). Использование в проверках «неточных» значений вроде 0.1 и 0.9 решает проблему исключений, но приводит к некорректному изменению громкости звука. Исключения работе плеера не мешают, но раздражают
            Мне стало интересно, я попробовал воспроизвести и не совсем понял в чем заключается «странное поведение»: у меня в Chrome console.log(audio.volume) выдает предсказуемые значения в диапазоне (0;1]. А такое решение у вас работает или тоже падает/ведет себя странно?

            согласно спецификации значение громкости звука варьируется от 0.1 до 1
            Нет, от 0 до 1.
              0
              Смотрите, без try/catch и вашей функции (clampNumber) это выглядит так (уменьшаем громкость):
              document.addEventListener("keydown", e => {
                  if (e.keyCode == 32) {
                      audio.paused ? audio.play() : audio.pause();
                  } else if (e.keyCode == 13) {
                      audio.load();
                  } else if (e.keyCode == 39) {
                      audio.currentTime += 10;
                  } else if (e.keyCode == 37) {
                      audio.currentTime -= 10;
                  } else if (e.keyCode == 40) {
                      audio.volume -= 0.1;
                      console.log(audio.volume)
                  } else if (e.keyCode == 38) {
                      audio.volume += 0.1;
                      console.log(audio.volume)
                  }
              });
              



              C вашей функцией так:
              document.addEventListener("keydown", e => {
                  if (e.keyCode == 32) {
                      audio.paused ? audio.play() : audio.pause();
                  } else if (e.keyCode == 13) {
                      audio.load();
                  } else if (e.keyCode == 39) {
                      audio.currentTime += 10;
                  } else if (e.keyCode == 37) {
                      audio.currentTime -= 10;
                  } else if (e.keyCode == 40) {
                      audio.volume = clampNumber(audio.volume - 0.1);
                  } else if (e.keyCode == 38) {
                      audio.volume = clampNumber(audio.volume + 0.1);
                  }
              });
              



              Проблема исключений решается, странные значения остаются. Результат такой же, как при использовании try/catch, но пришлось написать целую функцию (которая иногда приводит к громкости = 79, 89 и т.д.). С диапазоном согласен.
                0
                Ага, кажется понял о чем вы. «Странные» некруглые значения громкости — это всего лишь артефакт того, что значение храниться во float.

                В нормальных (ирония) языках вроде C, где есть отдельно целочисленные и плавающие типы, такое поведение — обычное дело. Там никого не удивишь, что 0.0001 + 0.0002 != 0.0003. А в javascript число представлено одним универсальным типом IEEE 754 binary64. Что позволяет однозначно закодировать (в том числе и) диапазон целых чисел от Number.MIN_SAFE_INTEGER до Number.MAX_SAFE_INTEGER и как будто бы ожидать от number поведение, свойственное для integer в других языках: 123 + 456 == 579 строго. И это ослабляет бдительность. Ведь по-прежнему 0.1 + 0.2 == 0.30000000000000004

                А в нашем случае вообще происходит итеративное сложение, которое за каждую операцию накапливает в себе ошибку. Так что это поведение абсолютно нормально. Чтобы видеть красивые ровные проценты в UI — отображаемое значение необходимо округлить. Это стандартная практика.
                  0
                  И ещё один момент по поводу:
                  Результат такой же, как при использовании try/catch, но пришлось написать целую функцию

                  Дело в том, что
                  catch(everyErrorIDontCare) { return; }

                  это антипаттерн, которого рекомендуют всячески избегать. Ведь вы никогда не узнаете, если что-то другое неожиданно сломается внутри try блока.

                  Считается, что допустимо использовать try/catch при выполнении трех условий:

                  1. без ошибок никак иначе не обойтись
                  2. минимально необходимое тело try
                  3. выброс необработанной ошибки наружу

                  try {
                    audio.volume = 1.1;
                  } catch (err) {
                    if (err instanceof DOMException && err.name === 'IndexSizeError') {
                      return;
                    } else {
                      throw err; // pass any other unhandled error
                    }
                  }
                  

                  Но всё это совершенно не оправдано в нашем случае. И не красиво.

                  А вот функция clampNumber() — вполне self-explaining code. Ведь вам сам API как бы говорит: «программист, позаботься о том, чтобы громкость была в интервале от 0 до 1, иначе я ломаюсь».
                  +1
                  Когда занимался подобным проектом, попалась интересная библиотека по определению ритма музыки (BPM): web-audio-beat-detector
                  Работает с применением Web-воркеров
                    0

                    А можно такое же, но для бесконечного радиопотока? :)

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое