Android NDK: OpenSL ES

в 22:46, , рубрики: android, c++, game development, OpenSL ES, Разработка под android, метки: , ,

Здравствуйте, уважемые читатели!
Недавно, читая хабр, я увидел статью об Android NDK и OpenAL. А в комментариях был задан вопрос о OpenSL ES. Тогда у меня и родилась мысль написать статью об этой библиотеке. Я занимался этой темой, когда мне понадобилось добавить звуки и музыку в игру под Android, написанную на C++, под NDK. Статья не претендует на полноту, здесь будут лишь основы.

Содержание:

  1. Краткое описание структур OpenSL ES
  2. Инициализация механизма библиотеки и создание объекта для работы с динамиками
  3. Проигрывание PCM(wav)
  4. Проигрывание MP3, OGG
  5. Заключение

1. Краткое описание структур

Работа с OpenSL ES построена на основе псевдообъектно-ориентированных структур языка Си. Они используются, когда проект пишется на Си, но хочется объекто-ориентированности. В общем псевдообъектно-ориентированные структуры представляют из себя обычные структуры языка Си, содержащие указатели на функции, получающие первым аргументом указатели на саму структуру, подобно this в С++, но явно.
В OpenSL ES существуют два основных типа описанных выше структур:

  • Объект(SLObjectItf) – абстракция набора ресурсов, предназначенная для выполнения определенного круга задач и хранения информации об этих ресурсах. При создании объекта определяется его тип, определяющий круг задач, которые можно решать с его помощью. Объект напоминает Object языка Java, может считаться подобием класса в С++
  • Интерфейс(SLEngineItf, SLPlayItf, SLSeekItf и тд) – абстракция набора взаимосвязанных функциональных возможностей, предоставляемых конкретным объектом. Интерфейс включает в себя множество методов, используемых для выполнения действий над объектом. Интерфейс имеет тип, определяющий точный перечень методов, поддерживаемых данным интерфейсом. Интерфейс определяется его идентификатором, который можно использовать в коде для ссылки на тип интерфейса.

Проще говоря, объекты нужны для выделения ресурсов и получения интерфейсов, а интерфейсы обеспечивают доступ к возможностям объектов. Один объект может иметь несколько интерфейсов. В зависимости от устройства, некоторые интерфейсы могут быть недоступны. Однако, я с этим не сталкивался.

2. Инициализация механизма библиотеки и создание объекта для работы с динамиками

Чтобы подключить OpenSL ES в Android NDK, достаточно добавить в секцию LOCAL_LDLIBS файла Android.mk флаг lOpenSLES:

LOCAL_LDLIBS := /*...*/  -lOpenSLES

Используемые заголовочные файлы:

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

Для начала работы с OpenSL ES необходимо инициализировать объект механизма OpenSL ES(SLObjectItf) с помощью вызова slCreateEngine, указав, что для работы с ним будет использоваться интерфейс SL_IID_ENGINE. Это нужно для того, чтобы иметь возможность создавать другие объекты. Объект, полученный с помощью такого вызова, становится центральным объектом для доступа к OpenSL ES API. Далее объект необходимо реализовать, используя псевдометод Realize, который является аналогом конструктора в С++. Первым параметром Realize указывается сам реализуемый объект(аналог this), а вторым — флаг async, указывающий будет ли объект асинхронным.
Текущая реализация Android NDK дает возможность создать только один механизм библиотеки и до 32 объектов вообще. Тем не менее, любая операция создания объекта может закончиться неудачей (например, из-за недостатка системных ресурсов).

Инициализация механизма библиотеки
SLObjectItf engineObj;
const SLInterfaceID pIDs[1] = {SL_IID_ENGINE};
const SLboolean pIDsRequired[1]  = {SL_TRUE};
SLresult result = slCreateEngine(
	&engineObj, /*Указатель на результирующий объект*/
	0, /*Количество элементов в массиве дополнительных опций*/
	NULL, /*Массив дополнительных опций, NULL, если они Вам не нужны*/
	1, /*Количество интерфесов, которые должен будет поддерживать создаваемый объект*/
	pIDs, /*Массив ID интерфейсов*/
pIDsRequired /*Массив флагов, указывающих, необходим ли соответствующий интерфейс. Если указано SL_TRUE, а интерфейс не поддерживается, вызов завершится неудачей, с кодом возврата SL_RESULT_FEATURE_UNSUPPORTED*/
);
/*Проверяем результат. Если вызов slCreateEngine завершился неуспехом – ничего не поделаешь*/
if(result != SL_RESULT_SUCCESS){
	LOGE("Error after slCreateEngine");
	return;
}
/*Вызов псевдометода. Первым аргументом всегда идет аналог this*/
result = (*engineObj)->Realize(engineObj, SL_BOOLEAN_FALSE); //Реализуем объект  в синхронном режиме
/*В дальнейшем я буду опускать проверки результата, дабы не загромождать код*/
if(result != SL_RESULT_SUCCESS){
	LOGE("Error after Realize engineObj");
	return;
}

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

Получение интерфейса SL_IID_ENGINE

SLEngineItf engine;
result = (*engineObj)->GetInterface(
	engineObj,  /*this*/
	SL_IID_ENGINE, /*ID интерфейса*/
	 &engine /*Куда поместить результат*/
);

Остановимся немного на общей схеме работы с объектами:

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

Для работы с динамиками создадим объект outputMixObj, используя псевдометод CreateOutputMix интерфейса engine объекта engineObj (Это только звучит страшно, дабы читатель научился различать объекты и интерфейсы). Этот объект понадобится нам позже для вывода звука.

Создание объекта для работы с динамиками

SLObjectItf outputMixObj;
const SLInterfaceID pOutputMixIDs[] = {};
const SLboolean pOutputMixRequired[] = {};
/*Аналогично slCreateEngine()*/
result = (*engine)->CreateOutputMix(engine, &outputMixObj, 0, pOutputMixIDs, pOutputMixRequired);
result = (*outputMixObj)->Realize(outputMixObj, SL_BOOLEAN_FALSE);

SLOutputMixItf – это объект, представляющий устройство вывода звука(динамик, наушники). Спецификация OpenSL ES предусматривает возможность получения списка доступных устройств ввода/вывода, но реализация Android NDK недостаточно полна и не поддерживает ни получение перечня устройств, ни выбор желаемого (официально для этого предназначен интерфейс SLAudioIODeviceCapabilitiesItf).

3. Проигрывание PCM(wav)

Сразу оговорюсь, что для упрощения я не использую данные из заголовка WAV. При желании, добавить поддержку этого достаточно легко. Здесь заголовок нужен лишь для корректного определения размера данных.

Работа с PCM-буфером

struct WAVHeader{
	char                RIFF[4];        
	unsigned long       ChunkSize;      
	char                WAVE[4];        
	char                fmt[4];         
	unsigned long       Subchunk1Size;
	unsigned short      AudioFormat;    
	unsigned short      NumOfChan;      
	unsigned long       SamplesPerSec;  
	unsigned long       bytesPerSec;  
	unsigned short      blockAlign;     
	unsigned short      bitsPerSample;  
	char                Subchunk2ID[4]; 
	unsigned long       Subchunk2Size;  
};
struct SoundBuffer{
	WAVHeader* header;
	char* buffer;
	int length;
};
/*Для чтения буфера PCM из файла используется AAssetManager:*/
SoundBuffer* loadSoundFile(const char* filename){
	SoundBuffer* result = new SoundBuffer();
	AAsset* asset = AAssetManager_open(assetManager, filename, AASSET_MODE_UNKNOWN);
	off_t length = AAsset_getLength(asset);
	result->length = length - sizeof(WAVHeader);
	result->header = new WAVHeader();
	result->buffer = new char[result->length];
	AAsset_read(asset, result->header, sizeof(WAVHeader));
	AAsset_read(asset, result->buffer, result->length);
	AAsset_close(asset);
	return result;
}

Теперь займемся настройкой быстрого буферного вывода звука. Для этого используем специализированное расширение SLDataLocator_AndroidSimpleBufferQueue. Также, для воспроизведения музыки необходимо заполнить две структуры: SLDataSource и SLDataSink, описывающие ввод и вывод аудиоканала соответственно.

Настройка буферизованного вывода звука

/*Данные, которые необходимо передать в CreateAudioPlayer() для создания буферизованного плеера */
SLDataLocator_AndroidSimpleBufferQueue locatorBufferQueue = {SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 1}; /*Один буфер в очереди*/
/*Информация, которую можно взять из заголовка wav*/
SLDataFormat_PCM formatPCM = {
	SL_DATAFORMAT_PCM,  1, SL_SAMPLINGRATE_44_1,
	SL_PCMSAMPLEFORMAT_FIXED_16, SL_PCMSAMPLEFORMAT_FIXED_16,
	SL_SPEAKER_FRONT_CENTER, SL_BYTEORDER_LITTLEENDIAN
}; 
SLDataSource audioSrc = {&locatorBufferQueue, &formatPCM};
SLDataLocator_OutputMix locatorOutMix = {SL_DATALOCATOR_OUTPUTMIX, outputMixObj};
SLDataSink audioSnk = {&locatorOutMix, NULL};
const SLInterfaceID pIDs[1] = {SL_IID_BUFFERQUEUE};
const SLboolean pIDsRequired[1] = {SL_BOOLEAN_TRUE };
/*Создаем плеер*/
result = (*engine)->CreateAudioPlayer(engine, &playerObj, &audioSrc, &audioSnk, 1, pIDs, pIDsRequired);
result = (*playerObj)->Realize(playerObj, SL_BOOLEAN_FALSE);
SLPlayItf player;

Реализация OpenSL ES в Android NDK не является строгой. Если какие-то интерфейсы не указаны, это не значит, что их невозможно получить. Но лучше так не делать. Самостоятельно укажите интерфейс SL_IID_PLAY выше.

result = (*playerObj)->GetInterface(playerObj, SL_IID_PLAY, &player);
SLBufferQueueItf bufferQueue;
result = (*playerObj)->GetInterface(playerObj, SL_IID_BUFFERQUEUE, &bufferQueue);
result = (*player)->SetPlayState(player, SL_PLAYSTATE_PLAYING);

Помимо SL_IID_PLAY и SL_IID_BUFFERQUEUE можно запросить другие интерфейсы, например:

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

и т.д.
Вызовом (*player)->SetPlayState(player, SL_PLAYSTATE_PLAYING); мы включаем вновь созданный плеер. Пока очередь пуста, поэтому слышно лишь тишину. Давайте поместим какой-нибудь звук в очередь.

Добавление звука в очередь

SoundBuffer* sound = loadSoundFile("mySound.wav");
(*soundsBufferQueue)->Clear(bufferQueue); /*Очищаем очередь на случай, если там что-то было. Можно опустить, если хочется, чтобы очередь реально была очередью*/
(*soundsBufferQueue)->Enqueue(bufferQueue, sound->buffer, sound->length);
/*Не забудьте почистить за собой SoundBuffer, когда он перестанет быть нужен*/

Вот и все, простейший проигрыватель wav готов.
Следует обратить внимание, что в пику спецификации, Android NDK не поддерживает буферизованный вывод музыки в отличных от PCM форматах.

4. Проигрывание MP3, OGG

Описанная выше схема плохо подходит для проигрывания длинных музыкальных файлов. В первую очередь из-за того, что длинный wav файл будет весить очень и очень много. Здесь лучше использовать MP3 или OGG. OpenSL ES поддерживает стриминг файлов «из коробки». Отличие от буферизованого вывода так же в том, что на каждый музыкальный файл необходимо создавать отдельный объект-плеер. Поменять файл в процессе воспроизведения для данного плеера невозможно.
Подготовимся к проигрыванию музыки:

Работа с файловыми декриптора

struct ResourseDescriptor{
	int32_t decriptor;
	off_t start;
	off_t length;
};
/*Вновь используем AAssetManager*/
ResourseDescriptor loadResourceDescriptor(const char* path){
	AAsset* asset = AAssetManager_open(assetManager, path, AASSET_MODE_UNKNOWN);
	ResourseDescriptor resourceDescriptor;
	resourceDescriptor.decriptor = AAsset_openFileDescriptor(asset, &resourceDescriptor.start, &resourceDescriptor.length);
	AAsset_close(asset);
	return resourceDescriptor;
}

Далее вновь заполняем SLDataSource и SLDataSink. И создаем аудиоплеер.

Создание плеера

ResourseDescriptor resourceDescriptor = loadResourceDescriptor("myMusic.mp3");
SLDataLocator_AndroidFD locatorIn = {
	SL_DATALOCATOR_ANDROIDFD,
	resourseDescriptor.decriptor,
	resourseDescriptor.start,
	resourseDescriptor.length
}

SLDataFormat_MIME dataFormat = {
	SL_DATAFORMAT_MIME,
	NULL,
	SL_CONTAINERTYPE_UNSPECIFIED
};

SLDataSource audioSrc = {&locatorIn, &dataFormat};

SLDataLocator_OutputMix dataLocatorOut = {
	SL_DATALOCATOR_OUTPUTMIX,
	outputMixObj
};

SLDataSink audioSnk = {&dataLocatorOut, NULL};
const SLInterfaceID pIDs[2] = {SL_IID_PLAY, SL_IID_SEEK};
const SLboolean pIDsRequired[2] = {SL_BOOLEAN_TRUE, SL_BOOLEAN_TRUE};
SLObjectItf playerObj;
SLresult result = (*engine)->CreateAudioPlayer(engine, &playerObj, &audioSrc, &audioSnk, 2, pIDs, pIDsRequired);
result = (*playerObj)->Realize(playerObj, SL_BOOLEAN_FALSE);

Для описания исходных данных используем MIME-тип, это обеспечивает автоматическое определение типа файла.
Далее получим интерфейсы SL_IID_PLAY и SL_IID_SEEK. Последний нужен для изменения позиции воспроизведения в файле и зацикливания. Его можно использовать вне зависимости от состояния воспроизведения и скорости.

Получение интерфейсов

SLPlayItf player;
result = (*playerObj)->GetInterface(playerObj, SL_IID_PLAY, &player);
SLSeekItf seek;
result = (*playerObj)->GetInterface(playerObj, SL_IID_SEEK, &seek);
(*seek)->SetLoop(
	seek, 
	SL_BOOLEAN_TRUE, /*Воспроизведение зациклено*/
	0, /*Зациклено на начало файла(0 мс)*/
	SL_TIME_UNKNOWN /*По достижению конца*/
);
(*player)->SetPlayState(player, SL_PLAYSTATE_PLAYING);

В теории, механизм зацикливания должен быть удобен для установки фоновой музыки в игре. На практике между концом композиции и ее началом проходит 0.5-1.0 секунд (время на слух, на разных девайсах плавает). Я поборол это, сделав в фоновой музыке несколько плавных затуханий в середине и конце. Т.о. разрыв незаметен.
По спецификации, на интерфейс SLPlayerItf можно навесить различные callback’и. В Android NDK фича не поддерживается (метод возвращает SL_RESULT_SUCCESS, но callback’и не отрабатывают).
Для остановки или паузы плеера можно воспользоваться методом SetPlayState интерфейса SLPlayerItf со значениями SL_PLAYSTATE_STOPPED или SL_PLAYSTATE_PAUSED соответственно. Узнать состояние плеера позволяет метод GetPlayState, возвращающий те же значения.

5. Заключение

OpenSL ES API достаточно богато, и кроме воспроизведения звука, позволяет записывать его. Здесь я не буду касаться записи звука, скажу лишь, что она есть и работает достаточно хорошо. Для получения данных используется очередь буферов. Данные приходят в формате PCM.
Библиотеку сложно использовать в кроссплатформенной разработке, т.к. многие фичи реализованы специализированными для Android методами. Тем не менее, она показалась мне достаточно удобной.
В минусах видится вольная реализация, не поддерживаются многие вещи из спецификации. Кроме того, это API не быстрее, чем API, доступные в Android SDK.

Литература

  1. Сильвен Ретабоуил. Android NDK. Разработка приложений под Android на С/С++.
  2. The Khronos Group Inc. OpenSL ES Specification.

Хорошие и более полные примеры кода можно посмотреть в стандартной поставке Android NDK (проект NativeAudio).
Предвосхищая вопросы по поводу необходимости использования Android NDK вообще и OpenSL ES в частности, а так же за неимением аккаунта, отвечу сразу. Android NDK нужен был по условию тестового задания от известной геймдев-компании (были конкурсы на хабре). Позже это переросло в вызов мне: смогу ли я красиво закончить начатое. Смог. OpenSL ES выбрал по наитию, т.к. опыта работы ни с ним, ни с OpenAL не было, а привлекать вызовы в Java для этого посчитал некрасивым решением.

Автор: zagayevskiy

Источник

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


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