Keep Mac audio buffers near 5 ms across output changes

Apply PR #262's device-buffer fix for clicks caused by audio underruns.
Reconfigure on both output-device and hardware sample-rate changes, respect
the device's frame range, and read back the actual buffer size. Avoid
rewriting unchanged sizes when buffer changes notify the format listener.
Remove listeners before teardown, including failed initialization, so they
cannot configure a device that the AudioUnit has already released.

Add hardware-independent regression coverage for switching, rate changes,
scope filtering, limits, synchronous notifications, diagnostic overrides,
absent devices, and listener cleanup. Record the original gameplay evidence
and the additional silent Core Audio validation in the audio reference.

Validation: full host tests, Mac host build, docs checks, and the iOS source
compile check (178 files) passed. Silent USB/speaker switching and speaker
rates of 44.1/96/48 kHz verified 221/480/240 frames. HDMI switching and
gameplay listening after these listener changes remain unvalidated.

Co-authored-by: parsifal295 <130349765+parsifal295@users.noreply.github.com>
This commit is contained in:
seeddsandparsifal295 committed 2026-10-27 00:41:13 +08:00
1 parent 06b7b3e563
commit 40b643bbe6
6 files changed
+220 -5

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ General host changes first needed here; each applies to every game:
- `alloc_big_align` was first needed on 13 Sentinels ([its note](../01003FC01670C000/README.md#memory), [the profile keys](../README.md#keys)). - `alloc_big_align` was first needed on 13 Sentinels ([its note](../01003FC01670C000/README.md#memory), [the profile keys](../README.md#keys)).
- **The hardware multi-stream Opus decoder's C interface** forwarded to the SDK's software decoder (`hle_codec.c`) and **`GenerateCryptographicallyRandomBytes` under the `sdk`'s own `getentropy`** (`HLE_FORCE`, `hle_os.c`): [Hardware Opus decoders built on the SDK's software decoders](../../../docs/reference/audio.md#hardware-opus-decoders-built-on-the-sdks-software-decoders), [Cryptographically random bytes](../../../docs/reference/services.md#cryptographically-random-bytes). - **The hardware multi-stream Opus decoder's C interface** forwarded to the SDK's software decoder (`hle_codec.c`) and **`GenerateCryptographicallyRandomBytes` under the `sdk`'s own `getentropy`** (`HLE_FORCE`, `hle_os.c`): [Hardware Opus decoders built on the SDK's software decoders](../../../docs/reference/audio.md#hardware-opus-decoders-built-on-the-sdks-software-decoders), [Cryptographically random bytes](../../../docs/reference/services.md#cryptographically-random-bytes).
- **A 5 ms device buffer on the Mac** (`hle_audio.c`), maintained across output-device and sample-rate changes: [The Mac's default I/O buffer](../../../docs/reference/audio.md#the-macs-default-io-buffer) records the underrun measurements, host validation and remaining checks.
- **Vibration handles answered before a controller's haptics are routed** (`hle_hid.c`): the game asks for them once at startup, before GameController reports the pad, and every value went to handle 0 (seen in the log; the game has no rumble, so nothing changed in play) ([Vibration handles before a controller is reported](../../../docs/reference/services.md#vibration-handles-before-a-controller-is-reported)). - **Vibration handles answered before a controller's haptics are routed** (`hle_hid.c`): the game asks for them once at startup, before GameController reports the pad, and every value went to handle 0 (seen in the log; the game has no rumble, so nothing changed in play) ([Vibration handles before a controller is reported](../../../docs/reference/services.md#vibration-handles-before-a-controller-is-reported)).
## Open ## Open
+47 -5
View File
@@ -160,6 +160,45 @@ static OSStatus au_render(void *ctx, AudioUnitRenderActionFlags *f, const AudioT
if (mute) memset(bl->mBuffers[0].mData, 0, bl->mBuffers[0].mDataByteSize); } if (mute) memset(bl->mBuffers[0].mData, 0, bl->mBuffers[0].mDataByteSize); }
return noErr; return noErr;
} }
#if !TARGET_OS_IPHONE
// Mac DefaultOutput's default 512 device frames can exceed the PCM a game has queued, producing zero-filled gaps.
// Request about 5 ms of device frames, as front_ios.m asks of the iOS audio session; reapply on device or output-rate changes within the supported range.
// DM_DBG_AU_FRAMES=N asks N frames instead (1024 = the iOS default, which reproduces the crackling)
static void au_buffer(AudioUnit au, const char *why) {
AudioDeviceID device = kAudioObjectUnknown; UInt32 sz = sizeof device;
if (AudioUnitGetProperty(au, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, &sz) || device == kAudioObjectUnknown) return;
AudioStreamBasicDescription df; sz = sizeof df;
OSStatus st = AudioUnitGetProperty(au, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0, &df, &sz);
if (st || !(df.mSampleRate > 0)) { LOG("[RED] audio device buffer: cannot read the output sample rate (status %d)", (int)st); return; }
const char *e = getenv("DM_DBG_AU_FRAMES");
UInt32 fr = e ? (UInt32)atoi(e) : (UInt32)(df.mSampleRate / 200 + 0.5);
AudioValueRange range; sz = sizeof range;
if (!e && !AudioUnitGetProperty(au, kAudioDevicePropertyBufferFrameSizeRange, kAudioUnitScope_Global, 0, &range, &sz)) {
if (fr < range.mMinimum) fr = (UInt32)range.mMinimum;
if (fr > range.mMaximum) fr = (UInt32)range.mMaximum;
}
// Changing the buffer can itself notify the stream-format listener. Do not write an unchanged value again.
UInt32 actual = 0; sz = sizeof actual;
st = AudioUnitGetProperty(au, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &actual, &sz);
if (st || actual != fr) st = AudioUnitSetProperty(au, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &fr, sizeof fr);
if (!st) { sz = sizeof actual; st = AudioUnitGetProperty(au, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &actual, &sz); }
LOG("%saudio device buffer: %u frames at %.0f Hz%s (requested %u, status %d)", st ? "[RED] " : "", actual, df.mSampleRate, why ? why : "", fr, (int)st);
}
static void au_device_changed(void *ctx, AudioUnit au, AudioUnitPropertyID id, AudioUnitScope scope, AudioUnitElement el) {
(void)ctx;
if (el == 0 && ((id == kAudioOutputUnitProperty_CurrentDevice && scope == kAudioUnitScope_Global) ||
(id == kAudioUnitProperty_StreamFormat && scope == kAudioUnitScope_Output)))
au_buffer(au, ", after output changed");
}
#endif
static void au_dispose(AudioUnit au) {
#if !TARGET_OS_IPHONE
// Disposal clears CurrentDevice and notifies listeners. Remove them before the unit tears down its device.
AudioUnitRemovePropertyListenerWithUserData(au, kAudioOutputUnitProperty_CurrentDevice, au_device_changed, NULL);
AudioUnitRemovePropertyListenerWithUserData(au, kAudioUnitProperty_StreamFormat, au_device_changed, NULL);
#endif
AudioUnitUninitialize(au); AudioComponentInstanceDispose(au);
}
static OSStatus au_create(aout *a) { static OSStatus au_create(aout *a) {
AudioComponentDescription d = {.componentType = kAudioUnitType_Output, AudioComponentDescription d = {.componentType = kAudioUnitType_Output,
#if TARGET_OS_IPHONE #if TARGET_OS_IPHONE
@@ -177,14 +216,17 @@ static OSStatus au_create(aout *a) {
.mChannelsPerFrame = ch, .mBitsPerChannel = 16}; .mChannelsPerFrame = ch, .mBitsPerChannel = 16};
AURenderCallbackStruct cb = {au_render, a}; AURenderCallbackStruct cb = {au_render, a};
#if !TARGET_OS_IPHONE #if !TARGET_OS_IPHONE
{ const char *e = getenv("DM_DBG_AU_FRAMES"); if (e) { // diagnostic: on the Mac, make the sound card callback ask N frames each time (1024 = the iOS default, reproduces the crackling) const AudioUnitPropertyID props[] = {kAudioOutputUnitProperty_CurrentDevice, kAudioUnitProperty_StreamFormat};
UInt32 fr = (UInt32)atoi(e); OSStatus st = AudioUnitSetProperty(a->au, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &fr, sizeof fr); for (unsigned i = 0; i < sizeof props / sizeof *props; i++) {
LOG("audio device buffer frame count set to %u: status %d", fr, (int)st); } } OSStatus st = AudioUnitAddPropertyListener(a->au, props[i], au_device_changed, NULL);
if (st) LOG("[RED] audio: cannot listen for output property %u (status %d)", (unsigned)props[i], (int)st);
}
au_buffer(a->au, NULL);
#endif #endif
OSStatus st = AudioUnitSetProperty(a->au, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &f, sizeof f); OSStatus st = AudioUnitSetProperty(a->au, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &f, sizeof f);
if (!st) st = AudioUnitSetProperty(a->au, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &cb, sizeof cb); if (!st) st = AudioUnitSetProperty(a->au, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &cb, sizeof cb);
if (!st) st = AudioUnitInitialize(a->au); if (!st) st = AudioUnitInitialize(a->au);
if (st) { AudioComponentInstanceDispose(a->au); a->au = NULL; } if (st) { au_dispose(a->au); a->au = NULL; }
return st; return st;
} }
// OpenAudioOut runs on the game's thread, and creating the AudioUnit takes about 24 KB of stack (measured on the Mac: component lookup, instance, Initialize), more than some game threads have // OpenAudioOut runs on the game's thread, and creating the AudioUnit takes about 24 KB of stack (measured on the Mac: component lookup, instance, Initialize), more than some game threads have
@@ -354,7 +396,7 @@ static void au_close(void *g, void *lr) {
pthread_mutex_lock(&g_aos_lock); pthread_mutex_lock(&g_aos_lock);
for (int i = 0; i < AOMAX; i++) if (g_aos[i] == a) g_aos[i] = NULL; for (int i = 0; i < AOMAX; i++) if (g_aos[i] == a) g_aos[i] = NULL;
pthread_mutex_unlock(&g_aos_lock); pthread_mutex_unlock(&g_aos_lock);
if (a->au) { AudioUnitUninitialize(a->au); AudioComponentInstanceDispose(a->au); } if (a->au) au_dispose(a->au);
dispatch_release(a->notify_sem); dispatch_release(a->notify_sem);
if (a->rel_sem) dispatch_release(a->rel_sem); if (a->rel_sem) dispatch_release(a->rel_sem);
pthread_mutex_destroy(&a->m); int ch = a->ch; free(a); pthread_mutex_destroy(&a->m); int ch = a->ch; free(a);
+6
View File
@@ -88,6 +88,12 @@ $(B)/t_audio: t_audio.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B)
clang -O1 -Wno-unused-function -I.. $(GINC) -o $@ t_audio.c -framework AudioToolbox clang -O1 -Wno-unused-function -I.. $(GINC) -o $@ t_audio.c -framework AudioToolbox
$(B)/t_audio_spsc: t_audio_spsc.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B) $(B)/t_audio_spsc: t_audio_spsc.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B)
clang -O1 -Wno-unused-function -I.. $(GINC) -o $@ t_audio_spsc.c -framework AudioToolbox clang -O1 -Wno-unused-function -I.. $(GINC) -o $@ t_audio_spsc.c -framework AudioToolbox
$(B)/t_audio_device: t_audio_device.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B)
clang -O1 -g -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_audio_device.c -framework AudioToolbox
.PHONY: audio-device
audio-device: $(B)/t_audio_device
$(B)/t_audio_device
run: audio-device
$(B)/t_audio_spsc_tsan: t_audio_spsc.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B) $(B)/t_audio_spsc_tsan: t_audio_spsc.c ../hle_audio.c ../hle.h ../dm.h $(GAME_H) | $(B)
clang -O1 -g -O1 -fsanitize=thread -Wno-unused-function -I.. $(GINC) -o $@ t_audio_spsc.c -framework AudioToolbox clang -O1 -g -O1 -fsanitize=thread -Wno-unused-function -I.. $(GINC) -o $@ t_audio_spsc.c -framework AudioToolbox
run: $(B)/t_fpslock run: $(B)/t_fpslock
+156
View File
@@ -0,0 +1,156 @@
// Mac device buffer contract: drive the real setup/listener/close paths with Core Audio property notifications,
// including synchronous format notifications from a buffer write and device removal during disposal. No hardware needed.
#include <AudioToolbox/AudioToolbox.h>
#include <assert.h>
#include <time.h>
static AudioComponent mock_find(AudioComponent, const AudioComponentDescription *);
static OSStatus mock_new(AudioComponent, AudioComponentInstance *);
static OSStatus mock_get(AudioUnit, AudioUnitPropertyID, AudioUnitScope, AudioUnitElement, void *, UInt32 *);
static OSStatus mock_set(AudioUnit, AudioUnitPropertyID, AudioUnitScope, AudioUnitElement, const void *, UInt32);
static OSStatus mock_add(AudioUnit, AudioUnitPropertyID, AudioUnitPropertyListenerProc, void *);
static OSStatus mock_remove(AudioUnit, AudioUnitPropertyID, AudioUnitPropertyListenerProc, void *);
static OSStatus mock_init(AudioUnit);
static OSStatus mock_uninit(AudioUnit);
static OSStatus mock_dispose(AudioComponentInstance);
#define AudioComponentFindNext mock_find
#define AudioComponentInstanceNew mock_new
#define AudioUnitGetProperty mock_get
#define AudioUnitSetProperty mock_set
#define AudioUnitAddPropertyListener mock_add
#define AudioUnitRemovePropertyListenerWithUserData mock_remove
#define AudioUnitInitialize mock_init
#define AudioUnitUninitialize mock_uninit
#define AudioComponentInstanceDispose mock_dispose
#include "../hle_audio.c"
static struct {
AudioDeviceID device;
Float64 rate;
AudioValueRange range;
UInt32 frames;
AudioUnitPropertyListenerProc device_listener, format_listener;
unsigned writes, disposals, input_formats;
OSStatus init_error;
} unit;
static AudioUnit fake_au(void) { return (AudioUnit)&unit; }
static void changed(AudioUnitPropertyID id, AudioUnitScope scope, AudioUnitElement el) {
AudioUnitPropertyListenerProc cb = id == kAudioOutputUnitProperty_CurrentDevice ? unit.device_listener : unit.format_listener;
if (cb) cb(&unit, fake_au(), id, scope, el);
}
static AudioComponent mock_find(AudioComponent c, const AudioComponentDescription *d) {
(void)c; assert(d->componentSubType == kAudioUnitSubType_DefaultOutput); return (AudioComponent)&unit;
}
static OSStatus mock_new(AudioComponent c, AudioComponentInstance *au) {
(void)c; assert(!unit.device_listener && !unit.format_listener); *au = fake_au(); return noErr;
}
static OSStatus mock_get(AudioUnit au, AudioUnitPropertyID id, AudioUnitScope scope, AudioUnitElement el, void *data, UInt32 *size) {
assert(au == fake_au() && el == 0);
if (id == kAudioOutputUnitProperty_CurrentDevice) {
assert(scope == kAudioUnitScope_Global && *size == sizeof unit.device); memcpy(data, &unit.device, *size);
} else if (id == kAudioUnitProperty_StreamFormat) {
assert(scope == kAudioUnitScope_Output && *size == sizeof(AudioStreamBasicDescription));
AudioStreamBasicDescription f = {.mSampleRate = unit.rate}; memcpy(data, &f, *size);
} else if (id == kAudioDevicePropertyBufferFrameSizeRange) {
assert(scope == kAudioUnitScope_Global && *size == sizeof unit.range); memcpy(data, &unit.range, *size);
} else {
assert(id == kAudioDevicePropertyBufferFrameSize && scope == kAudioUnitScope_Global && *size == sizeof unit.frames);
memcpy(data, &unit.frames, *size);
}
return noErr;
}
static OSStatus mock_set(AudioUnit au, AudioUnitPropertyID id, AudioUnitScope scope, AudioUnitElement el, const void *data, UInt32 size) {
assert(au == fake_au() && el == 0);
if (id == kAudioDevicePropertyBufferFrameSize) {
assert(unit.device && scope == kAudioUnitScope_Global && size == sizeof unit.frames);
UInt32 frames; memcpy(&frames, data, size);
assert(frames >= unit.range.mMinimum && frames <= unit.range.mMaximum);
unit.frames = frames; assert(++unit.writes < 100);
changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0); // must not recursively set the same buffer
} else if (id == kAudioUnitProperty_StreamFormat) {
const AudioStreamBasicDescription *f = data;
assert(scope == kAudioUnitScope_Input && size == sizeof *f && f->mSampleRate == RATE && f->mChannelsPerFrame == 2);
unit.input_formats++; changed(id, scope, el); // the 48 kHz guest format is not the hardware rate
} else {
assert(id == kAudioUnitProperty_SetRenderCallback && scope == kAudioUnitScope_Input && size == sizeof(AURenderCallbackStruct));
}
return noErr;
}
static OSStatus mock_add(AudioUnit au, AudioUnitPropertyID id, AudioUnitPropertyListenerProc cb, void *ctx) {
assert(au == fake_au() && !ctx);
if (id == kAudioOutputUnitProperty_CurrentDevice) unit.device_listener = cb;
else { assert(id == kAudioUnitProperty_StreamFormat); unit.format_listener = cb; }
return noErr;
}
static OSStatus mock_remove(AudioUnit au, AudioUnitPropertyID id, AudioUnitPropertyListenerProc cb, void *ctx) {
assert(au == fake_au() && !ctx);
if (id == kAudioOutputUnitProperty_CurrentDevice) { assert(unit.device_listener == cb); unit.device_listener = NULL; }
else { assert(id == kAudioUnitProperty_StreamFormat && unit.format_listener == cb); unit.format_listener = NULL; }
return noErr;
}
static OSStatus mock_init(AudioUnit au) { assert(au == fake_au()); return unit.init_error; }
static OSStatus mock_uninit(AudioUnit au) {
assert(au == fake_au() && !unit.device_listener && !unit.format_listener); return noErr;
}
static OSStatus mock_dispose(AudioComponentInstance au) {
assert(au == fake_au() && !unit.device_listener && !unit.format_listener);
unit.device = kAudioObjectUnknown;
changed(kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0);
unit.disposals++; return noErr;
}
uint64_t now_ns(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000000000ull + t.tv_nsec; }
static struct { const void *key; void *value; } object;
void *objmap_get(const void *key) { assert(key == object.key); return object.value; }
void objmap_put(const void *key, void *value) { assert(!object.key); object.key = key; object.value = value; }
void *objmap_del(const void *key) { assert(key == object.key); void *value = object.value; object.key = NULL; object.value = NULL; return value; }
void hle_signal_system_event(void *g) { (void)g; }
void hle_bind_system_event(void *g, int autoclear) { (void)g; (void)autoclear; }
module_t *module_of(const void *p) { (void)p; return NULL; }
void dm_host_helper_qos(qos_class_t q) { (void)q; }
int main(void) {
setenv("DM_AUDIO", "1", 1); unsetenv("DM_DBG_AU_FRAMES");
unit.device = 1; unit.rate = 48000; unit.range = (AudioValueRange){15, 4096}; unit.frames = 512;
uint32_t out[16] = {0}; uint8_t prm[16]; h_au_InitParam(prm);
assert(h_au_OpenDef0(out, prm) == RES_OK);
assert(unit.frames == 240 && unit.writes == 1 && unit.input_formats == 1 && unit.device_listener && unit.format_listener);
unit.device = 2; unit.rate = 44100; unit.frames = 512;
changed(kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0);
assert(unit.frames == 221 && unit.writes == 2);
// A device's rate can change without a CurrentDevice notification.
unit.rate = 96000; changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0);
assert(unit.frames == 480 && unit.writes == 3);
unit.rate = 48000; changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0);
assert(unit.frames == 240 && unit.writes == 4);
unit.rate = 44100;
changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0);
changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1);
assert(unit.frames == 240 && unit.writes == 4);
changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0);
assert(unit.frames == 221 && unit.writes == 5);
// Device limits are part of the HAL contract. Request the nearest supported frame count.
unit.device = 3; unit.rate = 48000; unit.range.mMinimum = 256;
changed(kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0);
assert(unit.frames == 256);
unit.range = (AudioValueRange){15, 128}; changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0);
assert(unit.frames == 128);
unit.range.mMaximum = 4096;
setenv("DM_DBG_AU_FRAMES", "1024", 1);
changed(kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0); assert(unit.frames == 1024);
unit.device = 4; unit.rate = 44100; changed(kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0); assert(unit.frames == 1024);
unsetenv("DM_DBG_AU_FRAMES");
unsigned before = unit.writes; unit.device = kAudioObjectUnknown;
changed(kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0); assert(unit.writes == before);
unit.device = 1;
h_au_Close(out); assert(unit.disposals == 1 && !object.key);
// Failure after listener registration follows the same cleanup path as Close.
unit.device = 1; unit.rate = 48000; unit.init_error = kAudioUnitErr_FailedInitialization;
aout failed = {.ch = 2};
assert(au_create(&failed) == kAudioUnitErr_FailedInitialization && !failed.au && unit.disposals == 2);
puts("audio device: switching, rate changes, limits, diagnostic override and listener cleanup passed");
return 0;
}
+1
View File
@@ -215,6 +215,7 @@ One line per symptom in the [subsystem references](../reference/): what you see
- no sound; red `audren: command 0 in the command list is malformed (first word 0x181901 size 0)`. → [SDK 21 DSP command header without a magic](../reference/audio.md#sdk-21-dsp-command-header-without-a-magic) - no sound; red `audren: command 0 in the command list is malformed (first word 0x181901 size 0)`. → [SDK 21 DSP command header without a magic](../reference/audio.md#sdk-21-dsp-command-header-without-a-magic)
- FMOD waits for buffer release after stopping an AudioOut, hanging the game during a scene transition. → [Stopping an AudioOut releases its pending buffers](../reference/audio.md#stopping-an-audioout-releases-its-pending-buffers) - FMOD waits for buffer release after stopping an AudioOut, hanging the game during a scene transition. → [Stopping an AudioOut releases its pending buffers](../reference/audio.md#stopping-an-audioout-releases-its-pending-buffers)
- crackling on iOS only. → [RemoteIO's default I/O buffer duration](../reference/audio.md#remoteios-default-io-buffer-duration) - crackling on iOS only. → [RemoteIO's default I/O buffer duration](../reference/audio.md#remoteios-default-io-buffer-duration)
- clicks and dropouts on the Mac, heard as clipping; the log's `audio #N device callbacks` lines show hundreds of underruns per 2000 callbacks. Closing an output can also log `[RED] audio device buffer: ... (status 560947818)` (`!obj`). → [The Mac's default I/O buffer](../reference/audio.md#the-macs-default-io-buffer)
- sound stays silent after Siri, an alarm, a call or another app's audio; a game whose audio thread waits for its buffers back (Wwise's poll-only output, FMOD) then waits forever. → [Interruptions that stop RemoteIO callbacks](../reference/audio.md#interruptions-that-stop-remoteio-callbacks) - sound stays silent after Siri, an alarm, a call or another app's audio; a game whose audio thread waits for its buffers back (Wwise's poll-only output, FMOD) then waits forever. → [Interruptions that stop RemoteIO callbacks](../reference/audio.md#interruptions-that-stop-remoteio-callbacks)
- combined numbers mislead: one fully fed output plus one fed by nobody look like "every other callback underruns, 2.0 blocks queued". → [Several outputs at once (`audio #N`)](../reference/audio.md#several-outputs-at-once-audio-n) - combined numbers mislead: one fully fed output plus one fed by nobody look like "every other callback underruns, 2.0 blocks queued". → [Several outputs at once (`audio #N`)](../reference/audio.md#several-outputs-at-once-audio-n)
- a null read at `sdk+0x1f2314` as the first movie starts (the title menu's background, right after the first press), in `nn::audio::SetAudioOutVolume`, called from `main+0x1c4fbc`. → [AudioOut volume kept per output](../reference/audio.md#audioout-volume-kept-per-output) - a null read at `sdk+0x1f2314` as the first movie starts (the title menu's background, right after the first press), in `nn::audio::SetAudioOutVolume`, called from `main+0x1c4fbc`. → [AudioOut volume kept per output](../reference/audio.md#audioout-volume-kept-per-output)
+9
View File
@@ -143,6 +143,15 @@ For a replaced DSP renderer the size query keeps the service's work-buffer contr
- **Fix:** `AVAudioSession` `setPreferredIOBufferDuration:0.005` (and 48 kHz) before the game opens audio and again on return to the foreground (`front_ios_audio_session` in `front_ios.m`); the device then calls back with 240 frames and nothing underruns. - **Fix:** `AVAudioSession` `setPreferredIOBufferDuration:0.005` (and 48 kHz) before the game opens audio and again on return to the foreground (`front_ios_audio_session` in `front_ios.m`); the device then calls back with 240 frames and nothing underruns.
- **Check:** when checking a new device, read the host's `audio #N device callbacks` line, logged every 2000 callbacks: frames per callback, queued blocks, underruns. - **Check:** when checking a new device, read the host's `audio #N device callbacks` line, logged every 2000 callbacks: frames per callback, queued blocks, underruns.
### The Mac's default I/O buffer
- **Symptom:** clicks and dropouts on the Mac, heard as clipping; the log's `audio #N device callbacks` lines show hundreds of underruns per 2000 callbacks. Closing an output can also log `[RED] audio device buffer: ... (status 560947818)` (`!obj`).
- **Cause:** the Mac's default output asks for 512 device frames per callback (10.7 ms at 48 kHz; 557 frames of the 48 kHz stream when the device runs at 44.1 kHz), more than a game that keeps only a few small blocks queued has ready every time. Unicorn Overlord on a MacBook Pro's speakers: 250–290 underruns in every 2000 callbacks at 48 kHz (0.1–0.2 s of zeros), about 1,000 at 44.1 kHz. Whether a game underruns depends on how much it queues: 13 Sentinels (2.8 blocks queued, like Unicorn Overlord's 3.0, but larger ones) had none at 512 frames.
- **Fix:** like iOS's session buffer, the Mac sets its output unit's device buffer to about 5 ms of device frames (`kAudioDevicePropertyBufferFrameSize`, 240 at 48 kHz, 221 at 44.1 kHz), within the device's supported range (`au_buffer` in `hle_audio.c`). It reapplies this when the default output changes and when the same device's output sample rate changes; `CurrentDevice` alone does not notify a rate change. An unchanged buffer is not written again, since a write can itself notify the format listener. Both listeners are removed before uninitializing or disposing the unit, including a failed open: disposal otherwise calls the listener after the device has gone (`!obj`). The log reads back the actual frame count. `DM_DBG_AU_FRAMES=N` asks N frames instead.
- **Found on:** [Unicorn Overlord](../../dm/games/010069401ADB8000/README.md) (Asian release 1.05 on the Mac).
- **Check:** [PR #262](https://github.com/seedds/nxnative/pull/262) reports Unicorn Overlord at 240 frames: 0 underruns in 10,000 callbacks; 13 Sentinels stays at 0. A silent Core Audio probe on a Mac mini reproduced the disposal error and the stale buffer after a rate change, then verified the fixes: default output switched between USB at 48 kHz and speakers at 44.1 kHz with callbacks continuing, and the same speakers at 44.1 / 96 / 48 kHz used 221 / 480 / 240 frames. The original default output and device rates were restored. `make -C dm/host/tests GAME=<id> audio-device` checks setup, device and rate notifications, scope filtering, buffer limits, synchronous notifications, the diagnostic override, absent devices, close and failed-open listener cleanup without audio hardware.
- **Open:** the listed HDMI output was not retained as the system default during the probe, so switching to it remains unvalidated. The probe verifies silent host playback; gameplay listening after these listener changes has not been repeated.
### Interruptions that stop RemoteIO callbacks ### Interruptions that stop RemoteIO callbacks
- **Symptom:** sound stays silent after Siri, an alarm, a call or another app's audio; a game whose audio thread waits for its buffers back (Wwise's poll-only output, FMOD) then waits forever. - **Symptom:** sound stays silent after Siri, an alarm, a call or another app's audio; a game whose audio thread waits for its buffers back (Wwise's poll-only output, FMOD) then waits forever.