// 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 #include #include #include #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 make_sine(double freq, unsigned rate, double seconds, double amp = 0.25) { const std::size_t n = static_cast(rate * seconds); std::vector v(n); const double step = kTwoPi * freq / rate; for (std::size_t i = 0; i < n; ++i) { v[i] = static_cast(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 inter(mono.size() * ch, 0); for (std::size_t i = 0; i < mono.size(); ++i) { inter[i * ch + 0] = static_cast(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; }