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

в 8:14, , рубрики: iOS, voip, Блог компании Viber, Работа со звуком, разработка под 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 шины.
Программирование звука с низкой задержкой в iOS
Программист может выставлять и запрашивать формат аудио потока на этих шинах. Запрашивая формат потока шины 1 на входе AudioUnit можно узнать параметры потока, получаемого с микрофона на аппаратном уровне, а выставляя формат шины 0 на выходе, можно определить в каком формате аудиопоток будет передаваться в приложение. Соответственно, выставляя формат шины 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.
Программирование звука с низкой задержкой в iOS

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

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

Затем, опять приложение получает 256 семплов.
Программирование звука с низкой задержкой в iOS

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

iOS6

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

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

Автор: notorca

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js