Адаптивный Waveform для вашего аудиосервиса

  • Tutorial


Когда мне понадобилось для сайта одной радиопередачи наладить выкладку аудио архива, помимо админки нужен был еще и аудиоплеер. Радиопередача шла 40 минут плюс две музыкальные паузы. Использовать Waveform в таких длинных форматах особенно удобно, поэтому как и многие музыкальные сервисы, я решил использовать это решение в оформлении плеера.

При планируемом будущем редизайне сайта и, возможно, будущих мобильных приложений, растровый waveform тут просто клином упирался. Он не адаптивен, его крайне ресурсоемко редизайнить, если он в растре.

Всем известный SOUNDCLOUD решил этот вопрос на маленьких экранах двиганием всей waveform относительно статического центра. Но я так не хочу.

Заливка радиопередач осуществлялась через админку, и я сразу делал более сжатые копии аудиофайлов через ffmpeg. Было бы глупо отказываться от его возможностей и по генерации waveform.

Алгоритм действий:


1. Генерация waveform в минимальном размере для хранения
2. Перевод в вектор (JSON)
3. Отрисовка плеера по этому массиву
4. Реализация адаптивности: равномерное сокращение массива и возврат к п.3

Генерация waveform



На момент реализации этого подхода, товарищи из BBC еще не релизили в своей утилите вывод в JSON, на сколько я помню. А на текущий момент, я бы вам рекомендовал пересобрать их утилиту, чтобы убрать бесполезный вывод отрицательных чисел и доп. инфу о каналах битности и прочей ерунде.
А пока, продожим:

Если мы возьмем мой дизайн плеера (он здесь уменьшен по ширине), то увидим, что на одну полоску приходится 2 пикселя (плюс 1 пиксель разделитель). Это значит, что 600px даст нам 1200px по ширине.



Предполагаю, что в будущем крайне маловероятным будет необходимость в большем представлении аудиофайла. Ну если только не тянуть по дизайну на всю ширину 4К монитора, стоит об этом подумать, но останавливаюсь на размере 600x60px.

А теперь ближе к коду:

shell_exec("ffmpeg -y -i '$name.mp3' -filter_complex 'aformat=channel_layouts=mono,compand,showwavespic=s=600x120,crop=in_w:in_h/2:0:0' -c:v png -pix_fmt monob -frames:v 1 '$png_path.png'   > /dev/null 2>/dev/null &");


-filter_complex — подключить фильтры

aformat — работа со звуком

channel_layouts

-mono — режим моно

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

-showwavespic=s=600x120 — s принимает размер изображения.

-crop=in_w:in_h/2:0:0 — обрезка полученного изображения. Как правило, выходная АЧХ зеркально отображается вокруг оси x. Поэтому мы кропаем, оставляя только верхушку «айсберга».

-c:v png -pix_fmt monob -frames:v 1 — формат выходного изображения, цветовая палитра чб и только первый фрейм (анимация нам не нужна). png8 отлично подходит по качеству(lossless в нашем случае)/месту.

> /dev/null 2>/dev/null & слать выходные и рабочие данные в пропасть. А '&' позволяет php не дожидаться завершения работы консоли, а продолжать дальше.

На выходе мы получаем вот такое изображение:


Размер итогового файла 2.4кб

Забавно то, что пару лет назад вместо белого был красный цвет. Разработчики, видимо, поменяли дефолтные значения.

Перевод waveform в вектор


Полученное изображение — это амплитуда по Y и время по X. Ее элементарно перевести в одномерный массив JSON. Где значения будут выступать в роли значений амплитуды, а время — просто их порядковый индекс.

Перевод я решил делать на лету, без кеширования результата, уж оочень быстро он делается.
Замеряем количество пикселей по Y сверху до первого другого, и переходим к следующему пикселю по X.

	
$a = imagecreatefrompng("test.png");
$i = 0;
$h = '60';
// horizontal movener
while ( $i < 600 ) {

    // vertical movener
    $y  = $h-1;
    $c = 0;
    while ( $c < $h ) {
        //echo imagecolorat($aa, $i, $c ); // test color
        if(imagecolorat($a, $i, $c ) == "255") {
            $arr[$i] =  $c;
            break;
        } else {
            $arr[$i] =  $y;
        }
        $c++;
    } 
    $i++;
};

echo json_encode($arr);

Итоговый массив состоит из 600 значений.

[46,28,34,35,34,35,26,33,39,29,29,30,30,30,33,33,28...]

Отрисовка плеера по JSON


Для удобной работы прогресс бара, я взял либу progressor.js у Elliot Bentley. Он ее сделал для сервиса аудио транскрипций.

github.com/ejb/progressor.js 2.76 KB

Взглянем еще раз на наш плеер.



Прогресс бар состоит из двух слоев: фон с серыми столбиками и с зелеными.

Ниже изображения отрисовываются функцией getGraph.

Смысл ее в том, чтобы рисовать столбики нужной толщины и цвета со столбиками разделителями.

var c    = document.createElement("canvas");
c.width  = width;
c.height = height;
var ctx  = c.getContext("2d");

function getGraph(fillStyle1,fillStyle2,fillStyle3) {
		
	if (fillStyle3) {
		//console.log(fillStyle1);
		var grd = ctx.createLinearGradient(0,120,0,0);
		grd.addColorStop(0.5,fillStyle1);
		grd.addColorStop(1,fillStyle2);
		fillStyle1 = grd;
		fillStyle2 = fillStyle3;
	}
	
	json.forEach(function(item, i, arr) {
		  ctx.fillStyle = fillStyle1;
		  ctx.fillRect(i * 3, height, 2, item - height);
		  ctx.fillStyle = fillStyle2;

			var next = json[i + 1];

			if( item <= next ) {
				h2 = next;
			} else {
				h2 = item;
			}		
	 
		  ctx.fillRect(i * 3 + 2, height, 1, h2 - height);

	});

	return c.toDataURL();
}

Вот так выглядит рабочий пример без адаптивности

4. Реализация адаптивности


Теперь нам нужносократить массив JSON на клиенте до нужного размера и вот тебе адаптивность.

План А


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

Модификация waveform через удаление значений массива — тупиковый путь. Когда вы это сделаете, то увидите на сколько форма волны становится обезличенно рваной, потому что вы выкидываете экстремумы и не усредняете соседей по высоте.

Нам нужны алгоритмы ресемплинга. Есть на js реализация алгоритма:

largestTriangleThreeBuckets

Работает она хорошо, только просит на вход такой массив, по индексам которого она получит координаты X.Y. У нас массив одномерный, поэтому пришлось чутка покумекать и переделать функцию. Работает это дело вот так:



А здесь можно потрогать с адаптивкой как КДПВ.

Переведите режим просмотра, где фрейм с html будет справа. Тогда можно менять ширину этого окошка.

План Б — пых


Однако, мне все таки не хотелось бы нагружать клиентскую часть. К примеру, я хочу 1000 точек-5000, да на всю ширину экрана. Если у меня будет больше точек, как поведет себя это дело на мобиле? С одной стороны, в этом совершенно нет проблем, это не так вроде бы и накладно если судить по демкам алгоритма, он жует 5000 точек легко. Но с другой стороны — давать надо столько, сколько спрашивают. Вопрос дизайна.

Элементарно, если у вас Node.Js вы можете этот код перенести на сервер. А если у вас php, вы можете найти реализацию этого алгоритма на php но… зачем, подумал я.

Где же алгоритмы ресемплинга? В той же нативной либе GD, которую мы использовали для генерации JSON. Мы просто передаем с клиента параметр в пикселях требуемой ширины и ресайзим нашу waveform перед переводом в JSON.

Поэтому расширю код, написанный в начале.


$h = 60;
$width_new = 600;

$a = imagecreatefrompng("$id.png");
$width_old = imagesx($a); 
$aa = imagecreatetruecolor($width_new, $h); 


imagecopyresized($aa, $a, 0, 0, 0, 0, $width_new, $h, $width_old, $h);
imagetruecolortopalette($aa, false, 2);

$i = 0;
// horizontal movener
while ( $i < $width_new ) {
	// vertical movener
	$y  = $h-1;
	$c = 0;
	while ( $c < $h ){
		//echo imagecolorat($aa, $i, $c ); // search what color is needed
		if(imagecolorat($aa, $i, $c ) == "1"){
			$arr[$i] =  $c;
			break;
		} else {
			$arr[$i] =  $y;
		}
		$c++;
	} 
	$i++;
};

echo json_encode($arr);

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

Код лежит тут

.
Пасхалка.

Наверное, это был солнечный день. Окно нашей комнаты выходило на две старые кирпичные 9-ти этажки, которые я помню еще подростком, знаю, что за ними открывается трамвайное кольцо, чуть дальше — старая больница, она сразу за школой, а текущее здание с офисом, где я пытаюсь находиться копаясь в воспоминаниях, это бывшая недостроенная больница, теперь уже чисто офисное помещение. Помню как в детстве здесь тренировались спецназовцы, их показывали по телевизору, бодро штурмующих бетонное сооружение, поросшее вокруг всем, чем только можно. А теперь, оказывается, я бодро бьюсь током о блестящие перила, спускаясь по лестнице, и любуюсь формой искажений этого здания в отражении ближайшего жилого комплекса. (Совсем рядом, по трамвайной линии открывается стена старого большого кладбища. И на ней надпись зеленой краской «Пока Борис у власти» и «Трудовая Россия». Черт знает кто и когда их сделал, но по прошествии пары десятков лет они все так же читаются, но остаются совершенно невидимыми. Я не видел больше из наследия 90-ых более древнего памятника в городе.)

На нашем верхнем этаже пусто, как бывает пусто в начатом пакете с гречкой: внизу куча всего и плотно: какие-то крутачи из спецгеоразведки, офис 2gis, потом очередные сеошники, а сверху — почти нет зерен. Думаешь, вот должно же прорасти что-то сквозь этажи что-то сюда, но за эти 5 лет из трансцендентного сюда заглядывал только мойщик окон, а из имманентного — бухгалтера с безумными глазами, которые стучат по всем дверям на этаже в поисках кого-либо, кто объяснит как им подписать платежку через безумный плагин интернет-банка из-за очередного обновления браузера.
Поделиться публикацией

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

    0

    Проще всё же стирать весь холст, а потом рисовать все цветные столбики, чем бегать и отдельно рисовать разделители и закрашивать старые столбики.


    Не понятно зачем toDataURL, если холст и так может показываться как картинка.


    Ну а ещё проще было бы не заморачиваться с холстом. Достаточно нарисовать свг в духе: <svg><path class="past" v="..."/> <path class="future" v="..."/></svg> и просто обновлять координаты линий. Стилизацией же можно весьма гибко управлять через CSS. По скорости работы это ничуть не хуже: http://mol.js.org/app/bench/#bench=geometry/sample=canvas-lines~canvas-path~svg-lines~svg-path/sort=render

      0
      Хм… так как размер полотна меняется в зависимости от ширины экрана, то его и нет смысла стирать, просто отрисовать по размеру заново по новым данным. Одноразовая операция, кроме момента, когда смартфону меняешь ориентацию.

      При старте страницы рисуется две PNG. Это два дива друг над другом, верхний всего лишь в процентах меняет свою ширину и тем самым создает анимацию прогресса.

      toDataURL я больше люблю т.к DOM и разметка не модифицируется и не нужно держать в памяти при просмотре html что тут js кое куда что-то подкладывает при старте.

      с SVG не хотел связываться, потому что большое количество мелких деталей всегда было задачей для растра. А вот с обновлением координат не выйдет, в зависимости от ширины экрана массив меняется полностью как по количеству данных так и по их значениям — этож тогда нужно в сущносности выделять каждый столбик, что совершенно не оправдано.
        0

        Один path элемент эквивалентен куче вызовов fillRect одного цвета.

          0
          Если он неразывен да. Но по вашему бенчу если на 2000 точек потыкать раз 10 в режиме отдельных линий все равно быстрее рисуются на полотне. Плюс там еще градиентик нужно наносить, не знаю как с этим у путей у SVG. Потесировать будет интересно. Спасибо.
      0
      .
        0
        .
        Второй раз промазал, да чтож такое-то
        0
        Перевод waveform в вектор

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

        Вы можете попасть в неприятную ситуацию, когда GD откажется обрабатывать ваш файл. А если быть точнее, то просто процесс будет убит с out of memory в силу того, что GD для обработки изображения в обязательном порядке загружает все изображение в память, а это изображение, совсем не обязательно у вас всегда будет полтора килобайта. Потому что тот же ffmpeg так же пишут люди которые допускают ошибки(не говоря уже об авторах графических библиотек), и результирующая картинка даже на микрофайле может оказаться в пару гигабайт.
          0
          Очень странно генерировать сначала картинку, потом конвертировать ее в данные, когда существует масса библиотек которые


          Все очень логично. Я конвертирую в два формата аудио файлы для веба — mp3 128 и opus, а оригинал — в архив. Библиотеки, которые умеют читать АЧХ без распаковки mp3? Мне такие почти не попадались. На сколько они точны, а главное, быстрее ffmpeg? А где гарантии, что завтра на вход FLAC не кинут? У меня нет никаких гарантий на входной аудиопоток. На стороне клиента, вариант не рассматривался вообще. Это не рабочий варинат.

          Вы можете попасть в неприятную ситуацию, когда GD откажется обрабатывать ваш файл. А если быть точнее, то просто процесс будет убит с out of memory

          Ну это же совсем несерьезно. Если вы говорите о high-load, конечно же, результат расчета на лету можно кешировать. И пусть загружает в память, она для этого и нужна. Но это общая ситуация с out of memory для любого узла в системе.

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

          Конечно же обязательно. Выходной формат на диск это png c палитрой в 8 бит. Там ничего не предсказуемого особо быть то и не может. Даже если учесть,1000x60px 2.5кб в распаковке это 60 кб (ого, что действительно не мало!) то при сервере с 128бм свободной оперативки это 2000 человек в секунду. О такой посещаемости мечты не стояли. А из-за ошибок может полететь все что угодно. Добавить механизм кеширования совсем не сложно. Но я выбрал более универсальное решение.
            0
            Ну это же совсем несерьезно. Если вы говорите о high-load, конечно же, результат расчета на лету можно кешировать. И пусть загружает в память, она для этого и нужна. Но это общая ситуация с out of memory для любого узла в системе.

            Я говорю о том, что использовать библиотеку GD можно только в небольших подделках, из за особенностей его архитектуры. А именно обязательной загрузки всего изображения в память. Более серезные инструменты обрабатывают изображения иначе.

            Конечно же обязательно.

            Нет не обязательно. Начиная с того, что сам ffmpeg вам может выдать картинку в гигабайты из за ошибки анализа файла, и заканчивая тем, что тоже самое может сделать GD либ.
              0

              Разве это не тот самый случай для GD работать на мелочевке? По хорошему, конечно, надо просто хранить json а не картинку. Но в моём случае мне нужны были бОльшие гарантии, который я получаю с картинки.


              Что касается ffmpeg, расчёт идёт на сервере с админкой. Поэтому теоретическое падение не страшно.

          0
          Для генерации пиков (сразу в JSON) есть отличное решение, впрочем как и для отрисовки waveform.prototyping.bbc.co.uk Генерация пиков там в несколько десятков раз будет быстрее, чем ffmpeg + php + gd2
            0
            Вы не очень глубоко, видимо, варились в этой теме. По вашей ссылке диаграмма, где вместо ffmpeg и php, некий код на c++ и руби. Вы никуда от распаковки mp3 в wave для построения ачх от С или С++ не убежите.
              0
              Во-первых
              C++ program that generates waveform data files from MP3, WAV, or FLAC format audio.

              Так что она отлично распаковывает самые популярные форматы, в том числе mp3.

              В-вторых, ffmpeg и php — это разве надежнее чем c++? На практике эта программа генерит из mp3 готовый json быстрее, чем запускается ffmpeg на чтение файла.

              Чтение из файла в спектрограмму сейчас можно даже средствами браузера Web Audio Api), а вы предлагаете через GD2 картинку парсить, что супер неэффективно для такой задачи.

              Вообще есть еще пару решений готовых (например, wavesurfer-js.org), перед изобретением велосипеда вам стоило повариться больше в гугле с этой темой.
                0
                Так что она отлично распаковывает самые популярные форматы, в том числе mp3

                Почему бы и нет? Как и ffmpeg использует библиотеки. Но не кодирует т.к у неё более узкая задача. А кодировать мне нужно. Поэтому взял более общее решение.
                ffmpeg и php — это разве надежнее чем c++?

                Вы наверное про мою связку ffmpeg PNG php json? И их waveform png ruby gem json? Что надежнее не знаю. Но логика совершенно такая же. Мне это даже льстит. Ffmpeg как и gd очень обточенные вещи с сотнями тысяч пользователей. Сомневаюсь что их решение шустрее ffmpeg т.к они брали все из исходников audacity. А как он работает на построение waveform я знаю т.к работал с этим софтом

                Web audio api это совсем не то. Работает только при проигрывании и все что там на клиенте считается это фигня. Каталог аудио не вывести.

                Wavesurfer сильно избыточен. Не подходит по дизайну. Как и peak.js от BBC он про другое. Мой велосипед отличный.
                  0
                  Вы наверное про мою связку ffmpeg PNG php json? И их waveform png ruby gem json?

                  Вы ffmpeg-ом делаете картинку, потом парсите ее через PHP+GD и на входе получаете json. Конечно это крайне неэффективно!

                  В audiowaveform вы запускаете команду
                  audiowaveform -i test.mp3 -o test.dat -z 256 -b 8

                  и на выходе получаете готовый json без лишних движений

                  Работает она быстрее даже, чем ffmpeg делает картинку, потому что по сути там тот же алгоритм, но ffmpeg еще тратит время на создание картинки. Я уже не говорю о том, что потом эту картинку надо парсить сомнительными инструментами вроде PHP+GD.

                  У нас на боевом сервере waveform генерируются налету (необходимость) на большом проекте. Несколько вещей пробовали, остановились на этой. Просто рекомендую ее.

                  Уверен, если бы вначале вы внимательнее погуглили на эту тему, вам бы не пришлось изобретать велосипед.
                    0
                    Я видел эту утилиту несколько лет назад, но если мне не изменяет память, json туда добавили не сразу. Статья на самом деле не новая. Сейчас я бы конечно взял бы эту утилиту. Я согласен, что решение не самое эффективное. Но совсем не согласен, что крайне. Отрабатывает оно так же, как если бы сервер просто отдавал файл. Какой бы там не был говнокод в libgd, на 60кб (распакованный 2кб png) данных все отрабатывается в считанные микросекунды. К тому же, нет никакой разницы, если кешировать Json. Я бы ещё подумал, нужен ли мне такой камтомный формат от BBC. Нафига там числа отрицательные...? Мне не нужно рисовать ниже оси, а если надо — я сам минус поставлю. Хотя можно же пересобрать самому, чего это я.
                      0
                      Да, мы тоже отрицательные убирали, нам это не нужно было ;) Кажется просто верх и низ не симметричны, на стерео файлах.
                        0
                        Они слишком много стащили из Audacity походу, а в редакторе волна должна показывать ассиметричность сигнала. А в любых других местах оно вообще не нужно. Большинству и в редакторе не нужно.
            0
            .

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

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