Add simulator audio support

This commit is contained in:
Adolfo Reyna
2026-09-23 19:13:39 -04:00
parent 982bba70b2
commit dcfd4e9bcc
15 changed files with 905 additions and 11 deletions
+301
View File
@@ -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(&microphone, &capability) == ERROR_NOT_SUPPORTED);
const AudioCodecStreamConfig config { 48000, 16, 1, AUDIO_CODEC_DIR_INPUT };
CHECK(audio_codec_open(&microphone, &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(&microphone, "tactility,sdl-audio") == ERROR_NONE) {
doctest::Context context(data->argc, data->argv);
context.setOption("no-breaks", true);
data->result = context.run();
device_stop(&microphone);
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;
}