diff --git a/audio_capture.cpp b/audio_capture.cpp index d9fc94d..25e0217 100644 --- a/audio_capture.cpp +++ b/audio_capture.cpp @@ -30,9 +30,6 @@ using Microsoft::WRL::ComPtr; // back to AudioCapture::ThreadMain via static_cast on the parameter. extern "C" DWORD WINAPI RawAudioThreadWithSEH(LPVOID param) noexcept; -namespace -{ - // Helper for DX_CALL-style throw on HRESULT failure. Duplicates the macro // from cli_args_debugger.cpp to keep this TU independent. #define AC_CALL(expr, msg) \ @@ -43,12 +40,8 @@ namespace throw std::runtime_error(msg); \ } while (0) -struct SampleFormat +namespace audio_capture::detail { - WORD tag; - WORD bps; - UINT32 channels; -}; // Resolve tag / bits-per-sample for WAVEFORMATEXTENSIBLE, falling back to // the base WAVEFORMATEX fields for plain PCM/IEEE_FLOAT streams. @@ -163,7 +156,7 @@ float PeakForFormat(const SampleFormat& sf, const BYTE* data, UINT32 total_sampl return 0.f; } -} // namespace +} // namespace audio_capture::detail AudioCapture::~AudioCapture() { @@ -420,6 +413,7 @@ float AudioCapture::ComputePeak(const BYTE* data, UINT32 frames, DWORD flags) co if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) || !data || !mix_format_) return 0.f; + using namespace audio_capture::detail; const SampleFormat sf = ResolveFormat(mix_format_); UINT64 total64 = static_cast(frames) * static_cast(sf.channels); diff --git a/audio_capture.hpp b/audio_capture.hpp index 2f83265..f2a0f68 100644 --- a/audio_capture.hpp +++ b/audio_capture.hpp @@ -72,3 +72,25 @@ class AudioCapture std::atomic thread_running_{false}; std::wstring mic_name_; }; + +// Pure DSP helpers. Deliberately exposed so unit tests can exercise them +// without bringing up a real WASAPI capture session. +namespace audio_capture::detail +{ + +struct SampleFormat +{ + WORD tag; + WORD bps; + UINT32 channels; +}; + +SampleFormat ResolveFormat(const WAVEFORMATEX* mix); + +float PeakFloat32(const BYTE* data, UINT32 total_samples); +float PeakPcm16(const BYTE* data, UINT32 total_samples); +float PeakPcm24(const BYTE* data, UINT32 total_samples); +float PeakPcm32(const BYTE* data, UINT32 total_samples); +float PeakForFormat(const SampleFormat& sf, const BYTE* data, UINT32 total_samples); + +} // namespace audio_capture::detail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d1d0a6..5abb7dc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(TEST_SOURCES path_utils_tests.cpp qr_code_tests.cpp audio_tests.cpp + audio_peak_tests.cpp logging_tests.cpp data_persistence_tests.cpp string_conversion_tests.cpp @@ -31,6 +32,7 @@ set(TEST_SOURCES fps_calculation_tests.cpp memory_safety_tests.cpp buffer_safety_tests.cpp + path_info_tests.cpp ) # Add source files from parent directory that contain functions we're testing diff --git a/tests/audio_peak_tests.cpp b/tests/audio_peak_tests.cpp new file mode 100644 index 0000000..1279d27 --- /dev/null +++ b/tests/audio_peak_tests.cpp @@ -0,0 +1,279 @@ +// Unit tests for the pure DSP helpers in audio_capture::detail. These don't +// require a live microphone — they operate on in-memory byte buffers — so +// they're safe to run in CI on any Windows build agent. + +// NOMINMAX suppresses the min()/max() macros in ; without it, +// std::numeric_limits::min() below expands into garbage under MSVC. +#ifndef NOMINMAX +#define NOMINMAX +#endif + +// clang-format off +#include +#include +#include // must precede +#include +// clang-format on + +#include +#include +#include +#include + +#include + +#include "../audio_capture.hpp" + +using audio_capture::detail::PeakFloat32; +using audio_capture::detail::PeakForFormat; +using audio_capture::detail::PeakPcm16; +using audio_capture::detail::PeakPcm24; +using audio_capture::detail::PeakPcm32; +using audio_capture::detail::ResolveFormat; +using audio_capture::detail::SampleFormat; + +namespace +{ + +std::vector BytesFromFloats(std::initializer_list values) +{ + std::vector bytes(values.size() * sizeof(float)); + std::memcpy(bytes.data(), std::data(values), bytes.size()); + return bytes; +} + +std::vector BytesFromInt16s(std::initializer_list values) +{ + std::vector bytes(values.size() * sizeof(int16_t)); + std::memcpy(bytes.data(), std::data(values), bytes.size()); + return bytes; +} + +std::vector BytesFromInt32s(std::initializer_list values) +{ + std::vector bytes(values.size() * sizeof(int32_t)); + std::memcpy(bytes.data(), std::data(values), bytes.size()); + return bytes; +} + +// 24-bit PCM is stored as three little-endian bytes per sample. +std::vector Bytes24FromInt32s(std::initializer_list values) +{ + std::vector bytes; + bytes.reserve(values.size() * 3); + for (int32_t v : values) + { + bytes.push_back(static_cast(v & 0xFF)); + bytes.push_back(static_cast((v >> 8) & 0xFF)); + bytes.push_back(static_cast((v >> 16) & 0xFF)); + } + return bytes; +} + +} // namespace + +// --------------------------------------------------------------------------- +// PeakFloat32 — 32-bit IEEE_FLOAT stream +// --------------------------------------------------------------------------- + +TEST(PeakFloat32, PicksMaxAbsoluteValue) +{ + auto buf = BytesFromFloats({0.2f, -0.7f, 0.5f, -0.3f}); + EXPECT_FLOAT_EQ(PeakFloat32(buf.data(), 4), 0.7f); +} + +TEST(PeakFloat32, AllZerosYieldsZero) +{ + auto buf = BytesFromFloats({0.f, 0.f, 0.f}); + EXPECT_FLOAT_EQ(PeakFloat32(buf.data(), 3), 0.f); +} + +TEST(PeakFloat32, UnitySampleYieldsOne) +{ + auto buf = BytesFromFloats({-1.0f, 0.0f, 1.0f}); + EXPECT_FLOAT_EQ(PeakFloat32(buf.data(), 3), 1.0f); +} + +// --------------------------------------------------------------------------- +// PeakPcm16 — signed 16-bit PCM normalised by 32768 +// --------------------------------------------------------------------------- + +TEST(PeakPcm16, MaxPositiveNearOne) +{ + auto buf = BytesFromInt16s({0, 16384, -8192}); + EXPECT_NEAR(PeakPcm16(buf.data(), 3), 16384.0f / 32768.0f, 1e-6f); +} + +TEST(PeakPcm16, MinNegativeYieldsOne) +{ + // INT16_MIN / 32768 = -1.0 exactly → |.| = 1.0 + auto buf = BytesFromInt16s({0, std::numeric_limits::min()}); + EXPECT_FLOAT_EQ(PeakPcm16(buf.data(), 2), 1.0f); +} + +TEST(PeakPcm16, SilenceYieldsZero) +{ + auto buf = BytesFromInt16s({0, 0, 0, 0}); + EXPECT_FLOAT_EQ(PeakPcm16(buf.data(), 4), 0.f); +} + +// --------------------------------------------------------------------------- +// PeakPcm24 — 3 bytes per sample, little-endian, normalised by 2^23 +// --------------------------------------------------------------------------- + +TEST(PeakPcm24, SignExtendsNegative) +{ + // -0x400000 is half-scale negative (≈ -0.5). Test that the sign bit is + // preserved by the 3-byte → int32 decode. + auto buf = Bytes24FromInt32s({0, -0x400000, 0x200000}); + const float peak = PeakPcm24(buf.data(), 3); + EXPECT_NEAR(peak, 0.5f, 1e-5f); +} + +TEST(PeakPcm24, FullScalePositive) +{ + auto buf = Bytes24FromInt32s({0x7FFFFF}); + EXPECT_NEAR(PeakPcm24(buf.data(), 1), 0x7FFFFF / 8388608.0f, 1e-6f); +} + +TEST(PeakPcm24, MinValueHitsUnity) +{ + // -0x800000 / 2^23 = -1.0 exactly. + auto buf = Bytes24FromInt32s({-0x800000}); + EXPECT_FLOAT_EQ(PeakPcm24(buf.data(), 1), 1.0f); +} + +// --------------------------------------------------------------------------- +// PeakPcm32 — signed 32-bit PCM normalised by 2^31 +// --------------------------------------------------------------------------- + +TEST(PeakPcm32, HalfScale) +{ + auto buf = BytesFromInt32s({0, 0x40000000, -0x20000000}); + EXPECT_NEAR(PeakPcm32(buf.data(), 3), 0.5f, 1e-6f); +} + +TEST(PeakPcm32, MinNegativeYieldsOne) +{ + auto buf = BytesFromInt32s({std::numeric_limits::min()}); + EXPECT_FLOAT_EQ(PeakPcm32(buf.data(), 1), 1.0f); +} + +// --------------------------------------------------------------------------- +// PeakForFormat — dispatch by tag/bps +// --------------------------------------------------------------------------- + +TEST(PeakForFormat, DispatchesFloat32) +{ + SampleFormat sf{WAVE_FORMAT_IEEE_FLOAT, 32, 1}; + auto buf = BytesFromFloats({0.25f, -0.8f}); + EXPECT_FLOAT_EQ(PeakForFormat(sf, buf.data(), 2), 0.8f); +} + +TEST(PeakForFormat, DispatchesPcm16) +{ + SampleFormat sf{WAVE_FORMAT_PCM, 16, 1}; + auto buf = BytesFromInt16s({0, -16384}); + EXPECT_NEAR(PeakForFormat(sf, buf.data(), 2), 0.5f, 1e-5f); +} + +TEST(PeakForFormat, DispatchesPcm24) +{ + SampleFormat sf{WAVE_FORMAT_PCM, 24, 1}; + auto buf = Bytes24FromInt32s({-0x400000}); + EXPECT_NEAR(PeakForFormat(sf, buf.data(), 1), 0.5f, 1e-5f); +} + +TEST(PeakForFormat, DispatchesPcm32) +{ + SampleFormat sf{WAVE_FORMAT_PCM, 32, 1}; + auto buf = BytesFromInt32s({0x40000000}); + EXPECT_NEAR(PeakForFormat(sf, buf.data(), 1), 0.5f, 1e-6f); +} + +TEST(PeakForFormat, UnsupportedFormatReturnsZero) +{ + // 8-bit PCM and A-law/µ-law are not implemented. Peak should degrade to 0 + // rather than reinterpret memory or crash. + SampleFormat sf{WAVE_FORMAT_PCM, 8, 1}; + std::vector buf = {0xFF, 0x00, 0x80}; + EXPECT_FLOAT_EQ(PeakForFormat(sf, buf.data(), 3), 0.f); + + SampleFormat alaw{WAVE_FORMAT_ALAW, 8, 1}; + EXPECT_FLOAT_EQ(PeakForFormat(alaw, buf.data(), 3), 0.f); +} + +// --------------------------------------------------------------------------- +// ResolveFormat — plain WAVEFORMATEX and WAVEFORMATEXTENSIBLE handling +// --------------------------------------------------------------------------- + +TEST(ResolveFormat, PassesThroughPlainPcm16Stereo) +{ + WAVEFORMATEX mix{}; + mix.wFormatTag = WAVE_FORMAT_PCM; + mix.nChannels = 2; + mix.wBitsPerSample = 16; + mix.cbSize = 0; + + auto sf = ResolveFormat(&mix); + EXPECT_EQ(sf.tag, WAVE_FORMAT_PCM); + EXPECT_EQ(sf.bps, 16); + EXPECT_EQ(sf.channels, 2u); +} + +TEST(ResolveFormat, PassesThroughPlainFloat32Mono) +{ + WAVEFORMATEX mix{}; + mix.wFormatTag = WAVE_FORMAT_IEEE_FLOAT; + mix.nChannels = 1; + mix.wBitsPerSample = 32; + + auto sf = ResolveFormat(&mix); + EXPECT_EQ(sf.tag, WAVE_FORMAT_IEEE_FLOAT); + EXPECT_EQ(sf.bps, 32); + EXPECT_EQ(sf.channels, 1u); +} + +TEST(ResolveFormat, UnwrapsExtensibleFloat32) +{ + WAVEFORMATEXTENSIBLE wfex{}; + wfex.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + wfex.Format.nChannels = 2; + wfex.Format.wBitsPerSample = 32; + wfex.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + wfex.Samples.wValidBitsPerSample = 32; + wfex.SubFormat = KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; + + auto sf = ResolveFormat(reinterpret_cast(&wfex)); + EXPECT_EQ(sf.tag, WAVE_FORMAT_IEEE_FLOAT); + EXPECT_EQ(sf.bps, 32); + EXPECT_EQ(sf.channels, 2u); +} + +TEST(ResolveFormat, UnwrapsExtensiblePcmWithValidBits) +{ + // Device declares a 32-bit container with only 24 valid bits (common on + // pro audio cards). ResolveFormat should prefer wValidBitsPerSample. + WAVEFORMATEXTENSIBLE wfex{}; + wfex.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + wfex.Format.nChannels = 2; + wfex.Format.wBitsPerSample = 32; + wfex.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + wfex.Samples.wValidBitsPerSample = 24; + wfex.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + + auto sf = ResolveFormat(reinterpret_cast(&wfex)); + EXPECT_EQ(sf.tag, WAVE_FORMAT_PCM); + EXPECT_EQ(sf.bps, 24); +} + +TEST(ResolveFormat, ExtensibleWithZeroChannelsDefaultsToMono) +{ + WAVEFORMATEX mix{}; + mix.wFormatTag = WAVE_FORMAT_PCM; + mix.nChannels = 0; // broken device description + mix.wBitsPerSample = 16; + + auto sf = ResolveFormat(&mix); + EXPECT_EQ(sf.channels, 1u); +} diff --git a/tests/path_info_tests.cpp b/tests/path_info_tests.cpp new file mode 100644 index 0000000..4622005 --- /dev/null +++ b/tests/path_info_tests.cpp @@ -0,0 +1,137 @@ +// Smoke tests for path_info:: helpers. These exercise live Win32 calls, so +// they verify the growing-buffer/queried-size patterns don't truncate against +// the actual CI runner's filesystem. They're not attempting exhaustive +// coverage — the helpers are thin wrappers — but they catch regressions in +// the "is the string non-empty / does it point at something sane" dimension. + +#include + +#include +#include +#include + +#include + +#include "../path_info.hpp" + +namespace +{ + +bool ContainsAny(const std::wstring& s, std::initializer_list needles) +{ + for (const wchar_t* n : needles) + { + if (s.find(n) != std::wstring::npos) + return true; + } + return false; +} + +} // namespace + +TEST(PathInfo, ExecutablePathIsNonEmptyAndAbsolute) +{ + const std::wstring p = path_info::ExecutablePath(); + ASSERT_FALSE(p.empty()); + // Windows absolute paths start with a drive letter + colon or a UNC + // prefix. Accept either. + EXPECT_TRUE((p.size() >= 3 && p[1] == L':' && (p[2] == L'\\' || p[2] == L'/')) || + (p.size() >= 2 && p[0] == L'\\' && p[1] == L'\\')) + << "not absolute: " << std::string(p.begin(), p.end()); +} + +TEST(PathInfo, ExecutablePathEndsInExe) +{ + const std::wstring p = path_info::ExecutablePath(); + ASSERT_GE(p.size(), 4u); + std::wstring tail = p.substr(p.size() - 4); + std::transform(tail.begin(), tail.end(), tail.begin(), + [](wchar_t c) { return static_cast(::towlower(c)); }); + EXPECT_EQ(tail, L".exe"); +} + +TEST(PathInfo, CurrentWorkingDirectoryIsNonEmpty) +{ + EXPECT_FALSE(path_info::CurrentWorkingDirectory().empty()); +} + +TEST(PathInfo, TempDirectoryHasNoTrailingSlash) +{ + const std::wstring t = path_info::TempDirectory(); + ASSERT_FALSE(t.empty()); + EXPECT_NE(t.back(), L'\\'); +} + +TEST(PathInfo, WindowsDirectoryMentionsWindows) +{ + const std::wstring w = path_info::WindowsDirectory(); + ASSERT_FALSE(w.empty()); + // The folder really is named "Windows" on every SKU CI runs on; a + // lower-case filesystem would still carry the literal 'W'. + EXPECT_TRUE(ContainsAny(w, {L"Windows", L"windows"})); +} + +TEST(PathInfo, SystemDirectoryUnderWindowsDirectory) +{ + const std::wstring w = path_info::WindowsDirectory(); + const std::wstring s = path_info::SystemDirectory(); + ASSERT_FALSE(w.empty()); + ASSERT_FALSE(s.empty()); + // system32 / SysWOW64 both live beneath the Windows directory on every + // supported runner configuration. + EXPECT_TRUE(s.find(w) == 0) << "system dir not inside windows dir"; +} + +TEST(PathInfo, OsVersionLooksLikeWindows) +{ + const std::wstring v = path_info::OsVersionString(); + ASSERT_FALSE(v.empty()); + // Either "Windows X.Y (Build Z)" on success, or "Unknown" if RtlGetVersion + // was not resolvable. Both are acceptable smoke outcomes — we just want + // the helper to never crash or hand back garbage. + EXPECT_TRUE(v.find(L"Windows") == 0 || v == L"Unknown"); +} + +TEST(PathInfo, WineOrProtonReturnsSomething) +{ + const std::wstring v = path_info::WineOrProtonVersion(); + // On real Windows: "Not detected". On Wine/Proton: starts with "Wine" + // or "Proton". Either way it must be non-empty. + EXPECT_FALSE(v.empty()); +} + +TEST(PathInfo, SaveFilePathEndsInSavedDataTxt) +{ + const std::wstring p = path_info::SaveFilePath(); + ASSERT_FALSE(p.empty()); + if (p == L"Not available") + return; // SHGetKnownFolderPath was unavailable; acceptable outcome. + const std::wstring suffix = L"saved_data.txt"; + ASSERT_GE(p.size(), suffix.size()); + EXPECT_EQ(p.substr(p.size() - suffix.size()), suffix); +} + +TEST(PathInfo, CollectProducesExpectedLabels) +{ + const auto items = path_info::Collect(); + ASSERT_EQ(items.size(), 11u); + + // The main class stores this vector and reads labels verbatim in the UI. + // Keep the order fixed so a future accidental reorder is a test failure. + const std::vector expected_labels = { + L"OS Version: ", L"Wine/Proton: ", L"Executable name: ", L"Full path: ", + L"Executable directory: ", L"Current directory: ", L"Command line: ", L"Save file path: ", + L"TEMP directory: ", L"Windows directory: ", L"System directory: "}; + for (size_t i = 0; i < expected_labels.size(); ++i) + EXPECT_EQ(items[i].first, expected_labels[i]) << "at index " << i; +} + +TEST(PathInfo, CollectValuesAreNonEmptyWhereExpected) +{ + const auto items = path_info::Collect(); + // Executable name / full path / cwd / save path must be populated on a + // live Windows environment. + EXPECT_FALSE(items[2].second.empty()); // Executable name + EXPECT_FALSE(items[3].second.empty()); // Full path + EXPECT_FALSE(items[5].second.empty()); // Current directory +}