From 3894911f25d0aebda2fd76695f36f27b74ab0eae Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Mon, 4 May 2026 16:54:05 +0400 Subject: [PATCH 1/2] fix(audio): keep producer reservoir filled across frame jitter the audio producer is woken once per Graph_ProcessGfxCommands call and pushes a single ~1680-sample burst sized for the current R_UPDATE_RATE. two layered issues caused crackling on macos over hdmi: - DesiredBuffered = 1680 was exactly one producer burst, so any video frame jitter emptied the device buffer between wakes -- audible as clicks. bump it to 4096 (~128 ms at 32 khz). - on the file-select screen R_UPDATE_RATE is 1, which expects the producer to wake at ~60 hz. when the user's display rate is 20 fps the producer only wakes 20 times/s and structurally underproduces by 3x, no reservoir size can cover that. fix by running additional audio engine ticks within the same outer call until the reservoir is back at desired (capped at 6 iterations). each tick advances internal audio time by num_samples / sample_rate and the consumer drains them at the same rate, so audio still plays at normal speed. also bumps the libultraship submodule to fix-coreaudio-crackling, which replaces the locked ring buffer in the coreaudio backend with a lock-free spsc ring (eliminating the priority-inversion path) and fixes the wrap-equality + whole-chunk-drop bugs that produced clicks even on the built-in speaker path. verified on hdmi: underflow count over a 65 s session dropped from 1023 to 13 (only at startup), buffer level stays in the 3000-5500 frame range, never empties. --- libultraship | 2 +- soh/soh/OTRGlobals.cpp | 40 ++++++++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/libultraship b/libultraship index fdcaf633677..f2b3b6befd2 160000 --- a/libultraship +++ b/libultraship @@ -1 +1 @@ -Subproject commit fdcaf6336776d24a6408d016b0a52243f108f250 +Subproject commit f2b3b6befd2a4f8e8bb2f247456bf659d003afd4 diff --git a/soh/soh/OTRGlobals.cpp b/soh/soh/OTRGlobals.cpp index 697f6e30eb7..15bceee5152 100644 --- a/soh/soh/OTRGlobals.cpp +++ b/soh/soh/OTRGlobals.cpp @@ -806,7 +806,14 @@ void OTRGlobals::Initialize() { CVarGetInteger(CVAR_SETTING("AutoCaptureMouse"), 1)); context->GetWindow()->SetForceCursorVisibility(CVarGetInteger(CVAR_SETTING("CursorVisibility"), 0)); - context->InitAudio({ .SampleRate = 32000, .SampleLength = 1024, .DesiredBuffered = 1680 }); + // DesiredBuffered is the steady-state reservoir the audio producer aims for. + // The producer fires once per video frame (~20 Hz on the original update rate) + // and pushes a single ~1680-sample burst, so a value of 1680 leaves no room + // for any frame jitter -- one missed video update empties the device buffer + // and produces an audible click. 4096 (~128 ms at 32 kHz) gives the producer + // enough headroom to cover a few slow frames without underflowing, + // particularly over HDMI where the consumer pace is set by the sink clock. + context->InitAudio({ .SampleRate = 32000, .SampleLength = 1024, .DesiredBuffered = 4096 }); SPDLOG_INFO("Starting Ship of Harkinian version {} (Branch: {} | Commit: {})", (char*)gBuildVersion, (char*)gGitBranch, (char*)gGitCommitHash); @@ -1031,18 +1038,31 @@ void OTRAudio_Thread() { #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; - // 3 is the maximum authentic frame divisor. 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)); + // The producer is woken once per Graph_ProcessGfxCommands call, whose + // rate depends on the user's display-rate setting and the current + // R_UPDATE_RATE (1, 2, or 3). When the producer's call rate falls + // below sampleRate / (AUDIO_FRAMES_PER_UPDATE * num_audio_samples), + // a single tick per call underproduces and the device underruns -- + // most visibly on the file-select screen at low display rates, + // where R_UPDATE_RATE = 1 expects ~60 wakes/s. To compensate, run + // additional audio engine ticks within the same outer call until + // the reservoir is back at the desired level. Each tick advances + // internal audio time by num_samples / sample_rate, and the consumer + // drains them at the same rate, so audio still plays at normal speed. + int max_iters = 6; + do { + int samples_left = AudioPlayer_Buffered(); + u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW; + 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)); + } while (AudioPlayer_Buffered() < AudioPlayer_GetDesiredBuffered() && --max_iters > 0); audio.processing = false; audio.cv_from_thread.notify_one(); From 84473b141b95a28b726d5f0be80b1db58284366a Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Tue, 5 May 2026 20:20:14 +0400 Subject: [PATCH 2/2] fix(audio): avoid overfilling backend audio buffer --- soh/soh/OTRGlobals.cpp | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/soh/soh/OTRGlobals.cpp b/soh/soh/OTRGlobals.cpp index 15bceee5152..da19a0ecb49 100644 --- a/soh/soh/OTRGlobals.cpp +++ b/soh/soh/OTRGlobals.cpp @@ -1037,6 +1037,7 @@ void OTRAudio_Thread() { #define AUDIO_FRAMES_PER_UPDATE (R_UPDATE_RATE > 0 ? R_UPDATE_RATE : 1) #define NUM_AUDIO_CHANNELS 2 +#define AUDIO_BUFFER_CAPACITY_FRAMES 6000 // 3 is the maximum authentic frame divisor. s16 audio_buffer[SAMPLES_HIGH * NUM_AUDIO_CHANNELS * 3]; @@ -1053,16 +1054,20 @@ void OTRAudio_Thread() { // internal audio time by num_samples / sample_rate, and the consumer // drains them at the same rate, so audio still plays at normal speed. int max_iters = 6; - do { - int samples_left = AudioPlayer_Buffered(); - u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW; - 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)); - } while (AudioPlayer_Buffered() < AudioPlayer_GetDesiredBuffered() && --max_iters > 0); + // Skip before generating audio if the backend cannot accept even the + // smallest next burst, otherwise CoreAudio truncates already-produced PCM. + if (AudioPlayer_Buffered() + (SAMPLES_LOW * AUDIO_FRAMES_PER_UPDATE) < AUDIO_BUFFER_CAPACITY_FRAMES) { + do { + int samples_left = AudioPlayer_Buffered(); + u32 num_audio_samples = samples_left < AudioPlayer_GetDesiredBuffered() ? SAMPLES_HIGH : SAMPLES_LOW; + 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)); + } while (AudioPlayer_Buffered() < AudioPlayer_GetDesiredBuffered() && --max_iters > 0); + } audio.processing = false; audio.cv_from_thread.notify_one();