feat: add audio system
This commit is contained in:
@@ -54,3 +54,6 @@
|
||||
[submodule "destrum/third_party/imguizmo"]
|
||||
path = destrum/third_party/imguizmo
|
||||
url = https://github.com/CedricGuillemet/ImGuizmo.git
|
||||
[submodule "destrum/third_party/openal-soft"]
|
||||
path = destrum/third_party/openal-soft
|
||||
url = https://github.com/kcat/openal-soft.git
|
||||
|
||||
@@ -15,6 +15,7 @@ Destrum is a C++20 Vulkan rendering/game-engine playground. It contains the core
|
||||
* Editor library: docked viewport, play/pause/stop, undo/redo, click to select, gizmos, asset browser
|
||||
* Stable asset ids (`.meta` files) and material files (`.dmat`)
|
||||
* HDR rendering with exposure and tonemapping (ACES, Khronos PBR Neutral)
|
||||
* 3D audio through OpenAL Soft (`AudioSource` / `AudioListener`, WAV and OGG)
|
||||
* CMake-based build setup with third-party dependencies as submodules
|
||||
|
||||
## Repository layout
|
||||
@@ -57,7 +58,8 @@ Most dependencies are included as Git submodules:
|
||||
* GLM
|
||||
* nlohmann/json
|
||||
* spdlog
|
||||
* stb
|
||||
* stb (image, vorbis)
|
||||
* OpenAL Soft
|
||||
* tinygltf
|
||||
* tinyexr
|
||||
* Assimp
|
||||
|
||||
@@ -36,7 +36,12 @@ set(SRC_FILES
|
||||
"src/Event.cpp"
|
||||
"src/Window.cpp"
|
||||
|
||||
"src/Audio/AudioDecoder.cpp"
|
||||
"src/Audio/AudioSystem.cpp"
|
||||
|
||||
"src/Components/Animator.cpp"
|
||||
"src/Components/AudioListener.cpp"
|
||||
"src/Components/AudioSource.cpp"
|
||||
"src/Components/CameraComponent.cpp"
|
||||
"src/Components/LightComponent.cpp"
|
||||
"src/Components/MeshRendererComponent.cpp"
|
||||
@@ -172,6 +177,8 @@ target_link_libraries(destrum
|
||||
PRIVATE
|
||||
freetype::freetype
|
||||
tinyexr
|
||||
OpenAL::OpenAL
|
||||
stb::vorbis
|
||||
)
|
||||
|
||||
target_compile_definitions(destrum
|
||||
@@ -272,6 +279,19 @@ function(destrum_cook_engine_assets GAME_TARGET GAME_OUTPUT_DIR)
|
||||
install(DIRECTORY "${_engine_runtime}/" DESTINATION "bin/assets/engine")
|
||||
endfunction()
|
||||
|
||||
# OpenAL is a DLL, every exe that links the engine needs a copy next to it
|
||||
function(destrum_copy_runtime_dlls TARGET)
|
||||
if (WIN32)
|
||||
add_custom_command(TARGET ${TARGET} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
$<TARGET_RUNTIME_DLLS:${TARGET}> $<TARGET_FILE_DIR:${TARGET}>
|
||||
COMMAND_EXPAND_LISTS
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
install(FILES $<TARGET_FILE:OpenAL::OpenAL> DESTINATION bin)
|
||||
|
||||
install(TARGETS destrum destrum_assets
|
||||
ARCHIVE DESTINATION lib
|
||||
LIBRARY DESTINATION lib
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace AssetTypes {
|
||||
|
||||
[[nodiscard]] inline bool IsModel(std::string_view path) { return HasExtension(path, {".fbx", ".glb", ".gltf", ".obj"}); }
|
||||
[[nodiscard]] inline bool IsImage(std::string_view path) { return HasExtension(path, {".png", ".jpg", ".jpeg", ".tga", ".bmp"}); }
|
||||
[[nodiscard]] inline bool IsAudio(std::string_view path) { return HasExtension(path, {".wav", ".ogg"}); }
|
||||
[[nodiscard]] inline bool IsMaterial(std::string_view path) { return HasExtension(path, {MaterialExtension}); }
|
||||
[[nodiscard]] inline bool IsScene(std::string_view path) { return HasExtension(path, {SceneExtension}); }
|
||||
[[nodiscard]] inline bool IsPrefab(std::string_view path) { return HasExtension(path, {PrefabExtension}); }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef DESTRUM_AUDIODECODER_H
|
||||
#define DESTRUM_AUDIODECODER_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
struct AudioData {
|
||||
int channels{0};
|
||||
int sampleRate{0};
|
||||
std::vector<std::int16_t> samples;
|
||||
|
||||
[[nodiscard]] float Duration() const
|
||||
{
|
||||
if (channels <= 0 || sampleRate <= 0) {
|
||||
return 0.0f;
|
||||
}
|
||||
return static_cast<float>(samples.size() / static_cast<std::size_t>(channels)) / static_cast<float>(sampleRate);
|
||||
}
|
||||
};
|
||||
|
||||
namespace AudioDecoder {
|
||||
[[nodiscard]] std::optional<AudioData> DecodeWav(std::span<const std::uint8_t> bytes);
|
||||
[[nodiscard]] std::optional<AudioData> DecodeOgg(std::span<const std::uint8_t> bytes);
|
||||
|
||||
[[nodiscard]] std::optional<AudioData> Decode(std::span<const std::uint8_t> bytes, std::string_view path);
|
||||
}
|
||||
|
||||
#endif // DESTRUM_AUDIODECODER_H
|
||||
@@ -0,0 +1,111 @@
|
||||
#ifndef DESTRUM_AUDIOSYSTEM_H
|
||||
#define DESTRUM_AUDIOSYSTEM_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include <destrum/Singleton.h>
|
||||
|
||||
struct AudioData;
|
||||
struct ALCdevice;
|
||||
struct ALCcontext;
|
||||
class Camera;
|
||||
|
||||
class AudioClip {
|
||||
public:
|
||||
AudioClip(std::string path, std::uint32_t buffer, int channels, int sampleRate, float duration);
|
||||
~AudioClip();
|
||||
|
||||
AudioClip(const AudioClip&) = delete;
|
||||
AudioClip& operator=(const AudioClip&) = delete;
|
||||
|
||||
[[nodiscard]] const std::string& GetPath() const { return m_Path; }
|
||||
[[nodiscard]] std::uint32_t GetBuffer() const { return m_Buffer; }
|
||||
[[nodiscard]] int GetChannels() const { return m_Channels; }
|
||||
[[nodiscard]] int GetSampleRate() const { return m_SampleRate; }
|
||||
[[nodiscard]] float GetDuration() const { return m_Duration; }
|
||||
|
||||
private:
|
||||
std::string m_Path;
|
||||
std::uint32_t m_Buffer{0};
|
||||
int m_Channels{0};
|
||||
int m_SampleRate{0};
|
||||
float m_Duration{0.0f};
|
||||
};
|
||||
|
||||
[[nodiscard]] glm::vec3 SmoothAudioVelocity(const glm::vec3& current, const glm::vec3& previous,
|
||||
const glm::vec3& smoothed, float dt);
|
||||
|
||||
struct OneShotParams {
|
||||
float volume{1.0f};
|
||||
float pitch{1.0f};
|
||||
bool spatial{true};
|
||||
float minDistance{1.0f};
|
||||
float maxDistance{50.0f};
|
||||
};
|
||||
|
||||
class AudioSystem final : public Singleton<AudioSystem> {
|
||||
public:
|
||||
friend class Singleton<AudioSystem>;
|
||||
|
||||
~AudioSystem() override;
|
||||
|
||||
bool Init();
|
||||
void Shutdown();
|
||||
[[nodiscard]] bool IsAvailable() const { return m_Context != nullptr; }
|
||||
|
||||
std::shared_ptr<const AudioClip> LoadClip(std::string_view vpath);
|
||||
std::shared_ptr<const AudioClip> CreateClip(std::string path, const AudioData& data);
|
||||
void ReleaseUnusedClips();
|
||||
|
||||
std::uint32_t CreateSource();
|
||||
void DestroySource(std::uint32_t source);
|
||||
|
||||
void PlayOneShot(std::string_view vpath, const glm::vec3& position, const OneShotParams& params = {});
|
||||
|
||||
void SetListener(const glm::vec3& position, const glm::vec3& forward, const glm::vec3& up);
|
||||
|
||||
void Update(float dt, bool paused, const Camera* fallbackListener);
|
||||
|
||||
void SetMasterVolume(float volume);
|
||||
[[nodiscard]] float GetMasterVolume() const { return m_MasterVolume; }
|
||||
[[nodiscard]] bool IsPaused() const { return m_Paused; }
|
||||
|
||||
private:
|
||||
AudioSystem() = default;
|
||||
|
||||
void SetPaused(bool paused);
|
||||
|
||||
struct OneShot {
|
||||
std::uint32_t source{0};
|
||||
std::shared_ptr<const AudioClip> clip;
|
||||
};
|
||||
|
||||
ALCdevice* m_Device{nullptr};
|
||||
ALCcontext* m_Context{nullptr};
|
||||
|
||||
std::unordered_map<std::string, std::shared_ptr<const AudioClip>> m_Clips;
|
||||
std::unordered_set<std::uint32_t> m_Sources;
|
||||
std::vector<std::uint32_t> m_PausedSources;
|
||||
std::vector<OneShot> m_OneShots;
|
||||
|
||||
glm::vec3 m_ListenerPosition{0.0f};
|
||||
glm::vec3 m_ListenerForward{0.0f, 0.0f, -1.0f};
|
||||
glm::vec3 m_ListenerUp{0.0f, 1.0f, 0.0f};
|
||||
glm::vec3 m_LastListenerPosition{0.0f};
|
||||
glm::vec3 m_ListenerVelocity{0.0f};
|
||||
bool m_HasLastListenerPosition{false};
|
||||
bool m_ListenerSetThisFrame{false};
|
||||
|
||||
float m_MasterVolume{1.0f};
|
||||
bool m_Paused{false};
|
||||
};
|
||||
|
||||
#endif // DESTRUM_AUDIOSYSTEM_H
|
||||
@@ -0,0 +1,22 @@
|
||||
#ifndef DESTRUM_AUDIOLISTENER_H
|
||||
#define DESTRUM_AUDIOLISTENER_H
|
||||
|
||||
#include <destrum/ObjectModel/Component.h>
|
||||
|
||||
class AudioListener final : public Component {
|
||||
public:
|
||||
explicit AudioListener(GameObject& parent);
|
||||
|
||||
AudioListener(const AudioListener& other) = delete;
|
||||
AudioListener(AudioListener&& other) noexcept = delete;
|
||||
AudioListener& operator=(const AudioListener& other) = delete;
|
||||
AudioListener& operator=(AudioListener&& other) noexcept = delete;
|
||||
|
||||
void LateUpdate(float dt) override;
|
||||
|
||||
std::string GetTypeName() const override { return "AudioListener"; }
|
||||
|
||||
void ImGuiInspector() override;
|
||||
};
|
||||
|
||||
#endif // DESTRUM_AUDIOLISTENER_H
|
||||
@@ -0,0 +1,86 @@
|
||||
#ifndef DESTRUM_AUDIOSOURCE_H
|
||||
#define DESTRUM_AUDIOSOURCE_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include <destrum/ObjectModel/Component.h>
|
||||
|
||||
class AudioClip;
|
||||
|
||||
class AudioSource final : public Component {
|
||||
public:
|
||||
explicit AudioSource(GameObject& parent, std::string clipPath = {});
|
||||
~AudioSource() override;
|
||||
|
||||
AudioSource(const AudioSource& other) = delete;
|
||||
AudioSource(AudioSource&& other) noexcept = delete;
|
||||
AudioSource& operator=(const AudioSource& other) = delete;
|
||||
AudioSource& operator=(AudioSource&& other) noexcept = delete;
|
||||
|
||||
void Update(float dt) override;
|
||||
void LateUpdate(float dt) override;
|
||||
void SetEnabled(bool enabled) override;
|
||||
|
||||
std::string GetTypeName() const override { return "AudioSource"; }
|
||||
|
||||
nlohmann::json Serialize() const override;
|
||||
void Deserialize(const nlohmann::json& data) override;
|
||||
void ImGuiInspector() override;
|
||||
|
||||
void Play();
|
||||
void Pause();
|
||||
void Stop();
|
||||
[[nodiscard]] bool IsPlaying() const;
|
||||
|
||||
void PlayOneShot(const std::string& clipPath, float volumeScale = 1.0f) const;
|
||||
|
||||
void SetClip(const std::string& clipPath);
|
||||
[[nodiscard]] const std::string& GetClip() const { return m_ClipPath; }
|
||||
|
||||
void SetVolume(float volume);
|
||||
[[nodiscard]] float GetVolume() const { return m_Volume; }
|
||||
|
||||
void SetPitch(float pitch);
|
||||
[[nodiscard]] float GetPitch() const { return m_Pitch; }
|
||||
|
||||
void SetLoop(bool loop);
|
||||
[[nodiscard]] bool GetLoop() const { return m_Loop; }
|
||||
|
||||
void SetPlayOnStart(bool playOnStart) { m_PlayOnStart = playOnStart; }
|
||||
[[nodiscard]] bool GetPlayOnStart() const { return m_PlayOnStart; }
|
||||
|
||||
void SetSpatial(bool spatial);
|
||||
[[nodiscard]] bool GetSpatial() const { return m_Spatial; }
|
||||
|
||||
void SetDistances(float minDistance, float maxDistance);
|
||||
[[nodiscard]] float GetMinDistance() const { return m_MinDistance; }
|
||||
[[nodiscard]] float GetMaxDistance() const { return m_MaxDistance; }
|
||||
|
||||
private:
|
||||
bool EnsureSource();
|
||||
void ApplyProperties();
|
||||
void SyncPosition(float dt);
|
||||
void ReleaseSource();
|
||||
|
||||
std::string m_ClipPath;
|
||||
float m_Volume{1.0f};
|
||||
float m_Pitch{1.0f};
|
||||
bool m_Loop{false};
|
||||
bool m_PlayOnStart{true};
|
||||
bool m_Spatial{true};
|
||||
float m_MinDistance{1.0f};
|
||||
float m_MaxDistance{50.0f};
|
||||
|
||||
std::uint32_t m_Source{0};
|
||||
std::shared_ptr<const AudioClip> m_Clip;
|
||||
bool m_AutoPlayed{false};
|
||||
glm::vec3 m_LastPosition{0.0f};
|
||||
glm::vec3 m_Velocity{0.0f};
|
||||
bool m_HasLastPosition{false};
|
||||
};
|
||||
|
||||
#endif // DESTRUM_AUDIOSOURCE_H
|
||||
@@ -25,6 +25,7 @@ public:
|
||||
void Init(std::filesystem::path exeDir);
|
||||
void Reset();
|
||||
void Mount(std::string scheme, std::filesystem::path root);
|
||||
void ReloadManifest(std::string_view scheme);
|
||||
|
||||
std::vector<uint8_t> ReadBytes(std::string_view vpath);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#define DESTRUM_ENGINECOMPONENTLIST_H
|
||||
|
||||
#include <destrum/Components/Animator.h>
|
||||
#include <destrum/Components/AudioListener.h>
|
||||
#include <destrum/Components/AudioSource.h>
|
||||
#include <destrum/Components/CameraComponent.h>
|
||||
#include <destrum/Components/LightComponent.h>
|
||||
#include <destrum/Components/MeshRendererComponent.h>
|
||||
@@ -25,6 +27,8 @@
|
||||
X(Spinner) \
|
||||
X(OrbitAndSpin) \
|
||||
X(Animator) \
|
||||
X(AudioSource) \
|
||||
X(AudioListener) \
|
||||
X(Rigidbody) \
|
||||
X(BoxCollider) \
|
||||
X(SphereCollider) \
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <tracy/Tracy.hpp>
|
||||
#include <common/TracySystem.hpp>
|
||||
|
||||
#include <destrum/Audio/AudioSystem.h>
|
||||
#include <destrum/FS/AssetFS.h>
|
||||
#include <destrum/Graphics/Resources/FontCache.h>
|
||||
#include <destrum/Scene/ResourceCollector.h>
|
||||
@@ -73,6 +74,7 @@ void App::init(const AppParams& params)
|
||||
renderer.init(gfxDevice, resources, params.renderSize);
|
||||
|
||||
InputManager::GetInstance().Init();
|
||||
AudioSystem::GetInstance().Init();
|
||||
Time::GetInstance().Update();
|
||||
|
||||
customInitStarted = true;
|
||||
@@ -134,6 +136,7 @@ void App::resizeRenderTarget(const glm::ivec2& size)
|
||||
void App::collectUnusedResources()
|
||||
{
|
||||
CollectUnusedResources(SceneManager::GetInstance(), resources, gfxDevice);
|
||||
AudioSystem::GetInstance().ReleaseUnusedClips();
|
||||
}
|
||||
|
||||
void App::run()
|
||||
@@ -196,6 +199,8 @@ void App::run()
|
||||
|
||||
runFixedSteps();
|
||||
|
||||
AudioSystem::GetInstance().Update(dt, isPlaying() && (isPaused() || simulation.paused), &getViewCamera());
|
||||
|
||||
bool skipFrame = false;
|
||||
handleSwapchainResize(skipFrame);
|
||||
if (skipFrame) {
|
||||
@@ -392,6 +397,7 @@ void App::cleanup()
|
||||
attempt([this] { customCleanup(); });
|
||||
}
|
||||
attempt([] { SceneManager::GetInstance().Destroy(); });
|
||||
attempt([] { AudioSystem::GetInstance().Shutdown(); });
|
||||
attempt([] { FontCache::GetInstance().Clear(); });
|
||||
attempt([this] { renderer.cleanup(gfxDevice); });
|
||||
attempt([this] { imguiPass.cleanup(); });
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#include <destrum/Audio/AudioDecoder.h>
|
||||
|
||||
#include <destrum/Assets/AssetTypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#define STB_VORBIS_HEADER_ONLY
|
||||
#include <stb_vorbis.c>
|
||||
|
||||
namespace {
|
||||
constexpr std::uint16_t FormatPcm = 1;
|
||||
constexpr std::uint16_t FormatFloat = 3;
|
||||
constexpr std::uint16_t FormatExtensible = 0xFFFE;
|
||||
|
||||
// OpenAL only has mono and stereo formats in core
|
||||
constexpr int MaxChannels = 2;
|
||||
|
||||
[[nodiscard]] std::uint16_t ReadU16(std::span<const std::uint8_t> bytes, std::size_t offset)
|
||||
{
|
||||
return static_cast<std::uint16_t>(bytes[offset] | (bytes[offset + 1] << 8));
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t ReadU32(std::span<const std::uint8_t> bytes, std::size_t offset)
|
||||
{
|
||||
return static_cast<std::uint32_t>(bytes[offset]) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[offset + 3]) << 24);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool Tag(std::span<const std::uint8_t> bytes, std::size_t offset, const char (&tag)[5])
|
||||
{
|
||||
return std::memcmp(bytes.data() + offset, tag, 4) == 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::int16_t FloatToPcm(float value)
|
||||
{
|
||||
return static_cast<std::int16_t>(std::lround(std::clamp(value, -1.0f, 1.0f) * 32767.0f));
|
||||
}
|
||||
|
||||
[[nodiscard]] std::int16_t ReadSample(std::span<const std::uint8_t> data, std::size_t offset,
|
||||
std::uint16_t format, std::uint16_t bits)
|
||||
{
|
||||
if (format == FormatFloat) {
|
||||
float value = 0.0f;
|
||||
const std::uint32_t raw = ReadU32(data, offset);
|
||||
std::memcpy(&value, &raw, sizeof(value));
|
||||
return std::isfinite(value) ? FloatToPcm(value) : std::int16_t{0};
|
||||
}
|
||||
|
||||
switch (bits) {
|
||||
case 8:
|
||||
return static_cast<std::int16_t>((static_cast<int>(data[offset]) - 128) << 8);
|
||||
case 16:
|
||||
return static_cast<std::int16_t>(ReadU16(data, offset));
|
||||
case 24:
|
||||
return static_cast<std::int16_t>(data[offset + 1] | (data[offset + 2] << 8));
|
||||
default:
|
||||
return static_cast<std::int16_t>(ReadU32(data, offset) >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<AudioData> AudioDecoder::DecodeWav(std::span<const std::uint8_t> bytes)
|
||||
{
|
||||
if (bytes.size() < 12 || !Tag(bytes, 0, "RIFF") || !Tag(bytes, 8, "WAVE")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::uint16_t format = 0;
|
||||
std::uint16_t channels = 0;
|
||||
std::uint32_t sampleRate = 0;
|
||||
std::uint16_t blockAlign = 0;
|
||||
std::uint16_t bits = 0;
|
||||
std::span<const std::uint8_t> data;
|
||||
bool hasFormat = false;
|
||||
bool hasData = false;
|
||||
|
||||
std::size_t offset = 12;
|
||||
while (offset + 8 <= bytes.size()) {
|
||||
const std::size_t chunkSize = ReadU32(bytes, offset + 4);
|
||||
const std::size_t chunkStart = offset + 8;
|
||||
// Writers that stream sometimes leave the data size at 0xFFFFFFFF, take what is there
|
||||
const std::size_t available = std::min(chunkSize, bytes.size() - chunkStart);
|
||||
|
||||
if (Tag(bytes, offset, "fmt ")) {
|
||||
if (available < 16) {
|
||||
return std::nullopt;
|
||||
}
|
||||
format = ReadU16(bytes, chunkStart);
|
||||
channels = ReadU16(bytes, chunkStart + 2);
|
||||
sampleRate = ReadU32(bytes, chunkStart + 4);
|
||||
blockAlign = ReadU16(bytes, chunkStart + 12);
|
||||
bits = ReadU16(bytes, chunkStart + 14);
|
||||
if (format == FormatExtensible) {
|
||||
if (available < 26) {
|
||||
return std::nullopt;
|
||||
}
|
||||
format = ReadU16(bytes, chunkStart + 24);
|
||||
}
|
||||
hasFormat = true;
|
||||
} else if (Tag(bytes, offset, "data")) {
|
||||
data = bytes.subspan(chunkStart, available);
|
||||
hasData = true;
|
||||
}
|
||||
|
||||
if (chunkSize > bytes.size() - chunkStart) {
|
||||
break;
|
||||
}
|
||||
// Chunks are padded to an even size
|
||||
offset = chunkStart + chunkSize + (chunkSize & 1u);
|
||||
}
|
||||
|
||||
if (!hasFormat || !hasData) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const bool pcm = format == FormatPcm && (bits == 8 || bits == 16 || bits == 24 || bits == 32);
|
||||
const bool floats = format == FormatFloat && bits == 32;
|
||||
if ((!pcm && !floats) || channels == 0 || channels > MaxChannels || sampleRate == 0 ||
|
||||
sampleRate > static_cast<std::uint32_t>(std::numeric_limits<int>::max())) {
|
||||
spdlog::warn("Unsupported WAV: format {}, {} bits, {} channels, {} Hz", format, bits, channels, sampleRate);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::size_t bytesPerSample = bits / 8u;
|
||||
if (blockAlign < bytesPerSample * channels) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
AudioData result;
|
||||
result.channels = channels;
|
||||
result.sampleRate = static_cast<int>(sampleRate);
|
||||
|
||||
const std::size_t frames = data.size() / blockAlign;
|
||||
result.samples.reserve(frames * channels);
|
||||
for (std::size_t frame = 0; frame < frames; ++frame) {
|
||||
for (std::size_t channel = 0; channel < channels; ++channel) {
|
||||
result.samples.push_back(ReadSample(data, frame * blockAlign + channel * bytesPerSample, format, bits));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<AudioData> AudioDecoder::DecodeOgg(std::span<const std::uint8_t> bytes)
|
||||
{
|
||||
if (bytes.empty() || bytes.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int channels = 0;
|
||||
int sampleRate = 0;
|
||||
short* decoded = nullptr;
|
||||
const int frames = stb_vorbis_decode_memory(bytes.data(), static_cast<int>(bytes.size()), &channels, &sampleRate, &decoded);
|
||||
if (frames < 0 || decoded == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
AudioData result;
|
||||
result.channels = channels;
|
||||
result.sampleRate = sampleRate;
|
||||
result.samples.assign(decoded, decoded + static_cast<std::size_t>(frames) * static_cast<std::size_t>(channels));
|
||||
std::free(decoded);
|
||||
|
||||
if (channels <= 0 || channels > MaxChannels || sampleRate <= 0) {
|
||||
spdlog::warn("Unsupported OGG: {} channels, {} Hz", channels, sampleRate);
|
||||
return std::nullopt;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<AudioData> AudioDecoder::Decode(std::span<const std::uint8_t> bytes, std::string_view path)
|
||||
{
|
||||
const std::string extension = AssetTypes::Extension(path);
|
||||
if (extension == ".wav") {
|
||||
return DecodeWav(bytes);
|
||||
}
|
||||
if (extension == ".ogg") {
|
||||
return DecodeOgg(bytes);
|
||||
}
|
||||
spdlog::warn("Unsupported audio format: {}", path);
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
#include <destrum/Audio/AudioSystem.h>
|
||||
|
||||
#include <destrum/Audio/AudioDecoder.h>
|
||||
#include <destrum/FS/AssetFS.h>
|
||||
#include <destrum/Graphics/Camera.h>
|
||||
|
||||
#include <AL/al.h>
|
||||
#include <AL/alc.h>
|
||||
#include <AL/alext.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <exception>
|
||||
#include <utility>
|
||||
|
||||
#include <glm/gtc/type_ptr.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
namespace {
|
||||
constexpr std::size_t MaxOneShots = 32;
|
||||
// Above this the object teleported (respawn, camera cut), a doppler shift would only sound wrong
|
||||
constexpr float MaxSpeed = 100.0f;
|
||||
constexpr float VelocitySmoothingTime = 0.1f;
|
||||
|
||||
[[nodiscard]] ALint SourceState(ALuint source)
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
alGetSourcei(source, AL_SOURCE_STATE, &state);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Physics moves objects in fixed steps, so the raw per frame velocity jumps between 0 and a spike and doppler warbles
|
||||
glm::vec3 SmoothAudioVelocity(const glm::vec3& current, const glm::vec3& previous, const glm::vec3& smoothed, float dt)
|
||||
{
|
||||
if (dt <= 0.0f) {
|
||||
return smoothed;
|
||||
}
|
||||
const glm::vec3 velocity = (current - previous) / dt;
|
||||
if (glm::length(velocity) > MaxSpeed) {
|
||||
return glm::vec3{0.0f};
|
||||
}
|
||||
return glm::mix(smoothed, velocity, 1.0f - std::exp(-dt / VelocitySmoothingTime));
|
||||
}
|
||||
|
||||
AudioClip::AudioClip(std::string path, std::uint32_t buffer, int channels, int sampleRate, float duration)
|
||||
: m_Path(std::move(path))
|
||||
, m_Buffer(buffer)
|
||||
, m_Channels(channels)
|
||||
, m_SampleRate(sampleRate)
|
||||
, m_Duration(duration)
|
||||
{
|
||||
}
|
||||
|
||||
AudioClip::~AudioClip()
|
||||
{
|
||||
if (m_Buffer != 0 && AudioSystem::GetInstance().IsAvailable()) {
|
||||
const ALuint buffer = m_Buffer;
|
||||
alDeleteBuffers(1, &buffer);
|
||||
}
|
||||
}
|
||||
|
||||
AudioSystem::~AudioSystem()
|
||||
{
|
||||
try {
|
||||
Shutdown();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioSystem::Init()
|
||||
{
|
||||
if (IsAvailable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
m_Device = alcOpenDevice(nullptr);
|
||||
if (m_Device == nullptr) {
|
||||
spdlog::warn("No audio device, sound is disabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_Context = alcCreateContext(m_Device, nullptr);
|
||||
if (m_Context == nullptr || alcMakeContextCurrent(m_Context) == ALC_FALSE) {
|
||||
spdlog::warn("Could not create an OpenAL context, sound is disabled");
|
||||
if (m_Context != nullptr) {
|
||||
alcDestroyContext(m_Context);
|
||||
m_Context = nullptr;
|
||||
}
|
||||
alcCloseDevice(m_Device);
|
||||
m_Device = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
alDistanceModel(AL_INVERSE_DISTANCE_CLAMPED);
|
||||
if (alIsExtensionPresent("AL_SOFT_source_spatialize") == AL_FALSE) {
|
||||
spdlog::warn("AL_SOFT_source_spatialize is missing, stereo clips will not be positioned");
|
||||
}
|
||||
alListenerf(AL_GAIN, m_MasterVolume);
|
||||
|
||||
const ALCchar* name = alcGetString(m_Device, ALC_DEVICE_SPECIFIER);
|
||||
spdlog::info("Audio device: {}", name != nullptr ? name : "unknown");
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioSystem::Shutdown()
|
||||
{
|
||||
if (!IsAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const OneShot& oneShot : m_OneShots) {
|
||||
DestroySource(oneShot.source);
|
||||
}
|
||||
m_OneShots.clear();
|
||||
|
||||
while (!m_Sources.empty()) {
|
||||
DestroySource(*m_Sources.begin());
|
||||
}
|
||||
m_PausedSources.clear();
|
||||
m_Clips.clear();
|
||||
|
||||
alcMakeContextCurrent(nullptr);
|
||||
alcDestroyContext(m_Context);
|
||||
alcCloseDevice(m_Device);
|
||||
m_Context = nullptr;
|
||||
m_Device = nullptr;
|
||||
m_Paused = false;
|
||||
m_HasLastListenerPosition = false;
|
||||
}
|
||||
|
||||
std::shared_ptr<const AudioClip> AudioSystem::LoadClip(std::string_view vpath)
|
||||
{
|
||||
if (!IsAvailable() || vpath.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string key{vpath};
|
||||
if (const auto it = m_Clips.find(key); it != m_Clips.end()) {
|
||||
return it->second;
|
||||
}
|
||||
|
||||
ZoneScopedN("AudioSystem::LoadClip");
|
||||
std::vector<std::uint8_t> bytes;
|
||||
try {
|
||||
bytes = AssetFS::GetInstance().ReadBytes(vpath);
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::error("Could not read audio clip {}: {}", key, e.what());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto data = AudioDecoder::Decode(bytes, vpath);
|
||||
if (!data) {
|
||||
spdlog::error("Could not decode audio clip {}", key);
|
||||
return nullptr;
|
||||
}
|
||||
return CreateClip(key, *data);
|
||||
}
|
||||
|
||||
std::shared_ptr<const AudioClip> AudioSystem::CreateClip(std::string path, const AudioData& data)
|
||||
{
|
||||
if (!IsAvailable() || data.samples.empty() || (data.channels != 1 && data.channels != 2)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
alGetError();
|
||||
ALuint buffer = 0;
|
||||
alGenBuffers(1, &buffer);
|
||||
alBufferData(buffer, data.channels == 1 ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16, data.samples.data(),
|
||||
static_cast<ALsizei>(data.samples.size() * sizeof(std::int16_t)), data.sampleRate);
|
||||
if (const ALenum error = alGetError(); error != AL_NO_ERROR) {
|
||||
spdlog::error("Could not upload audio clip {}: OpenAL error {:#x}", path, error);
|
||||
alDeleteBuffers(1, &buffer);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto clip = std::make_shared<const AudioClip>(path, buffer, data.channels, data.sampleRate, data.Duration());
|
||||
m_Clips[path] = clip;
|
||||
return clip;
|
||||
}
|
||||
|
||||
void AudioSystem::ReleaseUnusedClips()
|
||||
{
|
||||
std::erase_if(m_Clips, [](const auto& entry) { return entry.second.use_count() == 1; });
|
||||
}
|
||||
|
||||
std::uint32_t AudioSystem::CreateSource()
|
||||
{
|
||||
if (!IsAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
alGetError();
|
||||
ALuint source = 0;
|
||||
alGenSources(1, &source);
|
||||
if (alGetError() != AL_NO_ERROR) {
|
||||
spdlog::warn("Out of OpenAL sources");
|
||||
return 0;
|
||||
}
|
||||
m_Sources.insert(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
void AudioSystem::DestroySource(std::uint32_t source)
|
||||
{
|
||||
if (source == 0 || !IsAvailable() || !m_Sources.erase(source)) {
|
||||
return;
|
||||
}
|
||||
std::erase(m_PausedSources, source);
|
||||
|
||||
const ALuint alSource = source;
|
||||
alSourceStop(alSource);
|
||||
alSourcei(alSource, AL_BUFFER, 0);
|
||||
alDeleteSources(1, &alSource);
|
||||
}
|
||||
|
||||
void AudioSystem::PlayOneShot(std::string_view vpath, const glm::vec3& position, const OneShotParams& params)
|
||||
{
|
||||
auto clip = LoadClip(vpath);
|
||||
if (!clip) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_OneShots.size() >= MaxOneShots) {
|
||||
DestroySource(m_OneShots.front().source);
|
||||
m_OneShots.erase(m_OneShots.begin());
|
||||
}
|
||||
|
||||
const std::uint32_t source = CreateSource();
|
||||
if (source == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
alSourcei(source, AL_BUFFER, static_cast<ALint>(clip->GetBuffer()));
|
||||
alSourcef(source, AL_GAIN, params.volume);
|
||||
alSourcef(source, AL_PITCH, params.pitch);
|
||||
alSourcei(source, AL_SOURCE_RELATIVE, params.spatial ? AL_FALSE : AL_TRUE);
|
||||
alSourcei(source, AL_SOURCE_SPATIALIZE_SOFT, params.spatial ? AL_TRUE : AL_FALSE);
|
||||
alSourcef(source, AL_ROLLOFF_FACTOR, params.spatial ? 1.0f : 0.0f);
|
||||
alSourcef(source, AL_REFERENCE_DISTANCE, params.minDistance);
|
||||
alSourcef(source, AL_MAX_DISTANCE, params.maxDistance);
|
||||
const glm::vec3 sourcePosition = params.spatial ? position : glm::vec3{0.0f};
|
||||
alSourcefv(source, AL_POSITION, glm::value_ptr(sourcePosition));
|
||||
alSourcePlay(source);
|
||||
|
||||
m_OneShots.push_back({source, std::move(clip)});
|
||||
}
|
||||
|
||||
void AudioSystem::SetListener(const glm::vec3& position, const glm::vec3& forward, const glm::vec3& up)
|
||||
{
|
||||
m_ListenerPosition = position;
|
||||
m_ListenerForward = forward;
|
||||
m_ListenerUp = up;
|
||||
m_ListenerSetThisFrame = true;
|
||||
}
|
||||
|
||||
void AudioSystem::Update(float dt, bool paused, const Camera* fallbackListener)
|
||||
{
|
||||
if (!IsAvailable()) {
|
||||
return;
|
||||
}
|
||||
ZoneScopedN("AudioSystem::Update");
|
||||
|
||||
if (!m_ListenerSetThisFrame && fallbackListener != nullptr) {
|
||||
SetListener(fallbackListener->GetPosition(), fallbackListener->GetForward(), fallbackListener->GetUp());
|
||||
}
|
||||
m_ListenerSetThisFrame = false;
|
||||
|
||||
if (!m_HasLastListenerPosition || paused) {
|
||||
m_ListenerVelocity = glm::vec3{0.0f};
|
||||
} else {
|
||||
m_ListenerVelocity = SmoothAudioVelocity(m_ListenerPosition, m_LastListenerPosition, m_ListenerVelocity, dt);
|
||||
}
|
||||
m_LastListenerPosition = m_ListenerPosition;
|
||||
m_HasLastListenerPosition = true;
|
||||
|
||||
const std::array orientation{
|
||||
m_ListenerForward.x, m_ListenerForward.y, m_ListenerForward.z,
|
||||
m_ListenerUp.x, m_ListenerUp.y, m_ListenerUp.z,
|
||||
};
|
||||
alListenerfv(AL_POSITION, glm::value_ptr(m_ListenerPosition));
|
||||
alListenerfv(AL_VELOCITY, glm::value_ptr(m_ListenerVelocity));
|
||||
alListenerfv(AL_ORIENTATION, orientation.data());
|
||||
|
||||
SetPaused(paused);
|
||||
|
||||
std::erase_if(m_OneShots, [this](const OneShot& oneShot) {
|
||||
const ALint state = SourceState(oneShot.source);
|
||||
if (state == AL_PLAYING || state == AL_PAUSED) {
|
||||
return false;
|
||||
}
|
||||
DestroySource(oneShot.source);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void AudioSystem::SetMasterVolume(float volume)
|
||||
{
|
||||
m_MasterVolume = std::max(volume, 0.0f);
|
||||
if (IsAvailable()) {
|
||||
alListenerf(AL_GAIN, m_MasterVolume);
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSystem::SetPaused(bool paused)
|
||||
{
|
||||
if (paused == m_Paused) {
|
||||
return;
|
||||
}
|
||||
m_Paused = paused;
|
||||
|
||||
if (paused) {
|
||||
for (const std::uint32_t source : m_Sources) {
|
||||
if (SourceState(source) == AL_PLAYING) {
|
||||
alSourcePause(source);
|
||||
m_PausedSources.push_back(source);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sources paused on purpose before the simulation paused stay paused
|
||||
for (const std::uint32_t source : m_PausedSources) {
|
||||
if (SourceState(source) == AL_PAUSED) {
|
||||
alSourcePlay(source);
|
||||
}
|
||||
}
|
||||
m_PausedSources.clear();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#include <destrum/Components/AudioListener.h>
|
||||
|
||||
#include <destrum/Audio/AudioSystem.h>
|
||||
#include <destrum/ObjectModel/Transform.h>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
AudioListener::AudioListener(GameObject& parent)
|
||||
: Component(parent, "AudioListener")
|
||||
{
|
||||
}
|
||||
|
||||
void AudioListener::LateUpdate(float)
|
||||
{
|
||||
auto& transform = GetTransform();
|
||||
const glm::quat rotation = transform.GetWorldRotation();
|
||||
AudioSystem::GetInstance().SetListener(
|
||||
transform.GetWorldPosition(),
|
||||
rotation * glm::vec3{0.0f, 0.0f, -1.0f},
|
||||
rotation * glm::vec3{0.0f, 1.0f, 0.0f});
|
||||
}
|
||||
|
||||
void AudioListener::ImGuiInspector()
|
||||
{
|
||||
auto& audio = AudioSystem::GetInstance();
|
||||
float volume = audio.GetMasterVolume();
|
||||
if (ImGui::SliderFloat("Master Volume", &volume, 0.0f, 1.0f)) {
|
||||
audio.SetMasterVolume(volume);
|
||||
}
|
||||
ImGui::TextDisabled("Hears the scene while playing, the view camera does otherwise");
|
||||
ImGui::TextWrapped("Uses this object's position and rotation. If the transform does not turn with the view "
|
||||
"(a first person body), remove this and let the camera listen.");
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
#include <destrum/Components/AudioSource.h>
|
||||
|
||||
#include <destrum/Assets/AssetDatabase.h>
|
||||
#include <destrum/Assets/AssetTypes.h>
|
||||
#include <destrum/Audio/AudioSystem.h>
|
||||
#include <destrum/ObjectModel/Transform.h>
|
||||
#include <destrum/Util/ImGuiUtils.h>
|
||||
|
||||
#include <AL/al.h>
|
||||
#include <AL/alext.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include <glm/gtc/type_ptr.hpp>
|
||||
#include <imgui.h>
|
||||
|
||||
namespace {
|
||||
[[nodiscard]] ALint SourceState(std::uint32_t source)
|
||||
{
|
||||
ALint state = AL_STOPPED;
|
||||
if (source != 0) {
|
||||
alGetSourcei(source, AL_SOURCE_STATE, &state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
AudioSource::AudioSource(GameObject& parent, std::string clipPath)
|
||||
: Component(parent, "AudioSource")
|
||||
, m_ClipPath(std::move(clipPath))
|
||||
{
|
||||
}
|
||||
|
||||
AudioSource::~AudioSource()
|
||||
{
|
||||
ReleaseSource();
|
||||
}
|
||||
|
||||
void AudioSource::Update(float)
|
||||
{
|
||||
if (m_AutoPlayed) {
|
||||
return;
|
||||
}
|
||||
m_AutoPlayed = true;
|
||||
if (m_PlayOnStart) {
|
||||
Play();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSource::LateUpdate(float dt)
|
||||
{
|
||||
SyncPosition(dt);
|
||||
}
|
||||
|
||||
void AudioSource::SetEnabled(bool enabled)
|
||||
{
|
||||
if (!enabled) {
|
||||
Stop();
|
||||
}
|
||||
Component::SetEnabled(enabled);
|
||||
}
|
||||
|
||||
void AudioSource::Play()
|
||||
{
|
||||
if (!EnsureSource()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_Clip || m_Clip->GetPath() != m_ClipPath) {
|
||||
alSourceStop(m_Source);
|
||||
alSourcei(m_Source, AL_BUFFER, 0);
|
||||
m_Clip = AudioSystem::GetInstance().LoadClip(m_ClipPath);
|
||||
if (!m_Clip) {
|
||||
return;
|
||||
}
|
||||
alSourcei(m_Source, AL_BUFFER, static_cast<ALint>(m_Clip->GetBuffer()));
|
||||
}
|
||||
|
||||
ApplyProperties();
|
||||
m_HasLastPosition = false;
|
||||
SyncPosition(0.0f);
|
||||
alSourcePlay(m_Source);
|
||||
}
|
||||
|
||||
void AudioSource::Pause()
|
||||
{
|
||||
if (SourceState(m_Source) == AL_PLAYING) {
|
||||
alSourcePause(m_Source);
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSource::Stop()
|
||||
{
|
||||
if (m_Source != 0) {
|
||||
alSourceStop(m_Source);
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioSource::IsPlaying() const
|
||||
{
|
||||
return SourceState(m_Source) == AL_PLAYING;
|
||||
}
|
||||
|
||||
void AudioSource::PlayOneShot(const std::string& clipPath, float volumeScale) const
|
||||
{
|
||||
AudioSystem::GetInstance().PlayOneShot(clipPath, GetTransform().GetWorldPosition(), {
|
||||
.volume = m_Volume * volumeScale,
|
||||
.pitch = m_Pitch,
|
||||
.spatial = m_Spatial,
|
||||
.minDistance = m_MinDistance,
|
||||
.maxDistance = m_MaxDistance,
|
||||
});
|
||||
}
|
||||
|
||||
void AudioSource::SetClip(const std::string& clipPath)
|
||||
{
|
||||
if (clipPath == m_ClipPath) {
|
||||
return;
|
||||
}
|
||||
m_ClipPath = clipPath;
|
||||
if (m_Source != 0) {
|
||||
alSourceStop(m_Source);
|
||||
alSourcei(m_Source, AL_BUFFER, 0);
|
||||
}
|
||||
m_Clip.reset();
|
||||
}
|
||||
|
||||
void AudioSource::SetVolume(float volume)
|
||||
{
|
||||
m_Volume = std::max(volume, 0.0f);
|
||||
ApplyProperties();
|
||||
}
|
||||
|
||||
void AudioSource::SetPitch(float pitch)
|
||||
{
|
||||
m_Pitch = std::max(pitch, 0.01f);
|
||||
ApplyProperties();
|
||||
}
|
||||
|
||||
void AudioSource::SetLoop(bool loop)
|
||||
{
|
||||
m_Loop = loop;
|
||||
ApplyProperties();
|
||||
}
|
||||
|
||||
void AudioSource::SetSpatial(bool spatial)
|
||||
{
|
||||
m_Spatial = spatial;
|
||||
ApplyProperties();
|
||||
SyncPosition(0.0f);
|
||||
}
|
||||
|
||||
void AudioSource::SetDistances(float minDistance, float maxDistance)
|
||||
{
|
||||
m_MinDistance = std::max(minDistance, 0.01f);
|
||||
m_MaxDistance = std::max(maxDistance, m_MinDistance);
|
||||
ApplyProperties();
|
||||
}
|
||||
|
||||
nlohmann::json AudioSource::Serialize() const
|
||||
{
|
||||
return {
|
||||
{"clip", m_ClipPath.empty() ? nlohmann::json(nullptr) : AssetDatabase::GetInstance().WriteReference(m_ClipPath)},
|
||||
{"volume", m_Volume},
|
||||
{"pitch", m_Pitch},
|
||||
{"loop", m_Loop},
|
||||
{"playOnStart", m_PlayOnStart},
|
||||
{"spatial", m_Spatial},
|
||||
{"minDistance", m_MinDistance},
|
||||
{"maxDistance", m_MaxDistance},
|
||||
};
|
||||
}
|
||||
|
||||
void AudioSource::Deserialize(const nlohmann::json& data)
|
||||
{
|
||||
if (const auto clip = data.find("clip"); clip != data.end()) {
|
||||
if (clip->is_string()) {
|
||||
SetClip(clip->get<std::string>());
|
||||
} else if (const auto reference = AssetDatabase::GetInstance().ReadReference(*clip)) {
|
||||
SetClip(reference->path);
|
||||
} else {
|
||||
SetClip({});
|
||||
}
|
||||
}
|
||||
|
||||
m_Loop = data.value("loop", m_Loop);
|
||||
m_PlayOnStart = data.value("playOnStart", m_PlayOnStart);
|
||||
m_Spatial = data.value("spatial", m_Spatial);
|
||||
SetVolume(data.value("volume", m_Volume));
|
||||
SetPitch(data.value("pitch", m_Pitch));
|
||||
SetDistances(data.value("minDistance", m_MinDistance), data.value("maxDistance", m_MaxDistance));
|
||||
}
|
||||
|
||||
void AudioSource::ImGuiInspector()
|
||||
{
|
||||
if (!AudioSystem::GetInstance().IsAvailable()) {
|
||||
ImGui::TextDisabled("No audio device, sound is disabled");
|
||||
}
|
||||
|
||||
std::string clipPath = m_ClipPath;
|
||||
if (ImGuiUtils::InputString("Clip", clipPath, 0, "game://sounds/clip.ogg")) {
|
||||
SetClip(clipPath);
|
||||
}
|
||||
if (ImGui::BeginDragDropTarget()) {
|
||||
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::AssetPayload)) {
|
||||
const std::string dropped = static_cast<const char*>(payload->Data);
|
||||
if (AssetTypes::IsAudio(dropped)) {
|
||||
SetClip(dropped);
|
||||
}
|
||||
}
|
||||
ImGui::EndDragDropTarget();
|
||||
}
|
||||
ImGui::SetItemTooltip("Drop a .wav or .ogg from the Assets window");
|
||||
|
||||
if (m_Clip && m_Clip->GetPath() == m_ClipPath) {
|
||||
ImGui::TextDisabled("%s, %d Hz, %.2f s", m_Clip->GetChannels() == 1 ? "Mono" : "Stereo",
|
||||
m_Clip->GetSampleRate(), m_Clip->GetDuration());
|
||||
}
|
||||
|
||||
float volume = m_Volume;
|
||||
if (ImGui::SliderFloat("Volume", &volume, 0.0f, 2.0f)) {
|
||||
SetVolume(volume);
|
||||
}
|
||||
float pitch = m_Pitch;
|
||||
if (ImGui::SliderFloat("Pitch", &pitch, 0.1f, 3.0f)) {
|
||||
SetPitch(pitch);
|
||||
}
|
||||
|
||||
bool loop = m_Loop;
|
||||
if (ImGui::Checkbox("Loop", &loop)) {
|
||||
SetLoop(loop);
|
||||
}
|
||||
ImGui::Checkbox("Play On Start", &m_PlayOnStart);
|
||||
bool spatial = m_Spatial;
|
||||
if (ImGui::Checkbox("Spatial", &spatial)) {
|
||||
SetSpatial(spatial);
|
||||
}
|
||||
|
||||
if (m_Spatial) {
|
||||
float minDistance = m_MinDistance;
|
||||
float maxDistance = m_MaxDistance;
|
||||
bool changed = ImGui::DragFloat("Min Distance", &minDistance, 0.05f, 0.01f, 1000.0f, "%.2f m");
|
||||
ImGui::SetItemTooltip("Full volume up to this distance");
|
||||
changed |= ImGui::DragFloat("Max Distance", &maxDistance, 0.1f, 0.01f, 10000.0f, "%.2f m");
|
||||
ImGui::SetItemTooltip("The sound stops getting quieter past this distance");
|
||||
if (changed) {
|
||||
SetDistances(minDistance, maxDistance);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::BeginDisabled(m_ClipPath.empty());
|
||||
if (ImGui::Button(IsPlaying() ? "Restart" : "Play")) {
|
||||
Stop();
|
||||
Play();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Stop")) {
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioSource::EnsureSource()
|
||||
{
|
||||
if (m_Source == 0) {
|
||||
m_Source = AudioSystem::GetInstance().CreateSource();
|
||||
}
|
||||
return m_Source != 0;
|
||||
}
|
||||
|
||||
void AudioSource::ApplyProperties()
|
||||
{
|
||||
if (m_Source == 0) {
|
||||
return;
|
||||
}
|
||||
alSourcef(m_Source, AL_GAIN, m_Volume);
|
||||
alSourcef(m_Source, AL_PITCH, m_Pitch);
|
||||
alSourcei(m_Source, AL_LOOPING, m_Loop ? AL_TRUE : AL_FALSE);
|
||||
alSourcei(m_Source, AL_SOURCE_RELATIVE, m_Spatial ? AL_FALSE : AL_TRUE);
|
||||
alSourcei(m_Source, AL_SOURCE_SPATIALIZE_SOFT, m_Spatial ? AL_TRUE : AL_FALSE);
|
||||
alSourcef(m_Source, AL_ROLLOFF_FACTOR, m_Spatial ? 1.0f : 0.0f);
|
||||
alSourcef(m_Source, AL_REFERENCE_DISTANCE, m_MinDistance);
|
||||
alSourcef(m_Source, AL_MAX_DISTANCE, m_MaxDistance);
|
||||
}
|
||||
|
||||
void AudioSource::SyncPosition(float dt)
|
||||
{
|
||||
if (m_Source == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_Spatial) {
|
||||
const glm::vec3 zero{0.0f};
|
||||
alSourcefv(m_Source, AL_POSITION, glm::value_ptr(zero));
|
||||
alSourcefv(m_Source, AL_VELOCITY, glm::value_ptr(zero));
|
||||
m_HasLastPosition = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const glm::vec3 position = GetTransform().GetWorldPosition();
|
||||
m_Velocity = m_HasLastPosition ? SmoothAudioVelocity(position, m_LastPosition, m_Velocity, dt) : glm::vec3{0.0f};
|
||||
m_LastPosition = position;
|
||||
m_HasLastPosition = true;
|
||||
|
||||
alSourcefv(m_Source, AL_POSITION, glm::value_ptr(position));
|
||||
alSourcefv(m_Source, AL_VELOCITY, glm::value_ptr(m_Velocity));
|
||||
}
|
||||
|
||||
void AudioSource::ReleaseSource()
|
||||
{
|
||||
AudioSystem::GetInstance().DestroySource(m_Source);
|
||||
m_Source = 0;
|
||||
m_Clip.reset();
|
||||
}
|
||||
@@ -142,6 +142,22 @@ void AssetFS::Mount(std::string scheme, std::filesystem::path root) {
|
||||
mounts.push_back(std::move(mount));
|
||||
}
|
||||
|
||||
void AssetFS::ReloadManifest(std::string_view scheme) {
|
||||
for (FSMount& mount : mounts) {
|
||||
if (mount.scheme != scheme) {
|
||||
continue;
|
||||
}
|
||||
const std::filesystem::path manifestPath = mount.root / "manifest.json";
|
||||
std::error_code error;
|
||||
mount.manifest.reset();
|
||||
if (std::filesystem::is_regular_file(manifestPath, error)) {
|
||||
mount.manifest = FS::LoadAssetManifest(manifestPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("asset mount not found: " + std::string{scheme});
|
||||
}
|
||||
|
||||
std::vector<uint8_t> AssetFS::ReadBytes(std::string_view vpath) {
|
||||
return ReadFile(GetFullPath(vpath));
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -157,3 +157,27 @@ option(TRACY_ON_DEMAND "" ON)
|
||||
|
||||
add_subdirectory(tracy)
|
||||
|
||||
|
||||
# OpenAL Soft is LGPL, so it stays a DLL next to the exe instead of being linked in
|
||||
set(LIBTYPE SHARED CACHE STRING "" FORCE)
|
||||
set(ALSOFT_UTILS OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL_CONFIG OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL_HRTF_DATA OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL_AMBDEC_PRESETS OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_INSTALL_UTILS OFF CACHE BOOL "" FORCE)
|
||||
set(ALSOFT_EAX OFF CACHE BOOL "" FORCE)
|
||||
add_subdirectory(openal-soft)
|
||||
|
||||
add_library(stb_vorbis STATIC "${CMAKE_CURRENT_LIST_DIR}/stb/src/stb_vorbis.c")
|
||||
target_include_directories(stb_vorbis PUBLIC "${CMAKE_CURRENT_LIST_DIR}/stb/src")
|
||||
target_compile_definitions(stb_vorbis PRIVATE _CRT_SECURE_NO_WARNINGS)
|
||||
if (MSVC)
|
||||
target_compile_options(stb_vorbis PRIVATE /W0)
|
||||
else ()
|
||||
target_compile_options(stb_vorbis PRIVATE -w)
|
||||
endif ()
|
||||
add_library(stb::vorbis ALIAS stb_vorbis)
|
||||
|
||||
+1
Submodule destrum/third_party/openal-soft added at b2c48f7718
+5584
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
#define EDITOR_ASSETBROWSER_H
|
||||
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -10,6 +11,8 @@ class AssetBrowser final {
|
||||
public:
|
||||
std::optional<std::string> Draw(bool* open = nullptr);
|
||||
|
||||
void ConfigureCooking(std::filesystem::path cooker, std::vector<std::string> schemes);
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::string name;
|
||||
@@ -17,6 +20,8 @@ private:
|
||||
};
|
||||
|
||||
void Refresh();
|
||||
void StartCook();
|
||||
void PollCook();
|
||||
void Navigate(const std::string& scheme, const std::filesystem::path& folder);
|
||||
[[nodiscard]] std::string VirtualPath(const std::string& name) const;
|
||||
void DrawEntry(const Entry& entry, std::optional<std::string>& opened, bool& navigated);
|
||||
@@ -27,6 +32,11 @@ private:
|
||||
std::vector<Entry> m_Entries;
|
||||
std::string m_Filter;
|
||||
bool m_Dirty{true};
|
||||
|
||||
std::filesystem::path m_Cooker;
|
||||
std::vector<std::string> m_CookSchemes;
|
||||
std::future<bool> m_Cook;
|
||||
bool m_AppFocused{true};
|
||||
};
|
||||
|
||||
#endif // EDITOR_ASSETBROWSER_H
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <utility>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include <destrum/Assets/AssetDatabase.h>
|
||||
#include <destrum/Assets/AssetTypes.h>
|
||||
#include <destrum/FS/AssetFS.h>
|
||||
#include <destrum/Graphics/Material.h>
|
||||
@@ -42,13 +48,29 @@ namespace {
|
||||
if (AssetTypes::IsPrefab(name)) return "[prefab] ";
|
||||
if (AssetTypes::IsMaterial(name)) return "[material] ";
|
||||
if (AssetTypes::IsImage(name)) return "[image] ";
|
||||
if (AssetTypes::IsAudio(name)) return "[audio] ";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
void AssetBrowser::ConfigureCooking(std::filesystem::path cooker, std::vector<std::string> schemes)
|
||||
{
|
||||
m_Cooker = std::move(cooker);
|
||||
m_CookSchemes = std::move(schemes);
|
||||
}
|
||||
|
||||
std::optional<std::string> AssetBrowser::Draw(bool* open)
|
||||
{
|
||||
std::optional<std::string> opened;
|
||||
|
||||
// Files dropped into assets_src from Explorer show up when the user comes back to the editor
|
||||
const bool appFocused = !ImGui::GetIO().AppFocusLost;
|
||||
if (appFocused && !m_AppFocused) {
|
||||
StartCook();
|
||||
}
|
||||
m_AppFocused = appFocused;
|
||||
PollCook();
|
||||
|
||||
if (!ImGui::Begin("Assets", open)) {
|
||||
ImGui::End();
|
||||
return opened;
|
||||
@@ -70,9 +92,14 @@ std::optional<std::string> AssetBrowser::Draw(bool* open)
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (ImGui::Button("Refresh")) {
|
||||
const bool cooking = m_Cook.valid();
|
||||
ImGui::BeginDisabled(cooking);
|
||||
if (ImGui::Button(cooking ? "Cooking..." : "Refresh")) {
|
||||
m_Dirty = true;
|
||||
StartCook();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SetItemTooltip("Cooks new and changed files from assets_src, then lists the folder again");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGuiUtils::InputString("##Filter", m_Filter, 0, "Search this folder");
|
||||
@@ -172,6 +199,55 @@ void AssetBrowser::Navigate(const std::string& scheme, const std::filesystem::pa
|
||||
m_Dirty = true;
|
||||
}
|
||||
|
||||
void AssetBrowser::StartCook()
|
||||
{
|
||||
if (m_Cooker.empty() || m_Cook.valid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<std::string> commands;
|
||||
for (const std::string& scheme : m_CookSchemes) {
|
||||
const FSMount* mount = FindMount(scheme);
|
||||
if (mount == nullptr || !mount->sourceRoot) {
|
||||
continue;
|
||||
}
|
||||
// cmd.exe strips the outer quotes, the inner ones keep paths with spaces intact
|
||||
commands.push_back("\"\"" + m_Cooker.string() + "\" --input \"" + mount->sourceRoot->string() +
|
||||
"\" --output \"" + mount->root.string() + "\"\"");
|
||||
}
|
||||
if (commands.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_Cook = std::async(std::launch::async, [commands = std::move(commands)] {
|
||||
bool succeeded = true;
|
||||
for (const std::string& command : commands) {
|
||||
succeeded = std::system(command.c_str()) == 0 && succeeded;
|
||||
}
|
||||
return succeeded;
|
||||
});
|
||||
}
|
||||
|
||||
void AssetBrowser::PollCook()
|
||||
{
|
||||
if (!m_Cook.valid() || m_Cook.wait_for(std::chrono::seconds{0}) != std::future_status::ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_Cook.get()) {
|
||||
spdlog::error("Cooking the assets failed, see the log above");
|
||||
}
|
||||
try {
|
||||
for (const std::string& scheme : m_CookSchemes) {
|
||||
AssetFS::GetInstance().ReloadManifest(scheme);
|
||||
}
|
||||
AssetDatabase::GetInstance().Refresh();
|
||||
} catch (const std::exception& exception) {
|
||||
spdlog::error("Could not reload the asset manifest: {}", exception.what());
|
||||
}
|
||||
m_Dirty = true;
|
||||
}
|
||||
|
||||
void AssetBrowser::Refresh()
|
||||
{
|
||||
m_Dirty = false;
|
||||
|
||||
@@ -53,6 +53,10 @@ void Editor::onAttach()
|
||||
#if defined(DESTRUM_ENGINE_ASSETS_SRC_DIR) && defined(DESTRUM_THECHEF_PATH)
|
||||
m_ShaderHotReload.Configure(DESTRUM_THECHEF_PATH, DESTRUM_ENGINE_ASSETS_SRC_DIR, assetFs.GetFullPath("engine://shaders").parent_path());
|
||||
#endif
|
||||
#ifdef DESTRUM_THECHEF_PATH
|
||||
// Engine assets are left to the shader hot reload, two cooks writing one manifest would race
|
||||
m_AssetBrowser.ConfigureCooking(DESTRUM_THECHEF_PATH, {"game"});
|
||||
#endif
|
||||
|
||||
if (m_Settings.startInEditMode) {
|
||||
m_PlayMode.EnterEditMode();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <destrum/App.h>
|
||||
#include <destrum/Assets/AssetTypes.h>
|
||||
#include <destrum/Components/AudioSource.h>
|
||||
#include <destrum/Components/MeshRendererComponent.h>
|
||||
#include <destrum/Components/Physics/Collider.h>
|
||||
#include <destrum/Components/Physics/Rigidbody.h>
|
||||
@@ -179,6 +180,11 @@ void Editor::OnAssetDropped(std::string_view path)
|
||||
}
|
||||
instance->GetTransform().SetWorldPosition(spawnPosition);
|
||||
scene->SetSelectedObject(instance->GetId());
|
||||
} else if (AssetTypes::IsAudio(path)) {
|
||||
GameObject* object = scene->CreateGameObject(name);
|
||||
object->AddComponent<AudioSource>(std::string{path});
|
||||
object->GetTransform().SetWorldPosition(spawnPosition);
|
||||
scene->SetSelectedObject(object->GetId());
|
||||
} else if (AssetTypes::IsMaterial(path)) {
|
||||
GameObject* selected = scene->GetSelectedObject();
|
||||
if (selected == nullptr) {
|
||||
|
||||
@@ -96,6 +96,7 @@ add_custom_target(_internal_cook_game_assets
|
||||
add_dependencies(lightkeeper _internal_cook_game_assets)
|
||||
|
||||
destrum_cook_engine_assets(lightkeeper "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
destrum_copy_runtime_dlls(lightkeeper)
|
||||
|
||||
install(TARGETS lightkeeper RUNTIME DESTINATION bin)
|
||||
install(DIRECTORY "${ASSETS_RUNTIME_DIR}/" DESTINATION "bin/assets/game")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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