302 lines
12 KiB
C
302 lines
12 KiB
C
// sfxMixHost.c - Host-cc differential harness for the shared SFX
|
|
// overlay mixer (src/core/audioSfxMix.c). Built by
|
|
// scripts/check-sfxmix.sh on the millisHost.c / check-millis.sh
|
|
// pattern. The mixer TU has no jlp* platform hooks; its only external
|
|
// input is the stream fill callback, mocked below (streamFill) the way
|
|
// millisHost.c mocks jlpFrameCount/jlpFrameHz.
|
|
//
|
|
// Every scenario is fully deterministic: all sample data, background
|
|
// (dst) data, and stream chunks come from a fixed-seed LCG -- no time,
|
|
// no rand(), no host entropy. Each scenario prints:
|
|
//
|
|
// name=<scenario> slots=<activeSlots> len=<bytes> hash=0x<fnv1a>
|
|
// first=<hex of the first bytes, for debugging>
|
|
// raw=<hex of the whole mixed output>
|
|
//
|
|
// check-sfxmix.sh captures the first run as
|
|
// tests/goldens/sfxmix-baseline.txt and later diffs against it:
|
|
// * exact mode (default): hashes must match -- the gate for
|
|
// PERF-AUDIT findings #7 and #33 (bit-identical rewrites).
|
|
// * JL_SFX_TOLERANCE=1 mode: per-sample compare of the raw lines;
|
|
// finding #6 (interp 16x16 multiply) may deviate by at most 1 LSB
|
|
// PER SLOT CONTRIBUTION, so the allowed per-sample delta is
|
|
// tolerance * slots (deviations from simultaneous slots stack
|
|
// before the clamp). That is why slots= is part of the output.
|
|
//
|
|
// The mixer has no per-slot volume field (volume is applied upstream
|
|
// when the sample data is produced), so the "volume 0 / volume max"
|
|
// scenarios use amplitude-scaled sample data: amp 0 = silent samples
|
|
// (output must equal the untouched background), amp 127 = full-scale
|
|
// samples on a full-range background (exercises both clamp branches).
|
|
|
|
#include <stdint.h>
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "audioSfxMixInternal.h"
|
|
|
|
// Output samples per single mix call.
|
|
#define MIX_LEN 1024u
|
|
// Debug prefix bytes echoed on the first= line.
|
|
#define FIRST_BYTES 16u
|
|
// Mixer output rate used by most scenarios (DOS SB-ish rate).
|
|
#define OUT_RATE 11025u
|
|
// Largest fixed-buffer sample used by the single-mix scenarios.
|
|
#define SAMPLE_MAX 4096u
|
|
// fixedLongPos64k: enough sequential mix blocks at ~8.19 step to push
|
|
// the read index past 65535 input samples (the uint64 posFp headroom
|
|
// that the finding #7 rewrite must preserve): 8 * 1024 * 8.19 ~ 67k.
|
|
#define LONG_BLOCKS 8u
|
|
#define LONG_SAMPLE_LEN 68000u
|
|
#define LONG_RATE_HZ 65535u
|
|
#define LONG_OUT_RATE 8000u
|
|
|
|
// Mock stream source: hands out LCG-generated signed samples in
|
|
// chunks of at most chunkCap until remaining is exhausted, then
|
|
// returns 0 (end-of-stream).
|
|
typedef struct {
|
|
uint32_t lcgState;
|
|
uint32_t remaining;
|
|
uint32_t chunkCap;
|
|
int32_t amp;
|
|
} StreamCtxT;
|
|
|
|
// Prototypes (harness-local, alphabetical).
|
|
static void dstFill(uint8_t *dst, uint32_t len, uint32_t seed);
|
|
static uint32_t fnv1a(const uint8_t *data, uint32_t len);
|
|
static void genSignedSamples(uint32_t *state, uint8_t *dst, uint32_t count, int32_t amp);
|
|
static uint32_t lcgNext(uint32_t *state);
|
|
static void reportScenario(const char *name, uint8_t slots, const uint8_t *dst, uint32_t len);
|
|
static void runFixedScenario(const char *name, uint16_t rateHz, uint32_t outputRate, int32_t amp, uint32_t sampleCount, uint16_t mixLen, uint32_t dstSeed, uint32_t sampleSeed);
|
|
static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count);
|
|
|
|
static uint8_t gDst[MIX_LEN];
|
|
static uint8_t gLongOut[LONG_BLOCKS * MIX_LEN];
|
|
static uint8_t gLongSample[LONG_SAMPLE_LEN];
|
|
static uint8_t gSampleA[SAMPLE_MAX];
|
|
static uint8_t gSampleB[SAMPLE_MAX];
|
|
static uint8_t gSampleC[SAMPLE_MAX];
|
|
|
|
|
|
// Fill a mix destination with full-range unsigned LCG bytes -- a
|
|
// deterministic stand-in for already-rendered MOD output. Full range
|
|
// (not centered) so loud SFX hit both clamp branches.
|
|
static void dstFill(uint8_t *dst, uint32_t len, uint32_t seed) {
|
|
uint32_t state;
|
|
uint32_t i;
|
|
|
|
state = seed;
|
|
for (i = 0; i < len; i++) {
|
|
dst[i] = (uint8_t)(lcgNext(&state) >> 24);
|
|
}
|
|
}
|
|
|
|
|
|
// FNV-1a 32-bit.
|
|
static uint32_t fnv1a(const uint8_t *data, uint32_t len) {
|
|
uint32_t hash;
|
|
uint32_t i;
|
|
|
|
hash = 2166136261u;
|
|
for (i = 0; i < len; i++) {
|
|
hash ^= (uint32_t)data[i];
|
|
hash *= 16777619u;
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
|
|
// Fill dst with signed 8-bit samples (stored as their uint8_t bit
|
|
// pattern, matching AudioSfxSlotT.sample) scaled to amplitude `amp`
|
|
// (0 = silence, 127 = full scale). Truncation toward zero is
|
|
// C99-defined, so the scaling is portable and deterministic.
|
|
static void genSignedSamples(uint32_t *state, uint8_t *dst, uint32_t count, int32_t amp) {
|
|
uint32_t i;
|
|
int32_t raw;
|
|
int32_t scaled;
|
|
|
|
for (i = 0; i < count; i++) {
|
|
raw = (int32_t)(lcgNext(state) >> 24) - 128;
|
|
scaled = (raw * amp) / 127;
|
|
dst[i] = (uint8_t)(int8_t)scaled;
|
|
}
|
|
}
|
|
|
|
|
|
// Numerical Recipes LCG; only the high byte is consumed (the low bits
|
|
// of an LCG are weak).
|
|
static uint32_t lcgNext(uint32_t *state) {
|
|
*state = (*state * 1664525u) + 1013904223u;
|
|
return *state;
|
|
}
|
|
|
|
|
|
// Emit the three per-scenario output lines (see file header).
|
|
static void reportScenario(const char *name, uint8_t slots, const uint8_t *dst, uint32_t len) {
|
|
uint32_t i;
|
|
uint32_t show;
|
|
|
|
printf("name=%s slots=%u len=%lu hash=0x%08lX\n", name, (unsigned)slots, (unsigned long)len, (unsigned long)fnv1a(dst, len));
|
|
show = (len < FIRST_BYTES) ? len : FIRST_BYTES;
|
|
printf("first=");
|
|
for (i = 0; i < show; i++) {
|
|
if (i > 0) {
|
|
putchar(' ');
|
|
}
|
|
printf("%02X", dst[i]);
|
|
}
|
|
printf("\n");
|
|
printf("raw=");
|
|
for (i = 0; i < len; i++) {
|
|
printf("%02X", dst[i]);
|
|
}
|
|
printf("\n");
|
|
}
|
|
|
|
|
|
// One fixed-buffer slot mixed once into a fresh background. mixLen
|
|
// may exceed MIX_LEN up to sizeof(gLongOut).
|
|
static void runFixedScenario(const char *name, uint16_t rateHz, uint32_t outputRate, int32_t amp, uint32_t sampleCount, uint16_t mixLen, uint32_t dstSeed, uint32_t sampleSeed) {
|
|
AudioSfxSlotT slot;
|
|
uint32_t state;
|
|
|
|
state = sampleSeed;
|
|
genSignedSamples(&state, gSampleA, sampleCount, amp);
|
|
dstFill(gLongOut, mixLen, dstSeed);
|
|
memset(&slot, 0, sizeof(slot));
|
|
audioSfxSlotArm(&slot, gSampleA, sampleCount, rateHz, outputRate);
|
|
audioSfxOverlayMix(gLongOut, mixLen, &slot, 1u);
|
|
reportScenario(name, 1u, gLongOut, mixLen);
|
|
}
|
|
|
|
|
|
// Mock jlAudioStreamFillT (see StreamCtxT).
|
|
static uint32_t streamFill(void *ctx, int8_t *dst, uint32_t count) {
|
|
StreamCtxT *sc;
|
|
uint32_t give;
|
|
|
|
sc = (StreamCtxT *)ctx;
|
|
give = count;
|
|
if (give > sc->chunkCap) {
|
|
give = sc->chunkCap;
|
|
}
|
|
if (give > sc->remaining) {
|
|
give = sc->remaining;
|
|
}
|
|
genSignedSamples(&sc->lcgState, (uint8_t *)dst, give, sc->amp);
|
|
sc->remaining -= give;
|
|
return give;
|
|
}
|
|
|
|
|
|
int main(void) {
|
|
AudioSfxSlotT slot;
|
|
AudioSfxSlotT slots[3];
|
|
StreamCtxT ctx;
|
|
uint32_t state;
|
|
uint32_t block;
|
|
|
|
printf("sfxMixHost: audioSfxOverlayMix scenarios (LCG-seeded, deterministic)\n");
|
|
|
|
// --- Fixed-buffer slots, one slot per mix. ---
|
|
// Rate conversion up: 8363 Hz source into an 11025 Hz mix
|
|
// (step ~0.7585, every fractional interp path exercised).
|
|
runFixedScenario("fixedUp8363to11025", 8363u, OUT_RATE, 100, 2048u, (uint16_t)MIX_LEN, 0xD57B0001u, 0x5EED0001u);
|
|
// Rate conversion down: 22050 -> 11025 (step 2.0, input skipping).
|
|
runFixedScenario("fixedDown22050to11025", 22050u, OUT_RATE, 100, 2200u, (uint16_t)MIX_LEN, 0xD57B0002u, 0x5EED0002u);
|
|
// Unity rate: step exactly 1.0, frac stays 0 (interp identity).
|
|
runFixedScenario("fixedUnity11025", 11025u, OUT_RATE, 80, 1100u, (uint16_t)MIX_LEN, 0xD57B0003u, 0x5EED0003u);
|
|
// Odd stepFp ((5000 << 16) / 11025 = 29721): frac takes odd
|
|
// values, the case where the finding #6 interp reformulation
|
|
// deviates. Single slot, so tolerance mode checks the strict
|
|
// 1-LSB-per-sample bound here (the even-step scenarios above are
|
|
// insensitive to the frac low bit and must stay bit-identical).
|
|
// The deviation only fires when the dropped d * 1 crosses a 65536
|
|
// boundary (~|delta| / 65536 odds per odd-frac sample), so this
|
|
// one mixes 8x longer at full amplitude to make hits certain.
|
|
runFixedScenario("fixedOddStep5000", 5000u, OUT_RATE, 127, 4096u, (uint16_t)(LONG_BLOCKS * MIX_LEN), 0xD57B000Bu, 0x5EED000Cu);
|
|
// "Volume" 0: silent samples; output must equal the background.
|
|
runFixedScenario("fixedAmpZero", 8363u, OUT_RATE, 0, 2048u, (uint16_t)MIX_LEN, 0xD57B0004u, 0x5EED0004u);
|
|
// "Volume" max: full-scale samples on a full-range background --
|
|
// both clamp branches (+127 / -128) fire.
|
|
runFixedScenario("fixedAmpMax", 8363u, OUT_RATE, 127, 2048u, (uint16_t)MIX_LEN, 0xD57B0005u, 0x5EED0005u);
|
|
// Short sample: slot runs out and deactivates mid-mix (~output
|
|
// 395 of 1024); also hits the s1 == s0 tail at idx + 1 == length.
|
|
runFixedScenario("fixedShortEnd", 8363u, OUT_RATE, 127, 300u, (uint16_t)MIX_LEN, 0xD57B0006u, 0x5EED0006u);
|
|
|
|
// --- Multiple simultaneous fixed slots. ---
|
|
// Three rates (down / unity / up), three amplitudes; slot B is
|
|
// short and deactivates mid-mix while A and C keep going.
|
|
state = 0x5EED0007u;
|
|
genSignedSamples(&state, gSampleA, 2048u, 60);
|
|
state = 0x5EED0008u;
|
|
genSignedSamples(&state, gSampleB, 900u, 90);
|
|
state = 0x5EED0009u;
|
|
genSignedSamples(&state, gSampleC, 2048u, 127);
|
|
memset(slots, 0, sizeof(slots));
|
|
audioSfxSlotArm(&slots[0], gSampleA, 2048u, 5000u, OUT_RATE);
|
|
audioSfxSlotArm(&slots[1], gSampleB, 900u, 11025u, OUT_RATE);
|
|
audioSfxSlotArm(&slots[2], gSampleC, 2048u, 18000u, OUT_RATE);
|
|
dstFill(gDst, MIX_LEN, 0xD57B0007u);
|
|
audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, slots, 3u);
|
|
reportScenario("multiSlot3", 3u, gDst, MIX_LEN);
|
|
|
|
// --- Streaming slots. ---
|
|
// Rate-up stream whose 200-sample chunks force several
|
|
// streamRefill calls inside a single mix (prime + ~3 mid-mix).
|
|
memset(&slot, 0, sizeof(slot));
|
|
ctx.lcgState = 0x5712EA41u;
|
|
ctx.remaining = 4000u;
|
|
ctx.chunkCap = 200u;
|
|
ctx.amp = 100;
|
|
audioSfxSlotArmStream(&slot, streamFill, &ctx, 8363u, OUT_RATE);
|
|
dstFill(gDst, MIX_LEN, 0xD57B0008u);
|
|
audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, &slot, 1u);
|
|
reportScenario("streamRefillMid", 1u, gDst, MIX_LEN);
|
|
|
|
// Rate-down stream (step 2.0) that ends mid-mix: 500 source
|
|
// samples cover only ~250 outputs, then fill returns 0 and the
|
|
// slot deactivates with the background left untouched after it.
|
|
memset(&slot, 0, sizeof(slot));
|
|
ctx.lcgState = 0x5712EA42u;
|
|
ctx.remaining = 500u;
|
|
ctx.chunkCap = 255u;
|
|
ctx.amp = 110;
|
|
audioSfxSlotArmStream(&slot, streamFill, &ctx, 22050u, OUT_RATE);
|
|
dstFill(gDst, MIX_LEN, 0xD57B0009u);
|
|
audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, &slot, 1u);
|
|
reportScenario("streamEndMid", 1u, gDst, MIX_LEN);
|
|
|
|
// Fixed + streaming slot in the same mix call (the isStream
|
|
// branch takes both arms across one dst).
|
|
state = 0x5EED000Au;
|
|
genSignedSamples(&state, gSampleA, 2048u, 70);
|
|
memset(slots, 0, sizeof(slots));
|
|
audioSfxSlotArm(&slots[0], gSampleA, 2048u, 6000u, OUT_RATE);
|
|
ctx.lcgState = 0x5712EA43u;
|
|
ctx.remaining = 3000u;
|
|
ctx.chunkCap = 180u;
|
|
ctx.amp = 95;
|
|
audioSfxSlotArmStream(&slots[1], streamFill, &ctx, 16000u, OUT_RATE);
|
|
dstFill(gDst, MIX_LEN, 0xD57B000Au);
|
|
audioSfxOverlayMix(gDst, (uint16_t)MIX_LEN, slots, 2u);
|
|
reportScenario("mixedFixedStream", 2u, gDst, MIX_LEN);
|
|
|
|
// --- Long-position fixed slot (finding #7 headroom). ---
|
|
// Sequential mix blocks walk the read index past 65535 input
|
|
// samples; a posFp rewrite that truncates the index (or drops the
|
|
// frac carry) diverges here. Output is the concatenation of all
|
|
// blocks so every block is part of the hash and raw compare.
|
|
state = 0x5EED000Bu;
|
|
genSignedSamples(&state, gLongSample, LONG_SAMPLE_LEN, 90);
|
|
memset(&slot, 0, sizeof(slot));
|
|
audioSfxSlotArm(&slot, gLongSample, LONG_SAMPLE_LEN, LONG_RATE_HZ, LONG_OUT_RATE);
|
|
for (block = 0; block < LONG_BLOCKS; block++) {
|
|
dstFill(gLongOut + (block * MIX_LEN), MIX_LEN, 0xD57B0100u + block);
|
|
audioSfxOverlayMix(gLongOut + (block * MIX_LEN), (uint16_t)MIX_LEN, &slot, 1u);
|
|
}
|
|
reportScenario("fixedLongPos64k", 1u, gLongOut, LONG_BLOCKS * MIX_LEN);
|
|
|
|
return 0;
|
|
}
|