diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 31a359f3..c3b013bc 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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} ) diff --git a/runtime/include/audio_trace.h b/runtime/include/audio_trace.h new file mode 100644 index 00000000..61540c7a --- /dev/null +++ b/runtime/include/audio_trace.h @@ -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 + +#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 */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index a20cc01a..9f82b584 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -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 diff --git a/runtime/src/audio_trace.c b/runtime/src/audio_trace.c new file mode 100644 index 00000000..9008c094 --- /dev/null +++ b/runtime/src/audio_trace.c @@ -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 +#include +#include + +/* 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; +} diff --git a/runtime/src/beetle_debug_server.c b/runtime/src/beetle_debug_server.c index 39796c8d..b5d98fca 100644 --- a/runtime/src/beetle_debug_server.c +++ b/runtime/src/beetle_debug_server.c @@ -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 }, diff --git a/runtime/src/beetle_libretro.cpp b/runtime/src/beetle_libretro.cpp index 1d68b418..c239e702 100644 --- a/runtime/src/beetle_libretro.cpp +++ b/runtime/src/beetle_libretro.cpp @@ -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, diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index 16ca345f..ee062a96 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -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; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index b41540d4..e6468b11 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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 }, diff --git a/runtime/src/dma.c b/runtime/src/dma.c index 5cef7616..a923c3ce 100644 --- a/runtime/src/dma.c +++ b/runtime/src/dma.c @@ -20,6 +20,7 @@ #include "mdec.h" #include "overlay_capture.h" #include "spu.h" +#include "audio_trace.h" #include "event_ring.h" #include #include @@ -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); diff --git a/runtime/src/frame_pacing.c b/runtime/src/frame_pacing.c index 95f9c2e8..7af5bab8 100644 --- a/runtime/src/frame_pacing.c +++ b/runtime/src/frame_pacing.c @@ -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 */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cb26adb0..74b708af 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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(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(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); } } diff --git a/runtime/src/recomp_audio_drc.h b/runtime/src/recomp_audio_drc.h new file mode 100644 index 00000000..ec758ba3 --- /dev/null +++ b/runtime/src/recomp_audio_drc.h @@ -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 +#include + +#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 +#include +#include + +#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 */ diff --git a/runtime/src/spu.c b/runtime/src/spu.c index 3f4a2386..711048b3 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -9,6 +9,7 @@ #include "spu.h" #include "spu_shadow.h" +#include "audio_trace.h" #include @@ -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); diff --git a/tools/audio_ab_diff.py b/tools/audio_ab_diff.py new file mode 100644 index 00000000..9b1a63a5 --- /dev/null +++ b/tools/audio_ab_diff.py @@ -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 (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=" 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=" 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() diff --git a/tools/audio_analyze.py b/tools/audio_analyze.py new file mode 100644 index 00000000..10258158 --- /dev/null +++ b/tools/audio_analyze.py @@ -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(): + .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 [--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=" 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()) diff --git a/tools/audio_archive_ab.py b/tools/audio_archive_ab.py new file mode 100644 index 00000000..9ae027a8 --- /dev/null +++ b/tools/audio_archive_ab.py @@ -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 +""" +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(" 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() diff --git a/tools/audio_capture_ab.py b/tools/audio_capture_ab.py new file mode 100644 index 00000000..c5e6f3c2 --- /dev/null +++ b/tools/audio_capture_ab.py @@ -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() diff --git a/tools/audio_pair_segments.py b/tools/audio_pair_segments.py new file mode 100644 index 00000000..5bfcd107 --- /dev/null +++ b/tools/audio_pair_segments.py @@ -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 /pair_runtime.wav and +/pair_beetle.wav. + +Usage: python audio_pair_segments.py [--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() diff --git a/tools/audio_spectral_ab.py b/tools/audio_spectral_ab.py new file mode 100644 index 00000000..b4b1cf11 --- /dev/null +++ b/tools/audio_spectral_ab.py @@ -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, " 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, " 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()