From 0969f7c2f6f259ad0439d5b4e27e71c3b37506e6 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 8 Feb 2026 22:16:21 -0500 Subject: [PATCH] Implement SDL2_mixer audio for WASM builds, closes #247 Replace no-op audio stubs in SDL2Types.h with real SDL2_mixer-backed implementations of SoundBuffer, Sound, and Music. This enables audio playback in the browser with zero changes to Python bindings. - Add -sUSE_SDL_MIXER=2 to Emscripten compile/link flags (CMakeLists.txt) - Initialize Mix_OpenAudio in SDL2Renderer::init(), Mix_CloseAudio in shutdown() - SoundBuffer: Mix_LoadWAV/Mix_LoadWAV_RW with duration computation - Sound: channel-based playback with Mix_ChannelFinished tracking - Music: global channel streaming via Mix_LoadMUS/Mix_PlayMusic - Volume conversion: SFML 0-100 scale to SDL_mixer 0-128 scale Known limitations on web: Music.duration and Music.position getters return 0 (SDL_mixer 2.0.2 lacks Mix_MusicDuration/Mix_GetMusicPosition). Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 13 +- src/platform/SDL2Renderer.cpp | 24 ++- src/platform/SDL2Renderer.h | 2 + src/platform/SDL2Types.h | 280 +++++++++++++++++++++++++++++++--- 4 files changed, 290 insertions(+), 29 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 40e4ff0..70a8d4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,9 @@ option(MCRF_SDL2 "Build with SDL2+OpenGL ES 2 backend instead of SFML" OFF) # Playground mode - minimal scripts for web playground (REPL-focused) option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF) +# Game shell mode - fullscreen canvas, no REPL chrome (for itch.io / standalone web games) +option(MCRF_GAME_SHELL "Use minimal game-only HTML shell (no REPL)" OFF) + # Emscripten builds: use SDL2 if specified, otherwise fall back to headless if(EMSCRIPTEN) if(MCRF_SDL2) @@ -286,8 +289,8 @@ if(EMSCRIPTEN) --preload-file=${CMAKE_SOURCE_DIR}/src/$,scripts_playground,scripts>@/scripts # Preload assets --preload-file=${CMAKE_SOURCE_DIR}/assets@/assets - # Use custom HTML shell for crisp pixel rendering - --shell-file=${CMAKE_SOURCE_DIR}/src/shell.html + # Use custom HTML shell - game shell (fullscreen) or playground shell (REPL) + --shell-file=${CMAKE_SOURCE_DIR}/src/$,shell_game.html,shell.html> # Pre-JS to fix browser zoom causing undefined values in events --pre-js=${CMAKE_SOURCE_DIR}/src/emscripten_pre.js ) @@ -296,17 +299,19 @@ if(EMSCRIPTEN) if(MCRF_SDL2) list(APPEND EMSCRIPTEN_LINK_OPTIONS -sUSE_SDL=2 + -sUSE_SDL_MIXER=2 -sFULL_ES2=1 -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 -sUSE_FREETYPE=1 ) - # SDL2 and FreeType flags are also needed at compile time for headers + # SDL2, SDL2_mixer, and FreeType flags are also needed at compile time for headers target_compile_options(mcrogueface PRIVATE -sUSE_SDL=2 + -sUSE_SDL_MIXER=2 -sUSE_FREETYPE=1 ) - message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sFULL_ES2=1 -sUSE_FREETYPE=1") + message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sUSE_SDL_MIXER=2 -sFULL_ES2=1 -sUSE_FREETYPE=1") endif() target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS}) diff --git a/src/platform/SDL2Renderer.cpp b/src/platform/SDL2Renderer.cpp index d73e175..057ea6c 100644 --- a/src/platform/SDL2Renderer.cpp +++ b/src/platform/SDL2Renderer.cpp @@ -17,9 +17,11 @@ #include // Emscripten's USE_SDL=2 port puts headers directly in include path #include +#include #include #else #include +#include #include // Desktop OpenGL - we'll use GL 2.1 compatible subset that matches GLES2 #define GL_GLEXT_PROTOTYPES @@ -132,11 +134,22 @@ bool SDL2Renderer::init() { if (initialized_) return true; // Initialize SDL2 if not already done - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) < 0) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_AUDIO) < 0) { std::cerr << "SDL2Renderer: Failed to initialize SDL: " << SDL_GetError() << std::endl; return false; } + // Initialize SDL2_mixer for audio (non-fatal if it fails) + if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048) < 0) { + std::cerr << "SDL2Renderer: Failed to initialize audio: " << Mix_GetError() << std::endl; + std::cerr << "SDL2Renderer: Continuing without audio support" << std::endl; + } else { + Mix_AllocateChannels(16); + Mix_ChannelFinished(Sound::onChannelFinished); + audioInitialized_ = true; + std::cout << "SDL2Renderer: Audio initialized (16 channels, 44100 Hz)" << std::endl; + } + // Note: Shaders are initialized in initGL() after GL context is created // Set up initial projection matrix (identity) @@ -170,6 +183,12 @@ void SDL2Renderer::shutdown() { shapeProgram_ = spriteProgram_ = textProgram_ = 0; + // Close audio before SDL_Quit + if (audioInitialized_) { + Mix_CloseAudio(); + audioInitialized_ = false; + } + SDL_Quit(); initialized_ = false; } @@ -673,6 +692,9 @@ void RenderWindow::setSize(const Vector2u& size) { if (sdlWindow_) { SDL_SetWindowSize(static_cast(sdlWindow_), size.x, size.y); glViewport(0, 0, size.x, size.y); +#ifdef __EMSCRIPTEN__ + emscripten_set_canvas_element_size("#canvas", size.x, size.y); +#endif } } diff --git a/src/platform/SDL2Renderer.h b/src/platform/SDL2Renderer.h index f18656a..71690bd 100644 --- a/src/platform/SDL2Renderer.h +++ b/src/platform/SDL2Renderer.h @@ -44,6 +44,7 @@ public: void shutdown(); bool isInitialized() const { return initialized_; } bool isGLInitialized() const { return glInitialized_; } + bool isAudioInitialized() const { return audioInitialized_; } // Built-in shader programs enum class ShaderType { @@ -100,6 +101,7 @@ private: bool initialized_ = false; bool glInitialized_ = false; + bool audioInitialized_ = false; // Built-in shader programs unsigned int shapeProgram_ = 0; diff --git a/src/platform/SDL2Types.h b/src/platform/SDL2Types.h index d5f9bdf..ef8fbec 100644 --- a/src/platform/SDL2Types.h +++ b/src/platform/SDL2Types.h @@ -25,6 +25,7 @@ #include #include #include +#include // SDL2 headers - conditionally included when actually implementing // For now, forward declare what we need @@ -33,6 +34,13 @@ #include #endif +// SDL2_mixer for audio (always needed in SDL2 builds for SoundBuffer/Sound/Music types) +#ifdef __EMSCRIPTEN__ +#include +#else +#include +#endif + namespace sf { // Forward declarations (needed for RenderWindow) @@ -922,34 +930,195 @@ public: }; // ============================================================================= -// Audio Stubs (SDL2_mixer could implement these later) +// Audio (SDL2_mixer backed) // ============================================================================= class SoundBuffer { + Mix_Chunk* chunk_ = nullptr; + Time duration_; + public: SoundBuffer() = default; - bool loadFromFile(const std::string& filename) { return true; } // Stub - bool loadFromMemory(const void* data, size_t sizeInBytes) { return true; } // Stub - Time getDuration() const { return Time(); } + ~SoundBuffer() { + if (chunk_) { + Mix_FreeChunk(chunk_); + chunk_ = nullptr; + } + } + + // No copy (Mix_Chunk ownership) + SoundBuffer(const SoundBuffer&) = delete; + SoundBuffer& operator=(const SoundBuffer&) = delete; + + // Move + SoundBuffer(SoundBuffer&& other) noexcept + : chunk_(other.chunk_), duration_(other.duration_) { + other.chunk_ = nullptr; + } + SoundBuffer& operator=(SoundBuffer&& other) noexcept { + if (this != &other) { + if (chunk_) Mix_FreeChunk(chunk_); + chunk_ = other.chunk_; + duration_ = other.duration_; + other.chunk_ = nullptr; + } + return *this; + } + + bool loadFromFile(const std::string& filename) { + if (chunk_) { Mix_FreeChunk(chunk_); chunk_ = nullptr; } + chunk_ = Mix_LoadWAV(filename.c_str()); + if (!chunk_) return false; + computeDuration(); + return true; + } + + bool loadFromMemory(const void* data, size_t sizeInBytes) { + if (chunk_) { Mix_FreeChunk(chunk_); chunk_ = nullptr; } + SDL_RWops* rw = SDL_RWFromConstMem(data, static_cast(sizeInBytes)); + if (!rw) return false; + chunk_ = Mix_LoadWAV_RW(rw, 1); // 1 = free RWops after load + if (!chunk_) return false; + computeDuration(); + return true; + } + + Time getDuration() const { return duration_; } + Mix_Chunk* getChunk() const { return chunk_; } + +private: + void computeDuration() { + if (!chunk_) { duration_ = Time(); return; } + int freq = 0, channels = 0; + Uint16 format = 0; + Mix_QuerySpec(&freq, &format, &channels); + if (freq == 0 || channels == 0) { duration_ = Time(); return; } + // Compute bytes per sample based on format + int bytesPerSample = 2; // Default 16-bit + if (format == AUDIO_U8 || format == AUDIO_S8) bytesPerSample = 1; + else if (format == AUDIO_S32LSB || format == AUDIO_S32MSB) bytesPerSample = 4; + else if (format == AUDIO_F32LSB || format == AUDIO_F32MSB) bytesPerSample = 4; + int totalSamples = chunk_->alen / (bytesPerSample * channels); + float secs = static_cast(totalSamples) / static_cast(freq); + duration_ = seconds(secs); + } }; +// Forward declare Sound for channel tracking +class Sound; + +// Channel tracking: maps SDL_mixer channel indices to Sound* owners +// Defined as inline to keep header-only and avoid multiple definition issues +inline Sound* g_channelOwners[16] = {}; + class Sound { public: enum Status { Stopped, Paused, Playing }; Sound() = default; - Sound(const SoundBuffer& buffer) {} + Sound(const SoundBuffer& buffer) : chunk_(buffer.getChunk()) {} - void setBuffer(const SoundBuffer& buffer) {} - void play() {} - void pause() {} - void stop() {} + ~Sound() { + // Release our channel claim + if (channel_ >= 0 && channel_ < 16) { + if (g_channelOwners[channel_] == this) { + Mix_HaltChannel(channel_); + g_channelOwners[channel_] = nullptr; + } + channel_ = -1; + } + } - Status getStatus() const { return Stopped; } - void setVolume(float volume) {} - float getVolume() const { return 100.0f; } - void setLoop(bool loop) {} - bool getLoop() const { return false; } + // No copy (channel ownership) + Sound(const Sound&) = delete; + Sound& operator=(const Sound&) = delete; + + // Move + Sound(Sound&& other) noexcept + : chunk_(other.chunk_), channel_(other.channel_), + volume_(other.volume_), loop_(other.loop_) { + if (channel_ >= 0 && channel_ < 16) { + g_channelOwners[channel_] = this; + } + other.channel_ = -1; + other.chunk_ = nullptr; + } + Sound& operator=(Sound&& other) noexcept { + if (this != &other) { + stop(); + chunk_ = other.chunk_; + channel_ = other.channel_; + volume_ = other.volume_; + loop_ = other.loop_; + if (channel_ >= 0 && channel_ < 16) { + g_channelOwners[channel_] = this; + } + other.channel_ = -1; + other.chunk_ = nullptr; + } + return *this; + } + + void setBuffer(const SoundBuffer& buffer) { chunk_ = buffer.getChunk(); } + + void play() { + if (!chunk_) return; + channel_ = Mix_PlayChannel(-1, chunk_, loop_ ? -1 : 0); + if (channel_ >= 0 && channel_ < 16) { + // Clear any previous owner on this channel + if (g_channelOwners[channel_] && g_channelOwners[channel_] != this) { + g_channelOwners[channel_]->channel_ = -1; + } + g_channelOwners[channel_] = this; + Mix_Volume(channel_, static_cast(volume_ * 128.f / 100.f)); + } + } + + void pause() { + if (channel_ >= 0) Mix_Pause(channel_); + } + + void stop() { + if (channel_ >= 0) { + Mix_HaltChannel(channel_); + if (channel_ < 16 && g_channelOwners[channel_] == this) { + g_channelOwners[channel_] = nullptr; + } + channel_ = -1; + } + } + + Status getStatus() const { + if (channel_ < 0) return Stopped; + if (Mix_Paused(channel_)) return Paused; + if (Mix_Playing(channel_)) return Playing; + return Stopped; + } + + void setVolume(float vol) { + volume_ = std::clamp(vol, 0.f, 100.f); + if (channel_ >= 0) { + Mix_Volume(channel_, static_cast(volume_ * 128.f / 100.f)); + } + } + float getVolume() const { return volume_; } + + void setLoop(bool loop) { loop_ = loop; } + bool getLoop() const { return loop_; } + + // Called by Mix_ChannelFinished callback + static void onChannelFinished(int channel) { + if (channel >= 0 && channel < 16 && g_channelOwners[channel]) { + g_channelOwners[channel]->channel_ = -1; + g_channelOwners[channel] = nullptr; + } + } + +private: + Mix_Chunk* chunk_ = nullptr; // Borrowed from SoundBuffer + int channel_ = -1; + float volume_ = 100.f; + bool loop_ = false; }; class Music { @@ -957,20 +1126,83 @@ public: enum Status { Stopped, Paused, Playing }; Music() = default; - bool openFromFile(const std::string& filename) { return true; } // Stub + ~Music() { + if (music_) { + Mix_FreeMusic(music_); + music_ = nullptr; + } + } - void play() {} - void pause() {} - void stop() {} + // No copy (global music channel) + Music(const Music&) = delete; + Music& operator=(const Music&) = delete; - Status getStatus() const { return Stopped; } - void setVolume(float volume) {} - float getVolume() const { return 100.0f; } - void setLoop(bool loop) {} - bool getLoop() const { return false; } + // Move + Music(Music&& other) noexcept + : music_(other.music_), volume_(other.volume_), loop_(other.loop_) { + other.music_ = nullptr; + } + Music& operator=(Music&& other) noexcept { + if (this != &other) { + if (music_) Mix_FreeMusic(music_); + music_ = other.music_; + volume_ = other.volume_; + loop_ = other.loop_; + other.music_ = nullptr; + } + return *this; + } + + bool openFromFile(const std::string& filename) { + if (music_) { Mix_FreeMusic(music_); music_ = nullptr; } + music_ = Mix_LoadMUS(filename.c_str()); + return music_ != nullptr; + } + + void play() { + if (!music_) return; + Mix_PlayMusic(music_, loop_ ? -1 : 0); + Mix_VolumeMusic(static_cast(volume_ * 128.f / 100.f)); + } + + void pause() { + Mix_PauseMusic(); + } + + void stop() { + Mix_HaltMusic(); + } + + Status getStatus() const { + if (Mix_PausedMusic()) return Paused; + if (Mix_PlayingMusic()) return Playing; + return Stopped; + } + + void setVolume(float vol) { + volume_ = std::clamp(vol, 0.f, 100.f); + Mix_VolumeMusic(static_cast(volume_ * 128.f / 100.f)); + } + float getVolume() const { return volume_; } + + void setLoop(bool loop) { loop_ = loop; } + bool getLoop() const { return loop_; } + + // Duration not available in Emscripten's SDL_mixer 2.0.2 Time getDuration() const { return Time(); } + + // Playing offset getter not available in Emscripten's SDL_mixer 2.0.2 Time getPlayingOffset() const { return Time(); } - void setPlayingOffset(Time offset) {} + + // Setter works for OGG via Mix_SetMusicPosition + void setPlayingOffset(Time offset) { + if (music_) Mix_SetMusicPosition(static_cast(offset.asSeconds())); + } + +private: + Mix_Music* music_ = nullptr; + float volume_ = 100.f; + bool loop_ = false; }; // =============================================================================