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
+1
View File
@@ -80,6 +80,7 @@ if(NOT MSVC AND EXISTS "${BEETLE_LIB}")
src/frame_pacing.c
src/parity_trace.c
src/device_trace.c
src/audio_trace.c
${LIBRETRO_COMMON_SOURCES}
)
+123
View File
@@ -0,0 +1,123 @@
/*
* audio_trace.h - always-on audio observability rings (PCM taps + event ring).
*
* Port of the snesrecomp audio-campaign measurement harness (audio_trace.{c,h},
* commit 67ead27 lineage) to the PSX pipeline, following the cross-system tap
* model in F:/Projects/_audio_round2/ROUND2_PLAN.md: always-on ring buffers
* recording every sample/event from process start, dumped AFTER the fact via
* TCP debug commands — never arm-then-reproduce.
*
* Taps (PSX pipeline):
* AUDIO_TAP_SPU_OUT "T1" — canonical spu_render() output @44100, recorded
* inside spu_render so every caller (pump, fade tail) is
* covered, PRE any host-side fade/mute gain.
* AUDIO_TAP_CD_IN "T2" — decoded XA/CD-DA PCM pushed onto the SPU CD
* input bus (already resampled to 44100 by the producer).
* AUDIO_TAP_HOST "T3" — the exact bytes handed to the host audio API
* (post fade ramps; what SDL_QueueAudio receives).
*
* The same module compiles into psx-beetle, where AUDIO_TAP_SPU_OUT records
* the libretro audio_batch callback — Beetle's fully-mixed reference SPU
* output — giving symmetric `audio_*` commands on ports 4370 and 4380.
*
* Threading: each tap has a single writer. Readers (debug-server thread)
* take a stable [oldest, head) snapshot; the PCM rings hold ~95 s at 44100,
* so a dump cannot be lapped mid-read in practice. Indices are C11 atomics
* (release on publish, acquire on read).
*/
#ifndef PSX_AUDIO_TRACE_H
#define PSX_AUDIO_TRACE_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
enum {
AUDIO_TAP_SPU_OUT = 0,
AUDIO_TAP_CD_IN = 1,
AUDIO_TAP_HOST = 2,
AUDIO_TAP_VOICES = 3, /* T0: 24-voice sum only (pre CD mix, pre main) */
AUDIO_TAP_COUNT = 4
};
/* Event kinds. REG_WRITE covers every SPU register store (addr/value), which
* subsumes KEYON/KEYOFF/pitch/volume writes — the semantic KON ring stays in
* spu.c. The rest instrument the host delivery path. */
enum {
AUDIO_EV_NONE = 0,
AUDIO_EV_REG_WRITE = 1, /* a=SPU reg addr, b=value */
AUDIO_EV_RENDER = 2, /* a=frames rendered, b=host queued bytes before */
AUDIO_EV_PUMP_SKIP = 3, /* backpressure skip; a=queued bytes */
AUDIO_EV_UNDERRUN = 4, /* host queue empty at pump entry; a=queued bytes */
AUDIO_EV_MUTE = 5, /* turbo mute engaged; a=fade tail frames */
AUDIO_EV_UNMUTE = 6, /* pumping resumed; a=fade-in frames pending */
AUDIO_EV_CD_PUSH = 7, /* a=frames pushed, b=CD ring fill after */
AUDIO_EV_DMA_WRITE = 8, /* SPU RAM DMA; a=words, b=transfer addr after */
};
typedef struct {
uint64_t seq; /* monotone event counter */
uint64_t sample_idx; /* AUDIO_TAP_SPU_OUT frames produced when recorded */
uint32_t frame; /* host-noted frame counter (vblank / retro_run) */
uint16_t kind;
uint16_t reserved;
uint32_t a;
uint32_t b;
} AudioTraceEvent;
typedef struct {
/* Per-tap PCM production. */
uint64_t tap_frames[AUDIO_TAP_COUNT]; /* frames ever recorded */
uint64_t tap_nonzero[AUDIO_TAP_COUNT]; /* frames with |L| or |R| > 0 */
uint64_t tap_audible[AUDIO_TAP_COUNT]; /* frames with |L| or |R| > 256 (~ -42 dBFS) */
int32_t tap_peak[AUDIO_TAP_COUNT]; /* running peak |sample| */
/* Host pump behavior. */
uint64_t pump_calls;
uint64_t pump_skips; /* backpressure early-outs */
uint64_t underruns; /* pump entered with 0 queued bytes (post first audio) */
uint32_t queue_hiwater; /* max queued bytes observed at pump entry */
uint32_t queue_lowater; /* min queued bytes observed at pump entry (post first audio) */
uint64_t mute_events;
uint64_t unmute_events;
/* Event ring. */
uint64_t events_total;
} AudioTraceStats;
void audio_trace_init(void);
/* Record `frames` interleaved stereo s16 frames into tap `tap`. */
void audio_trace_pcm(int tap, const int16_t *stereo, int frames);
/* Record a pipeline event (timestamps applied internally). */
void audio_trace_event(uint16_t kind, uint32_t a, uint32_t b);
/* Host frame tag for event timestamps (call once per vblank / retro_run). */
void audio_trace_note_frame(uint32_t frame);
void audio_trace_get_stats(AudioTraceStats *out);
uint64_t audio_trace_tap_total(int tap);
uint64_t audio_trace_events_total(void);
/* Taps default to 44100 Hz; the host tap may run at the device rate in
* bridge/pull mode. The WAV dumper stamps this rate — a mislabeled header
* silently poisons every offline A/B. */
void audio_trace_set_tap_rate(int tap, uint32_t rate);
uint32_t audio_trace_tap_rate(int tap);
/* Copy the most recent `max` events, oldest first. Returns count copied. */
uint32_t audio_trace_events_get(AudioTraceEvent *out, uint32_t max);
/* Write a tap slice as a stereo s16 WAV at the tap's rate. start=-1,count=0
* dumps the whole currently-buffered ring. `start`/`count` are absolute frame
* indices on the tap's production timeline. Returns frames written, or -1 on
* error (unwritable path / empty ring / slice already evicted). */
int64_t audio_trace_dump_wav(int tap, const char *path,
int64_t start, uint64_t count);
#ifdef __cplusplus
}
#endif
#endif /* PSX_AUDIO_TRACE_H */
+1
View File
@@ -182,6 +182,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/spu.c
${PSXRECOMP_ROOT}/runtime/src/spu_shadow.c
${PSXRECOMP_ROOT}/runtime/src/audio_shadow.c
${PSXRECOMP_ROOT}/runtime/src/audio_trace.c
${PSXRECOMP_ROOT}/runtime/src/color_lut.c
${PSXRECOMP_ROOT}/runtime/src/iso_reader.cpp
${PSXRECOMP_ROOT}/runtime/src/iso_reader_c.cpp
+263
View File
@@ -0,0 +1,263 @@
/*
* audio_trace.c - always-on audio observability rings. See audio_trace.h.
*
* Memory: 3 PCM taps x 2^22 stereo frames x 4 bytes = 48 MiB static, plus a
* 2^19-entry event ring (16 MiB). Static BSS, zero cost until touched; the
* recording hot path is a memcpy per audio block and is compiled into
* Release/production builds too (always-on ring-buffer discipline — probes
* query history, they never arm recording).
*/
#include "audio_trace.h"
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
/* 2^22 frames @ 44100 ~= 95 s per tap. Power of two so wrap is a mask. */
#define PCM_RING_FRAMES (1u << 22)
#define PCM_RING_MASK (PCM_RING_FRAMES - 1u)
#define EV_RING_CAP (1u << 19)
#define EV_RING_MASK (EV_RING_CAP - 1u)
/* Audibility threshold shared with snesrecomp's dropped_audible metric:
* |sample| > 256 ~= -42 dBFS. Below that, gaps/drops are inaudible and must
* not trip regression counters (snesrecomp a1c659f). */
#define AUDIBLE_ABS 256
typedef struct {
int16_t pcm[PCM_RING_FRAMES * 2];
_Atomic uint64_t head; /* frames ever written; ring pos = head & MASK */
uint64_t nonzero;
uint64_t audible;
int32_t peak;
} PcmTap;
static PcmTap s_taps[AUDIO_TAP_COUNT];
/* Per-tap sample rate for WAV headers. SPU-side taps are natively 44100;
* the host tap follows the device rate in bridge/pull mode. */
static uint32_t s_tap_rate[AUDIO_TAP_COUNT] = { 44100u, 44100u, 44100u, 44100u };
void audio_trace_set_tap_rate(int tap, uint32_t rate)
{
if (tap < 0 || tap >= AUDIO_TAP_COUNT || rate == 0) return;
s_tap_rate[tap] = rate;
}
uint32_t audio_trace_tap_rate(int tap)
{
if (tap < 0 || tap >= AUDIO_TAP_COUNT) return 44100u;
return s_tap_rate[tap];
}
static AudioTraceEvent s_events[EV_RING_CAP];
static _Atomic uint64_t s_event_head;
static _Atomic uint32_t s_noted_frame;
/* Host pump counters (single writer: the pump/render thread). */
static uint64_t s_pump_calls;
static uint64_t s_pump_skips;
static uint64_t s_underruns;
static uint32_t s_queue_hiwater;
static uint32_t s_queue_lowater = 0xFFFFFFFFu;
static uint64_t s_mute_events;
static uint64_t s_unmute_events;
void audio_trace_init(void)
{
for (int t = 0; t < AUDIO_TAP_COUNT; t++) {
atomic_store(&s_taps[t].head, 0);
s_taps[t].nonzero = 0;
s_taps[t].audible = 0;
s_taps[t].peak = 0;
}
atomic_store(&s_event_head, 0);
atomic_store(&s_noted_frame, 0);
s_pump_calls = 0;
s_pump_skips = 0;
s_underruns = 0;
s_queue_hiwater = 0;
s_queue_lowater = 0xFFFFFFFFu;
s_mute_events = 0;
s_unmute_events = 0;
}
void audio_trace_note_frame(uint32_t frame)
{
atomic_store_explicit(&s_noted_frame, frame, memory_order_relaxed);
}
void audio_trace_pcm(int tap, const int16_t *stereo, int frames)
{
if (tap < 0 || tap >= AUDIO_TAP_COUNT || !stereo || frames <= 0) return;
PcmTap *t = &s_taps[tap];
uint64_t head = atomic_load_explicit(&t->head, memory_order_relaxed);
for (int f = 0; f < frames; f++) {
int16_t l = stereo[f * 2 + 0];
int16_t r = stereo[f * 2 + 1];
uint32_t pos = (uint32_t)((head + (uint64_t)f) & PCM_RING_MASK);
t->pcm[pos * 2 + 0] = l;
t->pcm[pos * 2 + 1] = r;
int32_t al = l < 0 ? -(int32_t)l : l;
int32_t ar = r < 0 ? -(int32_t)r : r;
int32_t a = al > ar ? al : ar;
if (a) t->nonzero++;
if (a > AUDIBLE_ABS) t->audible++;
if (a > t->peak) t->peak = a;
}
atomic_store_explicit(&t->head, head + (uint64_t)frames,
memory_order_release);
}
void audio_trace_event(uint16_t kind, uint32_t a, uint32_t b)
{
uint64_t seq = atomic_load_explicit(&s_event_head, memory_order_relaxed);
AudioTraceEvent *e = &s_events[(uint32_t)(seq & EV_RING_MASK)];
e->seq = seq;
e->sample_idx = atomic_load_explicit(&s_taps[AUDIO_TAP_SPU_OUT].head,
memory_order_relaxed);
e->frame = atomic_load_explicit(&s_noted_frame, memory_order_relaxed);
e->kind = kind;
e->reserved = 0;
e->a = a;
e->b = b;
atomic_store_explicit(&s_event_head, seq + 1, memory_order_release);
switch (kind) {
case AUDIO_EV_RENDER:
s_pump_calls++;
if (b > s_queue_hiwater) s_queue_hiwater = b;
if (b < s_queue_lowater) s_queue_lowater = b;
break;
case AUDIO_EV_PUMP_SKIP: s_pump_skips++; break;
case AUDIO_EV_UNDERRUN: s_underruns++; break;
case AUDIO_EV_MUTE: s_mute_events++; break;
case AUDIO_EV_UNMUTE: s_unmute_events++; break;
default: break;
}
}
void audio_trace_get_stats(AudioTraceStats *out)
{
if (!out) return;
memset(out, 0, sizeof(*out));
for (int t = 0; t < AUDIO_TAP_COUNT; t++) {
out->tap_frames[t] = atomic_load_explicit(&s_taps[t].head,
memory_order_acquire);
out->tap_nonzero[t] = s_taps[t].nonzero;
out->tap_audible[t] = s_taps[t].audible;
out->tap_peak[t] = s_taps[t].peak;
}
out->pump_calls = s_pump_calls;
out->pump_skips = s_pump_skips;
out->underruns = s_underruns;
out->queue_hiwater = s_queue_hiwater;
out->queue_lowater = s_queue_lowater == 0xFFFFFFFFu ? 0 : s_queue_lowater;
out->mute_events = s_mute_events;
out->unmute_events = s_unmute_events;
out->events_total = atomic_load_explicit(&s_event_head,
memory_order_acquire);
}
uint64_t audio_trace_tap_total(int tap)
{
if (tap < 0 || tap >= AUDIO_TAP_COUNT) return 0;
return atomic_load_explicit(&s_taps[tap].head, memory_order_acquire);
}
uint64_t audio_trace_events_total(void)
{
return atomic_load_explicit(&s_event_head, memory_order_acquire);
}
uint32_t audio_trace_events_get(AudioTraceEvent *out, uint32_t max)
{
if (!out || max == 0) return 0;
uint64_t total = atomic_load_explicit(&s_event_head, memory_order_acquire);
uint64_t avail = total < (uint64_t)EV_RING_CAP ? total : (uint64_t)EV_RING_CAP;
if ((uint64_t)max > avail) max = (uint32_t)avail;
uint64_t first = total - (uint64_t)max;
for (uint32_t i = 0; i < max; i++)
out[i] = s_events[(uint32_t)((first + i) & EV_RING_MASK)];
return max;
}
/* ---- WAV dump --------------------------------------------------------- */
static void wav_write_header(FILE *fp, uint32_t data_bytes, uint32_t rate)
{
uint8_t h[44];
uint32_t byte_rate = rate * 4u;
uint32_t riff_size = 36u + data_bytes;
memcpy(h + 0, "RIFF", 4);
h[4] = (uint8_t)riff_size; h[5] = (uint8_t)(riff_size >> 8);
h[6] = (uint8_t)(riff_size >> 16); h[7] = (uint8_t)(riff_size >> 24);
memcpy(h + 8, "WAVEfmt ", 8);
h[16] = 16; h[17] = 0; h[18] = 0; h[19] = 0; /* fmt chunk size */
h[20] = 1; h[21] = 0; /* PCM */
h[22] = 2; h[23] = 0; /* stereo */
h[24] = (uint8_t)rate; h[25] = (uint8_t)(rate >> 8);
h[26] = (uint8_t)(rate >> 16); h[27] = (uint8_t)(rate >> 24);
h[28] = (uint8_t)byte_rate; h[29] = (uint8_t)(byte_rate >> 8);
h[30] = (uint8_t)(byte_rate >> 16); h[31] = (uint8_t)(byte_rate >> 24);
h[32] = 4; h[33] = 0; /* block align */
h[34] = 16; h[35] = 0; /* bits per sample */
memcpy(h + 36, "data", 4);
h[40] = (uint8_t)data_bytes; h[41] = (uint8_t)(data_bytes >> 8);
h[42] = (uint8_t)(data_bytes >> 16); h[43] = (uint8_t)(data_bytes >> 24);
fwrite(h, 1, sizeof(h), fp);
}
int64_t audio_trace_dump_wav(int tap, const char *path,
int64_t start, uint64_t count)
{
if (tap < 0 || tap >= AUDIO_TAP_COUNT || !path || !path[0]) return -1;
PcmTap *t = &s_taps[tap];
/* Snapshot the head; everything in [head - avail, head) is stable
* (append-only, single writer) unless the writer laps us — the ring
* holds ~95 s, a dump takes well under a second. */
uint64_t head = atomic_load_explicit(&t->head, memory_order_acquire);
uint64_t avail = head < (uint64_t)PCM_RING_FRAMES ? head
: (uint64_t)PCM_RING_FRAMES;
if (avail == 0) return -1;
uint64_t oldest = head - avail;
uint64_t s; /* absolute first frame of the slice */
if (start < 0) {
s = oldest;
if (count == 0 || count > avail) count = avail;
} else {
s = (uint64_t)start;
if (s < oldest) s = oldest; /* evicted prefix clamps */
if (s >= head) return -1;
uint64_t max = head - s;
if (count == 0 || count > max) count = max;
}
/* Cap a single WAV at the ring capacity (paranoia; avail <= cap). */
if (count > (uint64_t)PCM_RING_FRAMES) count = PCM_RING_FRAMES;
FILE *fp = fopen(path, "wb");
if (!fp) return -1;
/* Stamp the tap's true rate (SPU taps: exactly 44100; host tap: device
* rate in bridge mode). A mislabeled header silently poisons every
* offline A/B (snesrecomp da33c06). */
wav_write_header(fp, (uint32_t)(count * 4u), s_tap_rate[tap]);
uint64_t done = 0;
while (done < count) {
uint32_t pos = (uint32_t)((s + done) & PCM_RING_MASK);
uint32_t chunk = PCM_RING_FRAMES - pos;
if ((uint64_t)chunk > count - done) chunk = (uint32_t)(count - done);
if (fwrite(&t->pcm[(size_t)pos * 2u], 4u, chunk, fp) != chunk) {
fclose(fp);
return -1;
}
done += chunk;
}
fclose(fp);
return (int64_t)count;
}
+118
View File
@@ -148,6 +148,7 @@ extern int beetle_spu_get_global_state(
/* Per-frame history ring (parity with runtime's frame_history). */
#include "beetle_history.h"
#include "audio_trace.h"
#include "parity_trace.h"
#include "device_trace.h"
#include "png_write.h" /* png_write_rgb — shared PNG encoder (matches runtime) */
@@ -1051,6 +1052,119 @@ static void h_spu_events(int id, const char *json) {
free(vlcs); free(vrcs); free(als); free(ahs); free(kinds); free(vs);
}
/* ---- audio_stats / audio_wav / audio_events: always-on oracle PCM tap ----
*
* Wire-protocol mirror of psx-runtime's audio_* commands (port 4370).
* On psx-beetle only tap 0 (spu_out) is fed — Beetle's fully-mixed
* 44.1 kHz reference SPU output captured at the libretro audio_batch
* callback (beetle_libretro.cpp). Pump/queue fields report zero: this
* binary never opens a host audio device. */
static void h_audio_stats(int id, const char *json) {
(void)json;
AudioTraceStats st;
audio_trace_get_stats(&st);
send_fmt("{\"id\":%d,\"ok\":true,"
"\"taps\":["
"{\"name\":\"spu_out\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d},"
"{\"name\":\"cd_in\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d},"
"{\"name\":\"host\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d}],"
"\"pump_calls\":%llu,\"pump_skips\":%llu,\"underruns\":%llu,"
"\"queue_hiwater\":%u,\"queue_lowater\":%u,"
"\"mutes\":%llu,\"unmutes\":%llu,\"events_total\":%llu}\n",
id,
(unsigned long long)st.tap_frames[0],
(unsigned long long)st.tap_nonzero[0],
(unsigned long long)st.tap_audible[0], st.tap_peak[0],
(unsigned long long)st.tap_frames[1],
(unsigned long long)st.tap_nonzero[1],
(unsigned long long)st.tap_audible[1], st.tap_peak[1],
(unsigned long long)st.tap_frames[2],
(unsigned long long)st.tap_nonzero[2],
(unsigned long long)st.tap_audible[2], st.tap_peak[2],
(unsigned long long)st.pump_calls,
(unsigned long long)st.pump_skips,
(unsigned long long)st.underruns,
st.queue_hiwater, st.queue_lowater,
(unsigned long long)st.mute_events,
(unsigned long long)st.unmute_events,
(unsigned long long)st.events_total);
}
static void h_audio_wav(int id, const char *json) {
char path[512];
if (!json_get_str(json, "path", path, sizeof(path))) {
send_err(id, "missing path");
return;
}
int tap = json_get_int(json, "tap", AUDIO_TAP_SPU_OUT);
char buf[32];
int64_t start = -1;
uint64_t count = 0;
if (json_get_str(json, "start", buf, sizeof(buf)))
start = strtoll(buf, NULL, 0);
else
start = (int64_t)json_get_int(json, "start", -1);
if (json_get_str(json, "count", buf, sizeof(buf)))
count = strtoull(buf, NULL, 0);
else
count = (uint64_t)json_get_int(json, "count", 0);
int64_t wrote = audio_trace_dump_wav(tap, path, start, count);
if (wrote < 0) {
send_err(id, "dump failed (bad tap/path or empty ring)");
return;
}
send_fmt("{\"id\":%d,\"ok\":true,\"tap\":%d,\"frames\":%lld,"
"\"rate\":44100,\"tap_total\":%llu}\n",
id, tap, (long long)wrote,
(unsigned long long)audio_trace_tap_total(tap));
}
static void h_audio_events(int id, const char *json) {
int count = json_get_int(json, "count", 256);
if (count < 1) count = 1;
if (count > 8192) count = 8192;
AudioTraceEvent *evs =
(AudioTraceEvent *)malloc((size_t)count * sizeof(AudioTraceEvent));
if (!evs) { send_err(id, "alloc"); return; }
uint32_t got = audio_trace_events_get(evs, (uint32_t)count);
uint64_t total = audio_trace_events_total();
static const char *kind_names[] = {
"?", "REG", "RENDER", "SKIP", "UNDERRUN",
"MUTE", "UNMUTE", "CD_PUSH", "DMA"
};
send_fmt("{\"id\":%d,\"ok\":true,\"total\":%llu,\"count\":%u,\"events\":[",
id, (unsigned long long)total, (unsigned)got);
for (uint32_t i = 0; i < got; i++) {
const AudioTraceEvent *e = &evs[i];
const char *kn = (e->kind < sizeof(kind_names) / sizeof(kind_names[0]))
? kind_names[e->kind] : "?";
if (i > 0) send_fmt(",");
send_fmt("{\"seq\":%llu,\"smp\":%llu,\"frame\":%u,"
"\"kind\":\"%s\",\"a\":\"0x%X\",\"b\":\"0x%X\"}",
(unsigned long long)e->seq,
(unsigned long long)e->sample_idx,
e->frame, kn, e->a, e->b);
}
send_fmt("]}\n");
free(evs);
}
/* ---- cdc_volume: CD-audio DecodeVolume matrix ground truth ---- */
extern int beetle_cdc_decode_volume(unsigned out[4]);
static void h_cdc_volume(int id, const char *json) {
(void)json;
unsigned m[4] = {0, 0, 0, 0};
if (!beetle_cdc_decode_volume(m)) { send_err(id, "cdc_unavailable"); return; }
send_fmt("{\"id\":%d,\"ok\":true,"
"\"ll\":\"0x%02X\",\"lr\":\"0x%02X\","
"\"rl\":\"0x%02X\",\"rr\":\"0x%02X\"}\n",
id, m[0], m[1], m[2], m[3]);
}
/* ---- wtrace_all (always-on, no-filter, lean fields) ----
* Mirrors runtime's wtrace_all surface so probes that haven't pre-armed
* a range can still see the last ~1 second of writes the moment they
@@ -1550,6 +1664,10 @@ static const CmdEntry CMDS[] = {
{ "fntrace_dump", h_fntrace_dump },
{ "spu_voices", h_spu_voices },
{ "spu_events", h_spu_events },
{ "audio_stats", h_audio_stats },
{ "audio_wav", h_audio_wav },
{ "audio_events", h_audio_events },
{ "cdc_volume", h_cdc_volume },
/* Per-frame history ring (parity with psx-runtime). */
{ "history", h_history },
{ "get_frame", h_get_frame },
+30 -2
View File
@@ -22,9 +22,11 @@
#include "mednafen/psx/cpu.h"
#include "mednafen/psx/gpu.h"
#include "mednafen/psx/spu.h"
#include "mednafen/psx/cdc.h"
#include "mednafen/psx/frontio.h"
#include "mednafen/psx/irq.h"
#include "beetle_history.h"
#include "audio_trace.h" /* always-on oracle PCM tap ring (port 4380 audio_wav) */
#include "parity_trace.h" /* general control-flow parity ring (oracle producer) */
#include "device_trace.h" /* general device-event cycle ring (oracle producer) */
static void irq_event_callback(int which); /* fwd: device-event ring producer */
@@ -375,8 +377,18 @@ void on_video_refresh(const void *data, unsigned width, unsigned height, size_t
}
}
void on_audio_sample(int16_t l, int16_t r) { (void)l; (void)r; }
size_t on_audio_sample_batch(const int16_t *data, size_t frames) { (void)data; return frames; }
/* Beetle's fully-mixed 44.1 kHz reference SPU output arrives here every
* retro_run. Record it into the always-on audio trace (tap SPU_OUT) so port
* 4380's audio_wav can dump oracle PCM for offline A/B against the recomp's
* tap on port 4370. The host still plays no audio from psx-beetle. */
void on_audio_sample(int16_t l, int16_t r) {
int16_t frame[2] = { l, r };
audio_trace_pcm(AUDIO_TAP_SPU_OUT, frame, 1);
}
size_t on_audio_sample_batch(const int16_t *data, size_t frames) {
audio_trace_pcm(AUDIO_TAP_SPU_OUT, data, (int)frames);
return frames;
}
void on_input_poll(void) {}
int16_t on_input_state(unsigned port, unsigned device, unsigned index, unsigned id) {
@@ -576,6 +588,7 @@ extern "C" void beetle_run_frame(uint16_t pad1_buttons) {
if (!s_loaded) return;
s_joypad = pad1_buttons;
s_frame_count++;
audio_trace_note_frame(s_frame_count);
retro_run();
beetle_history_record_frame();
}
@@ -995,6 +1008,21 @@ extern "C" void psxrecomp_beetle_spu_event(unsigned kind, unsigned voice,
extern "C" uint64_t beetle_spu_event_total(void) { return s_spu_event_seq; }
/* CD-audio volume matrix ground truth (PS_CDC::DecodeVolume; 0x80 = unity).
* Returns 0 if the CDC isn't up. */
extern "C" int beetle_cdc_decode_volume(unsigned out[4])
{
extern PS_CDC *PSX_CDC;
if (!PSX_CDC) return 0;
uint8 m[2][2];
PSX_CDC->PSXRecomp_GetDecodeVolume(m);
out[0] = m[0][0]; /* L -> L */
out[1] = m[0][1]; /* L -> R */
out[2] = m[1][0]; /* R -> L */
out[3] = m[1][1]; /* R -> R */
return 1;
}
extern "C" uint32_t beetle_spu_event_get(uint64_t *out_seq, uint32_t *out_frame,
uint32_t *out_addr, uint16_t *out_env,
uint16_t *out_pitch,
+44 -1
View File
@@ -635,6 +635,31 @@ static void xa_reset_decode(void) {
xa_stream_active = 0;
}
/* CD-audio volume matrix ([data_source][output_port], 0x80 = unity) applied
* to every decoded XA/CD-DA sample, exactly as the CD controller does on
* real hardware (Beetle PS_CDC::ApplyVolume, cdc.cpp:524). Written via the
* index-2/3 register banks and latched by the "apply changes" bit; games
* drive it CONSTANTLY — X5 fades music between scenes with it (measured
* 0x7E steady, ramping to 0x00 at transitions on the Beetle oracle). Not
* modeling it left fades missing and steady levels ~0.6 dB hot. */
static uint8_t cd_pending_vol[2][2] = { { 0x80, 0x00 }, { 0x00, 0x80 } };
static uint8_t cd_decode_vol[2][2] = { { 0x80, 0x00 }, { 0x00, 0x80 } };
static void cd_apply_decode_volume(int16_t *stereo, int frames) {
/* Fast path: identity matrix (the reset state). */
if (cd_decode_vol[0][0] == 0x80 && cd_decode_vol[1][1] == 0x80 &&
cd_decode_vol[0][1] == 0x00 && cd_decode_vol[1][0] == 0x00)
return;
for (int i = 0; i < frames; i++) {
int32_t l = stereo[i * 2 + 0];
int32_t r = stereo[i * 2 + 1];
int32_t lo = ((l * cd_decode_vol[0][0]) >> 7) + ((r * cd_decode_vol[1][0]) >> 7);
int32_t ro = ((l * cd_decode_vol[0][1]) >> 7) + ((r * cd_decode_vol[1][1]) >> 7);
stereo[i * 2 + 0] = clamp16_cd(lo);
stereo[i * 2 + 1] = clamp16_cd(ro);
}
}
static int xa_decode_sector_4bit_stereo(const uint8_t* data, int16_t* out) {
static const int k0[5] = { 0, 60, 115, 98, 122 };
static const int k1[5] = { 0, 0, -52, -55, -60 };
@@ -787,6 +812,9 @@ static int maybe_deliver_xa_audio(const uint8_t* raw_data,
: xa_decode_sector_4bit_mono(raw_data + XA_DATA_OFFSET, native);
int out_frames = xa_resample_to_44100(native, native_frames, sample_rate,
pcm_44100, XA_MAX_44100_FRAMES);
/* Volume is applied after resampling, per PS1 hardware tests (Beetle
* cdc.cpp GetCDAudio comment). */
cd_apply_decode_volume(pcm_44100, out_frames);
spu_cd_audio_push(pcm_44100, out_frames);
trace_cdrom('A', 0,
((uint32_t)file << 24) | ((uint32_t)channel << 16) |
@@ -1668,6 +1696,8 @@ void cdrom_write(uint32_t addr, uint32_t value) {
case 0x1F801801:
if (index_reg == 0) {
queue_or_exec_command(val);
} else if (index_reg == 3) {
cd_pending_vol[1][1] = val; /* Right-CD -> Right-SPU */
}
break;
@@ -1678,11 +1708,24 @@ void cdrom_write(uint32_t addr, uint32_t value) {
}
} else if (index_reg == 1) {
irq_enable = val & 0x1F;
} else if (index_reg == 2) {
cd_pending_vol[0][0] = val; /* Left-CD -> Left-SPU */
} else if (index_reg == 3) {
cd_pending_vol[1][0] = val; /* Right-CD -> Left-SPU */
}
break;
case 0x1F801803:
if (index_reg == 0) {
if (index_reg == 2) {
cd_pending_vol[0][1] = val; /* Left-CD -> Right-SPU */
} else if (index_reg == 3) {
/* Apply-changes latch (bit 5) copies pending -> live, exactly
* like Beetle cdc.cpp case 0x0B. (ADPMute bit 0 is not modeled
* by the Beetle oracle either.) */
if (val & 0x20) {
memcpy(cd_decode_vol, cd_pending_vol, sizeof(cd_decode_vol));
}
} else if (index_reg == 0) {
request_reg = val;
if (!(request_reg & CDROM_REQUEST_BFRD)) {
sector_read_pos = 0;
+144
View File
@@ -30,6 +30,7 @@
#include "sio.h"
#include "memcard.h"
#include "spu.h"
#include "audio_trace.h"
#include "mdec.h"
#include "interrupts.h"
#include "psx_cycles.h"
@@ -5656,6 +5657,146 @@ static void handle_spu_events_reset(int id, const char *json)
send_fmt("{\"id\":%d,\"ok\":true}\n", id);
}
/* ---- Always-on audio tap rings (audio_trace.c) -------------------------
* audio_stats — counters: per-tap produced/nonzero/audible/peak + pump
* behavior (skips, underruns, queue watermarks).
* audio_wav — dump a tap's PCM ring slice to a 44100 Hz s16 WAV on the
* server side: {"tap":0,"path":"...","start":-1,"count":0}.
* audio_events — most recent N pipeline events (REG_WRITE/RENDER/SKIP/
* UNDERRUN/MUTE/UNMUTE/CD_PUSH/DMA), sample-clock stamped.
* Protocol mirrored on psx-beetle's port 4380 (beetle_debug_server.c). */
/* Bridge/legacy output health (main.cpp; C linkage). Returns 0 pre-device. */
extern int psx_audio_out_stats(double *fill_ms, uint64_t *underruns,
uint64_t *overflow_drops, double *correction,
int *legacy, int *host_rate);
static void handle_audio_stats(int id, const char *json)
{
(void)json;
AudioTraceStats st;
audio_trace_get_stats(&st);
double fill_ms = 0.0, correction = 0.0;
uint64_t out_underruns = 0, overflow_drops = 0;
int legacy = 1, host_rate = 44100;
int out_ok = psx_audio_out_stats(&fill_ms, &out_underruns, &overflow_drops,
&correction, &legacy, &host_rate);
send_fmt("{\"id\":%d,\"ok\":true,"
"\"taps\":["
"{\"name\":\"spu_out\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d,\"rate\":%u},"
"{\"name\":\"cd_in\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d,\"rate\":%u},"
"{\"name\":\"host\",\"frames\":%llu,\"nonzero\":%llu,"
"\"audible\":%llu,\"peak\":%d,\"rate\":%u}],"
"\"pump_calls\":%llu,\"pump_skips\":%llu,\"underruns\":%llu,"
"\"queue_hiwater\":%u,\"queue_lowater\":%u,"
"\"mutes\":%llu,\"unmutes\":%llu,\"events_total\":%llu,"
"\"out\":{\"active\":%d,\"mode\":\"%s\",\"host_rate\":%d,"
"\"fill_ms\":%.1f,\"underruns\":%llu,\"overflow_drops\":%llu,"
"\"correction\":%.5f}}",
id,
(unsigned long long)st.tap_frames[0],
(unsigned long long)st.tap_nonzero[0],
(unsigned long long)st.tap_audible[0], st.tap_peak[0],
audio_trace_tap_rate(0),
(unsigned long long)st.tap_frames[1],
(unsigned long long)st.tap_nonzero[1],
(unsigned long long)st.tap_audible[1], st.tap_peak[1],
audio_trace_tap_rate(1),
(unsigned long long)st.tap_frames[2],
(unsigned long long)st.tap_nonzero[2],
(unsigned long long)st.tap_audible[2], st.tap_peak[2],
audio_trace_tap_rate(2),
(unsigned long long)st.pump_calls,
(unsigned long long)st.pump_skips,
(unsigned long long)st.underruns,
st.queue_hiwater, st.queue_lowater,
(unsigned long long)st.mute_events,
(unsigned long long)st.unmute_events,
(unsigned long long)st.events_total,
out_ok, legacy ? "legacy-push" : "bridge-pull", host_rate,
fill_ms,
(unsigned long long)out_underruns,
(unsigned long long)overflow_drops,
correction);
}
static void handle_audio_wav(int id, const char *json)
{
char path[512];
if (!json_get_str(json, "path", path, sizeof(path))) {
send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"missing path\"}", id);
return;
}
int tap = json_get_int(json, "tap", AUDIO_TAP_SPU_OUT);
/* start/count as strings so 64-bit sample indices survive. */
char buf[32];
int64_t start = -1;
uint64_t count = 0;
if (json_get_str(json, "start", buf, sizeof(buf)))
start = strtoll(buf, NULL, 0);
else
start = (int64_t)json_get_int(json, "start", -1);
if (json_get_str(json, "count", buf, sizeof(buf)))
count = strtoull(buf, NULL, 0);
else
count = (uint64_t)json_get_int(json, "count", 0);
int64_t wrote = audio_trace_dump_wav(tap, path, start, count);
if (wrote < 0) {
send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"dump failed (bad tap/path or empty ring)\"}", id);
return;
}
uint64_t total = audio_trace_tap_total(tap);
send_fmt("{\"id\":%d,\"ok\":true,\"tap\":%d,\"frames\":%lld,"
"\"rate\":%u,\"tap_total\":%llu}",
id, tap, (long long)wrote, audio_trace_tap_rate(tap),
(unsigned long long)total);
}
static void handle_audio_events(int id, const char *json)
{
int count = json_get_int(json, "count", 256);
if (count < 1) count = 1;
if (count > 8192) count = 8192;
AudioTraceEvent *evs =
(AudioTraceEvent *)malloc((size_t)count * sizeof(AudioTraceEvent));
if (!evs) { send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
uint32_t got = audio_trace_events_get(evs, (uint32_t)count);
uint64_t total = audio_trace_events_total();
static const char *kind_names[] = {
"?", "REG", "RENDER", "SKIP", "UNDERRUN",
"MUTE", "UNMUTE", "CD_PUSH", "DMA"
};
size_t cap = 256u + (size_t)got * 192u;
char *out = (char *)malloc(cap);
if (!out) { free(evs); send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
size_t off = 0;
int n = snprintf(out + off, cap - off,
"{\"id\":%d,\"ok\":true,\"total\":%llu,\"count\":%u,\"events\":[",
id, (unsigned long long)total, (unsigned)got);
if (n > 0) off += (size_t)n;
for (uint32_t i = 0; i < got; i++) {
const AudioTraceEvent *e = &evs[i];
const char *kn = (e->kind < sizeof(kind_names) / sizeof(kind_names[0]))
? kind_names[e->kind] : "?";
n = snprintf(out + off, cap - off,
"%s{\"seq\":%llu,\"smp\":%llu,\"frame\":%u,"
"\"kind\":\"%s\",\"a\":\"0x%X\",\"b\":\"0x%X\"}",
i == 0 ? "" : ",",
(unsigned long long)e->seq,
(unsigned long long)e->sample_idx,
e->frame, kn, e->a, e->b);
if (n > 0) off += (size_t)n;
}
n = snprintf(out + off, cap - off, "]}");
if (n > 0) off += (size_t)n;
send_fmt("%s", out);
free(out);
free(evs);
}
/* ---- SIO IRQ-arm audit -----------------------------------------------
* Reports counts of TX writes that reached the IRQ-arm decision in
* sio_write SIO_TX_DATA, partitioned by active_device. Tells us at the
@@ -11443,6 +11584,9 @@ static const CmdEntry s_commands[] = {
{ "spu_voices", handle_spu_voices },
{ "spu_events", handle_spu_events },
{ "spu_events_reset", handle_spu_events_reset },
{ "audio_stats", handle_audio_stats },
{ "audio_wav", handle_audio_wav },
{ "audio_events", handle_audio_events },
{ "card_buffer_dump", handle_card_buffer_dump },
{ "sio_arm_audit", handle_sio_arm_audit },
{ "sio_burst_stats", handle_sio_burst_stats },
+5
View File
@@ -20,6 +20,7 @@
#include "mdec.h"
#include "overlay_capture.h"
#include "spu.h"
#include "audio_trace.h"
#include "event_ring.h"
#include <stdint.h>
#include <stdio.h>
@@ -748,6 +749,10 @@ static uint32_t execute_ch4_spu(void) {
spu_dma_write(psx_read_word(addr));
addr = (addr + addr_step) & 0x1FFFFCu;
}
/* One aggregated event per SPU-bound transfer (per-word would flood
* the ring: a full sound-bank upload is ~128k words). */
audio_trace_event(AUDIO_EV_DMA_WRITE, total_words,
channels[4].madr & 0x1FFFFCu);
} else {
for (uint32_t i = 0; i < total_words; i++) {
psx_write_word(addr, 0);
+23 -2
View File
@@ -23,16 +23,37 @@ uint32_t frame_pacing_sleep_ms(uint64_t now, uint64_t deadline,
}
#ifndef FRAME_PACING_PURE_ONLY
/* Bounded catch-up window, in periods. A transient stall (heavy frame, CD
* burst) leaves next_deadline in the past; KEEPING that debt and running
* unpaced until it is repaid preserves the long-term rate at exactly one
* period per frame — which the audio pipeline depends on (the SPU produces
* 768 guest cycles per output sample; every re-anchor that forgives debt
* permanently starves the audio ring by the forgiven amount. Measured on
* MMX5: forgiving all >1-period debt averaged 59.80 Hz against a 59.94
* target = -0.4% chronic audio underrun). Only debt beyond this window —
* sustained sub-realtime emulation, not a hiccup — is forgiven, else the
* pacer would demand unbounded catch-up. */
#define FRAME_PACER_CATCHUP_MAX_PERIODS 8u
void frame_pacer_wait(FramePacer *p, double period_ms) {
uint64_t freq = SDL_GetPerformanceFrequency();
uint64_t period = (uint64_t)((double)freq * (period_ms / 1000.0));
uint64_t now = SDL_GetPerformanceCounter();
if (p->next_deadline == 0 || now >= p->next_deadline + period) {
/* First frame, or fell more than one period behind: re-anchor. */
if (p->next_deadline == 0 ||
now >= p->next_deadline + period * FRAME_PACER_CATCHUP_MAX_PERIODS) {
/* First frame, or sustained slowness beyond the catch-up window:
* re-anchor (forgive the debt). */
p->next_deadline = now + period;
return;
}
if (now >= p->next_deadline) {
/* In debt from a recent stall: run this frame unpaced and advance
* the deadline, repaying one period of debt per fast frame. */
p->next_deadline += period;
return;
}
for (;;) {
now = SDL_GetPerformanceCounter(); /* ONE read per iteration */
+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);
}
}
+362
View File
@@ -0,0 +1,362 @@
/* recomp_audio_drc.h - shared audio clock-domain bridge for the *recomp ecosystems.
*
* One persistent band-limited polyphase windowed-sinc fractional resampler driven
* by a P-only normalized-fill controller, fed by a millisecond-measured SPSC ring,
* with smooth fade-based startup / underrun / overflow handling.
*
* It exists because every runner (NES/SNES/GBA/N64/Genesis) is video-master /
* audio-slaved: guest audio is produced ~1 frame per wall-clock frame while the
* host device consumes at its own crystal rate. The unsynchronized clocks drift,
* and the historical "fix" was to discard samples (drop/flush/decimate or a
* nearest-neighbor servo) -- every discard is a click, and continuous micro
* discard/dup is a sustained crackle. This bridge replaces all of that: the
* controller nudges the *resample ratio* by <= +/-0.5% to hold the ring near a
* target fill, and the band-limited resampler keeps the waveform continuous.
*
* Single-file. Define RECOMP_AUDIO_DRC_IMPL in exactly ONE translation unit.
* C99 and C++ compatible. Depends only on the C stdlib + libm.
*
* Threading: rab_push() is called by the producer (emulation thread), rab_pull()
* by the consumer (host audio callback). One producer, one consumer. The shared
* state touched across threads (in_count / out_pos / ring) is accessed in a
* single-producer/single-consumer pattern; integate with whatever memory ordering
* the host runner already uses for its existing queue (a mutex around push/pull is
* fine and is what most of these runners already have).
*/
#ifndef RECOMP_AUDIO_DRC_H
#define RECOMP_AUDIO_DRC_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int channels; /* 1 or 2 */
double source_rate; /* nominal guest source sample rate (Hz) */
double host_rate; /* host device sample rate (Hz) */
int taps; /* prototype length; 16 min, 32 preferred */
int phases; /* fractional phases; 256-1024 (512 default) */
double target_ms; /* steady-state ring fill target (default 80) */
double ring_ms; /* ring capacity in ms (default 250) */
double kp; /* proportional gain (default 0.02) */
double ki_per_s; /* integral gain per second (default 0.02): holds
a steady clock-skew correction with ZERO
standing fill error (P-only must run a permanent
fill deficit of corr/kp to sustain correction) */
double max_correction; /* clamp on ratio correction (default 0.005) */
double err_lp_ms; /* error low-pass time constant ms (default 75) */
double slew_pp_per_s; /* correction slew, percent-points/sec (default 0.75) */
double deadband_ms; /* +/- band around target with no correction (1.0) */
double em_low_ms; /* below this = underrun emergency (default 12) */
double em_high_ms; /* above this = overflow emergency (default 105) */
} rab_config;
typedef struct {
uint64_t pushed_frames;
uint64_t pulled_frames;
uint64_t underrun_events; /* times the ring ran dry mid-pull */
uint64_t overflow_drops; /* source frames dropped on ring overflow */
double last_fill_ms;
double last_correction; /* applied ratio correction (signed) */
} rab_stats;
typedef struct rab_bridge {
rab_config cfg;
int half; /* taps/2 */
float *coeffs; /* phases*taps, DC-normalized per phase */
float *ring; /* cap_frames*channels, interleaved float */
int64_t cap; /* ring capacity in frames */
/* SPSC stream position */
uint64_t in_count; /* frames ever pushed (monotonic) */
double out_pos; /* fractional source-frame read cursor */
/* controller */
double cur_step; /* current source frames consumed per output frame */
double err_lp; /* low-passed normalized error */
double corr; /* current (slew-limited) correction */
double corr_i; /* integral term: holds steady clock-skew with
zero standing fill error (see controller) */
/* fade / emergency */
double gain; /* 0..1 smooth gate for startup/underrun */
int primed; /* set once fill first reaches target */
float last_out[2]; /* last emitted sample per channel (for holds) */
rab_stats stats;
} rab_bridge;
/* Fill a config with the locked-in ecosystem defaults. You must still set
* channels, source_rate and host_rate afterwards. */
void rab_config_defaults(rab_config *c);
/* Allocate internal buffers and build the polyphase filter bank. Returns 0 on
* success, non-zero on allocation/parameter failure. */
int rab_init(rab_bridge *b, const rab_config *cfg);
void rab_free(rab_bridge *b);
/* Producer: append `frames` interleaved int16 source samples. Thread: emu side. */
void rab_push(rab_bridge *b, const int16_t *interleaved, int frames);
/* Consumer: render exactly `frames` interleaved int16 host samples (never
* blocks; emits faded silence on underrun / before prime). Thread: callback. */
void rab_pull(rab_bridge *b, int16_t *out, int frames);
/* Current ring fill expressed in milliseconds of source audio. */
double rab_fill_ms(const rab_bridge *b);
void rab_get_stats(const rab_bridge *b, rab_stats *out);
#ifdef __cplusplus
}
#endif
/* ---------------------------------------------------------------------------- */
#ifdef RECOMP_AUDIO_DRC_IMPL
#include <stdlib.h>
#include <string.h>
#include <math.h>
#ifndef RAB_PI
#define RAB_PI 3.14159265358979323846
#endif
static double rab__sinc(double x) {
if (x > -1e-9 && x < 1e-9) return 1.0;
double p = RAB_PI * x;
return sin(p) / p;
}
/* Blackman window over [-1,1] mapped from t/half. */
static double rab__blackman(double tn) { /* tn in [-1,1] */
double a = RAB_PI * (tn + 1.0); /* 0..2pi */
return 0.42 - 0.5 * cos(a) + 0.08 * cos(2.0 * a);
}
void rab_config_defaults(rab_config *c) {
c->channels = 2;
c->source_rate = 48000.0;
c->host_rate = 48000.0;
c->taps = 32;
c->phases = 512;
/* target/ring raised 50/150 -> 80/250: a producer paced by an emulated
* machine stalls for tens of ms at a time (heavy frames, CD bursts) —
* jitter far beyond a host audio driver's. 80 ms keeps the servo's
* operating point clear of the dry floor through those spikes. */
c->target_ms = 80.0;
c->ring_ms = 250.0;
c->kp = 0.02;
c->ki_per_s = 0.02;
c->max_correction = 0.005;
c->err_lp_ms = 75.0;
c->slew_pp_per_s = 0.75;
c->deadband_ms = 1.0;
c->em_low_ms = 12.0;
c->em_high_ms = 205.0;
}
int rab_init(rab_bridge *b, const rab_config *cfg) {
if (!b || !cfg) return 1;
if (cfg->channels < 1 || cfg->channels > 2) return 2;
if (cfg->taps < 4 || (cfg->taps & 1)) return 3;
if (cfg->phases < 16) return 4;
if (cfg->source_rate <= 0.0 || cfg->host_rate <= 0.0) return 5;
memset(b, 0, sizeof(*b));
b->cfg = *cfg;
b->half = cfg->taps / 2;
/* Prototype lowpass cutoff (normalized to source Nyquist). When downsampling
* (host < source) we must pull the cutoff in to suppress aliases; when
* upsampling we keep the full source band. A small guard avoids the corner. */
double fc = (cfg->host_rate < cfg->source_rate)
? (cfg->host_rate / cfg->source_rate) : 1.0;
fc *= 0.92;
size_t ncoef = (size_t)cfg->phases * (size_t)cfg->taps;
b->coeffs = (float *)malloc(ncoef * sizeof(float));
if (!b->coeffs) return 6;
for (int p = 0; p < cfg->phases; ++p) {
double frac = (double)p / (double)cfg->phases; /* [0,1) sub-sample delay */
double sum = 0.0;
float *row = b->coeffs + (size_t)p * cfg->taps;
for (int k = 0; k < cfg->taps; ++k) {
/* input tap index relative to the interpolation point */
double t = (double)(k - (b->half - 1)) - frac;
double tn = t / (double)b->half; /* window arg in [-~1,~1] */
double w = (tn <= -1.0 || tn >= 1.0) ? 0.0 : rab__blackman(tn);
double h = fc * rab__sinc(fc * t) * w;
row[k] = (float)h;
sum += h;
}
/* normalize each phase to unit DC gain to kill level ripple / zipper */
if (sum > 1e-12) {
float inv = (float)(1.0 / sum);
for (int k = 0; k < cfg->taps; ++k) row[k] *= inv;
}
}
double cap_ms = cfg->ring_ms;
if (cap_ms < cfg->target_ms + 40.0) cap_ms = cfg->target_ms + 40.0;
b->cap = (int64_t)(cfg->source_rate * cap_ms / 1000.0) + cfg->taps + 2;
b->ring = (float *)calloc((size_t)b->cap * cfg->channels, sizeof(float));
if (!b->ring) { free(b->coeffs); b->coeffs = NULL; return 7; }
b->in_count = 0;
/* Start the read cursor half a window in so the polyphase filter always has
* valid history behind it; otherwise the first half-1 outputs can't be
* computed and the cursor would never advance. */
b->out_pos = (double)(b->half - 1);
b->cur_step = cfg->source_rate / cfg->host_rate;
b->err_lp = 0.0;
b->corr = 0.0;
b->gain = 0.0;
b->primed = 0;
return 0;
}
void rab_free(rab_bridge *b) {
if (!b) return;
free(b->coeffs); b->coeffs = NULL;
free(b->ring); b->ring = NULL;
}
double rab_fill_ms(const rab_bridge *b) {
double fill = (double)b->in_count - b->out_pos;
if (fill < 0.0) fill = 0.0;
return fill * 1000.0 / b->cfg.source_rate;
}
void rab_push(rab_bridge *b, const int16_t *in, int frames) {
int ch = b->cfg.channels;
for (int f = 0; f < frames; ++f) {
double fill = (double)b->in_count - b->out_pos;
if (fill >= (double)(b->cap - 1)) {
/* Overflow emergency: producer outran consumer past the ring. Drop the
* oldest source frame by nudging the read cursor forward. Rare under
* DRC; the resampler's continuous history limits the audible seam. */
b->out_pos += 1.0;
b->stats.overflow_drops++;
}
int64_t w = (int64_t)(b->in_count % (uint64_t)b->cap);
float *slot = b->ring + w * ch;
for (int c = 0; c < ch; ++c)
slot[c] = (float)in[f * ch + c] * (1.0f / 32768.0f);
b->in_count++;
}
b->stats.pushed_frames += (uint64_t)frames;
}
static void rab__update_controller(rab_bridge *b) {
rab_config *c = &b->cfg;
double fill_ms = rab_fill_ms(b);
b->stats.last_fill_ms = fill_ms;
if (!b->primed && fill_ms >= c->target_ms) b->primed = 1;
/* one control update per pull; low-pass the normalized error. Use the pull
* block duration as dt (approx via host_rate is unnecessary here -- we fold
* the time constant into a per-update smoothing alpha sized for ~10-20ms). */
double err = (fill_ms - c->target_ms) / c->target_ms;
if (fill_ms > c->target_ms - c->deadband_ms &&
fill_ms < c->target_ms + c->deadband_ms) {
err = 0.0; /* deadband */
}
/* smoothing alpha: assume ~one update per host audio block (~10ms typical). */
double dt_ms = 12.0;
double alpha = dt_ms / (c->err_lp_ms + dt_ms);
b->err_lp += alpha * (err - b->err_lp);
/* PI control. P alone cannot hold a sustained source-clock skew without a
* standing fill error of corr/kp (a 0.3% skew cost 7.5 ms of permanent
* fill deficit at kp=0.02/target=50 — margin that jitter spikes then
* drained to underrun). The integral accumulates the steady skew so fill
* returns to target; anti-windup: never integrate deeper into the clamp. */
double target_corr = c->kp * b->err_lp + b->corr_i;
int clamped_hi = target_corr > c->max_correction;
int clamped_lo = target_corr < -c->max_correction;
if ((!clamped_hi && !clamped_lo) ||
(clamped_hi && b->err_lp < 0.0) || (clamped_lo && b->err_lp > 0.0)) {
b->corr_i += c->ki_per_s * (dt_ms / 1000.0) * b->err_lp;
if (b->corr_i > c->max_correction) b->corr_i = c->max_correction;
if (b->corr_i < -c->max_correction) b->corr_i = -c->max_correction;
}
if (target_corr > c->max_correction) target_corr = c->max_correction;
if (target_corr < -c->max_correction) target_corr = -c->max_correction;
/* slew limit (percentage points per second -> fraction per update) */
double slew = (c->slew_pp_per_s / 100.0) * (dt_ms / 1000.0);
double d = target_corr - b->corr;
if (d > slew) d = slew;
if (d < -slew) d = -slew;
b->corr += d;
/* A high queue (positive error) must be consumed FASTER -> larger step. */
b->cur_step = (c->source_rate / c->host_rate) * (1.0 + b->corr);
b->stats.last_correction = b->corr;
}
void rab_pull(rab_bridge *b, int16_t *out, int frames) {
int ch = b->cfg.channels;
rab__update_controller(b);
double gstep = 1.0 / (b->cfg.host_rate * 0.003); /* ~3ms fade in/out */
double step = b->cur_step;
for (int f = 0; f < frames; ++f) {
int64_t i = (int64_t)floor(b->out_pos);
double fr = b->out_pos - (double)i;
int64_t newest_needed = i + b->half;
int have = b->primed && (newest_needed < (int64_t)b->in_count)
&& ((i - b->half + 1) >= 0);
float s[2] = {0.0f, 0.0f};
if (have) {
int phase = (int)(fr * b->cfg.phases);
if (phase >= b->cfg.phases) phase = b->cfg.phases - 1;
const float *cz = b->coeffs + (size_t)phase * b->cfg.taps;
for (int k = 0; k < b->cfg.taps; ++k) {
int64_t m = i - b->half + 1 + k;
const float *sl = b->ring + (m % b->cap) * ch;
float w = cz[k];
s[0] += w * sl[0];
if (ch == 2) s[1] += w * sl[1];
}
b->last_out[0] = s[0];
if (ch == 2) b->last_out[1] = s[1];
b->out_pos += step; /* advance only when we consumed */
} else {
/* underrun / not yet primed: hold last sample, let gain fade it out */
s[0] = b->last_out[0];
if (ch == 2) s[1] = b->last_out[1];
if (b->primed && newest_needed >= (int64_t)b->in_count)
b->stats.underrun_events++;
}
/* smooth gate: fade toward 1 when delivering real audio, toward 0 else */
double target_gain = have ? 1.0 : 0.0;
if (b->gain < target_gain) {
b->gain += gstep; if (b->gain > target_gain) b->gain = target_gain;
} else if (b->gain > target_gain) {
b->gain -= gstep; if (b->gain < target_gain) b->gain = target_gain;
}
for (int c = 0; c < ch; ++c) {
double v = (double)s[c] * b->gain * 32768.0;
if (v > 32767.0) v = 32767.0;
if (v < -32768.0) v = -32768.0;
out[f * ch + c] = (int16_t)lrint(v);
}
}
b->stats.pulled_frames += (uint64_t)frames;
}
void rab_get_stats(const rab_bridge *b, rab_stats *out) { *out = b->stats; }
#endif /* RECOMP_AUDIO_DRC_IMPL */
#endif /* RECOMP_AUDIO_DRC_H */
+46 -1
View File
@@ -9,6 +9,7 @@
#include "spu.h"
#include "spu_shadow.h"
#include "audio_trace.h"
#include <string.h>
@@ -284,6 +285,10 @@ void spu_cd_audio_push(const int16_t* stereo, int frames) {
}
cd_frame_count += in_frames;
cd_push_frames += in_frames;
/* T2 tap: what the CD/XA decoder feeds the SPU CD input bus. */
audio_trace_pcm(AUDIO_TAP_CD_IN, stereo, (int)in_frames);
audio_trace_event(AUDIO_EV_CD_PUSH, in_frames, cd_frame_count);
}
static int cd_audio_pop(int16_t* left, int16_t* right) {
@@ -327,7 +332,15 @@ static void decode_block(SpuVoice *v) {
}
}
if (flags & 0x04u) v->repeat_addr = addr;
if (flags & 0x04u) {
v->repeat_addr = addr;
/* The auto-latch updates the guest-visible register too (Beetle
* spu.cpp:386 + read path 1302): drivers read it back to save
* loop points. */
int v_idx = (int)(v - voices);
if (v_idx >= 0 && v_idx < SPU_VOICE_COUNT)
spu_regs[(uint32_t)v_idx * 8u + 7u] = (uint16_t)(addr >> 3);
}
v->flags = flags;
v->sample_idx = 0;
v->cur_addr = (addr + 16u) & (SPU_RAM_SIZE - 1u);
@@ -558,6 +571,13 @@ void spu_render(int16_t* out_stereo, int frames) {
mix_l += ((int32_t)s * vl) >> 14;
mix_r += ((int32_t)s * vr) >> 14;
}
{ /* T0 tap: voice sum only (pre CD mix, pre main volume) so the
* final mix can be decomposed source-by-source offline. */
int16_t vsum[2];
vsum[0] = clamp16(mix_l);
vsum[1] = clamp16(mix_r);
audio_trace_pcm(AUDIO_TAP_VOICES, vsum, 1);
}
if (ctrl & 0x0001u) {
int16_t cd_l = 0;
int16_t cd_r = 0;
@@ -596,6 +616,11 @@ void spu_render(int16_t* out_stereo, int frames) {
* while proven. No-op (byte-identical) when disabled. The canon mix above
* stays the authoritative output AND the verify oracle. */
spu_shadow_process(out_stereo, frames);
/* T1 tap: the SPU's final output block as handed to the host layer
* (post-shadow, pre host fade/mute). Placed here so every spu_render
* caller — the vblank pump and the turbo fade tail — is covered. */
audio_trace_pcm(AUDIO_TAP_SPU_OUT, out_stereo, frames);
}
void spu_debug_info(SpuDebugInfo* out) {
@@ -660,8 +685,28 @@ void spu_write(uint32_t addr, uint32_t value) {
if (addr >= 0x1F801C00u && addr <= 0x1F801DFFu) {
uint32_t idx = reg_index(addr);
if (idx < SPU_REG_COUNT) {
/* Event-ring every register store with its SPU_OUT sample-clock
* timestamp: this is what lets offline analysis correlate a
* write (key-on, pitch, volume) with the exact output sample
* the render loop first honored it at — the write-to-render
* quantization audit (snesrecomp 8ffc797 class). */
audio_trace_event(AUDIO_EV_REG_WRITE, addr, value & 0xFFFFu);
spu_regs[idx] = (uint16_t)value;
/* Voice repeat/loop address (voice reg 7) is LIVE state on real
* hardware: writing it after KEYON retargets where the next
* END+REPEAT block jumps (Beetle spu.cpp:1150/333). X5's driver
* uses exactly this to end one-shots on looped samples — KEYON,
* then point the loop register at a silent tail block; no KEYOFF
* is ever sent. Caching repeat_addr only at KEYON left voices
* looping the loud sample body forever (measured: 3 stuck voices
* re-looping ~1400x/s, +11 dB over the oracle, clipping). */
if (idx < (uint32_t)SPU_VOICE_COUNT * 8u && (idx & 7u) == 7u) {
int v = (int)(idx >> 3);
voices[v].repeat_addr =
((uint32_t)(uint16_t)value << 3) & (SPU_RAM_SIZE - 1u);
}
if (addr == 0x1F801D88u) {
kon_latch = (kon_latch & 0xFFFF0000u) | (uint32_t)(uint16_t)value;
key_on((uint32_t)(uint16_t)value);
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""audio_ab_diff.py - drift-tolerant A/B comparison of recomp audio vs an
accuracy-grade emulator reference.
WHY drift-tolerant (not bit-exact): the SNES recomp drives its SPC700/S-DSP on
a *synthetic* clock (cpu_state.c pace_cycles = +256 master cycles per HW-register
touch, converted to APU cycles), not a real cycle count. So even a perfect DSP
will phase-drift against hardware. A sample-exact diff is therefore meaningless
here; what matters is whether the *music* matches: same notes, same onsets, same
pitch, same timbre, with bounded time drift. This tool measures exactly those.
Inputs are two WAVs at (possibly) different native rates:
- REF : the oracle. Best = bsnes Accuracy via tools/snesref (embeds blargg's
cycle-exact S-DSP). bsnes libretro emits 48000 Hz.
- TEST : the recomp. Dump its always-on native PCM ring (32040 Hz) via the
debug server: audio_wav <path> (runner/src/audio_trace.c).
Both are resampled to a common rate (default 32040; SNES content is band-limited
well below 16 kHz so nothing is lost). Then:
1. global alignment - FFT cross-correlation -> best lag + peak correlation
2. drift - per-window local lag; a growing lag = clock/tempo drift
(reported as ms/s and ppm). This is the headline SNES
metric: it quantifies "off-cue".
3. onset timing - spectral-flux onsets matched across streams -> timing
error distribution (median / IQR / p90), in ms.
4. pitch / tuning - dominant-pitch track ratio in cents -> "off-tune".
5. timbre - log-spectral distance + spectral-centroid delta.
6. per-stream quality- click rate + noise floor (reused round-2 detectors).
Only dependency is numpy. Usage:
python audio_ab_diff.py --ref REF.wav --test TEST.wav [--rate 32040]
[--start-s 0] [--dur-s 10] [--json out.json]
"""
import sys, os, wave, argparse, json
import numpy as np
# ----------------------------------------------------------------------------- io
def load_wav(path):
"""Robust WAV loader: tolerates an UNFINALIZED header (data-size==0 or wrong)
from a force-killed capture by taking all bytes after the data chunk start.
Falls back to raw parse if the stdlib wave module rejects the header."""
try:
with wave.open(path, "rb") as w:
ch, rate, n = w.getnchannels(), w.getframerate(), w.getnframes()
raw = w.readframes(n)
if len(raw) >= (n * ch * 2) and n > 0:
x = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(axis=1) if ch > 1 else x), rate
except Exception:
pass
# manual parse
b = open(path, "rb").read()
assert b[:4] == b"RIFF" and b[8:12] == b"WAVE", "not a WAV"
ch, rate, bits, off = 2, 32040, 16, 12
data_off, data_len = None, 0
while off + 8 <= len(b):
cid = b[off:off + 4]; sz = int.from_bytes(b[off + 4:off + 8], "little"); body = off + 8
if cid == b"fmt ":
ch = int.from_bytes(b[body + 2:body + 4], "little")
rate = int.from_bytes(b[body + 4:body + 8], "little")
bits = int.from_bytes(b[body + 14:body + 16], "little")
elif cid == b"data":
data_off = body
data_len = sz if 0 < sz <= len(b) - body else (len(b) - body)
break
off = body + sz + (sz & 1)
raw = b[data_off:data_off + (data_len - data_len % (ch * (bits // 8)))]
x = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(axis=1) if ch > 1 else x), rate
def resample_linear(x, src, dst):
if src == dst or x.size == 0:
return x
n_out = int(round(x.size * dst / src))
t = np.arange(n_out) * (src / dst)
i0 = np.floor(t).astype(int)
i0 = np.clip(i0, 0, x.size - 2)
frac = t - i0
return x[i0] * (1.0 - frac) + x[i0 + 1] * frac
def active_region(x, thr=1e-4):
nz = np.where(np.abs(x) > thr)[0]
if nz.size == 0:
return 0, x.size
return int(nz[0]), int(nz[-1]) + 1
# --------------------------------------------------------------------- alignment
def best_lag(a, b, max_lag):
"""Lag (in samples) that best aligns b onto a, via FFT cross-correlation,
restricted to |lag| <= max_lag. Returns (lag, peak_norm_corr)."""
n = 1 << int(np.ceil(np.log2(a.size + b.size)))
fa = np.fft.rfft(a - a.mean(), n)
fb = np.fft.rfft(b - b.mean(), n)
cc = np.fft.irfft(fa * np.conj(fb), n)
cc = np.concatenate([cc[-max_lag:], cc[: max_lag + 1]])
lags = np.arange(-max_lag, max_lag + 1)
k = int(np.argmax(cc))
denom = (np.linalg.norm(a) * np.linalg.norm(b)) or 1.0
return int(lags[k]), float(cc[k] / denom)
def drift_track(a, b, rate, win_s=0.75, hop_s=0.375, search_ms=120):
"""Local lag per window -> drift slope. a,b already globally aligned."""
win = int(win_s * rate); hop = int(hop_s * rate); ml = int(search_ms * rate / 1000)
n = min(a.size, b.size)
centers, lags, corrs = [], [], []
pos = 0
while pos + win <= n:
aw = a[pos:pos + win]; bw = b[pos:pos + win]
if np.linalg.norm(aw) > 1e-3 and np.linalg.norm(bw) > 1e-3:
lag, c = best_lag(aw, bw, ml)
centers.append((pos + win / 2) / rate); lags.append(lag / rate * 1000.0); corrs.append(c)
pos += hop
if len(centers) < 3:
return None
centers = np.array(centers); lags = np.array(lags); corrs = np.array(corrs)
# weighted least-squares slope (weight by local correlation quality)
wts = np.clip(corrs, 0, None)
if wts.sum() < 1e-6:
wts = np.ones_like(corrs)
A = np.vstack([centers, np.ones_like(centers)]).T
W = np.diag(wts)
coef = np.linalg.lstsq(W @ A, W @ lags, rcond=None)[0]
slope_ms_per_s = float(coef[0])
return {
"n_windows": len(centers),
"lag_ms_mean": float(lags.mean()),
"lag_ms_std": float(lags.std()),
"drift_ms_per_s": slope_ms_per_s,
"drift_ppm": slope_ms_per_s * 1000.0, # ms/s -> ppm
"local_corr_median": float(np.median(corrs)),
}
# ------------------------------------------------------------------------ onsets
def onsets(x, rate, fft=1024, hop=256, k=1.5):
if x.size < fft:
return np.array([])
win = np.hanning(fft)
frames = 1 + (x.size - fft) // hop
mag = np.empty((frames, fft // 2 + 1))
for i in range(frames):
seg = x[i * hop:i * hop + fft] * win
mag[i] = np.abs(np.fft.rfft(seg))
flux = np.maximum(0.0, np.diff(mag, axis=0)).sum(axis=1)
if flux.size == 0:
return np.array([])
flux /= (flux.max() or 1.0)
# adaptive threshold: local mean + k*local std
w = 16
thr = np.array([flux[max(0, i - w):i + w].mean() + k * flux[max(0, i - w):i + w].std()
for i in range(flux.size)])
pk = (flux[1:-1] > flux[:-2]) & (flux[1:-1] >= flux[2:]) & (flux[1:-1] > thr[1:-1])
idx = np.where(pk)[0] + 1
return idx * hop / rate # seconds
def onset_match(ref_t, test_t, lag_s, tol_ms=50):
"""Match test onsets (shifted by -lag) to nearest ref onset; report errors."""
if ref_t.size == 0 or test_t.size == 0:
return None
tt = test_t - lag_s
errs = []
matched = 0
for t in tt:
d = np.abs(ref_t - t)
j = int(np.argmin(d))
if d[j] * 1000.0 <= tol_ms:
errs.append((t - ref_t[j]) * 1000.0); matched += 1
if not errs:
return {"matched": 0, "ref_onsets": int(ref_t.size), "test_onsets": int(test_t.size)}
errs = np.array(errs)
return {
"ref_onsets": int(ref_t.size), "test_onsets": int(test_t.size),
"matched": matched,
"match_rate": matched / max(1, test_t.size),
"err_ms_median": float(np.median(errs)),
"err_ms_abs_median": float(np.median(np.abs(errs))),
"err_ms_iqr": float(np.percentile(errs, 75) - np.percentile(errs, 25)),
"err_ms_p90": float(np.percentile(np.abs(errs), 90)),
}
# ---------------------------------------------------------------- pitch & timbre
def avg_spectrum(x, rate, fft=4096, hop=2048):
if x.size < fft:
return None, None
win = np.hanning(fft); frames = 1 + (x.size - fft) // hop
acc = np.zeros(fft // 2 + 1)
for i in range(frames):
acc += np.abs(np.fft.rfft(x[i * hop:i * hop + fft] * win))
acc /= frames
freqs = np.fft.rfftfreq(fft, 1.0 / rate)
return freqs, acc
def dominant_pitch_track(x, rate, fft=4096, hop=2048, fmin=80, fmax=2000):
if x.size < fft:
return np.array([])
win = np.hanning(fft); frames = 1 + (x.size - fft) // hop
freqs = np.fft.rfftfreq(fft, 1.0 / rate)
band = (freqs >= fmin) & (freqs <= fmax)
out = []
for i in range(frames):
sp = np.abs(np.fft.rfft(x[i * hop:i * hop + fft] * win))
if sp[band].max() < 1e-3:
continue
out.append(freqs[band][int(np.argmax(sp[band]))])
return np.array(out)
def cents(ratio):
return 1200.0 * np.log2(ratio) if ratio > 0 else float("nan")
# ----------------------------------------------------------- per-stream quality
def click_rate(x, rate, k=6.0, rms_ms=10.0):
if x.size < 4:
return 0.0
d1 = np.abs(np.diff(x, prepend=x[0])); d2 = np.abs(np.diff(x, n=2, prepend=[x[0], x[0]]))
w = max(8, int(rate * rms_ms / 1000.0))
cs = np.cumsum(np.concatenate([[0.0], x * x]))
lo = np.maximum(0, np.arange(x.size) - w); hi = np.minimum(x.size, np.arange(x.size) + w)
rms = np.sqrt((cs[hi] - cs[lo]) / np.maximum(1, hi - lo))
score = np.maximum(d1, d2) / np.maximum(rms, 1e-4)
idx = np.where(score > k)[0]
if idx.size:
keep = [idx[0]]
for i in idx[1:]:
if i - keep[-1] >= 3:
keep.append(i)
idx = np.array(keep)
return idx.size / (x.size / rate)
def noise_floor_db(x):
if x.size == 0:
return float("nan")
q = np.sqrt(np.mean(np.sort(x * x)[: max(1, x.size // 10)])) # quietest 10% RMS
return 20.0 * np.log10(q + 1e-12)
# ------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ref", required=True, help="oracle WAV (bsnes)")
ap.add_argument("--test", required=True, help="recomp WAV (audio_wav dump)")
ap.add_argument("--rate", type=int, default=32040, help="common analysis rate")
ap.add_argument("--start-s", type=float, default=0.0)
ap.add_argument("--dur-s", type=float, default=0.0, help="0 = all")
ap.add_argument("--max-lag-s", type=float, default=2.0)
ap.add_argument("--json", default=None)
a = ap.parse_args()
rate = a.rate
xr, rr = load_wav(a.ref); xt, rt = load_wav(a.test)
xr = resample_linear(xr, rr, rate); xt = resample_linear(xt, rt, rate)
# trim leading silence on each independently, then optional window
r0, r1 = active_region(xr); t0, t1 = active_region(xt)
xr = xr[r0:r1]; xt = xt[t0:t1]
if a.start_s or a.dur_s:
s = int(a.start_s * rate); e = xr.size if a.dur_s == 0 else s + int(a.dur_s * rate)
xr = xr[s:e]; xt = xt[s:min(e, xt.size)]
n = min(xr.size, xt.size)
xr = xr[:n]; xt = xt[:n]
if n < rate:
print("ERROR: <1s of overlapping active audio; check captures.", file=sys.stderr)
sys.exit(2)
ml = int(a.max_lag_s * rate)
glag, gcorr = best_lag(xr, xt, ml)
# apply global lag so downstream operate aligned
if glag >= 0:
xa, xb = xr[glag:], xt[: n - glag]
else:
xa, xb = xr[: n + glag], xt[-glag:]
m = min(xa.size, xb.size); xa = xa[:m]; xb = xb[:m]
drift = drift_track(xa, xb, rate)
o_ref = onsets(xa, rate); o_test = onsets(xb, rate)
onset = onset_match(o_ref, o_test, 0.0)
fr, sr_ = avg_spectrum(xa, rate); _, st_ = avg_spectrum(xb, rate)
timbre = {}
if sr_ is not None and st_ is not None:
lo = (fr >= 50) & (fr <= 16000)
ls = np.log(sr_[lo] + 1e-9); lt = np.log(st_[lo] + 1e-9)
timbre["log_spectral_dist_db"] = float(np.sqrt(np.mean((ls - lt) ** 2)) * (20 / np.log(10)))
cen = lambda s: float((fr[lo] * s[lo]).sum() / (s[lo].sum() + 1e-9))
timbre["centroid_ref_hz"] = cen(sr_); timbre["centroid_test_hz"] = cen(st_)
timbre["centroid_delta_hz"] = timbre["centroid_test_hz"] - timbre["centroid_ref_hz"]
pr = dominant_pitch_track(xa, rate); pt = dominant_pitch_track(xb, rate)
pitch = {}
if pr.size and pt.size:
ratio = np.median(pt) / np.median(pr)
pitch = {"median_ref_hz": float(np.median(pr)), "median_test_hz": float(np.median(pt)),
"tuning_error_cents": float(cents(ratio))}
quality = {
"ref": {"click_per_s": click_rate(xa, rate), "noise_floor_db": noise_floor_db(xa)},
"test": {"click_per_s": click_rate(xb, rate), "noise_floor_db": noise_floor_db(xb)},
}
result = {
"ref": os.path.basename(a.ref), "test": os.path.basename(a.test),
"rate": rate, "overlap_s": round(m / rate, 2),
"global_align": {"lag_ms": glag / rate * 1000.0, "peak_corr": gcorr},
"drift": drift, "onset": onset, "pitch": pitch, "timbre": timbre, "quality": quality,
}
# ----- human verdict -----
print(f"\n=== A/B audio diff: {result['test']} vs REF {result['ref']} ===")
print(f" overlap {result['overlap_s']}s @ {rate} Hz")
ga = result["global_align"]
print(f" global align : lag {ga['lag_ms']:+.1f} ms, peak corr {ga['peak_corr']:.3f} "
f"({'STRONG' if ga['peak_corr']>0.5 else 'WEAK - streams may differ structurally'})")
if drift:
print(f" drift : {drift['drift_ms_per_s']:+.2f} ms/s ({drift['drift_ppm']:+.0f} ppm), "
f"lag std {drift['lag_ms_std']:.1f} ms, local corr {drift['local_corr_median']:.2f}")
if onset:
if onset.get("matched"):
print(f" onsets : {onset['matched']}/{onset['test_onsets']} matched "
f"({onset['match_rate']*100:.0f}%), |err| median {onset['err_ms_abs_median']:.1f} ms, "
f"p90 {onset['err_ms_p90']:.1f} ms")
else:
print(f" onsets : 0 matched (ref {onset['ref_onsets']}, test {onset['test_onsets']})")
if pitch:
print(f" tuning : test {pitch['median_test_hz']:.1f} Hz vs ref "
f"{pitch['median_ref_hz']:.1f} Hz -> {pitch['tuning_error_cents']:+.0f} cents")
if timbre:
print(f" timbre : log-spectral dist {timbre['log_spectral_dist_db']:.1f} dB, "
f"centroid delta {timbre['centroid_delta_hz']:+.0f} Hz")
q = quality
print(f" quality : clicks ref {q['ref']['click_per_s']:.2f}/s test {q['test']['click_per_s']:.2f}/s; "
f"noise floor ref {q['ref']['noise_floor_db']:.0f} dB test {q['test']['noise_floor_db']:.0f} dB")
print()
if a.json:
with open(a.json, "w") as f:
json.dump(result, f, indent=2)
print(f" wrote {a.json}")
if __name__ == "__main__":
main()
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""audio_analyze.py - turn a recomp_audio_debug dump into a 'first dirty tap' verdict.
Reads a dump directory written by recomp_audio_debug_dump():
<tap>.wav (one per tap; names like t0_pulse1, t1_emu, t2_bridge_in,
t3_bridge_out, t4_callback)
events.csv (t_ms,type,metadata)
summary.txt
Runs the detectors from the round-2 plan and applies the cross-tap decision tree:
anomaly in T1 -> synthesis / upstream
T2 but not T1 -> per-system conversion / decimation
T3 but not T2 -> shared bridge / resampler
only T4 -> SDL callback fill / conversion / starvation
Usage:
python audio_analyze.py <dump_dir> [--block 735] [--click-k 6.0]
Only dependency is numpy.
"""
import os, sys, wave, struct, glob, argparse
import numpy as np
def load_wav(path):
with wave.open(path, "rb") as w:
ch = w.getnchannels()
rate = w.getframerate()
n = w.getnframes()
raw = w.readframes(n)
x = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
if ch > 1:
x = x.reshape(-1, ch)
mono = x.mean(axis=1)
else:
mono = x
return mono, rate, ch
def discontinuity(x, rate, k=6.0, rms_ms=10.0):
"""Return (click_indices, click_score array). A click = isolated |d1|/|d2|
spike that towers over the local RMS."""
if x.size < 4:
return np.array([], dtype=int), np.array([])
d1 = np.abs(np.diff(x, prepend=x[0]))
d2 = np.abs(np.diff(x, n=2, prepend=[x[0], x[0]]))
win = max(8, int(rate * rms_ms / 1000.0))
# rolling RMS via cumulative sum of squares
cs = np.cumsum(np.concatenate([[0.0], x * x]))
lo = np.maximum(0, np.arange(x.size) - win)
hi = np.minimum(x.size, np.arange(x.size) + win)
local_rms = np.sqrt((cs[hi] - cs[lo]) / np.maximum(1, hi - lo))
eps = 1e-4
score = np.maximum(d1, d2) / np.maximum(local_rms, eps)
idx = np.where(score > k)[0]
# keep only isolated spikes (gap >= 3 samples) to avoid counting one event many times
if idx.size:
keep = [idx[0]]
for i in idx[1:]:
if i - keep[-1] >= 3:
keep.append(i)
idx = np.array(keep, dtype=int)
return idx, score
def repeat_runs(x):
"""Longest run of identical consecutive samples, and count of runs >= 4."""
if x.size < 2:
return 0, 0
same = np.diff(x) == 0.0
longest = cur = 0
runs4 = 0
for s in same:
if s:
cur += 1
longest = max(longest, cur)
else:
if cur + 1 >= 4:
runs4 += 1
cur = 0
if cur + 1 >= 4:
runs4 += 1
return longest + (1 if longest else 0), runs4
def boundary_hist(click_idx, block):
"""Fraction of clicks within +/-2 samples of a block boundary."""
if click_idx.size == 0 or block <= 0:
return 0.0, None
mod = click_idx % block
near = np.sum((mod <= 2) | (mod >= block - 2))
return near / click_idx.size, mod
def fft_signature(x, rate):
"""Return dominant peaks and a crude aliasing/broadband read."""
if x.size < 2048:
return {}
n = 1 << int(np.floor(np.log2(min(x.size, 1 << 18))))
seg = x[:n] * np.hanning(n)
mag = np.abs(np.fft.rfft(seg))
freqs = np.fft.rfftfreq(n, 1.0 / rate)
mag_db = 20 * np.log10(np.maximum(mag, 1e-9))
nyq = rate / 2.0
# broadband floor = median of upper half; peak = max
upper = mag_db[freqs > nyq * 0.5]
floor_db = float(np.median(upper)) if upper.size else -120.0
peak_db = float(np.max(mag_db))
# top 5 peaks
order = np.argsort(mag_db)[::-1]
peaks = []
seen = []
for i in order:
f = freqs[i]
if any(abs(f - s) < rate / n * 8 for s in seen):
continue
seen.append(f)
peaks.append((round(float(f), 1), round(float(mag_db[i]) - peak_db, 1)))
if len(peaks) >= 5:
break
near_nyq = float(np.max(mag_db[freqs > nyq * 0.85]) - peak_db) if np.any(freqs > nyq * 0.85) else -120.0
return {"floor_dbfs_rel": round(floor_db - peak_db, 1),
"near_nyquist_rel": round(near_nyq, 1),
"peaks": peaks}
def canonical(tapname):
n = tapname.lower()
for t in ("t0", "t1", "t2", "t3", "t4"):
if n.startswith(t) or ("_" + t) in n:
return t
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dump_dir")
ap.add_argument("--block", type=int, default=735,
help="synthesis block size for boundary correlation (NES=735)")
ap.add_argument("--click-k", type=float, default=6.0)
args = ap.parse_args()
wavs = sorted(glob.glob(os.path.join(args.dump_dir, "*.wav")))
if not wavs:
print("no .wav stems in", args.dump_dir)
return 2
print("=" * 78)
print("recomp audio analysis:", args.dump_dir)
print("=" * 78)
per_tap = {}
for path in wavs:
name = os.path.splitext(os.path.basename(path))[0]
x, rate, ch = load_wav(path)
dur = x.size / rate if rate else 0
idx, score = discontinuity(x, rate, k=args.click_k)
cps = idx.size / dur if dur else 0.0
longest, runs4 = repeat_runs(x)
frac_b, _ = boundary_hist(idx, args.block)
sig = fft_signature(x, rate)
per_tap[name] = dict(rate=rate, ch=ch, dur=dur, clicks=idx.size, cps=cps,
longest_repeat=longest, runs4=runs4, frac_boundary=frac_b,
sig=sig, canon=canonical(name))
print(f"\n[{name}] rate={rate} ch={ch} dur={dur:.2f}s")
print(f" clicks={idx.size} ({cps:.2f}/s) longest_repeat={longest} "
f"repeat_runs>=4: {runs4}")
if idx.size:
print(f" clicks within +/-2 of {args.block}-sample boundary: {frac_b*100:.0f}%")
if sig:
print(f" fft: floor={sig['floor_dbfs_rel']}dB near-Nyquist={sig['near_nyquist_rel']}dB")
print(f" peaks(rel dB): {sig['peaks']}")
# ---- cross-tap decision tree ----
print("\n" + "=" * 78)
print("CROSS-TAP VERDICT")
print("=" * 78)
def dirty(canon):
# a canonical stage is "dirty" if any tap mapped to it has a meaningful click rate
worst = 0.0
for n, d in per_tap.items():
if d["canon"] == canon:
worst = max(worst, d["cps"])
return worst
THR = 0.5 # clicks/sec considered meaningful
t1, t2, t3, t4 = dirty("t1"), dirty("t2"), dirty("t3"), dirty("t4")
print(f" stage click rates (clicks/s): T1={t1:.2f} T2={t2:.2f} T3={t3:.2f} T4={t4:.2f}")
verdict = None
if t1 > THR:
verdict = ("T1", "synthesis / upstream — the emulated console/APU/HLE is "
"producing discontinuous PCM. Fix the generator, not the bridge.")
elif t2 > THR:
verdict = ("T2", "per-system conversion / decimation / staging between native "
"synthesis and the bridge input.")
elif t3 > THR:
verdict = ("T3", "shared bridge / resampler (ratio jumps, phase reset, "
"emergency fade, input starvation).")
elif t4 > THR:
verdict = ("T4", "SDL callback path (underfill, memset/zero fill, callback "
"starvation, format mismatch).")
if verdict:
print(f"\n >>> FIRST DIRTY TAP: {verdict[0]}\n {verdict[1]}")
else:
print("\n >>> No impulse-class clicks above threshold in any canonical tap.")
print(" If the user still hears static, it is NOT impulse crackle —")
print(" check aliasing (near-Nyquist energy), level/clipping, or tuning.")
# boundary hint
for n, d in per_tap.items():
if d["clicks"] and d["frac_boundary"] > 0.6:
print(f"\n NOTE: in [{n}], {d['frac_boundary']*100:.0f}% of clicks sit on a "
f"{args.block}-sample boundary -> block-generation discontinuity "
f"(fix continuous phase/time accumulator).")
return 0
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""audio_archive_ab.py - continuously archive both processes' always-on PCM
rings to disk with a screenshot timeline, so identical attract-demo tracks can
be paired offline. Ring holds ~95 s; dumping every ~75 s with absolute frame
indices yields a gapless per-side archive.
Usage: python audio_archive_ab.py <out_dir> <minutes>
"""
import json
import os
import socket
import struct
import sys
import time
SIDES = {"runtime": 4520, "beetle": 4382}
def call(port, p, timeout=30):
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
try:
s.sendall((json.dumps(p) + "\n").encode())
buf = b""
while True:
c = s.recv(1 << 16)
if not c:
break
buf += c
if buf.endswith(b"\n"):
break
return json.loads(buf.decode())
finally:
s.close()
def bmp_mean(path):
try:
with open(path, "rb") as f:
d = f.read()
off = struct.unpack("<I", d[10:14])[0]
px = d[off::37]
return sum(px) / max(1, len(px))
except Exception:
return -1
def main():
out = sys.argv[1]
minutes = float(sys.argv[2]) if len(sys.argv) > 2 else 8.0
if not (len(out) > 1 and out[1] == ":"):
raise SystemExit("out dir must be a Windows-style path (F:/...) — "
"the game processes fopen() these paths themselves")
os.makedirs(out, exist_ok=True)
log = open(os.path.join(out, "timeline.jsonl"), "w")
next_dump = {k: 0.0 for k in SIDES}
dump_idx = {k: 0 for k in SIDES}
last_total = {k: 0 for k in SIDES}
t_end = time.time() + minutes * 60
shot_i = 0
while time.time() < t_end:
now = time.time()
for side, port in SIDES.items():
try:
st = call(port, {"id": 1, "cmd": "audio_stats"})
tap0 = st["taps"][0]["frames"]
sp = os.path.join(out, f"{side}_{shot_i:04d}.bmp").replace("\\", "/")
call(port, {"id": 2, "cmd": "screenshot_file", "path": sp})
rec = {"t": now, "side": side, "shot": shot_i, "tap0": tap0,
"bright": round(bmp_mean(sp), 1)}
# dump the ring every ~75 s, from where the last dump ended
if now >= next_dump[side]:
start = last_total[side]
avail_oldest = max(0, tap0 - 4194304)
if start < avail_oldest:
start = avail_oldest
count = tap0 - start
if count > 0:
wp = os.path.join(
out, f"{side}_arch{dump_idx[side]:02d}_{start}.wav"
).replace("\\", "/")
r = call(port, {"id": 3, "cmd": "audio_wav", "tap": 0,
"path": wp, "start": str(start),
"count": str(count)}, timeout=60)
rec["dump"] = {"file": wp, "start": start,
"count": count, "ok": r.get("ok")}
last_total[side] = tap0
dump_idx[side] += 1
next_dump[side] = now + 75.0
log.write(json.dumps(rec) + "\n")
log.flush()
except Exception as e:
log.write(json.dumps({"t": now, "side": side, "err": str(e)}) + "\n")
log.flush()
shot_i += 1
time.sleep(10)
log.close()
print("archive complete")
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""audio_capture_ab.py - dump the always-on audio tap rings from psx-runtime
and psx-beetle (identical `audio_*` wire protocol, ports differ) and run the
drift-tolerant A/B comparison.
Both processes must already be sitting at the scene whose audio you want to
compare (drive them there first via `press` on their debug ports). The rings
are always-on: this tool dumps HISTORY (the last ~95 s of PCM already
captured), it never arms anything.
Usage:
python tools/audio_capture_ab.py --runtime-port 4520 --beetle-port 4380 \
--out F:/Projects/psxrecomp/_wt-audio-mmx5/_audio_captures/title \
[--secs 30] [--analyze]
Outputs in --out:
runtime_spu_out.wav T1: recomp SPU render output (pre host fade)
runtime_cd_in.wav T2: recomp XA/CD-DA PCM pushed to the SPU CD bus
runtime_host.wav T3: exact bytes handed to SDL_QueueAudio
beetle_spu_out.wav oracle: Beetle's fully-mixed reference SPU output
stats.json audio_stats from both sides
"""
import argparse
import json
import os
import socket
import subprocess
import sys
def call(port, payload, timeout=30):
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
try:
s.sendall((json.dumps(payload) + "\n").encode())
buf = b""
while True:
c = s.recv(1 << 16)
if not c:
break
buf += c
if buf.endswith(b"\n"):
break
return json.loads(buf.decode())
finally:
s.close()
def dump_tap(port, tap, path, secs):
"""Dump the trailing `secs` seconds of a tap ring to `path`."""
stats = call(port, {"id": 1, "cmd": "audio_stats"})
total = stats["taps"][tap]["frames"]
count = min(total, int(secs * 44100)) if secs > 0 else 0
start = total - count if count else -1
r = call(port, {"id": 2, "cmd": "audio_wav", "tap": tap,
"path": path.replace("\\", "/"),
"start": str(start), "count": str(count)})
return r, stats
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--runtime-port", type=int, default=4520)
ap.add_argument("--beetle-port", type=int, default=4380)
ap.add_argument("--out", required=True)
ap.add_argument("--secs", type=float, default=30.0,
help="trailing window to dump (0 = whole ring)")
ap.add_argument("--analyze", action="store_true",
help="run audio_ab_diff.py runtime_spu_out vs beetle_spu_out")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
out = lambda name: os.path.abspath(os.path.join(args.out, name))
results = {"runtime": {}, "beetle": {}}
for tap, name in ((0, "runtime_spu_out.wav"),
(1, "runtime_cd_in.wav"),
(2, "runtime_host.wav")):
try:
r, st = dump_tap(args.runtime_port, tap, out(name), args.secs)
results["runtime"][name] = r
results["runtime"]["stats"] = st
print(f"[runtime] tap {tap} -> {name}: {r}")
except Exception as e:
print(f"[runtime] tap {tap} FAILED: {e}")
try:
r, st = dump_tap(args.beetle_port, 0, out("beetle_spu_out.wav"), args.secs)
results["beetle"]["beetle_spu_out.wav"] = r
results["beetle"]["stats"] = st
print(f"[beetle] tap 0 -> beetle_spu_out.wav: {r}")
except Exception as e:
print(f"[beetle] tap 0 FAILED: {e}")
with open(out("stats.json"), "w") as f:
json.dump(results, f, indent=2)
print(f"stats -> {out('stats.json')}")
if args.analyze:
here = os.path.dirname(os.path.abspath(__file__))
cmd = [sys.executable, os.path.join(here, "audio_ab_diff.py"),
"--ref", out("beetle_spu_out.wav"),
"--test", out("runtime_spu_out.wav"),
"--rate", "44100",
"--json", out("ab_diff.json")]
print("running:", " ".join(cmd))
subprocess.run(cmd, check=False)
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""audio_pair_segments.py - find the SAME musical content in two archived
ring dumps (runtime vs beetle attract cycles are phase-shifted) and emit the
best-matching aligned window pair for A/B analysis.
Method: concatenate each side's numbered arch WAVs into one continuous
timeline (they were dumped gaplessly with absolute start indices in the
filename). Compute a coarse spectral fingerprint sequence (log-mel-ish band
energies per 0.5 s hop), normalized-cross-correlate runtime-vs-beetle
fingerprints at every offset, take the best offset, and cut the overlapping
region (minus silent edges) from both sides to <out>/pair_runtime.wav and
<out>/pair_beetle.wav.
Usage: python audio_pair_segments.py <archive_dir> [--min-secs 20]
Requires numpy.
"""
import argparse
import glob
import os
import re
import sys
import wave
import numpy as np
HOP_S = 0.5
BANDS = 24
def load_side(archive_dir, side):
files = []
for p in glob.glob(os.path.join(archive_dir, f"{side}_arch*_*.wav")):
m = re.search(rf"{side}_arch(\d+)_(\d+)\.wav$", p)
if m:
files.append((int(m.group(1)), int(m.group(2)), p))
files.sort()
chunks = []
rate = 44100
for _idx, _start, p in files:
w = wave.open(p)
rate = w.getframerate()
n = w.getnframes()
data = np.frombuffer(w.readframes(n), dtype=np.int16).reshape(-1, 2)
w.close()
chunks.append(data)
if not chunks:
raise SystemExit(f"no {side}_arch*.wav in {archive_dir}")
return np.concatenate(chunks), rate
def fingerprint(x, rate):
"""Band-energy sequence, one row per HOP_S, log-compressed, per-row norm."""
mono = x.astype(np.float32).mean(axis=1) / 32768.0
hop = int(rate * HOP_S)
nfft = 4096
rows = []
for i in range(0, len(mono) - nfft, hop):
seg = mono[i:i + nfft] * np.hanning(nfft)
mag = np.abs(np.fft.rfft(seg))
# log-spaced band edges 60 Hz .. 16 kHz
edges = np.geomspace(60, 16000, BANDS + 1) / (rate / 2) * (nfft // 2)
band = np.array([mag[int(a):max(int(a) + 1, int(b))].mean()
for a, b in zip(edges[:-1], edges[1:])])
rows.append(np.log1p(band * 1000.0))
fp = np.array(rows)
# per-row zero-mean so correlation keys on spectral SHAPE sequence
fp -= fp.mean(axis=1, keepdims=True)
n = np.linalg.norm(fp, axis=1, keepdims=True)
fp /= np.maximum(n, 1e-9)
return fp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("archive_dir")
ap.add_argument("--min-secs", type=float, default=20.0)
args = ap.parse_args()
rt, rate = load_side(args.archive_dir, "runtime")
bt, _ = load_side(args.archive_dir, "beetle")
print(f"runtime: {len(rt)/rate:.0f}s beetle: {len(bt)/rate:.0f}s")
frt = fingerprint(rt, rate)
fbt = fingerprint(bt, rate)
# slide beetle over runtime (both directions), score mean row-dot
best = (-1.0, 0)
min_rows = int(args.min_secs / HOP_S)
for off in range(-(len(fbt) - min_rows), len(frt) - min_rows):
a0, b0 = max(0, off), max(0, -off)
n = min(len(frt) - a0, len(fbt) - b0)
if n < min_rows:
continue
score = float(np.einsum("ij,ij->i", frt[a0:a0 + n], fbt[b0:b0 + n]).mean())
if score > best[0]:
best = (score, off)
score, off = best
a0, b0 = max(0, off), max(0, -off)
n = min(len(frt) - a0, len(fbt) - b0)
print(f"best offset {off * HOP_S:+.1f}s score {score:.3f} overlap {n * HOP_S:.0f}s")
# cut the overlapping window, trimming rows where either side is silent
energy_rt = np.abs(rt[:, 0].astype(np.int32)) > 256
energy_bt = np.abs(bt[:, 0].astype(np.int32)) > 256
s_rt = int(a0 * HOP_S * rate)
s_bt = int(b0 * HOP_S * rate)
length = int(n * HOP_S * rate)
cut_rt = rt[s_rt:s_rt + length]
cut_bt = bt[s_bt:s_bt + length]
def write(path, data):
w = wave.open(path, "wb")
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(data.astype(np.int16).tobytes())
w.close()
print("wrote", path, f"{len(data)/rate:.0f}s")
write(os.path.join(args.archive_dir, "pair_runtime.wav"), cut_rt)
write(os.path.join(args.archive_dir, "pair_beetle.wav"), cut_bt)
# rough level comparison on the matched content
def rms_db(x):
f = x.astype(np.float64) / 32768.0
act = f[np.abs(f[:, 0]) > 0.001]
if len(act) == 0:
return -120.0
return 20 * np.log10(np.sqrt((act ** 2).mean()))
print(f"matched-content RMS: runtime {rms_db(cut_rt):.1f} dBFS, "
f"beetle {rms_db(cut_bt):.1f} dBFS")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""audio_spectral_ab.py - deep TIMBRE/tone differential of recomp audio vs an
accuracy-grade reference. Where audio_ab_diff.py answers "same notes, same
timing?", this answers "same TONE?" - the spectral envelope / harmonic content
that onset & correlation metrics are blind to.
It is drift-tolerant by construction: it compares TIME-AVERAGED spectra over a
matched musical passage, so small timing drift between the streams does not
affect the result. That isolates synthesis/timbre error from timing error.
Metrics:
- third-octave band energy difference (dB), recomp - reference, 25 Hz..16 kHz
- spectral centroid / 85% rolloff / flatness for each stream
- overall log-spectral distance (dB RMS)
- PNG: overlaid average spectra + both spectrograms
Uses scipy (resample_poly, welch, spectrogram) + matplotlib. Usage:
python audio_spectral_ab.py --ref REF.wav --test TEST.wav
[--rate 32040] [--win-s 6] [--png out.png] [--json out.json]
"""
import sys, os, wave, argparse, json
import numpy as np
from scipy import signal
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def load_wav(path):
"""Robust to unfinalized (force-killed) headers: fall back to raw parse."""
try:
with wave.open(path, "rb") as w:
ch, rate, n = w.getnchannels(), w.getframerate(), w.getnframes()
raw = w.readframes(n)
if n > 0 and len(raw) >= n * ch * 2:
x = np.frombuffer(raw, "<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(1) if ch > 1 else x), rate
except Exception:
pass
b = open(path, "rb").read()
assert b[:4] == b"RIFF" and b[8:12] == b"WAVE", "not a WAV"
ch, rate, bits, off, doff, dlen = 2, 32040, 16, 12, None, 0
while off + 8 <= len(b):
cid = b[off:off + 4]; sz = int.from_bytes(b[off + 4:off + 8], "little"); body = off + 8
if cid == b"fmt ":
ch = int.from_bytes(b[body + 2:body + 4], "little")
rate = int.from_bytes(b[body + 4:body + 8], "little")
bits = int.from_bytes(b[body + 14:body + 16], "little")
elif cid == b"data":
doff = body; dlen = sz if 0 < sz <= len(b) - body else len(b) - body; break
off = body + sz + (sz & 1)
raw = b[doff:doff + dlen - dlen % (ch * bits // 8)]
x = np.frombuffer(raw, "<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(1) if ch > 1 else x), rate
def to_rate(x, src, dst):
if src == dst:
return x
from math import gcd
g = gcd(int(src), int(dst))
return signal.resample_poly(x, dst // g, src // g)
def active(x, thr=1e-4):
nz = np.where(np.abs(x) > thr)[0]
return (0, x.size) if nz.size == 0 else (int(nz[0]), int(nz[-1]) + 1)
def coarse_lag(a, b, rate, max_s=8):
ml = int(max_s * rate)
n = 1 << int(np.ceil(np.log2(a.size + b.size)))
cc = np.fft.irfft(np.fft.rfft(a - a.mean(), n) * np.conj(np.fft.rfft(b - b.mean(), n)), n)
cc = np.concatenate([cc[-ml:], cc[:ml + 1]])
return int(np.arange(-ml, ml + 1)[int(np.argmax(cc))])
def welch_psd(x, rate):
f, p = signal.welch(x, rate, nperseg=8192, noverlap=4096, scaling="spectrum")
return f, p
def third_octave(f, p, lo=25.0, hi=16000.0):
bands, centers = [], []
fc = lo
while fc <= hi:
f1 = fc / (2 ** (1 / 6)); f2 = fc * (2 ** (1 / 6))
m = (f >= f1) & (f < f2)
bands.append(p[m].sum() if m.any() else 0.0); centers.append(fc)
fc *= 2 ** (1 / 3)
return np.array(centers), np.array(bands)
def descriptors(f, p):
p = np.maximum(p, 1e-20); tot = p.sum()
centroid = float((f * p).sum() / tot)
cs = np.cumsum(p); rolloff = float(f[np.searchsorted(cs, 0.85 * tot)])
gm = np.exp(np.mean(np.log(p))); flatness = float(gm / (p.mean()))
return centroid, rolloff, flatness
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ref", required=True); ap.add_argument("--test", required=True)
ap.add_argument("--rate", type=int, default=32040)
ap.add_argument("--win-s", type=float, default=6.0)
ap.add_argument("--png", default=None); ap.add_argument("--json", default=None)
a = ap.parse_args()
rate = a.rate
xr, rr = load_wav(a.ref); xt, rt = load_wav(a.test)
xr = to_rate(xr, rr, rate); xt = to_rate(xt, rt, rate)
r0, r1 = active(xr); t0, t1 = active(xt); xr = xr[r0:r1]; xt = xt[t0:t1]
lag = coarse_lag(xr, xt, rate)
if lag >= 0:
xr = xr[lag:]
else:
xt = xt[-lag:]
n = min(xr.size, xt.size); xr = xr[:n]; xt = xt[:n]
# pick the loudest matched win-s window (most musical content) for the average
w = int(a.win_s * rate)
if n > w:
env = np.convolve(np.abs(xr) + np.abs(xt), np.ones(rate) / rate, "same")
c = int(np.argmax(env)); s = max(0, min(c - w // 2, n - w)); xr = xr[s:s + w]; xt = xt[s:s + w]
fr, pr = welch_psd(xr, rate); ft, pt = welch_psd(xt, rate)
cen_r, ce = third_octave(fr, pr); _, te = third_octave(ft, pt)
# normalize each to equal total energy in the analysis band (compare SHAPE,
# not absolute level - level differences are a separate, trivial fix)
ce_n = ce / (ce.sum() or 1.0); te_n = te / (te.sum() or 1.0)
db = 10 * np.log10(np.maximum(te_n, 1e-12) / np.maximum(ce_n, 1e-12))
dr = descriptors(fr, pr); dt = descriptors(ft, pt)
# overall log-spectral distance on normalized PSD shape
lo = (fr >= 50) & (fr <= 16000)
prn = pr / pr[lo].sum(); ptn = pt / pt[lo].sum()
lsd = float(np.sqrt(np.mean((10 * np.log10(ptn[lo] + 1e-12) - 10 * np.log10(prn[lo] + 1e-12)) ** 2)))
print(f"\n=== SPECTRAL / TONE diff: {os.path.basename(a.test)} vs REF {os.path.basename(a.ref)} ===")
print(f" window {a.win_s:.0f}s @ {rate} Hz (shape-normalized; level ignored)")
print(f" log-spectral distance : {lsd:.1f} dB RMS ({'CLOSE' if lsd<3 else 'AUDIBLE timbre diff' if lsd<6 else 'LARGE timbre diff'})")
print(f" centroid ref {dr[0]:6.0f} Hz test {dt[0]:6.0f} Hz ({dt[0]-dr[0]:+.0f})")
print(f" rolloff85 ref {dr[1]:6.0f} Hz test {dt[1]:6.0f} Hz ({dt[1]-dr[1]:+.0f})")
print(f" flatness ref {dr[2]:.4f} test {dt[2]:.4f}")
print(f" --- third-octave band energy (test - ref, dB; |delta|>3 dB flagged) ---")
for c, d in zip(cen_r, db):
flag = " <<<" if abs(d) > 3 else ""
bar = ("+" if d >= 0 else "-") * min(20, int(abs(d)))
print(f" {c:7.0f} Hz : {d:+5.1f} dB {bar}{flag}")
result = {"ref": os.path.basename(a.ref), "test": os.path.basename(a.test),
"lsd_db": lsd,
"centroid": {"ref": dr[0], "test": dt[0]},
"rolloff85": {"ref": dr[1], "test": dt[1]},
"flatness": {"ref": dr[2], "test": dt[2]},
"third_octave_db": {float(c): float(d) for c, d in zip(cen_r, db)}}
if a.json:
json.dump(result, open(a.json, "w"), indent=2); print(f"\n wrote {a.json}")
png = a.png or (os.path.splitext(a.test)[0] + "_spectral.png")
fig, ax = plt.subplots(3, 1, figsize=(10, 11))
ax[0].semilogx(fr, 10 * np.log10(prn + 1e-12), label="ref (bsnes)", lw=1)
ax[0].semilogx(ft, 10 * np.log10(ptn + 1e-12), label="test (recomp)", lw=1, alpha=0.8)
ax[0].set_xlim(40, 16000); ax[0].set_xlabel("Hz"); ax[0].set_ylabel("dB (norm)")
ax[0].set_title(f"avg spectrum (LSD {lsd:.1f} dB)"); ax[0].legend(); ax[0].grid(True, which="both", alpha=0.3)
for k, (x, t) in enumerate([(xr, "ref (bsnes)"), (xt, "test (recomp)")]):
f2, tt, Sxx = signal.spectrogram(x, rate, nperseg=2048, noverlap=1024)
ax[k + 1].pcolormesh(tt, f2, 10 * np.log10(Sxx + 1e-12), shading="gouraud", cmap="magma")
ax[k + 1].set_ylim(0, 16000); ax[k + 1].set_ylabel("Hz"); ax[k + 1].set_title(t)
fig.tight_layout(); fig.savefig(png, dpi=90); print(f" wrote {png}")
if __name__ == "__main__":
main()