Add audio-fidelity validator + fix mirror render under-run
Build coop_audio_validate, a tool that turns "the mirror audio sounds off" into numbers. It plays a known sine (coop_tone, 44.1 kHz on a 48 kHz endpoint -- the Godot/Brotato case), injects the hook as the host does, and runs a fidelity analyzer (coop/tone_analysis.hpp: pitch error in cents, SNR/THD, click + dropout counts), dumping a .wav to listen to. Modes: --render drives the real AudioMirror and measures its rendered output; --baseline/--selfcheck give the measurement floor; --listen <pid> records a live coop_host's output; --wav analyzes a recording. Analyzer + WAV I/O are unit-tested (tone_analysis_test) against synthesized defects. Using it, the capture ring measures pristine (~68 dB, 0 gaps) while the render path dropped to ~18 dB with gaps -- localizing a real defect in AudioMirror::run_hooked: it re-primed (withheld the feed until ~30 ms had rebuffered) on any partial fill (to_write < avail). A partial fill is normal producer jitter, and withholding the feed drains the device, so a one-frame ring dip became a full ~30 ms drop-out; on a jittery game it fired constantly. Fix: feed whatever is available each tick and re-prime only on a genuine starvation (device empty AND ring empty). The policy is factored into a pure RenderPacer reused by run_hooked + run_loopback and proven by render_pacer_test (the old policy withholds available data ~168x and drains to one period from silence on a jittery schedule; the new one never withholds). ctest 17/17. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
159
tests/tone_analysis_test.cpp
Normal file
159
tests/tone_analysis_test.cpp
Normal file
@@ -0,0 +1,159 @@
|
||||
// Unit test for the audio fidelity analyzer (common/include/coop/tone_analysis.hpp).
|
||||
//
|
||||
// Synthesizes controlled signals -- a clean sine, a sine analyzed at the wrong rate
|
||||
// (the pitch-shift bug), a sine with injected clicks, and a sine with a silence gap --
|
||||
// and asserts the analyzer's numbers match what was injected. This makes the metrics
|
||||
// trustworthy before they're used to diagnose the real mirror path. Also round-trips a
|
||||
// buffer through the WAV writer/reader + the PCM channel decoder. No audio device.
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#include "coop/tone_analysis.hpp"
|
||||
#include "coop/wav.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
namespace
|
||||
{
|
||||
int g_failures = 0;
|
||||
void check(bool ok, const char* what)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
std::printf(" ok: %s\n", what);
|
||||
}
|
||||
else
|
||||
{
|
||||
std::printf("FAIL: %s\n", what);
|
||||
++g_failures;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr double kTwoPi = 6.283185307179586;
|
||||
|
||||
// A clean sine of `freq` Hz at `rate`, `seconds` long, amplitude 0.25 (matches coop_tone).
|
||||
std::vector<float> make_sine(double freq, unsigned rate, double seconds, double amp = 0.25)
|
||||
{
|
||||
const std::size_t n = static_cast<std::size_t>(rate * seconds);
|
||||
std::vector<float> v(n);
|
||||
const double step = kTwoPi * freq / rate;
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
{
|
||||
v[i] = static_cast<float>(std::sin(step * i) * amp);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
// --- Clean 1 kHz tone at 48 kHz: ~0 cents, high SNR, no clicks/dropouts ----------
|
||||
{
|
||||
auto sine = make_sine(1000.0, 48000, 2.0);
|
||||
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
|
||||
check(r.valid, "clean: valid");
|
||||
check(std::fabs(r.pitch_error_cents) < 5.0, "clean: pitch error < 5 cents");
|
||||
check(std::fabs(r.dominant_hz - 1000.0) < 2.0, "clean: dominant ~1000 Hz");
|
||||
check(r.snr_db > 50.0, "clean: SNR > 50 dB");
|
||||
check(r.thd_percent < 1.0, "clean: THD < 1%");
|
||||
check(r.glitch_count == 0, "clean: no clicks");
|
||||
check(r.dropout_count == 0, "clean: no dropouts");
|
||||
check(std::fabs(r.peak - 0.25) < 0.01, "clean: peak ~0.25");
|
||||
std::printf(" (clean: %.3f Hz, %.2f cents, SNR %.1f dB, THD %.3f%%)\n", r.dominant_hz,
|
||||
r.pitch_error_cents, r.snr_db, r.thd_percent);
|
||||
}
|
||||
|
||||
// --- Pitch-shift bug: real 44100 samples played as if 48000 -----------------------
|
||||
// The hook captures true 44.1 kHz samples but mis-declares 48 kHz; the host renders
|
||||
// them at 48 kHz, shifting a 1000 Hz tone up to 1000*48000/44100 ~= 1088.4 Hz. Expected
|
||||
// cents = 1200*log2(48000/44100) ~= +146.7. The analyzer must recover that.
|
||||
{
|
||||
auto sine = make_sine(1000.0, 44100, 2.0); // generated at the *true* rate
|
||||
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0); // analyzed at the wrong rate
|
||||
const double expect_cents = 1200.0 * std::log2(48000.0 / 44100.0);
|
||||
check(std::fabs(r.pitch_error_cents - expect_cents) < 5.0, "pitch-shift: ~+147 cents detected");
|
||||
check(r.pitch_error_ratio > 1.05, "pitch-shift: ratio > 1.05 (audibly sharp)");
|
||||
check(std::fabs(r.dominant_hz - 1088.4) < 3.0, "pitch-shift: dominant ~1088 Hz");
|
||||
std::printf(" (pitch-shift: %.2f cents vs expected %.2f, dominant %.2f Hz)\n",
|
||||
r.pitch_error_cents, expect_cents, r.dominant_hz);
|
||||
}
|
||||
|
||||
// --- Click injection: discontinuities the analyzer must count ---------------------
|
||||
{
|
||||
auto sine = make_sine(1000.0, 48000, 2.0);
|
||||
const unsigned injected = 9;
|
||||
for (unsigned k = 0; k < injected; ++k)
|
||||
{
|
||||
const std::size_t at = sine.size() * (k + 1) / (injected + 2);
|
||||
sine[at] += 0.7f; // a sharp isolated jump (a click)
|
||||
}
|
||||
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
|
||||
check(r.glitch_count >= injected - 1 && r.glitch_count <= injected + 1,
|
||||
"clicks: counted ~9 discontinuities");
|
||||
check(r.dropout_count == 0, "clicks: no false dropouts");
|
||||
std::printf(" (clicks: injected %u, detected %u, rate %.2f/s)\n", injected, r.glitch_count,
|
||||
r.glitch_rate_per_sec);
|
||||
}
|
||||
|
||||
// --- Dropout injection: a mid-signal silence gap (the re-prime artifact) ----------
|
||||
{
|
||||
auto sine = make_sine(1000.0, 48000, 2.0);
|
||||
// Two ~20 ms gaps of silence.
|
||||
for (int g = 0; g < 2; ++g)
|
||||
{
|
||||
const std::size_t at = sine.size() * (g + 1) / 3;
|
||||
for (std::size_t i = 0; i < 48000u * 20 / 1000; ++i)
|
||||
{
|
||||
sine[at + i] = 0.0f;
|
||||
}
|
||||
}
|
||||
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
|
||||
check(r.dropout_count >= 2, "dropouts: counted >= 2 gaps");
|
||||
check(r.dropout_ms > 30.0, "dropouts: total > 30 ms");
|
||||
std::printf(" (dropouts: %u gaps, %.1f ms total)\n", r.dropout_count, r.dropout_ms);
|
||||
}
|
||||
|
||||
// --- Non-tone path: expected_hz = 0 skips pitch but still levels/clicks ------------
|
||||
{
|
||||
auto sine = make_sine(440.0, 48000, 0.5);
|
||||
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 0.0);
|
||||
check(r.valid && r.dominant_hz == 0.0, "no-expected: pitch skipped");
|
||||
check(r.rms > 0.1, "no-expected: RMS still measured");
|
||||
}
|
||||
|
||||
// --- WAV round-trip + int16 channel decode ----------------------------------------
|
||||
{
|
||||
// Build a 2-channel int16 buffer: channel 0 a 1 kHz sine, channel 1 silent.
|
||||
const unsigned rate = 48000, ch = 2;
|
||||
auto mono = make_sine(1000.0, rate, 0.5, 0.5);
|
||||
std::vector<std::int16_t> inter(mono.size() * ch, 0);
|
||||
for (std::size_t i = 0; i < mono.size(); ++i)
|
||||
{
|
||||
inter[i * ch + 0] = static_cast<std::int16_t>(mono[i] * 32767.0f);
|
||||
}
|
||||
const std::wstring path = L"tone_analysis_test_roundtrip.wav";
|
||||
const bool wrote = wav_write(path, inter.data(), inter.size() * sizeof(std::int16_t), rate, ch, 16,
|
||||
kToneFormatPcm);
|
||||
check(wrote, "wav: write ok");
|
||||
WavData wd;
|
||||
const bool readback = wav_read(path, wd);
|
||||
check(readback, "wav: read ok");
|
||||
check(wd.sample_rate == rate && wd.channels == ch && wd.bits == 16 && wd.format_tag == kToneFormatPcm,
|
||||
"wav: format round-trips");
|
||||
auto dec = decode_channel(wd.pcm.data(), wd.pcm.size(), wd.format_tag, wd.bits, wd.channels, 0);
|
||||
check(dec.size() == mono.size(), "decode: frame count matches");
|
||||
const ToneReport r = analyze_tone(dec.data(), dec.size(), rate, 1000.0);
|
||||
check(std::fabs(r.pitch_error_cents) < 5.0, "decode: channel 0 recovers 1 kHz");
|
||||
std::remove("tone_analysis_test_roundtrip.wav");
|
||||
}
|
||||
|
||||
if (g_failures == 0)
|
||||
{
|
||||
std::printf("PASS tone_analysis_test\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("FAILED tone_analysis_test (%d)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user