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:
Adrien Laws 2026-03-21 22:12:24 -06:00 committed by GitHub
parent 11f5068607
commit e1b4aa8fef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 335 additions and 64 deletions

View file

@ -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;
}
/*

View file

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

View file

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

View file

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