From 8f434d8fd0a57b5cb767373d4e991e4c29c54ffc Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Fri, 19 Dec 2025 17:21:23 +0000 Subject: [PATCH] lua audio support --- CMakeLists.txt | 3 +- scripts/cube_logic.lua | 26 +++++++ src/app/audio_player.cpp | 151 +++++++++++++++++++++++++++---------- src/app/audio_player.hpp | 29 +++++-- src/app/sdl3_app_core.cpp | 3 +- src/script/cube_script.cpp | 102 ++++++++++++++++++++++++- src/script/cube_script.hpp | 22 ++++++ 7 files changed, 287 insertions(+), 49 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a0c5ad1..0ec2cfd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,7 +109,8 @@ enable_testing() add_executable(cube_script_tests tests/test_cube_script.cpp src/script/cube_script.cpp + src/app/audio_player.cpp ) target_include_directories(cube_script_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") -target_link_libraries(cube_script_tests PRIVATE lua::lua assimp::assimp Bullet::Bullet glm::glm Vorbis::vorbisfile Vorbis::vorbis) +target_link_libraries(cube_script_tests PRIVATE sdl::sdl lua::lua assimp::assimp Bullet::Bullet glm::glm Vorbis::vorbisfile Vorbis::vorbis) add_test(NAME cube_script_tests COMMAND cube_script_tests) diff --git a/scripts/cube_logic.lua b/scripts/cube_logic.lua index f3a055c..b9de6aa 100644 --- a/scripts/cube_logic.lua +++ b/scripts/cube_logic.lua @@ -53,6 +53,14 @@ end load_cube_mesh() +local function init_audio() + if type(audio_play_background) == "function" then + audio_play_background("modmusic.ogg", true) + end +end + +init_audio() + if not cube_mesh_info.loaded then error("Unable to load cube mesh: " .. (cube_mesh_info.error or "unknown")) end @@ -188,6 +196,23 @@ local camera = { far = 10.0, } +local effect_key = "space" +local effect_active = false + +local function update_audio_controls() + if type(audio_play_sound) ~= "function" then + return + end + if gui_input.keyStates[effect_key] then + if not effect_active then + audio_play_sound("modmusic.ogg", false) + effect_active = true + end + else + effect_active = false + end +end + local zoom_settings = { min_distance = 2.0, max_distance = 12.0, @@ -301,6 +326,7 @@ function get_view_projection(aspect) if gui_input then update_camera_zoom(gui_input.wheel) end + update_audio_controls() local view = math3d.look_at(camera.eye, camera.center, camera.up) local projection = math3d.perspective(camera.fov, aspect, camera.near, camera.far) return math3d.multiply(projection, view) diff --git a/src/app/audio_player.cpp b/src/app/audio_player.cpp index 8f3e2b8..4f92539 100644 --- a/src/app/audio_player.cpp +++ b/src/app/audio_player.cpp @@ -7,13 +7,21 @@ #include #include #include +#include +#include #include namespace sdl3cpp::app { namespace { -std::vector DecodeOgg(const std::filesystem::path& path, int& rate, int& channels) { +struct DecodedAudio { + std::vector samples; + int sampleRate = 0; + int channels = 0; +}; + +DecodedAudio DecodeOgg(const std::filesystem::path& path) { FILE* file = std::fopen(path.string().c_str(), "rb"); if (!file) { throw std::runtime_error("Failed to open audio file: " + path.string()); @@ -30,8 +38,8 @@ std::vector DecodeOgg(const std::filesystem::path& path, int& rate, int ov_clear(&oggFile); throw std::runtime_error("Audio metadata is missing"); } - channels = info->channels; - rate = static_cast(info->rate); + int channels = info->channels; + int rate = static_cast(info->rate); std::vector decoded; decoded.reserve(static_cast(ov_pcm_total(&oggFile, -1)) * channels); @@ -58,21 +66,96 @@ std::vector DecodeOgg(const std::filesystem::path& path, int& rate, int if (decoded.empty()) { throw std::runtime_error("Decoded audio is empty"); } - return decoded; + + return DecodedAudio{std::move(decoded), rate, channels}; } } // namespace -AudioPlayer::AudioPlayer(const std::filesystem::path& oggPath) { - int sampleRate = 0; - int channelCount = 0; - buffer_ = DecodeOgg(oggPath, sampleRate, channelCount); - bufferSizeBytes_ = buffer_.size() * sizeof(int16_t); +AudioPlayer::AudioPlayer() = default; + +AudioPlayer::~AudioPlayer() { + if (stream_) { + SDL_PauseAudioStreamDevice(stream_); + SDL_DestroyAudioStream(stream_); + } +} + +void AudioPlayer::PlayBackground(const std::filesystem::path& path, bool loop) { + DecodedAudio clip = DecodeOgg(path); + EnsureStream(clip.sampleRate, clip.channels); + std::scoped_lock lock(voicesMutex_); + backgroundVoice_ = AudioVoice{std::move(clip.samples), 0, loop, true}; +} + +void AudioPlayer::PlayEffect(const std::filesystem::path& path, bool loop) { + DecodedAudio clip = DecodeOgg(path); + EnsureStream(clip.sampleRate, clip.channels); + std::scoped_lock lock(voicesMutex_); + effectVoices_.push_back(AudioVoice{std::move(clip.samples), 0, loop, true}); +} + +void AudioPlayer::AudioStreamCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount) { + auto* self = static_cast(userdata); + self->FeedStream(stream, totalAmount); +} + +void AudioPlayer::FeedStream(SDL_AudioStream* stream, int totalAmount) { + if (totalAmount <= 0 || !stream_) { + return; + } + size_t sampleCount = static_cast(totalAmount) / sizeof(int16_t); + if (sampleCount == 0) { + return; + } + mixBuffer_.assign(sampleCount, 0); + + std::scoped_lock lock(voicesMutex_); + if (backgroundVoice_ && backgroundVoice_->active) { + AddVoiceSamples(*backgroundVoice_, mixBuffer_, sampleCount); + if (!backgroundVoice_->active) { + backgroundVoice_.reset(); + } + } + + for (auto it = effectVoices_.begin(); it != effectVoices_.end();) { + AddVoiceSamples(*it, mixBuffer_, sampleCount); + if (!it->active) { + it = effectVoices_.erase(it); + } else { + ++it; + } + } + + outputBuffer_.resize(sampleCount); + for (size_t i = 0; i < sampleCount; ++i) { + int32_t value = mixBuffer_[i]; + if (value > std::numeric_limits::max()) { + value = std::numeric_limits::max(); + } else if (value < std::numeric_limits::min()) { + value = std::numeric_limits::min(); + } + outputBuffer_[i] = static_cast(value); + } + + SDL_PutAudioStreamData(stream, outputBuffer_.data(), static_cast(sampleCount * sizeof(int16_t))); +} + +void AudioPlayer::EnsureStream(int sampleRate, int channels) { + if (sampleRate <= 0 || channels <= 0) { + throw std::runtime_error("Audio format is invalid"); + } + if (sampleRate_ != 0 && (sampleRate != sampleRate_ || channels != channels_)) { + throw std::runtime_error("Requested audio format does not match initialized stream"); + } + if (stream_) { + return; + } SDL_AudioSpec desired{}; desired.freq = sampleRate; desired.format = SDL_AUDIO_S16; - desired.channels = channelCount; + desired.channels = channels; stream_ = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired, &AudioPlayer::AudioStreamCallback, this); if (!stream_) { @@ -83,43 +166,29 @@ AudioPlayer::AudioPlayer(const std::filesystem::path& oggPath) { stream_ = nullptr; throw std::runtime_error("Failed to resume audio stream device: " + std::string(SDL_GetError())); } + + sampleRate_ = sampleRate; + channels_ = channels; } -AudioPlayer::~AudioPlayer() { - if (stream_) { - SDL_PauseAudioStreamDevice(stream_); - SDL_DestroyAudioStream(stream_); - } -} - -void AudioPlayer::AudioStreamCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount) { - auto* self = static_cast(userdata); - self->FeedStream(stream, totalAmount); -} - -void AudioPlayer::FeedStream(SDL_AudioStream* stream, int totalAmount) { - if (totalAmount <= 0 || bufferSizeBytes_ == 0) { +void AudioPlayer::AddVoiceSamples(AudioVoice& voice, std::vector& mixBuffer, size_t sampleCount) { + if (voice.data.empty()) { + voice.active = false; return; } - const auto* source = reinterpret_cast(buffer_.data()); - int remaining = totalAmount; - while (remaining > 0) { - if (positionBytes_ >= bufferSizeBytes_) { - positionBytes_ = 0; + size_t idx = voice.position; + for (size_t sampleIndex = 0; sampleIndex < sampleCount; ++sampleIndex) { + if (idx >= voice.data.size()) { + if (voice.loop) { + idx = 0; + } else { + voice.active = false; + break; + } } - size_t available = bufferSizeBytes_ - positionBytes_; - if (available == 0) { - positionBytes_ = 0; - continue; - } - size_t chunk = std::min(available, static_cast(remaining)); - int queued = SDL_PutAudioStreamData(stream, source + positionBytes_, static_cast(chunk)); - if (queued <= 0) { - break; - } - positionBytes_ += static_cast(queued); - remaining -= queued; + mixBuffer[sampleIndex] += static_cast(voice.data[idx++]); } + voice.position = idx; } } // namespace sdl3cpp::app diff --git a/src/app/audio_player.hpp b/src/app/audio_player.hpp index 2b3eed8..0504932 100644 --- a/src/app/audio_player.hpp +++ b/src/app/audio_player.hpp @@ -3,7 +3,8 @@ #include #include -#include +#include +#include #include #include @@ -12,20 +13,38 @@ namespace sdl3cpp::app { class AudioPlayer { public: - explicit AudioPlayer(const std::filesystem::path& oggPath); + AudioPlayer(); ~AudioPlayer(); AudioPlayer(const AudioPlayer&) = delete; AudioPlayer& operator=(const AudioPlayer&) = delete; + void PlayBackground(const std::filesystem::path& path, bool loop = true); + void PlayEffect(const std::filesystem::path& path, bool loop = false); + private: + struct AudioVoice { + std::vector data; + size_t position = 0; + bool loop = false; + bool active = true; + }; + static void AudioStreamCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount); void FeedStream(SDL_AudioStream* stream, int totalAmount); + void EnsureStream(int sampleRate, int channels); + void AddVoiceSamples(AudioVoice& voice, std::vector& mixBuffer, size_t sampleCount); SDL_AudioStream* stream_ = nullptr; - std::vector buffer_; - size_t positionBytes_ = 0; - size_t bufferSizeBytes_ = 0; + int sampleRate_ = 0; + int channels_ = 0; + + std::optional backgroundVoice_; + std::vector effectVoices_; + + std::mutex voicesMutex_; + std::vector mixBuffer_; + std::vector outputBuffer_; }; } // namespace sdl3cpp::app diff --git a/src/app/sdl3_app_core.cpp b/src/app/sdl3_app_core.cpp index 8bc12ad..5a46d7e 100644 --- a/src/app/sdl3_app_core.cpp +++ b/src/app/sdl3_app_core.cpp @@ -111,7 +111,8 @@ void Sdl3App::InitSDL() { TRACE_VAR(window_); SDL_StartTextInput(window_); try { - audioPlayer_ = std::make_unique(scriptDirectory_ / "modmusic.ogg"); + audioPlayer_ = std::make_unique(); + cubeScript_.SetAudioPlayer(audioPlayer_.get()); } catch (const std::exception& exc) { std::cerr << "AudioPlayer: " << exc.what() << '\n'; } diff --git a/src/script/cube_script.cpp b/src/script/cube_script.cpp index 09deb10..459ee1c 100644 --- a/src/script/cube_script.cpp +++ b/src/script/cube_script.cpp @@ -1,4 +1,5 @@ #include "script/cube_script.hpp" +#include "app/audio_player.hpp" #include #include @@ -12,6 +13,7 @@ #include #include +#include #include #include #include @@ -426,11 +428,45 @@ int LuaPhysicsGetTransform(lua_State* L) { lua_rawseti(L, -2, 3); lua_pushnumber(L, orientation.w()); lua_rawseti(L, -2, 4); - lua_setfield(L, -2, "rotation"); + lua_setfield(L, -2, "rotation"); return 1; } +int LuaAudioPlayBackground(lua_State* L) { + auto* script = static_cast(lua_touserdata(L, lua_upvalueindex(1))); + const char* path = luaL_checkstring(L, 1); + bool loop = true; + if (lua_gettop(L) >= 2 && lua_isboolean(L, 2)) { + loop = lua_toboolean(L, 2); + } + std::string error; + if (!script->QueueAudioCommand(CubeScript::AudioCommandType::Background, path, loop, error)) { + lua_pushnil(L); + lua_pushstring(L, error.c_str()); + return 2; + } + lua_pushboolean(L, 1); + return 1; +} + +int LuaAudioPlaySound(lua_State* L) { + auto* script = static_cast(lua_touserdata(L, lua_upvalueindex(1))); + const char* path = luaL_checkstring(L, 1); + bool loop = false; + if (lua_gettop(L) >= 2 && lua_isboolean(L, 2)) { + loop = lua_toboolean(L, 2); + } + std::string error; + if (!script->QueueAudioCommand(CubeScript::AudioCommandType::Effect, path, loop, error)) { + lua_pushnil(L); + lua_pushstring(L, error.c_str()); + return 2; + } + lua_pushboolean(L, 1); + return 1; +} + int LuaGlmMatrixFromTransform(lua_State* L) { std::array translation = detail::ReadVector3(L, 1); std::array rotation = detail::ReadQuaternion(L, 2); @@ -474,6 +510,12 @@ CubeScript::CubeScript(const std::filesystem::path& scriptPath, bool debugEnable lua_pushlightuserdata(L_, this); lua_pushcclosure(L_, &LuaGlmMatrixFromTransform, 1); lua_setglobal(L_, "glm_matrix_from_transform"); + lua_pushlightuserdata(L_, this); + lua_pushcclosure(L_, &LuaAudioPlayBackground, 1); + lua_setglobal(L_, "audio_play_background"); + lua_pushlightuserdata(L_, this); + lua_pushcclosure(L_, &LuaAudioPlaySound, 1); + lua_setglobal(L_, "audio_play_sound"); lua_pushboolean(L_, debugEnabled_); lua_setglobal(L_, "lua_debug"); auto scriptDir = scriptPath.parent_path(); @@ -969,4 +1011,62 @@ bool CubeScript::ReadStringField(lua_State* L, int index, const char* name, std: return false; } +void CubeScript::SetAudioPlayer(app::AudioPlayer* audioPlayer) { + audioPlayer_ = audioPlayer; + if (!audioPlayer_) { + return; + } + for (const auto& command : pendingAudioCommands_) { + try { + ExecuteAudioCommand(audioPlayer_, command); + } catch (const std::exception& exc) { + std::cerr << "AudioPlayer: " << exc.what() << '\n'; + } + } + pendingAudioCommands_.clear(); +} + +bool CubeScript::QueueAudioCommand(AudioCommandType type, std::string path, bool loop, std::string& error) { + if (audioPlayer_) { + try { + AudioCommand command{type, std::move(path), loop}; + ExecuteAudioCommand(audioPlayer_, command); + return true; + } catch (const std::exception& exc) { + error = exc.what(); + return false; + } + } + pendingAudioCommands_.push_back(AudioCommand{type, std::move(path), loop}); + return true; +} + +void CubeScript::ExecuteAudioCommand(app::AudioPlayer* player, const AudioCommand& command) { + auto resolved = ResolveScriptPath(command.path); + if (!std::filesystem::exists(resolved)) { + throw std::runtime_error("Audio file not found: " + resolved.string()); + } + switch (command.type) { + case AudioCommandType::Background: + player->PlayBackground(resolved, command.loop); + break; + case AudioCommandType::Effect: + player->PlayEffect(resolved, command.loop); + break; + } +} + +std::filesystem::path CubeScript::ResolveScriptPath(const std::string& requested) const { + std::filesystem::path resolved(requested); + if (!resolved.is_absolute()) { + resolved = scriptDirectory_ / resolved; + } + std::error_code ec; + auto canonical = std::filesystem::weakly_canonical(resolved, ec); + if (!ec) { + resolved = canonical; + } + return resolved; +} + } // namespace sdl3cpp::script diff --git a/src/script/cube_script.hpp b/src/script/cube_script.hpp index b4b5785..57932f9 100644 --- a/src/script/cube_script.hpp +++ b/src/script/cube_script.hpp @@ -12,6 +12,10 @@ #include "core/vertex.hpp" +namespace sdl3cpp::app { +class AudioPlayer; +} + namespace sdl3cpp::script { struct PhysicsBridge; @@ -86,6 +90,11 @@ public: std::string shaderKey = "default"; }; + enum class AudioCommandType { + Background, + Effect, + }; + std::vector LoadSceneObjects(); std::array ComputeModelMatrix(int functionRef, float time); std::array GetViewProjectionMatrix(float aspect); @@ -95,8 +104,19 @@ public: bool HasGuiCommands() const; std::filesystem::path GetScriptDirectory() const; PhysicsBridge& GetPhysicsBridge(); + void SetAudioPlayer(app::AudioPlayer* audioPlayer); + bool QueueAudioCommand(AudioCommandType type, std::string path, bool loop, std::string& error); private: + struct AudioCommand { + AudioCommandType type = AudioCommandType::Background; + std::string path; + bool loop = false; + }; + + void ExecuteAudioCommand(app::AudioPlayer* player, const AudioCommand& command); + std::filesystem::path ResolveScriptPath(const std::string& requested) const; + static std::vector ReadVertexArray(lua_State* L, int index); static std::vector ReadIndexArray(lua_State* L, int index); static std::string LuaErrorMessage(lua_State* L); @@ -111,6 +131,8 @@ private: std::filesystem::path scriptDirectory_; bool debugEnabled_ = false; std::unique_ptr physicsBridge_; + app::AudioPlayer* audioPlayer_ = nullptr; + std::vector pendingAudioCommands_; }; } // namespace sdl3cpp::script