refactor: carve out a proper sound interface with miniaudio behind it

This commit is contained in:
MatthewBeshay 2026-04-08 21:10:18 +10:00
parent b032e2a3a0
commit 9b830f1bfc
9 changed files with 487 additions and 1 deletions

View file

@ -16,6 +16,7 @@ client_dependencies = [
profile_dep,
storage_dep,
fs_dep,
sound_dep,
assets_localisation_dep,
platform_dep,
minecraft_dep,

View file

@ -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,

View file

@ -31,3 +31,4 @@ subdir('profile')
subdir('storage')
subdir('fs')
subdir('renderer')
subdir('sound')

View file

@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <string>
#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

View file

@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
// 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 <class Tag>
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<SoundTag>;
using MusicHandle = Handle<MusicTag>;
} // namespace platform::sound

View file

@ -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],
)

View file

@ -0,0 +1,261 @@
#include "MiniaudioSound.h"
#include <atomic>
#include <unordered_map>
#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<std::uint32_t, std::unique_ptr<Voice>> sounds;
std::unordered_map<std::uint32_t, std::unique_ptr<Voice>> music;
std::atomic<std::uint32_t> nextHandleId{1};
};
MiniaudioSound::MiniaudioSound() : m_state(std::make_unique<State>()) {}
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<ma_uint32>(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<Voice>();
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<Voice>();
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<ma_uint32>(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<ma_uint32>(listenerIndex),
forwardX, forwardY, forwardZ);
ma_engine_listener_set_world_up(&m_state->engine,
static_cast<ma_uint32>(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

View file

@ -0,0 +1,61 @@
#pragma once
#include <memory>
#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<State> m_state;
};
} // namespace platform::sound::miniaudio

View file

@ -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())