System Programming
Development of communication systems
May 2014 19

WavPlayer — мы не ищем легких путей, мы их прокладываем

Как известно, телефония предполагает передачу голоса. Для передачи голоса полная полоса 20Гц-20кГц никому не нужна, для четкого различимого и узнаваемого голоса вполне достаточно до 3.5кГц. Если быть точнее, речевая полоса частот используемая в телефонии от 300 до 3400Гц. При компрессии в общий канал, для точного выделения нужны защитные интервалы частот по краям, потому полоса пропуския — 4кГц. При оцифровке это получается 8кГц. Сейчас, в связи с развитием толщины каналов связи, те же скайпы и прочие, хвастающиеся «повышенным» качеством, используют 16кГц, а то и 32кГц, что, впрочем, реально на слух практически не отличимо при обычном разговоре (зато очень хорошо различимо при ухудшении качества канала связи, но когда это волновало маркетолухов).

Итак, практически все звуковые файлы, которые используются в телефонии, записаны с 8кГц оцифровкой. Для ускорения обработки больших потоков, применяемые методы сжатия так же просты и направлены на достойный результат при применении к желаемому — сжатию речи. Это простая оцифровка (PCM), простые дельта-кодеки (ADPCM, G711), либо хитрые кодеки (GSM 06.10). Эти форматы являются «родными» для систем телефонии — asterisk, freeswitch (и наверняка других тоже). В этих форматах данные подготавливаются для проигрывания системой людям, в эти же форматы системы могут записывать записи.

Однако сейчас всё шире web шагает по планете, и людям хочется иметь возможность прослушать записи разговоров, приветствий и др. на вебе, где «родным» форматом стал mp3…

В результате, для редкой функции «прослушать архив», наивное решение — настроить на сервере перекодирование записей из телефонного формата в MP3.

Всё бы ничего, но:
  • в mp3 записи становятся либо больше, либо хуже;
  • перекодирование в mp3 требует нагрузки на CPU сервера;
  • перекодирование происходит постфактум, а не на лету (хотя лечение для этого тоже есть);
  • перекодированные файлы по сути нужны только для клиента.

Увидев это безобразие, душа инженера заболела и стала требовать сделать хорошо. Причем не «сделать плохо, а потом как было», а именно — хорошо и прямо: ведь по сути, используемые в телефонии кодеки рассчитаны на хороший результат, причем крайне дёшево. Так зачем делать дорогую операцию кодирования в MP3, чтобы потом делать дорогую операцию декодирования из MP3 на клиенте только потому, что этот декодер там уже есть? Давайте просто сделаем этот самый простой декодер на клиенте, и всё!

Особоенно меня удивило отсутствие этих готовых декодеров. Именно так родился WavPlayer: проигрыватель на flash для файлов телефонии.

Что он умеет:
  • GUI с полосой для прыжков по записи, GUI без неё, вообще отсутствие UI
  • API для управления и отрисовки интерфейса целиком на JS стороне
  • Поддержку кодеков: PCM, G.711u/a, GSM 06.10, IMA ADPCM
  • Поддержку форматов: AU, WAV, несколько стандартных RAW

И недавно пользователи добавили прокси в стандартный MP3 проигрыватель, чтобы можно было использовать только WavPlayer для проигрывания как родных, так и перекодированных архивов. (Изначально я этого не делал, предполагая что это забота JS стороны — использовать любой из flash-mp3 проигрывателей, html5, или использовать WavPlayer).

Любой, кто прочитает описания каждого из кодеков и форматов поймёт, что проигрыватель — прост как пробка. Но если бы это было так, он бы существовал уже давно… Посему расскажу вкратце историю его создания.

Для проигрывания звуков во флеше предполагалось изначально только одно: проигрывание mp3 вставки. Всё. Больше ничего. Начиная с версии 10го, в интерфейсе flash.media.Sound появилось событие sampleData, позволяющий генерировать и проигрывать сгенерированный звук. Но как и полагается флешу, он это делает только по-своему: только 44кГц, только стерео, только 32бит числа с плавающей точкой.

А у нас — 8кГц/16кГц целые числа. Если мы просто возьмём исходные данные и просто выдадим as-is, мы получим нечто плохо разборчивое и очень высокой частоты. Вывод? Надо интерполировать имеющиеся у нас выборки — сделать иначе говоря Resample.

При ресемплинге важно понимать, что даже при простом удвоении частоты нельзя просто взять и вставить «средние» числа между двумя выборками — полученный звук будет очень сильно «свистеть» на высоких частотах, так как вместо гладкой синусоиды мы получим пилу. Правильный ресемплинг получается путём восстановления исходного гладкого звука (с минимизацией второй производной), и переоцифровки его на нужной частоте. Таким образом мы получим правильный гладкий звук с нужной частотой дискретизации.

Поскольку я, конечно, теорию знаю, но в практике очень ленив, а так же задача стояла «проиграть записи» довольно остро, решать надо было быстро. Флеш я не знаю, да и рабочая машинка под линуксом. Глянул в размер компилятора флеша — за сотню метров, так стало вломы, что решил найти альтернативу, чтоб быстро и легко нарисовать на флеше. Quick Гуглёж дал прекрасный вариант — HaXe. Простой си/java-подобный язык, который умеет транслироваться в несколько целевых платформ, в числе нужной мне — флеш. Он и был взят.

В общем, на скорую руку был скрафчен первый рабочий макет:

Нашелся fogg проект, в котором как раз вручную декодировали ogg файлы. Оттуда был взят AudioSink, реализующий push интерфейс вместо pull: буфер, в который мы пишем, а когда флеш хочет следующий кусок данных, AudioSink их ему отдаёт из буфера. Не самая оптимальная и красивая реализация, зато готовая. В качестве ресемплера была взята в лоб реализация ресемплера Lanczos (самый качественный, на базе sinc функций) из OpenJDK. Код не самый оптимальный (позже реализовывал его на чистом Action Script — удалось ускорить почти в 4 раза), но работает (а мне больше ничего и не надо было).
Интерфейс простейший: рисуем треугольник когда стоит. По клику запускается play() и рисуется квадрат. По клику рисуется две вертикальных палки.
Для декодинга G711 код взят из Sox, для PCM код родил самостоятельно.

И, разумеется, ложка ООП в эту бочку тырокода: интерфейсы File и Decoder, позволяющие в основном проигрывателе абстрагироваться от конкретной вариации. Правда, интерфейсы рожались по надобности, а не планомерно, но когда это было иначе? File работает так — входные данные файла читаются, и пихаются через метод push() в декодер. Как только все заголовки прочитаны, файл создаёт внутри себя декодер соответствующего формата, и начнёт аудиоданные запихивать в него. Метод ready() начинает возвращать истину, и начиная с этого момента все остальные методы метаданных потока так же становятся валидными, и можно вычитывать данные аудиопотока запросом getSamples(), который вернёт samplesAvailable() сэмплов.

Работа декодера так же проста — он сообщаер размер семпла в байтах, чтобы файл мог нарезать нужными пакетами для кормления декодера. Декодер последовательно для преобразования данных буфера в один семпл (в знаковый флоат).

Главная проблема, которая при этом образуется — правильное кормление ресемплера. Напомню, что ресемплер работает на приципе виртуального двойного преобразования — на основе входных данных со входной частотой дискретизации восстанавливается гладкий сигнал, который переоцифровывается на выходной частоте. Для восстановления сигнала всегда необходима история; поэтому сперва декодер надо накормить тишиной нужной длины, для инициализации. И из первого ответа выкинуть эту тишину — тогда мы получим корректный ресемплинг прямо с начала. Точно так же после того как закончатся наши данные, ресемплер надо накормить тишиной после — чтобы получить всю восстановленную информацию.

И вот таким вот макаром наша рота солдат генерит ровно сколько надо данных на 44кГц в нужной форме.

После того как заработал базовый плеер, его немного начал причесывать: перво-наперво поддержка более сложных кодеков, конкретно gsm. Сразу стало понятно что посемплово декодятся далеко не все, тут нужна пакетная обработка — так что интерфейс декодера был переделан на входящий массив+смещение, выходной массив+смещение, возвращающий сколько положил семплов на выход. Для поддержки Raw файлов большая часть кода универсальна, она была вынесена в отдельный общий класс, так чтоб переопределять минимум — только требуемые параметры его в инициализаторе. GSM декодер сам был взят как обычно где нашлось, просто трансформирован быстро в нужный синтаксис. Как ни странно — это всё заработало на ура.

Заодно был нарисован интерфейс управления проигрывателем из JS кода + выданы события загрузки, проигрывания, паузы, позволяющие рисовать состояние проигрывателя в браузере как хочется. Полученный продукт начали запиливать в продакшен. Когда начали тестировать, вылезли некоторые проблемы, особенно в глубоко обожаемом IE, который файл подгружал кусками кажется по 8к или по 4к… в общем, событий в итоге генерировалась тонна, пришлось зарезать частоту их генерации.

К сожалению, очень быстро выяснилось, что желания делать интерфейс на JS ни у кого нет. Тогда было быстро и на коленке накидано решение путем гуя внутри. Проигрыватель начал генерить внутренние события, и создан WavPlayerGui. Его Mini наследник остался как раньше — всё кнопка; плюс создан был Full, у которого слева та же кнопка, а справа прогресс-бар, показывающий длину, объём загруженного и текущее положение. Ну то есть квадратиков чуть побольше чтук, размеры которых менялись в ответ на события.

Как только это появилось, стало понятно что вообще оно должно по нему еще и сикаться. Да и вообще, прослушивать записи только целиком глупо, когда надо из 15 минутной прослушать 3ю минуту… Надо делать seek(). Реализация seek() в данном случае оказалась самой сложной задачей: так как у нас нет возможности загрузить исходный файл с произвольной позиции (мы не можем гарантировать у сервера поддержку Range, да и во флеше так просто этого не сделать), пришлось ограничить возможности seek()'а только в пределах загруженной части. Но даже в таком случае, у нас не хранится полный объём данных перекодированные в 44кГц (память, хнык, жалко), поэтому при необходимости произвести перепозиционирование, происходит следующее:
  • проверяем, не в пределах ли готовых 44кГц данных идёт seek() — если да, просто делаем сик по готовым данным.
  • если вне, ищем семпл, начиная с которого должно начаться проигрывание в терминах исходного потока
  • реинициализируется ресемплер тишиной,
  • репозиционируется входной поток на нужную позицию,
  • запускаем проигрывание.


Затем было немного косметических модификаций от тех, кто начал использовать его в публике, и снова был вызов — можно ли сделать поддержку IMA ADPCM. Формат довольно мерзкий, с точки зрения укладывания в универсальность оказался: данные лежат не поканально, а в перемешку в одном и том же месте, так что пришлось в декодер передавать еще и декодируемый канал; заодно пришлось вынести немного универсальности для всех других кодеков, ибо количество выходных данных в зависимости от входных для всех других фиксированно и просто; а тут… в общем, тут зависит от — требуется четкая история, и нельзя никак начинать декодинг с произвольного места. Соответственно для seek() функция работает так:
  • проверяем, не в пределах ли готовых 44кГц данных идёт seek() — если да, просто делаем сик по готовым данным
  • если вне, ищем семпл, начиная с которого должно начаться проигрывание в терминах исходного потока
  • ищем семпл, с которого можно начинать декодирование
  • реинициализируется ресемплер тишиной,
  • репозиционируется входной поток на позицию декодирования
  • делаем декодинг и выбросинг до позиции с которогой начать проигрывание
  • запускаем проигрывание.


В общем, как ни странно, это тоже работает. И на текущий момент, он доступен для использования всем желающим: делает ровно то, что надо, ровно так, как надо.
Для полного кайфа осталось только как-нибудь сделать наконец тот самый интерфейс на JS, который я предполагал наши веб-девелоперы сделают; плюс сделать простой и понятный пример интеграции, который можно ставить copy-paste'ом в свой сайт, ибо чаще всего проблема интеграции этот падает на плечи сисадмина, а не программиста… Так что, to be continued.

Проект на Github | Онлайн демо.
+20
9.2k 53
Support the author
Comments 9
Top of the day