From 294ca00fb4c409e8226699ad814f19348713f6a9 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Wed, 21 Oct 2026 11:31:51 +0800 Subject: [PATCH 1/5] test: use GoogleTest for C API allocation test --- .gitmodules | 3 +++ CMakeLists.txt | 11 ++++++++++- tests/c-api-allocation-test.cpp | 35 ++++++++++++++++++++++----------- thirdparty/googletest | 1 + 4 files changed, 37 insertions(+), 13 deletions(-) create mode 160000 thirdparty/googletest diff --git a/.gitmodules b/.gitmodules index e679824..97bbd46 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,6 @@ [submodule "thirdparty/miniaudio"] path = thirdparty/miniaudio url = https://github.com/mackron/miniaudio.git +[submodule "thirdparty/googletest"] + path = thirdparty/googletest + url = https://github.com/google/googletest.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 3471dbe..f8be9ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,8 +77,17 @@ include(CTest) if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) if(NOT BUILD_SHARED_LIBS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/googletest/CMakeLists.txt") + message(FATAL_ERROR + "GoogleTest submodule is missing. Run: git submodule update --init --recursive") + endif() + set(BUILD_GMOCK OFF CACHE BOOL "Build GoogleMock" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "Install GoogleTest" FORCE) + set(gtest_force_shared_crt ON CACHE BOOL "Use the shared MSVC runtime" FORCE) + add_subdirectory(thirdparty/googletest EXCLUDE_FROM_ALL) + add_executable(clap-c-api-allocation-test tests/c-api-allocation-test.cpp) - target_link_libraries(clap-c-api-allocation-test PRIVATE clap) + target_link_libraries(clap-c-api-allocation-test PRIVATE clap GTest::gtest_main) add_test(NAME clap_c_api_allocation COMMAND clap-c-api-allocation-test) endif() add_test(NAME clap_conversion_imports diff --git a/tests/c-api-allocation-test.cpp b/tests/c-api-allocation-test.cpp index cbb420c..d1886f7 100644 --- a/tests/c-api-allocation-test.cpp +++ b/tests/c-api-allocation-test.cpp @@ -2,11 +2,17 @@ #include #include +#include #include namespace { bool fail_allocations = false; -} + +struct AllocationFailureScope { + AllocationFailureScope() { fail_allocations = true; } + ~AllocationFailureScope() { fail_allocations = false; } +}; +} // namespace void *operator new(std::size_t size) { if (fail_allocations) throw std::bad_alloc(); @@ -20,16 +26,21 @@ void operator delete[](void *p) noexcept { std::free(p); } void operator delete(void *p, std::size_t) noexcept { std::free(p); } void operator delete[](void *p, std::size_t) noexcept { std::free(p); } -int main() { +TEST(CApiAllocation, ReportsOutOfMemoryWithoutAllocating) { CLAP *model = nullptr; - fail_allocations = true; - const auto status = clap_create("unused.gguf", nullptr, &model); - // Keep allocations disabled while reading the error: reporting must itself - // be allocation-free, not replace bad_alloc with an escaping exception. - const bool valid = - status == CLAP_OUT_OF_MEMORY && model == nullptr && std::strstr(clap_last_error(), "out of memory") != nullptr; - clap_destroy(nullptr); - clap_free(nullptr); - fail_allocations = false; - return valid ? 0 : 1; + clap_status status; + bool valid_error; + { + AllocationFailureScope failure_scope; + status = clap_create("unused.gguf", nullptr, &model); + // Keep allocations disabled while reading the error: reporting must itself + // be allocation-free, not replace bad_alloc with an escaping exception. + valid_error = std::strstr(clap_last_error(), "out of memory") != nullptr; + clap_destroy(nullptr); + clap_free(nullptr); + } + // GoogleTest may allocate while reporting assertions, so restore allocations first. + EXPECT_EQ(CLAP_OUT_OF_MEMORY, status); + EXPECT_EQ(nullptr, model); + EXPECT_TRUE(valid_error); } diff --git a/thirdparty/googletest b/thirdparty/googletest new file mode 160000 index 0000000..52eb810 --- /dev/null +++ b/thirdparty/googletest @@ -0,0 +1 @@ +Subproject commit 52eb8108c5bdec04579160ae17225d66034bd723 From 4748444284080923fafd8815ef669f41fe4c021e Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 8 Nov 2026 18:23:40 +0800 Subject: [PATCH 2/5] test: migrate resampling to GoogleTest and rename test sources --- CMakeLists.txt | 26 +-- tests/audio-decode-test.cpp | 86 ++++++++ tests/audio-test.cpp | 205 +++++++++++------- ...-api-allocation-test.cpp => clap-test.cpp} | 0 tests/resample-test.cpp | 132 ----------- 5 files changed, 229 insertions(+), 220 deletions(-) create mode 100644 tests/audio-decode-test.cpp rename tests/{c-api-allocation-test.cpp => clap-test.cpp} (100%) delete mode 100644 tests/resample-test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f8be9ea..7ba433f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,17 +76,17 @@ endif() include(CTest) if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) - if(NOT BUILD_SHARED_LIBS) - if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/googletest/CMakeLists.txt") - message(FATAL_ERROR - "GoogleTest submodule is missing. Run: git submodule update --init --recursive") - endif() - set(BUILD_GMOCK OFF CACHE BOOL "Build GoogleMock" FORCE) - set(INSTALL_GTEST OFF CACHE BOOL "Install GoogleTest" FORCE) - set(gtest_force_shared_crt ON CACHE BOOL "Use the shared MSVC runtime" FORCE) - add_subdirectory(thirdparty/googletest EXCLUDE_FROM_ALL) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/googletest/CMakeLists.txt") + message(FATAL_ERROR + "GoogleTest submodule is missing. Run: git submodule update --init --recursive") + endif() + set(BUILD_GMOCK OFF CACHE BOOL "Build GoogleMock" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "Install GoogleTest" FORCE) + set(gtest_force_shared_crt ON CACHE BOOL "Use the shared MSVC runtime" FORCE) + add_subdirectory(thirdparty/googletest EXCLUDE_FROM_ALL) - add_executable(clap-c-api-allocation-test tests/c-api-allocation-test.cpp) + if(NOT BUILD_SHARED_LIBS) + add_executable(clap-c-api-allocation-test tests/clap-test.cpp) target_link_libraries(clap-c-api-allocation-test PRIVATE clap GTest::gtest_main) add_test(NAME clap_c_api_allocation COMMAND clap-c-api-allocation-test) endif() @@ -108,10 +108,10 @@ if(BUILD_TESTING) COMMENT "Generating audio test fixtures" VERBATIM) add_custom_target(clap-audio-fixtures ALL DEPENDS ${CLAP_AUDIO_FIXTURES}) - add_executable(clap-resample-test tests/resample-test.cpp) - target_link_libraries(clap-resample-test PRIVATE clap) + add_executable(clap-resample-test tests/audio-test.cpp) + target_link_libraries(clap-resample-test PRIVATE clap GTest::gtest_main) add_test(NAME clap_resample COMMAND clap-resample-test) - add_executable(clap-audio-test tests/audio-test.cpp) + add_executable(clap-audio-test tests/audio-decode-test.cpp) target_link_libraries(clap-audio-test PRIVATE clap) add_dependencies(clap-audio-test clap-audio-fixtures) add_test(NAME clap_audio COMMAND clap-audio-test "${CLAP_AUDIO_FIXTURE_DIR}") diff --git a/tests/audio-decode-test.cpp b/tests/audio-decode-test.cpp new file mode 100644 index 0000000..7504721 --- /dev/null +++ b/tests/audio-decode-test.cpp @@ -0,0 +1,86 @@ +#include "audio.h" +#include +#include +#include +#include +#include +#include + +static void require(bool value, const std::string &message) { + if (!value) + throw std::runtime_error(message); +} +int main(int argc, char **argv) { + try { + require(argc == 2, "usage: clap-audio-test FIXTURE_DIR"); + const std::filesystem::path dir(argv[1]); + for (const auto &name : {"mono.wav", "mono.flac", "mono.mp3", "stereo.wav", "stereo.flac", "stereo.mp3", + "unknown-length.flac", "pcm24.wav", "float.wav"}) { + const auto audio = clap::decode_audio((dir / name).string()); + std::ifstream file(dir / name, std::ios::binary); + const std::vector bytes((std::istreambuf_iterator(file)), {}); + const auto memory = clap::decode_audio(bytes.data(), bytes.size()); + require(memory.sample_rate == audio.sample_rate && memory.samples == audio.samples, + std::string(name) + " memory/file decoding parity"); + require(audio.sample_rate == 48000, std::string(name) + " sample rate"); + require(audio.samples.size() == 4800, std::string(name) + " frame count"); + const bool stereo = std::string(name).find("stereo") == 0; + double error = 0; + for (size_t i = 0; i < audio.samples.size(); ++i) { + require(std::isfinite(audio.samples[i]), std::string(name) + " non-finite sample"); + const double left = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 440 * i / 48000); + const double right = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 880 * i / 48000); + const double expected = stereo ? (left + right) / 2 : left; + error += std::pow(audio.samples[i] - expected, 2); + } + const double rmse = std::sqrt(error / audio.samples.size()); + const double tolerance = std::string(name).find(".mp3") != std::string::npos ? 0.02 : 0.00002; + require(rmse < tolerance, std::string(name) + " waveform mismatch: " + std::to_string(rmse)); + std::cout << name << " RMSE=" << rmse << '\n'; + } + const auto other_rate = clap::decode_audio((dir / "44100.flac").string()); + require(other_rate.sample_rate == 44100 && other_rate.samples.size() == 4410, + "decoding must preserve the sample rate"); + // Format detection must use contents, not filename extensions. + const auto decoded = clap::decode_audio((dir / "audio.dat").string()); + require(decoded.samples.size() == 4800, "content-based format detection"); + const auto surround = clap::decode_audio((dir / "surround.wav").string()); + require(surround.samples.size() == 4800, "surround frame count"); + for (float value : surround.samples) + require(std::abs(value - 10500.0f / 32768) < 1e-6f, "equal-weight surround downmix"); + for (const auto &path : {dir / "truncated.wav", dir / "truncated.flac", dir / "nonfinite.wav", + dir / "missing.wav", dir / "invalid.bin", dir / "empty.wav"}) { + bool rejected = false; + try { + clap::decode_audio(path.string()); + } catch (const std::exception &) { + rejected = true; + } + require(rejected, "must reject " + path.string()); + if (std::filesystem::exists(path)) { + std::ifstream file(path, std::ios::binary); + const std::vector bytes((std::istreambuf_iterator(file)), {}); + rejected = false; + try { + clap::decode_audio(bytes.data(), bytes.size()); + } catch (const std::exception &) { + rejected = true; + } + require(rejected, "must reject memory audio " + path.string()); + } + } + for (size_t size : {size_t{0}, size_t{4}}) { + bool rejected = false; + try { + clap::decode_audio(nullptr, size); + } catch (const std::exception &) { + rejected = true; + } + require(rejected, "must reject null audio buffer"); + } + return 0; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/audio-test.cpp b/tests/audio-test.cpp index 7504721..d34a3d9 100644 --- a/tests/audio-test.cpp +++ b/tests/audio-test.cpp @@ -1,86 +1,141 @@ #include "audio.h" +#include #include -#include -#include -#include +#include #include +#include #include +#include -static void require(bool value, const std::string &message) { - if (!value) - throw std::runtime_error(message); +namespace { +constexpr double pi = 3.14159265358979323846; +clap::Audio tone(int rate, double frequency) { + clap::Audio audio; + audio.sample_rate = rate; + audio.samples.resize(rate / 10); + for (size_t i = 0; i < audio.samples.size(); ++i) + audio.samples[i] = static_cast(0.5 * std::sin(2 * pi * frequency * i / rate)); + return audio; } -int main(int argc, char **argv) { - try { - require(argc == 2, "usage: clap-audio-test FIXTURE_DIR"); - const std::filesystem::path dir(argv[1]); - for (const auto &name : {"mono.wav", "mono.flac", "mono.mp3", "stereo.wav", "stereo.flac", "stereo.mp3", - "unknown-length.flac", "pcm24.wav", "float.wav"}) { - const auto audio = clap::decode_audio((dir / name).string()); - std::ifstream file(dir / name, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(file)), {}); - const auto memory = clap::decode_audio(bytes.data(), bytes.size()); - require(memory.sample_rate == audio.sample_rate && memory.samples == audio.samples, - std::string(name) + " memory/file decoding parity"); - require(audio.sample_rate == 48000, std::string(name) + " sample rate"); - require(audio.samples.size() == 4800, std::string(name) + " frame count"); - const bool stereo = std::string(name).find("stereo") == 0; - double error = 0; - for (size_t i = 0; i < audio.samples.size(); ++i) { - require(std::isfinite(audio.samples[i]), std::string(name) + " non-finite sample"); - const double left = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 440 * i / 48000); - const double right = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 880 * i / 48000); - const double expected = stereo ? (left + right) / 2 : left; - error += std::pow(audio.samples[i] - expected, 2); - } - const double rmse = std::sqrt(error / audio.samples.size()); - const double tolerance = std::string(name).find(".mp3") != std::string::npos ? 0.02 : 0.00002; - require(rmse < tolerance, std::string(name) + " waveform mismatch: " + std::to_string(rmse)); - std::cout << name << " RMSE=" << rmse << '\n'; + +TEST(Resample, PreservesDurationAmplitudeAndPhase) { + for (const auto rates : {std::pair{8000, 48000}, + {16000, 48000}, + {22050, 48000}, + {44100, 48000}, + {96000, 48000}, + {48000, 24000}, + {44100, 24000}, + {16000, 24000}}) { + const int source = rates.first, target = rates.second; + SCOPED_TRACE(testing::Message() << source << " -> " << target); + const auto output = clap::resample_audio(tone(source, 1000), target); + ASSERT_EQ(target, output.sample_rate); + ASSERT_EQ(static_cast(target / 10), output.samples.size()); + // Linear interpolation + causal LPF is not a zero-phase sinc. + // Fit sine/cosine coefficients: check frequency, gain and bounded delay + // independently, without requiring the previous algorithm's phase. + double sine = 0, cosine = 0; + const size_t begin = target / 100, end = target * 9 / 100; // whole periods, after startup + for (size_t i = begin; i < end; ++i) { + const double phase = 2 * pi * 1000 * i / target; + sine += output.samples[i] * std::sin(phase); + cosine += output.samples[i] * std::cos(phase); } - const auto other_rate = clap::decode_audio((dir / "44100.flac").string()); - require(other_rate.sample_rate == 44100 && other_rate.samples.size() == 4410, - "decoding must preserve the sample rate"); - // Format detection must use contents, not filename extensions. - const auto decoded = clap::decode_audio((dir / "audio.dat").string()); - require(decoded.samples.size() == 4800, "content-based format detection"); - const auto surround = clap::decode_audio((dir / "surround.wav").string()); - require(surround.samples.size() == 4800, "surround frame count"); - for (float value : surround.samples) - require(std::abs(value - 10500.0f / 32768) < 1e-6f, "equal-weight surround downmix"); - for (const auto &path : {dir / "truncated.wav", dir / "truncated.flac", dir / "nonfinite.wav", - dir / "missing.wav", dir / "invalid.bin", dir / "empty.wav"}) { - bool rejected = false; - try { - clap::decode_audio(path.string()); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject " + path.string()); - if (std::filesystem::exists(path)) { - std::ifstream file(path, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(file)), {}); - rejected = false; - try { - clap::decode_audio(bytes.data(), bytes.size()); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject memory audio " + path.string()); - } + sine *= 2.0 / (end - begin); + cosine *= 2.0 / (end - begin); + const double gain = std::hypot(sine, cosine) / 0.5; + const double phase = std::atan2(cosine, sine); + double error = 0; + for (size_t i = begin; i < end; ++i) { + const double angle = 2 * pi * 1000 * i / target; + error += std::pow(output.samples[i] - sine * std::sin(angle) - cosine * std::cos(angle), 2); } - for (size_t size : {size_t{0}, size_t{4}}) { - bool rejected = false; - try { - clap::decode_audio(nullptr, size); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject null audio buffer"); + const double rmse = std::sqrt(error / (end - begin)); + std::cout << source << " -> " << target << " gain=" << gain << " phase=" << phase << " residual=" << rmse + << '\n'; + ASSERT_GT(gain, 0.94); + ASSERT_LT(gain, 1.01); + ASSERT_LE(phase, 0.01); + ASSERT_GT(phase, -pi); + ASSERT_LT(rmse, 0.002); + } +} + +TEST(Resample, SuppressesFrequenciesAboveTargetNyquist) { + for (const auto rates : {std::pair{48000, 16000}, {96000, 48000}, {48000, 24000}}) { + SCOPED_TRACE(testing::Message() << rates.first << " -> " << rates.second); + const auto output = clap::resample_audio(tone(rates.first, rates.second * 0.75), rates.second); + ASSERT_GT(output.samples.size(), 256u); + double energy = 0; + for (size_t i = 128; i < output.samples.size() - 128; ++i) + energy += output.samples[i] * output.samples[i]; + const double rms = std::sqrt(energy / (output.samples.size() - 256)); + std::cout << rates.first << " -> " << rates.second << " alias RMS=" << rms << '\n'; + // At least 35 dB attenuation relative to the 0.5-amplitude input tone. + ASSERT_LT(rms, 0.006); + } +} + +TEST(Resample, SameRateOutputIsExact) { + const auto original = tone(48000, 1000); + ASSERT_EQ(original.samples, clap::resample_audio(original).samples); +} + +TEST(Resample, RoundsDurationUpAndPreservesSettledDcGain) { + for (int source : {8000, 44100, 96000}) { + SCOPED_TRACE(source); + const clap::Audio constant{std::vector(source / 10 + 1, 0.25f), source}; + const auto output = clap::resample_audio(constant); + const auto expected = (static_cast(constant.samples.size()) * 48000 + source - 1) / source; + ASSERT_EQ(expected, output.samples.size()); + ASSERT_LT(std::abs(output.samples.front()), 1e-6f); + ASSERT_TRUE(std::all_of(output.samples.begin() + 480, output.samples.end(), + [](float x) { return std::abs(x - 0.25f) < 1e-6f; })); + } +} + +TEST(Resample, HandlesSingleSampleClips) { + for (int source : {8000, 44100, 96000}) { + SCOPED_TRACE(source); + const auto short_clip = clap::resample_audio({{0.5f}, source}); + ASSERT_EQ((48000ULL + source - 1) / source, short_clip.samples.size()); + for (float x : short_clip.samples) + ASSERT_TRUE(std::isfinite(x)); + } +} + +TEST(Resample, BoundsCausalImpulseLatency) { + clap::Audio impulse{std::vector(4800, 0), 16000}; + impulse.samples[100] = 1; + const auto response = clap::resample_audio(impulse); + const auto peak = std::max_element(response.samples.begin(), response.samples.end()); + const auto delay = peak - response.samples.begin() - 300; + std::cout << "16000 -> 48000 impulse peak delay=" << delay << " samples\n"; + ASSERT_GT(delay, 0); + ASSERT_LT(delay, 64); +} + +TEST(Resample, PreservesShortAndFractionalSilence) { + for (const auto rates : {std::pair{44100, 48000}, {48000, 16000}, {96000, 44100}}) { + for (size_t count : {size_t{1}, size_t{2}, size_t{17}, size_t{4097}}) { + SCOPED_TRACE(testing::Message() << rates.first << " -> " << rates.second << " count=" << count); + const auto out = clap::resample_audio({std::vector(count, 0), rates.first}, rates.second); + ASSERT_EQ((count * rates.second + rates.first - 1) / rates.first, out.samples.size()); + for (float x : out.samples) + ASSERT_EQ(0, x); } - return 0; - } catch (const std::exception &error) { - std::cerr << error.what() << '\n'; - return 1; } } + +TEST(Resample, RejectsInvalidInputs) { + ASSERT_THROW(clap::resample_audio({{}, 48000}, 48000), std::exception); + ASSERT_THROW(clap::resample_audio({{0}, 0}, 48000), std::exception); + ASSERT_THROW(clap::resample_audio({{0}, -1}, 48000), std::exception); + ASSERT_THROW(clap::resample_audio({{0}, 48000}, 0), std::exception); + ASSERT_THROW(clap::resample_audio({{0}, 48000}, -1), std::exception); + ASSERT_THROW(clap::resample_audio({{std::numeric_limits::quiet_NaN()}, 48000}, 48000), std::exception); + ASSERT_THROW(clap::resample_audio({{std::numeric_limits::infinity()}, 44100}, 48000), std::exception); + ASSERT_THROW(clap::resample_audio({{0}, 1}, std::numeric_limits::max()), std::exception); +} +} // namespace diff --git a/tests/c-api-allocation-test.cpp b/tests/clap-test.cpp similarity index 100% rename from tests/c-api-allocation-test.cpp rename to tests/clap-test.cpp diff --git a/tests/resample-test.cpp b/tests/resample-test.cpp deleted file mode 100644 index eca39f4..0000000 --- a/tests/resample-test.cpp +++ /dev/null @@ -1,132 +0,0 @@ -#include "audio.h" -#include -#include -#include -#include -#include -#include - -namespace { -constexpr double pi = 3.14159265358979323846; -void require(bool condition, const char *message) { - if (!condition) - throw std::runtime_error(message); -} -clap::Audio tone(int rate, double frequency) { - clap::Audio audio; - audio.sample_rate = rate; - audio.samples.resize(rate / 10); - for (size_t i = 0; i < audio.samples.size(); ++i) - audio.samples[i] = static_cast(0.5 * std::sin(2 * pi * frequency * i / rate)); - return audio; -} -void rejected(const clap::Audio &audio, int rate) { - bool failed = false; - try { - clap::resample_audio(audio, rate); - } catch (const std::exception &) { - failed = true; - } - require(failed, "invalid resampling input must be rejected"); -} -} // namespace - -int main() { - try { - for (const auto rates : {std::pair{8000, 48000}, - {16000, 48000}, - {22050, 48000}, - {44100, 48000}, - {96000, 48000}, - {48000, 24000}, - {44100, 24000}, - {16000, 24000}}) { - const int source = rates.first, target = rates.second; - const auto output = clap::resample_audio(tone(source, 1000), target); - require(output.sample_rate == target, "target sample rate"); - require(output.samples.size() == static_cast(target / 10), "duration must be preserved"); - // Linear interpolation + causal LPF is not a zero-phase sinc. - // Fit sine/cosine coefficients: check frequency, gain and bounded delay - // independently, without requiring the previous algorithm's phase. - double sine = 0, cosine = 0; - const size_t begin = target / 100, end = target * 9 / 100; // whole periods, after startup - for (size_t i = begin; i < end; ++i) { - const double phase = 2 * pi * 1000 * i / target; - sine += output.samples[i] * std::sin(phase); - cosine += output.samples[i] * std::cos(phase); - } - sine *= 2.0 / (end - begin); - cosine *= 2.0 / (end - begin); - const double gain = std::hypot(sine, cosine) / 0.5; - const double phase = std::atan2(cosine, sine); - double error = 0; - for (size_t i = begin; i < end; ++i) { - const double angle = 2 * pi * 1000 * i / target; - error += std::pow(output.samples[i] - sine * std::sin(angle) - cosine * std::cos(angle), 2); - } - const double rmse = std::sqrt(error / (end - begin)); - std::cout << source << " -> " << target << " gain=" << gain << " phase=" << phase << " residual=" << rmse - << '\n'; - require(gain > 0.94 && gain < 1.01, "resampled tone amplitude mismatch"); - require(phase <= 0.01 && phase > -pi, "resampled tone delay out of bounds"); - require(rmse < 0.002, "resampled tone frequency/distortion mismatch"); - } - for (const auto rates : {std::pair{48000, 16000}, {96000, 48000}, {48000, 24000}}) { - const auto output = clap::resample_audio(tone(rates.first, rates.second * 0.75), rates.second); - double energy = 0; - for (size_t i = 128; i < output.samples.size() - 128; ++i) - energy += output.samples[i] * output.samples[i]; - const double rms = std::sqrt(energy / (output.samples.size() - 256)); - std::cout << rates.first << " -> " << rates.second << " alias RMS=" << rms << '\n'; - // At least 35 dB attenuation relative to the 0.5-amplitude input tone. - require(rms < 0.006, "downsampling must suppress frequencies above target Nyquist"); - } - const auto original = tone(48000, 1000); - require(clap::resample_audio(original).samples == original.samples, "same-rate output must be exact"); - for (int source : {8000, 44100, 96000}) { - const clap::Audio constant{std::vector(source / 10 + 1, 0.25f), source}; - const auto output = clap::resample_audio(constant); - const auto expected = - (static_cast(constant.samples.size()) * 48000 + source - 1) / source; - require(output.samples.size() == expected, "fractional duration rounds up"); - require(std::abs(output.samples.front()) < 1e-6f, "causal DC startup"); - require(std::all_of(output.samples.begin() + 480, output.samples.end(), - [](float x) { return std::abs(x - 0.25f) < 1e-6f; }), - "settled DC gain must remain unity through end of clip"); - } - for (int source : {8000, 44100, 96000}) { - const auto short_clip = clap::resample_audio({{0.5f}, source}); - require(short_clip.samples.size() == (48000ULL + source - 1) / source, "single-sample duration"); - for (float x : short_clip.samples) - require(std::isfinite(x), "finite single-sample output"); - } - clap::Audio impulse{std::vector(4800, 0), 16000}; - impulse.samples[100] = 1; - const auto response = clap::resample_audio(impulse); - const auto peak = std::max_element(response.samples.begin(), response.samples.end()); - const auto delay = peak - response.samples.begin() - 300; - std::cout << "16000 -> 48000 impulse peak delay=" << delay << " samples\n"; - require(delay > 0 && delay < 64, "causal resampler impulse latency"); - for (const auto rates : {std::pair{44100, 48000}, {48000, 16000}, {96000, 44100}}) { - for (size_t count : {size_t{1}, size_t{2}, size_t{17}, size_t{4097}}) { - const auto out = clap::resample_audio({std::vector(count, 0), rates.first}, rates.second); - require(out.samples.size() == (count * rates.second + rates.first - 1) / rates.first, - "short/fractional duration"); - for (float x : out.samples) - require(x == 0, "silence stays silent"); - } - } - rejected({{}, 48000}, 48000); - rejected({{0}, 0}, 48000); - rejected({{0}, -1}, 48000); - rejected({{0}, 48000}, 0); - rejected({{0}, 48000}, -1); - rejected({{std::numeric_limits::quiet_NaN()}, 48000}, 48000); - rejected({{std::numeric_limits::infinity()}, 44100}, 48000); - rejected({{0}, 1}, std::numeric_limits::max()); - return 0; - } catch (const std::exception &error) { - std::cerr << error.what() << '\n'; - return 1; - } -} From 66c7b228449c10628708d449ae3d9a28b8e6dd14 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 8 Nov 2026 18:36:20 +0800 Subject: [PATCH 3/5] test: combine audio decoding and resampling GoogleTests --- CMakeLists.txt | 9 ++-- tests/audio-decode-test.cpp | 86 ------------------------------------- tests/audio-test.cpp | 85 ++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 92 deletions(-) delete mode 100644 tests/audio-decode-test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ba433f..1b3214f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,11 +108,8 @@ if(BUILD_TESTING) COMMENT "Generating audio test fixtures" VERBATIM) add_custom_target(clap-audio-fixtures ALL DEPENDS ${CLAP_AUDIO_FIXTURES}) - add_executable(clap-resample-test tests/audio-test.cpp) - target_link_libraries(clap-resample-test PRIVATE clap GTest::gtest_main) - add_test(NAME clap_resample COMMAND clap-resample-test) - add_executable(clap-audio-test tests/audio-decode-test.cpp) - target_link_libraries(clap-audio-test PRIVATE clap) + add_executable(clap-audio-test tests/audio-test.cpp) + target_link_libraries(clap-audio-test PRIVATE clap GTest::gtest) add_dependencies(clap-audio-test clap-audio-fixtures) add_test(NAME clap_audio COMMAND clap-audio-test "${CLAP_AUDIO_FIXTURE_DIR}") add_executable(clap-audio-path-test tests/audio-path-test.cpp) @@ -382,7 +379,7 @@ if(BUILD_TESTING) set_tests_properties(clap_embedding_cli PROPERTIES TIMEOUT 30) endif() # Audio preprocessing helpers are private to the core and its tests. - foreach(target IN ITEMS clap-resample-test clap-audio-test clap-audio-path-test + foreach(target IN ITEMS clap-audio-test clap-audio-path-test mert2-fft-test mert-model-test clamp3-bundled-test muq-model-test muq-mulan-model-test) if(TARGET ${target}) target_include_directories(${target} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/tests/audio-decode-test.cpp b/tests/audio-decode-test.cpp deleted file mode 100644 index 7504721..0000000 --- a/tests/audio-decode-test.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "audio.h" -#include -#include -#include -#include -#include -#include - -static void require(bool value, const std::string &message) { - if (!value) - throw std::runtime_error(message); -} -int main(int argc, char **argv) { - try { - require(argc == 2, "usage: clap-audio-test FIXTURE_DIR"); - const std::filesystem::path dir(argv[1]); - for (const auto &name : {"mono.wav", "mono.flac", "mono.mp3", "stereo.wav", "stereo.flac", "stereo.mp3", - "unknown-length.flac", "pcm24.wav", "float.wav"}) { - const auto audio = clap::decode_audio((dir / name).string()); - std::ifstream file(dir / name, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(file)), {}); - const auto memory = clap::decode_audio(bytes.data(), bytes.size()); - require(memory.sample_rate == audio.sample_rate && memory.samples == audio.samples, - std::string(name) + " memory/file decoding parity"); - require(audio.sample_rate == 48000, std::string(name) + " sample rate"); - require(audio.samples.size() == 4800, std::string(name) + " frame count"); - const bool stereo = std::string(name).find("stereo") == 0; - double error = 0; - for (size_t i = 0; i < audio.samples.size(); ++i) { - require(std::isfinite(audio.samples[i]), std::string(name) + " non-finite sample"); - const double left = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 440 * i / 48000); - const double right = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 880 * i / 48000); - const double expected = stereo ? (left + right) / 2 : left; - error += std::pow(audio.samples[i] - expected, 2); - } - const double rmse = std::sqrt(error / audio.samples.size()); - const double tolerance = std::string(name).find(".mp3") != std::string::npos ? 0.02 : 0.00002; - require(rmse < tolerance, std::string(name) + " waveform mismatch: " + std::to_string(rmse)); - std::cout << name << " RMSE=" << rmse << '\n'; - } - const auto other_rate = clap::decode_audio((dir / "44100.flac").string()); - require(other_rate.sample_rate == 44100 && other_rate.samples.size() == 4410, - "decoding must preserve the sample rate"); - // Format detection must use contents, not filename extensions. - const auto decoded = clap::decode_audio((dir / "audio.dat").string()); - require(decoded.samples.size() == 4800, "content-based format detection"); - const auto surround = clap::decode_audio((dir / "surround.wav").string()); - require(surround.samples.size() == 4800, "surround frame count"); - for (float value : surround.samples) - require(std::abs(value - 10500.0f / 32768) < 1e-6f, "equal-weight surround downmix"); - for (const auto &path : {dir / "truncated.wav", dir / "truncated.flac", dir / "nonfinite.wav", - dir / "missing.wav", dir / "invalid.bin", dir / "empty.wav"}) { - bool rejected = false; - try { - clap::decode_audio(path.string()); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject " + path.string()); - if (std::filesystem::exists(path)) { - std::ifstream file(path, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(file)), {}); - rejected = false; - try { - clap::decode_audio(bytes.data(), bytes.size()); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject memory audio " + path.string()); - } - } - for (size_t size : {size_t{0}, size_t{4}}) { - bool rejected = false; - try { - clap::decode_audio(nullptr, size); - } catch (const std::exception &) { - rejected = true; - } - require(rejected, "must reject null audio buffer"); - } - return 0; - } catch (const std::exception &error) { - std::cerr << error.what() << '\n'; - return 1; - } -} diff --git a/tests/audio-test.cpp b/tests/audio-test.cpp index d34a3d9..90a58cc 100644 --- a/tests/audio-test.cpp +++ b/tests/audio-test.cpp @@ -1,8 +1,11 @@ #include "audio.h" #include #include +#include +#include #include #include +#include #include #include #include @@ -139,3 +142,85 @@ TEST(Resample, RejectsInvalidInputs) { ASSERT_THROW(clap::resample_audio({{0}, 1}, std::numeric_limits::max()), std::exception); } } // namespace + +namespace { +std::filesystem::path fixture_dir; + +TEST(AudioDecode, PreservesWaveformsAndMatchesMemoryDecoding) { + for (const auto &name : {"mono.wav", "mono.flac", "mono.mp3", "stereo.wav", "stereo.flac", "stereo.mp3", + "unknown-length.flac", "pcm24.wav", "float.wav"}) { + SCOPED_TRACE(name); + const auto audio = clap::decode_audio((fixture_dir / name).string()); + std::ifstream file(fixture_dir / name, std::ios::binary); + const std::vector bytes((std::istreambuf_iterator(file)), {}); + const auto memory = clap::decode_audio(bytes.data(), bytes.size()); + ASSERT_EQ(audio.sample_rate, memory.sample_rate); + ASSERT_EQ(audio.samples, memory.samples); + ASSERT_EQ(48000, audio.sample_rate); + ASSERT_EQ(4800u, audio.samples.size()); + const bool stereo = std::string(name).find("stereo") == 0; + double error = 0; + for (size_t i = 0; i < audio.samples.size(); ++i) { + ASSERT_TRUE(std::isfinite(audio.samples[i])) << "sample " << i; + const double left = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 440 * i / 48000); + const double right = 12000.0 / 32768 * std::sin(2 * 3.141592653589793 * 880 * i / 48000); + const double expected = stereo ? (left + right) / 2 : left; + error += std::pow(audio.samples[i] - expected, 2); + } + const double rmse = std::sqrt(error / audio.samples.size()); + const double tolerance = std::string(name).find(".mp3") != std::string::npos ? 0.02 : 0.00002; + ASSERT_LT(rmse, tolerance); + std::cout << name << " RMSE=" << rmse << '\n'; + } +} + +TEST(AudioDecode, PreservesSampleRate) { + const auto other_rate = clap::decode_audio((fixture_dir / "44100.flac").string()); + ASSERT_EQ(44100, other_rate.sample_rate); + ASSERT_EQ(4410u, other_rate.samples.size()); +} + +TEST(AudioDecode, DetectsFormatFromContents) { + const auto decoded = clap::decode_audio((fixture_dir / "audio.dat").string()); + ASSERT_EQ(4800u, decoded.samples.size()); +} + +TEST(AudioDecode, MixesSurroundChannelsWithEqualWeights) { + const auto surround = clap::decode_audio((fixture_dir / "surround.wav").string()); + ASSERT_EQ(4800u, surround.samples.size()); + for (float value : surround.samples) + ASSERT_LT(std::abs(value - 10500.0f / 32768), 1e-6f); +} + +TEST(AudioDecode, RejectsInvalidFilesAndMemoryBuffers) { + for (const auto &path : + {fixture_dir / "truncated.wav", fixture_dir / "truncated.flac", fixture_dir / "nonfinite.wav", + fixture_dir / "missing.wav", fixture_dir / "invalid.bin", fixture_dir / "empty.wav"}) { + SCOPED_TRACE(path.string()); + ASSERT_THROW(clap::decode_audio(path.string()), std::exception); + if (std::filesystem::exists(path)) { + std::ifstream file(path, std::ios::binary); + const std::vector bytes((std::istreambuf_iterator(file)), {}); + ASSERT_THROW(clap::decode_audio(bytes.data(), bytes.size()), std::exception); + } + } +} + +TEST(AudioDecode, RejectsNullBuffers) { + for (size_t size : {size_t{0}, size_t{4}}) { + SCOPED_TRACE(size); + ASSERT_THROW(clap::decode_audio(nullptr, size), std::exception); + } +} +} // namespace + +int main(int argc, char **argv) { + testing::InitGoogleTest(&argc, argv); + if (argc != 2 && !GTEST_FLAG_GET(list_tests)) { + std::cerr << "usage: clap-audio-test FIXTURE_DIR\n"; + return 1; + } + if (argc == 2) + fixture_dir = argv[1]; + return RUN_ALL_TESTS(); +} From 5d28fc6b5a8b5ebfbcdc15dcc7c3bc4f5e6b935b Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 8 Nov 2026 19:25:24 +0800 Subject: [PATCH 4/5] fix: import shared GoogleTest symbols in build-tree consumers --- CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1b3214f..55c0808 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,10 @@ if(BUILD_TESTING) set(INSTALL_GTEST OFF CACHE BOOL "Install GoogleTest" FORCE) set(gtest_force_shared_crt ON CACHE BOOL "Use the shared MSVC runtime" FORCE) add_subdirectory(thirdparty/googletest EXCLUDE_FROM_ALL) + if(BUILD_SHARED_LIBS) + # GoogleTest only sets this for installed consumers, not build-tree users. + target_compile_definitions(gtest INTERFACE GTEST_LINKED_AS_SHARED_LIBRARY=1) + endif() if(NOT BUILD_SHARED_LIBS) add_executable(clap-c-api-allocation-test tests/clap-test.cpp) From f7a9871fb2f28b06f340f7e0909f97b792caf772 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 8 Nov 2026 19:58:05 +0800 Subject: [PATCH 5/5] fix: deploy GoogleTest DLL beside Windows audio test --- CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 55c0808..0f21319 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,12 @@ if(BUILD_TESTING) add_custom_target(clap-audio-fixtures ALL DEPENDS ${CLAP_AUDIO_FIXTURES}) add_executable(clap-audio-test tests/audio-test.cpp) target_link_libraries(clap-audio-test PRIVATE clap GTest::gtest) + if(WIN32 AND BUILD_SHARED_LIBS) + add_custom_command(TARGET clap-audio-test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" "$" + VERBATIM) + endif() add_dependencies(clap-audio-test clap-audio-fixtures) add_test(NAME clap_audio COMMAND clap-audio-test "${CLAP_AUDIO_FIXTURE_DIR}") add_executable(clap-audio-path-test tests/audio-path-test.cpp)