Merge feat/audio-quality-pin: measured audio-quality campaign (MMX5 complaint)

Always-on audio tap rings + SPU event ring (runtime + psx-beetle oracle),
audio_stats/audio_wav/audio_events debug commands, offline A/B toolchain.

Fixes, all measured against the Beetle oracle:
- clock-domain DRC bridge for SPU output (pull-mode, polyphase resampler)
- guest-cycle sample budget (768 cyc/sample exact; was 60.00Hz assumption)
- frame pacer bounded catch-up (was forgiving all >1-period debt)
- PI fill servo with anti-windup (was P-only with standing deficit)
- voice loop-address register is LIVE state (X5 stuck-voice +11dB fix)
- CDC decode-volume matrix (X5 music fades)

MMX5: underruns ~360 -> 1 per 150s; level -3.8 dBFS clipping -> -15.5
vs oracle -14.1. Cross-title gate: Tomba built+booted, bridge fill
stable at target, user ear-validated both titles.
This commit is contained in:
Matthew Stanley
2026-07-10 08:13:17 -07:00
19 changed files with 2425 additions and 19 deletions
+186 -13
View File
@@ -31,7 +31,16 @@
#include "latency_ring.h"
#include "sio.h"
#include "spu.h"
#include "audio_trace.h"
#include "spu_shadow.h"
/* Shared clock-domain bridge: band-limited polyphase resampler + P-only DRC.
* The SPU renders at a fixed 44100/60 per wall-clock frame while the host
* consumes on its own crystal; with no resampling/DRC the queue slowly drifts
* to underrun (silence gaps). The bridge resamples ~1:1 with a <=+/-0.5% ratio
* trim to hold the ring near target -- no gaps. See recomp_audio_drc.h. */
#define RECOMP_AUDIO_DRC_IMPL
#include "recomp_audio_drc.h"
#include "memcard.h"
#include "debug_server.h"
#include "crash_trace.h"
@@ -281,6 +290,41 @@ uint64_t g_turbo_loads_frames = 0; /* vblanks run unpaced (observability) */
static SDL_AudioDeviceID sdl_audio_device;
static int16_t sdl_audio_buf[2048 * 2];
/* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under
* SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio
* thread. */
static rab_bridge s_drc;
static bool s_drc_ready = false;
/* Observability + A/B. PSXRECOMP_AUDIO_LEGACY=1 keeps the historical push model
* (SDL_QueueAudio, no bridge) so the underrun baseline can be measured against
* the bridge from a single build; default (unset) = bridge/pull model. Output
* health is surfaced through the audio_stats TCP command (no stderr probe). */
static bool audio_legacy_mode(void) {
static int v = -1;
if (v < 0) { const char* e = getenv("PSXRECOMP_AUDIO_LEGACY"); v = (e && *e && *e != '0') ? 1 : 0; }
return v != 0;
}
/* Legacy-mode underrun counter: incremented when the SDL queue is found empty
* at pump time (the device was silence-filling = an audible gap). */
static uint64_t g_legacy_underruns = 0;
/* Set at unmute so the pump resyncs its bridge-underrun baseline past the
* intentional mute-drain instead of reporting it as gaps. */
int g_audio_unmute_resync = 0;
/* Actual device rate the host opened at (bridge mode may differ from 44100;
* the T3 tap ring runs at this rate and its WAV dump must say so). */
int g_audio_host_rate = 44100;
static void sdl_drc_callback(void* /*user*/, Uint8* stream, int len) {
if (!s_drc_ready) { std::memset(stream, 0, (size_t)len); return; }
int frames = len / (int)(2 * sizeof(int16_t)); /* stereo S16 */
rab_pull(&s_drc, reinterpret_cast<int16_t*>(stream), frames);
/* T3 tap in bridge mode: the exact device-rate bytes the host consumes.
* This callback is the tap's single writer while the bridge is active. */
audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast<const int16_t*>(stream),
frames);
}
static std::filesystem::path find_upward(std::filesystem::path start,
const std::filesystem::path& marker) {
std::error_code ec;
@@ -641,9 +685,10 @@ static void shutdown_runtime(void) {
overlay_capture_write_json();
if (sdl_audio_device) {
SDL_ClearQueuedAudio(sdl_audio_device);
SDL_CloseAudioDevice(sdl_audio_device);
SDL_CloseAudioDevice(sdl_audio_device); /* stops the pull callback */
sdl_audio_device = 0;
}
if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; }
close_controller();
debug_server_shutdown();
}
@@ -670,16 +715,74 @@ static void sdl_audio_pump(void) {
if (!sdl_audio_device) return;
const uint32_t bytes_per_frame = sizeof(int16_t) * 2u;
const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u;
if (SDL_GetQueuedAudioSize(sdl_audio_device) > max_queue_bytes) return;
const bool legacy = audio_legacy_mode();
static int had_audio = 0;
uint32_t queued = 0; /* RENDER event b: bytes (legacy) / fill ms (bridge) */
if (legacy) {
/* Historical push model + baseline measurement: a drained (==0) queue
* means the device was silence-filling since the last pump = a gap.
* Only meaningful after the first audio has been queued. */
const uint32_t max_queue_bytes = 44100u * bytes_per_frame / 5u;
queued = SDL_GetQueuedAudioSize(sdl_audio_device);
if (queued == 0 && had_audio) {
g_legacy_underruns++;
audio_trace_event(AUDIO_EV_UNDERRUN, 0, 0);
}
if (queued > max_queue_bytes) {
audio_trace_event(AUDIO_EV_PUMP_SKIP, queued, 0);
return;
}
} else {
/* No host-queue backpressure check: the bridge's ring + DRC hold the
* fill near target, so we always render this frame and push it. */
if (!s_drc_ready) return;
/* Surface bridge underruns (counted on the SDL audio thread) into the
* event ring from this thread — the event ring is single-writer.
* Across a turbo mute the ring intentionally runs dry (the pump stops
* while the callback keeps pulling); those dry pulls are the mute,
* not gaps — resync past them instead of reporting them. */
rab_stats st;
rab_get_stats(&s_drc, &st);
static uint64_t prev_underruns = 0;
extern int g_audio_unmute_resync;
if (g_audio_unmute_resync) {
prev_underruns = st.underrun_events;
g_audio_unmute_resync = 0;
} else if (st.underrun_events > prev_underruns) {
audio_trace_event(AUDIO_EV_UNDERRUN,
(uint32_t)(st.underrun_events - prev_underruns), 1);
prev_underruns = st.underrun_events;
}
queued = (uint32_t)st.last_fill_ms;
}
static double sample_accum = 0.0;
sample_accum += 44100.0 / 60.0;
int frames = (int)sample_accum;
sample_accum -= (double)frames;
/* Faithful sample budget: the SPU is clocked by the GUEST, not by host
* presents. 33.8688 MHz / 44100 Hz = exactly 768 guest cycles per output
* frame, so production tracks guest time precisely — including the real
* NTSC 59.94 Hz vblank — instead of assuming 60.00 Hz per present, which
* built in a systematic -0.1% production deficit (measured: 43950/s
* produced vs 44100/s consumed = recurring ring underruns no +/-0.5%
* DRC trim could absorb during jitter spikes). */
extern uint64_t psx_cycle_count;
static uint64_t last_cycles = 0;
static uint64_t cycle_carry = 0;
const uint64_t now_cycles = psx_cycle_count;
if (last_cycles == 0) last_cycles = now_cycles;
uint64_t delta = (now_cycles - last_cycles) + cycle_carry;
last_cycles = now_cycles;
int frames = (int)(delta / 768u);
cycle_carry = delta % 768u;
if (frames <= 0) return;
if (frames > 2048) frames = 2048;
if (frames > 2048) {
/* A burst beyond one buffer (e.g. right after an unmute or a long
* stall): render one full buffer and DROP the remainder of the debt —
* same semantic as the mute model (voice positions freeze across the
* gap) rather than time-compressing a backlog into garble. */
frames = 2048;
cycle_carry = 0;
}
audio_trace_event(AUDIO_EV_RENDER, (uint32_t)frames, queued);
spu_render(sdl_audio_buf, frames);
if (sdl_audio_fadein_left > 0) {
const float g0 = 1.0f - (float)sdl_audio_fadein_left
@@ -690,8 +793,21 @@ static void sdl_audio_pump(void) {
sdl_audio_gain_ramp(sdl_audio_buf, ramp, g0, g1);
sdl_audio_fadein_left -= ramp;
}
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)frames * bytes_per_frame);
if (legacy) {
/* T3 tap: the exact post-fade bytes handed to the host audio queue. */
audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)frames * bytes_per_frame);
had_audio = 1;
} else {
/* Hand to the bridge (band-limited resample + DRC) instead of
* SDL_QueueAudio. Lock guards the SPSC ring against the pull callback.
* The T3 tap moves to sdl_drc_callback: what the device actually
* receives is the bridge's device-rate output, not this buffer. */
SDL_LockAudioDevice(sdl_audio_device);
rab_push(&s_drc, sdl_audio_buf, frames);
SDL_UnlockAudioDevice(sdl_audio_device);
}
}
/* Audio gating across turbo-loads transitions.
@@ -707,8 +823,40 @@ static void sdl_audio_pump(void) {
* re-trigger within a few frames; without the debounce the mute would
* flicker audibly), then resume pumping with a rising ramp applied
* across the first ~50 ms of samples (sdl_audio_pump above). */
/* Bridge/legacy output health, surfaced through the audio_stats TCP command
* (debug_server.c) — no stderr probe; rule 3. */
extern "C" int psx_audio_out_stats(double *fill_ms, uint64_t *underruns,
uint64_t *overflow_drops, double *correction,
int *legacy, int *host_rate)
{
extern int g_audio_host_rate; /* set at device open */
*legacy = audio_legacy_mode() ? 1 : 0;
*host_rate = g_audio_host_rate;
if (*legacy || !s_drc_ready) {
*fill_ms = sdl_audio_device
? (double)SDL_GetQueuedAudioSize(sdl_audio_device)
/ (44100.0 * 4.0) * 1000.0
: 0.0;
*underruns = g_legacy_underruns;
*overflow_drops = 0;
*correction = 0.0;
return sdl_audio_device != 0;
}
rab_stats st;
rab_get_stats(&s_drc, &st);
*fill_ms = st.last_fill_ms;
*underruns = st.underrun_events;
*overflow_drops = st.overflow_drops;
*correction = st.last_correction;
return 1;
}
static void sdl_audio_update(int turbo_active) {
if (!sdl_audio_device) return;
{ /* Tag audio events with the vblank frame counter. */
extern uint64_t s_frame_count;
audio_trace_note_frame((uint32_t)s_frame_count);
}
const int HANGOVER_FRAMES = 8; /* ~133 ms at 60 fps */
static int muted = 0;
static int hangover = 0;
@@ -725,8 +873,16 @@ static void sdl_audio_update(int turbo_active) {
sdl_audio_fadein_left = 0;
spu_render(sdl_audio_buf, tail);
sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)tail * sizeof(int16_t) * 2u);
audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0);
if (audio_legacy_mode()) {
audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, tail);
SDL_QueueAudio(sdl_audio_device, sdl_audio_buf,
(uint32_t)tail * sizeof(int16_t) * 2u);
} else if (s_drc_ready) {
SDL_LockAudioDevice(sdl_audio_device);
rab_push(&s_drc, sdl_audio_buf, tail);
SDL_UnlockAudioDevice(sdl_audio_device);
}
muted = 1;
}
hangover = HANGOVER_FRAMES;
@@ -736,6 +892,9 @@ static void sdl_audio_update(int turbo_active) {
if (hangover > 0) { hangover--; return; }
muted = 0;
sdl_audio_fadein_left = sdl_audio_fade_samples;
audio_trace_event(AUDIO_EV_UNMUTE, (uint32_t)sdl_audio_fadein_left, 0);
extern int g_audio_unmute_resync;
g_audio_unmute_resync = 1;
}
sdl_audio_pump();
}
@@ -2826,6 +2985,7 @@ int main(int argc, char** argv) {
controller_deadzone = std::max(0, std::min(32767, resolved_deadzone));
refresh_player_devices(); /* open SDL handles to match the player config */
#ifndef PSX_SDL_NO_AUDIO
audio_trace_init();
if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) {
SDL_AudioSpec want;
SDL_AudioSpec have;
@@ -2834,8 +2994,21 @@ int main(int argc, char** argv) {
want.format = AUDIO_S16SYS;
want.channels = 2;
want.samples = 1024;
sdl_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0);
const bool legacy = audio_legacy_mode();
if (!legacy)
want.callback = sdl_drc_callback; /* pull model: bridge resamples + DRC */
sdl_audio_device = SDL_OpenAudioDevice(NULL, 0, &want, &have,
legacy ? 0 : SDL_AUDIO_ALLOW_FREQUENCY_CHANGE);
if (sdl_audio_device) {
if (!legacy) {
rab_config cfg; rab_config_defaults(&cfg);
cfg.channels = 2;
cfg.source_rate = 44100.0; /* SPU render rate */
cfg.host_rate = (double)have.freq; /* actual device rate */
if (rab_init(&s_drc, &cfg) == 0) s_drc_ready = true;
}
g_audio_host_rate = have.freq;
audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq);
SDL_PauseAudioDevice(sdl_audio_device, 0);
}
}