Статья «Простейший звуковой движок на библиотеке Bass»
2016

Сегодня мы будем писать простейший звуковой движок с использованием библиотеки bass.dll на Delphi.

Техническое задание

Что должен уметь наш «звуковой движок»? Ну, если он будет простейшим, то и содержать в себе должен только самые необходимые функции:

  • Инициализация и очистка памяти
  • Воспроизведение файла
  • Зацикливание
  • Остановка
  • Пауза

Зацикленное воспроизведение нужно для фоновой музыки и некоторых звуковых эффектов, имеющих определённую продолжительность. Например, для электрического щита как в Galaxy Boom Mini - звуковой эффект будет повторяться до тех пор, пока мы сами его не прервём.

Разумеется, этих функций далеко не достаточно для хорошего звукового движка. Не хватает, например, плейлиста с возможностью зацикливания списка композиций, необходимого для воспроизведения фоновой музыки в большинстве игр. Поэтому, условимся, что в данной статье будут показаны основные принципы работы с библиотекой bass.dll, борьба с утечками памяти, которые могут возникнуть при неправильной обработке звуковых потоков, ну а расширение метода для конкретного проекта - уже на ваше усмотрение.

Что такое bass.dll?

Bass.dll - это распространённая мультимедийная библиотека, предназначенная для работы со звуком. С её использованием, например, написан такой известный музыкальный плеер как AIMP. Здесь можно подробнее почитать о библиотеке, а по этой ссылке - скачать её с официального сайта.

Вместе с библиотекой, кстати, распространяются и её заголовочные файлы для различных языков, в том числе, и для Delphi. Заголовочный файл bass.pas мы будем подключать к проекту в разделе uses.

Планирование структуры

Перед началом написания всякой программы нужно не только определить, что она должна уметь, но и то, как она будет это делать. И данный небольшой движок, ни в коем случае, не является исключением. Итак, какие вопросы нам следует для себя решить?

Где будем хранить потоки?

Для воспроизведения звука библиотека Bass использует потоки - собственный поток создаётся для каждого нового звука. Обратиться к потоку для получения и установки его параметров (например, громкости воспроизведения) можно по его идентификатору, возвращаемому библиотекой после его создания:

Язык: pascal
vStream := bass_StreamCreateFile(...);

Вот переменная vStream и будет хранить в себе идентификатор потока. Очень важно помнить о том, что по окончании воспроизведения поток никуда не денется, и память, затраченную на его создание, необходимо очищать вручную соответствующей функцией Bass.dll.

Именно по этой причичне хранить идентификаторы создаваемых потоков мы будем в массиве, а по окончании воспроизведения звукового файла эти потоки удалять. Вот ещё один небольшой фокус: по удалении завершённого потока элемент массива мы будем помечать как «мёртвый», во избежание дорогостоящих операций по работе с памятью (изменение длины динамического массива). Затем, при открытии нового файла, созданный поток мы запишем на место первого попавшегося «убитого» элемента, и если таковой не найден, - расширим массив.

Вот, как будет объявляться наш массив:

Язык: pascal
TSound = record
...id: Cardinal;//Идентификатор потока
...event: Cardinal;//Id события окончания воспроизведения
...isLoop: Boolean;//Зациклено ли воспроизведение?
...isAlive: Boolean;//Жив ли поток?
end;

vSounds: array of TSound;//Массив потоков

Как видите, каждый элемент массива является записью. Здесь есть пара не указанных мною раньше переменных:

  • Event - будет хранить в себе идентификатор события, которое мы назначим для отслеживания окончания воспроизведения потока. Память, занимаемую этим событием, тоже следует очищать.
  • IsLoop - зациклено ли воспроизведение мелодии? Зацикленные потоки не будут удаляться автоматически, и пользователь сам должен инициировать их остановку.

Как будем удалять потоки?

Изначально мы планировали удалять потоки в таймере. То есть, заводим отдельный таймер для звукового движка, скажем, срабатывающий раз в 1-2 секунды, и в нём проверяем каждый поток:

Язык: pascal
if (bass_ChannelGetLength(stream, 0) = bass_ChannelGetPosition(stream, 0))...

А удовлетворяющие условию - удаляем. Но по некоторым весомым причинам мы отказались от этого способа в пользу второго - назначения события завершения с последующим удалением в нём потока-инициатора.

Использовать ли ООП?

Хороший вопрос. Сначала именно так мы и поступили, но затем столкнулись с некоторыми проблемами, заставившими нас отказаться от объектной реализации в пользу процедурной. Дело в том, что на событие Bass не может быть назначен метод класса, это ограничение накладывается скрытым параметром - указателем на объект, передаваемом Delphi в каждом методе, а используемое нами событие объявлено в Bass.dll как обычная процедура.

Внешние функции движка

Переходим к написанию кода, начнём с описания публичных функций движка. Их будет всего пять:

Язык: pascal
function soundInitialize(vHandle: LongWord): Boolean;
function soundDestroy(): Boolean;
function soundPlay(vFileName: String; vVolume: Real; vIsLoop: Boolean = false): Integer;
function soundPause(vIsPause: Boolean): Boolean;
function soundStop(vId: Integer): Boolean;

Инициализация и удаление Bass

Здесь всё очень просто: создаём две простые, однострочные функции:

Язык: pascal
//Инициализация библиотеки Bass
function soundInitialize(vHandle: LongWord): Boolean;
begin
...result := bass_Init(-1, 44100, 0, vHandle, nil);
end;

//Удаление библиотеки Bass
function soundDestroy(): Boolean;
begin
...result := bass_Free();
end;

В качестве параметра в первую из них передаём handle нашего приложения.

Воспроизведение звукового файла

Язык: pascal
function soundPlay(vFileName: String; vVolume: Real; vIsLoop: Boolean = false): Integer;
var
...vPCharName: PChar;//Имя файла в формате PChar
...vStream: Integer;//Индекс потока для воспроизведения
...vFlag: Cardinal;//Флаг для зацикливания
begin
...if (vIsPaused) then exit;//Если воспроизведение на паузе
...vPCharName := PChar(vFileName);//Преобразование строки в PChar
...vFlag := BASS_SAMPLE_LOOP * Byte(vIsLoop);//Флаг для зацикливания воспроизведения
...vStream := innerGetStream();//Получение свободного потока
//Воспроизведение файла
...with vSounds[vStream] do begin
......id := bass_StreamCreateFile(false, vPCharName, 0, 0, vFlag);
......event := bass_ChannelSetSync(Id, BASS_SYNC_END, 0, @onCompleted, nil);
......bass_ChannelSetAttribute(Id, BASS_ATTRIB_VOL, vVolume);//Установка громкости
......bass_ChannelPlay(Id, true);//Начало воспроизведения потока
......isLoop := vIsLoop;
......isAlive := true;
...end;
...if (not(vIsLoop)) then result := -1
...else result := vStream;
end;

Сначала мы подготавливаем данные, получаем свободное место в нашем массиве (функция innerGetStream(), которая будет описана чуть позже). Переменная vFlag определяет специальный флаг, указывающий Bass на необходимость зациклить запущенный поток (Byte(vIsLoop) позволяет избежать лишних условий).

Затем создаём новый поток (bass_StreamCreateFile()), назначаем ему событие окончания воспроизведения (bass_ChannelSetSync(..., BASS_SYNC_END, ...)), устанавливаем громкость и запускаем поток. Если поток должен быть зациклен, наша функция возвратит пользователю идентификатор элемента массива, в который он был записан, чтобы впоследствии пользователь смог инициировать его остановку:

Язык: pascal
function soundStop(vId: Integer): Boolean;
begin
...if (vSounds[vId].isLoop) then result := innerRemoveStream(vId)
...else result := false;
end;

Это и есть функция принудительной остановки зацикленного потока. Внутренняя функция innerRemoveStream(), как раз, и занимающаяся этим, будет описана ниже.

Временная остановка и возобновление

Язык: pascal
function soundPause(vIsPause: Boolean): Boolean;
var
...i: Integer;
begin
...for i := 0 to High(vSounds) do begin
......if (vIsPause) then bass_ChannelPause(vSounds[i].id)
......else bass_ChannelPlay(vSounds[i].id, False);
...end;
...vIsPaused := vIsPause;
...result := true;
end;

Проходим по всем существующим потокам и останавливаем их или возобновляем, в зависимости от переданного сюда логического параметра «vIsPause». Эта функция может понадобиться при сворачивании полноэкранного приложения или потере окном фокуса (если создаёте оконное приложение).

Событие окончания воспроизведения

Как мы помним, это событие назначается в функции soundPlay() и должно вызывать некоторую внутреннюю функцию для удаления потока, помечая при этом соответствующий элемент массива как «мёртвый»:

Язык: pascal
procedure onCompleted(vHandle, vStream, vData: Cardinal; vUser: Pointer); stdcall;
var
...vId: Integer;
begin
...vId := innerFindStream(vStream);
...if (not(vSounds[vId].isLoop)) then innerRemoveStream(vId);
end;

Внутренняя функция innerFindStream() предназначена для поиска элемента массива по переданному в неё идентификатору потока.

Внутренние функции движка

Внутренних функций всего три:

Язык: pascal
function innerGetStream(): Integer;
function innerFindStream(vId: Cardinal): Integer;
function innerRemoveStream(vId: Integer): Boolean;

Поиск свободного места

Язык: pascal
function innerGetStream(): Integer;
var
...i: Integer;
begin
...result := -1;
...//Поиск свободного места в массиве
...for i := 0 to High(vSounds) do begin
......if (vSounds[i].isAlive) then continue;
......result := i;
......Break;
...end;
...//Создание нового потока, если не найден готовый
...if (result = -1) then begin
......SetLength(vSounds, Length(vSounds) + 1);
......result := High(vSounds);
...end;
end;

Функция подготавливает свободный элемент массива для записи в него информации о потоке. Сначала производится поиск «убитого» элемента и, если он не найден, массив расширяется. В результате функция возвращает индекс подготовленного элемента.

Поиск потока по его идентификатору

Язык: pascal
function innerFindStream(vId: Cardinal): Integer;
var
...i: Integer;
begin
...result := -1;
...for i := 0 to High(vSounds) do begin
......if (vSounds[i].id <> vId) then continue;
......result := i;
......break;
...end;
end;

Тут тоже всё очень просто. Перебираем все элементы массива в поисках того, чей идентификатор потока (id) будет равен искомому.

Удаление потока по его идентификатору

Язык: pascal
function innerRemoveStream(vId: Integer): Boolean;
begin
...with vSounds[vId] do begin
......result := bass_ChannelRemoveSync(id, event) and bass_StreamFree(Id);
......isAlive := false;
...end;
end;

Удаляем событие и очищаем память, занимаемую потоком. Помечаем элемент как «мёртвый» для дальнейшей записи на его место других потоков.

Подведём итоги

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

Недавно мы выяснили, что уже выпущенная нами логическая игра Crown и подготовленная альфа-версия Galaxy Boom: Mini расходуют неприлично много оперативной памяти компьютера. В результате проведения некоторых исследований, удалось быстро выявить причину: неправильная работа со звуковыми потоками в Bass - мы просто забывали их удалять.

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

Всем спасибо за внимание. Исходный код движка с демонстрационной программой вы можете скачать по прямой ссылке ниже.

perfectsound.zip (108kb) Тестовая программа с исходным кодом на языке Pascal, демонстрирующая возможности движка.

Малышка из «Поездки в Печению» наконец дала согласие на использование её образа в дизайне визиток. Но уговорить её, и правда, было непросто - у них там свои порядки.

Несколько образных рассказов о любви, звездах и потерях, написанных в далеком 2014 году, читаются под настроение.

Они актуальны и сейчас, но из-за образности повествования могут быть понятны не сразу. Это рассказы-эмоции, читать их нужно вдумчиво и внимательно.

Возможно, вы найдете в этих текстах что-нибудь для себя или про себя...

Мода на анимацию никого не обойдет стороной, и узнав об этом, Робик очень захотел себя показать. Он крутился и вертелся, пока я старательно, кадр за кадром, его перерисовывал.