summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Racine <bass_dr@hotmail.com>2026-06-14 14:06:44 -0400
committerGitHub <noreply@github.com>2026-06-14 18:06:44 +0000
commit4415537deb9942ae7f2bb8d5fe458422b829bb1a (patch)
tree14a65fe887d37c5edf563ffe536d1cf20a004d99
parent041295f0586098bbf9ca635516941714daf36a31 (diff)
fix(audio): Fix audio stutter when gfx hitches (#6704)
The audio thread will now self-pump every 5 ms in case the rendering loop takes too much time before waking up the audio thread, causing audio starvation. This is what was causing audio stutters/cuts during world loading. I had the issue constantly the first time I pressed start to get the game menu. To avoid issues, the first wakeup of the audio thread is behind a `primed` flag and will wait unconditionnally for the rendering loop to wake us up. This is to make sure the game has initialized properly and avoid a crash on boot.
-rw-r--r--soh/soh/OTRAudio.h6
-rw-r--r--soh/soh/OTRGlobals.cpp110
2 files changed, 82 insertions, 34 deletions
diff --git a/soh/soh/OTRAudio.h b/soh/soh/OTRAudio.h
index 5ba4b23de..eaa0d1edc 100644
--- a/soh/soh/OTRAudio.h
+++ b/soh/soh/OTRAudio.h
@@ -4,8 +4,8 @@
static struct {
std::thread thread;
- std::condition_variable cv_to_thread, cv_from_thread;
+ std::condition_variable cv_to_thread;
std::mutex mutex;
- bool running;
- bool processing;
+ std::atomic_bool running;
+ std::atomic_bool processing;
} audio;
diff --git a/soh/soh/OTRGlobals.cpp b/soh/soh/OTRGlobals.cpp
index 0c889ad5b..7fff1ba28 100644
--- a/soh/soh/OTRGlobals.cpp
+++ b/soh/soh/OTRGlobals.cpp
@@ -835,7 +835,11 @@ void OTRGlobals::Initialize() {
CVarGetInteger(CVAR_SETTING("AutoCaptureMouse"), 1));
context->GetWindow()->SetForceCursorVisibility(CVarGetInteger(CVAR_SETTING("CursorVisibility"), 0));
- context->InitAudio({ .SampleRate = 32000, .SampleLength = 1024, .DesiredBuffered = 1680 });
+ context->InitAudio({ .SampleRate = 32000,
+ .SampleLength = 1024,
+ // 4096 frames at 32 kHz (~128 ms) gives enough reservoir for frame
+ // jitter and slow-frame spikes without perceptible audio latency.
+ .DesiredBuffered = 4096 });
SPDLOG_INFO("Starting Ship of Harkinian version {} (Branch: {} | Commit: {})", (char*)gBuildVersion,
(char*)gGitBranch, (char*)gGitCommitHash);
@@ -1020,42 +1024,93 @@ extern "C" int AudioPlayer_GetDesiredBuffered(void);
std::unordered_map<std::string, ExtensionEntry> ExtensionCache;
void OTRAudio_Thread() {
- while (audio.running) {
- {
- std::unique_lock<std::mutex> Lock(audio.mutex);
- while (!audio.processing && audio.running) {
- audio.cv_to_thread.wait(Lock);
- }
-
- if (!audio.running) {
- break;
- }
- }
- std::unique_lock<std::mutex> Lock(audio.mutex);
-// AudioMgr_ThreadEntry(&gAudioMgr);
-// 528 and 544 relate to 60 fps at 32 kHz 32000/60 = 533.333..
-// in an ideal world, one third of the calls should use num_samples=544 and two thirds num_samples=528
#define SAMPLES_HIGH 560
#define SAMPLES_LOW 528
-
#define AUDIO_FRAMES_PER_UPDATE (R_UPDATE_RATE > 0 ? R_UPDATE_RATE : 1)
#define NUM_AUDIO_CHANNELS 2
- int samples_left = AudioPlayer_Buffered();
- u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW;
+ // Single producer routine used by both the wake-driven and pre-buffer
+ // loops. Captures the per-iteration sample count from the caller.
+ auto produce_and_play = [&](u32 num_audio_samples) {
+ const u32 total_frames = num_audio_samples * AUDIO_FRAMES_PER_UPDATE;
+ const u32 total_samples = total_frames * NUM_AUDIO_CHANNELS;
// 3 is the maximum authentic frame divisor.
- s16 audio_buffer[SAMPLES_HIGH * NUM_AUDIO_CHANNELS * 3];
+ static thread_local s16 audio_buffer[SAMPLES_HIGH * NUM_AUDIO_CHANNELS * 3];
+
for (int i = 0; i < AUDIO_FRAMES_PER_UPDATE; i++) {
AudioMgr_CreateNextAudioBuffer(audio_buffer + i * (num_audio_samples * NUM_AUDIO_CHANNELS),
num_audio_samples);
}
- AudioPlayer_Play((u8*)audio_buffer,
- num_audio_samples * (sizeof(int16_t) * NUM_AUDIO_CHANNELS * AUDIO_FRAMES_PER_UPDATE));
+ AudioPlayer_Play(reinterpret_cast<u8*>(audio_buffer), total_samples * sizeof(int16_t));
+ };
+
+ // Self-pump cadence. The gfx thread wakes us once per rendered frame
+ // (Graph_ProcessGfxCommands sets audio.processing), but a single long
+ // frame leave us asleep while the backend's queue drains to silence.
+ // So we also wake on a short timeout, independent of the gfx frame rate.
+ // Doing so is in fact closer to the console, where the audio task ran
+ // off the scheduler rather than gated on rendering..
+ constexpr auto kSelfPumpInterval = std::chrono::milliseconds(5);
+
+ // The self-pump timeout must wait that the game has reached its render
+ // loop, to avoid accessing uninitialized variables.
+ bool primed = false;
+
+ while (audio.running) {
+ {
+ std::unique_lock<std::mutex> Lock(audio.mutex);
+ if (!primed) {
+ // Pre-init: block until the gfx thread drives the first buffer
+ // (engine guaranteed ready by then), exactly as before.
+ while (!audio.processing && audio.running) {
+ audio.cv_to_thread.wait(Lock);
+ }
+ primed = true;
+ } else if (!audio.processing && audio.running) {
+ // Primed: wait for the next gfx wake, but no longer than
+ // kSelfPumpInterval so a stalled gfx thread can't starve the
+ // backend queue. A pending wake falls straight through.
+ audio.cv_to_thread.wait_for(Lock, kSelfPumpInterval);
+ }
+
+ if (!audio.running) {
+ break;
+ }
+ }
+
+ {
+ std::unique_lock<std::mutex> Lock(audio.mutex);
+ int samples_left = AudioPlayer_Buffered();
+ u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW;
+
+ // Producer guard (banteg/Shipwright#6594): skip advancing the audio
+ // engine if the backend ring cannot accept the smallest next burst.
+ // Generating PCM that DoPlay() would refuse creates a discontinuity
+ // audible as a click. The pre-buffer loop below will catch up once
+ // the backend drains enough.
+ if (AudioPlayer_Buffered() + SAMPLES_LOW * AUDIO_FRAMES_PER_UPDATE > AudioPlayer_GetDesiredBuffered()) {
+ audio.processing = false;
+ } else {
+ produce_and_play(num_audio_samples);
+ audio.processing = false;
+ }
+ }
- audio.processing = false;
- audio.cv_from_thread.notify_one();
+ // Pre-buffer: fill the reservoir while the backend can accept more,
+ // without waiting for the next frame signal. This absorbs load spikes.
+ // Safe for BGM — the N64 sequencer advances independently of gameplay.
+ // The producer guard (same as above) prevents advancing the audio engine
+ // when the backend ring is already at capacity.
+ while (audio.running && AudioPlayer_Buffered() < AudioPlayer_GetDesiredBuffered()) {
+ if (AudioPlayer_Buffered() + SAMPLES_LOW * AUDIO_FRAMES_PER_UPDATE > AudioPlayer_GetDesiredBuffered()) {
+ break;
+ }
+ int samples_left = AudioPlayer_Buffered();
+ u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW;
+ produce_and_play(num_audio_samples);
+ }
}
}
@@ -1791,13 +1846,6 @@ extern "C" void Graph_ProcessGfxCommands(Gfx* commands) {
last_fps = fps;
last_update_rate = R_UPDATE_RATE;
- {
- std::unique_lock<std::mutex> Lock(audio.mutex);
- while (audio.processing) {
- audio.cv_from_thread.wait(Lock);
- }
- }
-
bool curAltAssets = CVarGetInteger(CVAR_SETTING("AltAssets"), 1);
if (prevAltAssets != curAltAssets) {
prevAltAssets = curAltAssets;