Implement TSR audio

This commit is contained in:
tovjemam 2026-01-09 22:19:52 +01:00
parent 1f50bf2888
commit 8f59d76cf9
16 changed files with 812 additions and 4 deletions

View File

@ -46,6 +46,19 @@ set(COMMON_SOURCES
set(CLIENT_ONLY_SOURCES set(CLIENT_ONLY_SOURCES
"src/assets/mesh_builder.hpp" "src/assets/mesh_builder.hpp"
"src/assets/mesh_builder.cpp" "src/assets/mesh_builder.cpp"
"src/audio/defs.hpp"
"src/audio/master.hpp"
"src/audio/master.cpp"
"src/audio/ogg.hpp"
"src/audio/ogg.cpp"
"src/audio/player.hpp"
"src/audio/player.cpp"
"src/audio/sound_source.hpp"
"src/audio/sound_source.cpp"
"src/audio/sound.hpp"
"src/audio/sound.cpp"
"src/audio/source.hpp"
"src/audio/source.cpp"
"src/client/app.hpp" "src/client/app.hpp"
"src/client/app.cpp" "src/client/app.cpp"
"src/client/gl.hpp" "src/client/gl.hpp"
@ -117,10 +130,14 @@ endif()
# Include directories # Include directories
target_include_directories(${MAIN_NAME} PRIVATE "src") target_include_directories(${MAIN_NAME} PRIVATE "src")
target_compile_definitions(${MAIN_NAME} PRIVATE CLIENT)
# add glm
add_subdirectory(external/glm) add_subdirectory(external/glm)
target_link_libraries(${MAIN_NAME} PRIVATE glm) target_link_libraries(${MAIN_NAME} PRIVATE glm)
target_include_directories(${MAIN_NAME} PRIVATE "external/stb") target_include_directories(${MAIN_NAME} PRIVATE "external/stb")
# add bullet3
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_BULLET2_DEMOS OFF CACHE BOOL "" FORCE) set(BUILD_BULLET2_DEMOS OFF CACHE BOOL "" FORCE)
set(BUILD_UNIT_TESTS OFF CACHE BOOL "" FORCE) set(BUILD_UNIT_TESTS OFF CACHE BOOL "" FORCE)
@ -131,7 +148,12 @@ add_subdirectory(external/bullet3)
target_link_libraries(${MAIN_NAME} PRIVATE BulletDynamics BulletCollision LinearMath Bullet3Common) target_link_libraries(${MAIN_NAME} PRIVATE BulletDynamics BulletCollision LinearMath Bullet3Common)
target_include_directories(${MAIN_NAME} PRIVATE "external/bullet3/src") target_include_directories(${MAIN_NAME} PRIVATE "external/bullet3/src")
target_compile_definitions(${MAIN_NAME} PRIVATE CLIENT) # add ogg
add_subdirectory(external/ogg)
# add vorbis
add_subdirectory(external/vorbis)
target_link_libraries(${MAIN_NAME} PRIVATE vorbisfile vorbis)
# Platform-specific SDL2 handling # Platform-specific SDL2 handling
if (CMAKE_SYSTEM_NAME STREQUAL Emscripten) if (CMAKE_SYSTEM_NAME STREQUAL Emscripten)
@ -196,6 +218,11 @@ endif()
target_link_libraries(${MAIN_NAME} PRIVATE easywsclient) target_link_libraries(${MAIN_NAME} PRIVATE easywsclient)
# add openal
set(ALSOFT_EXAMPLES OFF)
add_subdirectory(external/openal-soft)
target_link_libraries(${MAIN_NAME} PRIVATE OpenAL)
# build server # build server
set(ASIO_INCLUDE_DIR "external/asio/include") set(ASIO_INCLUDE_DIR "external/asio/include")
include_directories(${ASIO_INCLUDE_DIR}) include_directories(${ASIO_INCLUDE_DIR})

View File

@ -5,4 +5,5 @@ assets::ModelCache assets::CacheManager::model_cache_;
assets::MapCache assets::CacheManager::map_cache_; assets::MapCache assets::CacheManager::map_cache_;
assets::VehicleCache assets::CacheManager::vehicle_cache_; assets::VehicleCache assets::CacheManager::vehicle_cache_;
CLIENT_ONLY(assets::TextureCache assets::CacheManager::texture_cache_;) CLIENT_ONLY(assets::TextureCache assets::CacheManager::texture_cache_;)
CLIENT_ONLY(assets::SoundCache assets::CacheManager::sound_cache_;)

View File

@ -8,6 +8,7 @@
#include "utils/defs.hpp" #include "utils/defs.hpp"
#ifdef CLIENT #ifdef CLIENT
#include "audio/sound.hpp"
#include "gfx/texture.hpp" #include "gfx/texture.hpp"
#endif #endif
@ -49,6 +50,12 @@ class TextureCache final : public Cache<gfx::Texture>
protected: protected:
PtrType Load(const std::string& key) override { return gfx::Texture::LoadFromFile(key); } PtrType Load(const std::string& key) override { return gfx::Texture::LoadFromFile(key); }
}; };
class SoundCache final : public Cache<audio::Sound>
{
protected:
PtrType Load(const std::string& key) override { return audio::Sound::LoadFromFile(key); }
};
#endif // CLIENT #endif // CLIENT
class SkeletonCache final : public Cache<Skeleton> class SkeletonCache final : public Cache<Skeleton>
@ -86,14 +93,22 @@ public:
static std::shared_ptr<const Model> GetModel(const std::string& filename) { return model_cache_.Get(filename); } static std::shared_ptr<const Model> GetModel(const std::string& filename) { return model_cache_.Get(filename); }
static std::shared_ptr<const Map> GetMap(const std::string& filename) { return map_cache_.Get(filename); } static std::shared_ptr<const Map> GetMap(const std::string& filename) { return map_cache_.Get(filename); }
static std::shared_ptr<const VehicleModel> GetVehicleModel(const std::string& filename) { return vehicle_cache_.Get(filename); } static std::shared_ptr<const VehicleModel> GetVehicleModel(const std::string& filename)
{
return vehicle_cache_.Get(filename);
}
#ifdef CLIENT #ifdef CLIENT
static std::shared_ptr<const gfx::Texture> GetTexture(const std::string& filename) static std::shared_ptr<const gfx::Texture> GetTexture(const std::string& filename)
{ {
return texture_cache_.Get(filename); return texture_cache_.Get(filename);
} }
static std::shared_ptr<const audio::Sound> GetSound(const std::string& filename)
{
return sound_cache_.Get(filename);
}
#endif #endif
private: private:
@ -102,6 +117,7 @@ private:
static MapCache map_cache_; static MapCache map_cache_;
static VehicleCache vehicle_cache_; static VehicleCache vehicle_cache_;
CLIENT_ONLY(static TextureCache texture_cache_;) CLIENT_ONLY(static TextureCache texture_cache_;)
CLIENT_ONLY(static SoundCache sound_cache_;)
}; };
} // namespace assets } // namespace assets

3
src/audio/defs.hpp Normal file
View File

@ -0,0 +1,3 @@
#pragma once
#define AUDIO_DBG(...) __VA_ARGS__

125
src/audio/master.cpp Normal file
View File

@ -0,0 +1,125 @@
#include "master.hpp"
#include <iostream>
#include <AL/al.h>
#include <AL/alc.h>
#include "defs.hpp"
#include "sound_source.hpp"
#include "source.hpp"
static const size_t MAX_CATEGORIES = 32;
audio::Master::Master()
{
std::cout << "Initializing audio master...\n";
categories_.reserve(MAX_CATEGORIES);
device_ = alcOpenDevice(nullptr);
if (!device_)
{
throw std::runtime_error("(AL) Failed to open audio device");
}
// get device info
const ALchar* info = alcGetString(device_, ALC_DEVICE_SPECIFIER);
std::cout << "Opened audio device: " << (info ? info : "Unknown") << std::endl;
context_ = alcCreateContext(device_, nullptr);
if (!context_)
{
alcCloseDevice(device_);
device_ = nullptr;
throw std::runtime_error("(AL) Failed to create audio context");
}
std::cout << "Created audio context" << std::endl;
if (!alcMakeContextCurrent(context_))
{
alcDestroyContext(context_);
alcCloseDevice(device_);
context_ = nullptr;
device_ = nullptr;
throw std::runtime_error("(AL) Failed to make audio context current");
}
std::cout << "Audio context is now current" << std::endl;
}
void audio::Master::SetListenerOrientation(const glm::vec3& position, const glm::vec3& forward, const glm::vec3& up)
{
alListener3f(AL_POSITION, position.x, position.y, position.z);
// alListener3f(AL_VELOCITY, 0.0f, 0.0f, 0.0f);
ALfloat orientation[] = {forward.x, forward.y, forward.z, up.x, up.y, up.z};
alListenerfv(AL_ORIENTATION, orientation);
}
void audio::Master::SetListenerOrientation(const glm::mat4& world_matrix)
{
glm::vec3 position = glm::vec3(world_matrix[3]);
glm::vec3 forward = -glm::normalize(glm::vec3(world_matrix[2]));
glm::vec3 up = glm::normalize(glm::vec3(world_matrix[1]));
SetListenerOrientation(position, forward, up);
}
audio::Category* audio::Master::GetCategory(const std::string& name)
{
auto it = category_map_.find(name);
if (it != category_map_.end())
{
return it->second;
}
if (categories_.size() >= MAX_CATEGORIES)
{
throw std::runtime_error("(AL) Maximum number of audio categories reached");
}
Category* new_category = &categories_.emplace_back(name);
category_map_[name] = new_category;
AUDIO_DBG(std::cout << "Created new audio category: " << name << std::endl;)
return new_category;
}
void audio::Master::SetMasterVolume(float volume)
{
master_volume_ = volume;
alListenerf(AL_GAIN, master_volume_);
AUDIO_DBG(std::cout << "Set master volume to " << master_volume_ << std::endl;)
}
audio::Master::~Master()
{
std::cout << "Shutting down audio master...\n";
if (context_)
{
alcMakeContextCurrent(nullptr);
alcDestroyContext(context_);
context_ = nullptr;
}
if (device_)
{
alcCloseDevice(device_);
device_ = nullptr;
}
}
audio::Category::Category(const std::string& name) : name_(name) {}
void audio::Category::SetVolume(float volume)
{
volume_ = volume;
for (Source* source = first_source_; source; source = source->cat_next_)
{
source->UpdateVolume();
}
}

63
src/audio/master.hpp Normal file
View File

@ -0,0 +1,63 @@
#pragma once
#include <map>
#include <string>
#include <vector>
#include "utils/defs.hpp"
#include <glm/glm.hpp>
class ALCdevice;
class ALCcontext;
namespace audio
{
class Source;
class Category
{
public:
Category(const std::string& name);
DELETE_COPY_MOVE(Category)
const std::string& GetName() const { return name_; }
void SetVolume(float volume);
float GetVolume() const { return volume_; }
private:
float volume_ = 1.0f;
std::string name_;
Source* first_source_ = nullptr;
friend class Source;
};
class Master
{
public:
Master();
DELETE_COPY_MOVE(Master)
void SetListenerOrientation(const glm::vec3& position, const glm::vec3& forward, const glm::vec3& up);
void SetListenerOrientation(const glm::mat4& world_matrix);
Category* GetCategory(const std::string& name);
void SetMasterVolume(float volume);
float GetMasterVolume() const { return master_volume_; }
~Master();
private:
ALCdevice* device_ = nullptr;
ALCcontext* context_ = nullptr;
std::vector<Category> categories_;
std::map<std::string, Category*> category_map_;
float master_volume_ = 1.0f;
};
} // namespace audio

85
src/audio/ogg.cpp Normal file
View File

@ -0,0 +1,85 @@
#include "ogg.hpp"
#include <stdexcept>
audio::OggFile::OggFile(const char* filename)
{
int result = ov_fopen(filename, &ogg_file_);
if (result < 0)
{
throw std::runtime_error("(OGG) Failed to open OGG file: " + std::string(filename));
}
try
{
LoadInfo();
}
catch (const std::exception& e)
{
ov_clear(&ogg_file_);
throw std::runtime_error(std::string("(OGG) Error loading OGG file info: ") + e.what());
}
}
size_t audio::OggFile::GetNumChannels() const
{
return vorbis_info_->channels;
}
size_t audio::OggFile::GetSampleRate() const
{
return vorbis_info_->rate;
}
size_t audio::OggFile::GetNumSamples()
{
return ov_pcm_total(&ogg_file_, -1);
}
size_t audio::OggFile::Read(char* buffer, size_t length)
{
long res = ov_read(&ogg_file_, buffer, static_cast<int>(length), 0, 2, 1, NULL);
if (res < 0)
{
throw std::runtime_error("Error reading OGG file");
}
return static_cast<size_t>(res);
}
std::vector<char> audio::OggFile::ReadAll()
{
size_t total_size = ov_pcm_total(&ogg_file_, -1) * vorbis_info_->channels * 2;
std::vector<char> buffer(total_size);
size_t bytes_read = 0;
while (bytes_read < total_size)
{
size_t bytes_to_read = total_size - bytes_read;
size_t read = Read(buffer.data() + bytes_read, bytes_to_read);
if (read == 0)
{
break; // End of file reached
}
bytes_read += read;
}
return buffer;
}
audio::OggFile::~OggFile()
{
ov_clear(&ogg_file_); // Clear the OGG file resources
}
void audio::OggFile::LoadInfo()
{
vorbis_info_ = ov_info(&ogg_file_, -1); // Initialize the OGG file info
if (vorbis_info_->channels <= 0 || vorbis_info_->channels > 2)
throw std::runtime_error("Unsupported number of channels in OGG file");
}

36
src/audio/ogg.hpp Normal file
View File

@ -0,0 +1,36 @@
#pragma once
#include "utils/defs.hpp"
#include <vector>
#include <vorbis/vorbisfile.h>
// class OggVorbis_File;
// class vorbis_info;
namespace audio
{
class OggFile
{
public:
OggFile(const char* filename);
DELETE_COPY_MOVE(OggFile)
size_t GetNumChannels() const;
size_t GetSampleRate() const;
size_t GetNumSamples();
size_t Read(char* buffer, size_t length);
std::vector<char> ReadAll();
~OggFile();
private:
void LoadInfo();
private:
OggVorbis_File ogg_file_;
vorbis_info* vorbis_info_ = nullptr;
};
} // namespace audio

30
src/audio/player.cpp Normal file
View File

@ -0,0 +1,30 @@
#include "player.hpp"
audio::Player::Player(Master& master) : master_(master) {}
audio::SoundSource* audio::Player::PlaySound(const std::shared_ptr<const Sound>& sound,
const glm::vec3* attach_position)
{
SoundSource* source = new SoundSource(this, sound);
source->AttachToPosition(attach_position);
return source;
}
void audio::Player::Update()
{
Source* current = first_source_.get();
while (current)
{
current->Update();
Source* next = current->player_next_.get();
if (current->ShouldBeDeleted())
{
current->Delete();
}
current = next;
}
}

32
src/audio/player.hpp Normal file
View File

@ -0,0 +1,32 @@
#pragma once
#include <memory>
#include "master.hpp"
#include "sound_source.hpp"
namespace audio
{
class Player
{
public:
Player(Master& master);
DELETE_COPY_MOVE(Player)
SoundSource* PlaySound(const std::shared_ptr<const Sound>& sound, const glm::vec3* attach_position);
void Update();
Master& GetMaster() const { return master_; }
~Player() = default;
private:
Master& master_;
std::unique_ptr<Source> first_source_;
friend class Source;
};
} // namespace audio

66
src/audio/sound.cpp Normal file
View File

@ -0,0 +1,66 @@
#include "sound.hpp"
#include <AL/al.h>
#include <AL/alc.h>
#include "assets/cmdfile.hpp"
#include "utils/files.hpp"
#include "ogg.hpp"
audio::Sound::Sound()
{
alGenBuffers(1, &buffer_);
if (!buffer_)
throw std::runtime_error("Failed to generate OpenAL buffer");
}
static void LoadBufferOGG(ALuint buffer, const char* path)
{
audio::OggFile ogg_file(path);
std::vector<char> data = ogg_file.ReadAll();
ALenum format = ogg_file.GetNumChannels() == 1 ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16;
ALsizei sample_rate = ogg_file.GetSampleRate();
alBufferData(buffer, format, data.data(), static_cast<ALsizei>(data.size()), sample_rate);
}
std::shared_ptr<const audio::Sound> audio::Sound::LoadFromFile(const std::string& path)
{
auto sound = std::make_shared<Sound>();
std::string ogg_path;
assets::LoadCMDFile(path, [&](const std::string& cmd, std::istringstream& iss) {
if (cmd == "ogg")
{
iss >> ogg_path;
}
else if (cmd == "category")
{
iss >> sound->category_name_;
}
else if (cmd == "volume")
{
iss >> sound->volume_;
}
else if (cmd == "pitch")
{
iss >> sound->pitch_;
}
});
if (sound->category_name_.empty())
sound->category_name_ = "default";
LoadBufferOGG(sound->GetBufferId(), ogg_path.c_str());
return sound;
}
audio::Sound::~Sound()
{
alDeleteBuffers(1, &buffer_);
}

37
src/audio/sound.hpp Normal file
View File

@ -0,0 +1,37 @@
#pragma once
#include "master.hpp"
#include <memory>
namespace audio
{
class Sound
{
private:
Sound();
friend std::shared_ptr<Sound> std::make_shared<Sound>();
public:
static std::shared_ptr<const Sound> LoadFromFile(const std::string& path);
unsigned int GetBufferId() const { return buffer_; }
const std::string& GetCategoryName() const { return category_name_; }
float GetVolume() const { return volume_; }
float GetPitch() const { return pitch_; }
~Sound();
private:
unsigned int buffer_ = 0;
std::string category_name_;
float volume_ = 1.0f;
float pitch_ = 1.0f;
};
} // namespace audio

View File

@ -0,0 +1,67 @@
#include "sound_source.hpp"
#include <AL/al.h>
#include <AL/alc.h>
audio::SoundSource::SoundSource(Player* player, std::shared_ptr<const Sound> sound)
: Super(sound->GetCategoryName(), player), sound_(std::move(sound))
{
SetVolume(1.0f);
SetPitch(1.0f);
alSourcei(source_, AL_BUFFER, sound->GetBufferId());
}
void audio::SoundSource::SetLooping(bool looping)
{
alSourcei(source_, AL_LOOPING, looping ? AL_TRUE : AL_FALSE);
// TsrDebugf(DML_2, "Set source %p looping to %s\n", this, looping ? "true" : "false");
}
void audio::SoundSource::SetPitch(float pitch)
{
Super::SetSourcePitch(sound_->GetPitch() * pitch);
}
void audio::SoundSource::SetVolume(float volume)
{
Super::SetSourceVolume(sound_->GetVolume() * volume);
}
void audio::SoundSource::Update()
{
Super::Update();
ALint state;
alGetSourcei(source_, AL_SOURCE_STATE, &state);
finished_ = state == AL_STOPPED;
switch (state)
{
case AL_PLAYING: {
if (!should_play_)
alSourcePause(source_);
break;
}
case AL_INITIAL:
case AL_STOPPED:
case AL_PAUSED: {
if (should_play_)
alSourcePlay(source_);
break;
}
default:
break;
}
}
audio::SoundSource::~SoundSource()
{
alSourceStop(source_);
alSourcei(source_, AL_BUFFER, 0);
}

View File

@ -0,0 +1,29 @@
#pragma once
#include "sound.hpp"
#include "source.hpp"
namespace audio
{
class SoundSource : public Source
{
public:
SoundSource(Player* player, std::shared_ptr<const Sound> sound);
virtual void SetLooping(bool looping) override;
virtual void SetPitch(float pitch) override;
virtual void SetVolume(float volume) override;
virtual void Update() override;
virtual ~SoundSource() override;
private:
using Super = Source;
std::shared_ptr<const Sound> sound_;
};
} // namespace audio

122
src/audio/source.cpp Normal file
View File

@ -0,0 +1,122 @@
#include "source.hpp"
#include <stdexcept>
#include <AL/al.h>
#include <AL/alc.h>
#include "defs.hpp"
#include "player.hpp"
audio::Source::Source(const std::string& category_name, Player* player)
{
alGenSources(1, &source_);
if (!source_)
throw std::runtime_error("Failed to create audio source");
// link to category
category_ = player->GetMaster().GetCategory(category_name);
cat_next_ = category_->first_source_;
category_->first_source_ = this;
cat_prev_next_ = &category_->first_source_;
if (cat_next_)
cat_next_->cat_prev_next_ = &cat_next_;
// link to player
player_ = player;
player_next_ = std::move(player->first_source_);
player->first_source_.reset(this);
player_prev_next_ = &player->first_source_;
if (player_next_)
player_next_->player_prev_next_ = &player_next_;
// TsrDebugf(DML_2, "Created audio source %p in category '%s'\n", this, category->GetName().c_str());
// alSourcef(m_source, AL_ROLLOFF_FACTOR, 0.0f); // disable rolloff
// alSourcef(m_source, AL_REFERENCE_DISTANCE, 1.0f); // set reference distance
}
void audio::Source::SetPosition(const glm::vec3& position)
{
alSource3f(source_, AL_POSITION, position.x, position.y, position.z);
}
void audio::Source::SetVelocity(const glm::vec3& velocity)
{
alSource3f(source_, AL_VELOCITY, velocity.x, velocity.y, velocity.z);
}
void audio::Source::AttachToPosition(const glm::vec3* position)
{
attach_position_ = position;
if (attach_position_)
SetPosition(*attach_position_);
// TsrDebugf(DML_2, "Attached source %p to position %p\n", this, position);
}
void audio::Source::SetRelativeToListener(bool relative)
{
if (relative)
{
alSourcei(source_, AL_SOURCE_RELATIVE, AL_TRUE);
// TsrDebugf(DML_2, "Set source %p to be relative to listener\n", this);
}
else
{
alSourcei(source_, AL_SOURCE_RELATIVE, AL_FALSE);
// TsrDebugf(DML_2, "Set source %p to be absolute\n", this);
}
}
void audio::Source::Update()
{
if (attach_position_)
SetPosition(*attach_position_);
}
void audio::Source::Delete()
{
// unlink from player (causes destruction)
if (player_next_)
{
player_next_->player_prev_next_ = player_prev_next_;
}
std::unique_ptr<Source>& player_prev_next = *player_prev_next_;
player_prev_next = std::move(player_next_);
// TsrDebugf(DML_2, "Deleted audio source %p\n", this);
}
void audio::Source::SetSourceVolume(float volume)
{
volume_ = volume;
UpdateVolume();
}
void audio::Source::SetSourcePitch(float pitch)
{
alSourcef(source_, AL_PITCH, pitch);
// TsrDebugf(DML_2, "Set source %p pitch to %.2f\n", this, pitch);
}
void audio::Source::UpdateVolume()
{
float cat_volume = category_->GetVolume();
float result_volume = volume_ * cat_volume;
alSourcef(source_, AL_GAIN, result_volume);
// TsrDebugf(DML_2, "Set source %p volume to %.2f (this %.2f, category %.2f)\n", this, result_volume, m_volume,
// cat_volume);
}
audio::Source::~Source()
{
alDeleteSources(1, &source_);
// unlink from category
if (cat_next_)
{
cat_next_->cat_prev_next_ = cat_prev_next_;
}
*cat_prev_next_ = cat_next_;
}

69
src/audio/source.hpp Normal file
View File

@ -0,0 +1,69 @@
#pragma once
#include "master.hpp"
#include <memory>
namespace audio
{
class Player;
class Source
{
protected:
Source(const std::string& category_name, Player* player);
public:
DELETE_COPY_MOVE(Source)
void SetPlay(bool play) { should_play_ = play; }
void Play() { SetPlay(true); }
void Stop() { SetPlay(false); }
void SetDeleteOnFinish(bool destroy) { delete_on_finish_ = destroy; }
void SetPosition(const glm::vec3& position);
void SetVelocity(const glm::vec3& velocity);
void AttachToPosition(const glm::vec3* position);
void SetRelativeToListener(bool relative);
virtual void SetLooping(bool looping) = 0;
virtual void SetPitch(float pitch) = 0;
virtual void SetVolume(float volume) = 0;
virtual void Update();
bool ShouldBeDeleted() { return finished_ && delete_on_finish_; }
void Delete();
virtual ~Source();
protected:
void SetSourceVolume(float volume);
void SetSourcePitch(float pitch);
unsigned int source_ = 0;
const glm::vec3* attach_position_ = nullptr;
bool should_play_ = true; // auto play when created
bool finished_ = false;
bool delete_on_finish_ = true; // auto delete when finished
private:
void UpdateVolume(); // on this or category volume change
friend class Category;
Category* category_ = nullptr;
Source** cat_prev_next_ = nullptr;
Source* cat_next_ = nullptr;
friend class Player;
Player* player_ = nullptr;
std::unique_ptr<Source>* player_prev_next_ = nullptr;
std::unique_ptr<Source> player_next_ = nullptr;
float volume_ = 1.0f; // volume set by source
};
} // namespace audio