Android NDK: работа с OpenSL ES

Ранее писал уже про OpenAL. В Кубике мы использовали именно OpenAL (игра была изначально написана под iOS). Дабы не переписывать весь код по работе со звуком, мы не стали переписать всё на OpenSL ES. В игре использовалось не так много звуков, поэтому проблем с ними не было. Некоторые, правда, жаловались на задержки при воспроизведении, но в целом всё было довольно неплохо. В Снежке же у нас использовалось много звуков (специфика игры обязывает), вот тут-то мы и столкнулись с большой проблемой. Было решено переписать всё на OpenSL ES. Для этого я написал парочку враперов, которыми решил и поделиться. Так же решил небольшой экскурс провести, так сказать Quick Start в OpenSL ES (:

  1. Краткое описание OpenSL ES.
  2. Аудио контент.
  3. Немного про обёртки.
  4. Принцип работы с объектами.
  5. Инициализация библиотеки (контекста).
  6. Работа со звуками.
  7. Проигрывание PCM.
  8. Проигрывание сжатых форматов.
  9. Заключение.
  10. Доп. информация.

Краткое описание OpenSL ES

Доступно сие дело с Android API 9 (Android 2.3) и выше. Некоторые возможности доступны лишь в Android API 14 (Android 4.0) и выше. OpenSL ES предоставляет интерфейс на языке С, который также можно вызывать из C++, предоставляющий те же возможности, что и части Android Java API по работе со звуками:

Примечание: хотя оно и основано на OpenSL ES, это API не является полной реализацией любого профиля из OpenSL ES 1.0.1.

Либа, как вы могли догадаться, написана на чистом C. По-сему полноценного ООП там нет. Используются специальные структуры (назовём их псевдообъектно-ориентированными структуры (: ), которые представляют собой обычные структуры языка C, содержащие указатели на функции, получающие первым аргументом указатели на саму структуру. Что-то вроде this в С++, но явно. В OpenSL ES два вида таких структур:

  1. Объект (SLObjectItf) – абстракция набора ресурсов, предназначенная для выполнения определенного круга задач и хранения информации об этих ресурсах. При создании объекта определяется его тип, определяющий круг задач, которые можно решать с его помощью.
  2. Интерфейс (SLEngineItf, SLSeekItf и тд) – абстракция набора взаимосвязанных функциональных возможностей, предоставляемых конкретным объектом. Интерфейс включает в себя множество методов, используемых для выполнения действий над объектом. Интерфейс имеет тип, определяющий точный перечень методов, поддерживаемых данным интерфейсом. Интерфейс определяется его идентификатором, который можно использовать в коде для ссылки на тип интерфейса (например SL_IID_VOLUME, SL_IID_SEEK). Все константы и названия интерфейсов довольно очевидные, так что проблем особых возникнуть не должно.

Если обобщить: объекты используются для выделения ресурсов и получения интерфейсов. А уже потом с помощью этих интерфейсов происходит работа с объектом. Один объект может иметь несколько интерфейсов (для изменения громкости, для изменения позиции т.п.). В зависимости от устройства (или типа объекта), некоторые интерфейсы могут быть недоступны. Скажу наперёд, вы можете стримить аудио из вашей директории assets, используя SLDataLocator_AndroidFD, который поддерживает интерфейс для перемещения позиции по треку. В тоже время, вы можете загрузить файл целиком в буфер (используя SLDataLocator_AndroidFD), и проигрывать уже оттуда. Но этот объект не поддерживает интерфейс SL_IID_SEEK, посему переместиться по треку не получится =/

Аудио контент

Есть много способов, чтобы упаковать аудио-контент в приложение:

  • Resources. Размещая аудио файлы в res/raw/ директории, можно легко получить к ним доступ с помощью API для Resources. Однако нет прямого нативного доступа к этим ресурсам, поэтому вам придётся их скопировать из Java кода.
  • Assets. Размещая аудио файлы в директории assets/, вы сможете получить к ним доступ из C++ с помощаью нативного менеджера. См. хэдеры android/asset_manager.h и android/asset_manager_jni.h для дополнительной информации.
  • Сеть. Можно использовать URI data locator для проигрывания аудио непосредственно из сети. Не забываем про необходимые пермишены для этого (:
  • Локальная файловая система. The URI data locator поддерживает схему file: для доступа к локальным файлам, при условии, что файлы доступны приложению (ну, то есть, прочитать файлы из внутреннего хранилища другого приложения не получится). Обратите внимание, что в Android доступ к файлам ограничивается с помощью механизмов Linux user ID и group ID.
  • Запись. Ваше приложение может записывать аудио с микрофона, сохранить контент, а позже проиграть.
  • Compiled and linked inline. Вы можете непосредственно запихать аудио контент в библиотеку, а затем проиграть с помощью buffer queue data locator. Это очень хорошо подходит для коротких композиций в PCM формате. PCM данные конвертируются в hex строку с использование bin2c tool.
  • Генерация в реальном времени. Приложение может генерировать (синтезировать) данные PCM на лету, а затем воспроизводить с помощью buffer queue data locator.

Немного про мои обёртки

Я вообще поклонник ООП, поэтому стараюсь как-то сгруппировать определённый функционал Си-методов и обернуть своими классами, чтобы в дальнейшем было удобно работать. По аналогии с тем, как я это делал для OpenAL, появились классы:

  1. OSLContext. Он ответственен за инициализацию библиотеки и создание экземпляров нужных буферов.
  2. OSLSound. Базовый класс для работы со звуками.
  3. OSLWav. Класс для работы с WAV. Наследуется от OSLSound, чтобы сохранить общий интерфейс для работы. Для работы с ogg можно потом создать класс OSLOgg, как я в OpenAL делал. Такое разграничение сделал, так как у этих форматов кардинально отличается процесс загрузки. WAV — чистый формат, там достаточно просто прочитать байты, ogg же надо ещё декомпрессить с помощью Vorbis, про mp3 вообще молчу (:
  4. OSLMp3. Класс для работы с Mp3. Наследуется от OSLSound, чтобы сохранить общий интерфейс для работы. Класс вообще ничего почти не реализует у меня, потому что mp3 стримлю. Но если захотите декодировать mp3 с помощью какого-нибудь lame или ещё чего-нить, то в методе load(char* filename) можете реализовать декодирование и использовать BufferPlayer.
  5. OSLPlayer. Собственно, основной класс по работе со звуком. Дело в том, что механизм работы в OpenSL ES не такой как в OpenAL. В OpenAL есть специальная структура для буфера и источника звука (на который мы навешиваем буфер). В OpenSL ES же всё крутится вокруг плейеров, которые бывают разные.
  6. OSLBufferPlayer. Используем этот плейер, когда хотим загрузить файл целиком в память. Как правило, используется для коротеньких звуковых эффектов (выстрел, взрыв и т.п.). Как уже говорил, не поддерживает интерфейс SL_IID_SEEK, посему переместиться по треку не получится.
  7. OSLAssetPlayer, позволяет стримить из директории assets (то есть, не грузить весь файл в память). Использовать для проигрывания длинных треков (музыки фоновой, например).

Принцип работы с объектами

Весь цикл работы с объектами примерно такой:

  • Получить объект, указав желаемые интерфейсы.
  • Реализовать его, вызвав (*obj)->Realize(obj, async).
  • Получить необходимые интерфейсы вызвав (*obj)-> GetInterface (obj, ID, &itf);
  • Работать через интерфейсы.
  • Удалить объект и очистить используемые ресурсы, вызвав (*obj)->Destroy(obj).

Инициализация библиотеки (контекста)

Для начала необходимо добавить в секцию LOCAL_LDLIBS файла Android.mk в jni директории флаг lOpenSLES: LOCAL_LDLIBS += -lOpenSLES и два заголовочных файла подключить:

#include <SLES/OpenSLES.h>
#include <SLES/OpenSLES_Android.h>

Теперь необходимо создать объект, через который будем работать с библиотекой, (что-то аналогичное контексту в OpenAL) с помощь метода slCreateEngine. Полученный объект становится центральным объектом для доступа к OpenSL ES API. Далее инициализируем объект с помощью метода Realize.

	result = slCreateEngine(&engineObj, //pointer to object
			0, // count of elements is array of additional options
			NULL, // array of additional options
			lEngineMixIIDCount, // interface count
			lEngineMixIIDs, // array of interface ids
			lEngineMixReqs);

	if (result != SL_RESULT_SUCCESS ) {
		LOGE("Error after slCreateEngine");
		return;
	}

	result = (*engineObj)->Realize(engineObj, SL_BOOLEAN_FALSE );

	if (result != SL_RESULT_SUCCESS ) {
		LOGE("Error after Realize");
		return;
	}

Теперь необходимо получить интерфейс SL_IID_ENGINE, через который будет осуществляться доступ к динамикам, проигрыванию звуков и тд.

	result = (*engineObj)->GetInterface(engineObj, SL_IID_ENGINE, &engine);

	if (result != SL_RESULT_SUCCESS ) {
		LOGE("Error after GetInterface");
		return;
	}

Теперь остаётся получить и инициализировать объект OutputMix для работы с динамиками с помощью метода CreateOutputMix:

result = (*engine)->CreateOutputMix(engine, &outputMixObj, lOutputMixIIDCount, lOutputMixIIDs, lOutputMixReqs);

	if(result != SL_RESULT_SUCCESS){
			LOGE("Error after CreateOutputMix");
			return;
	}

	result = (*outputMixObj)->Realize(outputMixObj, SL_BOOLEAN_FALSE);

	if(result != SL_RESULT_SUCCESS){
			LOGE("Error after Realize");
			return;
	}

Помимо инициализации основных объектов в конструкторе моего врапера OSLContext происходит инициализация всех необходимых плееров. Максимально возможно число плееров ограничено. Рекомендую создавать не более 20.

void OSLContext::initPlayers(){
	for(int i = 0; i< MAX_ASSET_PLAYERS_COUNT; ++i)
		assetPlayers[i] = new OSLAssetPlayer(this);

	for(int i = 0; i< MAX_BUF_PLAYERS_COUNT; ++i)
			bufPlayers[i] = new OSLBufferPlayer(this);

}

Работа со звуками

По сути, можно разделить на две категории типы звуков: чистые (не сжатые данные) PCM, которые содержатся в WAV и сжатые форматы (mp3, ogg и т.п.). Mp3 и ogg можно декодировать и получить всё те же несжатые звуковые данные PCM. Для работы с PCM используем BufferPlayer. Для сжатых форматов AssetPlayer, так как декодирование файлов будет довольно затратно. Если взять mp3, то аппаратно его декодировать на старых телефонах не получится, а с помощью сторонних софтверных решений декодирование займёт не один десяток секунд, что, согласитесь, не приемлемо. К тому же, слишком много весить будут такие PCM данные.

При вызове метода player() запрашивает свободный плеер у контекста (OSLContext). Если необходимо зацикливание звука, то получим OSLAssetPlayer, в другом случае OSLBufferPlayer.

Проигрывание PCM

Про чтение самого WAV писать снова не буду, можно посмотреть про это в статье про OpenAL. В этой же статье расскажу как с помощью полученных PCM данных создать BufferPlayer.

Инициализация BufferPlayer для работы с PCM

	locatorBufferQueue.locatorType = SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE;
	locatorBufferQueue.numBuffers = 16;
	
	// описание формата аудио, об этом чуть ниже расскажу
	SLDataFormat_PCM formatPCM;
	formatPCM.formatType = SL_DATAFORMAT_PCM;
	formatPCM.numChannels = 2;
	formatPCM.samplesPerSec = SL_SAMPLINGRATE_44_1;// header.samplesPerSec*1000;
	formatPCM.bitsPerSample = SL_PCMSAMPLEFORMAT_FIXED_16 ;//header.bitsPerSample;
	formatPCM.containerSize = SL_PCMSAMPLEFORMAT_FIXED_16;// header.fmtSize;
	formatPCM.channelMask = SL_SPEAKER_FRONT_LEFT|SL_SPEAKER_FRONT_RIGHT ;
	formatPCM.endianness = SL_BYTEORDER_LITTLEENDIAN;

	audioSrc.pLocator = &locatorBufferQueue;
	audioSrc.pFormat = &formatPCM;

	locatorOutMix.locatorType = SL_DATALOCATOR_OUTPUTMIX;
	locatorOutMix.outputMix =  context->getOutputMixObject();

	audioSnk.pLocator = &locatorOutMix;
	audioSnk.pFormat = NULL;

	 // создание плеера
	const SLInterfaceID ids[2] = {SL_IID_ANDROIDSIMPLEBUFFERQUEUE,/*SL_IID_MUTESOLO,*/
			/*SL_IID_EFFECTSEND,SL_IID_SEEK,*/
			/*SL_IID_MUTESOLO,*/ SL_IID_VOLUME};
	const SLboolean req[2] = {SL_BOOLEAN_TRUE,SL_BOOLEAN_TRUE};

	result = (*context->getEngine())->CreateAudioPlayer(context->getEngine(),
			&playerObj, &audioSrc, &audioSnk,2, ids, req);

	assert(SL_RESULT_SUCCESS == result);

	result = (*playerObj)->Realize(playerObj, SL_BOOLEAN_FALSE );
	assert(SL_RESULT_SUCCESS == result);
	if (result != SL_RESULT_SUCCESS ) {
		LOGE("Can not CreateAudioPlayer %d", result);
		playerObj = NULL;
	}

	// получение интерфейса 
	result = (*playerObj)->GetInterface(playerObj, SL_IID_PLAY, &player);
	assert(SL_RESULT_SUCCESS == result);


	// получение интерфейса для работы с громкостью
	result = (*playerObj)->GetInterface(playerObj, SL_IID_VOLUME, &fdPlayerVolume);
	assert(SL_RESULT_SUCCESS == result);


	result = (*playerObj)->GetInterface(playerObj,       SL_IID_ANDROIDSIMPLEBUFFERQUEUE, &bufferQueue);
	assert(SL_RESULT_SUCCESS == result);

В целом ничего сложного нет. Вот только есть ОГРОМНАЯ проблема. Обратите внимание на структуру SLDataFormat_PCM. Почему я явно сам заполнил параметры, а не прочитал из хэдеров WAV-файла? Потому что у меня все WAV файлы в едином формате, т.е. одно и тоже количество каналов, частота, битрейт и т.д. Дело в том, что если вы создадите буфер и в параметрах укажите 2 канала, а попытаетесь проиграть дорожку с 1 каналом, то приложение упадёт. Единственный вариант — переинициализировать целиком буфер, если у файла другой формат. Но ведь вся прелесть как раз в том, что мы плеер инициализируем 1 раз, а потом просто меняем буфер на нём. По-этому, тут два варианта, либо создавать несколько плееров с различными параметрами, либо все ваши .wav файлы приводить к одному формату. Ну, или же инициализировать буфер каждый раз заново -_-

Помимо интерфейса для громкости есть ещё два других интерфейса:

  1. SL_IID_MUTESOLO для управления каналами (только для многоканального звука, это указывается в поле numChannels структуры SLDataFormat_PCM).
  2. SL_IID_EFFECTSEND для наложения эффектов (по спецификации – только эффект реверберации).

Добавление звука в очередь при выборе плеера и установки звука на него:

void OSLBufferPlayer::setSound(OSLSound * sound){

	if(bufferQueue == NULL)
		LOGD("bufferQueue is null");

	this->sound = sound;

	(*bufferQueue)->Clear(bufferQueue);
	(*bufferQueue)->Enqueue(bufferQueue, sound->getBuffer() , sound->getSize());

}

Проигрывание сжатых форматов

В WAV все звуки хранить не вариант. И не потому что что сами файлы много места занимают (хотя и это тоже), просто когда вы их в память загрузите, то просто не хватит оперативки для этого (:

Я создаю классы для каждого из форматов, чтобы в будущем, если понадобиться, писать часть по декодированию в них. Для mp3 есть класс OSLMp3, который, по сути, лишь имя файла хранит для того, чтобы в будущем установить на плеер. Тоже самое можно для ogg сделать и других поддерживаемых форматов.

Приведу полностью метод по инициализации, пояснения в комментариях.

Инициализация AssetPlayer для работы со сжатыми форматами

	void OSLAssetPlayer::init(char * filename){
	SLresult result;

	AAsset* asset = AAssetManager_open(mgr, filename, AASSET_MODE_UNKNOWN);

	if (NULL == asset) {
		return JNI_FALSE;
	}

	// открываем дескриптор
	off_t start, length;
	int fd = AAsset_openFileDescriptor(asset, &start, &length);
	assert(0 <= fd);
	AAsset_close(asset);

	// настраиваем данные по файлу
	SLDataLocator_AndroidFD loc_fd = {SL_DATALOCATOR_ANDROIDFD, fd, start, length};
	SLDataFormat_MIME format_mime = {SL_DATAFORMAT_MIME, NULL, SL_CONTAINERTYPE_UNSPECIFIED};
	SLDataSource audioSrc = {&loc_fd, &format_mime};

	
	SLDataLocator_OutputMix loc_outmix = {SL_DATALOCATOR_OUTPUTMIX,  context->getOutputMixObject()};
	SLDataSink audioSnk = {&loc_outmix, NULL};

	// создаём плеер
	const SLInterfaceID ids[3] = {SL_IID_SEEK, SL_IID_MUTESOLO, SL_IID_VOLUME};
	const SLboolean req[3] = {SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE};
	result = (*context->getEngine())->CreateAudioPlayer(context->getEngine(), &playerObj, &audioSrc, &audioSnk,
			3, ids, req);
	assert(SL_RESULT_SUCCESS == result);

	// реализуем плеер
	result = (*playerObj)->Realize(playerObj, SL_BOOLEAN_FALSE);
	assert(SL_RESULT_SUCCESS == result);

	// получаем интерфейс для работы со звуком
	result = (*playerObj)->GetInterface(playerObj, SL_IID_PLAY, &player);
	assert(SL_RESULT_SUCCESS == result);

	// получение интерфейса для перемещения по файлу
	result = (*playerObj)->GetInterface(playerObj, SL_IID_SEEK, &fdPlayerSeek);
	assert(SL_RESULT_SUCCESS == result);

	// получение интерфейса для управления каналами
	result = (*playerObj)->GetInterface(playerObj, SL_IID_MUTESOLO, &fdPlayerMuteSolo);
	assert(SL_RESULT_SUCCESS == result);

	// получение интерфейса для управления громокстью
	result = (*playerObj)->GetInterface(playerObj, SL_IID_VOLUME, &fdPlayerVolume);
	assert(SL_RESULT_SUCCESS == result);

	// задаём необходимо ли зацикливание файла
	result = (*fdPlayerSeek)->SetLoop(fdPlayerSeek, sound->isLooping() ? SL_BOOLEAN_TRUE : SL_BOOLEAN_FALSE, 0, SL_TIME_UNKNOWN);
	assert(SL_RESULT_SUCCESS == result);

//	return JNI_TRUE;
}

Заключение

OpenSL ES достаточно прост в изучении. Да и возможностей у него не мало (к примеру можно записывать аудио). Жаль только, что с кроссплатформенностью проблемы. OpenAL кроссплатформенный, но на Android ведёт себя не очень. Есть у OpenSL пара минусов, странное поведение callback’ов, не все возможности спецификации поддерживаются и т.д. Но в целом, простота реализации и стабильная работы покрывают эти минусы.

Сорсы можно взять на github.com

Доп. инфа

Интересное чтиво по теме:

  1. The Standard for Embedded Audio Acceleration на сайте разработчика.
  2. The Khronos Group Inc. OpenSL ES Specification.
  3. Android NDK. Разработка приложений под Android на С/С++. Можно купить на Озоне.
  4. Ogg Vorbis