Add simulator audio support
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#define DOCTEST_CONFIG_IMPLEMENT
|
||||
#include "doctest.h"
|
||||
#include "sdl_audio.h"
|
||||
#include "sdl_audio_buffer.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/freertos/task.h>
|
||||
#include <tactility/kernel_init.h>
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
extern Driver sdl_audio_driver;
|
||||
extern "C" {
|
||||
extern Module platform_posix_module;
|
||||
extern Module audio_stream_module;
|
||||
extern Device audio_stream_device;
|
||||
static Driver* const drivers[] = { &sdl_audio_driver, nullptr };
|
||||
Module simulator_module = { .name = "simulator-audio-test", .drivers = drivers };
|
||||
}
|
||||
|
||||
static const SdlAudioConfig output_config { AUDIO_CODEC_DIR_OUTPUT };
|
||||
static const SdlAudioConfig input_config { AUDIO_CODEC_DIR_INPUT };
|
||||
static Device speaker { .name = "speaker-test", .config = &output_config };
|
||||
static Device microphone { .name = "microphone-test", .config = &input_config };
|
||||
|
||||
struct Stream {
|
||||
AudioStreamHandle handle = nullptr;
|
||||
~Stream() { if (handle != nullptr) audio_stream_close(handle); }
|
||||
};
|
||||
|
||||
TEST_CASE("bounded PCM buffer preserves data across wrap and overflow") {
|
||||
SdlAudioBuffer buffer;
|
||||
std::vector<int16_t> input(20000);
|
||||
for (size_t i = 0; i < input.size(); ++i) input[i] = static_cast<int16_t>(i);
|
||||
std::vector<int16_t> output(20000, -1);
|
||||
CHECK(buffer.read(output.data(), output.size()) == 0);
|
||||
REQUIRE(buffer.write(input.data(), input.size()) == 16384);
|
||||
CHECK(buffer.write(input.data(), 2) == 0);
|
||||
REQUIRE(buffer.read(output.data(), 10000) == 10000);
|
||||
CHECK(std::equal(output.begin(), output.begin() + 10000, input.begin()));
|
||||
REQUIRE(buffer.write(input.data(), 10000) == 10000);
|
||||
REQUIRE(buffer.read(output.data(), output.size()) == 16384);
|
||||
CHECK(std::equal(output.begin(), output.begin() + 6384, input.begin() + 10000));
|
||||
CHECK(std::equal(output.begin() + 6384, output.begin() + 16384, input.begin()));
|
||||
CHECK(buffer.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("simulator streams support independent full duplex and common PCM rates") {
|
||||
for (uint32_t rate : { 16000u, 44100u, 48000u }) {
|
||||
CAPTURE(rate);
|
||||
const AudioStreamConfig config { rate, 16, 1 };
|
||||
Stream input;
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
AudioStreamHandle duplicate = nullptr;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &duplicate) == ERROR_INVALID_STATE);
|
||||
std::vector<int16_t> samples(rate / 20, 1234);
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
// Closing the speaker must not stop the microphone.
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("simulator audio controls persist across opens and disabling closes output") {
|
||||
auto* device = &audio_stream_device;
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25.0f) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
float volume = 0;
|
||||
bool muted = false;
|
||||
REQUIRE(audio_codec_get_volume(&speaker, AUDIO_CODEC_DIR_OUTPUT, &volume) == ERROR_NONE);
|
||||
REQUIRE(audio_codec_get_mute(&speaker, AUDIO_CODEC_DIR_OUTPUT, &muted) == ERROR_NONE);
|
||||
CHECK(volume == 25.0f);
|
||||
CHECK(muted);
|
||||
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
|
||||
output.handle = nullptr; // set_enabled closes and owns destruction of the handle
|
||||
CHECK(audio_stream_open_output(device, &config, &output.handle) == ERROR_NOT_ALLOWED);
|
||||
REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("bounded playback reports partial progress on a nonblocking timeout") {
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(48000 * 2, 0);
|
||||
size_t count = 999;
|
||||
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 4 == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("converted streams report partial progress on timeout") {
|
||||
const AudioStreamConfig config { 16000, 16, 1 };
|
||||
Stream output;
|
||||
Stream input;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(16000, 0);
|
||||
size_t count = 999;
|
||||
CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 2 == 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(30)); // allow dummy capture to produce some samples
|
||||
count = 999;
|
||||
CHECK(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
|
||||
CHECK(count > 0);
|
||||
CHECK(count < samples.size() * 2);
|
||||
CHECK(count % 2 == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("missing selected microphone does not prevent speaker playback") {
|
||||
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "tactility-nonexistent-microphone", 1) == 0);
|
||||
AudioCodecDirection capability;
|
||||
CHECK(audio_codec_get_capabilities(µphone, &capability) == ERROR_NOT_SUPPORTED);
|
||||
const AudioCodecStreamConfig config { 48000, 16, 1, AUDIO_CODEC_DIR_INPUT };
|
||||
CHECK(audio_codec_open(µphone, &config) == ERROR_NOT_SUPPORTED);
|
||||
CHECK(audio_codec_get_capabilities(&speaker, &capability) == ERROR_NONE);
|
||||
CHECK(capability == AUDIO_CODEC_DIR_OUTPUT);
|
||||
REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "", 1) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("unsupported sample widths fail without breaking a later open") {
|
||||
AudioStreamConfig config { 16000, 24, 1 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) != ERROR_NONE);
|
||||
CHECK(output.handle == nullptr);
|
||||
config.bits_per_sample = 16;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("zero sample rate is rejected before conversion") {
|
||||
const AudioStreamConfig config { 0, 16, 1 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
TEST_CASE("disabling while a codec is opening cancels the pending stream") {
|
||||
const auto* original_api = static_cast<const AudioCodecApi*>(sdl_audio_driver.api);
|
||||
static const AudioCodecApi* wrapped_api;
|
||||
wrapped_api = original_api;
|
||||
AudioCodecApi delayed_api = *original_api;
|
||||
delayed_api.open = [](Device* device, const AudioCodecStreamConfig* config) {
|
||||
vTaskDelay(pdMS_TO_TICKS(50)); // models waiting for microphone permission
|
||||
return wrapped_api->open(device, config);
|
||||
};
|
||||
sdl_audio_driver.api = &delayed_api;
|
||||
struct RestoreApi {
|
||||
const AudioCodecApi* api;
|
||||
~RestoreApi() { sdl_audio_driver.api = api; }
|
||||
} restore { original_api };
|
||||
REQUIRE(xTaskCreate([](void*) {
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, false);
|
||||
// Re-enabling does not resurrect the cancelled attempt.
|
||||
audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, true);
|
||||
vTaskDelete(nullptr);
|
||||
}, "disable-audio", 8192, nullptr, 1, nullptr) == pdPASS);
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NOT_ALLOWED);
|
||||
CHECK(output.handle == nullptr);
|
||||
sdl_audio_driver.api = original_api;
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("hardware output smoke test" * doctest::skip()) {
|
||||
// Explicit opt-in only: SDL_AUDIODRIVER=coreaudio ... --no-skip --test-case='hardware output smoke test'
|
||||
const AudioStreamConfig config { 48000, 16, 2 };
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_set_volume(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, 20) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(48000); // half a second, stereo, quiet 440 Hz tone
|
||||
for (size_t frame = 0; frame < samples.size() / 2; ++frame) {
|
||||
samples[frame * 2] = samples[frame * 2 + 1] = static_cast<int16_t>(3000 * std::sin(frame * 440.0 * 6.283185307 / 48000));
|
||||
}
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(2000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
}
|
||||
|
||||
TEST_CASE("disk PCM capture and playback apply gain and mute" * doctest::skip()) {
|
||||
REQUIRE(std::strcmp(SDL_GetCurrentAudioDriver(), "disk") == 0);
|
||||
auto* device = &audio_stream_device;
|
||||
const AudioStreamConfig config { 48000, 16, 1 };
|
||||
Stream input;
|
||||
Stream output;
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_INPUT, 50) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_input(device, &config, &input.handle) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
std::vector<int16_t> samples(960, -1);
|
||||
size_t count = 0;
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(count == samples.size() * 2);
|
||||
// Fixture contains 10000; microphone gain is 50%.
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 5000; }));
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
{
|
||||
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
|
||||
REQUIRE(file.good());
|
||||
size_t nonzero = 0;
|
||||
int16_t sample;
|
||||
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
|
||||
CHECK((sample == 0 || sample == 1250)); // 25% output volume
|
||||
if (sample != 0) nonzero++;
|
||||
}
|
||||
CHECK(nonzero == samples.size() * 2); // mono was duplicated to stereo
|
||||
}
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_INPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
|
||||
REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
|
||||
std::fill(samples.begin(), samples.end(), 5000);
|
||||
REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
|
||||
REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
|
||||
output.handle = nullptr;
|
||||
std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
|
||||
REQUIRE(file.good());
|
||||
size_t total = 0;
|
||||
int16_t sample;
|
||||
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
|
||||
CHECK(sample == 0);
|
||||
total++;
|
||||
}
|
||||
CHECK(total >= samples.size() * 2);
|
||||
}
|
||||
|
||||
struct TestContext { int argc; char** argv; int result = 1; };
|
||||
|
||||
static void run_tests(void* argument) {
|
||||
auto* data = static_cast<TestContext*>(argument);
|
||||
Module* modules[] = { &platform_posix_module, &simulator_module, &audio_stream_module, nullptr };
|
||||
DtsDevice devices[] = { DTS_DEVICE_TERMINATOR };
|
||||
if (kernel_init(modules, devices) == ERROR_NONE
|
||||
&& device_construct_add_start(&speaker, "tactility,sdl-audio") == ERROR_NONE
|
||||
&& device_construct_add_start(µphone, "tactility,sdl-audio") == ERROR_NONE) {
|
||||
doctest::Context context(data->argc, data->argv);
|
||||
context.setOption("no-breaks", true);
|
||||
data->result = context.run();
|
||||
device_stop(µphone);
|
||||
device_stop(&speaker);
|
||||
}
|
||||
vTaskEndScheduler();
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (std::getenv("SDL_AUDIODRIVER") == nullptr) SDL_setenv("SDL_AUDIODRIVER", "dummy", 1);
|
||||
char input_path[] = "sim-audio-input-XXXXXX";
|
||||
char output_path[] = "sim-audio-output-XXXXXX";
|
||||
const bool disk = std::strcmp(std::getenv("SDL_AUDIODRIVER"), "disk") == 0;
|
||||
if (disk) {
|
||||
const int input_fd = mkstemp(input_path);
|
||||
const int output_fd = mkstemp(output_path);
|
||||
if (input_fd < 0 || output_fd < 0) return 1;
|
||||
FILE* file = fdopen(input_fd, "wb");
|
||||
if (file == nullptr) return 1;
|
||||
const std::vector<int16_t> fixture(48000, 10000);
|
||||
const size_t written = std::fwrite(fixture.data(), sizeof(int16_t), fixture.size(), file);
|
||||
std::fclose(file);
|
||||
::close(output_fd);
|
||||
if (written != fixture.size()) return 1;
|
||||
SDL_setenv("SDL_DISKAUDIOFILEIN", input_path, 1);
|
||||
SDL_setenv("SDL_DISKAUDIOFILE", output_path, 1);
|
||||
}
|
||||
TestContext data { argc, argv };
|
||||
if (xTaskCreate(run_tests, "audio-test", 32768, &data, 1, nullptr) != pdPASS) return 1;
|
||||
vTaskStartScheduler();
|
||||
if (disk) {
|
||||
std::remove(input_path);
|
||||
std::remove(output_path);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
Reference in New Issue
Block a user