400 lines
14 KiB
C++
400 lines
14 KiB
C++
#include <test.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include <sonus.hpp>
|
|
|
|
|
|
namespace {
|
|
|
|
constexpr auto pi = 3.141592653589793238462643383279502884;
|
|
|
|
auto no_device_config() -> sonus::audio_config {
|
|
auto config = sonus::audio_config{};
|
|
config.no_device = true;
|
|
return config;
|
|
}
|
|
|
|
auto test_file(std::string_view name) -> std::filesystem::path {
|
|
return std::filesystem::temp_directory_path() / name;
|
|
}
|
|
|
|
auto peak(std::vector<std::float32_t> const &samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
|
auto result = std::float32_t{};
|
|
auto const begin = begin_frame * channels;
|
|
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
|
for (auto index = begin; index < end; ++index) {
|
|
result = std::max(result, std::abs(samples[index]));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
auto write_bytes(std::ofstream &output, std::string_view value) -> void {
|
|
output.write(value.data(), static_cast<std::streamsize>(value.size()));
|
|
}
|
|
|
|
auto write_u16_le(std::ofstream &output, std::uint16_t value) -> void {
|
|
auto bytes = std::array<char, 2>{
|
|
static_cast<char>(value & 0xFFU),
|
|
static_cast<char>((value >> 8U) & 0xFFU),
|
|
};
|
|
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
|
}
|
|
|
|
auto write_u32_le(std::ofstream &output, std::uint32_t value) -> void {
|
|
auto bytes = std::array<char, 4>{
|
|
static_cast<char>(value & 0xFFU),
|
|
static_cast<char>((value >> 8U) & 0xFFU),
|
|
static_cast<char>((value >> 16U) & 0xFFU),
|
|
static_cast<char>((value >> 24U) & 0xFFU),
|
|
};
|
|
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
|
}
|
|
|
|
auto write_test_wav(std::filesystem::path const &path, std::uint32_t sample_rate, std::uint16_t channels, std::uint32_t frame_count, double frequency_hz) -> void {
|
|
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
|
BOOST_REQUIRE(output.good());
|
|
auto const bits_per_sample = std::uint16_t{16};
|
|
auto const bytes_per_sample = bits_per_sample / 8U;
|
|
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
|
|
auto const byte_rate = sample_rate * block_align;
|
|
auto const data_size = frame_count * block_align;
|
|
write_bytes(output, "RIFF");
|
|
write_u32_le(output, 36U + data_size);
|
|
write_bytes(output, "WAVE");
|
|
write_bytes(output, "fmt ");
|
|
write_u32_le(output, 16U);
|
|
write_u16_le(output, 1U);
|
|
write_u16_le(output, channels);
|
|
write_u32_le(output, sample_rate);
|
|
write_u32_le(output, byte_rate);
|
|
write_u16_le(output, block_align);
|
|
write_u16_le(output, bits_per_sample);
|
|
write_bytes(output, "data");
|
|
write_u32_le(output, data_size);
|
|
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
|
|
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate);
|
|
auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
|
|
for (auto channel = std::uint16_t{}; channel < channels; ++channel) {
|
|
write_u16_le(output, static_cast<std::uint16_t>(sample));
|
|
}
|
|
}
|
|
BOOST_REQUIRE(output.good());
|
|
}
|
|
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
|
auto audio = sonus::audio_config{};
|
|
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
|
audio.sample_rate = 0;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.channels = 0;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.master_volume = 1.5F;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.period_size_frames = 128;
|
|
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
|
audio.period_size_milliseconds = 10;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.period_size_milliseconds = 1001;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.period_count = 17;
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.no_device = true;
|
|
audio.playback_device_name = "default";
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.no_device = true;
|
|
audio.playback_device_id = "0:1:default";
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
BOOST_TEST(!sonus::engine::create(audio));
|
|
audio = {};
|
|
audio.playback_device_id.assign("a\0b", 3);
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
audio = {};
|
|
audio.playback_device_name.assign("a\0b", 3);
|
|
BOOST_TEST(!sonus::validate(audio));
|
|
auto metronome = sonus::metronome_config{};
|
|
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
|
|
metronome.bpm = 0;
|
|
BOOST_TEST(!sonus::validate(metronome));
|
|
metronome = {};
|
|
metronome.beats_per_bar = 0;
|
|
BOOST_TEST(!sonus::validate(metronome));
|
|
metronome = {};
|
|
metronome.volume = -0.1F;
|
|
BOOST_TEST(!sonus::validate(metronome));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
|
|
auto enumerated = sonus::enumerate_playback_devices();
|
|
if (!enumerated) {
|
|
BOOST_TEST(!enumerated.error().message().empty());
|
|
return;
|
|
}
|
|
for (auto const &device : *enumerated) {
|
|
BOOST_TEST(!device.id.empty());
|
|
BOOST_TEST(device.id.find('\0') == std::string::npos);
|
|
BOOST_TEST(device.name.find('\0') == std::string::npos);
|
|
}
|
|
if (!enumerated->empty()) {
|
|
auto config = sonus::audio_config{};
|
|
config.playback_device_id = enumerated->front().id;
|
|
BOOST_TEST(static_cast<bool>(sonus::validate(config)));
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
|
auto created = sonus::engine::create(no_device_config());
|
|
BOOST_REQUIRE(created);
|
|
auto samples = std::vector<std::float32_t>(2048U * created->channels(), 1.0F);
|
|
auto rendered = created->render(samples.data(), 2048U);
|
|
BOOST_REQUIRE(rendered);
|
|
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
|
return sample == 0.0F;
|
|
}));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
|
|
auto created = sonus::engine::create(no_device_config());
|
|
BOOST_REQUIRE(created);
|
|
auto failed = std::atomic_bool{false};
|
|
auto volume_worker = [&created, &failed](std::uint32_t offset) {
|
|
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
|
auto const volume = static_cast<std::float32_t>(((index + offset) % 101U)) / 100.0F;
|
|
if (!created->set_master_volume(volume)) {
|
|
failed.store(true, std::memory_order_release);
|
|
}
|
|
}
|
|
};
|
|
auto render_worker = [&created, &failed] {
|
|
auto samples = std::vector<std::float32_t>(256U * created->channels());
|
|
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
|
if (!created->render(samples.data(), 256U) || !created->update_metronome({})) {
|
|
failed.store(true, std::memory_order_release);
|
|
}
|
|
}
|
|
};
|
|
auto first = std::thread{volume_worker, 0U};
|
|
auto second = std::thread{volume_worker, 37U};
|
|
auto renderer = std::thread{render_worker};
|
|
first.join();
|
|
second.join();
|
|
renderer.join();
|
|
BOOST_TEST(!failed.load(std::memory_order_acquire));
|
|
BOOST_TEST(!created->set_master_volume(-0.01F));
|
|
BOOST_TEST(!created->set_master_volume(1.01F));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 120;
|
|
metro.beats_per_bar = 4;
|
|
metro.volume = 0.70F;
|
|
metro.click_duration = std::chrono::milliseconds{25};
|
|
BOOST_REQUIRE(created->start_metronome(metro));
|
|
auto samples = std::vector<std::float32_t>(config.sample_rate * config.channels);
|
|
BOOST_REQUIRE(created->render(samples.data(), config.sample_rate));
|
|
auto downbeat_peak = peak(samples, 0, 2400, config.channels);
|
|
auto beat_peak = peak(samples, config.sample_rate / 2, 2400, config.channels);
|
|
auto between_peak = peak(samples, config.sample_rate / 4, 2400, config.channels);
|
|
BOOST_TEST(downbeat_peak > 0.2F);
|
|
BOOST_TEST(beat_peak > 0.1F);
|
|
BOOST_TEST(downbeat_peak > beat_peak * 1.2F);
|
|
BOOST_TEST(between_peak < 0.05F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
|
|
auto created = sonus::engine::create(no_device_config());
|
|
BOOST_REQUIRE(created);
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 140;
|
|
BOOST_REQUIRE(created->update_metronome(metro));
|
|
BOOST_TEST(!created->metronome_active());
|
|
BOOST_REQUIRE(created->start_metronome(metro));
|
|
BOOST_TEST(created->metronome_active());
|
|
metro.bpm = 180;
|
|
BOOST_REQUIRE(created->update_metronome(metro));
|
|
BOOST_TEST(created->metronome_active());
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
for (auto index = std::uint32_t{}; index < 500U; ++index) {
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 60U + (index % 180U);
|
|
metro.beats_per_bar = 1U + (index % 8U);
|
|
metro.click_duration = std::chrono::milliseconds{1 + static_cast<int>(index % 40U)};
|
|
BOOST_REQUIRE(created->update_metronome(metro));
|
|
BOOST_TEST(!created->metronome_active());
|
|
}
|
|
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
|
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
|
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
|
return sample == 0.0F;
|
|
}));
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 120;
|
|
BOOST_REQUIRE(created->start_metronome(metro));
|
|
BOOST_TEST(created->metronome_active());
|
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
|
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.1F);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
|
auto created = sonus::engine::create(no_device_config());
|
|
BOOST_REQUIRE(created);
|
|
auto worker = [&created](std::uint32_t offset) {
|
|
for (auto index = std::uint32_t{}; index < 100U; ++index) {
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 80U + ((index + offset) % 80U);
|
|
static_cast<void>(created->start_metronome(metro));
|
|
static_cast<void>(created->update_metronome(metro));
|
|
if (index % 7U == 0U) {
|
|
created->stop_metronome();
|
|
}
|
|
}
|
|
};
|
|
auto first = std::thread{worker, 0U};
|
|
auto second = std::thread{worker, 11U};
|
|
auto third = std::thread{worker, 23U};
|
|
first.join();
|
|
second.join();
|
|
third.join();
|
|
auto samples = std::vector<std::float32_t>(4096U * created->channels());
|
|
BOOST_TEST(static_cast<bool>(created->render(samples.data(), 4096U)));
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
|
|
auto created = sonus::engine::create(no_device_config());
|
|
BOOST_REQUIRE(created);
|
|
auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"};
|
|
auto played = created->play_file(missing);
|
|
BOOST_TEST(!played);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
auto path = test_file("sonus-tone-long.wav");
|
|
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
|
|
auto played = created->play_file(path);
|
|
BOOST_REQUIRE(played);
|
|
BOOST_TEST(created->active_sound_count() == 1U);
|
|
auto samples = std::vector<std::float32_t>(4096U * config.channels);
|
|
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
|
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
|
|
auto playing = created->is_playing(*played);
|
|
BOOST_REQUIRE(playing);
|
|
BOOST_TEST(*playing);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
auto path = test_file("sonus-tone-short.wav");
|
|
write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
|
|
auto played = created->play_file(path);
|
|
BOOST_REQUIRE(played);
|
|
auto samples = std::vector<std::float32_t>(2048U * config.channels);
|
|
BOOST_REQUIRE(created->render(samples.data(), 2048U));
|
|
auto playing = created->is_playing(*played);
|
|
BOOST_REQUIRE(playing);
|
|
BOOST_TEST(!*playing);
|
|
BOOST_TEST(created->active_sound_count() == 0U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
auto path = test_file("sonus-tone-repeated.wav");
|
|
write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
|
|
auto samples = std::vector<std::float32_t>(512U * config.channels);
|
|
auto collected = std::uint64_t{};
|
|
for (auto index = 0; index < 20; ++index) {
|
|
auto played = created->play_file(path);
|
|
BOOST_REQUIRE(played);
|
|
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
|
collected += created->collect_finished();
|
|
}
|
|
BOOST_TEST(collected == 20U);
|
|
BOOST_TEST(created->active_sound_count() == 0U);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
|
|
auto config = no_device_config();
|
|
config.sample_rate = 48000;
|
|
config.channels = 2;
|
|
auto created = sonus::engine::create(config);
|
|
BOOST_REQUIRE(created);
|
|
auto path = test_file("sonus-tone-concurrent.wav");
|
|
write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
|
|
auto render_worker = [&created, channels = config.channels] {
|
|
auto samples = std::vector<std::float32_t>(512U * channels);
|
|
for (auto index = 0; index < 150; ++index) {
|
|
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
|
}
|
|
};
|
|
auto control_worker = [&created, &path] {
|
|
for (auto index = 0; index < 80; ++index) {
|
|
auto metro = sonus::metronome_config{};
|
|
metro.bpm = 90U + static_cast<std::uint32_t>(index % 80);
|
|
if (index % 5 == 0) {
|
|
created->stop_metronome();
|
|
} else if (created->metronome_active()) {
|
|
static_cast<void>(created->update_metronome(metro));
|
|
} else {
|
|
static_cast<void>(created->start_metronome(metro));
|
|
}
|
|
auto played = created->play_file(path);
|
|
if (played && index % 3 == 0) {
|
|
static_cast<void>(created->stop(*played));
|
|
}
|
|
static_cast<void>(created->collect_finished());
|
|
}
|
|
};
|
|
auto renderer = std::thread{render_worker};
|
|
auto controller = std::thread{control_worker};
|
|
renderer.join();
|
|
controller.join();
|
|
created->stop_all();
|
|
created->stop_metronome();
|
|
BOOST_TEST(created->active_sound_count() == 0U);
|
|
}
|