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,3k
    • 9
    Поддержать автора
    Поделиться публикацией

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

      +1
      ссылка на репозиторий крайне приветствуется
      однако персоны избалованные онлайн демо негодуют в желании потыкать кнопашки :)
      +1
      Подскажите, а чем вам html5 не угодил?
      <video controls="" autoplay="" name="media"><source src="1400671195.944.wav" type="audio/x-wav"></video>

      Воспроизводит аудио во всех современных браузерах и девайсах (ios и android — точно). Мы, именно так, слушаем свой астеровский архив.
        0
        1) А давно html5 научился воспроизводить raw, sln, au, gsm?
        2) Проигрыватель рисовался в 2009 году.
          0
          Здесь я с вами согласен. Тот же хром начал поддерживать wav с третьей версии, которая зарелизилась в сентябре 2009…
            0
            помнится, когда я тестировал, <audio> не жевал wav нигде, попробовать <video> для этого не догадался.
            когда начало жевать, сильно зависило от — PCM игрался на ура, ADPCM тоже, а вот gsm и G711 уже не жевало.
              0
              Ну, у нас, слава богу только PCM, по в связи с чем проблемы нет. Кстати, раз пошла такая пьянка, подскажите, как вы абонентов уведомляете о грядущей записи? Если пользователь идет через голосовое меню, то ему перед соединением можно проиграть уведомление, а вот если он сразу прямой номер абонента набирает, то как?
                0
                У нас нет прямых номеров абонента — всё только через IVR.

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

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