mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-27 18:52:11 -04:00
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:
+186
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user