Add simulator audio support
This commit is contained in:
@@ -45,6 +45,8 @@ cat > "$contents_path/Info.plist" <<'EOF'
|
|||||||
<string>0.8.0-dev</string>
|
<string>0.8.0-dev</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Tactility uses the microphone when a simulator app records audio.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ endif ()
|
|||||||
|
|
||||||
# Defined as regular project for PC and component for ESP
|
# Defined as regular project for PC and component for ESP
|
||||||
if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
||||||
|
if (APPLE)
|
||||||
|
enable_language(OBJC OBJCXX)
|
||||||
|
endif ()
|
||||||
add_subdirectory(Tactility)
|
add_subdirectory(Tactility)
|
||||||
add_subdirectory(TactilityFreeRtos)
|
add_subdirectory(TactilityFreeRtos)
|
||||||
add_subdirectory(TactilityKernel)
|
add_subdirectory(TactilityKernel)
|
||||||
@@ -137,6 +140,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
|||||||
add_subdirectory(Modules/lvgl-window-manager-module)
|
add_subdirectory(Modules/lvgl-window-manager-module)
|
||||||
add_subdirectory(Drivers/gps-generic-module)
|
add_subdirectory(Drivers/gps-generic-module)
|
||||||
add_subdirectory(Drivers/gps-meshtastic-module)
|
add_subdirectory(Drivers/gps-meshtastic-module)
|
||||||
|
add_subdirectory(Drivers/audio-stream-module)
|
||||||
|
|
||||||
# FreeRTOS
|
# FreeRTOS
|
||||||
set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "")
|
set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "")
|
||||||
|
|||||||
@@ -21,4 +21,13 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
|
|||||||
|
|
||||||
target_link_libraries(simulator PRIVATE ${SDL2_LIBRARIES})
|
target_link_libraries(simulator PRIVATE ${SDL2_LIBRARIES})
|
||||||
|
|
||||||
|
if (APPLE)
|
||||||
|
target_sources(simulator PRIVATE Source/drivers/sdl_audio_permission.mm)
|
||||||
|
target_link_libraries(simulator PRIVATE "-framework AVFoundation" "-framework Foundation")
|
||||||
|
# The developer executable can also request recording outside an .app bundle.
|
||||||
|
target_link_options(simulator INTERFACE
|
||||||
|
"LINKER:-sectcreate,__TEXT,__info_plist,${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
|
||||||
|
set_property(TARGET Tactility APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
|
||||||
|
endif ()
|
||||||
|
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
#include "sdl_audio.h"
|
||||||
|
#include "sdl_audio_buffer.h"
|
||||||
|
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/driver.h>
|
||||||
|
#include <tactility/freertos/task.h>
|
||||||
|
#include <tactility/log.h>
|
||||||
|
#include <tactility/module.h>
|
||||||
|
|
||||||
|
#include <SDL2/SDL.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr auto* TAG = "SdlAudio";
|
||||||
|
constexpr uint32_t SAMPLE_RATE = 48000;
|
||||||
|
|
||||||
|
struct AudioData {
|
||||||
|
SdlAudioBuffer buffer;
|
||||||
|
SemaphoreHandle_t mutex = nullptr; // task-side only; never used by the SDL callback
|
||||||
|
SDL_AudioDeviceID id = 0;
|
||||||
|
AudioCodecDirection direction;
|
||||||
|
std::atomic<float> volume { 100.0f };
|
||||||
|
std::atomic<bool> muted { false };
|
||||||
|
std::atomic<uint32_t> callbacks { 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioData* get_data(Device* device) {
|
||||||
|
return static_cast<AudioData*>(device_get_driver_data(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
class Lock {
|
||||||
|
SemaphoreHandle_t mutex;
|
||||||
|
public:
|
||||||
|
explicit Lock(AudioData* data) : mutex(data->mutex) { xSemaphoreTake(mutex, portMAX_DELAY); }
|
||||||
|
~Lock() { xSemaphoreGive(mutex); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* device_name(const AudioData* data) {
|
||||||
|
const char* name = std::getenv(data->direction == AUDIO_CODEC_DIR_INPUT ? "SIM_AUDIO_INPUT" : "SIM_AUDIO_OUTPUT");
|
||||||
|
return name != nullptr && name[0] != '\0' ? name : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool available(const AudioData* data) {
|
||||||
|
const char* name = device_name(data);
|
||||||
|
if (name != nullptr && std::strcmp(name, "none") == 0) return false;
|
||||||
|
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
|
||||||
|
const int count = SDL_GetNumAudioDevices(capture);
|
||||||
|
if (name == nullptr) return count > 0;
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
const char* candidate = SDL_GetAudioDeviceName(i, capture);
|
||||||
|
if (candidate != nullptr && std::strcmp(candidate, name) == 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply_volume(AudioData* data, void* bytes, size_t size) {
|
||||||
|
const float gain = data->muted.load() ? 0.0f : data->volume.load() / 100.0f;
|
||||||
|
auto* output = static_cast<uint8_t*>(bytes);
|
||||||
|
for (size_t i = 0; i < size; i += sizeof(int16_t)) {
|
||||||
|
int16_t sample;
|
||||||
|
std::memcpy(&sample, output + i, sizeof(sample));
|
||||||
|
sample = static_cast<int16_t>(sample * gain);
|
||||||
|
std::memcpy(output + i, &sample, sizeof(sample));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void audio_callback(void* context, Uint8* stream, int length) {
|
||||||
|
auto* data = static_cast<AudioData*>(context);
|
||||||
|
const size_t count = static_cast<size_t>(length) / sizeof(int16_t);
|
||||||
|
if (data->direction == AUDIO_CODEC_DIR_INPUT) {
|
||||||
|
// Drop incoming frames when the bounded capture buffer is full. Muted capture
|
||||||
|
// must not leave real microphone samples queued for a later unmute.
|
||||||
|
if (data->muted.load()) std::memset(stream, 0, length);
|
||||||
|
data->buffer.write(stream, count);
|
||||||
|
} else {
|
||||||
|
const size_t copied = data->buffer.read(stream, count) * sizeof(int16_t);
|
||||||
|
std::memset(stream + copied, 0, length - copied); // silence on underrun
|
||||||
|
apply_volume(data, stream, copied);
|
||||||
|
}
|
||||||
|
data->callbacks.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t start(Device* device) {
|
||||||
|
const auto* config = static_cast<const SdlAudioConfig*>(device->config);
|
||||||
|
if (config == nullptr || (config->direction != AUDIO_CODEC_DIR_INPUT && config->direction != AUDIO_CODEC_DIR_OUTPUT)) {
|
||||||
|
return ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
|
||||||
|
LOG_E(TAG, "Cannot initialize audio: %s", SDL_GetError());
|
||||||
|
return ERROR_RESOURCE;
|
||||||
|
}
|
||||||
|
auto* data = new (std::nothrow) AudioData;
|
||||||
|
if (data == nullptr) {
|
||||||
|
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||||
|
return ERROR_OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
data->direction = config->direction;
|
||||||
|
data->mutex = xSemaphoreCreateMutex();
|
||||||
|
if (data->mutex == nullptr) {
|
||||||
|
delete data;
|
||||||
|
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||||
|
return ERROR_OUT_OF_MEMORY;
|
||||||
|
}
|
||||||
|
device_set_driver_data(device, data);
|
||||||
|
const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
|
||||||
|
for (int i = 0; i < SDL_GetNumAudioDevices(capture); ++i) {
|
||||||
|
LOG_I(TAG, "%s device: %s", capture ? "Input" : "Output", SDL_GetAudioDeviceName(i, capture));
|
||||||
|
}
|
||||||
|
LOG_I(TAG, "%s: %s (%s)", device->name, device_name(data) != nullptr ? device_name(data) : "system default",
|
||||||
|
available(data) ? "available" : "unavailable");
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t open(Device* device, const AudioCodecStreamConfig* config) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (config == nullptr) return ERROR_INVALID_ARGUMENT;
|
||||||
|
if (config->direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
// The shared stream module performs rate/channel conversion on S16 PCM.
|
||||||
|
if (config->bits_per_sample != 16) return ERROR_NOT_SUPPORTED;
|
||||||
|
const uint8_t channels = data->direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
|
||||||
|
if (config->sample_rate != SAMPLE_RATE || config->channels != channels) return ERROR_INVALID_ARGUMENT;
|
||||||
|
Lock lock(data);
|
||||||
|
if (data->id != 0) return ERROR_INVALID_STATE;
|
||||||
|
if (!available(data)) {
|
||||||
|
LOG_W(TAG, "No selected %s device available", data->direction == AUDIO_CODEC_DIR_INPUT ? "input" : "output");
|
||||||
|
return ERROR_NOT_SUPPORTED;
|
||||||
|
}
|
||||||
|
#ifdef __APPLE__
|
||||||
|
// Only ask when recording is requested, and only with real macOS audio (dummy
|
||||||
|
// and disk backends are also useful for automated tests).
|
||||||
|
if (data->direction == AUDIO_CODEC_DIR_INPUT && std::strcmp(SDL_GetCurrentAudioDriver(), "coreaudio") == 0) {
|
||||||
|
error_t permission;
|
||||||
|
while ((permission = sdl_audio_microphone_permission()) == ERROR_RESOURCE_BUSY) vTaskDelay(1);
|
||||||
|
if (permission != ERROR_NONE) {
|
||||||
|
LOG_W(TAG, "Microphone permission denied; enable access in macOS Privacy & Security > Microphone");
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
SDL_AudioSpec wanted {};
|
||||||
|
wanted.freq = SAMPLE_RATE;
|
||||||
|
wanted.format = AUDIO_S16SYS;
|
||||||
|
wanted.channels = channels;
|
||||||
|
wanted.samples = 512;
|
||||||
|
wanted.callback = audio_callback;
|
||||||
|
wanted.userdata = data;
|
||||||
|
data->buffer.reset();
|
||||||
|
data->callbacks.store(0);
|
||||||
|
// No allowed changes: SDL converts between our fixed PCM format and the host
|
||||||
|
// device's format when necessary. No hardware-specific format leaks to apps.
|
||||||
|
data->id = SDL_OpenAudioDevice(device_name(data), data->direction == AUDIO_CODEC_DIR_INPUT, &wanted, nullptr, 0);
|
||||||
|
if (data->id == 0) {
|
||||||
|
LOG_E(TAG, "Cannot open %s: %s", device->name, SDL_GetError());
|
||||||
|
return ERROR_RESOURCE;
|
||||||
|
}
|
||||||
|
SDL_PauseAudioDevice(data->id, 0);
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t close(Device* device) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
Lock lock(data);
|
||||||
|
if (data->id != 0) {
|
||||||
|
// Preserve the end of short sounds. Drain is bounded even if the device has
|
||||||
|
// disappeared; microphone close never waits for the capture buffer to empty.
|
||||||
|
const TickType_t start = xTaskGetTickCount();
|
||||||
|
while (data->direction == AUDIO_CODEC_DIR_OUTPUT && !data->buffer.empty()
|
||||||
|
&& xTaskGetTickCount() - start < pdMS_TO_TICKS(250)
|
||||||
|
&& SDL_GetAudioDeviceStatus(data->id) == SDL_AUDIO_PLAYING) {
|
||||||
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
SDL_CloseAudioDevice(data->id);
|
||||||
|
data->id = 0;
|
||||||
|
data->buffer.reset();
|
||||||
|
}
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t stop(Device* device) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
close(device);
|
||||||
|
vSemaphoreDelete(data->mutex);
|
||||||
|
delete data;
|
||||||
|
device_set_driver_data(device, nullptr);
|
||||||
|
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t transfer(Device* device, void* destination, const void* source, size_t size, size_t* transferred, TickType_t timeout, bool capture) {
|
||||||
|
if (transferred != nullptr) *transferred = 0;
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if ((data->direction == AUDIO_CODEC_DIR_INPUT) != capture) return ERROR_NOT_SUPPORTED;
|
||||||
|
const size_t frame_size = sizeof(int16_t) * (capture ? 1 : 2);
|
||||||
|
if (size % frame_size != 0 || (size != 0 && (capture ? destination == nullptr : source == nullptr))) return ERROR_INVALID_ARGUMENT;
|
||||||
|
Lock lock(data);
|
||||||
|
if (data->id == 0) return ERROR_INVALID_STATE;
|
||||||
|
const TickType_t start = xTaskGetTickCount();
|
||||||
|
TickType_t last_callback = start;
|
||||||
|
uint32_t callbacks = data->callbacks.load();
|
||||||
|
size_t done = 0;
|
||||||
|
error_t result = ERROR_NONE;
|
||||||
|
while (done < size) {
|
||||||
|
if (SDL_GetAudioDeviceStatus(data->id) != SDL_AUDIO_PLAYING) {
|
||||||
|
result = ERROR_RESOURCE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const size_t count = (size - done) / sizeof(int16_t);
|
||||||
|
const size_t copied = capture
|
||||||
|
? data->buffer.read(static_cast<uint8_t*>(destination) + done, count)
|
||||||
|
: data->buffer.write(static_cast<const uint8_t*>(source) + done, count);
|
||||||
|
done += copied * sizeof(int16_t);
|
||||||
|
if (done == size) break;
|
||||||
|
const TickType_t now = xTaskGetTickCount();
|
||||||
|
if (timeout != portMAX_DELAY && now - start >= timeout) {
|
||||||
|
result = ERROR_TIMEOUT;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const uint32_t current_callbacks = data->callbacks.load();
|
||||||
|
if (current_callbacks != callbacks) {
|
||||||
|
callbacks = current_callbacks;
|
||||||
|
last_callback = now;
|
||||||
|
} else if (now - last_callback >= pdMS_TO_TICKS(2000)) {
|
||||||
|
// A stopped backend must not strand an infinite-timeout caller or a
|
||||||
|
// concurrent Settings disable waiting for that caller to finish.
|
||||||
|
result = ERROR_RESOURCE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
if (capture) apply_volume(data, destination, done);
|
||||||
|
if (transferred != nullptr) *transferred = done;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t read(Device* device, void* destination, size_t size, size_t* count, TickType_t timeout) {
|
||||||
|
return transfer(device, destination, nullptr, size, count, timeout, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t write(Device* device, const void* source, size_t size, size_t* count, TickType_t timeout) {
|
||||||
|
return transfer(device, nullptr, source, size, count, timeout, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t set_volume(Device* device, AudioCodecDirection direction, float volume) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
if (!std::isfinite(volume) || volume < 0.0f || volume > 100.0f) return ERROR_INVALID_ARGUMENT;
|
||||||
|
data->volume.store(volume);
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t get_volume(Device* device, AudioCodecDirection direction, float* volume) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
*volume = data->volume.load();
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t set_mute(Device* device, AudioCodecDirection direction, bool muted) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
data->muted.store(muted);
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t get_mute(Device* device, AudioCodecDirection direction, bool* muted) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (direction != data->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
*muted = data->muted.load();
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t get_rate(Device* device, AudioCodecDirection direction, uint32_t* rate) {
|
||||||
|
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
*rate = SAMPLE_RATE;
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t get_channels(Device* device, AudioCodecDirection direction, uint8_t* channels) {
|
||||||
|
if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
|
||||||
|
*channels = direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_t get_capabilities(Device* device, AudioCodecDirection* direction) {
|
||||||
|
auto* data = get_data(device);
|
||||||
|
if (!available(data)) return ERROR_NOT_SUPPORTED;
|
||||||
|
*direction = data->direction;
|
||||||
|
return ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioCodecApi api = {
|
||||||
|
.open = open,
|
||||||
|
.close = close,
|
||||||
|
.read = read,
|
||||||
|
.write = write,
|
||||||
|
.set_volume = set_volume,
|
||||||
|
.get_volume = get_volume,
|
||||||
|
.set_mute = set_mute,
|
||||||
|
.get_mute = get_mute,
|
||||||
|
.get_native_sample_rate = get_rate,
|
||||||
|
.get_native_channels = get_channels,
|
||||||
|
.get_capabilities = get_capabilities,
|
||||||
|
.get_input_gain_multiplier = nullptr,
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" Module simulator_module;
|
||||||
|
|
||||||
|
Driver sdl_audio_driver = {
|
||||||
|
.name = "sdl-audio",
|
||||||
|
.compatible = (const char*[]) { "tactility,sdl-audio", nullptr },
|
||||||
|
.start_device = start,
|
||||||
|
.stop_device = stop,
|
||||||
|
.api = &api,
|
||||||
|
.device_type = &AUDIO_CODEC_TYPE,
|
||||||
|
.owner = &simulator_module,
|
||||||
|
.internal = nullptr,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tactility/drivers/audio_codec.h>
|
||||||
|
|
||||||
|
struct SdlAudioConfig {
|
||||||
|
AudioCodecDirection direction;
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef __APPLE__
|
||||||
|
// Nonblocking: ERROR_RESOURCE_BUSY means that the user has not answered the prompt yet.
|
||||||
|
error_t sdl_audio_microphone_permission();
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
// Single producer / single consumer. The SDL callback is a native thread: it must never
|
||||||
|
// allocate, block, log, or call FreeRTOS. Unsigned counters also work across wraparound.
|
||||||
|
class SdlAudioBuffer {
|
||||||
|
static constexpr uint32_t CAPACITY = 16384; // samples; ~171 ms of 48 kHz stereo
|
||||||
|
std::array<int16_t, CAPACITY> samples {};
|
||||||
|
std::atomic<uint32_t> read_position { 0 };
|
||||||
|
std::atomic<uint32_t> write_position { 0 };
|
||||||
|
|
||||||
|
public:
|
||||||
|
size_t write(const void* source, size_t count) {
|
||||||
|
const uint32_t write = write_position.load(std::memory_order_relaxed);
|
||||||
|
const uint32_t read = read_position.load(std::memory_order_acquire);
|
||||||
|
count = std::min(count, static_cast<size_t>(CAPACITY - (write - read)));
|
||||||
|
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - write % CAPACITY));
|
||||||
|
const auto* bytes = static_cast<const uint8_t*>(source);
|
||||||
|
std::memcpy(samples.data() + write % CAPACITY, bytes, first * sizeof(int16_t));
|
||||||
|
std::memcpy(samples.data(), bytes + first * sizeof(int16_t), (count - first) * sizeof(int16_t));
|
||||||
|
write_position.store(write + count, std::memory_order_release);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t read(void* destination, size_t count) {
|
||||||
|
const uint32_t read = read_position.load(std::memory_order_relaxed);
|
||||||
|
const uint32_t write = write_position.load(std::memory_order_acquire);
|
||||||
|
count = std::min(count, static_cast<size_t>(write - read));
|
||||||
|
const size_t first = std::min(count, static_cast<size_t>(CAPACITY - read % CAPACITY));
|
||||||
|
auto* bytes = static_cast<uint8_t*>(destination);
|
||||||
|
std::memcpy(bytes, samples.data() + read % CAPACITY, first * sizeof(int16_t));
|
||||||
|
std::memcpy(bytes + first * sizeof(int16_t), samples.data(), (count - first) * sizeof(int16_t));
|
||||||
|
read_position.store(read + count, std::memory_order_release);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool empty() const {
|
||||||
|
return read_position.load(std::memory_order_acquire) == write_position.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only while the SDL device is closed and no caller is doing I/O.
|
||||||
|
void reset() {
|
||||||
|
read_position.store(0);
|
||||||
|
write_position.store(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
#include "sdl_audio.h"
|
||||||
|
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
error_t sdl_audio_microphone_permission() {
|
||||||
|
@autoreleasepool {
|
||||||
|
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]) {
|
||||||
|
case AVAuthorizationStatusAuthorized:
|
||||||
|
return ERROR_NONE;
|
||||||
|
case AVAuthorizationStatusDenied:
|
||||||
|
case AVAuthorizationStatusRestricted:
|
||||||
|
return ERROR_NOT_ALLOWED;
|
||||||
|
case AVAuthorizationStatusNotDetermined: {
|
||||||
|
static std::atomic<bool> requested { false };
|
||||||
|
if (!requested.exchange(true)) {
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
|
||||||
|
requested.store(false);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return ERROR_RESOURCE_BUSY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ERROR_NOT_ALLOWED;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "drivers/sdl_display.h"
|
#include "drivers/sdl_display.h"
|
||||||
|
#include "drivers/sdl_audio.h"
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/device_listener.h>
|
#include <tactility/device_listener.h>
|
||||||
@@ -16,11 +17,13 @@ extern "C" {
|
|||||||
extern Driver sdl_display_driver;
|
extern Driver sdl_display_driver;
|
||||||
extern Driver sdl_pointer_driver;
|
extern Driver sdl_pointer_driver;
|
||||||
extern Driver sdl_keyboard_driver;
|
extern Driver sdl_keyboard_driver;
|
||||||
|
extern Driver sdl_audio_driver;
|
||||||
|
|
||||||
static Driver* const simulator_drivers[] = {
|
static Driver* const simulator_drivers[] = {
|
||||||
&sdl_display_driver,
|
&sdl_display_driver,
|
||||||
&sdl_pointer_driver,
|
&sdl_pointer_driver,
|
||||||
&sdl_keyboard_driver,
|
&sdl_keyboard_driver,
|
||||||
|
&sdl_audio_driver,
|
||||||
nullptr
|
nullptr
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +57,10 @@ static SdlDisplayConfig sdl_display_config = { 480, 320 };
|
|||||||
static Device sdl_display_device {};
|
static Device sdl_display_device {};
|
||||||
static Device sdl_pointer_device {};
|
static Device sdl_pointer_device {};
|
||||||
static Device sdl_keyboard_device {};
|
static Device sdl_keyboard_device {};
|
||||||
|
static Device sdl_speaker_device {};
|
||||||
|
static Device sdl_microphone_device {};
|
||||||
|
static const SdlAudioConfig sdl_speaker_config { AUDIO_CODEC_DIR_OUTPUT };
|
||||||
|
static const SdlAudioConfig sdl_microphone_config { AUDIO_CODEC_DIR_INPUT };
|
||||||
|
|
||||||
static bool construct_add_start(Device* device, Device* parent, const char* name, const void* config, const char* compatible) {
|
static bool construct_add_start(Device* device, Device* parent, const char* name, const void* config, const char* compatible) {
|
||||||
device->address = 0;
|
device->address = 0;
|
||||||
@@ -109,6 +116,8 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) {
|
|||||||
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
|
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
|
||||||
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
|
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
|
||||||
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
|
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
|
||||||
|
construct_add_start(&sdl_speaker_device, device, "speaker0", &sdl_speaker_config, "tactility,sdl-audio");
|
||||||
|
construct_add_start(&sdl_microphone_device, device, "microphone0", &sdl_microphone_config, "tactility,sdl-audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- Platforms/platform-posix
|
- Platforms/platform-posix
|
||||||
|
- Drivers/audio-stream-module
|
||||||
dts: simulator.dts
|
dts: simulator.dts
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.tactilityproject.simulator</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Tactility</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Tactility uses the microphone when a simulator app records audio.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
add_executable(SimulatorAudioTests EXCLUDE_FROM_ALL
|
||||||
|
audio.cpp
|
||||||
|
../Source/drivers/sdl_audio.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(SimulatorAudioTests PRIVATE ${DOCTESTINC} ../Source/drivers)
|
||||||
|
target_link_libraries(SimulatorAudioTests PRIVATE TactilityKernel platform-posix audio-stream-module SDL2-static)
|
||||||
|
if (APPLE)
|
||||||
|
target_sources(SimulatorAudioTests PRIVATE ../Source/drivers/sdl_audio_permission.mm)
|
||||||
|
target_link_libraries(SimulatorAudioTests PRIVATE "-framework AVFoundation" "-framework Foundation")
|
||||||
|
endif ()
|
||||||
|
add_test(NAME SimulatorAudioTests COMMAND SimulatorAudioTests)
|
||||||
|
set_tests_properties(SimulatorAudioTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=dummy;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
|
||||||
|
add_test(NAME SimulatorAudioPcmTests COMMAND SimulatorAudioTests --no-skip "--test-case=disk PCM*")
|
||||||
|
set_tests_properties(SimulatorAudioPcmTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=disk;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Simulator audio
|
||||||
|
|
||||||
|
The desktop simulator exposes an SDL speaker and microphone through Tactility's
|
||||||
|
standard `audio_stream_*` API. On macOS, SDL uses CoreAudio. Audio Settings controls
|
||||||
|
the simulator's input/output volume, mute, and enabled state; these controls do not
|
||||||
|
change macOS's system volume.
|
||||||
|
|
||||||
|
## Running on macOS
|
||||||
|
|
||||||
|
Build the simulator in the usual host build environment (with `python`, `lark`,
|
||||||
|
and `pyyaml` available, and without `ESP_IDF_VERSION`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B buildsim
|
||||||
|
cmake --build buildsim --target Tactility -j 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a fresh application bundle (the script deliberately refuses to overwrite
|
||||||
|
an existing bundle):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh Buildscripts/release-simulator-macos-app.sh buildsim release/Tactility-audio.app
|
||||||
|
open release/Tactility-audio.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, run `../buildsim/Tactility/Tactility` with `Data/` as the working
|
||||||
|
directory. Both the executable and the application bundle include a microphone
|
||||||
|
usage description. macOS asks for microphone access on the first actual recording
|
||||||
|
request, not at simulator startup. If denied, enable access in **System Settings
|
||||||
|
→ Privacy & Security → Microphone** and relaunch. For command-line launches, macOS
|
||||||
|
may attribute the permission to the terminal or launching application.
|
||||||
|
|
||||||
|
A Mac mini needs an external input device, such as a USB mic or headset. Without
|
||||||
|
an input device, speaker output still works and input is reported unavailable.
|
||||||
|
Connect the input before launch for predictable discovery/UI behavior.
|
||||||
|
|
||||||
|
## Selecting devices
|
||||||
|
|
||||||
|
By default, each stream opens the system's default device. Startup logs list SDL's
|
||||||
|
device names. Optional environment variables select an exact name:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SIM_AUDIO_OUTPUT="Mac mini Speakers" SIM_AUDIO_INPUT="USB Microphone" \
|
||||||
|
release/Tactility-audio.app/Contents/MacOS/Tactility
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SIM_AUDIO_OUTPUT`: exact output name, or `none` to disable output.
|
||||||
|
- `SIM_AUDIO_INPUT`: exact input name, or `none` to disable input.
|
||||||
|
- Unset or empty values use the system default.
|
||||||
|
|
||||||
|
Selection is applied when opening a stream. An already-open stream does not
|
||||||
|
automatically switch when the system default changes; close/reopen it or relaunch.
|
||||||
|
A missing selected device causes an open failure rather than silently selecting a
|
||||||
|
different device. Permission/device errors are logged under `SdlAudio`.
|
||||||
|
|
||||||
|
## Formats and behavior
|
||||||
|
|
||||||
|
- Signed 16-bit PCM; the shared stream module converts app sample rates and channel
|
||||||
|
counts to 48 kHz mono capture / stereo playback. SDL handles host format conversion.
|
||||||
|
- One input and one output stream can be open simultaneously.
|
||||||
|
- Read/write from a worker task, never the LVGL thread.
|
||||||
|
- Audio callbacks use bounded lock-free buffers, with silence on playback underrun
|
||||||
|
and dropped incoming frames on capture overflow.
|
||||||
|
- Reads/writes report partial byte counts on timeout, including converted streams.
|
||||||
|
- Closing output drains its bounded buffer for up to 250 ms. Closing input does not
|
||||||
|
affect output, and vice versa.
|
||||||
|
- No audio mixing or acoustic echo cancellation is added by this backend. Use
|
||||||
|
headphones when testing simultaneous microphone capture and playback.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build buildsim --target SimulatorAudioTests -j 8
|
||||||
|
ctest --test-dir buildsim/Tests -R SimulatorAudio --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests use SDL's dummy and disk backends without requiring microphone access.
|
||||||
|
They cover full duplex, 16/44.1/48 kHz app formats, bounded buffering, partial
|
||||||
|
timeouts, enable/disable, unavailable inputs, and sample-level input/output gain
|
||||||
|
and mute. They also cover disabling audio while a slow codec open is pending,
|
||||||
|
as can happen during a microphone permission prompt. Disk fixtures and output
|
||||||
|
files are temporary and removed after the run.
|
||||||
|
|
||||||
|
To explicitly play a quiet half-second 440 Hz tone through real Mac audio:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SDL_AUDIODRIVER=coreaudio SIM_AUDIO_OUTPUT= \
|
||||||
|
buildsim/Tests/simulator/SimulatorAudioTests \
|
||||||
|
--no-skip --test-case="hardware output smoke test"
|
||||||
|
```
|
||||||
|
|
||||||
|
Physical microphone capture, the first-use permission prompt, and live device
|
||||||
|
unplugging require a separate manual check with an input device attached.
|
||||||
@@ -113,6 +113,10 @@ struct AudioStreamHandleImpl : AudioStreamHandleData {
|
|||||||
SemaphoreHandle_t drain_semaphore = nullptr;
|
SemaphoreHandle_t drain_semaphore = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A slow codec open (notably the macOS microphone permission prompt) reserves a
|
||||||
|
// direction before a real handle exists. It must never be passed to close_stream().
|
||||||
|
AudioStreamHandleImpl* const OPENING_STREAM = reinterpret_cast<AudioStreamHandleImpl*>(1);
|
||||||
|
|
||||||
struct AudioStreamData {
|
struct AudioStreamData {
|
||||||
Device* input_codec = nullptr;
|
Device* input_codec = nullptr;
|
||||||
Device* output_codec = nullptr;
|
Device* output_codec = nullptr;
|
||||||
@@ -130,6 +134,8 @@ struct AudioStreamData {
|
|||||||
bool output_muted = false;
|
bool output_muted = false;
|
||||||
AudioStreamHandleImpl* open_input = nullptr;
|
AudioStreamHandleImpl* open_input = nullptr;
|
||||||
AudioStreamHandleImpl* open_output = nullptr;
|
AudioStreamHandleImpl* open_output = nullptr;
|
||||||
|
bool input_open_cancelled = false;
|
||||||
|
bool output_open_cancelled = false;
|
||||||
// Guards open_input/open_output and the closing/busy_count fields of any handle reachable
|
// Guards open_input/open_output and the closing/busy_count fields of any handle reachable
|
||||||
// through them, so close (possibly forced by set_enabled) can't race with read/write.
|
// through them, so close (possibly forced by set_enabled) can't race with read/write.
|
||||||
SemaphoreHandle_t mutex = nullptr;
|
SemaphoreHandle_t mutex = nullptr;
|
||||||
@@ -261,7 +267,7 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
|||||||
return ERROR_INVALID_ARGUMENT;
|
return ERROR_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config->channels == 0) {
|
if (config->channels == 0 || config->sample_rate == 0) {
|
||||||
return ERROR_INVALID_ARGUMENT;
|
return ERROR_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +297,9 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
|||||||
|
|
||||||
// Reserve the slot with a placeholder so concurrent opens can't race past the check
|
// Reserve the slot with a placeholder so concurrent opens can't race past the check
|
||||||
// above while we do the (potentially slow) codec open below outside the lock.
|
// above while we do the (potentially slow) codec open below outside the lock.
|
||||||
auto* reservation = reinterpret_cast<AudioStreamHandleImpl*>(1);
|
auto* reservation = OPENING_STREAM;
|
||||||
|
bool* open_cancelled = is_input ? &data->input_open_cancelled : &data->output_open_cancelled;
|
||||||
|
*open_cancelled = false;
|
||||||
*slot = reservation;
|
*slot = reservation;
|
||||||
xSemaphoreGive(data->mutex);
|
xSemaphoreGive(data->mutex);
|
||||||
|
|
||||||
@@ -361,6 +369,18 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
|
|||||||
}
|
}
|
||||||
|
|
||||||
xSemaphoreTake(data->mutex, portMAX_DELAY);
|
xSemaphoreTake(data->mutex, portMAX_DELAY);
|
||||||
|
if (*open_cancelled) {
|
||||||
|
// Keep the reservation until the codec is closed, so re-enabling cannot
|
||||||
|
// open a second stream while this cancelled open is still cleaning up.
|
||||||
|
xSemaphoreGive(data->mutex);
|
||||||
|
vSemaphoreDelete(handle->drain_semaphore);
|
||||||
|
delete handle;
|
||||||
|
audio_codec_close(codec);
|
||||||
|
xSemaphoreTake(data->mutex, portMAX_DELAY);
|
||||||
|
*slot = nullptr;
|
||||||
|
xSemaphoreGive(data->mutex);
|
||||||
|
return ERROR_NOT_ALLOWED;
|
||||||
|
}
|
||||||
*slot = handle;
|
*slot = handle;
|
||||||
xSemaphoreGive(data->mutex);
|
xSemaphoreGive(data->mutex);
|
||||||
|
|
||||||
@@ -377,6 +397,7 @@ error_t open_output(Device* device, const struct AudioStreamConfig* config, Audi
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_size, size_t* bytes_read, TickType_t timeout) {
|
error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_size, size_t* bytes_read, TickType_t timeout) {
|
||||||
|
if (bytes_read != nullptr) *bytes_read = 0;
|
||||||
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
||||||
if (handle->direction != AUDIO_CODEC_DIR_INPUT || handle->bytes_per_frame == 0) {
|
if (handle->direction != AUDIO_CODEC_DIR_INPUT || handle->bytes_per_frame == 0) {
|
||||||
return ERROR_INVALID_STATE;
|
return ERROR_INVALID_STATE;
|
||||||
@@ -419,7 +440,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
|||||||
|
|
||||||
size_t codec_bytes_read = 0;
|
size_t codec_bytes_read = 0;
|
||||||
result = audio_codec_read(data->input_codec, handle->codec_buffer.data(), codec_bytes_needed, &codec_bytes_read, timeout);
|
result = audio_codec_read(data->input_codec, handle->codec_buffer.data(), codec_bytes_needed, &codec_bytes_read, timeout);
|
||||||
if (result == ERROR_NONE) {
|
if (codec_bytes_read > 0) {
|
||||||
size_t codec_frames_read = codec_bytes_read / handle->codec_bytes_per_frame;
|
size_t codec_frames_read = codec_bytes_read / handle->codec_bytes_per_frame;
|
||||||
const int16_t* rate_input = reinterpret_cast<const int16_t*>(handle->codec_buffer.data());
|
const int16_t* rate_input = reinterpret_cast<const int16_t*>(handle->codec_buffer.data());
|
||||||
uint8_t rate_input_channels = handle->codec_channels;
|
uint8_t rate_input_channels = handle->codec_channels;
|
||||||
@@ -448,7 +469,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result == ERROR_NONE && handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
|
if (handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
|
||||||
auto* samples = reinterpret_cast<int16_t*>(out_data);
|
auto* samples = reinterpret_cast<int16_t*>(out_data);
|
||||||
size_t sample_count = *bytes_read / sizeof(int16_t);
|
size_t sample_count = *bytes_read / sizeof(int16_t);
|
||||||
for (size_t i = 0; i < sample_count; i++) {
|
for (size_t i = 0; i < sample_count; i++) {
|
||||||
@@ -462,6 +483,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t data_size, size_t* bytes_written, TickType_t timeout) {
|
error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t data_size, size_t* bytes_written, TickType_t timeout) {
|
||||||
|
if (bytes_written != nullptr) *bytes_written = 0;
|
||||||
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
auto* handle = static_cast<AudioStreamHandleImpl*>(handle_base);
|
||||||
if (handle->direction != AUDIO_CODEC_DIR_OUTPUT || handle->bytes_per_frame == 0) {
|
if (handle->direction != AUDIO_CODEC_DIR_OUTPUT || handle->bytes_per_frame == 0) {
|
||||||
return ERROR_INVALID_STATE;
|
return ERROR_INVALID_STATE;
|
||||||
@@ -532,9 +554,13 @@ error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t
|
|||||||
size_t codec_bytes_to_write = codec_frames * handle->codec_bytes_per_frame;
|
size_t codec_bytes_to_write = codec_frames * handle->codec_bytes_per_frame;
|
||||||
size_t codec_bytes_written = 0;
|
size_t codec_bytes_written = 0;
|
||||||
result = audio_codec_write(data->output_codec, handle->codec_buffer.data(), codec_bytes_to_write, &codec_bytes_written, timeout);
|
result = audio_codec_write(data->output_codec, handle->codec_buffer.data(), codec_bytes_to_write, &codec_bytes_written, timeout);
|
||||||
if (result == ERROR_NONE && bytes_written != nullptr) {
|
if (bytes_written != nullptr && codec_frames > 0) {
|
||||||
// The caller provided `data_size` worth of input; we consumed all of it (resampled/converted).
|
// A bounded host/hardware queue can accept only part of a converted write,
|
||||||
*bytes_written = data_size;
|
// including when returning ERROR_TIMEOUT. Report progress in app-side whole
|
||||||
|
// frames rather than leaving the count untouched or claiming the entire write.
|
||||||
|
size_t written_frames = codec_bytes_written / handle->codec_bytes_per_frame;
|
||||||
|
if (written_frames > codec_frames) written_frames = codec_frames;
|
||||||
|
*bytes_written = (in_frames * written_frames / codec_frames) * handle->bytes_per_frame;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,14 +712,18 @@ error_t set_enabled(Device* device, AudioCodecDirection direction, bool enabled)
|
|||||||
data->output_enabled = enabled;
|
data->output_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture and clear the slot under the lock so we hand close_stream() a pointer that
|
// A pending open has no handle to close yet. Let its owning task clean up when
|
||||||
// can't simultaneously be torn down by a racing close from the owning app (close_stream
|
// the codec returns, even if the user re-enables the direction in the meantime.
|
||||||
// re-checks `*slot == handle` and no-ops if it's already been cleared/replaced).
|
|
||||||
AudioStreamHandleImpl* to_close = nullptr;
|
AudioStreamHandleImpl* to_close = nullptr;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
AudioStreamHandleImpl** slot = is_input ? &data->open_input : &data->open_output;
|
AudioStreamHandleImpl** slot = is_input ? &data->open_input : &data->open_output;
|
||||||
|
if (*slot == OPENING_STREAM) {
|
||||||
|
if (is_input) data->input_open_cancelled = true;
|
||||||
|
else data->output_open_cancelled = true;
|
||||||
|
} else {
|
||||||
to_close = *slot;
|
to_close = *slot;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
xSemaphoreGive(data->mutex);
|
xSemaphoreGive(data->mutex);
|
||||||
|
|
||||||
if (to_close != nullptr) {
|
if (to_close != nullptr) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_
|
|||||||
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
|
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
|
||||||
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
|
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
|
||||||
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
|
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
|
||||||
|
add_subdirectory(${CMAKE_SOURCE_DIR}/Devices/simulator/tests ${CMAKE_CURRENT_BINARY_DIR}/simulator)
|
||||||
|
|
||||||
add_custom_target(build-tests)
|
add_custom_target(build-tests)
|
||||||
add_dependencies(build-tests ServiceModuleTests)
|
add_dependencies(build-tests ServiceModuleTests)
|
||||||
@@ -21,3 +22,4 @@ add_dependencies(build-tests CryptModuleTests)
|
|||||||
add_dependencies(build-tests AppModuleTests)
|
add_dependencies(build-tests AppModuleTests)
|
||||||
add_dependencies(build-tests AppPosixModuleTests)
|
add_dependencies(build-tests AppPosixModuleTests)
|
||||||
add_dependencies(build-tests HttpModuleTests)
|
add_dependencies(build-tests HttpModuleTests)
|
||||||
|
add_dependencies(build-tests SimulatorAudioTests)
|
||||||
|
|||||||
Reference in New Issue
Block a user