feat: add audio system
This commit is contained in:
@@ -9,6 +9,7 @@ add_executable(destrum_tests
|
||||
ui_tests.cpp
|
||||
gameplay_tests.cpp
|
||||
editor_tests.cpp
|
||||
audio_tests.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
|
||||
@@ -35,7 +36,8 @@ target_include_directories(destrum_tests PRIVATE "${CMAKE_SOURCE_DIR}/lightkeepe
|
||||
target_link_libraries(destrum_tests PRIVATE destrum::destrum destrum::editor)
|
||||
|
||||
destrum_enable_warnings(destrum_tests)
|
||||
destrum_copy_runtime_dlls(destrum_tests)
|
||||
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor)
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor audio)
|
||||
add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite})
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <destrum/Audio/AudioDecoder.h>
|
||||
#include <destrum/Audio/AudioSystem.h>
|
||||
#include <destrum/Components/AudioListener.h>
|
||||
#include <destrum/Components/AudioSource.h>
|
||||
#include <destrum/Scene/Scene.h>
|
||||
#include <destrum/Scene/SceneManager.h>
|
||||
#include <destrum/Serialization/ComponentFactory.h>
|
||||
#include <destrum/Serialization/ComponentRegistry.h>
|
||||
|
||||
#include "TestUtils.h"
|
||||
|
||||
using namespace test;
|
||||
|
||||
namespace {
|
||||
void put16(std::vector<std::uint8_t>& bytes, std::uint16_t value)
|
||||
{
|
||||
bytes.push_back(static_cast<std::uint8_t>(value & 0xFF));
|
||||
bytes.push_back(static_cast<std::uint8_t>(value >> 8));
|
||||
}
|
||||
|
||||
void put32(std::vector<std::uint8_t>& bytes, std::uint32_t value)
|
||||
{
|
||||
for (int shift = 0; shift < 32; shift += 8) {
|
||||
bytes.push_back(static_cast<std::uint8_t>((value >> shift) & 0xFF));
|
||||
}
|
||||
}
|
||||
|
||||
void putTag(std::vector<std::uint8_t>& bytes, const char* tag)
|
||||
{
|
||||
bytes.insert(bytes.end(), tag, tag + 4);
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> makeWav(std::uint16_t format, std::uint16_t channels, std::uint32_t sampleRate,
|
||||
std::uint16_t bits, const std::vector<std::uint8_t>& data,
|
||||
const std::vector<std::uint8_t>& extraChunk = {})
|
||||
{
|
||||
std::vector<std::uint8_t> body;
|
||||
putTag(body, "WAVE");
|
||||
putTag(body, "fmt ");
|
||||
put32(body, 16);
|
||||
put16(body, format);
|
||||
put16(body, channels);
|
||||
put32(body, sampleRate);
|
||||
put32(body, sampleRate * channels * bits / 8u);
|
||||
put16(body, static_cast<std::uint16_t>(channels * bits / 8u));
|
||||
put16(body, bits);
|
||||
body.insert(body.end(), extraChunk.begin(), extraChunk.end());
|
||||
putTag(body, "data");
|
||||
put32(body, static_cast<std::uint32_t>(data.size()));
|
||||
body.insert(body.end(), data.begin(), data.end());
|
||||
|
||||
std::vector<std::uint8_t> bytes;
|
||||
putTag(bytes, "RIFF");
|
||||
put32(bytes, static_cast<std::uint32_t>(body.size()));
|
||||
bytes.insert(bytes.end(), body.begin(), body.end());
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void testDecodeWav16Mono()
|
||||
{
|
||||
std::vector<std::uint8_t> data;
|
||||
for (const std::int16_t sample : {std::int16_t{0}, std::int16_t{1000}, std::int16_t{-1000}, std::int16_t{32767}}) {
|
||||
put16(data, static_cast<std::uint16_t>(sample));
|
||||
}
|
||||
|
||||
const auto decoded = AudioDecoder::DecodeWav(makeWav(1, 1, 22050, 16, data));
|
||||
check(decoded.has_value(), "16-bit mono wav decodes");
|
||||
check(decoded->channels == 1 && decoded->sampleRate == 22050, "wav format is read");
|
||||
check(decoded->samples == std::vector<std::int16_t>{0, 1000, -1000, 32767}, "16-bit samples are copied as is");
|
||||
check(close(decoded->Duration(), 4.0f / 22050.0f), "duration comes from frames and rate");
|
||||
}
|
||||
|
||||
void testDecodeWavConvertsFormats()
|
||||
{
|
||||
const auto eightBit = AudioDecoder::DecodeWav(makeWav(1, 2, 8000, 8, {128, 255, 0, 128}));
|
||||
check(eightBit.has_value() && eightBit->channels == 2, "8-bit stereo wav decodes");
|
||||
check(eightBit->samples == std::vector<std::int16_t>{0, 127 << 8, -32768, 0}, "8-bit samples are unsigned around 128");
|
||||
|
||||
const auto twentyFour = AudioDecoder::DecodeWav(makeWav(1, 1, 48000, 24, {0x00, 0x34, 0x12, 0x00, 0x00, 0x80}));
|
||||
check(twentyFour.has_value(), "24-bit wav decodes");
|
||||
check(twentyFour->samples == std::vector<std::int16_t>{0x1234, -32768}, "24-bit keeps the top 16 bits");
|
||||
|
||||
std::vector<std::uint8_t> floats;
|
||||
for (const float value : {0.5f, -1.0f, 2.0f}) {
|
||||
std::uint32_t raw = 0;
|
||||
std::memcpy(&raw, &value, sizeof(raw));
|
||||
put32(floats, raw);
|
||||
}
|
||||
const auto floatWav = AudioDecoder::DecodeWav(makeWav(3, 1, 44100, 32, floats));
|
||||
check(floatWav.has_value(), "float wav decodes");
|
||||
check(floatWav->samples == std::vector<std::int16_t>{16384, -32767, 32767}, "float samples are scaled and clamped");
|
||||
}
|
||||
|
||||
void testDecodeWavSkipsPaddedChunks()
|
||||
{
|
||||
std::vector<std::uint8_t> list;
|
||||
putTag(list, "LIST");
|
||||
put32(list, 3);
|
||||
list.insert(list.end(), {'a', 'b', 'c', 0});
|
||||
|
||||
std::vector<std::uint8_t> data;
|
||||
put16(data, 42);
|
||||
const auto decoded = AudioDecoder::DecodeWav(makeWav(1, 1, 8000, 16, data, list));
|
||||
check(decoded.has_value() && decoded->samples == std::vector<std::int16_t>{42}, "odd sized chunks are padded");
|
||||
}
|
||||
|
||||
void testDecodeRejectsBadFiles()
|
||||
{
|
||||
std::vector<std::uint8_t> data;
|
||||
put16(data, 0);
|
||||
check(!AudioDecoder::DecodeWav(makeWav(1, 6, 48000, 16, data)), "surround wav is rejected");
|
||||
check(!AudioDecoder::DecodeWav(makeWav(2, 1, 48000, 4, data)), "compressed wav is rejected");
|
||||
check(!AudioDecoder::DecodeWav(makeWav(1, 1, 0, 16, data)), "wav without a sample rate is rejected");
|
||||
|
||||
auto truncated = makeWav(1, 1, 8000, 16, data);
|
||||
truncated.resize(30);
|
||||
check(!AudioDecoder::DecodeWav(truncated), "wav without data is rejected");
|
||||
|
||||
const std::vector<std::uint8_t> garbage{'O', 'g', 'g', 'S', 1, 2, 3, 4, 5, 6, 7, 8};
|
||||
check(!AudioDecoder::DecodeWav(garbage), "non wav bytes are rejected");
|
||||
check(!AudioDecoder::DecodeOgg(garbage), "broken ogg is rejected");
|
||||
check(!AudioDecoder::Decode(data, "game://sound.mp3"), "unknown extensions are rejected");
|
||||
}
|
||||
|
||||
void testAudioSourceSerialization()
|
||||
{
|
||||
RegisterEngineComponents();
|
||||
check(ComponentFactory::IsRegistered("AudioSource"), "AudioSource is registered");
|
||||
check(ComponentFactory::IsRegistered("AudioListener"), "AudioListener is registered");
|
||||
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("audio_serialization");
|
||||
GameObject* object = scene.CreateGameObject("speaker");
|
||||
auto* source = object->AddComponent<AudioSource>("game://sounds/hum.ogg");
|
||||
source->SetVolume(0.5f);
|
||||
source->SetPitch(1.25f);
|
||||
source->SetLoop(true);
|
||||
source->SetPlayOnStart(false);
|
||||
source->SetSpatial(false);
|
||||
source->SetDistances(2.0f, 1.0f);
|
||||
check(close(source->GetMaxDistance(), 2.0f), "max distance is never below min distance");
|
||||
|
||||
const nlohmann::json data = source->Serialize();
|
||||
check(data.at("clip").at("path") == "game://sounds/hum.ogg", "clip is saved as an asset reference");
|
||||
|
||||
GameObject* copy = scene.CreateGameObject("copy");
|
||||
auto* loaded = dynamic_cast<AudioSource*>(ComponentFactory::Create("AudioSource", *copy));
|
||||
check(loaded != nullptr, "factory creates an AudioSource");
|
||||
loaded->Deserialize(data);
|
||||
check(loaded->GetClip() == "game://sounds/hum.ogg", "clip round trips");
|
||||
check(close(loaded->GetVolume(), 0.5f) && close(loaded->GetPitch(), 1.25f), "volume and pitch round trip");
|
||||
check(loaded->GetLoop() && !loaded->GetPlayOnStart() && !loaded->GetSpatial(), "flags round trip");
|
||||
check(close(loaded->GetMinDistance(), 2.0f) && close(loaded->GetMaxDistance(), 2.0f), "distances round trip");
|
||||
|
||||
loaded->Deserialize({{"clip", nullptr}, {"volume", -3.0f}});
|
||||
check(loaded->GetClip().empty(), "null clip clears it");
|
||||
check(close(loaded->GetVolume(), 0.0f), "negative volume is clamped");
|
||||
|
||||
SceneManager::GetInstance().Destroy();
|
||||
}
|
||||
|
||||
void testVelocitySmoothing()
|
||||
{
|
||||
// 60 Hz physics seen at 120 fps: the object moves 0.2 m every other frame, 12 m/s on average
|
||||
constexpr float dt = 1.0f / 120.0f;
|
||||
glm::vec3 position{0.0f};
|
||||
glm::vec3 velocity{0.0f};
|
||||
float lowest = 1000.0f;
|
||||
float highest = 0.0f;
|
||||
for (int frame = 0; frame < 240; ++frame) {
|
||||
const glm::vec3 previous = position;
|
||||
if (frame % 2 == 0) {
|
||||
position.x += 0.2f;
|
||||
}
|
||||
velocity = SmoothAudioVelocity(position, previous, velocity, dt);
|
||||
if (frame >= 120) {
|
||||
lowest = std::min(lowest, velocity.x);
|
||||
highest = std::max(highest, velocity.x);
|
||||
}
|
||||
}
|
||||
check(lowest > 9.0f && highest < 15.0f, "fixed step movement gives a steady velocity, not 0 / 24 m/s");
|
||||
|
||||
const glm::vec3 teleported = SmoothAudioVelocity(glm::vec3{50.0f, 0.0f, 0.0f}, glm::vec3{0.0f}, velocity, dt);
|
||||
check(teleported == glm::vec3{0.0f}, "teleports reset the velocity");
|
||||
}
|
||||
|
||||
void testWithoutDeviceEverythingIsANoOp()
|
||||
{
|
||||
auto& audio = AudioSystem::GetInstance();
|
||||
audio.Shutdown();
|
||||
check(!audio.IsAvailable(), "audio is off after shutdown");
|
||||
check(audio.LoadClip("game://missing.wav") == nullptr, "no clips without a device");
|
||||
check(audio.CreateSource() == 0, "no sources without a device");
|
||||
audio.PlayOneShot("game://missing.wav", glm::vec3{0.0f});
|
||||
audio.Update(0.016f, false, nullptr);
|
||||
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("audio_no_device");
|
||||
auto* source = scene.CreateGameObject("speaker")->AddComponent<AudioSource>("game://missing.wav");
|
||||
source->Play();
|
||||
check(!source->IsPlaying(), "playing without a device does nothing");
|
||||
SceneManager::GetInstance().Destroy();
|
||||
}
|
||||
|
||||
void testPlaybackFollowsPause()
|
||||
{
|
||||
auto& audio = AudioSystem::GetInstance();
|
||||
if (!audio.Init()) {
|
||||
std::cout << " no audio device, skipping playback test\n";
|
||||
return;
|
||||
}
|
||||
|
||||
AudioData silence;
|
||||
silence.channels = 1;
|
||||
silence.sampleRate = 8000;
|
||||
silence.samples.assign(8000, 0);
|
||||
auto clip = audio.CreateClip("test://silence.wav", silence);
|
||||
check(clip != nullptr && close(clip->GetDuration(), 1.0f), "clips upload to OpenAL");
|
||||
check(audio.LoadClip("test://silence.wav") == clip, "created clips are cached by path");
|
||||
|
||||
Scene& scene = SceneManager::GetInstance().CreateScene("audio_playback");
|
||||
GameObject* speaker = scene.CreateGameObject("speaker");
|
||||
speaker->AddComponent<AudioListener>();
|
||||
auto* source = speaker->AddComponent<AudioSource>("test://silence.wav");
|
||||
source->SetLoop(true);
|
||||
source->Update(0.016f);
|
||||
check(source->IsPlaying(), "play on start plays on the first update");
|
||||
|
||||
audio.Update(0.016f, true, nullptr);
|
||||
check(audio.IsPaused() && !source->IsPlaying(), "pausing the simulation pauses sources");
|
||||
audio.Update(0.016f, false, nullptr);
|
||||
check(source->IsPlaying(), "resuming the simulation resumes sources");
|
||||
|
||||
source->Pause();
|
||||
audio.Update(0.016f, true, nullptr);
|
||||
audio.Update(0.016f, false, nullptr);
|
||||
check(!source->IsPlaying(), "a source paused by hand stays paused after resume");
|
||||
|
||||
source->SetEnabled(false);
|
||||
check(!source->IsPlaying(), "disabling stops the source");
|
||||
|
||||
AudioData stereo = silence;
|
||||
stereo.channels = 2;
|
||||
check(audio.CreateClip("test://stereo.wav", stereo) != nullptr, "stereo clips upload");
|
||||
auto* stereoSource = scene.CreateGameObject("stereo")->AddComponent<AudioSource>("test://stereo.wav");
|
||||
stereoSource->SetSpatial(true);
|
||||
stereoSource->Play();
|
||||
check(stereoSource->IsPlaying(), "stereo clips play as spatial sources");
|
||||
stereoSource->SetSpatial(false);
|
||||
check(stereoSource->IsPlaying(), "a playing source can switch to flat");
|
||||
|
||||
SceneManager::GetInstance().Destroy();
|
||||
clip.reset();
|
||||
audio.ReleaseUnusedClips();
|
||||
audio.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
void RunAudioTests()
|
||||
{
|
||||
testDecodeWav16Mono();
|
||||
testDecodeWavConvertsFormats();
|
||||
testDecodeWavSkipsPaddedChunks();
|
||||
testDecodeRejectsBadFiles();
|
||||
testAudioSourceSerialization();
|
||||
testVelocitySmoothing();
|
||||
testWithoutDeviceEverythingIsANoOp();
|
||||
testPlaybackFollowsPause();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ void RunGraphicsTests();
|
||||
void RunUiTests();
|
||||
void RunGameplayTests();
|
||||
void RunEditorTests();
|
||||
void RunAudioTests();
|
||||
|
||||
namespace {
|
||||
struct Suite {
|
||||
@@ -26,6 +27,7 @@ namespace {
|
||||
{"ui", RunUiTests},
|
||||
{"gameplay", RunGameplayTests},
|
||||
{"editor", RunEditorTests},
|
||||
{"audio", RunAudioTests},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user