mirror of
https://github.com/Meridian59/Meridian59
synced 2026-08-17 18:23:09 -04:00
feature: add ogg streaming playback for music (#1388)
## What - replaced full-file OGG decoding with streaming playback so music transitions no longer freeze the game - related to [ #1293](https://github.com/Meridian59/Meridian59/pull/1293) ## Why - when music changes (room transitions, Jala song interrupts), `MusicPlay()` calls `stb_vorbis_decode_filename()` which decompresses the entire OGG file into raw PCM in a single blocking call on the main thread. - A typical 3.7 MB OGG (Main.ogg, 3:01) expands to ~30 MB of PCM, and the largest track (Castle2.ogg, 4:55) expands to ~50 MB - no rendering, input processing, or animation happens during this decode, so the game visibly stutters/locks for a moment - reported by a tester on older hardware, but the blocking call affects all clients - music is the only audio path affected. Sound effects use small one-shot files with buffer caching and are fine ## How - replaced full-file `stb_vorbis_decode_filename()` with the stb_vorbis streaming API: open the file, read headers, decode in 4096-sample chunks on demand - consolidated music globals into a `MusicStream` struct with a 4-buffer ring, vorbis handle, and state flags - `MusicPlay()` now opens the OGG, fills 4 initial buffers (~64 KB total), and starts playback in ~1ms - a Win32 timer calls MusicStreamUpdate() to rotate processed buffers (unqueue, decode next chunk, re-queue) with automatic underrun recovery - looping is handled by seeking the vorbis stream to the start on EOF, since OpenAL's `AL_LOOPING` only works on single buffers, not queued buffers - the timer approach means streaming works in every client state, including modal dialogs (e.g. login screen) that block the main loop - `music.c` unchanged, the `MusicPlay`/`MusicStop` API contract is the same - added stb to the global include path in common.mak so `audio_openal.c` can include the `stb_vorbis` header directly ### Streaming buffer parameters | Parameter | Value | Rationale | |-----------|-------|-----------| | Buffer count | 4 | Standard ring buffer depth, enough runway for frame rate drops | | Buffer size | 4096 samples | ~93ms per buffer at 44100 Hz | | Total runway | ~0.37 seconds | 4 x 93ms, survives typical frame spikes and timer intervals | | Total streaming memory | ~64 KB | vs 30-50 MB for full decode (470-780x reduction) | | Timer interval | 50ms | Below the ~93ms buffer duration, ensures at least 1 refill per buffer lifetime | ## Examples ### Before https://github.com/user-attachments/assets/6757e5c4-09da-4326-be63-162e4e104257 When a Jala song is interrupted by running, the new room music triggers a full OGG decode on the main thread. On a mid-range system this takes ~100-200ms, during which the player character visibly freezes. On older hardware it is much worse. ### After https://github.com/user-attachments/assets/fec2c139-0ad9-4aab-b230-160755928cdf `MusicPlay()` opens the file and reads headers only (~1ms). The first ~370ms of audio is decoded across 4 small buffers (~16KB each) during the initial call. Subsequent decoding happens via a Win32 timer calling MusicStreamUpdate(), with no perceptible impact on frame time. ### Architecture: Full Decode vs Streaming ```mermaid graph TD subgraph NEW["Streaming (New)"] direction TB N1["MusicPlay()"] N2["stb_vorbis_open_filename()<br/>Parse headers only, ~1ms"] N3["Fill 4 buffers<br/>4096 samples each = 16KB<br/>~0.37s of audio runway"] N4["alSourceQueueBuffers()<br/>alSourcePlay()"] N5["MusicStreamUpdate()<br/>Called via Win32 timer"] N6{"Buffers<br/>processed?"} N7["Unqueue, decode next chunk,<br/>re-queue"] N8["Source starved?<br/>alSourcePlay() recovery"] N1 --> N2 --> N3 --> N4 --> N5 --> N6 N6 -- Yes --> N7 --> N5 N6 -- "No, but stopped" --> N8 --> N5 end subgraph OLD["Full Decode (Old)"] direction TB O1["MusicPlay()"] O2["stb_vorbis_decode_filename()<br/>Decode entire OGG to PCM<br/>~31MB, blocks 100-500ms"] O3["alBufferData()<br/>Upload full PCM to 1 buffer"] O4["alSourcePlay()<br/>AL_LOOPING = TRUE"] O5["OpenAL audio thread<br/>plays indefinitely"] O1 --> O2 --> O3 --> O4 --> O5 end style O2 fill:#ff0000,color:#fff style N2 fill:#2f9e44,color:#fff ```
This commit is contained in:
parent
11f5068607
commit
e1b4aa8fef
4 changed files with 335 additions and 64 deletions
|
|
@ -21,8 +21,8 @@
|
|||
#include <unordered_map>
|
||||
#include <filesystem>
|
||||
|
||||
// STB Vorbis for OGG decoding (compiled separately with STB_VORBIS_NO_PUSHDATA_API)
|
||||
extern "C" int stb_vorbis_decode_filename(const char *filename, int *channels, int *sample_rate, short **output);
|
||||
#define STB_VORBIS_HEADER_ONLY
|
||||
#include <stb_vorbis.c>
|
||||
|
||||
// Configuration
|
||||
static const int MAX_AUDIO_SOURCES = 32;
|
||||
|
|
@ -72,10 +72,34 @@ static std::list<CacheNode> g_cacheList;
|
|||
static std::unordered_map<std::string, std::list<CacheNode>::iterator,
|
||||
CaseInsensitiveHash, CaseInsensitiveEqual> g_cacheMap;
|
||||
|
||||
// Music state
|
||||
static ALuint g_musicSource = 0;
|
||||
static ALuint g_musicBuffer = 0;
|
||||
static bool g_musicPlaying = false;
|
||||
// Music streaming state
|
||||
static const int STREAM_NUM_BUFFERS = 4;
|
||||
static const int STREAM_BUFFER_SAMPLES = 4096;
|
||||
|
||||
struct MusicStream {
|
||||
stb_vorbis* vorbis;
|
||||
ALuint source;
|
||||
ALuint buffers[4];
|
||||
ALenum format;
|
||||
int channels;
|
||||
int sample_rate;
|
||||
bool looping;
|
||||
bool playing;
|
||||
bool finished;
|
||||
};
|
||||
|
||||
static MusicStream g_music = {};
|
||||
|
||||
/* Timer keeps streaming alive during modal dialogs (e.g. login dialog)
|
||||
* whose internal message pump blocks MainIdle from running. */
|
||||
static const int STREAM_TIMER_MS = 50;
|
||||
static UINT_PTR g_streamTimerId = 0;
|
||||
static void MusicStreamUpdate(void);
|
||||
|
||||
static void CALLBACK MusicStreamTimerProc(HWND, UINT, UINT_PTR, DWORD)
|
||||
{
|
||||
MusicStreamUpdate();
|
||||
}
|
||||
|
||||
// Master volume
|
||||
static float g_masterVolume = 1.0f;
|
||||
|
|
@ -128,12 +152,18 @@ bool AudioInit(HWND hWnd)
|
|||
}
|
||||
g_numSources = MAX_AUDIO_SOURCES;
|
||||
|
||||
alGenSources(1, &g_musicSource);
|
||||
alGenSources(1, &g_music.source);
|
||||
if (alGetError() != AL_NO_ERROR)
|
||||
{
|
||||
debug(("AudioInit: Failed to generate music source\n"));
|
||||
}
|
||||
|
||||
alGenBuffers(STREAM_NUM_BUFFERS, g_music.buffers);
|
||||
if (alGetError() != AL_NO_ERROR)
|
||||
{
|
||||
debug(("AudioInit: Failed to generate music stream buffers\n"));
|
||||
}
|
||||
|
||||
alListener3f(AL_POSITION, 0.0f, 0.0f, 0.0f);
|
||||
alListener3f(AL_VELOCITY, 0.0f, 0.0f, 0.0f);
|
||||
ALfloat listenerOri[] = { 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f }; // forward, up
|
||||
|
|
@ -159,12 +189,14 @@ void AudioShutdown(void)
|
|||
SoundStopAll();
|
||||
MusicStop();
|
||||
|
||||
if (g_musicSource)
|
||||
if (g_music.source)
|
||||
{
|
||||
alDeleteSources(1, &g_musicSource);
|
||||
g_musicSource = 0;
|
||||
alDeleteSources(1, &g_music.source);
|
||||
g_music.source = 0;
|
||||
}
|
||||
|
||||
alDeleteBuffers(STREAM_NUM_BUFFERS, g_music.buffers);
|
||||
|
||||
if (g_numSources > 0)
|
||||
{
|
||||
alDeleteSources(g_numSources, g_sources);
|
||||
|
|
@ -338,91 +370,267 @@ static ALuint ParseOGGFile(const char* filename)
|
|||
}
|
||||
|
||||
/*
|
||||
* MusicPlay: Play background music file
|
||||
* MusicStreamFillBuffer: Returns true if audio was written to the buffer,
|
||||
* false if the stream ended without producing samples.
|
||||
*/
|
||||
static bool MusicStreamFillBuffer(MusicStream* ms, ALuint buffer)
|
||||
{
|
||||
short pcm[STREAM_BUFFER_SAMPLES * 2]; // Max stereo interleaved
|
||||
int num_shorts = STREAM_BUFFER_SAMPLES * ms->channels;
|
||||
int samples = 0;
|
||||
|
||||
// Decode a chunk. stb_vorbis returns the number of samples per channel.
|
||||
samples = stb_vorbis_get_samples_short_interleaved(
|
||||
ms->vorbis, ms->channels, pcm, num_shorts);
|
||||
|
||||
if (samples == 0)
|
||||
{
|
||||
int err = stb_vorbis_get_error(ms->vorbis);
|
||||
debug(("MusicStreamFillBuffer: decode returned 0 samples (error=%d, looping=%d)\n",
|
||||
err, (int)ms->looping));
|
||||
|
||||
// End of file. If looping, seek to start and try again.
|
||||
if (ms->looping)
|
||||
{
|
||||
stb_vorbis_seek_start(ms->vorbis);
|
||||
samples = stb_vorbis_get_samples_short_interleaved(
|
||||
ms->vorbis, ms->channels, pcm, num_shorts);
|
||||
}
|
||||
|
||||
if (samples == 0)
|
||||
{
|
||||
debug(("MusicStreamFillBuffer: still 0 after seek, marking finished\n"));
|
||||
ms->finished = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
alBufferData(buffer, ms->format, pcm,
|
||||
samples * ms->channels * (int)sizeof(short), ms->sample_rate);
|
||||
|
||||
ALenum bufErr = alGetError();
|
||||
if (bufErr != AL_NO_ERROR)
|
||||
{
|
||||
debug(("MusicStreamFillBuffer: alBufferData failed (err=0x%X, buf=%u, samples=%d)\n",
|
||||
bufErr, buffer, samples));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* MusicPlay: Opens an OGG file for streaming playback. Returns true on
|
||||
* success. Only parses headers and fills initial buffers.
|
||||
*/
|
||||
bool MusicPlay(const char* filename, bool loop)
|
||||
{
|
||||
ALuint newBuffer;
|
||||
ALint sourceState;
|
||||
|
||||
if (!g_initialized)
|
||||
{
|
||||
debug(("MusicPlay: OpenAL not initialized!\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always stop any currently playing music unconditionally
|
||||
// This ensures we stop even if our flag got out of sync with OpenAL state
|
||||
alSourceStop(g_musicSource);
|
||||
alSourcei(g_musicSource, AL_BUFFER, 0); // Detach buffer
|
||||
// Drain any leftover OpenAL error from sound effect operations.
|
||||
alGetError();
|
||||
|
||||
// Verify the source actually stopped (defensive check)
|
||||
alGetSourcei(g_musicSource, AL_SOURCE_STATE, &sourceState);
|
||||
if (sourceState == AL_PLAYING)
|
||||
MusicStop();
|
||||
|
||||
// Drain any errors generated by MusicStop's OpenAL calls.
|
||||
alGetError();
|
||||
|
||||
if (!std::filesystem::exists(filename))
|
||||
{
|
||||
debug(("MusicPlay: Warning - source still playing after stop!\n"));
|
||||
}
|
||||
|
||||
// Delete old buffer if we had one (now safe since source is stopped and detached)
|
||||
if (g_musicBuffer != 0)
|
||||
{
|
||||
alDeleteBuffers(1, &g_musicBuffer);
|
||||
g_musicBuffer = 0;
|
||||
}
|
||||
|
||||
g_musicPlaying = false;
|
||||
|
||||
// Load OGG file (filename already includes path) - no caching for music
|
||||
newBuffer = ParseOGGFile(filename);
|
||||
if (newBuffer == 0)
|
||||
{
|
||||
debug(("MusicPlay: Failed to load %s\n", filename));
|
||||
debug(("MusicPlay: File not found: %s\n", filename));
|
||||
return false;
|
||||
}
|
||||
|
||||
g_musicBuffer = newBuffer;
|
||||
|
||||
alSourcei(g_musicSource, AL_BUFFER, g_musicBuffer);
|
||||
alSourcei(g_musicSource, AL_LOOPING, loop ? AL_TRUE : AL_FALSE);
|
||||
alSourcei(g_musicSource, AL_SOURCE_RELATIVE, AL_TRUE);
|
||||
alSource3f(g_musicSource, AL_POSITION, 0.0f, 0.0f, 0.0f);
|
||||
alSourcef(g_musicSource, AL_GAIN, (float)config.music_volume / 100.0f);
|
||||
|
||||
alSourcePlay(g_musicSource);
|
||||
|
||||
if (alGetError() != AL_NO_ERROR)
|
||||
// Open the vorbis stream (reads headers only, not a full decode)
|
||||
int error = 0;
|
||||
g_music.vorbis = stb_vorbis_open_filename(filename, &error, NULL);
|
||||
if (!g_music.vorbis)
|
||||
{
|
||||
debug(("MusicPlay: Failed to play music\n"));
|
||||
debug(("MusicPlay: Failed to open %s (error %d)\n", filename, error));
|
||||
return false;
|
||||
}
|
||||
|
||||
g_musicPlaying = true;
|
||||
stb_vorbis_info info = stb_vorbis_get_info(g_music.vorbis);
|
||||
g_music.channels = info.channels;
|
||||
g_music.sample_rate = info.sample_rate;
|
||||
g_music.looping = loop;
|
||||
g_music.finished = false;
|
||||
|
||||
if (info.channels == 1)
|
||||
g_music.format = AL_FORMAT_MONO16;
|
||||
else
|
||||
g_music.format = AL_FORMAT_STEREO16;
|
||||
|
||||
// Fill initial buffers with the first few chunks of decoded audio
|
||||
int queued = 0;
|
||||
for (int i = 0; i < STREAM_NUM_BUFFERS; i++)
|
||||
{
|
||||
if (MusicStreamFillBuffer(&g_music, g_music.buffers[i]))
|
||||
queued++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
if (queued == 0)
|
||||
{
|
||||
debug(("MusicPlay: No audio data in %s\n", filename));
|
||||
stb_vorbis_close(g_music.vorbis);
|
||||
g_music.vorbis = NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* OpenAL sources must be in AL_INITIAL state before queueing buffers.
|
||||
* Setting AL_BUFFER to 0 detaches any previous buffer and resets the
|
||||
* source. AL_LOOPING is off because OpenAL's looping only replays a
|
||||
* single buffer, not the whole queue. We loop manually by seeking the
|
||||
* decoder back to the start when it reaches end-of-file. */
|
||||
alSourcei(g_music.source, AL_BUFFER, 0);
|
||||
alSourcei(g_music.source, AL_LOOPING, AL_FALSE);
|
||||
alSourcei(g_music.source, AL_SOURCE_RELATIVE, AL_TRUE);
|
||||
alSource3f(g_music.source, AL_POSITION, 0.0f, 0.0f, 0.0f);
|
||||
alSourcef(g_music.source, AL_GAIN, (float)config.music_volume / 100.0f);
|
||||
|
||||
alGetError(); // Clear errors before queue+play
|
||||
alSourceQueueBuffers(g_music.source, queued, g_music.buffers);
|
||||
ALenum queueErr = alGetError();
|
||||
alSourcePlay(g_music.source);
|
||||
ALenum playErr = alGetError();
|
||||
|
||||
if (queueErr != AL_NO_ERROR || playErr != AL_NO_ERROR)
|
||||
{
|
||||
debug(("MusicPlay: Failed - source=%u, queueErr=0x%X, playErr=0x%X\n",
|
||||
g_music.source, queueErr, playErr));
|
||||
stb_vorbis_close(g_music.vorbis);
|
||||
g_music.vorbis = NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
g_music.playing = true;
|
||||
|
||||
/* A Win32 timer calls MusicStreamUpdate to rotate the streaming
|
||||
* buffers. WM_TIMER messages are dispatched by any message pump, so
|
||||
* streaming keeps going even during modal dialogs (e.g. the login
|
||||
* screen) that block the main game loop. */
|
||||
if (g_streamTimerId == 0)
|
||||
g_streamTimerId = SetTimer(NULL, 0, STREAM_TIMER_MS, MusicStreamTimerProc);
|
||||
|
||||
debug(("MusicPlay: streaming '%s' ch=%d rate=%d looping=%d source=%u\n",
|
||||
filename, g_music.channels, g_music.sample_rate, (int)loop,
|
||||
g_music.source));
|
||||
return true;
|
||||
}
|
||||
|
||||
void MusicStop(void)
|
||||
/*
|
||||
* MusicStreamUpdate: Refills processed OpenAL buffers from the vorbis
|
||||
* stream. Recovers from buffer underruns by restarting the source.
|
||||
* Called by the Win32 timer; not called directly from the game loop.
|
||||
*/
|
||||
static void MusicStreamUpdate(void)
|
||||
{
|
||||
if (!g_initialized || !g_musicSource)
|
||||
if (!g_initialized || !g_music.playing)
|
||||
return;
|
||||
|
||||
alSourceStop(g_musicSource);
|
||||
// Detach buffer from source before any future buffer deletion
|
||||
alSourcei(g_musicSource, AL_BUFFER, 0);
|
||||
g_musicPlaying = false;
|
||||
// Drain any stale error from other OpenAL calls (SFX, etc.)
|
||||
alGetError();
|
||||
|
||||
ALint state = 0;
|
||||
alGetSourcei(g_music.source, AL_SOURCE_STATE, &state);
|
||||
|
||||
// Check how many buffers the source has finished playing
|
||||
ALint processed = 0;
|
||||
alGetSourcei(g_music.source, AL_BUFFERS_PROCESSED, &processed);
|
||||
|
||||
while (processed > 0)
|
||||
{
|
||||
ALuint buf;
|
||||
alSourceUnqueueBuffers(g_music.source, 1, &buf);
|
||||
|
||||
if (!g_music.finished)
|
||||
{
|
||||
if (MusicStreamFillBuffer(&g_music, buf))
|
||||
{
|
||||
alSourceQueueBuffers(g_music.source, 1, &buf);
|
||||
}
|
||||
}
|
||||
|
||||
processed--;
|
||||
}
|
||||
|
||||
if (state != AL_PLAYING)
|
||||
{
|
||||
ALint queued = 0;
|
||||
alGetSourcei(g_music.source, AL_BUFFERS_QUEUED, &queued);
|
||||
if (queued > 0)
|
||||
{
|
||||
// Source starved. Restart playback with whatever is queued.
|
||||
alSourcePlay(g_music.source);
|
||||
}
|
||||
else
|
||||
{
|
||||
debug(("MusicStreamUpdate: all buffers consumed, playback done\n"));
|
||||
g_music.playing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* MusicStop: Stops playback and releases the streaming decoder.
|
||||
*/
|
||||
void MusicStop(void)
|
||||
{
|
||||
if (!g_initialized)
|
||||
return;
|
||||
|
||||
debug(("MusicStop: called (playing=%d, vorbis=%p)\n",
|
||||
(int)g_music.playing, (void*)g_music.vorbis));
|
||||
|
||||
alSourceStop(g_music.source);
|
||||
|
||||
// Unqueue all buffers so they can be reused
|
||||
ALint queued = 0;
|
||||
alGetSourcei(g_music.source, AL_BUFFERS_QUEUED, &queued);
|
||||
while (queued > 0)
|
||||
{
|
||||
ALuint buf;
|
||||
alSourceUnqueueBuffers(g_music.source, 1, &buf);
|
||||
queued--;
|
||||
}
|
||||
|
||||
// Fully reset source to AL_INITIAL so it is ready for queue mode next time
|
||||
alSourcei(g_music.source, AL_BUFFER, 0);
|
||||
|
||||
if (g_music.vorbis)
|
||||
{
|
||||
stb_vorbis_close(g_music.vorbis);
|
||||
g_music.vorbis = NULL;
|
||||
}
|
||||
|
||||
g_music.playing = false;
|
||||
g_music.finished = false;
|
||||
|
||||
if (g_streamTimerId != 0)
|
||||
{
|
||||
KillTimer(NULL, g_streamTimerId);
|
||||
g_streamTimerId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void MusicSetVolume(float volume)
|
||||
{
|
||||
g_musicVolume = volume;
|
||||
if (g_initialized && g_musicSource)
|
||||
if (g_initialized && g_music.source)
|
||||
{
|
||||
alSourcef(g_musicSource, AL_GAIN, volume * g_masterVolume);
|
||||
alSourcef(g_music.source, AL_GAIN, volume * g_masterVolume);
|
||||
}
|
||||
}
|
||||
|
||||
bool MusicIsPlaying(void)
|
||||
{
|
||||
return g_musicPlaying;
|
||||
return g_music.playing;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
bool AudioInit(HWND hWnd);
|
||||
void AudioShutdown(void);
|
||||
|
||||
// Music control (for MP3/OGG background music)
|
||||
// Music control (for background music via streaming playback)
|
||||
bool MusicPlay(const char* filename, bool loop);
|
||||
void MusicStop(void);
|
||||
void MusicSetVolume(float volume); // 0.0 to 1.0
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ LIBPNGDIR = $(EXTERNALDIR)\libpng
|
|||
ZLIBDIR = $(EXTERNALDIR)\zlib
|
||||
OPENALDIR = $(EXTERNALDIR)\openal-soft\openal-soft-1.24.3-bin
|
||||
FMTLIBDIR = $(EXTERNALDIR)\fmtlib
|
||||
STBDIR = $(EXTERNALDIR)\stb
|
||||
|
||||
BLAKBINDIR = $(TOPDIR)\bin
|
||||
BLAKLIBDIR = $(TOPDIR)\lib
|
||||
|
|
@ -134,4 +135,4 @@ MAKEBGF = $(BLAKBINDIR)\makebgf
|
|||
# environment variables for compiler
|
||||
|
||||
LIB = $(LIB);$(BLAKLIBDIR)
|
||||
INCLUDE = $(INCLUDE);$(BLAKINCLUDEDIR);$(LIBARCHIVEDIR);$(LIBPNGDIR);$(ZLIBDIR);$(OPENALDIR)\include;$(FMTLIBDIR);
|
||||
INCLUDE = $(INCLUDE);$(BLAKINCLUDEDIR);$(LIBARCHIVEDIR);$(LIBPNGDIR);$(ZLIBDIR);$(OPENALDIR)\include;$(FMTLIBDIR);$(STBDIR);
|
||||
|
|
|
|||
|
|
@ -80,8 +80,9 @@ graph TD
|
|||
|----------|---------|
|
||||
| `AudioInit(hwnd)` | Initialize OpenAL device, context, and sources |
|
||||
| `AudioShutdown()` | Clean up all audio resources |
|
||||
| `MusicPlay(filename, loop)` | Play OGG file on dedicated music source |
|
||||
| `MusicStop()` | Stop music playback |
|
||||
| `MusicPlay(filename, loop)` | Open OGG for streaming and start playback |
|
||||
| `MusicStop()` | Stop music and release streaming decoder |
|
||||
| `MusicIsPlaying()` | Returns true if music is currently streaming |
|
||||
| `MusicSetVolume(volume)` | Set music volume (0.0 - 1.0) |
|
||||
| `SoundPlay(filename, volume, flags, ...)` | Play sound effect with optional 3D positioning |
|
||||
| `SoundStopAll()` | Stop all sound effects |
|
||||
|
|
@ -158,6 +159,67 @@ Sound effects are cached using an LRU (Least Recently Used) strategy:
|
|||
- **Lookup:** O(1) via case-insensitive hash map
|
||||
|
||||
Music is NOT cached because tracks are large and typically don't repeat rapidly.
|
||||
Instead, music uses streaming playback (see below).
|
||||
|
||||
## Music Streaming
|
||||
|
||||
Music files are played via streaming rather than full-file decoding. This eliminates
|
||||
the loading hitch that occurred when decompressing entire OGG files (30-50 MB of
|
||||
decoded PCM from 2-7 MB OGG files) in a single blocking call on the main thread.
|
||||
|
||||
### How It Works
|
||||
|
||||
On `MusicPlay()`, only the OGG headers are parsed (~1ms). Audio is decoded in small
|
||||
chunks and fed to OpenAL through a ring buffer:
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Buffer count | 4 |
|
||||
| Samples per buffer | 4096 (~93ms at 44100 Hz) |
|
||||
| Total audio runway | ~0.37 seconds |
|
||||
| Memory per buffer | 16,384 bytes (stereo 16-bit) |
|
||||
| Total streaming memory | ~64 KB (vs 30-50 MB full decode) |
|
||||
|
||||
`MusicStreamUpdate()` is called by a Win32 timer (`MusicStreamTimerProc`).
|
||||
It queries `AL_BUFFERS_PROCESSED` to find
|
||||
consumed buffers, unqueues them, decodes the next chunk of Vorbis data, and re-queues.
|
||||
If the source runs out of buffers (underrun), it restarts playback automatically.
|
||||
Looping is handled by seeking the Vorbis stream back to the start when it reaches EOF.
|
||||
|
||||
The timer approach means streaming works in every client state: normal gameplay, the
|
||||
splash screen, and modal dialogs (e.g. the login dialog) whose internal message pump
|
||||
would otherwise block the main game loop. `MusicStreamUpdate()` is static to
|
||||
`audio_openal.c` and not exposed in the public API.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Buffer Ring (4 x 16KB)"
|
||||
B1["Buf 1<br/>Playing"]
|
||||
B2["Buf 2<br/>Queued"]
|
||||
B3["Buf 3<br/>Queued"]
|
||||
B4["Buf 4<br/>Decoding"]
|
||||
end
|
||||
|
||||
subgraph "Update Loop"
|
||||
U1["alGetSourcei<br/>AL_BUFFERS_PROCESSED"]
|
||||
U2["alSourceUnqueueBuffers"]
|
||||
U3["stb_vorbis_get_samples<br/>4096 samples"]
|
||||
U4["alBufferData + alSourceQueueBuffers"]
|
||||
end
|
||||
|
||||
subgraph "Callers"
|
||||
C1["Win32 SetTimer<br/>MusicStreamTimerProc"]
|
||||
end
|
||||
|
||||
U1 --> U2 --> U3 --> U4
|
||||
C1 --> U1
|
||||
B4 -.-> U3
|
||||
|
||||
style B1 fill:#2f9e44,color:#fff
|
||||
style B2 fill:#1864ab,color:#fff
|
||||
style B3 fill:#1864ab,color:#fff
|
||||
style B4 fill:#e67700,color:#fff
|
||||
```
|
||||
|
||||
## 3D Positional Audio
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue