javascript

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

  • вторник, 24 июля 2018 г. в 00:18:34
https://habr.com/post/412629/
  • Обработка изображений
  • Интерфейсы
  • Алгоритмы
  • PHP
  • JavaScript




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

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

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

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

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


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

Генерация waveform


Каким размером выбрать итоговый растровый файл? Если мы возьмем мой дизайн плеера (он здесь уменьшен по ширине), то увидим, что на одну полоску приходится 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 pal8 -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 pal8 -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 лет из трансцендентного сюда заглядывал только мойщик окон, а из имманентного — бухгалтера с безумными глазами, которые стучат по всем дверям на этаже в поисках кого-либо, кто объяснит как им подписать платежку через безумный плагин интернет-банка из-за очередного обновления браузера.