Files
CoopAllTheThings/tests/tone_analysis_test.cpp
BlackMark 30eccf749d Apply clang-format across the whole tree
Run clang-format (the repo's .clang-format: LLVM base, 120 cols, tabs,
Allman functions) over every source file so the tree is formatter-clean.
Whitespace only -- no behavior change; full x64 + x86 suites pass.

Also set SortIncludes: false in .clang-format. Windows include order is
load-bearing (windows.h must precede tlhelp32.h / mmreg.h / xinput.h /
dinput.h; winsock2.h must precede windows.h), and the default
alphabetical sort reorders tlhelp32.h ahead of windows.h -- a build
break. Leaving order alone keeps the manual, correct grouping.
2026-07-12 11:52:53 +02:00

149 lines
6.2 KiB
C++

// 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;
}