diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fd53d1..a0c5ad1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,12 +78,14 @@ find_package(CLI11 CONFIG REQUIRED) find_package(rapidjson CONFIG REQUIRED) find_package(assimp CONFIG REQUIRED) find_package(Bullet CONFIG REQUIRED) +find_package(Vorbis CONFIG REQUIRED) find_package(glm CONFIG REQUIRED) if(BUILD_SDL3_APP) add_executable(sdl3_app src/main.cpp src/app/sdl3_app_core.cpp + src/app/audio_player.cpp src/app/sdl3_app_device.cpp src/app/sdl3_app_swapchain.cpp src/app/sdl3_app_pipeline.cpp @@ -95,7 +97,7 @@ add_executable(sdl3_app src/script/cube_script.cpp ) target_include_directories(sdl3_app PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") - target_link_libraries(sdl3_app PRIVATE sdl::sdl Vulkan::Vulkan lua::lua CLI11::CLI11 rapidjson assimp::assimp Bullet::Bullet glm::glm) + target_link_libraries(sdl3_app PRIVATE sdl::sdl Vulkan::Vulkan lua::lua CLI11::CLI11 rapidjson assimp::assimp Bullet::Bullet glm::glm Vorbis::vorbisfile Vorbis::vorbis) target_compile_definitions(sdl3_app PRIVATE SDL_MAIN_HANDLED) file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/shaders" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") @@ -109,5 +111,5 @@ add_executable(cube_script_tests src/script/cube_script.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) +target_link_libraries(cube_script_tests PRIVATE 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/conanfile.py b/conanfile.py index 7632ea3..fa2b460 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,3 +28,4 @@ class SDL3CppConan(ConanFile): self.requires("box2d/3.1.1") self.requires("assimp/6.0.2") self.requires("glm/1.0.1") + self.requires("vorbis/1.3.7") diff --git a/scripts/convert_mod_to_ogg.py b/scripts/convert_mod_to_ogg.py new file mode 100644 index 0000000..9c92cee --- /dev/null +++ b/scripts/convert_mod_to_ogg.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Convert the bundled XM tracker file to an OGG so the demo can play music.""" + +from __future__ import annotations + +import argparse +import shlex +import subprocess +from pathlib import Path + +import imageio_ffmpeg + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Convert scripts/modmusic.xm into OGG.") + parser.add_argument( + "--input", + type=Path, + default=Path(__file__).parent / "modmusic.xm", + help="Tracker file to render (default: scripts/modmusic.xm).", + ) + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).parent / "modmusic.ogg", + help="Path for the rendered OGG (default next to scripts/modmusic.xm).", + ) + parser.add_argument( + "--bitrate", + default="192k", + help="FFmpeg audio bitrate (default: 192k).", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if not args.input.exists(): + raise SystemExit(f"Error: XM source {args.input} is missing") + + args.output.parent.mkdir(parents=True, exist_ok=True) + ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe() + + ffmpeg_cmd = [ + ffmpeg_path, + "-y", + "-i", + str(args.input), + "-b:a", + args.bitrate, + str(args.output), + ] + + print("Executing:", " ".join(shlex.quote(arg) for arg in ffmpeg_cmd)) + subprocess.run(ffmpeg_cmd, check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/modmusic.ogg b/scripts/modmusic.ogg new file mode 100644 index 0000000..e119706 Binary files /dev/null and b/scripts/modmusic.ogg differ diff --git a/src/app/audio_player.cpp b/src/app/audio_player.cpp new file mode 100644 index 0000000..8f3e2b8 --- /dev/null +++ b/src/app/audio_player.cpp @@ -0,0 +1,125 @@ +#include "app/audio_player.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace sdl3cpp::app { + +namespace { + +std::vector DecodeOgg(const std::filesystem::path& path, int& rate, int& channels) { + FILE* file = std::fopen(path.string().c_str(), "rb"); + if (!file) { + throw std::runtime_error("Failed to open audio file: " + path.string()); + } + + OggVorbis_File oggFile{}; + if (ov_open(file, &oggFile, nullptr, 0) < 0) { + std::fclose(file); + throw std::runtime_error("Failed to open OGG stream: " + path.string()); + } + + vorbis_info* info = ov_info(&oggFile, -1); + if (!info) { + ov_clear(&oggFile); + throw std::runtime_error("Audio metadata is missing"); + } + channels = info->channels; + rate = static_cast(info->rate); + + std::vector decoded; + decoded.reserve(static_cast(ov_pcm_total(&oggFile, -1)) * channels); + int bitstream = 0; + constexpr size_t kChunkBytes = 4096 * sizeof(int16_t); + std::vector chunk(kChunkBytes); + + while (true) { + long bytesRead = ov_read(&oggFile, chunk.data(), static_cast(chunk.size()), 0, 2, 1, &bitstream); + if (bytesRead == 0) { + break; + } + if (bytesRead < 0) { + ov_clear(&oggFile); + throw std::runtime_error("Error decoding OGG stream"); + } + size_t samples = static_cast(bytesRead) / sizeof(int16_t); + size_t oldSize = decoded.size(); + decoded.resize(oldSize + samples); + std::memcpy(decoded.data() + oldSize, chunk.data(), samples * sizeof(int16_t)); + } + + ov_clear(&oggFile); + if (decoded.empty()) { + throw std::runtime_error("Decoded audio is empty"); + } + return decoded; +} + +} // 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); + + SDL_AudioSpec desired{}; + desired.freq = sampleRate; + desired.format = SDL_AUDIO_S16; + desired.channels = channelCount; + + stream_ = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired, &AudioPlayer::AudioStreamCallback, this); + if (!stream_) { + throw std::runtime_error("Failed to open audio stream: " + std::string(SDL_GetError())); + } + if (!SDL_ResumeAudioStreamDevice(stream_)) { + SDL_DestroyAudioStream(stream_); + stream_ = nullptr; + throw std::runtime_error("Failed to resume audio stream device: " + std::string(SDL_GetError())); + } +} + +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) { + return; + } + const auto* source = reinterpret_cast(buffer_.data()); + int remaining = totalAmount; + while (remaining > 0) { + if (positionBytes_ >= bufferSizeBytes_) { + positionBytes_ = 0; + } + 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; + } +} + +} // namespace sdl3cpp::app diff --git a/src/app/audio_player.hpp b/src/app/audio_player.hpp new file mode 100644 index 0000000..2b3eed8 --- /dev/null +++ b/src/app/audio_player.hpp @@ -0,0 +1,33 @@ +#ifndef SDL3CPP_APP_AUDIO_PLAYER_HPP +#define SDL3CPP_APP_AUDIO_PLAYER_HPP + +#include +#include +#include +#include + +#include + +namespace sdl3cpp::app { + +class AudioPlayer { +public: + explicit AudioPlayer(const std::filesystem::path& oggPath); + ~AudioPlayer(); + + AudioPlayer(const AudioPlayer&) = delete; + AudioPlayer& operator=(const AudioPlayer&) = delete; + +private: + static void AudioStreamCallback(void* userdata, SDL_AudioStream* stream, int additionalAmount, int totalAmount); + void FeedStream(SDL_AudioStream* stream, int totalAmount); + + SDL_AudioStream* stream_ = nullptr; + std::vector buffer_; + size_t positionBytes_ = 0; + size_t bufferSizeBytes_ = 0; +}; + +} // namespace sdl3cpp::app + +#endif // SDL3CPP_APP_AUDIO_PLAYER_HPP diff --git a/src/app/sdl3_app.hpp b/src/app/sdl3_app.hpp index d314b3c..5dc2b7a 100644 --- a/src/app/sdl3_app.hpp +++ b/src/app/sdl3_app.hpp @@ -18,10 +18,15 @@ #include #include +#include "app/audio_player.hpp" #include "core/vertex.hpp" #include "script/cube_script.hpp" #include "gui/gui_renderer.hpp" +namespace sdl3cpp::app { +class AudioPlayer; +} + namespace sdl3cpp::app { namespace script = sdl3cpp::script; @@ -135,6 +140,8 @@ private: std::unique_ptr guiRenderer_; bool guiHasCommands_ = false; std::vector renderObjects_; + std::filesystem::path scriptDirectory_; + std::unique_ptr audioPlayer_; }; } // namespace sdl3cpp::app diff --git a/src/app/sdl3_app_core.cpp b/src/app/sdl3_app_core.cpp index 1d0f22d..8bc12ad 100644 --- a/src/app/sdl3_app_core.cpp +++ b/src/app/sdl3_app_core.cpp @@ -1,8 +1,10 @@ +#include "app/audio_player.hpp" #include "app/sdl3_app.hpp" #include "app/trace.hpp" #include #include +#include #include #include @@ -82,7 +84,8 @@ void ThrowSdlErrorIfFailed(bool success, const char* context) { } // namespace Sdl3App::Sdl3App(const std::filesystem::path& scriptPath, bool luaDebug) - : cubeScript_(scriptPath, luaDebug) { + : cubeScript_(scriptPath, luaDebug), + scriptDirectory_(scriptPath.parent_path()) { TRACE_FUNCTION(); TRACE_VAR(scriptPath); } @@ -99,7 +102,7 @@ void Sdl3App::InitSDL() { TRACE_FUNCTION(); TRACE_VAR(kWidth); TRACE_VAR(kHeight); - ThrowSdlErrorIfFailed(SDL_Init(SDL_INIT_VIDEO), "SDL_Init failed"); + ThrowSdlErrorIfFailed(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO), "SDL_Init failed"); ThrowSdlErrorIfFailed(SDL_Vulkan_LoadLibrary(nullptr), "SDL_Vulkan_LoadLibrary failed"); window_ = SDL_CreateWindow("SDL3 Vulkan Demo", kWidth, kHeight, SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE); if (!window_) { @@ -107,6 +110,11 @@ void Sdl3App::InitSDL() { } TRACE_VAR(window_); SDL_StartTextInput(window_); + try { + audioPlayer_ = std::make_unique(scriptDirectory_ / "modmusic.ogg"); + } catch (const std::exception& exc) { + std::cerr << "AudioPlayer: " << exc.what() << '\n'; + } } void Sdl3App::InitVulkan() { @@ -188,6 +196,7 @@ void Sdl3App::Cleanup() { } SDL_Vulkan_UnloadLibrary(); SDL_StopTextInput(window_); + audioPlayer_.reset(); SDL_Quit(); } diff --git a/src/script/cube_script.cpp b/src/script/cube_script.cpp index 5bc0f6f..09deb10 100644 --- a/src/script/cube_script.cpp +++ b/src/script/cube_script.cpp @@ -22,8 +22,6 @@ namespace sdl3cpp::script { -namespace { - struct PhysicsBridge { struct BodyRecord { std::unique_ptr shape; @@ -137,6 +135,69 @@ bool PhysicsBridge::getRigidBodyTransform(const std::string& name, return true; } +namespace detail { + +std::array ReadVector3(lua_State* L, int index) { + std::array result{}; + int absIndex = lua_absindex(L, index); + size_t len = lua_rawlen(L, absIndex); + if (len != 3) { + throw std::runtime_error("Expected vector with 3 components"); + } + for (size_t i = 1; i <= 3; ++i) { + lua_rawgeti(L, absIndex, static_cast(i)); + if (!lua_isnumber(L, -1)) { + lua_pop(L, 1); + throw std::runtime_error("Vector component is not a number"); + } + result[i - 1] = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + return result; +} + +std::array ReadMatrix(lua_State* L, int index) { + std::array result{}; + int absIndex = lua_absindex(L, index); + size_t len = lua_rawlen(L, absIndex); + if (len != 16) { + throw std::runtime_error("Expected 4x4 matrix with 16 components"); + } + for (size_t i = 1; i <= 16; ++i) { + lua_rawgeti(L, absIndex, static_cast(i)); + if (!lua_isnumber(L, -1)) { + lua_pop(L, 1); + throw std::runtime_error("Matrix component is not a number"); + } + result[i - 1] = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + return result; +} + +std::array ReadQuaternion(lua_State* L, int index) { + std::array result{}; + int absIndex = lua_absindex(L, index); + size_t len = lua_rawlen(L, absIndex); + if (len != 4) { + throw std::runtime_error("Expected quaternion with 4 components"); + } + for (size_t i = 1; i <= 4; ++i) { + lua_rawgeti(L, absIndex, static_cast(i)); + if (!lua_isnumber(L, -1)) { + lua_pop(L, 1); + throw std::runtime_error("Quaternion component is not a number"); + } + result[i - 1] = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + return result; +} + +} // namespace detail + +namespace { + struct MeshPayload { std::vector> positions; std::vector> colors; @@ -190,9 +251,9 @@ bool TryLoadMeshPayload(const CubeScript* script, aiColor3D materialColor = defaultColor; if (mesh->mMaterialIndex < scene->mNumMaterials) { const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; - aiColor3D diffuse; + aiColor4D diffuse; if (material && material->Get(AI_MATKEY_COLOR_DIFFUSE, diffuse) == AI_SUCCESS) { - materialColor = diffuse; + materialColor = aiColor3D(diffuse.r, diffuse.g, diffuse.b); } } @@ -202,7 +263,8 @@ bool TryLoadMeshPayload(const CubeScript* script, aiColor3D color = materialColor; if (mesh->HasVertexColors(0) && mesh->mColors[0]) { - color = mesh->mColors[0][i]; + const aiColor4D& vertexColor = mesh->mColors[0][i]; + color = aiColor3D(vertexColor.r, vertexColor.g, vertexColor.b); } payload.colors.push_back({color.r, color.g, color.b}); } @@ -298,10 +360,10 @@ int LuaPhysicsCreateBox(lua_State* L) { if (!lua_istable(L, 2) || !lua_istable(L, 4) || !lua_istable(L, 5)) { luaL_error(L, "physics_create_box expects vector tables for half extents, origin, and rotation"); } - std::array halfExtents = ReadVector3(L, 2); + std::array halfExtents = detail::ReadVector3(L, 2); float mass = static_cast(luaL_checknumber(L, 3)); - std::array origin = ReadVector3(L, 4); - std::array rotation = ReadQuaternion(L, 5); + std::array origin = detail::ReadVector3(L, 4); + std::array rotation = detail::ReadQuaternion(L, 5); btTransform transform; transform.setIdentity(); @@ -370,8 +432,8 @@ int LuaPhysicsGetTransform(lua_State* L) { } int LuaGlmMatrixFromTransform(lua_State* L) { - std::array translation = CubeScript::ReadVector3(L, 1); - std::array rotation = CubeScript::ReadQuaternion(L, 2); + std::array translation = detail::ReadVector3(L, 1); + std::array rotation = detail::ReadQuaternion(L, 2); glm::vec3 pos = ToVec3(translation); glm::quat quat = ToQuat(rotation); glm::mat4 matrix = glm::translate(glm::mat4(1.0f), pos) * glm::mat4_cast(quat); @@ -553,7 +615,7 @@ std::array CubeScript::ComputeModelMatrix(int functionRef, float time throw std::runtime_error("'compute_model_matrix' did not return a table"); } - std::array matrix = ReadMatrix(L_, -1); + std::array matrix = detail::ReadMatrix(L_, -1); lua_pop(L_, 1); return matrix; } @@ -574,7 +636,7 @@ std::array CubeScript::GetViewProjectionMatrix(float aspect) { lua_pop(L_, 1); throw std::runtime_error("'get_view_projection' did not return a table"); } - std::array matrix = ReadMatrix(L_, -1); + std::array matrix = detail::ReadMatrix(L_, -1); lua_pop(L_, 1); return matrix; } @@ -600,11 +662,11 @@ std::vector CubeScript::ReadVertexArray(lua_State* L, int index) { core::Vertex vertex{}; lua_getfield(L, vertexIndex, "position"); - vertex.position = ReadVector3(L, -1); + vertex.position = detail::ReadVector3(L, -1); lua_pop(L, 1); lua_getfield(L, vertexIndex, "color"); - vertex.color = ReadVector3(L, -1); + vertex.color = detail::ReadVector3(L, -1); lua_pop(L, 1); lua_pop(L, 1); @@ -697,63 +759,6 @@ CubeScript::ShaderPaths CubeScript::ReadShaderPathsTable(lua_State* L, int index return paths; } -std::array CubeScript::ReadVector3(lua_State* L, int index) { - std::array result{}; - int absIndex = lua_absindex(L, index); - size_t len = lua_rawlen(L, absIndex); - if (len != 3) { - throw std::runtime_error("Expected vector with 3 components"); - } - for (size_t i = 1; i <= 3; ++i) { - lua_rawgeti(L, absIndex, static_cast(i)); - if (!lua_isnumber(L, -1)) { - lua_pop(L, 1); - throw std::runtime_error("Vector component is not a number"); - } - result[i - 1] = static_cast(lua_tonumber(L, -1)); - lua_pop(L, 1); - } - return result; -} - -std::array CubeScript::ReadMatrix(lua_State* L, int index) { - std::array result{}; - int absIndex = lua_absindex(L, index); - size_t len = lua_rawlen(L, absIndex); - if (len != 16) { - throw std::runtime_error("Expected 4x4 matrix with 16 components"); - } - for (size_t i = 1; i <= 16; ++i) { - lua_rawgeti(L, absIndex, static_cast(i)); - if (!lua_isnumber(L, -1)) { - lua_pop(L, 1); - throw std::runtime_error("Matrix component is not a number"); - } - result[i - 1] = static_cast(lua_tonumber(L, -1)); - lua_pop(L, 1); - } - return result; -} - -std::array CubeScript::ReadQuaternion(lua_State* L, int index) { - std::array result{}; - int absIndex = lua_absindex(L, index); - size_t len = lua_rawlen(L, absIndex); - if (len != 4) { - throw std::runtime_error("Expected quaternion with 4 components"); - } - for (size_t i = 1; i <= 4; ++i) { - lua_rawgeti(L, absIndex, static_cast(i)); - if (!lua_isnumber(L, -1)) { - lua_pop(L, 1); - throw std::runtime_error("Quaternion component is not a number"); - } - result[i - 1] = static_cast(lua_tonumber(L, -1)); - lua_pop(L, 1); - } - return result; -} - std::string CubeScript::LuaErrorMessage(lua_State* L) { const char* message = lua_tostring(L, -1); return message ? message : "unknown lua error"; diff --git a/src/script/cube_script.hpp b/src/script/cube_script.hpp index 32debe3..b4b5785 100644 --- a/src/script/cube_script.hpp +++ b/src/script/cube_script.hpp @@ -97,9 +97,6 @@ public: PhysicsBridge& GetPhysicsBridge(); private: - static std::array ReadVector3(lua_State* L, int index); - static std::array ReadMatrix(lua_State* L, int index); - static std::array ReadQuaternion(lua_State* L, int index); 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);