В статье будут рассмотрены особенности работы низкоуровневого 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 можно узнать параметры потока, получаемого с микрофона на аппаратном уровне, а выставляя формат шины 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.
При втором вызове callback’а, приложение опять получит буфер из 256 семплов: часть данных в нем будет из предыдущего аппаратного буфера, а часть — из нового.
При третьем вызове, остаток, накопившийся после ресемплирования, позволит передать в приложение сразу 512 семплов.
Затем, опять приложение получает 256 семплов.
Таким образом при записи с ресемплированием, callback будет вызываться через равные промежутки времени, а размер получаемого им буфера будет не постоянным (но равным степени двойки).
iOS6
В iOS6 убрали ограничение на размер буфера, передаваемого между приложением и AudioUnit, избавившись таким образом от промежуточной буферизации при ресемплинге и, соответственно, уменьшив задержку. Приложение будет получать буферы размером 371 и 372 семпла попеременно.
API CoreAudio сложно назвать понятным и хорошо документированным. Многие особенности работы приходится узнавать экспериментально, однако нужно помнить, что поведение может отличаться в разных версиях ОС. Для тех, кому интересна тема обработки звука в реальном времени, помимо документации Apple, можно рекомендовать “iZotope iOS Audio Programming Guide“.
Автор: notorca