Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 23 additions & 7 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,22 @@ endif()
include(CTest)
if(BUILD_TESTING)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
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(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/c-api-allocation-test.cpp)
target_link_libraries(clap-c-api-allocation-test PRIVATE clap)
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()
add_test(NAME clap_conversion_imports
Expand All @@ -99,11 +112,14 @@ 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_test(NAME clap_resample COMMAND clap-resample-test)
add_executable(clap-audio-test tests/audio-test.cpp)
target_link_libraries(clap-audio-test PRIVATE clap)
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
"$<TARGET_FILE:gtest>" "$<TARGET_FILE_DIR:clap-audio-test>"
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)
Expand Down Expand Up @@ -373,7 +389,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")
Expand Down
284 changes: 212 additions & 72 deletions tests/audio-test.cpp
Original file line number Diff line number Diff line change
@@ -1,86 +1,226 @@
#include "audio.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <gtest/gtest.h>
#include <iostream>
#include <iterator>
#include <limits>
#include <stdexcept>
#include <utility>

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<float>(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<char> bytes((std::istreambuf_iterator<char>(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<int, int>{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<size_t>(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);
}
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';
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<int, int>{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<float>(source / 10 + 1, 0.25f), source};
const auto output = clap::resample_audio(constant);
const auto expected = (static_cast<unsigned long long>(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<float>(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<int, int>{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<float>(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);
}
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<char> bytes((std::istreambuf_iterator<char>(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());
}
}
}

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<float>::quiet_NaN()}, 48000}, 48000), std::exception);
ASSERT_THROW(clap::resample_audio({{std::numeric_limits<float>::infinity()}, 44100}, 48000), std::exception);
ASSERT_THROW(clap::resample_audio({{0}, 1}, std::numeric_limits<int>::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<char> bytes((std::istreambuf_iterator<char>(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);
}
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 / 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<char> bytes((std::istreambuf_iterator<char>(file)), {});
ASSERT_THROW(clap::decode_audio(bytes.data(), bytes.size()), std::exception);
}
return 0;
} catch (const std::exception &error) {
std::cerr << error.what() << '\n';
}
}

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();
}
35 changes: 0 additions & 35 deletions tests/c-api-allocation-test.cpp

This file was deleted.

Loading
Loading