Здравствуйте, уважемые хабражители!
Недавно, читая хабр, я увидел статью об Android NDK и OpenAL. А в комментариях был задан вопрос о OpenSL ES. Тогда у меня и родилась мысль написать статью об этой библиотеке. Я занимался этой темой, когда мне понадобилось добавить звуки и музыку в игру под Android, написанную на C++, под NDK. Статья не претендует на полноту, здесь будут лишь основы.
Содержание:
Работа с OpenSL ES построена на основе псевдообъектно-ориентированных структур языка Си. Они используются, когда проект пишется на Си, но хочется объекто-ориентированности. В общем псевдообъектно-ориентированные структуры представляют из себя обычные структуры языка Си, содержащие указатели на функции, получающие первым аргументом указатели на саму структуру, подобно this в С++, но явно.
В OpenSL ES существуют два основных типа описанных выше структур:
Проще говоря, объекты нужны для выделения ресурсов и получения интерфейсов, а интерфейсы обеспечивают доступ к возможностям объектов. Один объект может иметь несколько интерфейсов. В зависимости от устройства, некоторые интерфейсы могут быть недоступны. Однако, я с этим не сталкивался.
Чтобы подключить OpenSL ES в Android NDK, достаточно добавить в секцию LOCAL_LDLIBS файла Android.mk флаг lOpenSLES:
Используемые заголовочные файлы:
Для начала работы с OpenSL ES необходимо инициализировать объект механизма OpenSL ES(SLObjectItf) с помощью вызова slCreateEngine, указав, что для работы с ним будет использоваться интерфейс SL_IID_ENGINE. Это нужно для того, чтобы иметь возможность создавать другие объекты. Объект, полученный с помощью такого вызова, становится центральным объектом для доступа к OpenSL ES API. Далее объект необходимо реализовать, используя псевдометод Realize, который является аналогом конструктора в С++. Первым параметром Realize указывается сам реализуемый объект(аналог this), а вторым — флаг async, указывающий будет ли объект асинхронным.
Текущая реализация Android NDK дает возможность создать только один механизм библиотеки и до 32 объектов вообще. Тем не менее, любая операция создания объекта может закончиться неудачей (например, из-за недостатка системных ресурсов).
Далее необходимо получить интерфейс SL_IID_ENGINE, с помощью которого мы будем иметь доступ к динамикам, проигрыванию музыки, звуков и тд.
Остановимся немного на общей схеме работы с объектами:
Для работы с динамиками создадим объект outputMixObj, используя псевдометод CreateOutputMix интерфейса engine объекта engineObj (Это только звучит страшно, дабы читатель научился различать объекты и интерфейсы). Этот объект понадобится нам позже для вывода звука.
SLOutputMixItf – это объект, представляющий устройство вывода звука(динамик, наушники). Спецификация OpenSL ES предусматривает возможность получения списка доступных устройств ввода/вывода, но реализация Android NDK недостаточно полна и не поддерживает ни получение перечня устройств, ни выбор желаемого (официально для этого предназначен интерфейс SLAudioIODeviceCapabilitiesItf).
Сразу оговорюсь, что для упрощения я не использую данные из заголовка WAV. При желании, добавить поддержку этого достаточно легко. Здесь заголовок нужен лишь для корректного определения размера данных.
Теперь займемся настройкой быстрого буферного вывода звука. Для этого используем специализированное расширение SLDataLocator_AndroidSimpleBufferQueue. Также, для воспроизведения музыки необходимо заполнить две структуры: SLDataSource и SLDataSink, описывающие ввод и вывод аудиоканала соответственно.
Помимо SL_IID_PLAY и SL_IID_BUFFERQUEUE можно запросить другие интерфейсы, например:
и т.д.
Вызовом
Вот и все, простейший проигрыватель wav готов.
Следует обратить внимание, что в пику спецификации, Android NDK не поддерживает буферизованный вывод музыки в отличных от PCM форматах.
Описанная выше схема плохо подходит для проигрывания длинных музыкальных файлов. В первую очередь из-за того, что длинный wav файл будет весить очень и очень много. Здесь лучше использовать MP3 или OGG. OpenSL ES поддерживает стриминг файлов «из коробки». Отличие от буферизованого вывода так же в том, что на каждый музыкальный файл необходимо создавать отдельный объект-плеер. Поменять файл в процессе воспроизведения для данного плеера невозможно.
Подготовимся к проигрыванию музыки:
Далее вновь заполняем SLDataSource и SLDataSink. И создаем аудиоплеер.
Для описания исходных данных используем MIME-тип, это обеспечивает автоматическое определение типа файла.
Далее получим интерфейсы SL_IID_PLAY и SL_IID_SEEK. Последний нужен для изменения позиции воспроизведения в файле и зацикливания. Его можно использовать вне зависимости от состояния воспроизведения и скорости.
В теории, механизм зацикливания должен быть удобен для установки фоновой музыки в игре. На практике между концом композиции и ее началом проходит 0.5-1.0 секунд (время на слух, на разных девайсах плавает). Я поборол это, сделав в фоновой музыке несколько плавных затуханий в середине и конце. Т.о. разрыв незаметен.
По спецификации, на интерфейс SLPlayItf можно навесить различные callback’и. В Android NDK фича не поддерживается (метод возвращает SL_RESULT_SUCCESS, но callback’и не отрабатывают).
Для остановки или паузы плеера можно воспользоваться методом SetPlayState интерфейса SLPlayItf со значениями SL_PLAYSTATE_STOPPED или SL_PLAYSTATE_PAUSED соответственно. Узнать состояние плеера позволяет метод GetPlayState, возвращающий те же значения.
OpenSL ES API достаточно богато, и кроме воспроизведения звука, позволяет записывать его. Здесь я не буду касаться записи звука, скажу лишь, что она есть и работает достаточно хорошо. Для получения данных используется очередь буферов. Данные приходят в формате PCM.
Библиотеку сложно использовать в кроссплатформенной разработке, т.к. многие фичи реализованы специализированными для Android методами. Тем не менее, она показалась мне достаточно удобной.
В минусах видится вольная реализация, не поддерживаются многие вещи из спецификации. Кроме того, это API не быстрее, чем API, доступные в Android SDK.
Хорошие и более полные примеры кода можно посмотреть в стандартной поставке Android NDK (проект NativeAudio).
Предвосхищая вопросы по поводу необходимости использования Android NDK вообще и OpenSL ES в частности, отвечу сразу. Android NDK нужен был по условию тестового задания от известной геймдев-компании (были конкурсы на хабре). Позже это переросло в вызов мне: смогу ли я красиво закончить начатое. Смог. OpenSL ES выбрал по наитию, т.к. опыта работы ни с ним, ни с OpenAL не было, а привлекать вызовы в Java для этого посчитал некрасивым решением.
Недавно, читая хабр, я увидел статью об Android NDK и OpenAL. А в комментариях был задан вопрос о OpenSL ES. Тогда у меня и родилась мысль написать статью об этой библиотеке. Я занимался этой темой, когда мне понадобилось добавить звуки и музыку в игру под Android, написанную на C++, под NDK. Статья не претендует на полноту, здесь будут лишь основы.
Содержание:
- Краткое описание структур OpenSL ES
- Инициализация механизма библиотеки и создание объекта для работы с динамиками
- Проигрывание PCM(wav)
- Проигрывание MP3, OGG
- Заключение
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, описывающие ввод и вывод аудиоканала соответственно.
Настройка буферизованного вывода звука
Реализация OpenSL ES в Android NDK не является строгой. Если какие-то интерфейсы не указаны, это не значит, что их невозможно получить. Но лучше так не делать. Самостоятельно укажите интерфейс SL_IID_PLAY выше.
/*Данные, которые необходимо передать в 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);
Реализация OpenSL ES в Android NDK не является строгой. Если какие-то интерфейсы не указаны, это не значит, что их невозможно получить. Но лучше так не делать. Самостоятельно укажите интерфейс SL_IID_PLAY выше.
SLPlayItf player;
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 секунд (время на слух, на разных девайсах плавает). Я поборол это, сделав в фоновой музыке несколько плавных затуханий в середине и конце. Т.о. разрыв незаметен.
По спецификации, на интерфейс SLPlayItf можно навесить различные callback’и. В Android NDK фича не поддерживается (метод возвращает SL_RESULT_SUCCESS, но callback’и не отрабатывают).
Для остановки или паузы плеера можно воспользоваться методом SetPlayState интерфейса SLPlayItf со значениями SL_PLAYSTATE_STOPPED или SL_PLAYSTATE_PAUSED соответственно. Узнать состояние плеера позволяет метод GetPlayState, возвращающий те же значения.
5. Заключение
OpenSL ES API достаточно богато, и кроме воспроизведения звука, позволяет записывать его. Здесь я не буду касаться записи звука, скажу лишь, что она есть и работает достаточно хорошо. Для получения данных используется очередь буферов. Данные приходят в формате PCM.
Библиотеку сложно использовать в кроссплатформенной разработке, т.к. многие фичи реализованы специализированными для Android методами. Тем не менее, она показалась мне достаточно удобной.
В минусах видится вольная реализация, не поддерживаются многие вещи из спецификации. Кроме того, это API не быстрее, чем API, доступные в Android SDK.
Литература
- Сильвен Ретабоуил. Android NDK. Разработка приложений под Android на С/С++.
- The Khronos Group Inc. OpenSL ES Specification.
Хорошие и более полные примеры кода можно посмотреть в стандартной поставке Android NDK (проект NativeAudio).
Предвосхищая вопросы по поводу необходимости использования Android NDK вообще и OpenSL ES в частности, отвечу сразу. Android NDK нужен был по условию тестового задания от известной геймдев-компании (были конкурсы на хабре). Позже это переросло в вызов мне: смогу ли я красиво закончить начатое. Смог. OpenSL ES выбрал по наитию, т.к. опыта работы ни с ним, ни с OpenAL не было, а привлекать вызовы в Java для этого посчитал некрасивым решением.