Программирование звука с низкой задержкой в iOS

    В статье будут рассмотрены особенности работы низкоуровневого API для работы со звуком в iOS, с которыми пришлось столкнуться при разработке Viber. Речь пойдет о выборе размера аппаратного буфера и поведении AudioUnit при изменений частоты дискретизации.

    Для программной работы со звуком в iOS Apple предоставляет 4 группы API, каждая из которых предназначена для решения определенного класса задач:
    • AVFoundation позволяет проигрывать и записывать файлы и буферы в памяти с возможностью использовать предоставляемые платформой аппаратные или программные реализации некоторых аудио-кодеков. Рекомендуется использовать, когда нет жестких требований к низкой задержке проигрывания и воспроизведения.
    • OpenAL API предназначено для рендеринга и воспроизведения трехмерного звука а так же использования звуковых эффектов. Применяется, в основном, в играх. Обеспечивает низкую задержку воспроизведения, но не предоставляет возможности записывать звук.
    • AudioQueue базовое API для записи и воспроизведения аудиопотоков с возможностью использования кодеков, предоставляемых платформой. Используя это API, не получится получить минимальную задержку, но пользоваться им крайне просто.
    • И наконец AudioUnit, мощное и богатое API, для работы со звуковыми потоками. По сравнению с Mac OS X на iOS программисту доступно не полностью, но для записи и воспроизведения звука как можно ближе к «железу» подходит лучше всего.


    AudioUnit

    Об инициализации и базовом использовании AudioUnit написано довольно много, в том числе и примеров в официальной документации. Рассмотрим не совсем тривиальные особенности его конфигурации и использования. За взаимодействие со звуком, максимально «близким» к аппаратуре отвечают модули RemoteIO и VoiceProcessingIO. VoiceProcessingIO добавляет к RemoteIO возможность контролировать дополнительную обработку звука на уровне ОС для улучшения качества воспроизведения голоса и автоматической коррекции уровня сигнала (AGC). С точки зрения программиста, оба этих модуля имеют «вход» и «выход», к которым подключены по 2 шины.

    Программист может выставлять и запрашивать формат аудио потока на этих шинах. Запрашивая формат потока шины 1 на входе AudioUnit можно узнать параметры потока, получаемого с микрофона на аппаратном уровне, а выставляя формат шины 1 на выходе, можно определить в каком формате аудиопоток будет передаваться в приложение. Соответственно, выставляя формат шины 0 входа AudioUnit мы сообщаем формат аудиопотока, который будем предоставлять для проигрывания, а запрашивая формат шины 0 выхода — узнать какой формат использует аппаратура для проигрывания. Обмен буферами с AudioUnit происходит в 2х callbacks с сигнатурой:
    OSStatus AURenderCallback(void * inRefCon,
        AudioUnitRenderActionFlags * ioActionFlags,
              const AudioTimeStamp * inTimeStamp,
                             Int32   inBusNumber,
                            UInt32   inNumberFrames,
                   AudioBufferList * ioData);
    

    InputCallback вызывается, когда модуль готов предоставить нам буфер данных, записанных с микрофона. Чтобы получить эти данные в приложение, необходимо вызвать в этом callback функцию AudioUnitRender. RenderCallback вызывается, когда модуль запрашивает у приложения данные для проигрывания, которые должны быть записаны в буфер ioData. Эти callbacks вызываются в контексте внутреннего потока AudioUnit и должны отрабатывать как можно скорее. В идеале их работа должна ограничится копированием готовых буферов с данными. Это вносит дополнительные сложности в организацию обработки аудио сигнала в части синхронизации потоков. Помимо буферов, в эти callbacks передается отметка времени в виде:
    struct AudioTimeStamp {
      Float64 mSampleTime;   // метка в количестве семплов
      UInt64  mHostTime;   // метка в абсолютном системном времени
      // поля не имеющие отношения к звуку
      // ...
      UInt32 mFlags;   // маска указывающая какие из полей заполнены
     };
    

    Эту отметку времени можно (и нужно) использовать для обнаружения пропущенных семплов при записи и воспроизведении. Основные причины выпадения семплов:
    • Переключение устройств записи и воспроизведения (динамик/наушники/Bluetooth). Избавиться от потери части семплов в этом случае невозможно. Метку времени можно использовать для корректировки дальнейшей обработки звука, например, синхронизации с видеопотоком или пересчета поля “Timestamp” RTP пакета.
    • Слишком большая загрузка CPU, при которой потоку AudioUnit не выделяется достаточно времени для работы, исправляется оптимизацией алгоримов либо отказом от поддержки недостаточно мощных устройств.
    • Ошибки в реализации синхронизации потоков при работе с буферами аудио-данных. В этом случае поможет правильное использование lock-free структур, циклических буферов, GCD (однако GCD не всегда хорошее решение для задач близких к real-time). Для выявления причин проблем с синхронизацией потоков можно использовать System Trace из Instruments.

    Размер аппаратного буфера

    В идеальном случае, для получения минимальной задержки при записи звука, любая промежуточная буферизация отсутствует. Однако, в реальном мире аппаратное и программное обеспечение более оптимизировано для работы с группами последовательных сэмплов, а не с одиночными семплами. При этом iOS предоставляет возможность регулировать размер аппаратного буфера. Свойство аудио сессии PreferredHardwareIOBufferDuration позволяет запросить требуемую продолжительность буфера в секундах, а CurrentHardwareIOBufferDuration — получить реальную. Возможные значения продолжительности зависят от используемой в данный момент частоты дискретизации. Например по умолчанию при проигрывании через встроенные динамики и запись через встроенные микрофон, аппаратура будет работать с частотой дискретизации 44100Hz. Минимальный буфер, которым оперирует аудио подсистема — 256 байт, размер обычно равен степени двойки (значения получено экспериментально и в документации не фигурирует). Поэтому, буфер может иметь продолжительности:
    256/44100 = 5.805ms
    512/44100 = 11.61ms
    1024/44100 = 23.22ms
    Если использовать bluetooth гарнитуру с частотой дискретизации 16000Hz, то размер аппаратного буфера может быть:
    256/16000 = 16ms
    512/16000 = 32ms
    1024/16000 = 64ms

    Продолжительность аппаратного буфера влияет не только на задержку, но и на количество семплов, которыми AudioUnit обменивается с приложением при каждом вызове callback. При совпадении частоты дискритезации на входе и выходе AudioUnit, в callback будет передаваться и запрашивается буфер, равный по продолжительности аппаратному, и callback будет вызываться через равные промежутки времени. Соответственно, если алгоритмы приложения рассчитаны на работу с последовательностями по 10ms, то в любом случае будет необходима промежуточная буферизация на стороне приложения, так как не получится сконфигурировать AudioUnit на работу с буферами произвольной продолжительности. Размер аппаратного буфера лучше подбирать экспериментально, учитывая производительность конкретных устройств. Уменьшение улучшает показатели задержки, но добавляет накладные расходы на переключение потоков при вызове callbacks и увеличивает вероятность пропуска семплов при высокой загрузке CPU.

    Буферизация при изменении частоты дискретизации

    В приложениях для VoIP коммуникации не всегда имеет смысл обрабатывать звук с частотой дискретизации выше 16000Hz. К тому же, проще абстрагироваться от аппаратной частоты дискретизации, поскольку она может измениться в любой момент при переключении источника звука. Конфигурируя AudioUnit, можно выставить частоту дискретизации аудио потока при обмене данными с AudioUnit. Рассмотрим, как это будет работать для записи звука на следующем примере:
    SampleRateHW = 44100 // аппаратная частота дискретизации
    buffSizeHW = 1024 // размер аппаратного буфера (1024 / 44100 = 23.22ms)
    mSampleRateAPP = 16000 // частота дискретизации приложения
    buffSizeAPP = 1024 * 16000/44100 = 371.52 // размер одного после ресемплирования
    

    После ресемплирования на выходе получится целое число семплов, а дробный остаток будет храниться в виде коэффициентов фильтра ресемплера. Поведение AudioUnit довольно сильно различается в iOS5 и iOS6.

    iOS5

    В iOS5 модули AudioUnit обмениваются буферами, по размеру кратными степени двойки, поэтому во время первого вызова приложение получит 256 семплов (16ms@16kHz). Остальные 371-256=115 останутся внутри AudioUnit.


    При втором вызове callback’а, приложение опять получит буфер из 256 семплов: часть данных в нем будет из предыдущего аппаратного буфера, а часть — из нового.


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


    Затем, опять приложение получает 256 семплов.


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

    iOS6
    В iOS6 убрали ограничение на размер буфера, передаваемого между приложением и AudioUnit, избавившись таким образом от промежуточной буферизации при ресемплинге и, соответственно, уменьшив задержку. Приложение будет получать буферы размером 371 и 372 семпла попеременно.

    API CoreAudio сложно назвать понятным и хорошо документированным. Многие особенности работы приходится узнавать экспериментально, однако нужно помнить, что поведение может отличаться в разных версиях ОС. Для тех, кому интересна тема обработки звука в реальном времени, помимо документации Apple, можно рекомендовать “iZotope iOS Audio Programming Guide“.
    Viber
    69.68
    Company
    Share post

    Comments 12

      +1
      Не пробовали использовать сторонние библиотеки, например fmod?
        +5
        Основная задача была получать и проигрывать звук как можно ближе к железу, а так же оценивать задержку, которая при этом возникает. Поэтому от дополнительных промежуточных библиотек особой пользы не было бы. При работе с системным API напрямую больше контроля и проще решать проблемы, связанные, например, с различиями в работе на разных устройствах и версиях iOS.
          +2
          fmod — не промежуточная библиотека, не обертка над существующими апи. Это кроссплатформенная библиотека для софтварного проигрывания, микширования и риалтайм обработки звука. Для больших проектов стоит $15к (это не аргумент, конечно, но подразумевает серьезность )

          На виндовс ее использование дало мне меньшие задержки (по сравнению с XNA), и идеальное микширование нескольких звуковых дорожек. До iOS я пока еще не добрался и поэтому мне интересно было сравнение fmod с нативным апи.
          Я далеко не профи в обработке звука — просто сейчас пишу музыкальную игру, но советовал бы посмотреть в сторону fmod если еще не щупали (не сочтите за рекламу).
            +3
            На iOS это практически наверняка обертка над AudioUnit. Не думаю, что Apple пустил их на уровень драйверов и системных сервисов. К тому же у VoIP приложений специфика немного другая, чем у игр либо музыкальных приложений. Много каналов микшировать не нужно, обработка идет в основном на 16kHz. Упор больше на алгоритмы компрессий, компенсации задержек, шумо и эхо-подавление.
        +1
        В iOS 5, 6 все еще нужны пляски с бубном, чтобы заставить работать VoiceProcessingIO?
          +4
          Почти всегда работает без проблем.
          +1
          Звук у вас в приложении классный, был приятно удивлен. Еще хорошо, что когда у одного из абонентов штатный gprs, а у второго 3g, разговор можно довольно комфортно вести хоть и с 2-ух секундной задержкой.
            +2
            С gprs сложно сделать что-то для уменьшения задержки, из-за особенностей выделения таймслотов на upload, но стараемся улучшать и в этом направлении.
            +1
            Спасибо за статью. А для синтеза звука с минимальной задержкой (относительно тач-нажатия, естественно) какой на ваш взгляд API целесообразней использовать? Так же буду рад, если кто подскажет библиотеки для синтеза/процессинга многоканального звука в iOS.
              +1
              AudioUnit дает минимальную задержку в любом случае, им так же можно микшировать несколько каналов и применять эквалайзер к каждому из каналов. Если нужно 3D позиционирование источников звука — то OpenAL проще. Не уверен правда насчет масштабируемости этих API на 10ки каналов на iOS. Насчет библиотек не подскажу, выше упоминали fmod, но использовать его не приходилось.
                +1
                Спасибо, FMOD уже смотрю
              0
              После ресемплирования на выходе получится целое число семплов, а дробный остаток будет храниться в виде коэффициентов фильтра ресемплера.

              Маленький терминологический баг.
              Дробный остаток в явном виде не хранится, по крайней мере в коэффициентах.
              Фильтры, конечно, можно по-разному организовать, но правильнее сказать, что он «накапливается» в линии задержки. Накопили на целый сэмпл — выдали.

              Only users with full accounts can post comments. Log in, please.