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:
140
common/include/coop/wav.hpp
Normal file
140
common/include/coop/wav.hpp
Normal file
@@ -0,0 +1,140 @@
|
||||
// Minimal WAV (RIFF/WAVE) reader + writer for the audio-validation tooling: dump a
|
||||
// captured stream to disk so it can be *listened to*, and read one back to analyze.
|
||||
// Supports the two formats the mirror carries -- 16-bit PCM (tag 1) and 32-bit float
|
||||
// (tag 3) -- interleaved, any channel count / sample rate. Header-only, no deps beyond
|
||||
// the C++ standard library, so the tool and a unit test share it. Not a general WAV
|
||||
// library: it reads/writes the canonical 44-byte-header layout these tools produce.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
struct WavData
|
||||
{
|
||||
std::uint32_t sample_rate = 0;
|
||||
std::uint32_t channels = 0;
|
||||
std::uint32_t bits = 0;
|
||||
std::uint32_t format_tag = 0; // 1 = PCM, 3 = IEEE float
|
||||
std::vector<std::uint8_t> pcm; // interleaved frames
|
||||
};
|
||||
|
||||
namespace detail
|
||||
{
|
||||
inline void wav_put_u32(std::vector<std::uint8_t>& b, std::uint32_t v)
|
||||
{
|
||||
b.push_back(v & 0xFF);
|
||||
b.push_back((v >> 8) & 0xFF);
|
||||
b.push_back((v >> 16) & 0xFF);
|
||||
b.push_back((v >> 24) & 0xFF);
|
||||
}
|
||||
inline void wav_put_u16(std::vector<std::uint8_t>& b, std::uint16_t v)
|
||||
{
|
||||
b.push_back(v & 0xFF);
|
||||
b.push_back((v >> 8) & 0xFF);
|
||||
}
|
||||
inline std::uint32_t wav_get_u32(const std::uint8_t* p)
|
||||
{
|
||||
return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<std::uint32_t>(p[3]) << 24);
|
||||
}
|
||||
inline std::uint16_t wav_get_u16(const std::uint8_t* p)
|
||||
{
|
||||
return static_cast<std::uint16_t>(p[0] | (p[1] << 8));
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// Write interleaved PCM to a WAV file. Returns false on an I/O error.
|
||||
inline bool wav_write(const std::wstring& path, const void* pcm, std::size_t bytes, std::uint32_t sample_rate,
|
||||
std::uint32_t channels, std::uint32_t bits, std::uint32_t format_tag)
|
||||
{
|
||||
const std::uint32_t block_align = channels * (bits / 8);
|
||||
const std::uint32_t byte_rate = sample_rate * block_align;
|
||||
std::vector<std::uint8_t> hdr;
|
||||
hdr.reserve(44);
|
||||
const char* riff = "RIFF";
|
||||
hdr.insert(hdr.end(), riff, riff + 4);
|
||||
detail::wav_put_u32(hdr, 36 + static_cast<std::uint32_t>(bytes)); // file size - 8
|
||||
const char* wave = "WAVE";
|
||||
hdr.insert(hdr.end(), wave, wave + 4);
|
||||
const char* fmt = "fmt ";
|
||||
hdr.insert(hdr.end(), fmt, fmt + 4);
|
||||
detail::wav_put_u32(hdr, 16); // PCM fmt chunk size
|
||||
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(format_tag));
|
||||
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(channels));
|
||||
detail::wav_put_u32(hdr, sample_rate);
|
||||
detail::wav_put_u32(hdr, byte_rate);
|
||||
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(block_align));
|
||||
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(bits));
|
||||
const char* data = "data";
|
||||
hdr.insert(hdr.end(), data, data + 4);
|
||||
detail::wav_put_u32(hdr, static_cast<std::uint32_t>(bytes));
|
||||
|
||||
FILE* f = nullptr;
|
||||
if (_wfopen_s(&f, path.c_str(), L"wb") != 0 || f == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const bool ok = std::fwrite(hdr.data(), 1, hdr.size(), f) == hdr.size() &&
|
||||
(bytes == 0 || std::fwrite(pcm, 1, bytes, f) == bytes);
|
||||
std::fclose(f);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Read a WAV file (PCM/float, canonical layout). Returns false if it can't be parsed.
|
||||
inline bool wav_read(const std::wstring& path, WavData& out)
|
||||
{
|
||||
FILE* f = nullptr;
|
||||
if (_wfopen_s(&f, path.c_str(), L"rb") != 0 || f == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long size = std::ftell(f);
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
if (size < 44)
|
||||
{
|
||||
std::fclose(f);
|
||||
return false;
|
||||
}
|
||||
std::vector<std::uint8_t> all(static_cast<std::size_t>(size));
|
||||
const bool read_ok = std::fread(all.data(), 1, all.size(), f) == all.size();
|
||||
std::fclose(f);
|
||||
if (!read_ok || std::memcmp(all.data(), "RIFF", 4) != 0 || std::memcmp(all.data() + 8, "WAVE", 4) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk chunks for "fmt " and "data".
|
||||
std::size_t pos = 12;
|
||||
bool have_fmt = false, have_data = false;
|
||||
while (pos + 8 <= all.size())
|
||||
{
|
||||
const std::uint8_t* p = all.data() + pos;
|
||||
const std::uint32_t chunk_size = detail::wav_get_u32(p + 4);
|
||||
const std::size_t body = pos + 8;
|
||||
if (std::memcmp(p, "fmt ", 4) == 0 && body + 16 <= all.size())
|
||||
{
|
||||
out.format_tag = detail::wav_get_u16(all.data() + body + 0);
|
||||
out.channels = detail::wav_get_u16(all.data() + body + 2);
|
||||
out.sample_rate = detail::wav_get_u32(all.data() + body + 4);
|
||||
out.bits = detail::wav_get_u16(all.data() + body + 14);
|
||||
have_fmt = true;
|
||||
}
|
||||
else if (std::memcmp(p, "data", 4) == 0)
|
||||
{
|
||||
const std::size_t avail = all.size() - body;
|
||||
const std::size_t n = std::min<std::size_t>(chunk_size, avail);
|
||||
out.pcm.assign(all.begin() + body, all.begin() + body + n);
|
||||
have_data = true;
|
||||
}
|
||||
pos = body + chunk_size + (chunk_size & 1); // chunks are word-aligned
|
||||
}
|
||||
return have_fmt && have_data;
|
||||
}
|
||||
|
||||
} // namespace coop
|
||||
Reference in New Issue
Block a user