Как известно, телефония предполагает передачу голоса. Для передачи голоса полная полоса 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.
Автор: datacompboy