diff --git a/targets/app/meson.build b/targets/app/meson.build index 6641edc3f..6f90b86e7 100644 --- a/targets/app/meson.build +++ b/targets/app/meson.build @@ -16,6 +16,7 @@ client_dependencies = [ profile_dep, storage_dep, fs_dep, + sound_dep, assets_localisation_dep, platform_dep, minecraft_dep, diff --git a/targets/minecraft/meson.build b/targets/minecraft/meson.build index 2de8cc71a..dfa8b95b6 100644 --- a/targets/minecraft/meson.build +++ b/targets/minecraft/meson.build @@ -23,7 +23,7 @@ endif lib_minecraft = static_library('minecraft', minecraft_sources, dependencies : [ - miniaudio_dep, # TODO: remove once a SoundEngine facade is present + sound_dep, render_dep, input_dep, profile_dep, diff --git a/targets/platform/meson.build b/targets/platform/meson.build index a52dc8255..f8ab4cd4c 100644 --- a/targets/platform/meson.build +++ b/targets/platform/meson.build @@ -31,3 +31,4 @@ subdir('profile') subdir('storage') subdir('fs') subdir('renderer') +subdir('sound') diff --git a/targets/platform/sound/IPlatformSound.h b/targets/platform/sound/IPlatformSound.h new file mode 100644 index 000000000..7541dc967 --- /dev/null +++ b/targets/platform/sound/IPlatformSound.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +#include "platform/sound/SoundHandles.h" + +// Platform sound interface. The backend (currently miniaudio) implements +// this; consumers in app/common/Audio/SoundEngine talk to the interface +// rather than to miniaudio directly. +// +// The interface is intent-level: load a sample from disk, play it, +// stop it, set listener position. State (volume, pitch, looping) is +// passed at play time as a struct rather than via global setters. +// +// Concrete handles (SoundHandle, MusicHandle) are tagged structs so the +// type system catches sample-vs-music confusion at compile time. + +namespace platform::sound { + +struct PlaySoundParams { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float volume = 1.0f; + float pitch = 1.0f; + bool spatial = true; // 3D sound positioned in the world + bool looping = false; + float minDistance = 1.0f; // distance below which the sound is full-volume + float maxDistance = 16.0f; // distance above which the sound is silent +}; + +struct PlayMusicParams { + float volume = 1.0f; + float pitch = 1.0f; + bool looping = false; +}; + +class IPlatformSound { +public: + virtual ~IPlatformSound() = default; + + // Lifecycle. init() takes the maximum number of simultaneous local + // listeners (splitscreen player count). Idempotent: a second init() + // call with the same parameters is a no-op. + virtual void init(int listenerCount) = 0; + virtual void shutdown() = 0; + + // Per-frame tick. Drives streaming reads, voice cleanup, etc. + virtual void tick() = 0; + + // Master volume scaling. 0.0 to 1.0. Applies to all subsequent + // sound and music playback. + virtual void setMasterVolume(float volume) = 0; + + // -- Spatial / one-shot sound effects -- + + // Load and start playing a sound. The backend allocates a voice and + // returns a handle that can be used to query / stop the sound. If + // the load fails or the mixer is at capacity, returns an invalid + // handle (the caller should treat that as "sound was dropped" - not + // an error). + [[nodiscard]] virtual SoundHandle playSoundFromFile( + const std::string& path, const PlaySoundParams& params) = 0; + + virtual void stopSound(SoundHandle handle) = 0; + [[nodiscard]] virtual bool isSoundPlaying(SoundHandle handle) const = 0; + + virtual void setSoundVolume(SoundHandle handle, float volume) = 0; + virtual void setSoundPosition(SoundHandle handle, float x, float y, + float z) = 0; + virtual void setSoundPitch(SoundHandle handle, float pitch) = 0; + + // Release the voice. Idempotent on invalid handles. + virtual void releaseSound(SoundHandle handle) = 0; + + // -- Streaming music -- + + // Load and start a streaming music track. Streaming means the + // backend reads the file incrementally rather than decoding it all + // upfront. Use for music tracks; sound effects should use the + // playSound* methods above. + [[nodiscard]] virtual MusicHandle playMusicFromFile( + const std::string& path, const PlayMusicParams& params) = 0; + + virtual void stopMusic(MusicHandle handle) = 0; + [[nodiscard]] virtual bool isMusicPlaying(MusicHandle handle) const = 0; + virtual void setMusicVolume(MusicHandle handle, float volume) = 0; + virtual void setMusicPitch(MusicHandle handle, float pitch) = 0; + virtual void releaseMusic(MusicHandle handle) = 0; + + // -- Listener (one per local splitscreen player) -- + + virtual void setListenerPosition(int listenerIndex, float x, float y, + float z) = 0; + virtual void setListenerOrientation(int listenerIndex, float forwardX, + float forwardY, float forwardZ, + float upX, float upY, float upZ) = 0; +}; + +} // namespace platform::sound diff --git a/targets/platform/sound/SoundHandles.h b/targets/platform/sound/SoundHandles.h new file mode 100644 index 000000000..16a856df1 --- /dev/null +++ b/targets/platform/sound/SoundHandles.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +// Type-safe handles for the platform sound subsystem. Each handle is a +// tagged 32-bit integer; the tag struct prevents accidental conversion +// between handle types at compile time. +// +// Handle 0 is reserved for "invalid". Backends should never return 0 +// from a successful create call. + +namespace platform::sound { + +template +struct Handle { + std::uint32_t id = 0; + + [[nodiscard]] constexpr bool valid() const noexcept { return id != 0; } + constexpr explicit operator bool() const noexcept { return valid(); } + + friend constexpr bool operator==(Handle, Handle) = default; +}; + +struct SoundTag {}; +struct MusicTag {}; + +using SoundHandle = Handle; +using MusicHandle = Handle; + +} // namespace platform::sound diff --git a/targets/platform/sound/meson.build b/targets/platform/sound/meson.build new file mode 100644 index 000000000..79f6f4519 --- /dev/null +++ b/targets/platform/sound/meson.build @@ -0,0 +1,15 @@ +_miniaudio_dep = dependency('miniaudio') + +lib_platform_sound_miniaudio = static_library( + 'platform_sound_miniaudio', + files('miniaudio/MiniaudioSound.cpp'), + include_directories: [platform_inc, include_directories('../..')], + dependencies: [_threads, _miniaudio_dep], + cpp_args: global_cpp_args + global_cpp_defs, +) + +sound_dep = declare_dependency( + link_with: lib_platform_sound_miniaudio, + include_directories: [platform_inc], + dependencies: [_miniaudio_dep], +) diff --git a/targets/platform/sound/miniaudio/MiniaudioSound.cpp b/targets/platform/sound/miniaudio/MiniaudioSound.cpp new file mode 100644 index 000000000..5aa8c3424 --- /dev/null +++ b/targets/platform/sound/miniaudio/MiniaudioSound.cpp @@ -0,0 +1,261 @@ +#include "MiniaudioSound.h" + +#include +#include + +#include "miniaudio.h" + +#include "platform/sound/sound.h" + +namespace platform::sound::miniaudio { + +namespace { + +// Each loaded sound voice. Owned by the State's map; the SoundHandle +// the caller holds is the integer key. +struct Voice { + ma_sound sound{}; + bool inUse = false; +}; + +} // namespace + +struct State { + ma_engine engine{}; + ma_engine_config engineConfig{}; + bool engineReady = false; + + // Voice pool. Sound handles are keys; the backend allocates a fresh + // id for every play call. Caller releases via releaseSound() (or + // we'll auto-release when the sound finishes - tick() handles that). + std::unordered_map> sounds; + std::unordered_map> music; + std::atomic nextHandleId{1}; +}; + +MiniaudioSound::MiniaudioSound() : m_state(std::make_unique()) {} +MiniaudioSound::~MiniaudioSound() { + if (m_state && m_state->engineReady) { + shutdown(); + } +} +MiniaudioSound::MiniaudioSound(MiniaudioSound&&) noexcept = default; +MiniaudioSound& MiniaudioSound::operator=(MiniaudioSound&&) noexcept = default; + +void MiniaudioSound::init(int listenerCount) { + if (m_state->engineReady) return; + m_state->engineConfig = ma_engine_config_init(); + m_state->engineConfig.listenerCount = + listenerCount > 0 ? static_cast(listenerCount) : 1; + if (ma_engine_init(&m_state->engineConfig, &m_state->engine) != + MA_SUCCESS) { + return; + } + ma_engine_set_volume(&m_state->engine, 1.0f); + m_state->engineReady = true; +} + +void MiniaudioSound::shutdown() { + if (!m_state->engineReady) return; + // Tear down all live voices first. + for (auto& [id, voice] : m_state->sounds) { + if (voice && voice->inUse) { + ma_sound_uninit(&voice->sound); + } + } + m_state->sounds.clear(); + for (auto& [id, voice] : m_state->music) { + if (voice && voice->inUse) { + ma_sound_uninit(&voice->sound); + } + } + m_state->music.clear(); + ma_engine_uninit(&m_state->engine); + m_state->engineReady = false; +} + +void MiniaudioSound::tick() { + if (!m_state->engineReady) return; + // Reap finished one-shot sounds (non-looping, not currently playing). + // We don't auto-reap music here; music is explicitly stopped by the + // game's music system. + for (auto it = m_state->sounds.begin(); it != m_state->sounds.end();) { + auto& voice = it->second; + if (voice && voice->inUse && + !ma_sound_is_playing(&voice->sound) && + !ma_sound_is_looping(&voice->sound)) { + ma_sound_uninit(&voice->sound); + voice->inUse = false; + it = m_state->sounds.erase(it); + } else { + ++it; + } + } +} + +void MiniaudioSound::setMasterVolume(float volume) { + if (!m_state->engineReady) return; + ma_engine_set_volume(&m_state->engine, volume); +} + +SoundHandle MiniaudioSound::playSoundFromFile(const std::string& path, + const PlaySoundParams& params) { + if (!m_state->engineReady) return {}; + auto voice = std::make_unique(); + if (ma_sound_init_from_file(&m_state->engine, path.c_str(), + MA_SOUND_FLAG_ASYNC, nullptr, nullptr, + &voice->sound) != MA_SUCCESS) { + return {}; + } + ma_sound_set_spatialization_enabled(&voice->sound, + params.spatial ? MA_TRUE : MA_FALSE); + ma_sound_set_min_distance(&voice->sound, params.minDistance); + ma_sound_set_max_distance(&voice->sound, params.maxDistance); + ma_sound_set_volume(&voice->sound, params.volume); + ma_sound_set_pitch(&voice->sound, params.pitch); + ma_sound_set_position(&voice->sound, params.x, params.y, params.z); + ma_sound_set_looping(&voice->sound, params.looping ? MA_TRUE : MA_FALSE); + ma_sound_start(&voice->sound); + voice->inUse = true; + + SoundHandle handle{m_state->nextHandleId.fetch_add(1)}; + m_state->sounds.emplace(handle.id, std::move(voice)); + return handle; +} + +void MiniaudioSound::stopSound(SoundHandle handle) { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end() || !it->second) return; + if (it->second->inUse) { + ma_sound_stop(&it->second->sound); + } +} + +bool MiniaudioSound::isSoundPlaying(SoundHandle handle) const { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end() || !it->second || !it->second->inUse) { + return false; + } + return ma_sound_is_playing(&it->second->sound) != MA_FALSE; +} + +void MiniaudioSound::setSoundVolume(SoundHandle handle, float volume) { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end() || !it->second || !it->second->inUse) + return; + ma_sound_set_volume(&it->second->sound, volume); +} + +void MiniaudioSound::setSoundPosition(SoundHandle handle, float x, float y, + float z) { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end() || !it->second || !it->second->inUse) + return; + ma_sound_set_position(&it->second->sound, x, y, z); +} + +void MiniaudioSound::setSoundPitch(SoundHandle handle, float pitch) { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end() || !it->second || !it->second->inUse) + return; + ma_sound_set_pitch(&it->second->sound, pitch); +} + +void MiniaudioSound::releaseSound(SoundHandle handle) { + auto it = m_state->sounds.find(handle.id); + if (it == m_state->sounds.end()) return; + if (it->second && it->second->inUse) { + ma_sound_uninit(&it->second->sound); + it->second->inUse = false; + } + m_state->sounds.erase(it); +} + +MusicHandle MiniaudioSound::playMusicFromFile(const std::string& path, + const PlayMusicParams& params) { + if (!m_state->engineReady) return {}; + auto voice = std::make_unique(); + if (ma_sound_init_from_file(&m_state->engine, path.c_str(), + MA_SOUND_FLAG_STREAM, nullptr, nullptr, + &voice->sound) != MA_SUCCESS) { + return {}; + } + ma_sound_set_spatialization_enabled(&voice->sound, MA_FALSE); + ma_sound_set_volume(&voice->sound, params.volume); + ma_sound_set_pitch(&voice->sound, params.pitch); + ma_sound_set_looping(&voice->sound, params.looping ? MA_TRUE : MA_FALSE); + ma_sound_start(&voice->sound); + voice->inUse = true; + + MusicHandle handle{m_state->nextHandleId.fetch_add(1)}; + m_state->music.emplace(handle.id, std::move(voice)); + return handle; +} + +void MiniaudioSound::stopMusic(MusicHandle handle) { + auto it = m_state->music.find(handle.id); + if (it == m_state->music.end() || !it->second) return; + if (it->second->inUse) { + ma_sound_stop(&it->second->sound); + } +} + +bool MiniaudioSound::isMusicPlaying(MusicHandle handle) const { + auto it = m_state->music.find(handle.id); + if (it == m_state->music.end() || !it->second || !it->second->inUse) + return false; + return ma_sound_is_playing(&it->second->sound) != MA_FALSE; +} + +void MiniaudioSound::setMusicVolume(MusicHandle handle, float volume) { + auto it = m_state->music.find(handle.id); + if (it == m_state->music.end() || !it->second || !it->second->inUse) + return; + ma_sound_set_volume(&it->second->sound, volume); +} + +void MiniaudioSound::setMusicPitch(MusicHandle handle, float pitch) { + auto it = m_state->music.find(handle.id); + if (it == m_state->music.end() || !it->second || !it->second->inUse) + return; + ma_sound_set_pitch(&it->second->sound, pitch); +} + +void MiniaudioSound::releaseMusic(MusicHandle handle) { + auto it = m_state->music.find(handle.id); + if (it == m_state->music.end()) return; + if (it->second && it->second->inUse) { + ma_sound_uninit(&it->second->sound); + it->second->inUse = false; + } + m_state->music.erase(it); +} + +void MiniaudioSound::setListenerPosition(int listenerIndex, float x, float y, + float z) { + if (!m_state->engineReady) return; + ma_engine_listener_set_position(&m_state->engine, + static_cast(listenerIndex), x, + y, z); +} + +void MiniaudioSound::setListenerOrientation(int listenerIndex, float forwardX, + float forwardY, float forwardZ, + float upX, float upY, float upZ) { + if (!m_state->engineReady) return; + ma_engine_listener_set_direction(&m_state->engine, + static_cast(listenerIndex), + forwardX, forwardY, forwardZ); + ma_engine_listener_set_world_up(&m_state->engine, + static_cast(listenerIndex), + upX, upY, upZ); +} + +} // namespace platform::sound::miniaudio + +namespace platform_internal { +::platform::sound::IPlatformSound& PlatformSound_get() { + static ::platform::sound::miniaudio::MiniaudioSound instance; + return instance; +} +} // namespace platform_internal diff --git a/targets/platform/sound/miniaudio/MiniaudioSound.h b/targets/platform/sound/miniaudio/MiniaudioSound.h new file mode 100644 index 000000000..44a7845e5 --- /dev/null +++ b/targets/platform/sound/miniaudio/MiniaudioSound.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +#include "platform/sound/IPlatformSound.h" + +// Forward-declare the miniaudio state struct so this header doesn't +// need to include miniaudio.h - keeps the include footprint small for +// any consumer of the backend header. +namespace platform::sound::miniaudio { + +struct State; + +class MiniaudioSound : public IPlatformSound { +public: + MiniaudioSound(); + ~MiniaudioSound() override; + + // Move-only - the engine owns hardware state. + MiniaudioSound(MiniaudioSound&&) noexcept; + MiniaudioSound& operator=(MiniaudioSound&&) noexcept; + MiniaudioSound(const MiniaudioSound&) = delete; + MiniaudioSound& operator=(const MiniaudioSound&) = delete; + + // -- IPlatformSound -- + + void init(int listenerCount) override; + void shutdown() override; + void tick() override; + + void setMasterVolume(float volume) override; + + [[nodiscard]] SoundHandle playSoundFromFile( + const std::string& path, const PlaySoundParams& params) override; + void stopSound(SoundHandle handle) override; + [[nodiscard]] bool isSoundPlaying(SoundHandle handle) const override; + void setSoundVolume(SoundHandle handle, float volume) override; + void setSoundPosition(SoundHandle handle, float x, float y, + float z) override; + void setSoundPitch(SoundHandle handle, float pitch) override; + void releaseSound(SoundHandle handle) override; + + [[nodiscard]] MusicHandle playMusicFromFile( + const std::string& path, const PlayMusicParams& params) override; + void stopMusic(MusicHandle handle) override; + [[nodiscard]] bool isMusicPlaying(MusicHandle handle) const override; + void setMusicVolume(MusicHandle handle, float volume) override; + void setMusicPitch(MusicHandle handle, float pitch) override; + void releaseMusic(MusicHandle handle) override; + + void setListenerPosition(int listenerIndex, float x, float y, + float z) override; + void setListenerOrientation(int listenerIndex, float forwardX, + float forwardY, float forwardZ, float upX, + float upY, float upZ) override; + +private: + std::unique_ptr m_state; +}; + +} // namespace platform::sound::miniaudio diff --git a/targets/platform/sound/sound.h b/targets/platform/sound/sound.h new file mode 100644 index 000000000..41dba077f --- /dev/null +++ b/targets/platform/sound/sound.h @@ -0,0 +1,16 @@ +#pragma once + +#include "platform/sound/IPlatformSound.h" + +// Function accessor backed by a function-local static (Meyers singleton). +// Same pattern as input/renderer/profile/storage/fs - SIOF-safe. +// +// The macro lets call sites keep using `PlatformSound.foo()` syntax; +// the expansion is just a function call returning a reference, which +// LTO inlines. + +namespace platform_internal { +::platform::sound::IPlatformSound& PlatformSound_get(); +} + +#define PlatformSound (::platform_internal::PlatformSound_get())